Files
craftvia/docs/_certvia-archiv/FEINDESIGN-identity-mandanten.md
T
msolarczekandClaude Opus 5 cadaedc6cc L10b Betrieb & Aufräumen: Deploy – craftvia-worker, CI-Testjob, DEPLOY.md, Certvia-Doku archiviert
- docker-compose.coolify(.prebuilt).yml: Service craftvia-worker (Target worker, Chromium,
  shm_size 1gb, gleiche Härtung), Craftvia-Variablen für app und worker.
- Dockerfile: worker-Stage mit HOME=/home/app (Chromium-Profil für non-root); lokaler
  docker build der Targets runner und worker erfolgreich, PDF-Erzeugung im Image geprüft.
- .env.example/.env.prod.example/.env.coolify.example: alle Craftvia-Variablen inkl. RLS,
  KI-Provider, PDF_CHROMIUM_PATH, OFFLINE_MAX_DAYS, API_RATE_LIMIT_*, AI_GENERATION_RETENTION_DAYS,
  AI_MONTHLY_TOKEN_LIMIT.
- CI (.github, .gitea): Job gate mit Postgres (pgvector) und Redis als Service: migrate deploy,
  seed, Passwort für craftvia_app, tsc, lint, build, npm run test.
- docs/craftvia/DEPLOY.md (aus den Certvia-Deploy-Docs abgeleitet): Architektur, Domains, Secrets,
  Worker, Migrationen, RLS-Aktivierung, Backup/Restore, KI, Rate Limits, Aufbewahrung, Smoke,
  Update/Rollback. build-and-push-images.sh baut craftvia-worker.
- Certvia-/ISMS-Dokumente aus docs/ nach docs/_certvia-archiv/ (mit README); Verweise in README.md
  und Skript-Kommentaren angepasst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 18:19:19 +02:00

220 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).