Files

181 lines
15 KiB
Markdown

---
phase: 15-modul-berechtigungen-gruppen-user-grants
plan: 03
subsystem: auth
tags: [nestjs, prisma, module-access, groups, grants, vitest]
# Dependency graph
requires:
- phase: 15-modul-berechtigungen-gruppen-user-grants
plan: 01
provides: Group/GroupMembership/ModuleGrant-Schema, ModuleAccessService.getAccessibleModuleIds (D-01) als Single Source of Truth
- phase: 15-modul-berechtigungen-gruppen-user-grants
plan: 02
provides: GroupsModule/GroupsService (CRUD, Ownership-Check-Muster), an das dieser Plan Controller und Service anhängt
provides:
- ModuleGrantsService.grant/revoke — Schreibseite der Modul-Freigaben (PERM-03), mit assertTargetBelongsToTenant als Cross-Tenant-Grant-Injection-Schutz vor jedem Insert
- ModuleGrantsService.getMatrix — Datenlieferung für die Freigabe-Matrix-Seite (D-15), konsumiert von Plan 15-06/15-07
- ModuleGrantsService.getUserAccess — Datenlieferung für das Benutzer-Detail (D-16), zeigt geerbte Gruppen + Direkt-Grant
- ModuleGrantsController — vier rollengeschützte /module-grants-Routen
- ModuleAccessService.getCatalogFlags + GET /modules/catalog — beide Statusflags (isActiveForTenant, hasAccess) in einer Antwort für den Marketplace (D-08), konsumiert von Plan 15-08
affects: [15-06, 15-07, 15-08]
actuals:
tokens: 9438
tasks: 3
commits: 3
tech-stack:
added: []
patterns:
- "assertTargetBelongsToTenant als eigenständige Cross-Tenant-Prüfung eines REFERENZIERTEN Fremdobjekts (nicht nur direktes Eigentum wie DashboardService.removeWidget) — kein Vorbild im Bestandscode, neu etabliert für Grant-Ziele"
- "P2002 als Erfolgspfad: create() wirft, catch fängt den Fehlercode ab und liefert per findFirst den bestehenden Datensatz zurück statt eines HTTP 500 — Fortführung des in GroupsService.create etablierten Verfahrens"
- "getCatalogFlags liefert eine Map nur für aktive Module; ein fehlender Eintrag wird im Controller auf beide Flags false gemappt, statt die Map für jedes registrierte Modul vorzubefüllen"
key-files:
created:
- apps/api/src/groups/module-grants.service.ts
- apps/api/src/groups/module-grants.service.spec.ts
- apps/api/src/groups/module-grants.controller.ts
- apps/api/src/groups/dto/create-module-grant.dto.ts
modified:
- apps/api/src/groups/groups.module.ts
- 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-registry.controller.ts
key-decisions:
- "Kein Import von ModuleRegistryModule in GroupsModule — ModuleGrantsService injiziert ausschließlich PrismaService, die im Plan-Action-Text als 'falls benötigt' formulierte Ergänzung war nicht nötig"
- "grant prüft die aktive TenantModuleActivation ausschließlich über tenantModuleActivation.findUnique (kein zusätzlicher module.findUnique-Existenzcheck) — eine nicht existierende moduleId fällt bereits über die fehlende Aktivierung in dieselbe BadRequestException, ein zweiter Query wäre redundant"
requirements-completed: [PERM-03, PERM-04]
coverage:
- id: D1
description: "ModuleGrantsService.grant/revoke mit assertTargetBelongsToTenant vor jedem Insert (T-15-01), Entweder-oder-Regel (D-04), aktive TenantModuleActivation als Voraussetzung (D-02), P2002 als Erfolg"
requirement: "PERM-03"
verification:
- kind: unit
ref: "apps/api/src/groups/module-grants.service.spec.ts (20 Tests, deckt jeden <behavior>-Fall inkl. adjacency/empty/ordering/idempotency/concurrency)"
status: pass
- kind: e2e
ref: "curl gegen laufende lokale API: POST /module-grants mit groupId eines fremden Test-Mandanten -> 404, Datensatz unangetastet; Gruppen-Grant gesetzt -> GET /modules/tender-radar als Gruppenmitglied 200; Grant entzogen -> derselbe Aufruf sofort 403 (D-09, ohne Zwischenschritt)"
status: pass
human_judgment: false
- id: D2
description: "getMatrix (D-15) und getUserAccess (D-16) liefern Freigabe-Matrix- und Benutzer-Detail-Daten in je einer Antwort, sortiert und mandantengescoped"
requirement: "PERM-03"
verification:
- kind: unit
ref: "apps/api/src/groups/module-grants.service.spec.ts — getMatrix/getUserAccess-Testgruppen"
status: pass
- kind: e2e
ref: "curl gegen laufende lokale API: GET /module-grants/matrix als ADMIN -> 200 mit modules/groups/grants, als USER -> 403; GET /module-grants/users/:userId zeigt viaGroups=['Alle Benutzer'] + direct:false für den Testbenutzer"
status: pass
human_judgment: false
- id: D3
description: "ModuleAccessService.getCatalogFlags + GET /modules/catalog liefert isActiveForTenant und hasAccess in einer Antwort (D-08), ADMIN/SUPER_ADMIN sehen hasAccess immer wahr für aktive Module (D-03)"
requirement: "PERM-04"
verification:
- kind: unit
ref: "apps/api/src/module-registry/module-access.service.spec.ts (4 neue getCatalogFlags-Tests)"
status: pass
- kind: e2e
ref: "curl gegen laufende lokale API: GET /modules/catalog als USER ohne Gruppen-Mitgliedschaft zeigt tender-radar mit isActiveForTenant:true, hasAccess:false; derselbe Aufruf als ADMIN zeigt beide Flags true"
status: pass
human_judgment: false
duration: 32min
completed: 2026-08-04
status: complete
---
# Phase 15 Plan 03: Modul-Freigaben — Schreibseite, Matrix, Benutzer-Detail, Katalog Summary
**`ModuleGrantsService`/`ModuleGrantsController` für Gruppen- und Direkt-Grants mit einer eigens etablierten Cross-Tenant-Gegenprüfung vor jedem Insert (`assertTargetBelongsToTenant`, T-15-01), die Datenlieferung für Freigabe-Matrix (D-15) und Benutzer-Detail (D-16), und `GET /modules/catalog`, das dem Marketplace beide Statusflags (`isActiveForTenant`, `hasAccess`) in einer Antwort liefert (D-08) — alles end-to-end gegen die laufende lokale API bewiesen, inklusive des sofortigen Entzugs ohne Zwischenschritt (D-09).**
## Performance
- **Duration:** 32 min
- **Started:** 2026-08-04T16:28:00Z
- **Completed:** 2026-08-04T18:41:00Z (inkl. manueller E2E-Verifikation)
- **Tasks:** 3
- **Files modified:** 8
## Accomplishments
- `ModuleGrantsService` mit `grant`, `revoke`, `getMatrix`, `getUserAccess` und der privaten `assertTargetBelongsToTenant` — der zentrale Schutz gegen mandantenübergreifende Freigaben (T-15-01). Für dieses Muster gab es kein Vorbild im Bestandscode: bisherige Ownership-Checks (`DashboardService.removeWidget`, `GroupsService.findOwned`) prüfen nur direktes Eigentum, nicht eine zweite Mandantengrenze über eine referenzierte Relation
- `grant` prüft in fester Reihenfolge: Entweder-oder von `groupId`/`userId` (D-04, `BadRequestException` mit Klartext statt rohem Postgres-Constraint-Namen), dann die Mandanten-Gegenprüfung, dann die aktive `TenantModuleActivation` (D-02), dann `create`. Ein `P2002` aus dem partiellen Unique-Index (zwei parallele Klicks auf dieselbe Matrix-Zelle) wird abgefangen und liefert per `findFirst` den bestehenden Datensatz zurück — kein HTTP 500 bei Doppelklick oder echter Nebenläufigkeit
- `revoke` nutzt `deleteMany` mit `tenantId` im `where` als IDOR-Schutz (T-15-02) — ein Ziel eines fremden Mandanten trifft null Zeilen, kein vorheriger Lookup nötig
- `getMatrix(tenantId)` liefert `{ modules, groups, grants }` in einer Antwort: aktive Module nach category+name, Gruppen nach name, Gruppen-Grants als Paare aus moduleId/groupId — deterministisch über wiederholte Aufrufe
- `getUserAccess(tenantId, userId)` liefert je aktivem Modul die Namen der Gruppen, über die der Benutzer erbt, und ein Direkt-Grant-Kennzeichen — Gruppen-Grant und Direkt-Grant auf dasselbe Modul erscheinen gleichzeitig, keiner verdrängt den anderen (Union statt Vorrang)
- `ModuleGrantsController` bildet vier rollengeschützte Routen unter `/module-grants` ab (`GET matrix`, `GET users/:userId`, `POST`, `DELETE`); `matrix` ist vor `users/:userId` deklariert (Beschattungsfehler-Vermeidung, Projekt hatte diesen Fehler schon einmal)
- `ModuleAccessService.getCatalogFlags(tenantId, userId, role)` und `ModuleRegistryController.findCatalog` (`GET /modules/catalog`) liefern je registriertem Modul beide Statusflags in einer Antwort (D-08) — für ADMIN/SUPER_ADMIN ist `hasAccess` bei jedem aktiven Modul wahr (D-03), weil `getAccessibleModuleIds` den Rollen-Kurzschluss anwendet
- Jede erfolgreiche `grant`-/`revoke`-Operation schreibt eine Logzeile über `this.logger` (D-23) — keine Audit-Tabelle, keine Ansicht im Admin-UI
- D-04 vollständig eingehalten: der Datensatz trägt kein Rechtestufen-Feld, und der Service bietet keine Methode, die eines setzen könnte
- End-to-End-Nachweis gegen die laufende lokale API (`dist/main` gegen die DB-Container-IP, `DATABASE_URL`/`JWT_SECRET` wie in vorherigen Plänen dieser Phase): `GET /module-grants/matrix` als ADMIN → 200 mit `modules`/`groups`/`grants`, als USER → 403; `POST /module-grants` mit `groupId` eines eigens angelegten Test-Fremdmandanten → 404, kein Datensatz angelegt; `GET /modules/catalog` als USER (ohne Gruppen-Mitgliedschaft) zeigt `tender-radar` mit `isActiveForTenant:true, hasAccess:false`, derselbe Aufruf als ADMIN zeigt beide Flags `true`; Testbenutzer der Gruppe „Alle Benutzer“ hinzugefügt → `GET /modules/tender-radar` 200; Grant entzogen → derselbe Aufruf sofort 403 ohne Zwischenschritt (D-09); `GET /module-grants/users/:userId` zeigt `viaGroups:['Alle Benutzer'], direct:false`
## Task Commits
Jeder Task wurde atomar committet:
1. **Task 1: ModuleGrantsService — Freigaben setzen und entziehen mit Mandanten-Gegenprüfung** - `5e256db` (feat)
2. **Task 2: ModuleGrantsController und Einbindung in GroupsModule** - `072fb7f` (feat)
3. **Task 3: GET /modules/catalog — beide Statusflags in einer Antwort für den Marketplace** - `1c32543` (feat)
**Plan metadata:** siehe Commit dieser SUMMARY.md (docs: complete plan)
## Files Created/Modified
- `apps/api/src/groups/module-grants.service.ts` - `ModuleGrantsService` (neu): grant/revoke/getMatrix/getUserAccess/assertTargetBelongsToTenant
- `apps/api/src/groups/module-grants.service.spec.ts` - 20 Tests, hand-rolled In-Memory-Prisma-Fake (Projekt-Konvention)
- `apps/api/src/groups/dto/create-module-grant.dto.ts` - `CreateModuleGrantDto` (moduleId required, groupId/userId optional)
- `apps/api/src/groups/module-grants.controller.ts` - `ModuleGrantsController` (neu), vier rollengeschützte `/module-grants`-Routen
- `apps/api/src/groups/groups.module.ts` - `ModuleGrantsController`/`ModuleGrantsService` in controllers/providers/exports ergänzt
- `apps/api/src/module-registry/module-access.service.ts` - `getCatalogFlags` ergänzt
- `apps/api/src/module-registry/module-access.service.spec.ts` - 4 neue `getCatalogFlags`-Tests
- `apps/api/src/module-registry/module-registry.controller.ts` - `findCatalog`-Handler (`GET /modules/catalog`) ergänzt
## Decisions Made
- Kein Import von `ModuleRegistryModule` in `GroupsModule` — der Plan-Action-Text formulierte dies als „falls der Service dessen Registry-Methoden benötigt“; `ModuleGrantsService` injiziert ausschließlich `PrismaService`, der Import wäre unbenutzte Kopplung gewesen
- `grant` prüft die Modul-Aktivierung ausschließlich über `tenantModuleActivation.findUnique`, ohne separaten `module.findUnique`-Existenzcheck — eine nicht existierende `moduleId` landet bereits über die fehlende Aktivierung in derselben `BadRequestException`, ein zweiter Query wäre redundant gewesen und ist im `<behavior>`-Block auch nicht gefordert
## Deviations from Plan
**1. [Sonstiges — Akzeptanzkriterium-Diskrepanz, keine Rule 1-4] DTO-Grep-Kriterium zählt 2 statt 3**
- **Found during:** Task 1, beim Verifizieren der Akzeptanzkriterien nach dem Schreiben von `create-module-grant.dto.ts`
- **Issue:** Das Akzeptanzkriterium `grep -cE '(moduleId|groupId|userId)[?]?: string' ... gibt 3 aus` erwartet implizit ein Pflichtfeld ohne Definite-Assignment-Assertion (`moduleId: string`). Das Projekt erzwingt aber `strict: true` (inkl. `strictPropertyInitialization`) in `tsconfig.base.json`, und jede bestehende DTO im Projekt (`CreateGroupDto`, `AddGroupMembersDto`, `CreateWidgetDto` u. a.) schreibt Pflichtfelder deshalb als `feld!: string`. Mit `moduleId!: string;` liefert der Grep nur 2 Treffer (`groupId?:`, `userId?:`), weil das `!` zwischen Feldname und `:` steht und die Regex `[?]?` nur ein optionales `?` erlaubt
- **Warum keine Codeänderung:** `moduleId: string;` ohne `!` bricht `tsc --noEmit` mit `TS2564: Property 'moduleId' has no initializer` — das zweite, härtere Akzeptanzkriterium desselben Tasks. Die beiden Kriterien stehen im Widerspruch; die Projekt-Konvention (`!` für Pflichtfelder) und ein sauberer `type-check` wiegen schwerer als der wörtliche Grep-Zähler eines einzelnen Akzeptanzkriteriums
- **Verifiziert stattdessen:** `pnpm --filter @tessera/api run type-check` läuft fehlerfrei, alle 20 Service-Tests inkl. der Entweder-oder-/XOR-Fälle sind grün, und `grep -cE '^\s+[a-zA-Z]+[?]?:' ...` zeigt weiterhin genau die drei erwarteten Feldnamen (kein viertes Feld)
- **Files modified:** `apps/api/src/groups/dto/create-module-grant.dto.ts`
- **Commit:** `5e256db`
## Issues Encountered
- Für den End-to-End-Nachweis lief zunächst ein veralteter `dist/main`-Prozess aus einer vorherigen Sitzung (Build-Stand von 15-05, 15:36 Uhr) auf Port 3001 — beendet, `pnpm --filter @tessera/api build` neu ausgeführt, mit denselben `DATABASE_URL`/`JWT_SECRET`-Werten wie die vorherige Sitzung (Container-IP `172.19.0.2`, `tessera:tessera_dev`) neu gestartet
- Für den Fremdmandanten-Nachweis (T-15-01) wurde per `psql` ein zweiter, isolierter Test-Tenant samt Gruppe und Benutzer angelegt (analog zum in 15-02 etablierten Verfahren) — nach dem Nachweis vollständig wieder aus der DB entfernt
- Für den D-09-Nachweis (sofortiger Entzug) wurde vorübergehend der einzige produktionsnahe Gruppen-Grant (aus dem D-06-Backfill: „Alle Benutzer“ → `tender-radar`) entzogen und danach exakt wiederhergestellt — die lokale DB enthält nach diesem Plan denselben `ModuleGrant`-Datensatz wie zuvor (neue `id`, identische `tenantId`/`moduleId`/`groupId`)
- Zwei Testbenutzer (`test-admin-1503`, `test-user-1503`) und ein Test-Fremdmandant (`test-foreign-1503` samt Tenant/Gruppe) wurden ausschließlich für die Dauer der Verifikation angelegt und danach vollständig entfernt — `SELECT * FROM "User"`/`"Tenant"` zeigt nach dem Aufräumen wieder exakt den Ausgangsstand (1 Benutzer, 1 Mandant)
- `isDefault` der Gruppe „Alle Benutzer“ war bereits vor diesem Plan `false` (siehe `local_db_note` im Auftrag) — dieser Plan hat daran nichts geändert und war davon nicht betroffen, da `grant`/`revoke`/`getMatrix`/`getUserAccess` nicht auf `isDefault` zugreifen
## User Setup Required
None - keine externe Service-Konfiguration nötig.
## Next Phase Readiness
- `ModuleGrantsService.getImpact`-Äquivalent für Freigaben existiert nicht in diesem Plan (nicht gefordert) — Plan 15-06/15-07 bauen die Admin-Oberflächen (Matrix-Seite, Benutzer-Detail) direkt auf `getMatrix`/`getUserAccess`/`grant`/`revoke` auf
- `GET /modules/catalog` ist bereit für `MarketplaceCard` (Plan 15-08) — beide Flags kommen in einer Antwort, keine Karte kann kurzzeitig ohne Sperrhinweis klickbar erscheinen
- Kein Bestandsverhalten gebrochen: volle API-Testsuite (493/493) grün, `type-check` fehlerfrei
- Kein offener Blocker aus diesem Plan
---
*Phase: 15-modul-berechtigungen-gruppen-user-grants*
*Completed: 2026-08-04*
## Self-Check: PASSED
Alle in dieser SUMMARY genannten Dateien existieren auf der Platte, alle genannten Commit-Hashes (`5e256db`, `072fb7f`, `1c32543`) sind im Git-Log auffindbar.