diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 42993c6..22a4879 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -63,7 +63,7 @@ ### PERM — Modul-Berechtigungen -- [ ] **PERM-01**: Admin verwaltet Gruppen pro Mandant (anlegen, umbenennen, löschen) und weist Benutzer manuell zu oder entfernt sie. Beim Löschen einer belegten Gruppe warnt ein Dialog mit Mitglieder- und Freigabenanzahl, bevor Mitgliedschaften und Freigaben mitgelöscht werden. +- [x] **PERM-01**: Admin verwaltet Gruppen pro Mandant (anlegen, umbenennen, löschen) und weist Benutzer manuell zu oder entfernt sie. Beim Löschen einer belegten Gruppe warnt ein Dialog mit Mitglieder- und Freigabenanzahl, bevor Mitgliedschaften und Freigaben mitgelöscht werden. - [ ] **PERM-02**: Eine Gruppe kann optional an einen AD-Gruppen-DN gebunden werden, ausgewählt aus der bestehenden LDAP-Gruppensuche. Der Benutzer-Sync liest `memberOf` mit und pflegt daraus die Mitgliedschaften; verlässt ein Benutzer die AD-Gruppe, fällt nur seine LDAP-Mitgliedschaft weg — manuell gesetzte bleiben bestehen. - [ ] **PERM-03**: Admin gibt ein mandantenweit aktives Modul gezielt für Gruppen und für einzelne Benutzer frei und entzieht Freigaben wieder. Gruppenfreigaben werden in einer Matrix Module × Gruppen gepflegt, Einzelfreigaben im Benutzer-Detail samt Anzeige der über Gruppen geerbten Rechte. - [x] **PERM-04**: Ohne Freigabe hat ein USER keinen Zugriff: das Modul fehlt in der Sidebar, die Modulseite antwortet mit einer 403-Seite samt Hinweis, und die Modul-API antwortet mit 403. Sidebar, Modulseite und API nutzen dieselbe Zugriffsauflösung. diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 7c2de32..16af5ff 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -511,7 +511,7 @@ Plans: **Neue Modelle**: `Group` (tenantId, name, ldapDn?), `GroupMembership` (userId, groupId, source MANUAL|LDAP), `ModuleGrant` (tenantId, moduleId, groupId? | userId?) -**Plans**: 1/8 plans executed +**Plans**: 2/8 plans executed Plans: **Wave 1** @@ -520,7 +520,7 @@ Plans: **Wave 2** *(blocked on Wave 1 completion)* -- [ ] 15-02-PLAN.md — Gruppen-API und automatische Standardgruppen-Mitgliedschaft +- [x] 15-02-PLAN.md — Gruppen-API und automatische Standardgruppen-Mitgliedschaft - [ ] 15-04-PLAN.md — AD-Gruppenbindung im bestehenden LDAP-Sync - [ ] 15-05-PLAN.md — Modulfilter für Dashboard-Widgets @@ -559,4 +559,4 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10 | 12. Tender Notifications | 4/4 | In Progress| | | 13. Scraping Adapters & Cross-Source Deduplication | 6/6 | In Progress| | | 14. RSS, Email-Alert Ingestion & Module Rollout | 5/5 | In Progress| | -| 15. Modul-Berechtigungen: Gruppen & User-Grants | 1/8 | In Progress| | +| 15. Modul-Berechtigungen: Gruppen & User-Grants | 2/8 | In Progress| | diff --git a/.planning/STATE.md b/.planning/STATE.md index ba9f6c3..79d2e67 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -5,15 +5,15 @@ milestone_name: Ausschreibungs-Radar current_phase: 15 current_phase_name: modul-berechtigungen-gruppen-user-grants status: executing -stopped_at: Completed 15-01-PLAN.md -last_updated: "2026-08-04T13:14:07.699Z" +stopped_at: Completed 15-02-PLAN.md +last_updated: "2026-08-04T13:27:34.942Z" last_activity: 2026-08-04 last_activity_desc: Phase 15 execution started progress: total_phases: 15 completed_phases: 13 total_plans: 75 - completed_plans: 67 + completed_plans: 68 --- # Project State @@ -28,11 +28,11 @@ See: .planning/PROJECT.md (updated 2026-07-17) ## Current Position Phase: 15 (modul-berechtigungen-gruppen-user-grants) — EXECUTING -Plan: 2 of 8 +Plan: 3 of 8 Status: Ready to execute Last activity: 2026-08-04 — Phase 15 execution started -Progress: [█████████░] 89% +Progress: [█████████░] 91% ## Performance Metrics @@ -101,6 +101,7 @@ Progress: [█████████░] 89% | Phase 14 P04 | 25min | 2 tasks | 6 files | | Phase 14 P05 | 50min | 3 tasks | 19 files | | Phase 15 P01 | 24min | 3 tasks | 10 files | +| Phase 15-modul-berechtigungen-gruppen-user-grants P02 | 11min | 2 tasks | 11 files | ## Accumulated Context @@ -232,6 +233,9 @@ Recent decisions affecting current work: - [Phase ?]: [260729-d3k]: syncUsersForTenant no-op guard re-keyed from empty groupFilterDns to empty parsed base-DN list — Base-DN(s) are now the sync scope, groupFilterDns is an optional extra restriction (ou= = extra bases, group DN = memberOf constraint) - [Phase ?]: [15-01]: D-01/D-05/D-06/D-02 wie in 15-CONTEXT.md gesperrt umgesetzt (Nutzer-Checkpoint mit 'proceed' bestaetigt) - [Phase ?]: [15-01]: RLS fuer Group/GroupMembership/ModuleGrant aktiviert (T-15-11) statt sie wie Tender* RLS-frei zu lassen +- [Phase ?]: [15-02]: isDefault:true läuft in this.prisma.$transaction([updateMany, update]); partieller Unique-Index aus 15-01 bleibt Sicherheitsnetz +- [Phase ?]: [15-02]: remove() fängt zusätzlich P2025 ab (NotFoundException statt unbehandeltem 500) — Rule 2, für Concurrency-Anforderung aus must_haves +- [Phase ?]: [15-02]: UserService.create ist die einzige Codestelle für D-11/D-12 — LdapService erbt die Regel ohne eigene Kopie (ldap.service.ts unverändert) ### Pending Todos @@ -273,7 +277,7 @@ Items acknowledged and carried forward from previous milestone close: ## Session Continuity -Last session: 2026-08-04T13:14:07.663Z -Stopped at: Completed 15-01-PLAN.md +Last session: 2026-08-04T13:27:34.911Z +Stopped at: Completed 15-02-PLAN.md Resume file: None Last activity: 2026-07-14 - Built LDAP per-user exclude/denylist filter (9d1323f), migration applied on live DB, verified via Playwright: sync deactivated 4 excluded service accounts (administrator/krbtgt/guest/dns-ldap), 2 real LDAP users stay active, 0 wrongly created diff --git a/.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-02-SUMMARY.md b/.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-02-SUMMARY.md new file mode 100644 index 0000000..ba4444a --- /dev/null +++ b/.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-02-SUMMARY.md @@ -0,0 +1,178 @@ +--- +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 -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 -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 ``-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 ``-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.