--- 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/_add_groups_and_module_grants/migration.sql - apps/api/prisma/migrations/_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/_add_groups_and_module_grants/migration.sql" - "apps/api/prisma/migrations/_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 --- 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`. @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.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 Freigabe der drei unumkehrbaren Grundsatzentscheidungen D-01, D-05 und D-06 vor dem ersten Schreibvorgang 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. Antworte "proceed" oder "hold" Task 1: [BLOCKING] Schema-Sync — Group/GroupMembership/ModuleGrant plus D-06-Bestandsübernahme apps/api/prisma/schema.prisma, apps/api/prisma/migrations/<generated>_add_groups_and_module_grants/migration.sql, apps/api/src/groups/migration-sql.spec.ts 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. - 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 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. pnpm --filter @tessera/api test -- migration-sql && pnpm --filter @tessera/api run type-check - `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). Die drei Tabellen existieren mit ihren DB-erzwungenen Invarianten, der Bestand ist ohne Zugriffsverlust übernommen, und der Prisma-Client kennt die neuen Typen. 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. Task 2: [TRACER] Zugriffsauflösung end-to-end — ModuleAccessService, ModuleGuard, GET /modules/active 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/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 - 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. 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>`. 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 '' 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 `` genannten Fälle mit gemocktem PrismaService beziehungsweise gemocktem ModuleAccessService abdecken. pnpm --filter @tessera/api test -- module-access.service && pnpm --filter @tessera/api test -- module.guard - `pnpm --filter @tessera/api test -- module-access.service` und `pnpm --filter @tessera/api test -- module.guard` sind grün und decken jeden unter `` 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. 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. Setzt D-01 um: die Auflösung wird zur Zugriffsgrundlage jedes Modul-Endpoints. Task 3: RLS-Policies für die drei neuen Tabellen und erneuter Tracer-Nachweis apps/api/prisma/migrations/<generated>_groups_rls_policies/migration.sql, apps/api/src/groups/migration-sql.spec.ts - 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 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). pnpm --filter @tessera/api test -- migration-sql && pnpm --filter @tessera/api test -- module-access.service - `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. Die drei neuen Tabellen tragen dieselben RLS-Policies wie die Auth-Kerntabellen, und die in Task 2 bewiesene Bahn funktioniert danach nachweislich unverändert. ## 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 | - `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. - 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. ## 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** - `_add_groups_and_module_grants` — **(15-01)** - `_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) Erstelle `.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-01-SUMMARY.md`, wenn der Plan abgeschlossen ist.