Zum Inhalt springen

/v1/auth/me Extended — displayName, tenant{name,slug}, availableModules, permissions, lastLoginAt

Beta — in Erprobung

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 bestehenden GET /v1/auth/me. Konsumenten sind alle Web-Admin-Feinkonzepte ab web/01-admin-portal-shell aufwärts (web/02 bis web/09), die ohne diese Felder N+1-Calls fahren oder Platzhalter rendern müssen (siehe web/01-admin-portal-shell.md:754 „Note zum displayName“). Senior-Berater-Empfehlung: einmal richtig erweitern statt sieben Workarounds in sieben Frontends.


feature_id: kern/09b-auth-me-extended
title: /v1/auth/me Extended — displayName, tenant{name,slug}, availableModules, permissions, lastLoginAt
funktionsumfang_ref: §3.9 (Delta zu kern/09)
roadmap_horizont: V1
plattformen:
mobile: vollständig # Mobile App nutzt /me ebenfalls für Greeting
web: vollständig # Adminportal Topbar + Sidebar Modul-Filter
desktop: aus
owner_rolle: alle # /me ist rollen-agnostisch — jeder authentifizierte User
modul_gate_flag: module.kern.auth # Kern-Auth, kein gated Modul
compliance_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 + Tests
abhä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 RLS

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:

  1. Topbar-Begrüßung „Hallo Anna · ACME Bau GmbH“ nicht renderbar. displayName und tenantName fehlen. web/01 umgeht das aktuell mit dem Workaround „lese display_name aus /v1/admin/me/preferences per 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.
  2. Sidebar zeigt aktuell hartcodiert 6 Menüpunkte (web/01:41). Sobald module.handwerk.aufmass oder module.kern.dienstplan per Tenant gegated werden, braucht die Shell eine Liste availableModules, sonst rendert sie Menüpunkte zu 501-Endpunkten.
  3. Permission-Checks sind heute reine Rollen-Checks (role === 'admin'). Sobald wir feiner gehen (z.B. „Manager mit employees:invite-Capability, aber ohne payroll: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).
  4. lastLoginAt wird in web/06-admin-einstellungen und web/07-admin-mfa gebraucht („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).


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.


  1. Additive Erweiterung der bestehenden Response — keine Breaking-Changes für Mobile-Clients, die die alten Felder lesen (Forward-Compatibility).
  2. Keine Versionierung auf /v2/auth/me — die Felder sind reine Ergänzungen, alle alten Pfade (response.user.id, response.tenantId) bleiben unverändert.
  3. Ein Roundtrip liefert: Identity + Tenant + Module-Gating + Capability-Liste + Last-Login-Stempel.
  4. TypeScript-Typ-Sicherheit durch Update in packages/openapi-spec → generierter Client (Mobile + Web) sieht die neuen Felder typisiert.
  5. Cache-Strategie explizit: Cache-Control: private, max-age=0, must-revalidate/me darf weder von CloudFront noch vom Browser-Disk-Cache wiederverwendet werden, weil Permission-Änderungen sofort wirksam sein müssen.
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.

Datei: packages/openapi-spec/paths/auth/me.yaml

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 }
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_-]*$"
  • tenantId bleibt mit deprecated: true erhalten. Mobile-App hat ältere Versionen im Field, die tenantId direkt lesen — wir brechen sie nicht. Der TypeScript-Generator emittiert @deprecated-JSDoc, ESLint-Regel meldet Neu-Verwendungen.
  • permissions und availableModules sind required, niemals null. Leere Capability/Modul-Liste = [], nicht null. Frontend-Code spart sich die Null-Guard-Kaskade.
  • lastLoginAt ist nullable: true — Erst-Login (allererster /me-Call nach Account-Anlage) hat noch keinen vorherigen Login. Frontend rendert dann „Erstmals angemeldet“.
  • displayName ist required auf 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.


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):

-- Auszug
INSERT 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 perms
FROM users u
JOIN tenants t ON t.id = u.tenant_id
WHERE 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 Filter occurred_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.


# 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.

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();
expect(body.user.displayName).toBe('anna'); // [email protected] → 'anna'
});
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.

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 respektiert app.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 auf login_events (user_id, occurred_at DESC).
  • I-04 Concurrency: 50 parallele /me-Calls liefern alle dieselbe lastLoginAt (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).

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.

  1. Migration 0043_login_events_user_idx.sql (falls nicht vorhanden) — Index login_events (user_id, occurred_at DESC) für lastLoginAt-Subquery. Idempotent (CREATE INDEX IF NOT EXISTS).
  2. OpenAPI-Spec-Update in packages/openapi-spec/paths/auth/me.yaml — additiv, kein Breaking Change.
  3. Handler-Update in apps/api/src/routes/auth/index.ts:47-54 — Drizzle-Query mit den 4 zusätzlichen Joins.
  4. TypeScript-Client-Regeneration (pnpm gen:openapi) — automatischer PR-Bot.
  5. Web-Konsumenten-Migration (web/01-09) — bewusst nicht in diesem Konzept; jedes Web-Feinkonzept aktualisiert seine Topbar/Sidebar im eigenen PR.
  6. Mobile-App-Update — optional, Mobile darf die neuen Felder lesen, muss aber nicht. v1.4-Release nutzt sie.

Keiner. Additive Änderung, kein Risiko durch dunkle Schaltfläche. Wenn der Build grün ist, ist die Erweiterung live.

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).


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.


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.

  • kern/09-auth-self-service: Erweitert dessen /me-Handler. Bricht ihn nicht.
  • kern/02-modul-gates: Liefert tenant_modules-Tabelle. Pflicht-Vorbedingung.
  • kern/03-rollen-rbac: Liefert role_permissions-Mapping. Pflicht-Vorbedingung.
  • web/01-admin-portal-shell: konsumiert displayName, tenant.name. Entfernt den Workaround „lese aus Preferences“ (web/01:754).
  • web/02-admin-zeiterfassung bis web/09-*: konsumieren availableModules für Sidebar-Filter, permissions für Action-Button-Sichtbarkeit.
  • apps/mobile: optionaler Konsument ab v1.4.

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_modules existiert nicht → Implementierung pausieren, kern/02 priorisieren.
  • 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):

  1. Cross-Tenant-Isolation (Test I-01) — nicht verhandelbar.
  2. Schema-Drift-Test — verhindert Production-Bugs.
  3. Edge-Cases EC-01 (Fallback) und EC-02 (Tenant gelöscht) — UX-Stabilität.
  4. Performance-Test (I-03) — kann nachgereicht werden, wenn Load-Profil sauber.

Ende Feinkonzept §3.9b · v0.1 · 2026-05-01


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-01Rollenzuweisung pro Mitarbeiter. Editor für users.role (owner/admin/manager/employee/accounting). Genau ein owner pro Tenant erzwungen (DB-Constraint). Wechsel der owner-Rolle erfordert E-Mail-Bestätigung des bisherigen Owners (2-Step).
  • F-A-02Permission-Override. Pro User abweichende Permissions zur Rolle hinzufügen oder entziehen (z.B. accounting:export-datev für Mitarbeiter ohne Buchhalterrolle). UI zeigt klar: „Rolle gibt X — manuell hinzu Y, manuell weg Z“. Audit-Log-Eintrag pro Änderung.
  • F-A-03Display-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-04Aktivitätsanzeige. Schalter ob last_active_at im Mitarbeiter-Cockpit für Manager sichtbar ist (BetrVG-Mitbestimmung — Default aus, BR-Zustimmung Pflicht zum Aktivieren).
  • F-A-05Service-Accounts. Anlage technischer User für Integrationen (DATEV-Brücke, Lohnsoftware, Zeit-Kiosk-Tablet). Pflicht-Markierung is_service_account schaltet MFA aus, erlaubt Token-basierte Auth, untersagt UI-Login. Audit-Trail aller Aktionen mit Service-Account-Markierung.
  • F-A-06Permissions-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?”).