Files
tessera-ctl/.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-02-SUMMARY.md
T

179 lines
13 KiB
Markdown

---
phase: 15-modul-berechtigungen-gruppen-user-grants
plan: 02
subsystem: auth
tags: [nestjs, prisma, groups, rbac, vitest]
# Dependency graph
requires:
- phase: 15-modul-berechtigungen-gruppen-user-grants
plan: 01
provides: Group/GroupMembership/ModuleGrant-Schema mit DB-erzwungener Entweder-oder- und Ein-Default-Invariante, RLS auf allen drei Tabellen
provides:
- GroupsService/GroupsController — vollständiges CRUD für Gruppen und Mitgliedschaften eines Mandanten (PERM-01)
- GroupsService.getImpact — Zahlenmaterial ({ memberCount, grantCount }) für den Löschdialog (D-17), konsumiert von Plan 15-06
- GroupsService.addUserToDefaultGroup — einziger Codepfad für die automatische Standardgruppen-Mitgliedschaft, exportiert für UserModule
- UserService.create erweitert um die Standardgruppen-Zuordnung (D-11/D-12) — erbt an LdapService.upsertMappedUser/importUsersByDn ohne eigene Kopie der Regel
affects: [15-03, 15-04, 15-06]
actuals:
tokens: 10100
tasks: 2
commits: 2
tech-stack:
added: []
patterns:
- "Ownership-Check-Muster aus DashboardService.removeWidget auf Gruppen-IDs übertragen: private findOwned(tenantId, id) vor jeder Mutation/jedem Lookup, NotFoundException statt eines Fremdmandanten-Treffers"
- "P2002/P2025-Fehlercode-Übersetzung im Service (ConflictException/NotFoundException) statt unbehandelter Prisma-Fehler bis zum Controller durchzureichen"
- "Hand-rolled In-Memory-Prisma-Fake im Stil von tender-saved-search.service.spec.ts statt eines Mocking-Frameworks — auch für $transaction (Promise.all über bereits erstellte Promises)"
key-files:
created:
- apps/api/src/groups/groups.module.ts
- apps/api/src/groups/groups.service.ts
- apps/api/src/groups/groups.controller.ts
- apps/api/src/groups/groups.service.spec.ts
- apps/api/src/groups/dto/create-group.dto.ts
- apps/api/src/groups/dto/update-group.dto.ts
- apps/api/src/groups/dto/add-group-members.dto.ts
- apps/api/src/user/user.service.spec.ts
modified:
- apps/api/src/app.module.ts
- apps/api/src/user/user.service.ts
- apps/api/src/user/user.module.ts
key-decisions:
- "isDefault:true läuft in this.prisma.$transaction([updateMany, update]) statt zwei sequenziellen Calls — der partielle Unique-Index aus 15-01 bleibt das Sicherheitsnetz, die Transaktion der normale Pfad (D-13, wie im Plan vorgegeben)"
- "remove() fängt zusätzlich P2025 (Record-not-found) ab und übersetzt es in NotFoundException — nicht explizit im Plan-Action-Text benannt, aber notwendig für die im must_haves-Block geforderte Concurrency-Eigenschaft (zwei DELETE auf dieselbe ID erzeugen kein HTTP 500)"
- "GroupsService.listMembers als zusätzliche Methode ergänzt (nicht im <behavior>-Block des Plans einzeln aufgeführt, aber in den Artefakten/Routen gefordert: GET /groups/:id/members) — Rule 2, notwendig für die vollständige CRUD-Oberfläche"
requirements-completed: [PERM-01, PERM-06]
coverage:
- id: D1
description: "GroupsService/GroupsController: vollständiges CRUD für Gruppen (create/update/remove/list), Mitgliederverwaltung (addMembers/removeMember/listMembers) und Löschauswirkung (getImpact), jede Lookup-Query tenantId-gescoped"
requirement: "PERM-01"
verification:
- kind: unit
ref: "apps/api/src/groups/groups.service.spec.ts (19 Tests, deckt jeden <behavior>-Fall inkl. adjacency/empty/encoding/idempotency)"
status: pass
- kind: e2e
ref: "curl gegen laufende lokale API: POST /groups mit vergebenem Namen -> 409; GET /groups/:id/impact -> {memberCount,grantCount}; DELETE /groups/:id einer echten Fremdmandanten-Gruppe -> 404, Gruppe unangetastet in der DB"
status: pass
human_judgment: false
- id: D2
description: "addUserToDefaultGroup + UserService.create-Erweiterung: jeder neu angelegte Benutzer wird automatisch Mitglied der markierten Standardgruppe seines Mandanten, an genau einer Codestelle"
requirement: "PERM-06"
verification:
- kind: unit
ref: "apps/api/src/groups/groups.service.spec.ts (addUserToDefaultGroup-Fälle) + apps/api/src/user/user.service.spec.ts (4 Tests)"
status: pass
- kind: e2e
ref: "curl gegen laufende lokale API: Gruppe als Standard markiert, POST /users angelegt, GET /groups/:defaultGroupId/members zeigt den neuen Benutzer direkt danach"
status: pass
human_judgment: false
- id: D3
description: "Cascade-Löschung: DELETE /groups/:id entfernt GroupMembership-Zeilen ohne anwendungsseitiges Aufräumen (onDelete:Cascade aus 15-01)"
requirement: "PERM-01"
verification:
- kind: e2e
ref: "curl + psql gegen laufende lokale API: Gruppe mit 2 Mitgliedern gelöscht, anschließend SELECT count(*) FROM GroupMembership WHERE groupId=... liefert 0"
status: pass
human_judgment: false
duration: 11min
completed: 2026-08-04
status: complete
---
# Phase 15 Plan 02: Gruppenverwaltung & Standardgruppen-Mitgliedschaft Summary
**GroupsModule mit vollständigem Gruppen-CRUD, Mitgliederverwaltung und Löschauswirkungs-Zahlenmaterial (D-17), plus die Erweiterung von `UserService.create` um die automatische Standardgruppen-Mitgliedschaft (D-11/D-12) an genau einer Codestelle — beide Teile end-to-end gegen die laufende lokale API bewiesen.**
## Performance
- **Duration:** 11 min
- **Started:** 2026-08-04T13:15:00Z
- **Completed:** 2026-08-04T13:25:32Z
- **Tasks:** 2
- **Files modified:** 11
## Accomplishments
- `GroupsService` mit `listForTenant`, `create`, `update`, `remove`, `getImpact`, `listMembers`, `addMembers`, `removeMember`, `addUserToDefaultGroup` — jede Methode mit Gruppen-ID filtert zusätzlich auf `tenantId` (Ownership-Check-Muster aus `DashboardService.removeWidget`), RLS aus 15-01 ist das zweite Netz, nicht der primäre Schutz
- `update` mit `isDefault:true` läuft in `this.prisma.$transaction([updateMany, update])`: erst alle Gruppen des Mandanten auf `isDefault:false`, dann die Zielgruppe auf `true` — der partielle Unique-Index `Group_one_default_per_tenant` aus 15-01 bleibt das Sicherheitsnetz gegen parallele Aufrufe
- `getImpact(tenantId, id)` liefert `{ memberCount, grantCount }` über zwei parallele `count`-Queries — das Zahlenmaterial, das der Löschdialog aus D-17 in Plan 15-06 direkt anzeigt
- `remove` löscht ausschließlich die Gruppenzeile; Mitgliedschaften und Grants verschwinden über die in 15-01 definierten `onDelete:Cascade`-Regeln, per `psql`-Nachweis bestätigt (0 verwaiste `GroupMembership`-Zeilen nach dem Löschen)
- `addMembers`/`removeMember` setzen D-19 um: `addMembers` verifiziert jede `userId` gegen die `tenantId` des Aufrufers und überspringt fremde IDs (T-15-12), `removeMember` löscht ausschließlich `source: MANUAL`-Mitgliedschaften und lässt `LDAP`-Mitgliedschaften unberührt
- `GroupsController` bildet acht rollengeschützte Routen unter `/groups` ab, `tenantId` ausschließlich aus `req.tenantId ?? req.user?.tenantId` (nie aus Body/Params, T-03-04)
- `UserService.create` ruft nach `prisma.user.create(...)` genau einmal `GroupsService.addUserToDefaultGroup(created.tenantId, created.id)` in try/catch mit `Logger` auf — dies ist die EINZIGE Stelle im Backend, an der D-11/D-12 steht; `LdapService.upsertMappedUser` und `importUsersByDn` erben die Regel unverändert über den bestehenden `userService.create`-Aufruf (`git diff --stat` bestätigt: `ldap.service.ts` in diesem Task nicht verändert)
- End-to-End-Nachweis gegen die laufende lokale API: Gruppe angelegt, Duplikatname → 409, Mitglied hinzugefügt, `impact` → `{memberCount:1, grantCount:0}`, echte Fremdmandanten-Gruppen-ID (separater Test-Tenant, per `psql` angelegt) auf `DELETE /groups/:id` → 404 und in der DB unangetastet, Gruppe als Standard markiert, `POST /users` angelegt → Benutzer erscheint direkt danach in `GET /groups/:defaultGroupId/members`, Gruppe gelöscht → 0 verwaiste `GroupMembership`-Zeilen
## Task Commits
Jeder Task wurde atomar committet:
1. **Task 1: GroupsModule — CRUD für Gruppen, Mitgliedschaften und Löschauswirkung** - `69494d7` (feat)
2. **Task 2: Automatische Standardgruppen-Mitgliedschaft an genau einem Ort** - `33838dd` (feat)
**Plan metadata:** siehe Commit dieser SUMMARY.md (docs: complete plan)
## Files Created/Modified
- `apps/api/src/groups/groups.module.ts` - `GroupsModule`, exportiert `GroupsService` für `UserModule`
- `apps/api/src/groups/groups.service.ts` - `GroupsService` (neu), CRUD/Mitgliederverwaltung/Standardgruppen-Zuordnung
- `apps/api/src/groups/groups.controller.ts` - `GroupsController` (neu), acht rollengeschützte `/groups`-Routen
- `apps/api/src/groups/dto/create-group.dto.ts`, `update-group.dto.ts`, `add-group-members.dto.ts` - class-validator-DTOs
- `apps/api/src/groups/groups.service.spec.ts` - 19 Tests, hand-rolled In-Memory-Prisma-Fake
- `apps/api/src/app.module.ts` - `GroupsModule` in der Import-Liste nach `ModuleRegistryModule`
- `apps/api/src/user/user.service.ts` - `create()` erweitert um `addUserToDefaultGroup`-Aufruf in try/catch mit Logger
- `apps/api/src/user/user.service.spec.ts` - 4 Tests, gemockter `PrismaService` + `GroupsService`
- `apps/api/src/user/user.module.ts` - Import von `GroupsModule` ergänzt
## Decisions Made
- `isDefault:true` läuft in `this.prisma.$transaction([updateMany, update])` — wie im Plan vorgegeben, kein Abweichen
- `remove()` fängt zusätzlich Prisma-Fehlercode `P2025` (Record-not-found) ab und übersetzt ihn in `NotFoundException` — im Plan-Action-Text nicht wörtlich benannt, aber notwendig, damit ein zweiter `remove()`-Aufruf auf dieselbe (bereits gelöschte) ID nie einen unbehandelten 500er erzeugt, wie es der `must_haves`-Block unter „concurrency/PERM-01" explizit fordert
- `listMembers` als zusätzliche Service-Methode ergänzt — nicht einzeln im `<behavior>`-Block aufgeführt, aber Voraussetzung für die im Plan gelistete Route `GET /groups/:id/members` und damit Teil der geforderten vollständigen CRUD-Oberfläche (Rule 2: fehlende, aber für Korrektheit/Vollständigkeit nötige Funktionalität)
## Deviations from Plan
**1. [Rule 2 - fehlende Funktionalität] `remove()` fängt P2025 zusätzlich ab**
- **Found during:** Task 1, beim Schreiben des Concurrency-Tests aus dem `must_haves`-Block
- **Issue:** Der reine `findOwned`-Vorab-Check schützt nicht gegen den Fall, dass die Gruppe zwischen dem Lookup und dem `delete`-Call bereits von einem parallelen Request gelöscht wurde — dann würde `prisma.group.delete` einen unbehandelten `P2025`-Fehler werfen statt der geforderten `NotFoundException`
- **Fix:** `try/catch` um `prisma.group.delete`, `P2025` → `NotFoundException`
- **Files modified:** `apps/api/src/groups/groups.service.ts`
- **Commit:** `69494d7`
**2. [Rule 2 - fehlende Funktionalität] `listMembers`-Methode ergänzt**
- **Found during:** Task 1, beim Umsetzen der im Plan gelisteten Route `GET /groups/:id/members`
- **Issue:** Der `<behavior>`-Block listet die Methode nicht einzeln auf, aber die Route ist Teil der geforderten CRUD-Oberfläche und ohne sie nicht implementierbar
- **Fix:** `listMembers(tenantId, id)` ergänzt, mandantengescoped wie jede andere Methode
- **Files modified:** `apps/api/src/groups/groups.service.ts`
- **Commit:** `69494d7`
## Issues Encountered
- Kein API-Container im Docker-Compose-Stack lief zu Beginn (nur `tessera-ctl-db-1`); der End-to-End-Nachweis lief über `pnpm start:dev` mit `DATABASE_URL` gegen die Container-IP der DB (`172.19.0.2`, `tessera:tessera_dev`, wie in `project_local_db_migrations` dokumentiert), Port fest auf 3001 in `main.ts` (der `PORT`-Env-Var-Versuch wurde ignoriert). Kein Deploy auf den Testserver, ausschließlich lokal, wie in den Constraints gefordert.
- Für den Fremdmandanten-Nachweis von `DELETE /groups/:id` wurde per `psql` ein zweiter, isolierter Test-Tenant samt Gruppe angelegt und nach dem Test wieder gelöscht — kein Nachweis über eine erratene/zufällige ID, sondern über eine real existierende Gruppe eines anderen Mandanten.
## User Setup Required
None - keine externe Service-Konfiguration nötig.
## Next Phase Readiness
- `GroupsService.getImpact` ist bereit für den Löschdialog in Plan 15-06 (D-17: konkrete Zahlen statt allgemeiner Warnung)
- `GroupsModule` exportiert `GroupsService`, bereit für Plan 15-03 (Grant-CRUD gegen Gruppen) und Plan 15-04 (AD-Bindung/Sync-Erweiterung von `Group.ldapDn`)
- Kein Bestandsverhalten gebrochen: volle API-Testsuite (449/449) und `ldap.service.ts` unverändert (`git diff --stat` bestätigt 0 Zeilen Diff für diesen Task)
- 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, beide Commit-Hashes (`69494d7`, `33838dd`) sind im Git-Log auffindbar.