363 lines
37 KiB
Markdown
363 lines
37 KiB
Markdown
---
|
||
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/<generated>_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/<generated>_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>
|