/v1/auth/me Extended — displayName, tenant{name,slug}, availableModules, permissions, lastLoginAt
Feinkonzept · §3.9b · /v1/auth/me Extended — Topbar/Sidebar-Payload
Abschnitt betitelt „Feinkonzept · §3.9b · /v1/auth/me Extended — Topbar/Sidebar-Payload“Einordnung. Delta-Feinkonzept zu
kern/09-auth-self-service. Kein neues Feature, sondern eine additive API-Erweiterung des bestehendenGET /v1/auth/me. Konsumenten sind alle Web-Admin-Feinkonzepte abweb/01-admin-portal-shellaufwärts (web/02bisweb/09), die ohne diese Felder N+1-Calls fahren oder Platzhalter rendern müssen (sieheweb/01-admin-portal-shell.md:754„Note zumdisplayName“). Senior-Berater-Empfehlung: einmal richtig erweitern statt sieben Workarounds in sieben Frontends.
1. Header & Metadaten
Abschnitt betitelt „1. Header & Metadaten“feature_id: kern/09b-auth-me-extendedtitle: /v1/auth/me Extended — displayName, tenant{name,slug}, availableModules, permissions, lastLoginAtfunktionsumfang_ref: §3.9 (Delta zu kern/09)roadmap_horizont: V1plattformen: mobile: vollständig # Mobile App nutzt /me ebenfalls für Greeting web: vollständig # Adminportal Topbar + Sidebar Modul-Filter desktop: ausowner_rolle: alle # /me ist rollen-agnostisch — jeder authentifizierte Usermodul_gate_flag: module.kern.auth # Kern-Auth, kein gated Modulcompliance_flags: gobd: false arbzg: false vob: false dsgvo: true # displayName ist personenbezogen (Klarname) bfsg: false # Pure API-Erweiterung, keine UI in diesem Konzept betrvg: false stvg: false weitere: - "OpenAPI 3.1 Schema-Drift-Test (Vitest gegen packages/openapi-spec)"referenzkunde: status: TBD name: "Eigenbedarf — Adminportal-Shell (web/01) blockiert ohne diese Felder bei der Topbar-Begrüßung" quelle: "web/01-admin-portal-shell.md:754 (Note zum displayName)"estimate_eng_tage: 1.5 # S-Klasse: Schema + 4 Joins + Testsabhängigkeiten: - kern/09-auth-self-service # Bestehender /me-Handler wird erweitert, nicht ersetzt - kern/11-compliance-audit # lastLoginAt liest aus login_events - infra/rls-foundation # tenant_modules-Lookup respektiert RLS2. Problemstellung
Abschnitt betitelt „2. Problemstellung“Heute liefert /v1/auth/me (siehe apps/api/src/routes/auth/index.ts:47-54):
{ "user": { "id": "u1", "role": "admin" }, "tenantId": "t1", "sessionId": "s1" }Was das Adminportal stattdessen rendern will (Topbar + Sidebar):
┌─────────────────────────────────────────────────────────────────┐│ Werkszeit · ACME Bau GmbH Hallo Anna Schmidt · Admin ▾ │└─────────────────────────────────────────────────────────────────┘Konkrete Pain-Points:
- Topbar-Begrüßung „Hallo Anna · ACME Bau GmbH“ nicht renderbar.
displayNameundtenantNamefehlen.web/01umgeht das aktuell mit dem Workaround „lesedisplay_nameaus/v1/admin/me/preferencesper JOIN“ (web/01:754) — eine API-Schicht, die für Locale/Theme gedacht war, übernimmt jetzt PII-Auslieferung. Architektonisch falsch: Preferences gehören dem User, Identity-Felder gehören zu/auth/me. - Sidebar zeigt aktuell hartcodiert 6 Menüpunkte (
web/01:41). Sobaldmodule.handwerk.aufmassodermodule.kern.dienstplanper Tenant gegated werden, braucht die Shell eine ListeavailableModules, sonst rendert sie Menüpunkte zu 501-Endpunkten. - Permission-Checks sind heute reine Rollen-Checks (
role === 'admin'). Sobald wir feiner gehen (z.B. „Manager mitemployees:invite-Capability, aber ohnepayroll:export“), braucht das Frontend eine Capability-Liste, nicht nur einen Rollen-String. Ohne diese Liste rendert es entweder zu viel (Security-Drift) oder zu wenig (UX-Bug, Knöpfe fehlen). lastLoginAtwird inweb/06-admin-einstellungenundweb/07-admin-mfagebraucht („Letzter Login: vor 3 Stunden auf iPhone“). Heute extra Roundtrip an/v1/auth/login-events?limit=1.
Konsequenz ohne dieses Delta: jedes Web-Feinkonzept löst das Problem eigenwillig — web/01 über Preferences-JOIN, web/03 über separaten /v1/users/me/full-Call, web/06 über login-events?limit=1. Drei Frontends, drei Wahrheiten, vier zusätzliche Roundtrips beim Cold-Start.
Erwarteter Outcome. Cold-Start-Probe /auth/me liefert in einem Call alles, was Topbar + Sidebar + Initial-RBAC-Filter brauchen. Ein zusätzlicher Call entfällt komplett (/v1/admin/me/preferences bleibt bestehen, übernimmt aber wieder nur Locale/Theme — nicht Identity).
3. Stakeholder, Rollen, Personas
Abschnitt betitelt „3. Stakeholder, Rollen, Personas“| Rolle | Konsumiert Feld(er) | Wo |
|---|---|---|
| Adminportal-Web | displayName, tenant.name, availableModules, permissions |
Topbar, Sidebar, RoleGate |
| Mobile App | displayName, tenant.slug, permissions |
Greeting-Card, Bottom-Nav-Filter |
| Manager-Dashboard | permissions (employees:invite, vacation:approve) |
Action-Button-Sichtbarkeit |
| Buchhaltung-View | permissions (payroll:export, datev:export) |
DATEV-Panel Sichtbarkeit |
| Audit-Log | lastLoginAt (read-only) |
Profil-Detail-Panel |
Kein neuer Stakeholder — nur eine konsolidierte Datenlieferung an bestehende Konsumenten.
4. Ziele & Nicht-Ziele
Abschnitt betitelt „4. Ziele & Nicht-Ziele“Ziele (V1, MVP-blockierend für web/01-09)
Abschnitt betitelt „Ziele (V1, MVP-blockierend für web/01-09)“- Additive Erweiterung der bestehenden Response — keine Breaking-Changes für Mobile-Clients, die die alten Felder lesen (Forward-Compatibility).
- Keine Versionierung auf
/v2/auth/me— die Felder sind reine Ergänzungen, alle alten Pfade (response.user.id,response.tenantId) bleiben unverändert. - Ein Roundtrip liefert: Identity + Tenant + Module-Gating + Capability-Liste + Last-Login-Stempel.
- TypeScript-Typ-Sicherheit durch Update in
packages/openapi-spec→ generierter Client (Mobile + Web) sieht die neuen Felder typisiert. - Cache-Strategie explizit:
Cache-Control: private, max-age=0, must-revalidate—/medarf weder von CloudFront noch vom Browser-Disk-Cache wiederverwendet werden, weil Permission-Änderungen sofort wirksam sein müssen.
Nicht-Ziele (V2 oder später)
Abschnitt betitelt „Nicht-Ziele (V2 oder später)“| Nicht-Ziel | Begründung |
|---|---|
| WebSocket-Push bei Permission-Änderung | V2. MVP-Konsumenten holen /me beim App-Start und nach jedem Login — das reicht für 95 % der Fälle. Live-Demotion wird über Session-Revoke gelöst (Re-Auth zwingt Re-Fetch). |
GraphQL-Style Field-Selection (?fields=user,tenant) |
V2. Payload ist klein (< 4 KB JSON gzipped), keine Bandwidth-Optimierung nötig. |
permissions als Bitfield (uint64) |
Trade-off bewertet (siehe §6). Wir wählen Liste — siehe Begründung. |
tenant.logoUrl, tenant.brandColor |
Tenant-Branding ist eigenes Feinkonzept (web/06-admin-einstellungen); /me liefert Identity-Minimum. |
user.avatarUrl |
Kommt mit kern/12-user-avatars (V1.5), nicht hier. |
| Mobile-Push-Token-Registrierung im selben Call | Eigenes Endpoint /v1/devices/register. |
5. API-Schema-Delta (OpenAPI 3.1)
Abschnitt betitelt „5. API-Schema-Delta (OpenAPI 3.1)“Datei: packages/openapi-spec/paths/auth/me.yaml
5.1 Vorher (Stand kern/09)
Abschnitt betitelt „5.1 Vorher (Stand kern/09)“AuthMeResponse: type: object required: [user, tenantId, sessionId] properties: user: type: object required: [id, role] properties: id: { type: string, format: uuid } role: { type: string, enum: [admin, manager, bauleiter, buchhaltung, monteur, azubi] } tenantId: { type: string, format: uuid } sessionId: { type: string, format: uuid }5.2 Nachher (dieses Delta)
Abschnitt betitelt „5.2 Nachher (dieses Delta)“AuthMeResponse: type: object required: [user, tenant, sessionId, availableModules, permissions] properties: user: type: object required: [id, role, displayName] properties: id: { type: string, format: uuid } role: { type: string, enum: [admin, manager, bauleiter, buchhaltung, monteur, azubi] } displayName: { type: string, example: "Anna Schmidt", maxLength: 200 } lastLoginAt: type: string format: date-time nullable: true # null bei Erst-Login (vor diesem Call) example: "2026-04-30T07:14:32Z" tenant: type: object required: [id, name, slug] properties: id: { type: string, format: uuid } name: { type: string, example: "ACME Bau GmbH", maxLength: 200 } slug: { type: string, example: "acme-bau", pattern: "^[a-z0-9-]+$" } tenantId: # DEPRECATED — bleibt als Alias bestehen type: string format: uuid deprecated: true description: "Verwende `tenant.id`. Wird in V2 entfernt." sessionId: { type: string, format: uuid } availableModules: type: array description: "Liste der für diesen Tenant freigeschalteten Module (Modul-Gates)." items: type: string example: "module.kern.zeit" pattern: "^module\\.[a-z]+(\\.[a-z-]+)+$" permissions: type: array description: "Liste aller RBAC-Capabilities, die die aktuelle Rolle in diesem Tenant gewährt." items: type: string example: "employees:invite" pattern: "^[a-z][a-z0-9-]*:[a-z][a-z0-9_-]*$"5.3 Wichtige Schema-Entscheidungen
Abschnitt betitelt „5.3 Wichtige Schema-Entscheidungen“tenantIdbleibt mitdeprecated: trueerhalten. Mobile-App hat ältere Versionen im Field, dietenantIddirekt lesen — wir brechen sie nicht. Der TypeScript-Generator emittiert@deprecated-JSDoc, ESLint-Regel meldet Neu-Verwendungen.permissionsundavailableModulessindrequired, niemalsnull. Leere Capability/Modul-Liste =[], nichtnull. Frontend-Code spart sich die Null-Guard-Kaskade.lastLoginAtistnullable: true— Erst-Login (allererster/me-Call nach Account-Anlage) hat noch keinen vorherigen Login. Frontend rendert dann „Erstmals angemeldet“.displayNameistrequiredauf User-Ebene. Fallback siehe §7 (E-Mail-Local-Part).
6. Trade-off · permissions als Liste vs. Bitfield
Abschnitt betitelt „6. Trade-off · permissions als Liste vs. Bitfield“Bewusst dokumentiert, weil die Frage in der Code-Review garantiert kommt:
| Aspekt | Liste (string[]) |
Bitfield (uint64) |
|---|---|---|
| Payload-Größe | ~ 600 B bei 30 Capabilities (gzip ~ 200 B) | 8 B fest |
| Lesbarkeit (Debug, Logs) | Sofort lesbar (employees:invite) |
Hex-String, Mapping-Tabelle nötig |
| Frontend-Check | permissions.includes('x') — O(n), n ≤ 50 |
(perm & MASK) !== 0 — O(1) |
| Erweiterbarkeit | Neue Capability = neuer String, Backward-OK | 64-Bit-Limit, dann Migration auf 128-Bit oder Mehrfach-Felder |
| Schema-Drift-Risiko | Niedrig (OpenAPI-String-Items) | Hoch (Bit-Position muss in Client+Server synchron bleiben — klassischer Bug-Vektor) |
| Compliance-Audit-Lesbarkeit | Auditor sieht „payroll:export“ | Auditor sieht 0x0000000000200000 |
| Cache-Friendliness | identische Response = identischer Hash | identisch |
Wir wählen Liste, weil: (a) Payload-Differenz ist irrelevant (~ 400 B), (b) Schema-Drift-Risiko bei Bitfield ist real (Mobile-App und Web können Bit-Positionen unterschiedlich kennen, wenn Releases asynchron rollen), (c) Audit-Log-Lesbarkeit ist Compliance-Vorteil (DSGVO Art. 30 Verarbeitungsverzeichnis), (d) Capability-Anzahl wächst absehbar (~ 50 in V2), nicht ~ 5000 — kein O(n)-Problem.
Was 2027 dankbar macht: wenn wir in V2 ABAC (Attribute-Based Access Control) einführen, sind String-Capabilities trivial zu erweitern (employees:invite:own-team). Bitfield wäre da ein Fass ohne Boden.
7. Datenmodell-Quellen
Abschnitt betitelt „7. Datenmodell-Quellen“Keine neue Tabelle. Alle Felder werden aus existierenden Tabellen geJOINed:
| Feld | Quelle | Lookup-Kosten |
|---|---|---|
user.displayName |
users.full_name (siehe kern/09:417 Schema) |
Index users_pk |
user.lastLoginAt |
MAX(login_events.occurred_at) WHERE event_type='login.succeeded' AND user_id = $1 |
Index login_events (user_id, occurred_at DESC) — Pflicht-Migration falls nicht vorhanden |
tenant.id/name/slug |
tenants (PK-Lookup über users.tenant_id) |
Index tenants_pk |
availableModules |
tenant_modules WHERE tenant_id = $1 AND active = true |
Index tenant_modules (tenant_id) |
permissions |
role_permissions WHERE role = $1 (statische Tabelle, RAM-Cache 5 min) |
In-Memory |
Annahme über tenant_modules-Tabelle. Wir gehen davon aus, dass kern/02-modul-gates bereits eine tenant_modules (tenant_id, module_key, active, activated_at)-Tabelle eingeführt hat. Falls nein zum Zeitpunkt der Implementierung: Pre-Step im selben PR — Migration 0042_tenant_modules.sql mit RLS-Policy tenant_id = current_setting('app.tenant_id')::uuid. Stop-the-Bus, falls kern/02 nicht gelandet ist — availableModules: [] als Fallback ist fachlich falsch, weil der Frontend dann alle Module versteckt.
role_permissions-Tabelle. Statisches Mapping (siehe kern/03-rollen-rbac):
-- AuszugINSERT INTO role_permissions (role, capability) VALUES ('admin', 'employees:invite'), ('admin', 'employees:disable'), ('admin', 'payroll:export'), ('admin', 'datev:export'), ('manager', 'employees:invite'), ('manager', 'vacation:approve'), ('bauleiter', 'projects:edit:own'), ('monteur', 'time:create:own'), ('azubi', 'time:create:own');Performance-Profil. Ein einziger Roundtrip mit 4 zusätzlichen Selects (oder einem JOIN-Block):
SELECT u.id, u.full_name, u.role, t.id AS tenant_id, t.name AS tenant_name, t.slug AS tenant_slug, (SELECT MAX(occurred_at) FROM login_events WHERE user_id = u.id AND event_type = 'login.succeeded' AND occurred_at < now() - interval '5 seconds' ) AS last_login_at, ARRAY(SELECT module_key FROM tenant_modules WHERE tenant_id = u.tenant_id AND active = true) AS modules, ARRAY(SELECT capability FROM role_permissions WHERE role = u.role) AS permsFROM users uJOIN tenants t ON t.id = u.tenant_idWHERE u.id = current_setting('app.user_id')::uuid;p95 erwartet ≤ 15 ms (warmer Cache, lokales Postgres). RLS greift implizit über tenant_id-Filter.
Subtilität bei
lastLoginAt: Der Filteroccurred_at < now() - interval '5 seconds'schließt den aktuellen Login-Event aus. Sonst zeigt die Topbar „Letzter Login: vor 0 Sekunden“ — sinnlos. Wir wollen den vorherigen erfolgreichen Login.
8. Edge-Cases
Abschnitt betitelt „8. Edge-Cases“| # | Szenario | Erwartetes Verhalten |
|---|---|---|
| EC-01 | users.full_name ist NULL (Legacy-Account, nie gepflegt) |
Fallback im Handler: displayName = email.split('@')[0] (Local-Part). Nie leerer String, nie null. |
| EC-02 | Tenant zwischenzeitlich gelöscht (Hard-Delete via Admin-Panel), Session noch aktiv | /me liefert 401 AUTH-TENANT-DELETED, nicht 200 mit leerem tenant-Objekt. Session wird in derselben Response per Set-Cookie: ...; Max-Age=0 revoked. Begründung: konsistent mit „Account-Sperre“-Verhalten aus kern/09. |
| EC-03 | role_permissions-Tabelle leer für eine Rolle (Misskonfiguration) |
permissions: [], kein 500. Ops-Alert (Sentry-Tag rbac.empty_permissions) wird ausgelöst. |
| EC-04 | Tenant hat 0 aktive Module (frisch angelegt, Onboarding nicht durch) | availableModules: []. Frontend zeigt eine Onboarding-Card statt Sidebar-Items. |
| EC-05 | User noch nie eingeloggt (allererster /me-Call) |
lastLoginAt: null. Frontend rendert „Willkommen — erstmals angemeldet“. |
| EC-06 | Sehr großer displayName (User hat 500 Zeichen Doktor-Titel-Kette eingetragen) |
Server-side Truncate auf 200 Zeichen vor Response (DB-Constraint users.full_name VARCHAR(200) reinforced). |
| EC-07 | tenant.slug enthält Sonderzeichen (Legacy-Daten vor Slug-Constraint) |
Pattern-Validation in der Drizzle-Layer wirft, Handler liefert 500 mit Sentry-Alert — nicht 200 mit kaputtem Slug. |
| EC-08 | Race: User-Rolle wird während /me-Call demotiert |
Drizzle-Transaktion mit REPEATABLE READ — wir lesen die Rolle, die zu Session-Start galt. Nächster /me-Call sieht die neue Rolle (Sliding-Window). Konsistent mit kern/09:850 (Rollen-Downgrade-Verhalten). |
| EC-09 | Permission-Liste extrem groß (V2-Szenario, > 200 Capabilities) | Heute keine Action — ab 200 erwägen wir Bitfield-Companion-Field (Hybrid). MVP-Schwelle: ≤ 100. |
| EC-10 | Cross-Tenant-Bleed-Versuch (User-Cookie aus Tenant A, manipulierter tenant_id-Header) |
Header wird ignoriert — Wahrheit ist Session-Lookup. Cross-Tenant-Test-Invariante aus kern/09:617 greift unverändert. |
9. Tests
Abschnitt betitelt „9. Tests“9.1 Vitest — Unit (Handler-Logik in Isolation)
Abschnitt betitelt „9.1 Vitest — Unit (Handler-Logik in Isolation)“Datei: apps/api/tests/auth/me-extended.unit.test.ts
describe('GET /v1/auth/me — extended response', () => { it('liefert displayName, tenant.name, tenant.slug, availableModules, permissions, lastLoginAt', async () => { const res = await app.request('/v1/auth/me', {}, { cookie: validSessionCookie }); expect(res.status).toBe(200); const body = await res.json(); expect(body.user.displayName).toBe('Anna Schmidt'); expect(body.tenant).toEqual({ id: 't1', name: 'ACME Bau GmbH', slug: 'acme-bau' }); expect(body.availableModules).toContain('module.kern.zeit'); expect(body.permissions).toContain('employees:invite'); expect(body.lastLoginAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); });
it('Fallback displayName auf E-Mail-Local-Part bei NULL full_name', async () => { await db.update(users).set({ fullName: null }).where(eq(users.id, 'u1')); const body = await (await app.request('/v1/auth/me', {}, { cookie: validSessionCookie })).json(); });
it('lastLoginAt = null wenn nur die aktuelle Session existiert (Erst-Login)', async () => { await db.delete(loginEvents).where(eq(loginEvents.userId, 'u1')); await db.insert(loginEvents).values({ userId: 'u1', eventType: 'login.succeeded', occurredAt: now() }); const body = await (await app.request('/v1/auth/me', {}, { cookie: validSessionCookie })).json(); expect(body.lastLoginAt).toBeNull(); });
it('availableModules ist [] (nicht null) bei frischem Tenant', async () => { await db.delete(tenantModules).where(eq(tenantModules.tenantId, 't1')); const body = await (await app.request('/v1/auth/me', {}, { cookie: validSessionCookie })).json(); expect(body.availableModules).toEqual([]); expect(body.availableModules).not.toBeNull(); });
it('permissions ist [] (nicht null) bei unbekannter Rolle', async () => { await db.delete(rolePermissions); const body = await (await app.request('/v1/auth/me', {}, { cookie: validSessionCookie })).json(); expect(body.permissions).toEqual([]); });
it('AUTH-TENANT-DELETED 401 wenn Tenant gelöscht ist', async () => { await db.delete(tenants).where(eq(tenants.id, 't1')); const res = await app.request('/v1/auth/me', {}, { cookie: validSessionCookie }); expect(res.status).toBe(401); expect((await res.json()).code).toBe('AUTH-TENANT-DELETED'); expect(res.headers.get('set-cookie')).toContain('Max-Age=0'); });
it('Cache-Header korrekt gesetzt', async () => { const res = await app.request('/v1/auth/me', {}, { cookie: validSessionCookie }); expect(res.headers.get('cache-control')).toBe('private, max-age=0, must-revalidate'); });});9.2 Vitest — OpenAPI-Schema-Drift-Test (Pflicht)
Abschnitt betitelt „9.2 Vitest — OpenAPI-Schema-Drift-Test (Pflicht)“Datei: apps/api/tests/openapi/me-schema.test.ts
import meSchema from '@werkszeit/openapi-spec/paths/auth/me.yaml';import Ajv from 'ajv';
it('Response matcht OpenAPI-Schema exakt (kein zusätzliches Feld, keine fehlenden)', async () => { const body = await (await app.request('/v1/auth/me', {}, { cookie: validSessionCookie })).json(); const ajv = new Ajv({ strict: true, allErrors: true }); const validate = ajv.compile(meSchema.components.schemas.AuthMeResponse); expect(validate(body)).toBe(true); expect(validate.errors).toBeNull();});Schema-Drift bricht den Build — kein Frontend-Bug erst in Production sichtbar.
9.3 Vitest + Testcontainers — Echte DB mit RLS
Abschnitt betitelt „9.3 Vitest + Testcontainers — Echte DB mit RLS“Datei: apps/api/tests/auth/me-extended.integration.test.ts
- I-01 Cross-Tenant-Invariante: User von Tenant A loggt ein, manipulierter Cookie vorgeben mit Tenant-B-Subject → 401, niemals Tenant-B-Modulen oder Tenant-B-Name geleakt.
- I-02 RLS:
tenant_modules-Lookup respektiertapp.tenant_id— User von Tenant A sieht nur dessen Module, auch wenn Subquery technisch alle Tenants joinen könnte. - I-03 Performance: 1000 sequenzielle
/me-Calls in < 15 s (p95 ≤ 15 ms pro Call) — verifiziert die Index-Strategie auflogin_events (user_id, occurred_at DESC). - I-04 Concurrency: 50 parallele
/me-Calls liefern alle dieselbelastLoginAt(REPEATABLE-READ-Snapshot).
9.4 Playwright — Reused via web/01-admin-portal-shell
Abschnitt betitelt „9.4 Playwright — Reused via web/01-admin-portal-shell“Keine eigene E2E-Suite hier — die Topbar-Tests aus web/01:1352 (Greeting „Hallo Anna · ACME Bau GmbH“) werden umgestellt: statt Mock-Response für /v1/auth/me mit nur id+role liefert der Mock jetzt die volle Extended-Response. Visual Regression Golden „dashboard“ wird einmalig neu generiert (Topbar enthält jetzt echten Tenant-Name statt UUID-Fragment).
9.5 Mobile-Client-Compat-Test (Pflicht)
Abschnitt betitelt „9.5 Mobile-Client-Compat-Test (Pflicht)“Datei: apps/mobile/test/auth/me_response_test.dart
- Liest die Extended-Response, ignoriert die neuen Felder (Forward-Compatibility-Test). Build wird nicht durch unbekannte Felder gebrochen — JSON-Parser ist
permissive. - Liest gleichzeitig die alten Felder (
response.tenantId,response.user.id,response.user.role) — sicherstellen, dass Mobile-App-Versionen v1.0–v1.3 weiter funktionieren.
10. Migration & Rollout
Abschnitt betitelt „10. Migration & Rollout“Reihenfolge
Abschnitt betitelt „Reihenfolge“- Migration
0043_login_events_user_idx.sql(falls nicht vorhanden) — Indexlogin_events (user_id, occurred_at DESC)fürlastLoginAt-Subquery. Idempotent (CREATE INDEX IF NOT EXISTS). - OpenAPI-Spec-Update in
packages/openapi-spec/paths/auth/me.yaml— additiv, kein Breaking Change. - Handler-Update in
apps/api/src/routes/auth/index.ts:47-54— Drizzle-Query mit den 4 zusätzlichen Joins. - TypeScript-Client-Regeneration (
pnpm gen:openapi) — automatischer PR-Bot. - Web-Konsumenten-Migration (
web/01-09) — bewusst nicht in diesem Konzept; jedes Web-Feinkonzept aktualisiert seine Topbar/Sidebar im eigenen PR. - Mobile-App-Update — optional, Mobile darf die neuen Felder lesen, muss aber nicht. v1.4-Release nutzt sie.
Feature-Flag
Abschnitt betitelt „Feature-Flag“Keiner. Additive Änderung, kein Risiko durch dunkle Schaltfläche. Wenn der Build grün ist, ist die Erweiterung live.
Rollback
Abschnitt betitelt „Rollback“Standard git revert + Re-Deploy — Mobile-Clients und Web-Frontends, die schon auf neue Felder zugreifen, fallen sauber auf alte Pfade zurück (alte Felder sind weiter da).
11. Kosten / Aufwand
Abschnitt betitelt „11. Kosten / Aufwand“Build (Engineering).
| Schritt | Aufwand |
|---|---|
| OpenAPI-Schema-Update + Beispiel-Werte | 0.2 d |
| Handler-Erweiterung (Drizzle-Joins, Fallback-Logik) | 0.4 d |
Migration login_events-Index (falls nötig) |
0.1 d |
| Vitest-Unit (7 Cases) | 0.3 d |
| Vitest-Integration mit Testcontainers (4 Cases) | 0.3 d |
| Schema-Drift-Test | 0.1 d |
| Mobile-Compat-Smoke-Test | 0.1 d |
| Summe | 1.5 d |
Run. Kein zusätzlicher Infra-Cost — eine Query mehr pro /me-Call (~ 4 ms p50). Bei 50.000 Calls/Tag pro Tenant = 200 s zusätzliche DB-Zeit/Tag = vernachlässigbar.
12. Risiken & offene Annahmen
Abschnitt betitelt „12. Risiken & offene Annahmen“| Risiko / Annahme | Impact | Wahrscheinlichkeit | Gegenmaßnahme |
|---|---|---|---|
tenant_modules-Tabelle existiert noch nicht |
Hoch (Stop-the-Bus) | Mittel | Vor PR-Start prüfen, ob kern/02-modul-gates gelandet ist; sonst zuerst dort. |
role_permissions-Tabelle ist nicht versioniert (Drift zwischen Code-Annahme und DB-State) |
Mittel | Mittel | Seed via Migration, nicht Runtime-Insert; Test, der gegen Hardcoded-Liste prüft. |
lastLoginAt-Subquery wird langsam bei großen login_events (> 10 M Rows) |
Mittel | Niedrig (V1) | Index-Pflicht + Monitoring; bei p95 > 50 ms separates Caching in Redis (5 min TTL). |
| Mobile-Client-Versionen v1.0–v1.3 können Forward-Compatibility-Bugs zeigen | Niedrig | Niedrig | Compat-Test in §9.5; Sentry-Monitoring auf JSON-Parse-Errors. |
| Permission-Liste wird in V2 zu groß (> 100 Strings) | Niedrig | Mittel | Bitfield-Companion-Field als V2-Migration vorbereitet, kein MVP-Blocker. |
13. Abhängigkeiten
Abschnitt betitelt „13. Abhängigkeiten“- ↔
kern/09-auth-self-service: Erweitert dessen/me-Handler. Bricht ihn nicht. - ↔
kern/02-modul-gates: Lieferttenant_modules-Tabelle. Pflicht-Vorbedingung. - ↔
kern/03-rollen-rbac: Liefertrole_permissions-Mapping. Pflicht-Vorbedingung. - →
web/01-admin-portal-shell: konsumiertdisplayName,tenant.name. Entfernt den Workaround „lese aus Preferences“ (web/01:754). - →
web/02-admin-zeiterfassungbisweb/09-*: konsumierenavailableModulesfür Sidebar-Filter,permissionsfür Action-Button-Sichtbarkeit. - →
apps/mobile: optionaler Konsument ab v1.4.
14. Senior-Berater-Empfehlung
Abschnitt betitelt „14. Senior-Berater-Empfehlung“Kernaussage. Implementierung am Stück in einer einzelnen 1.5-Tage-Iteration — nicht stückeln. Begründung: sieben nachgelagerte Web-Feinkonzepte (web/01–07) plus Mobile v1.4 hängen an dieser API-Form. Jeden Tag, den dieses Delta liegt, baut ein anderes Feinkonzept einen weiteren Workaround, der später wieder ausgebaut werden muss. Wir kennen das Muster aus der Alt-App (LESSONS-LEARNED §3): die Topbar-Begrüßung war dort drei verschiedene Calls aus drei verschiedenen Komponenten — am Ende inkonsistent („Hannes“ oben, „hannes@…“ in der Sidebar, leerer String im Mobile-Drawer).
Was 2027 dankbar macht. Der /me-Endpunkt ist das Schaufenster der Auth-Schicht. Jede zukünftige Identity-Erweiterung (Avatar, Locale, Tenant-Branding, MFA-Status) hat hier ihren natürlichen Platz — additiv, ohne Versionssprung. Wir setzen heute den Anker: eine Wahrheit, ein Call, klare Schema-Verträge, Forward-Compatibility per Default. Wer in 18 Monaten ein Multi-Tenant-Switcher-Feature baut, erweitert hier um availableTenants[] — und alle Frontends sehen es typsicher.
Stop-the-Bus-Trigger.
tenant_modulesexistiert nicht → Implementierung pausieren,kern/02priorisieren.- Cross-Tenant-Test-Invariante schlägt fehl → kein Merge, egal wie spät im Sprint.
- Mobile-Compat-Test bricht → kein Merge, Mobile-Releases würden brechen.
Risiko-Reihenfolge (was zuerst absichern, falls Zeitnot):
- Cross-Tenant-Isolation (Test I-01) — nicht verhandelbar.
- Schema-Drift-Test — verhindert Production-Bugs.
- Edge-Cases EC-01 (Fallback) und EC-02 (Tenant gelöscht) — UX-Stabilität.
- Performance-Test (I-03) — kann nachgereicht werden, wenn Load-Profil sauber.
Ende Feinkonzept §3.9b · v0.1 · 2026-05-01
15. Admin-Konfiguration
Abschnitt betitelt „15. Admin-Konfiguration“Dieses Schema-Delta liefert die Daten-Grundlage für die Admin-Mitarbeiter-Verwaltung in web/01-admin-portal-shell und web/03-mitarbeiter-cockpit. Diese Sektion fasst die F-A-* Feature-IDs konsolidiert zusammen — Admin-Modul-Stub-URL: /admin/kern/auth-me-extended/.
- F-A-01 — Rollenzuweisung pro Mitarbeiter. Editor für
users.role(owner/admin/manager/employee/accounting). Genau einownerpro Tenant erzwungen (DB-Constraint). Wechsel derowner-Rolle erfordert E-Mail-Bestätigung des bisherigen Owners (2-Step). - F-A-02 — Permission-Override. Pro User abweichende Permissions zur Rolle hinzufügen oder entziehen (z.B.
accounting:export-datevfür Mitarbeiter ohne Buchhalterrolle). UI zeigt klar: „Rolle gibt X — manuell hinzu Y, manuell weg Z“. Audit-Log-Eintrag pro Änderung. - F-A-03 — Display-Name-Policy. Tenant-Setting: erzwungener
Vorname Nachname, Kürzel-Erlaubnis ja/nein, Pseudonyme erlaubt ja/nein (relevant für Datenschutz im Schicht-/Pausen-Display öffentlicher Tafeln). - F-A-04 — Aktivitätsanzeige. Schalter ob
last_active_atim Mitarbeiter-Cockpit für Manager sichtbar ist (BetrVG-Mitbestimmung — Default aus, BR-Zustimmung Pflicht zum Aktivieren). - F-A-05 — Service-Accounts. Anlage technischer User für Integrationen (DATEV-Brücke, Lohnsoftware, Zeit-Kiosk-Tablet). Pflicht-Markierung
is_service_accountschaltet MFA aus, erlaubt Token-basierte Auth, untersagt UI-Login. Audit-Trail aller Aktionen mit Service-Account-Markierung. - F-A-06 — Permissions-Katalog (Tenant-Übersicht). Read-only-View aller im Tenant verfügbaren Permission-Keys mit Beschreibung und Anzahl User die sie haben. Hilft beim Audit (“Wer kann was?”).