# Feindesign & Implementierungsplan — Zentrale Identität + Mandanten-Mitgliedschaften (certvia) > Produkt: **certvia** (ISMS-Tool). Grundlage: [KONZEPT-identity-mandanten.md](KONZEPT-identity-mandanten.md) (Option C, Entscheidungen A–E + Two-Step-Login + Passphrasen). Dieses Dokument ist der **umsetzbare Bauplan** für ein mehrköpfiges, noch zu onboardendes Entwicklerteam inkl. PM. Alle Datei:Zeile-Angaben beziehen sich auf Branch `dev`. ## 0. Wie dieses Dokument zu lesen ist - **PM:** §7 (Meilensteine), §8 (Risiken), §9 (DoD), §11 (Rollen/Cadence), §12 (Aufwand). - **Entwickler:** §1–§6 (Feindesign), §10 (Onboarding + „Goldene Regeln"). - **Alle:** §1 ist die eine Leitentscheidung, aus der alles Weitere folgt. --- ## 1. Leitentscheidung (das Sicherheitsnetz): `User.id` bleibt stabil Wir führen **nicht** eine große Umverkabelung durch. Stattdessen: - **`User` bleibt** die per-Mandant-Zeile (jetzt gedanklich „Mitgliedschaft") mit **derselben `id`** und **derselben `tenantId`**. - Wir **schneiden nur die Auth-Felder heraus** in eine neue globale **`Identity`** und hängen `User.identityId → Identity.id` an. - In der Session bleibt **`session.user.tenantId`** erhalten — es zeigt künftig auf den **aktiven** Mandanten. Dadurch bleiben **~356 `dbForTenant(session.user.tenantId)`-Stellen, alle 6 Owner-FKs (`assets/processes/risks/measures.owner_id`, `user_roles`, `webauthn`) und das RLS-Modell unangetastet.** **Folge:** Der Umbau konzentriert sich auf **Login, Session-Aufbau, Mandantenauswahl, Nutzer-Lifecycle** — nicht auf die 356 Fachstellen. Das ist die risikoärmste Schneise. > **Zusatz-Vereinfachung (Entscheidung D):** Es gibt **keinen Produktivdatenbestand** (nur Testdaten). Deshalb **kein Backfill/Merge**, sondern **Schema-Neuschnitt + Reseed**. Der aufwändigste und riskanteste Teil eines solchen Umbaus entfällt komplett. --- ## 2. Zieldatenmodell (konkret) **Neu — `Identity` (global, KEIN `tenantId`, NICHT in `TENANT_MODELS`):** `id, email @unique, passwordHash, mustChangePassword, mfaSecret, mfaEnrolledAt, recoveryCodes, lastTotpStep, failedLogins, lockedUntil, sessionsValidAfter, status, createdAt, updatedAt`. → Übernimmt exakt die Felder, die heute in `User` (`schema.prisma:98-139`) und `PlatformAdmin` doppelt liegen. **Geändert — `User` = Mitgliedschaft (bleibt in `TENANT_MODELS`, behält `tenantId`+`id`):** - **entfernt** (wandern zu Identity): `passwordHash, mfaSecret, mfaEnrolledAt, recoveryCodes, lastTotpStep, failedLogins, lockedUntil, mustChangePassword, sessionsValidAfter, isPlatformAdmin`. - **neu:** `identityId → Identity`. - **bleibt:** `tenantId, name, status: UserStatus, userRoles`, alle Ownership-Relationen. `@@unique([tenantId, email])` → ersetzt durch `@@unique([tenantId, identityId])` (eine Person max. 1 Mitgliedschaft je Mandant); `email` bleibt als Denormalisierung optional oder entfällt (Quelle = Identity). **`WebAuthnCredential`** (`schema.prisma:143-160`): `userId → identityId`, `tenantId` entfällt → **raus aus `TENANT_MODELS`**, RLS-Policy der Tabelle entfernen (Passkeys sind identitäts-, nicht mandantengebunden). **`AuthToken`** (`schema.prisma:1679-1700`): `principalType/principalId` → auf `identity` umstellen; neuer `TokenType: "invitation"` (heute wird `password_reset` mit 7-Tage-TTL zweckentfremdet). **`PlatformAdmin`** (`schema.prisma:211-234`): **bleibt getrennt** (Entscheidung E). *Option (empfohlen, klein):* eine Identity kann zusätzlich Plattform-Admin sein → `PlatformAdmin.identityId` als Verweis, Store aber getrennt. Nicht zwingend für Phase 1. **`TENANT_MODELS` (`db.ts:81-148`):** `Identity` **nicht** aufnehmen (globaler Lookup über Owner-`prisma`, wie heute der Login). `WebAuthnCredential` **entfernen**. `User` **bleibt** drin. --- ## 3. Session-/Token-Shape (neu) **JWT/Session (`next-auth.d.ts` erweitern):** ``` identityId activeTenantId → gespiegelt als session.user.tenantId (⇒ 356 Call-Sites unverändert!) activeMembershipId (= User.id des aktiven Mandanten) memberships[] [{ tenantId, tenantSlug, membershipId, tenantName }] (schlanke Liste für den Switcher) permissions[] (des AKTIVEN Mandanten) tenantSlug (des aktiven Mandanten) isPlatformAdmin, mfaEnrolled, tokenIssuedAt ``` **Kritische Regel:** `session.user.tenantId === activeTenantId`, immer **genau ein** aktiver Mandant. Kippt das (leer/mehrdeutig), kippt der RLS-Kontext → Fail-closed. **Konsumenten anpassen (aber minimal):** - `auth.ts` jwt/session-Callbacks (`:276-299`): neue Felder setzen, `tenantId` = aktiver Mandant. - `action-guard.ts:29-84` und `(app)/layout.tsx:37-120`: `sessionsValidAfter` künftig aus **Identity** lesen; Permissions bei **Mandantenwechsel** neu auflösen (heute beim Login eingefroren). - `rbac.ts` (`hasPermission/requirePermission`, 43 Konsumenten): unverändert — liest weiter `session.user.permissions`, die aber pro aktivem Mandant befüllt werden. - `proxy.ts`: neuer Pfad `/select-tenant` in Post-Login-Gate; „angemeldet, aber kein aktiver Mandant → `/select-tenant`". --- ## 4. Die Flows (Feindesign) ### 4.1 Login (Two-Step, MFA beim Login) — `login/page.tsx`, `auth.ts` 1. **Seite 1: E-Mail + Passwort** (Feld „Organisation" **entfällt**). `signIn` gegen **Identity** (`auth.ts:96` von `user.findMany` auf `identity.findUnique({email})` umstellen; Lockout/Dummy-Verify bleiben). 2. **MFA-pending-Zustand:** kurzlebiger, signierter, einzweckiger Server-State (kein voller Session-Cookie). Realisierung: eigener NextAuth-Step oder ein `mfa_pending`-JWT mit 5 min TTL, das nur `/login/mfa` bedient. 3. **Seite 2: MFA** — angezeigt, wenn Identity MFA hat **oder** ≥ 1 Mitgliedschaft in MFA-Pflicht-Mandant. TOTP/Passkey verifizieren (`mfa.ts:47`, Replay via Identity.`lastTotpStep`) oder Enroll erzwingen. 4. **Volle Session** ausstellen (Identity + MFA erfüllt), `memberships[]` laden. 5. **Weiterleitung:** 1 Mitgliedschaft → direkt `/dashboard` (activeTenant gesetzt); mehrere → `/select-tenant`; keine → Hinweisseite. ### 4.2 Mandantenauswahl & -Wechsel — neu: `/select-tenant` + Action `setActiveTenant` - Server Action `setActiveTenant(membershipId)`: **(1)** Membership gehört zur Session-Identity? **(2)** Mitgliedschaft+Mandant ACTIVE? **(3)** `sessionsValidAfter` ok? **(4)** MFA-Netz: verlangt Zielmandant MFA und nicht erfüllt → Enroll. Dann Token neu prägen: `activeTenantId/activeMembershipId/permissions/tenantSlug`. - **Sicherheitskontrollen (Pflicht):** aktiver `tenantId` **server-autoritativ** (nie Client-Input); Re-Validierung bei jedem Wechsel; **Audit** „Identity → Mandant". (Siehe §8.) - **Tenant-Switcher** in `(app)/layout.tsx` (Kopf/Sidebar) → ruft `setActiveTenant`. ### 4.3 Einladung (Standard-Anlageweg) — `auth-selfservice.ts`, `auth-token.ts`, neue Seite `/invite` - Neuer **`TokenType: "invitation"`** + Zielseite **`/invite?token=`** (Erst-Passwort + optional MFA **auf der Identity** setzen), statt Zweckentfremdung von `/reset`. - **Neutralität:** Der einladende Admin sieht **immer** „Einladung gesendet" — unabhängig davon, ob die Identity schon existierte (kein Cross-Tenant-Leak). - Existiert die Identity → Mail „Sie wurden zu Mandant X hinzugefügt" + **Bestätigungslink** (Beitritt annehmen). Existiert nicht → „Konto einrichten + beitreten". ### 4.4 Nutzeranlage - **Plattform** (`platform-users.ts:89`): `createTenantUser` → Identity finden/erstellen + Membership + Rollen; **`pwMode "set"` + Fallback-Passwort entfernen**; Betreiber darf direkt verknüpfen. - **Kunde** (`tenant-users.ts:139`): `createUser` → **immer Einladung**; „Passwort setzen"-Zweig raus. - **Onboarding-Wizard** (`onboarding-team.ts:166`): `inviteFunctionHolder` → Identity-Einladung statt User+Passwort. - **UI** (`user-forms.tsx`): pwMode-Auswahl entfällt → reines Einladungsformular (Name, E-Mail, Rollen). ### 4.5 MFA-Pflicht-Durchsetzung (Mandanten-Policy trifft Identity-MFA) - Gates umstellen: `(app)/layout.tsx:73-79`, `enroll-mfa/page.tsx:24-32`, `(platform)/layout.tsx:27` → prüfen **Identity.mfaEnrolledAt** statt `User`. - `mfa-policy.ts:7` (`resolveMfaRequired`, mandantenweit) bleibt. Regel: **strengster betretener Mandant gewinnt**; da MFA an der Identity hängt, deckt eine Einrichtung alle ab. ### 4.6 Passwort/MFA-Reset → Identity-Ebene, raus aus Mandantenverwaltung - **Entfernen:** `platform-users.ts:152` (`resetTenantUserPassword`), `tenant-users.ts:207` (`resetUserPassword`) + UI-Bindungen (`admin/[id]/page.tsx:205`, `settings/users/page.tsx:105`). Ersatz: Identity-Self-Service (Recovery-Codes) + Plattform-Ebene. - **Umleiten auf Identity:** `auth-recovery.ts` (gesamt), `account.ts:66` (`changeOwnPassword`), `sessions.ts:34/92` (`sessionsValidAfter` an Identity), `webauthn.ts:73` (Passkey an Identity), `secret-crypto.ts`-Nutzung unverändert (Schlüssel bleibt). --- ## 5. Migrationsstrategie (DB) Dank Entscheidung D **kein Backfill**. Ablauf: 1. **Schema-Recut** in `schema.prisma`: `Identity` neu, `User` verschlanken (+`identityId`), `WebAuthnCredential`/`AuthToken` umhängen. 2. **Forward-Migration(en):** `identities` (ohne RLS/Policy!), `users`-Spalten entfernen + `identity_id` FK, `webauthn_credentials` `tenant_id`/Policy entfernen + `identity_id`, `auth_tokens` principal-Umbau. RLS-Policies der betroffenen Tabellen anpassen (analog `rls_enforce`-Muster, `20260730160000`). 3. **`db.ts`:** `TENANT_MODELS` anpassen (Identity raus lassen, WebAuthn entfernen). 4. **Reseed:** `prisma/seed.ts`, `provision.ts`, `bootstrap-admin.ts`, `sync-role-permissions.ts` auf Identity+Membership. Testumgebung frisch aufsetzen. > Für die Test-/Coolify-Instanz: einmalig **DB leeren** (nur Testdaten) → `migrate deploy` → Seed. Kein Sonderpfad nötig. --- ## 6. Arbeitspakete (Workstreams) für das Team | WS | Inhalt | Kernfiles | Abhängig von | |---|---|---|---| | **WS0 Fundament** | `Identity`-Modell, Schema-Recut, Migrationen, `TENANT_MODELS`, RLS-Anpassung WebAuthn | `schema.prisma`, `prisma/migrations/*`, `db.ts` | — (Blocker) | | **WS1 Auth-Kern** | Login gegen Identity, Two-Step + MFA-pending, Token/Session-Shape, jwt/session-Callbacks | `auth.ts`, `next-auth.d.ts` | WS0 | | **WS2 Mandantenkontext** | `/select-tenant`, `setActiveTenant`, Tenant-Switcher, Guards + Permissions bei Wechsel, `proxy.ts` | `action-guard.ts`, `(app)/layout.tsx`, `proxy.ts`, `rbac.ts` | WS1 | | **WS3 Einladungs-Lifecycle** | `invitation`-Token, `/invite`-Seite, Anlage überall auf Einladung, „Passwort setzen" raus | `auth-token.ts`, `auth-selfservice.ts`, `platform-users.ts`, `tenant-users.ts`, `onboarding-team.ts`, `user-forms.tsx` | WS0 | | **WS4 Passwort/MFA an Identity** | Reset/Change/Email-Change, MFA/Passkey/Recovery, Sessions-Invalidierung, Reset raus aus Mgmt | `auth-recovery.ts`, `account.ts`, `sessions.ts`, `webauthn.ts`, `platform.ts` | WS0 | | **WS5 UI** | Login-Seiten (2-stufig), `/select-tenant`, Switcher, Einladungsformulare | `login/*`, neue Seiten, `(app)/layout.tsx` | WS1, WS2 | | **WS6 Seed/Provision/Bootstrap** | Reseed auf Identity, `provision.ts`, `bootstrap-admin.ts`, `sync-role-permissions.ts` | genannte | WS0 | | **WS7 Tests & Gate** | neue `scripts/test-*`: Identity-Login, Tenant-Switch-Isolation, MFA-Enforcement multi-tenant, Einladung, Two-Step-Bypass | `scripts/test-*.ts` | fortlaufend | **Parallelisierung:** Nach **WS0** laufen **WS1, WS3, WS4, WS6** parallel. **WS2** setzt auf WS1, **WS5** auf WS1+WS2. **WS7** durchgehend (jede Story bringt ihren Test mit). --- ## 7. Meilensteine (PM-Sicht) | M | Ergebnis (Demo-fähig) | umfasst | |---|---|---| | **M0** | Onboarding abgeschlossen, Dev-Umgebungen laufen, Gate grün | §10 | | **M1** | Fundament: Schema+Migration+Reseed grün, RLS-Tests grün | WS0, WS6, WS7-Basis | | **M2** | Login gegen Identity (Two-Step) + Single-Membership-Nutzer landet im Dashboard | WS1, WS5-Login | | **M3** | Multi-Membership: Auswahl + Wechsel + MFA-Netz + Isolation nachgewiesen | WS2, WS5-Switcher, WS7-Isolation | | **M4** | Einladungs-Lifecycle (Plattform+Kunde+Wizard), „Passwort setzen" entfernt | WS3 | | **M5** | Passwort/MFA-Reset auf Identity, Reset raus aus Mandantenverwaltung | WS4 | | **M6** | Härtung, vollständige Test-Suite, Doku, STAND aktualisiert, Deploy | WS7, Doku | --- ## 8. Risiken & Gegenmaßnahmen | Risiko | Gegenmaßnahme | |---|---| | **RLS kippt**, wenn kein/mehrdeutiger aktiver Mandant | `session.user.tenantId` immer = genau **eine** validierte Membership; fail-closed; Guard wirft bei leer | | **356 Call-Sites** anfassen | Leitentscheidung §1: `User.id` + `session.user.tenantId`-Semantik erhalten → Call-Sites unverändert | | **Two-Step „halb angemeldet"-Bypass** | MFA-pending-State ist einzweckig + kurzlebig, **keine** App-Session vor MFA | | **Rechte veraltet** bei Wechsel ohne Re-Login | `setActiveTenant` löst Permissions **neu** auf (nicht Token-eingefroren) | | **Breiterer Blast-Radius** eines Credentials | starke Passphrase-Policy + MFA + kurze Session + globaler Kill-Switch (`Identity.sessionsValidAfter`) | | **Plattform/Tenant-Cookie-Kollision** | bereits gelöst (getrennte Cookie-Namen, `platform-auth.ts`) — beibehalten | | **Team nicht eingearbeitet** | M0-Onboarding als eigener Meilenstein, „Goldene Regeln" §10, Pair auf WS0 | --- ## 9. Definition of Done **Pro Story:** tsc + lint + build grün · zugehöriges `scripts/test-*.ts` grün · keine neuen `dbForTenant`-Regressionen · Doku-Schnipsel im PR. **Gesamt (M6):** alle `scripts/test-*` grün (inkl. **neuer** Isolations-/Enforcement-Tests) · Login/Auswahl/Wechsel/Einladung/Reset im Browser verifiziert · RLS-Test mit Multi-Membership-Nutzer nachweist, dass Wechsel **keine** Fremddaten sichtbar macht · `docs/STAND-dev-branch.md` + dieses Doc aktualisiert · sauber nach `dev` (beide Remotes) integriert. --- ## 10. Onboarding der neuen Entwickler (M0) **Pflichtlektüre (in dieser Reihenfolge):** `docs/HANDOVER-DEV.md` → `docs/SPEC.md` → `docs/STAND-dev-branch.md` → dieses Doc → [KONZEPT-identity-mandanten.md](KONZEPT-identity-mandanten.md). **Dev-Setup:** Node + Postgres (pgvector-Image), `.env` aus `.env.example`, `npx prisma migrate deploy`, Demo-Seed (`RUN_DEMO_SEED`), `npm run dev`. Demo-Logins in `prisma/seed.ts`. **Validierungs-Gate (vor jedem PR):** `npx tsc --noEmit` · `npm run lint` · `npm run build` · **alle 17 `scripts/test-*.ts`**. **RLS-Mentalmodell (Pflichtverständnis):** `dbForTenant(tenantId)` setzt pro Transaktion `app.tenant_id`; `TENANT_MODELS` sagt, welche Modelle mandantengefiltert sind; globale Modelle (Kataloge, künftig `Identity`) laufen über den Owner-`prisma`. **Nie** ein globales Modell in `TENANT_MODELS` aufnehmen und **nie** ein tenant-Modell ohne Kontext lesen. **DevOps-Workflow:** Feature-Branch → `git merge --no-ff` nach `dev` → Gate grün → Push auf **beide** Remotes (`origin` = git.certvia.de, `local-gitea`) → `docs/STAND-dev-branch.md` pflegen. (certvia ist strikt getrennt von anderen Produkten — nichts vermischen.) **Goldene Regeln dieses Umbaus:** 1. `User.id` = Mitgliedschaft, **stabil lassen**. Auth-Felder leben auf `Identity`. 2. `Identity` ist **global**, **nicht** in `TENANT_MODELS`, kein `tenant_id`, keine RLS-Policy. 3. Genau **ein** aktiver Mandant pro Session; server-autoritativ; bei Wechsel re-validieren. 4. **Keine** „Passwort direkt setzen"-Anlage mehr — nur Einladung. 5. MFA/Passwort gehören der Identity — **kein** Mandanten-Admin-Reset. --- ## 11. Rollen & Cadence (Beispielbesetzung: 1 Lead + 2 Devs + PM) | Rolle | Verantwortung | |---|---| | **Tech-Lead / Senior** | WS0 + WS1 (Fundament + Auth-Kern), Review aller Auth-/RLS-PRs, Sicherheitskontrollen §8 | | **Dev A** | WS2 (Mandantenkontext/Guards) + WS5 (UI) | | **Dev B** | WS3 (Einladung) + WS4 (Passwort/MFA an Identity) + WS6 (Seed) | | **alle** | WS7 (jede Story bringt ihren Test) | | **PM** | Meilenstein-Tracking (§7), Risiken (§8), DoD-Abnahme (§9), Entscheidungs-Eskalation (Phase-2-Punkte), Cadence | **Cadence-Vorschlag:** WS0 als **Pairing** (Lead + je 1 Dev) — dient zugleich als Onboarding-Vehikel; danach 2-Wochen-Iterationen entlang M1–M6, Demo je Meilenstein, PR-Review verpflichtend für Auth/RLS. --- ## 12. Aufwandsschätzung (grob, Personentage) > Annahme: 3 Devs, noch nicht eingearbeitet → Ramp-up eingepreist. Ohne Produktivdaten-Migration (Entscheidung D). | Block | PT (Bereich) | |---|---| | M0 Onboarding (3 Personen) | 6–9 | | WS0 Fundament + Migration + Reseed | 5–8 | | WS1 Auth-Kern (Two-Step, Session-Shape) | 8–12 | | WS2 Mandantenkontext + Guards + Switcher | 6–9 | | WS3 Einladungs-Lifecycle | 5–8 | | WS4 Passwort/MFA an Identity | 6–9 | | WS5 UI | 4–6 | | WS6 Seed/Provision/Bootstrap | 2–4 | | WS7 Tests & Härtung | 5–8 | | **Summe** | **≈ 47–73 PT** | **Kalenderdauer** bei 3 Devs mit Parallelisierung (§6) + PM-Overhead: **≈ 5–7 Wochen** bis M6 (inkl. Onboarding-Woche). Kritischer Pfad: **WS0 → WS1 → WS2 → WS5**. WS3/WS4/WS6 laufen daneben. --- ## 13. Phase 2 (bewusst später, kein Blocker) - Per-Mandant-Schalter „bei jedem Betreten Step-up erzwingen" (Hochsicherheits-Kunden). - E-Mail-Änderung als Identity-Operation (Sonderfälle/Merge). - Optionale Konsolidierung `PlatformAdmin` in die `Identity` (Store getrennt lassen, nur Verweis).