docs(15-01): add SUMMARY for Group/ModuleGrant foundation + access resolution tracer

This commit is contained in:
2026-08-04 15:13:33 +02:00
parent 92e8eaffa5
commit 3b3950cfdb
@@ -0,0 +1,185 @@
---
phase: 15-modul-berechtigungen-gruppen-user-grants
plan: 01
subsystem: auth
tags: [prisma, postgres, nestjs, rls, module-access, guards, vitest]
# Dependency graph
requires:
- phase: 03-module-marketplace
provides: Module/TenantModuleActivation-Modelle, ModuleGuard/ModuleRegistryService/@UseModule als bestehendes Zugriffs-Grundgerüst
provides:
- Group/GroupMembership/ModuleGrant-Datenmodelle mit DB-erzwungener Entweder-oder- und Ein-Default-Invariante
- D-06-Backfill (Standardgruppe je Mandant, Bestandsbenutzer, Bestandsgrants) als Teil von `prisma migrate deploy`
- ModuleAccessService als Single Source of Truth für Modulzugriff (D-01)
- ModuleGuard und GET /modules/active auf Benutzer-Ebene (statt nur Mandanten-Aktivierung) umgestellt
- RLS-Policies für die drei neuen Tabellen (T-15-11, defense-in-depth)
affects: [15-02, 15-03, 15-04, 15-05, 15-06, 15-07, 15-08]
actuals:
tokens: 10800
tasks: 3
commits: 3
tech-stack:
added: []
patterns:
- "Hand-editierte migration.sql für DB-Invarianten, die Prisma nicht ausdrücken kann (CHECK num_nonnulls, partielle Unique-Indizes) — Fortführung des in 20260618112133_rls_policies/20260721150000_tender_cpv_divisions_backfill etablierten Verfahrens"
- "Migrations-Backfill mit WHERE-NOT-EXISTS-Wächtern statt separatem TS-Skript, weil er beim automatischen Container-Start (prisma migrate deploy) unbeaufsichtigt laufen muss"
- "Single-Source-of-Truth Access Resolution: eine Methode (getAccessibleModuleIds) speist Guard UND Listing-Endpoint, keine zweite Implementierung"
key-files:
created:
- apps/api/prisma/migrations/20260804130130_add_groups_and_module_grants/migration.sql
- apps/api/prisma/migrations/20260804130918_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.spec.ts
- apps/api/src/groups/migration-sql.spec.ts
modified:
- apps/api/prisma/schema.prisma
- apps/api/src/module-registry/module.guard.ts
- apps/api/src/module-registry/module-registry.controller.ts
- apps/api/src/module-registry/module-registry.module.ts
key-decisions:
- "D-01/D-05/D-06/D-02 wie in 15-CONTEXT.md gesperrt umgesetzt (Nutzer-Checkpoint mit 'proceed' bestätigt)"
- "RLS für Group/GroupMembership/ModuleGrant aktiviert (T-15-11) statt sie wie Tender* RLS-frei zu lassen — sie steuern Zugriff wie die Auth-Kerntabellen"
- "grantedIds.length===0 kurzschließt die zweite Query in getAccessibleModuleIds (kein leerer IN-Filter gegen tenantModuleActivation) — Optimierung, keine Verhaltensänderung"
patterns-established:
- "Pattern 1 aus 15-RESEARCH.md (Single-Source-of-Truth Access Resolution) 1:1 umgesetzt"
- "Pattern 2 (Per-Request-Memoisierung über request.moduleAccessIds statt Scope.REQUEST) im Guard verankert"
requirements-completed: [PERM-04, PERM-05, PERM-06]
coverage:
- id: D1
description: "Group/GroupMembership/ModuleGrant-Schema mit DB-erzwungener Entweder-oder-Beziehung (CHECK num_nonnulls) und Ein-Default-pro-Mandant (partieller Unique-Index)"
requirement: "PERM-06"
verification:
- kind: unit
ref: "apps/api/src/groups/migration-sql.spec.ts — add_groups_and_module_grants migration.sql (6 Tests)"
status: pass
- kind: manual_procedural
ref: "psql gegen lokale DB: INSERT ohne groupId/userId schlägt mit ModuleGrant_group_xor_user fehl; SELECT count(*) FROM \"Group\" WHERE isDefault=true entspricht count(*) FROM \"Tenant\""
status: pass
human_judgment: false
- id: D2
description: "D-06-Migrations-Backfill: pro Mandant eine Standardgruppe mit allen Bestandsbenutzern und Grants für alle aktiven Module, idempotent bei Wiederholungslauf"
requirement: "PERM-06"
verification:
- kind: unit
ref: "apps/api/src/groups/migration-sql.spec.ts — gen_random_uuid()/NOT EXISTS/Reihenfolge-Tests"
status: pass
- kind: manual_procedural
ref: "Migration gegen lokale DB angewendet: 1 Tenant -> 1 Default-Gruppe, 1 User -> 1 Membership, 1 aktive Aktivierung -> 1 Grant"
status: pass
human_judgment: false
- id: D3
description: "ModuleAccessService.getAccessibleModuleIds als Single Source of Truth (D-01) — ADMIN/SUPER_ADMIN-Kurzschluss, Direkt- und Gruppen-Grants, Schnittmenge mit aktiven Aktivierungen (D-02)"
requirement: "PERM-04"
verification:
- kind: unit
ref: "apps/api/src/module-registry/module-access.service.spec.ts (13 Tests, deckt alle <behavior>-Fälle inkl. adjacency/empty/idempotency/concurrency)"
status: pass
human_judgment: false
- id: D4
description: "ModuleGuard erzwingt getAccessibleModuleIds statt der alten tenant-only isModuleActive-Prüfung; GET /modules/active nutzt dieselbe Auflösung"
requirement: "PERM-05"
verification:
- kind: unit
ref: "apps/api/src/module-registry/module.guard.spec.ts (7 Tests)"
status: pass
- kind: e2e
ref: "curl gegen laufende lokale API: USER ohne Grant -> 403 auf GET /modules/tender-radar + [] in GET /modules/active; USER mit Direkt-Grant -> 200 + Slug in der Liste; ADMIN ohne Grant -> 200 + Slug in der Liste (vor UND nach der RLS-Migration in Task 3 identisch reproduziert)"
status: pass
human_judgment: false
- id: D5
description: "RLS-Policies für Group/GroupMembership/ModuleGrant (T-15-11), defense-in-depth analog Auth-Kerntabellen"
verification:
- kind: unit
ref: "apps/api/src/groups/migration-sql.spec.ts — groups_rls_policies migration.sql (4 Tests)"
status: pass
- kind: manual_procedural
ref: "Task-2-E2E-Nachweis nach Anwenden der RLS-Migration unverändert reproduziert (kein Verhaltensbruch)"
status: pass
human_judgment: false
duration: 24min
completed: 2026-08-04
status: complete
---
# Phase 15 Plan 01: Fundament der Modul-Berechtigungen Summary
**Group/GroupMembership/ModuleGrant-Schema mit DB-erzwungener Entweder-oder- und Ein-Default-Invariante, produktionswirksamer D-06-Migrationsbackfill, und `ModuleAccessService.getAccessibleModuleIds` als einzige Auflösungsfunktion, verdrahtet in `ModuleGuard` und `GET /modules/active` — beide End-to-End gegen die laufende lokale API bewiesen, vor und nach Aktivierung der RLS-Policies.**
## Performance
- **Duration:** 24 min
- **Started:** 2026-08-04T12:48:45Z
- **Completed:** 2026-08-04T13:12:24Z
- **Tasks:** 3 (plus 1 Checkpoint:decision)
- **Files modified:** 10
## Accomplishments
- Drei neue Prisma-Modelle (`Group`, `GroupMembership`, `ModuleGrant`) plus `MembershipSource`-Enum, mit hand-editierter Migrations-SQL für Invarianten, die Prisma 6.19 nicht ausdrücken kann: CHECK-Constraint `num_nonnulls("groupId","userId") = 1` (D-04, Gruppe XOR Benutzer), partieller Unique-Index für genau eine Standardgruppe pro Mandant (D-13), zwei partielle Unique-Indizes gegen Duplikat-Grants
- D-06-Backfill in derselben Migration: pro Mandant eine Gruppe "Alle Benutzer", alle Bestandsbenutzer als Mitglieder, Grants für alle zum Migrationszeitpunkt aktiven Module — läuft automatisch bei jedem `prisma migrate deploy` (Container-Start), jedes der drei INSERTs ist über `WHERE NOT EXISTS` idempotent gegen Wiederholungsläufe abgesichert
- `ModuleAccessService.getAccessibleModuleIds(tenantId, userId, role)` als einzige Auflösungsfunktion (D-01): ADMIN/SUPER_ADMIN-Kurzschluss vor jeder Grant-Query (D-03), sonst eine einzige verschachtelte Prisma-Query für Direkt- und Gruppen-Grants (kein N+1), geschnitten mit den mandantenweit aktiven Modulen (D-02)
- `ModuleGuard` liest `userId`/`role` jetzt zusätzlich zu `tenantId` aus `request.user` (JWT-Herkunft, nie Body/Params) und ruft `getAccessibleModuleIds` auf; `GET /modules/active` delegiert an `ModuleAccessService.findAccessibleModules` statt an die alte mandantenweite `findActiveForTenant` (die für Plan 15-03 unverändert erhalten bleibt)
- RLS aktiviert für alle drei neuen Tabellen (T-15-11), nach dem Muster der Auth-Kerntabellen (`User`, `LdapConfig`) statt der RLS-freien `Tender*`-Tabellen — als separate, eigenständig review-bare Migration
- End-to-End-Nachweis zweimal gegen die laufende lokale API geführt (vor und nach der RLS-Migration, identisches Ergebnis): USER ohne Grant → 403 auf einem `@UseModule`-geschützten Endpoint plus leere `GET /modules/active`-Liste; derselbe USER mit einem Direkt-Grant → 200 plus Slug in der Liste; ADMIN ohne jeden Grant → 200 plus Slug in der Liste
## Task Commits
Jeder Task wurde atomar committet:
1. **Task 1: Schema-Sync — Group/GroupMembership/ModuleGrant plus D-06-Bestandsübernahme** - `c5c704b` (feat)
2. **Task 2: Zugriffsauflösung end-to-end — ModuleAccessService, ModuleGuard, GET /modules/active** - `9a4ba8a` (feat)
3. **Task 3: RLS-Policies für die drei neuen Tabellen und erneuter Tracer-Nachweis** - `92e8eaf` (feat)
**Plan metadata:** siehe Commit dieser SUMMARY.md (docs: complete plan)
## Files Created/Modified
- `apps/api/prisma/schema.prisma` - `MembershipSource`-Enum plus `Group`/`GroupMembership`/`ModuleGrant`-Modelle, Gegenrelationen an `Tenant`/`User`/`Module`
- `apps/api/prisma/migrations/20260804130130_add_groups_and_module_grants/migration.sql` - generierte `CREATE TABLE`-Statements plus hand-editierte Invarianten und D-06-Backfill
- `apps/api/prisma/migrations/20260804130918_groups_rls_policies/migration.sql` - RLS ENABLE/FORCE plus `tenant_isolation_policy` für alle drei Tabellen
- `apps/api/src/module-registry/module-access.service.ts` - `ModuleAccessService` (neu), Single Source of Truth für Modulzugriff
- `apps/api/src/module-registry/module.guard.ts` - erweitert um Benutzer-Dimension (userId/role aus JWT, `getAccessibleModuleIds`-Aufruf, Per-Request-Memoisierung)
- `apps/api/src/module-registry/module-registry.controller.ts` - `findActive` nutzt jetzt `ModuleAccessService.findAccessibleModules`
- `apps/api/src/module-registry/module-registry.module.ts` - `ModuleAccessService` in providers/exports
- `apps/api/src/module-registry/module-access.service.spec.ts` - 13 Tests, deckt jeden `<behavior>`-Fall aus dem Plan ab
- `apps/api/src/module-registry/module.guard.spec.ts` - 7 Tests, deckt jeden Guard-`<behavior>`-Fall ab
- `apps/api/src/groups/migration-sql.spec.ts` - 10 Tests, prüft beide Migrationsdateien per Textabgleich ohne DB
## Decisions Made
- Checkpoint bestätigt: D-01, D-05, D-06 (one-way) sowie D-02 (costly) wie in `15-CONTEXT.md` gesperrt umgesetzt — keine inhaltliche Abweichung
- RLS für die drei neuen Tabellen aktiviert (T-15-11-Empfehlung aus `15-RESEARCH.md` Pitfall 4 übernommen), weil sie wie `LdapConfig` unmittelbar Zugriff steuern, nicht wie `Tender*` reine App-Layer-Daten sind
- `getAccessibleModuleIds` kürzt die zweite Query ab, wenn `grantedIds` leer ist (kein `moduleId: { in: [] }`-Query gegen `tenantModuleActivation`) — reine Performance-Optimierung ohne Verhaltensänderung, durch Tests abgedeckt (`empty`-Fall)
## Deviations from Plan
None - plan executed exactly as written.
## Issues Encountered
- Der `db`-Container war zu Beginn gestoppt (`Exited`); `docker compose up -d db` startete ihn neu mit demselben `pgdata`-Volume, keine Datenverluste. `psql` war lokal nicht installiert — Verifikationsabfragen liefen stattdessen über `docker exec tessera-ctl-db-1 psql ...`
- Task 3 verlangte, den End-to-End-Nachweis erneut zu führen und bei Abweichung als Blocker zu melden statt die Policies stillschweigend zu entfernen: die Datenbankrolle `tessera` ist ein Postgres-Superuser mit `rolbypassrls=true` und umgeht RLS strukturell — der Nachweis lief nach der RLS-Migration identisch zu Task 2 durch, kein Blocker
## User Setup Required
None - keine externe Service-Konfiguration nötig.
## Next Phase Readiness
- `ModuleAccessService` ist exportiert und bereit für Plan 15-02 (Gruppen-CRUD, Standardgruppen-Mitgliedschaft in `UserService.create`), Plan 15-03 (Grant-CRUD, Marketplace-Katalog über die unveränderte `findActiveForTenant`) und Plan 15-05 (Dashboard-Widget-Filterung)
- Kein Bestandsbenutzer verliert Zugriff: die Migration wurde gegen die lokale DB angewendet und der Bestand (1 Tenant, 1 User, 1 aktive Aktivierung) korrekt in Standardgruppe/Mitgliedschaft/Grant übernommen
- Kein offener Blocker aus diesem Plan
---
*Phase: 15-modul-berechtigungen-gruppen-user-grants*
*Completed: 2026-08-04*