Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
220 lines
17 KiB
Markdown
220 lines
17 KiB
Markdown
# 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).
|