37 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, user_setup, estimate, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | user_setup | estimate | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 15-modul-berechtigungen-gruppen-user-grants | 01 | execute | 1 |
|
false |
|
|
|
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.
<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_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 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.
Wie entschieden ausführen
Setzt D-01, D-05, D-06 und D-02 exakt wie in 15-CONTEXT.md gesperrt um
Ab Task 1 ist der Zustand nur noch per Datenmigration rückholbar
Anhalten und Entscheidungen erneut besprechen
Letzte Gelegenheit, das Zugriffsmodell zu ändern, bevor Daten geschrieben werden
Blockiert die gesamte Phase; erfordert einen neuen Durchlauf von /gsd-discuss-phase 15
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<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.
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.
<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> |
<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
ModuleAccessServicemitgetAccessibleModuleIds()undfindAccessibleModules()— (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_MAPinapps/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.tsmitcheckModuleAccess()— (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) undmodule-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)