Files
tessera-ctl/.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-01-PLAN.md
T
schalli 8a4bb32847
Tessera CI/CD / Lint & Type Check (push) Successful in 47s
Tessera CI/CD / Tests (push) Successful in 46s
Tessera CI/CD / Build & Publish Images (push) Successful in 6s
docs(15): create phase plan — 8 plans, 4 waves, PERM-01..07
2026-08-04 14:39:46 +02:00

363 lines
37 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.
---
phase: 15-modul-berechtigungen-gruppen-user-grants
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- apps/api/prisma/schema.prisma
- apps/api/prisma/migrations/<generated>_add_groups_and_module_grants/migration.sql
- apps/api/prisma/migrations/<generated>_groups_rls_policies/migration.sql
- apps/api/src/module-registry/module-access.service.ts
- apps/api/src/module-registry/module-access.service.spec.ts
- apps/api/src/module-registry/module.guard.ts
- apps/api/src/module-registry/module.guard.spec.ts
- apps/api/src/module-registry/module-registry.controller.ts
- apps/api/src/module-registry/module-registry.module.ts
- apps/api/src/groups/migration-sql.spec.ts
autonomous: false
requirements: [PERM-04, PERM-05, PERM-06]
user_setup: []
estimate:
tokens: 72000
raw_tokens: 72000
tasks: 3
confidence: low
must_haves:
truths:
- "getAccessibleModuleIds(tenantId, userId, role) ist die einzige Stelle im Backend, die entscheidet, ob ein Benutzer ein Modul nutzen darf — ModuleGuard und GET /modules/active rufen dieselbe Methode auf (D-01)."
- "Ein USER ohne Grant erhält auf einem mit @UseModule(slug) geschützten Endpoint 403 und sieht das Modul nicht in GET /modules/active (PERM-04)."
- "Ein ADMIN oder SUPER_ADMIN erhält ohne jeden Grant alle mandantenweit aktiven Module — der Rollen-Kurzschluss greift vor jeder Grant-Query (D-03, PERM-05)."
- "Ein Grant auf ein mandantenweit deaktiviertes Modul gewährt keinen Zugriff: die Auflösung ist die Schnittmenge aus Aktivierung UND Grant (D-02)."
- "Nach der Migration existiert pro Mandant genau eine Gruppe mit isDefault=true, die alle Bestandsbenutzer als Mitglieder und Grants für alle zum Migrationszeitpunkt aktiven Module trägt (D-06, PERM-06)."
- "Die Datenbank erzwingt strukturell: ein ModuleGrant zeigt auf genau eine Gruppe ODER genau einen Benutzer, und pro Mandant trägt höchstens eine Gruppe die Standard-Markierung (D-05, D-13)."
- "Ein ModuleGrant trägt kein Feld, aus dem ein Modul eine Rechtestufe ableiten könnte — nur Zugriff an/aus (D-04)."
- "Ein Freigabe-Entzug wirkt bei der nächsten API-Anfrage, weil die Auflösung pro Request neu läuft und über Request-Grenzen hinweg nicht zwischengespeichert wird (D-09)."
# --- UI-SPEC 'UI Considerations' — covered (Zeile 'partial · Sidebar und Dashboard-Grid (E7)') ---
- "Sidebar und Dashboard-Grid erhalten vom Server die fertig gefilterte Modulliste — ein teilweise gefiltertes Ergebnis kann strukturell nicht entstehen, weil die Filterung in einer einzigen serverseitigen Auflösung passiert."
# --- Edge-Probe PERM-04 (5 Kategorien) ---
- "adjacency/PERM-04: Ist ein Modul mandantenweit aktiv UND freigegeben, entsteht Zugriff; ein Grant auf ein deaktiviertes Modul und eine Aktivierung ohne Grant ergeben beide keinen Zugriff."
- "empty/PERM-04: Ein Benutzer ohne Gruppenmitgliedschaft und ohne Direkt-Grant erhält ein leeres Set; GET /modules/active antwortet dann mit [] und HTTP 200, nicht mit 403."
- "ordering/PERM-04: GET /modules/active sortiert deterministisch nach Modulname aufsteigend, damit sich die Sidebar-Reihenfolge über wiederholte Aufrufe nicht ändert."
- "idempotency/PERM-04: getAccessibleModuleIds ist rein lesend — zwei identische Anfragen liefern dieselbe Entscheidung und schreiben keinen Datensatz."
- "concurrency/PERM-04: Wird ein Grant während einer laufenden Anfrage entzogen, entscheidet der Zustand zum Zeitpunkt der Guard-Prüfung; die nächste Anfrage ist bereits 403 (D-09)."
# --- Edge-Probe PERM-05 (2 Kategorien) ---
- "idempotency/PERM-05: Der Rollen-Kurzschluss ist zustandslos — wiederholte Aufrufe für denselben ADMIN liefern dasselbe Ergebnis und legen keine Grants an."
- "concurrency/PERM-05: Der ADMIN-Bypass gilt ausschliesslich fuer die tenantId aus dem JWT; ein paralleler Request eines ADMIN eines anderen Mandanten leitet daraus keinen Zugriff auf fremde Module ab."
# --- Edge-Probe PERM-06 (7 Kategorien) ---
- "boundary/PERM-06: Ein Mandant mit 0 Benutzern erhält trotzdem seine Standardgruppe (mit 0 Mitgliedschaften); ein Mandant mit 0 aktiven Modulen erhält die Gruppe ohne Grants."
- "adjacency/PERM-06: Existiert für einen Mandanten bereits eine Gruppe mit isDefault=true, legt der Backfill keine zweite an — jedes INSERT trägt einen NOT-EXISTS-Wächter."
- "empty/PERM-06: Eine Datenbank ohne Mandanten lässt die Migration ohne Fehler und ohne eingefügte Zeilen durchlaufen."
- "ordering/PERM-06: Die drei INSERT-Statements laufen in der Reihenfolge Group, GroupMembership, ModuleGrant; jedes spätere Statement liest die im selben Lauf erzeugten Zeilen über isDefault=true statt über zwischengespeicherte IDs."
- "precision/PERM-06: Alle Backfill-IDs stammen aus gen_random_uuid() (Präzedenz: Migration 20260723120000_add_tender_source) — keine anwendungsseitige ID-Erzeugung."
- "idempotency/PERM-06: Scheitert der Lauf mittendrin, rollt die Migrations-Transaktion alle drei INSERTs zurück; die NOT-EXISTS-Wächter machen einen Wiederholungslauf zusätzlich folgenlos."
- statement: "concurrency/PERM-06: Starten zwei API-Container gleichzeitig, verhindert das Advisory-Lock von prisma migrate deploy, dass der Backfill doppelt läuft."
verification: backstop
artifacts:
- "apps/api/prisma/schema.prisma — Modelle Group, GroupMembership, ModuleGrant, Enum MembershipSource"
- "apps/api/prisma/migrations/<generated>_add_groups_and_module_grants/migration.sql"
- "apps/api/prisma/migrations/<generated>_groups_rls_policies/migration.sql"
- "apps/api/src/module-registry/module-access.service.ts — ModuleAccessService"
- "apps/api/src/module-registry/module-access.service.spec.ts"
- "apps/api/src/module-registry/module.guard.spec.ts"
- "apps/api/src/groups/migration-sql.spec.ts"
key_links:
- "ModuleGuard.canActivate → ModuleAccessService.getAccessibleModuleIds — der einzige Durchsetzungspunkt jedes Modul-Endpoints"
- "ModuleRegistryController.findActive → ModuleAccessService.getAccessibleModuleIds — dieselbe Auflösung wie der Guard (D-01)"
- "ModuleRegistryModule exportiert ModuleAccessService — DashboardModule (Plan 15-05) und die Grant-Services (Plan 15-03) hängen daran"
- "migration.sql D-06-Backfill → TenantModuleActivation + User — ohne diesen Schritt verlieren alle Bestandsbenutzer beim Deploy den Zugriff"
prohibitions:
- statement: "Kein Deploy-Pfad und keine Admin-Aktion darf einem Mandanten-Admin den Zugang zum Admin-UI oder zu den aktiven Modulen seines Mandanten nehmen — Aussperren ist nie ein akzeptabler Zwischenzustand."
status: active
verification: flagged-unverified
---
<objective>
Diese Phase baut die Zugriffskontrolle um ein zweites Standbein. Plan 15-01 legt das Fundament und beweist es sofort end-to-end: neue Datenmodelle samt Migration mit Bestandsübernahme, eine einzige Auflösungsfunktion, und deren Verdrahtung in Guard und Listing-Endpoint.
**Die drei Tasks dieses Plans bilden zusammen den Tracer-Slice der Phase** — eine dünne, produktionsreife Bahn durch jede Schicht, die diese Phase anfasst: Datenbank (Task 1) → Service/Guard/Controller (Task 2) → Sicherheitsnetz RLS (Task 3). Task 2 trägt den echten End-to-End-Nachweis. Alle weiteren Pläne der Phase sind horizontale Ausbaustufen auf dieser bewiesenen Bahn.
Purpose: Ohne eine einzige Wahrheitsquelle für "darf dieser Benutzer dieses Modul" (D-01) driften Sidebar und API auseinander — genau das Sicherheitsloch, das D-01 verhindert. Ohne den Migrations-Backfill (D-06) verliert beim nächsten `docker compose up` jeder Bestandsbenutzer seinen Modulzugriff, weil `apps/api/Dockerfile:38` beim Container-Start automatisch `prisma migrate deploy` ausführt.
Output: Drei neue Tabellen mit DB-erzwungenen Invarianten, ein befüllter Bestand, `ModuleAccessService` als Single Source of Truth, ein erweiterter `ModuleGuard` und ein benutzergefiltertes `GET /modules/active`.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-CONTEXT.md
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-RESEARCH.md
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-PATTERNS.md
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-VALIDATION.md
</context>
<tasks>
<task type="checkpoint:decision" gate="blocking">
<decision>Freigabe der drei unumkehrbaren Grundsatzentscheidungen D-01, D-05 und D-06 vor dem ersten Schreibvorgang</decision>
<context>
Diese drei Entscheidungen sind in 15-CONTEXT.md als `one-way` bewertet und werden von diesem Plan in Code und Daten gegossen. Nach Ausführung sind sie nur noch per Datenmigration rückholbar:
- **D-01** — eine einzige Auflösungsfunktion wird zur Zugriffsgrundlage jedes Modul-Endpoints. Ein späterer Modellwechsel müsste jeden Guard-Aufrufpfad und alle bereits vergebenen Grants migrieren.
- **D-05** — `Group`, `GroupMembership` und `ModuleGrant` entstehen als neue Tabellen mit Fremdschlüsseln auf `User`, `Tenant` und `Module`. Ein Umbau nach Vergabe echter Freigaben ist eine Datenmigration.
- **D-06** — die Migration schreibt Bestandsdaten (Standardgruppe je Mandant, alle Benutzer als Mitglieder, Grants für alle aktiven Module). Ein Rückbau erfordert ein eigenes Rückabwicklungsskript.
Ebenfalls betroffen, als `costly` bewertet: **D-02** (Default geschlossen) kehrt die Bedeutung des Zugriffsmodells um; ein Rückbau auf "offen, sofern nicht eingeschränkt" verlangt eine erneute Datenmigration.
Es gibt keine Alternative zur Umsetzung — diese Entscheidungen sind in 15-CONTEXT.md gesperrt. Dieser Checkpoint ist ein bewusster Halt vor dem Punkt ohne Rückweg, kein erneutes Aufrollen der Entscheidung.
</context>
<options>
<option id="proceed">
<name>Wie entschieden ausführen</name>
<pros>Setzt D-01, D-05, D-06 und D-02 exakt wie in 15-CONTEXT.md gesperrt um</pros>
<cons>Ab Task 1 ist der Zustand nur noch per Datenmigration rückholbar</cons>
</option>
<option id="hold">
<name>Anhalten und Entscheidungen erneut besprechen</name>
<pros>Letzte Gelegenheit, das Zugriffsmodell zu ändern, bevor Daten geschrieben werden</pros>
<cons>Blockiert die gesamte Phase; erfordert einen neuen Durchlauf von /gsd-discuss-phase 15</cons>
</option>
</options>
<resume-signal>Antworte "proceed" oder "hold"</resume-signal>
</task>
<task type="auto">
<name>Task 1: [BLOCKING] Schema-Sync — Group/GroupMembership/ModuleGrant plus D-06-Bestandsübernahme</name>
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/&lt;generated&gt;_add_groups_and_module_grants/migration.sql, apps/api/src/groups/migration-sql.spec.ts</files>
<precondition>Die lokale PostgreSQL des Projekts ist vom Host aus erreichbar. Der `db`-Container veröffentlicht keinen Host-Port — die Verbindung läuft über die Container-IP mit den Zugangsdaten `tessera:tessera_dev` (siehe Memory `project_local_db_migrations`). Ermittle die IP mit `docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $(docker ps -qf name=db)` und setze `DATABASE_URL` für den Migrationslauf entsprechend. Halte an, wenn keine Verbindung zustande kommt — ohne angewendete Migration ist keine weitere Task dieses Plans lauffähig.</precondition>
<read_first>
- apps/api/prisma/schema.prisma — die bestehenden Modelle Tenant, User, Module, TenantModuleActivation sowie das Role-Enum; Konventionen für @id/@default(uuid()), @@index, Kommentarstil
- apps/api/prisma/migrations/20260618112133_rls_policies/migration.sql — das etablierte Muster, wie Hand-SQL an eine generierte Migration angehängt wird
- apps/api/prisma/migrations/20260721150000_tender_cpv_divisions_backfill/migration.sql — das etablierte Backfill-Muster in einer Migrationsdatei
- apps/api/prisma/migrations/20260723120000_add_tender_source/migration.sql — Präzedenzfall für gen_random_uuid() in einem INSERT ... SELECT (belegt die Verfügbarkeit in der Ziel-Datenbank)
- apps/api/Dockerfile — Zeile 38, der CMD mit `prisma migrate deploy` beim Container-Start
- apps/api/package.json — das postinstall-Skript, das `prisma generate` ausführt
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-RESEARCH.md — Abschnitt "Code Examples" mit dem vorgeschlagenen Schema und dem Backfill-SQL
</read_first>
<action>
Ergänze `apps/api/prisma/schema.prisma` um das Enum `MembershipSource` mit den Werten `MANUAL` und `LDAP` sowie um drei Modelle. Setze die Modelle unter `TenantModuleActivation`, mit einem deutschsprachigen Blockkommentar im Stil der Tender-Modelle, der D-05, D-13 und D-04 benennt.
`Group`: `id` String @id @default(uuid()), `tenantId` String mit Relation auf `Tenant`, `name` String, `ldapDn` String? (optionale AD-Bindung, D-05), `isDefault` Boolean @default(false) (D-13), `createdAt`/`updatedAt`, Gegenrelationen `memberships` und `grants`. Constraints: `@@unique([tenantId, name])` — Gruppennamen sind pro Mandant eindeutig, damit die Freigabe-Matrix keine ununterscheidbaren Spalten bekommt; `@@unique([tenantId, ldapDn])` — dieselbe AD-Gruppe wird nicht zweimal gebunden, wobei Postgres NULL je Zeile als distinct behandelt, also beliebig viele ungebundene Gruppen erlaubt; `@@index([tenantId])`.
`GroupMembership`: `id`, `groupId` mit Relation auf `Group` und `onDelete: Cascade`, `userId` mit Relation auf `User` und `onDelete: Cascade`, `source` MembershipSource @default(MANUAL), `createdAt`. Constraints: `@@unique([groupId, userId])` als Upsert-Ziel, `@@index([userId])`, `@@index([groupId])`.
`ModuleGrant`: `id`, `tenantId` mit Relation auf `Tenant`, `moduleId` mit Relation auf `Module` und `onDelete: Cascade`, `groupId` String? mit optionaler Relation auf `Group` und `onDelete: Cascade`, `userId` String? mit optionaler Relation auf `User` und `onDelete: Cascade`, `createdAt`. `@@index([tenantId])`, `@@index([moduleId])`. Bewusst KEIN Feld für eine Rechtestufe (D-04) — der Datensatz trägt ausschliesslich die Zuordnung. Die Entweder-oder-Invariante ist in Prisma 6.19 nicht ausdrückbar und kommt unten als Hand-SQL.
Ergänze die Gegenrelationen an den bestehenden Modellen: `Tenant` bekommt `groups Group[]` und `moduleGrants ModuleGrant[]`, `User` bekommt `groupMemberships GroupMembership[]` und `moduleGrants ModuleGrant[]`, `Module` bekommt `grants ModuleGrant[]`.
Erzeuge die Migration mit `pnpm --filter @tessera/api exec prisma migrate dev --name add_groups_and_module_grants --create-only`. Nutze ausdrücklich NICHT `prisma db push` — dieses Projekt ist migrationsbasiert, weil der Container-Start `prisma migrate deploy` ausführt und der D-06-Backfill nur so unbeaufsichtigt beim Deploy läuft.
Hänge an die generierte `migration.sql` folgende Hand-SQL-Blöcke an, jeder mit einem deutschen Kommentar, der die zugehörige Entscheidung nennt:
Erstens die Invarianten: ein partieller Unique-Index `Group_one_default_per_tenant` auf `"Group"("tenantId") WHERE "isDefault" = true` (D-13, genau eine Standardgruppe je Mandant); ein CHECK-Constraint `ModuleGrant_group_xor_user` mit `num_nonnulls("groupId", "userId") = 1`; zwei partielle Unique-Indizes `ModuleGrant_tenant_module_group_unique` auf `("tenantId","moduleId","groupId") WHERE "groupId" IS NOT NULL` und `ModuleGrant_tenant_module_user_unique` auf `("tenantId","moduleId","userId") WHERE "userId" IS NOT NULL`.
Zweitens der D-06-Backfill als drei `INSERT ... SELECT`-Statements in genau dieser Reihenfolge: Standardgruppe `'Alle Benutzer'` mit `isDefault = true` je Zeile aus `"Tenant"`; dann `"GroupMembership"` mit `source = 'MANUAL'` für jeden `"User"` mit passender `tenantId` verbunden über die Gruppe mit `isDefault = true`; dann `"ModuleGrant"` für jede `"TenantModuleActivation"` mit `isActive = true`, verbunden über dieselbe Standardgruppe. Alle IDs kommen aus `gen_random_uuid()`. Jedes der drei Statements erhält einen `WHERE NOT EXISTS (...)`-Wächter gegen den jeweils bereits vorhandenen Zieldatensatz, damit ein Wiederholungslauf folgenlos bleibt und der partielle Unique-Index die Migration nicht abbrechen kann.
Wende die Migration mit `pnpm --filter @tessera/api exec prisma migrate dev` an und lasse danach `pnpm --filter @tessera/api exec prisma generate` laufen — das Projekt generiert den Client nur über `postinstall`, nicht über `build`, ein separater Aufruf ist also nötig.
Lege `apps/api/src/groups/migration-sql.spec.ts` an: ein Vitest-Test, der die erzeugte `migration.sql` per `fs.readFileSync` über einen Glob auf `apps/api/prisma/migrations/*_add_groups_and_module_grants/migration.sql` einliest und den Inhalt prüft — Vorhandensein von `Group_one_default_per_tenant`, `ModuleGrant_group_xor_user`, `num_nonnulls`, beider partieller Grant-Indizes, dreier `gen_random_uuid()`-Vorkommen, dreier `NOT EXISTS`-Wächter sowie die Reihenfolge der drei Zieltabellen über die jeweilige `indexOf`-Position. Der Test braucht keine Datenbank.
</action>
<verify>
<automated>pnpm --filter @tessera/api test -- migration-sql && pnpm --filter @tessera/api run type-check</automated>
</verify>
<acceptance_criteria>
- `ls apps/api/prisma/migrations/ | grep -c add_groups_and_module_grants` gibt `1` aus.
- `pnpm --filter @tessera/api exec prisma migrate status` meldet keine ausstehende Migration.
- `grep -c 'model Group\b\|model GroupMembership\|model ModuleGrant\|enum MembershipSource' apps/api/prisma/schema.prisma` gibt mindestens `4` aus.
- `pnpm --filter @tessera/api test -- migration-sql` ist grün; der Test belegt CHECK-Constraint, drei partielle Indizes, drei NOT-EXISTS-Wächter und die Statement-Reihenfolge Group vor GroupMembership vor ModuleGrant.
- `pnpm --filter @tessera/api run type-check` läuft fehlerfrei durch — belegt, dass `prisma generate` die neuen Typen erzeugt hat.
- Gegen die lokale DB liefert `SELECT count(*) FROM "Group" WHERE "isDefault" = true;` genau so viele Zeilen wie `SELECT count(*) FROM "Tenant";`.
- Gegen die lokale DB schlägt `INSERT INTO "ModuleGrant" (id,"tenantId","moduleId","createdAt") VALUES (gen_random_uuid(),'x','y',now());` mit einer Constraint-Verletzung fehl (weder groupId noch userId gesetzt).
</acceptance_criteria>
<done>Die drei Tabellen existieren mit ihren DB-erzwungenen Invarianten, der Bestand ist ohne Zugriffsverlust übernommen, und der Prisma-Client kennt die neuen Typen.</done>
<reversibility rating="one-way">Setzt D-05 und D-06 um: neue Tabellen mit Fremdschlüsseln plus geschriebene Bestandsdaten — ein Rückbau nach Vergabe echter Freigaben ist eine eigene Datenmigration.</reversibility>
</task>
<task type="tracer" tdd="true">
<name>Task 2: [TRACER] Zugriffsauflösung end-to-end — ModuleAccessService, ModuleGuard, GET /modules/active</name>
<files>apps/api/src/module-registry/module-access.service.ts, apps/api/src/module-registry/module-access.service.spec.ts, apps/api/src/module-registry/module.guard.ts, apps/api/src/module-registry/module.guard.spec.ts, apps/api/src/module-registry/module-registry.controller.ts, apps/api/src/module-registry/module-registry.module.ts</files>
<read_first>
- apps/api/src/module-registry/module-registry.service.ts — Prisma-Query-Stil (destrukturiertes where, select), `isModuleActive` und `findActiveForTenant` als unmittelbare Vorlagen
- apps/api/src/module-registry/module.guard.ts — der komplette Bestandsguard, der erweitert und nicht ersetzt wird; die Herkunft von tenantId aus `request.tenantId ?? request.user?.tenantId`
- apps/api/src/module-registry/module-registry.controller.ts — `findActive` (Zeilen 47–54) und das tenantId-aus-Request-Muster
- apps/api/src/module-registry/module-registry.module.ts — providers/exports, die um den neuen Service ergänzt werden
- apps/api/prisma/schema.prisma — die in Task 1 erzeugten Modelle und das Role-Enum
- apps/api/src/tenders/tender-matching.service.spec.ts — Vorbild für eine Vitest-Suite mit gemocktem PrismaService in diesem Projekt
</read_first>
<behavior>
- ADMIN: `getAccessibleModuleIds(t1, uAdmin, 'ADMIN')` liefert die Menge aller moduleIds aus `tenantModuleActivation` mit `isActive: true` für t1 — ohne jede Grant-Query.
- SUPER_ADMIN: identisches Verhalten wie ADMIN.
- USER ohne Grants: leeres Set.
- USER mit Direkt-Grant auf ein aktives Modul: Set enthält genau diese moduleId.
- USER mit Grant über eine Gruppe, in der er Mitglied ist: Set enthält diese moduleId.
- USER mit Direkt-Grant UND Gruppen-Grant auf dasselbe Modul: Set enthält die moduleId genau einmal.
- USER mit Grant auf ein Modul, dessen `TenantModuleActivation.isActive` false ist: Set enthält die moduleId NICHT (D-02).
- Guard ohne Metadaten-Slug: gibt true zurück, ohne den Service aufzurufen.
- Guard ohne tenantId im Request: wirft ForbiddenException mit der Meldung `No tenant context`.
- Guard mit unbekanntem Slug: wirft ForbiddenException.
- Guard, USER ohne Grant auf ein aktives Modul: wirft ForbiddenException.
- Guard, ADMIN ohne Grant auf ein aktives Modul: gibt true zurück.
</behavior>
<action>
Lege `apps/api/src/module-registry/module-access.service.ts` mit `ModuleAccessService` an, injiziert wird `PrismaService` (global bereitgestellt über `PrismaModule`, kein Import nötig).
Öffentliche Methode `getAccessibleModuleIds(tenantId: string, userId: string, role: Role): Promise<Set<string>>`. Ablauf: bei `role === 'ADMIN'` oder `role === 'SUPER_ADMIN'` sofort alle `tenantModuleActivation`-Zeilen mit `isActive: true` für den Mandanten lesen und deren `moduleId` als Set zurückgeben (D-03, Kurzschluss vor jeder Grant-Query). Andernfalls in einem `Promise.all` zwei Queries auf `moduleGrant` absetzen: die erste mit `where: { tenantId, userId }`, die zweite mit `where: { tenantId, group: { memberships: { some: { userId } } } }` — eine einzige verschachtelte Query statt einer Schleife über die Gruppen des Benutzers, sonst entsteht ein N+1 pro geschütztem Endpoint. Die Vereinigungsmenge der moduleIds wird anschliessend gegen `tenantModuleActivation` mit `isActive: true` und `moduleId: { in: [...] }` geschnitten und als Set zurückgegeben (D-02).
Zweite öffentliche Methode `findAccessibleModules(tenantId, userId, role)`: ruft `getAccessibleModuleIds` auf und lädt daraus die vollständigen `module`-Datensätze mit `orderBy: { name: 'asc' }`. Diese Methode bedient den Listing-Endpoint; die Sortierung ist explizit, damit die Sidebar-Reihenfolge über Aufrufe hinweg stabil bleibt.
Kein try/catch im Service — Prisma-Fehler propagieren an Guard beziehungsweise Controller, exakt wie im gesamten `module-registry.service.ts`.
Erweitere `module.guard.ts`: `ModuleGuard` injiziert zusätzlich `ModuleAccessService`. Nach der bestehenden tenantId-Auflösung werden `userId` aus `request.user?.id` und `role` aus `request.user?.role` gelesen — derselbe Herkunftsweg wie tenantId, niemals aus Body oder Params. Fehlt eines von beiden, wirft der Guard `ForbiddenException` mit `No user context`. Danach wird der Modul-Datensatz über `ModuleRegistryService.findBySlug(moduleSlug)` aufgelöst; ist er null, bleibt es bei der bestehenden ForbiddenException. Anschliessend entscheidet `(await getAccessibleModuleIds(tenantId, userId, role)).has(module.id)`; bei false wirft der Guard `ForbiddenException` mit der Meldung `Module '<slug>' is not accessible for this user`. Das Ergebnis wird zusätzlich als `request.moduleAccessIds` abgelegt, damit ein Handler im selben Request die Auflösung nicht ein zweites Mal bezahlt; über Request-Grenzen hinweg wird nichts zwischengespeichert (D-09). Der `@UseModule(slug)`-Dekorator bleibt unverändert.
Erweitere `module-registry.controller.ts`: `findActive` liest zusätzlich `userId` und `role` aus `req.user` und delegiert an `ModuleAccessService.findAccessibleModules` statt an `ModuleRegistryService.findActiveForTenant`. Fehlt der Benutzerkontext, wirft der Handler ForbiddenException. `findActiveForTenant` bleibt im Registry-Service unverändert erhalten — Plan 15-03 braucht die mandantenweite Sicht weiterhin für den Marketplace-Katalog.
Ergänze `module-registry.module.ts` um `ModuleAccessService` in `providers` und `exports`, damit DashboardModule (Plan 15-05) und die Grant-Services (Plan 15-03) daran andocken können.
Lege die beiden Spezifikationen `module-access.service.spec.ts` und `module.guard.spec.ts` an, die alle unter `<behavior>` genannten Fälle mit gemocktem PrismaService beziehungsweise gemocktem ModuleAccessService abdecken.
</action>
<verify>
<automated>pnpm --filter @tessera/api test -- module-access.service && pnpm --filter @tessera/api test -- module.guard</automated>
</verify>
<acceptance_criteria>
- `pnpm --filter @tessera/api test -- module-access.service` und `pnpm --filter @tessera/api test -- module.guard` sind grün und decken jeden unter `<behavior>` gelisteten Fall mit je einem eigenen `it(...)` ab.
- `grep -c 'getAccessibleModuleIds' apps/api/src/module-registry/module.guard.ts apps/api/src/module-registry/module-access.service.ts` belegt den Aufruf im Guard und die Definition im Service.
- `grep -c 'moduleAccessService\|ModuleAccessService' apps/api/src/module-registry/module-registry.controller.ts` gibt mindestens `2` aus — der Listing-Endpoint nutzt dieselbe Auflösung wie der Guard (D-01).
- `grep -c 'ModuleAccessService' apps/api/src/module-registry/module-registry.module.ts` gibt mindestens `3` aus (Import, providers, exports).
- End-to-End gegen die laufende lokale API: ein USER ohne Grant erhält auf `GET /domaincheck/...` (bzw. einem anderen mit `@UseModule` geschützten Endpoint) HTTP 403 und in `GET /modules/active` eine Liste, die den Slug nicht enthält; derselbe Aufruf als ADMIN desselben Mandanten liefert HTTP 200 und den Slug in der Liste.
- `pnpm --filter @tessera/api test` läuft vollständig grün — kein bestehender Test bricht durch die Guard-Erweiterung.
</acceptance_criteria>
<done>Ein einziger Aufruf entscheidet über Modulzugriff; Guard und Listing-Endpoint liefern nachweislich dieselbe Antwort, und ein USER ohne Grant ist auf beiden Wegen ausgesperrt, während ein ADMIN durchkommt.</done>
<reversibility rating="one-way">Setzt D-01 um: die Auflösung wird zur Zugriffsgrundlage jedes Modul-Endpoints.</reversibility>
</task>
<task type="auto">
<name>Task 3: RLS-Policies für die drei neuen Tabellen und erneuter Tracer-Nachweis</name>
<files>apps/api/prisma/migrations/&lt;generated&gt;_groups_rls_policies/migration.sql, apps/api/src/groups/migration-sql.spec.ts</files>
<read_first>
- apps/api/prisma/migrations/20260618112133_rls_policies/migration.sql — die vollständige Vorlage: `current_tenant_id()`, ENABLE/FORCE ROW LEVEL SECURITY, direkte tenantId-Policy sowie die Join-Policy für PasswordResetToken
- apps/api/src/prisma/prisma-tenant.extension.ts — `forTenant()` und wie `app.current_tenant` gesetzt wird
- apps/api/src/module-registry/module-access.service.ts — der in Task 2 entstandene Service, dessen Queries auf unskaliertem `this.prisma` laufen
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-RESEARCH.md — Pitfall 4 und Annahme A1 zur RLS-Entscheidung
</read_first>
<action>
Erzeuge eine zweite, bewusst getrennt review-bare Migration mit `pnpm --filter @tessera/api exec prisma migrate dev --name groups_rls_policies --create-only`. Die Datei enthält ausschliesslich Hand-SQL, kein von Prisma generiertes DDL.
Aktiviere für `"Group"`, `"GroupMembership"` und `"ModuleGrant"` jeweils `ENABLE ROW LEVEL SECURITY` und `FORCE ROW LEVEL SECURITY` und lege je eine Policy `tenant_isolation_policy` an. `"Group"` und `"ModuleGrant"` tragen eine eigene `tenantId`-Spalte, ihre Policy vergleicht direkt gegen `current_tenant_id()`. `"GroupMembership"` hat keine eigene tenantId — ihre Policy folgt dem Join-Muster von `PasswordResetToken` und prüft `"groupId" IN (SELECT "id" FROM "Group" WHERE "tenantId" = current_tenant_id())`.
Setze einen deutschen Kopfkommentar in die Datei, der die Begründung festhält: diese drei Tabellen steuern unmittelbar, wer auf was zugreifen darf, und folgen damit dem Muster der Auth-Kerntabellen (`User`, `LdapConfig`) statt dem App-Layer-Muster der `Tender*`-Tabellen; RLS ist hier ein zweites Sicherheitsnetz für den Fall, dass irgendwo ein `where: { tenantId }` vergessen wird.
Wende die Migration an und führe danach den End-to-End-Nachweis aus Task 2 unverändert erneut aus. Dieser Schritt ist der eigentliche Zweck des getrennten Tasks: `ModuleAccessService` liest über unskaliertes `this.prisma`, ohne dass `app.current_tenant` gesetzt ist. Sollte der Datenbankbenutzer RLS nicht ohnehin umgehen, liefern die Grant-Queries nach dieser Migration null Zeilen und jeder USER wäre ausgesperrt. Tritt genau das ein, halte an und melde den Befund als Blocker, statt die Policies stillschweigend wieder zu entfernen — der richtige Fix ist dann, die Queries in `ModuleAccessService` durch `forTenant(this.prisma, tenantId)` zu führen, was eine eigene Entscheidung ist.
Ergänze `apps/api/src/groups/migration-sql.spec.ts` um einen zweiten `describe`-Block, der die RLS-Migrationsdatei einliest und die neun erwarteten Anweisungen belegt (je Tabelle ENABLE, FORCE und CREATE POLICY).
</action>
<verify>
<automated>pnpm --filter @tessera/api test -- migration-sql && pnpm --filter @tessera/api test -- module-access.service</automated>
</verify>
<acceptance_criteria>
- `ls apps/api/prisma/migrations/ | grep -c groups_rls_policies` gibt `1` aus.
- `grep -c 'ROW LEVEL SECURITY' apps/api/prisma/migrations/*_groups_rls_policies/migration.sql` gibt `6` aus (je Tabelle ENABLE und FORCE).
- `grep -c 'CREATE POLICY tenant_isolation_policy' apps/api/prisma/migrations/*_groups_rls_policies/migration.sql` gibt `3` aus.
- `pnpm --filter @tessera/api test -- migration-sql` ist grün und deckt beide Migrationsdateien ab.
- Nach angewendeter Migration liefert derselbe End-to-End-Durchlauf wie in Task 2 unverändert: USER ohne Grant HTTP 403 plus fehlender Slug in `GET /modules/active`, USER mit Grant HTTP 200 plus vorhandener Slug, ADMIN HTTP 200. Weicht das Ergebnis ab, gilt der Task als blockiert, nicht als erledigt.
- `pnpm --filter @tessera/api exec prisma migrate status` meldet keine ausstehende Migration.
</acceptance_criteria>
<done>Die drei neuen Tabellen tragen dieselben RLS-Policies wie die Auth-Kerntabellen, und die in Task 2 bewiesene Bahn funktioniert danach nachweislich unverändert.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → API (JWT-Cookie) | Rolle, userId und tenantId erreichen den Guard ausschliesslich über das validierte JWT; nichts davon stammt aus Body oder Params |
| API → PostgreSQL | Jede Grant-/Gruppen-Query trägt die tenantId aus dem JWT; RLS ist das zweite Netz |
| Container-Start → Datenbank | `prisma migrate deploy` schreibt beim Deploy unbeaufsichtigt Bestandsdaten |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-15-03 | Elevation of Privilege | Ein künftiger Modul-Controller ohne `@UseModule(slug)` | medium | mitigate | `ModuleGuard` gibt ohne Metadaten-Slug bewusst `true` zurück — die Durchsetzung hängt am Dekorator. Task 2 hält das im Guard-Kommentar fest und `module.guard.spec.ts` belegt den Fall explizit, damit die Lücke sichtbar bleibt; jeder neue Modul-Controller trägt `@UseModule` (Projektregel seit Phase 3) |
| T-15-06 | Denial of Service | D-06-Backfill in `migration.sql`, ausgeführt durch `prisma migrate deploy` beim Container-Start | high | mitigate | Drei `INSERT ... SELECT` mit `WHERE NOT EXISTS`-Wächtern innerhalb der von Prisma je Migrationsdatei geöffneten Transaktion; keine interaktive Bestätigung, kein externer Zustand. Ein Fehlschlag rollt vollständig zurück, statt einen halb migrierten Mandanten zu hinterlassen |
| T-15-10 | Elevation of Privilege | `ModuleAccessService` Rollen-Kurzschluss | high | mitigate | Der Bypass liest die Rolle ausschliesslich aus `request.user.role` (JWT) und beschränkt die Menge auf `tenantModuleActivation` desselben `tenantId` — ein ADMIN kann daraus keine Module eines fremden Mandanten ableiten; `module-access.service.spec.ts` deckt den Fall ab |
| T-15-11 | Tampering / Information Disclosure | Unskalierte Prisma-Queries auf den drei neuen Tabellen | high | mitigate | Task 3 aktiviert RLS mit FORCE nach dem Muster der Auth-Kerntabellen und verifiziert danach empirisch, dass die Zugriffsauflösung unverändert arbeitet |
| T-15-SC | Tampering | npm/pnpm-Installationen | low | accept | Diese Phase installiert kein einziges neues Paket (15-RESEARCH.md, Abschnitt "Package Legitimacy Audit"). Es gibt keinen Install-Task, damit greift der Legitimacy-Gate nicht |
</threat_model>
<verification>
- `pnpm --filter @tessera/api test` vollständig grün, inklusive der drei neuen Spezifikationen.
- `pnpm --filter @tessera/api run type-check` fehlerfrei.
- `pnpm --filter @tessera/api exec prisma migrate status` ohne ausstehende Migration.
- Manuell gegen die lokale Datenbank (PERM-06, laut 15-VALIDATION.md nicht sinnvoll durch Unit-Tests abgedeckt): je Mandant genau eine Gruppe mit `isDefault = true`; deren Mitgliederzahl gleich der Benutzerzahl des Mandanten; deren Grant-Zahl gleich der Zahl der aktiven Module des Mandanten.
</verification>
<success_criteria>
- Eine einzige Methode entscheidet über Modulzugriff und wird von Guard und Listing-Endpoint aufgerufen (D-01).
- Ein USER ohne Grant ist auf API- und Listing-Ebene ausgesperrt, ein ADMIN nicht (PERM-04, PERM-05).
- Kein Bestandsbenutzer verliert durch die Migration Zugriff (PERM-06).
- Entweder-oder-Beziehung und Ein-Default-pro-Mandant sind von der Datenbank erzwungen, nicht nur vom Anwendungscode.
</success_criteria>
## Artifacts this phase produces
Vollständige Liste aller Symbole, die Phase 15 über alle Pläne hinweg erzeugt. Die Zeilen dieses Plans sind mit **(15-01)** markiert.
**Prisma-Modelle und Enums**
- `MembershipSource` (Enum: `MANUAL`, `LDAP`) — **(15-01)**
- `Group` (id, tenantId, name, ldapDn?, isDefault, createdAt, updatedAt) — **(15-01)**
- `GroupMembership` (id, groupId, userId, source, createdAt) — **(15-01)**
- `ModuleGrant` (id, tenantId, moduleId, groupId?, userId?, createdAt) — **(15-01)**
**Migrationsverzeichnisse**
- `<generated>_add_groups_and_module_grants` — **(15-01)**
- `<generated>_groups_rls_policies` — **(15-01)**
**NestJS-Services, Controller, Module**
- `ModuleAccessService` mit `getAccessibleModuleIds()` und `findAccessibleModules()` — **(15-01)**
- `ModuleGuard` (erweitert um die Benutzer-Dimension) — **(15-01)**
- `ModuleRegistryController.findActive` (auf Benutzer-Sicht umgestellt) — **(15-01)**
- `GroupsModule`, `GroupsService`, `GroupsController` — (15-02)
- `UserService.create` (Standardgruppen-Mitgliedschaft) — (15-02)
- `ModuleGrantsService`, `ModuleGrantsController` — (15-03)
- `ModuleRegistryController.findCatalog` (`GET /modules/catalog`) — (15-03)
- `LdapService.syncGroupMembershipsForTenant` — (15-04)
- `WIDGET_MODULE_MAP` in `apps/api/src/dashboard/widget-module-map.ts` — (15-05)
- `DashboardService.getWidgets` (modulgefiltert) — (15-05)
**DTOs**
- `CreateGroupDto`, `UpdateGroupDto`, `AddGroupMembersDto` — (15-02)
- `CreateModuleGrantDto` — (15-03)
**React-Komponenten und Routen**
- `apps/web/src/lib/module-access-actions.ts` mit `checkModuleAccess()` — (15-08)
- `/admin/groups` (`page.tsx`) — (15-06)
- `/admin/modules/grants` (`page.tsx`, Freigabe-Matrix) — (15-07)
- `apps/web/src/app/(portal)/admin/modules/components/ActivateModuleDialog.tsx` — (15-07)
- `apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx` — (15-07)
- `apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx` (Server Component) und `module-shell.tsx` — (15-08)
- `MarketplaceCard` (`hasAccess`-Prop, Badge "Kein Zugriff") — (15-08)
- `AdminSidebar` (sechster Eintrag) — (15-06)
**i18n-Namensräume** (alle Schlüssel entstehen gebündelt in 15-06)
- `admin.groups.*`, `admin.users.grants.*`, `adminModules.grants.*`, `adminModules.activationDialog.*`, `adminModules.grantsLink`, `modules.accessDenied.*`, `marketplace.statusNoAccess`, `marketplace.toastNoAccess`, `header.admin.groups`
**Testdateien**
- `module-access.service.spec.ts`, `module.guard.spec.ts`, `groups/migration-sql.spec.ts` — **(15-01)**
- `groups.service.spec.ts` — (15-02)
- `module-grants.service.spec.ts` — (15-03)
- `ldap.service.spec.ts` (erweitert) — (15-04)
- `dashboard.service.spec.ts` — (15-05)
<output>
Erstelle `.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-01-SUMMARY.md`, wenn der Plan abgeschlossen ist.
</output>