docs(15): create phase plan — 8 plans, 4 waves, PERM-01..07
This commit is contained in:
+12
-3
@@ -511,11 +511,20 @@ Plans:
|
|||||||
|
|
||||||
**Neue Modelle**: `Group` (tenantId, name, ldapDn?), `GroupMembership` (userId, groupId, source MANUAL|LDAP), `ModuleGrant` (tenantId, moduleId, groupId? | userId?)
|
**Neue Modelle**: `Group` (tenantId, name, ldapDn?), `GroupMembership` (userId, groupId, source MANUAL|LDAP), `ModuleGrant` (tenantId, moduleId, groupId? | userId?)
|
||||||
|
|
||||||
**Plans**: 0 plans
|
**Plans**: 8 plans
|
||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
|
|
||||||
- [ ] TBD (run /gsd-plan-phase 15 to break down)
|
- [ ] 15-01-PLAN.md — Schema, Migration mit Bestandsübernahme und Zugriffsauflösung (Tracer)
|
||||||
|
- [ ] 15-02-PLAN.md — Gruppen-API und automatische Standardgruppen-Mitgliedschaft
|
||||||
|
- [ ] 15-03-PLAN.md — Freigabe-API für Gruppen und Benutzer plus Modulkatalog-Endpoint
|
||||||
|
- [ ] 15-04-PLAN.md — AD-Gruppenbindung im bestehenden LDAP-Sync
|
||||||
|
- [ ] 15-05-PLAN.md — Modulfilter für Dashboard-Widgets
|
||||||
|
- [ ] 15-06-PLAN.md — Gruppenverwaltung im Admin-UI und alle i18n-Schlüssel der Phase
|
||||||
|
- [ ] 15-07-PLAN.md — Freigabe-Matrix, Benutzer-Detail und Aktivierungsdialog
|
||||||
|
- [ ] 15-08-PLAN.md — Serverseitige 403-Modulsperre und Marketplace-Kennzeichnung
|
||||||
|
|
||||||
|
**Wellen**: 1 → 15-01 · 2 → 15-02, 15-04, 15-05 · 3 → 15-03, 15-06 · 4 → 15-07, 15-08
|
||||||
|
|
||||||
**UI hint**: yes
|
**UI hint**: yes
|
||||||
|
|
||||||
@@ -540,4 +549,4 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10
|
|||||||
| 12. Tender Notifications | 4/4 | In Progress| |
|
| 12. Tender Notifications | 4/4 | In Progress| |
|
||||||
| 13. Scraping Adapters & Cross-Source Deduplication | 6/6 | In Progress| |
|
| 13. Scraping Adapters & Cross-Source Deduplication | 6/6 | In Progress| |
|
||||||
| 14. RSS, Email-Alert Ingestion & Module Rollout | 5/5 | In Progress| |
|
| 14. RSS, Email-Alert Ingestion & Module Rollout | 5/5 | In Progress| |
|
||||||
| 15. Modul-Berechtigungen: Gruppen & User-Grants | 0/0 | Not Planned | |
|
| 15. Modul-Berechtigungen: Gruppen & User-Grants | 0/8 | Planned | |
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
---
|
||||||
|
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>
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
---
|
||||||
|
phase: 15-modul-berechtigungen-gruppen-user-grants
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["15-01"]
|
||||||
|
files_modified:
|
||||||
|
- 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.ts
|
||||||
|
- apps/api/src/user/user.service.spec.ts
|
||||||
|
- apps/api/src/app.module.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements: [PERM-01, PERM-06]
|
||||||
|
user_setup: []
|
||||||
|
|
||||||
|
estimate:
|
||||||
|
tokens: 58000
|
||||||
|
raw_tokens: 58000
|
||||||
|
tasks: 2
|
||||||
|
confidence: low
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Ein Admin kann über die API Gruppen seines Mandanten anlegen, umbenennen, löschen sowie Mitglieder manuell zuweisen und entfernen (PERM-01, D-14)."
|
||||||
|
- "Vor dem Löschen einer Gruppe liefert die API die konkrete Zahl der Mitglieder und der Modul-Freigaben, damit der Dialog beide Zahlen nennen kann statt allgemein zu warnen (D-17)."
|
||||||
|
- "Beim Löschen einer Gruppe verschwinden Mitgliedschaften und Grants mit — erzwungen über onDelete: Cascade, nicht über anwendungsseitiges Aufräumen (D-17)."
|
||||||
|
- "Welche Gruppe die Standardgruppe ist, entscheidet die Markierung isDefault, nicht der Name; die Markierung lässt sich auf eine andere Gruppe umhängen und ganz abschalten (D-13)."
|
||||||
|
- "Die Standardgruppe bleibt eine gewöhnliche Gruppe: umbenennbar, löschbar, mit AD bindbar — sie ist kein Sonderobjekt mit eigenen Regeln (D-13)."
|
||||||
|
- "Jeder neu angelegte Benutzer wird automatisch Mitglied der markierten Standardgruppe seines Mandanten — manuell angelegt wie per LDAP importiert, über genau einen Codepfad (D-11, D-12, PERM-06)."
|
||||||
|
- "Existiert keine markierte Standardgruppe, wird ein neuer Benutzer keiner Gruppe zugeordnet und die Anlage schlägt trotzdem nicht fehl (D-13)."
|
||||||
|
- "Jede Gruppen- und Mitgliedschafts-Query filtert zusätzlich auf die tenantId aus dem JWT — eine Gruppen-ID eines fremden Mandanten führt zu 404, nicht zu einem Treffer."
|
||||||
|
# --- UI-SPEC 'UI Considerations' — kein Eintrag dieses Plans (Backend); Backstop-Zeile 'error/Gruppe löschen schlägt fehl' lebt in 15-06 ---
|
||||||
|
# --- Edge-Probe PERM-01 (6 Kategorien) ---
|
||||||
|
- "adjacency/PERM-01: Ein zweites POST /groups mit exakt gleichem Namen im selben Mandanten wird mit HTTP 409 abgelehnt; derselbe Name in einem anderen Mandanten ist erlaubt."
|
||||||
|
- "empty/PERM-01: POST /groups mit leerem oder nur aus Leerzeichen bestehendem Namen wird mit HTTP 400 abgelehnt; GET /groups eines Mandanten ohne Gruppen liefert eine leere Liste mit HTTP 200, nicht 404."
|
||||||
|
- "encoding/PERM-01: Gruppennamen werden getrimmt, aber weder normalisiert noch kleingeschrieben gespeichert und verglichen — 'Vertrieb' und 'vertrieb' sind zwei verschiedene Gruppen (bewusst anders als bei Benutzernamen, die kleingeschrieben werden)."
|
||||||
|
- "ordering/PERM-01: GET /groups sortiert nach name aufsteigend; da (tenantId, name) eindeutig ist, gibt es keine Gleichstände und die Reihenfolge ist über Aufrufe hinweg stabil."
|
||||||
|
- "idempotency/PERM-01: Ein bereits vorhandenes Mitglied erneut hinzuzufügen ist ein folgenloser Upsert mit HTTP 200 statt eines Fehlers; ein nicht vorhandenes Mitglied zu entfernen antwortet HTTP 204 ohne Seiteneffekt."
|
||||||
|
- "concurrency/PERM-01: Zwei gleichzeitige DELETE /groups/:id auf dieselbe Gruppe erzeugen keinen HTTP 500 — der unterlegene Aufruf endet in HTTP 404, Mitgliedschaften und Grants werden per Cascade genau einmal entfernt."
|
||||||
|
artifacts:
|
||||||
|
- "apps/api/src/groups/groups.module.ts — GroupsModule"
|
||||||
|
- "apps/api/src/groups/groups.service.ts — GroupsService"
|
||||||
|
- "apps/api/src/groups/groups.controller.ts — GroupsController"
|
||||||
|
- "apps/api/src/groups/dto/create-group.dto.ts, update-group.dto.ts, add-group-members.dto.ts"
|
||||||
|
- "apps/api/src/groups/groups.service.spec.ts"
|
||||||
|
- "apps/api/src/user/user.service.spec.ts"
|
||||||
|
key_links:
|
||||||
|
- "UserService.create → GroupsService.addUserToDefaultGroup — der einzige Codepfad, über den sowohl der Admin-Controller als auch LdapService.upsertMappedUser und LdapService.importUsersByDn Benutzer erzeugen (D-11/D-12)"
|
||||||
|
- "GroupsController → RolesGuard + @Roles(ADMIN, SUPER_ADMIN) — dieselbe Absicherung wie die Modul-Aktivierungsrouten"
|
||||||
|
- "GET /groups/:id/impact → das Zahlenmaterial für den Löschdialog aus D-17 (Plan 15-06)"
|
||||||
|
- "AppModule importiert GroupsModule — ohne diese Zeile existiert keine der neuen Routen"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Die Gruppenverwaltung auf der API-Seite: vollständiges CRUD für Gruppen und Mitgliedschaften eines Mandanten, das Zahlenmaterial für den Löschdialog, und die automatische Standardgruppen-Mitgliedschaft für jeden neu entstehenden Benutzer.
|
||||||
|
|
||||||
|
Purpose: Ohne Gruppen gibt es nichts, worauf Freigaben zeigen könnten. Die automatische Standardgruppen-Mitgliedschaft (D-11/D-12) ist der Teil von PERM-06, der über den einmaligen Migrations-Backfill hinausgeht: sie sorgt dafür, dass auch nach dem Deploy neu angelegte Benutzer nicht in einem zugriffslosen Zustand landen.
|
||||||
|
Output: `GroupsModule` mit Service und Controller, drei DTOs, die Erweiterung von `UserService.create` und zwei Testsuites.
|
||||||
|
</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/STATE.md
|
||||||
|
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-CONTEXT.md
|
||||||
|
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-PATTERNS.md
|
||||||
|
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-01-SUMMARY.md
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: GroupsModule — CRUD für Gruppen, Mitgliedschaften und Löschauswirkung</name>
|
||||||
|
<files>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/app.module.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/api/src/module-registry/module-registry.service.ts — Prisma-Query-Stil, NotFoundException-Behandlung, upsert-Muster als Vorlage
|
||||||
|
- apps/api/src/module-registry/module-registry.controller.ts — die Zeilen 47–54 und 62–74: tenantId aus `req.tenantId ?? req.user?.tenantId`, `@UseGuards(RolesGuard)` plus `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`
|
||||||
|
- apps/api/src/module-registry/module-registry.module.ts — Aufbau eines NestJS-Moduls in diesem Projekt (imports/controllers/providers/exports)
|
||||||
|
- apps/api/src/dashboard/dashboard.service.ts — Zeilen 119–164: das Ownership-Prüfmuster vor jeder Lookup-Mutation (IDOR-Schutz)
|
||||||
|
- apps/api/src/app.module.ts — die Reihenfolge der Modul-Importe
|
||||||
|
- apps/api/prisma/schema.prisma — die aus 15-01 stammenden Modelle Group, GroupMembership, ModuleGrant und ihre Constraints
|
||||||
|
- apps/api/src/tenders/tender-saved-search.service.spec.ts — Vorbild für eine Service-Testsuite mit gemocktem PrismaService
|
||||||
|
- apps/api/src/dkv/dto/ — ein bestehendes DTO als Vorlage für den class-validator-Stil dieses Projekts
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- `listForTenant(tenantId)` liefert die Gruppen des Mandanten nach `name` aufsteigend, jeweils mit der Zahl der Mitglieder als `memberCount` (über `_count`).
|
||||||
|
- `listForTenant` eines Mandanten ohne Gruppen liefert ein leeres Array.
|
||||||
|
- `create(tenantId, { name })` legt eine Gruppe an; führender und abschliessender Leerraum wird entfernt.
|
||||||
|
- `create` mit leerem oder nur aus Leerzeichen bestehendem Namen wirft BadRequestException.
|
||||||
|
- `create` mit einem im Mandanten bereits vergebenen Namen wirft ConflictException (Prisma-Fehlercode P2002 wird abgefangen).
|
||||||
|
- `create` mit demselben Namen in einem anderen Mandanten ist erfolgreich.
|
||||||
|
- `update(tenantId, id, { name })` benennt um; eine ID eines fremden Mandanten wirft NotFoundException.
|
||||||
|
- `update(tenantId, id, { isDefault: true })` setzt die Markierung und entfernt sie in derselben Transaktion von jeder anderen Gruppe desselben Mandanten.
|
||||||
|
- `update(tenantId, id, { isDefault: false })` schaltet die Markierung ab, ohne sie irgendwo anders zu setzen.
|
||||||
|
- `update(tenantId, id, { ldapDn })` bindet an eine AD-Gruppe; `ldapDn: null` löst die Bindung.
|
||||||
|
- `getImpact(tenantId, id)` liefert `{ memberCount, grantCount }`; eine ID eines fremden Mandanten wirft NotFoundException.
|
||||||
|
- `remove(tenantId, id)` löscht die Gruppe; ein zweiter Aufruf auf dieselbe ID wirft NotFoundException statt eines unbehandelten Prisma-Fehlers.
|
||||||
|
- `addMembers(tenantId, id, userIds)` legt fehlende Mitgliedschaften mit `source: MANUAL` an und ist für bereits vorhandene folgenlos.
|
||||||
|
- `addMembers` mit einer userId eines fremden Mandanten überspringt diese ID und nimmt sie nicht auf.
|
||||||
|
- `removeMember(tenantId, id, userId)` entfernt ausschliesslich Mitgliedschaften mit `source: MANUAL` und lässt LDAP-Mitgliedschaften unberührt.
|
||||||
|
- `removeMember` für ein nicht vorhandenes Mitglied ist folgenlos und wirft nicht.
|
||||||
|
- `addUserToDefaultGroup(tenantId, userId)` legt eine Mitgliedschaft in der Gruppe mit `isDefault: true` an.
|
||||||
|
- `addUserToDefaultGroup` bei einem Mandanten ohne markierte Standardgruppe tut nichts und wirft nicht.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Lege das Verzeichnis `apps/api/src/groups/` an mit `groups.module.ts`, `groups.service.ts`, `groups.controller.ts` und `dto/`.
|
||||||
|
|
||||||
|
`GroupsService` injiziert `PrismaService` (global bereitgestellt) und implementiert die unter `<behavior>` beschriebenen Methoden. Zentrale Regel für jede Methode mit einer Gruppen-ID: die Lookup-Query filtert immer zusätzlich auf `tenantId`, nach dem Vorbild des Ownership-Checks in `DashboardService.removeWidget` — eine ID aus einem anderen Mandanten darf nie einen Treffer liefern, sondern führt zu `NotFoundException`. Verlasse dich dabei nicht auf RLS, sondern schreibe den Filter explizit.
|
||||||
|
|
||||||
|
`create` fängt Prisma-Fehlercode `P2002` ab und wandelt ihn in `ConflictException` mit einer Meldung, die den Namenskonflikt benennt — der Unique-Index aus 15-01 ist der eigentliche Durchsetzungspunkt, die Ausnahmebehandlung liefert nur die brauchbare Fehlermeldung.
|
||||||
|
|
||||||
|
`update` mit `isDefault: true` läuft in `this.prisma.$transaction`: zuerst `updateMany` auf alle Gruppen des Mandanten mit `isDefault: false`, dann `update` der Zielgruppe auf `true`. Der partielle Unique-Index `Group_one_default_per_tenant` aus 15-01 ist das Sicherheitsnetz gegen parallele Aufrufe; die Transaktion ist der normale Pfad.
|
||||||
|
|
||||||
|
`getImpact` zählt Mitgliedschaften und Grants der Gruppe über zwei `count`-Queries und gibt beide Zahlen zurück. `remove` löscht ausschliesslich die Gruppenzeile — Mitgliedschaften und Grants verschwinden über die in 15-01 definierten `onDelete: Cascade`-Regeln; räume nicht zusätzlich anwendungsseitig auf, sonst gibt es zwei Wahrheiten über das Aufräumverhalten.
|
||||||
|
|
||||||
|
`addMembers` prüft für jede übergebene userId zuerst, dass der Benutzer zum selben Mandanten gehört, und nutzt dann `createMany` mit `skipDuplicates: true` gegen `@@unique([groupId, userId])`. `removeMember` nutzt `deleteMany` mit `where: { groupId, userId, source: 'MANUAL' }` — die Einschränkung auf MANUAL ist die Umsetzung von D-19: eine über AD gesteuerte Mitgliedschaft entfernt ausschliesslich der Sync.
|
||||||
|
|
||||||
|
`addUserToDefaultGroup(tenantId, userId)` sucht die Gruppe mit `tenantId` und `isDefault: true`; ist keine vorhanden, kehrt die Methode ohne Wirkung zurück (D-13: die Markierung darf abgeschaltet sein). Andernfalls legt sie die Mitgliedschaft mit `source: MANUAL` per `createMany` mit `skipDuplicates: true` an. Diese Methode wird in Task 2 von `UserService` aufgerufen und muss deshalb aus `GroupsModule` exportiert werden.
|
||||||
|
|
||||||
|
`GroupsController` unter dem Pfad `groups` bildet ab: `GET /groups` (Liste), `POST /groups` (anlegen), `PATCH /groups/:id` (umbenennen, isDefault setzen, ldapDn setzen oder lösen), `DELETE /groups/:id`, `GET /groups/:id/impact`, `GET /groups/:id/members`, `POST /groups/:id/members`, `DELETE /groups/:id/members/:userId`. Jede Route liest `tenantId` mit `(req as any).tenantId ?? (req as any).user?.tenantId` und wirft bei fehlendem Kontext `ForbiddenException('No tenant context')`. Jede Route trägt `@UseGuards(RolesGuard)` und `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`. Statische Segmente stehen vor Parameter-Routen, damit `:id` keine Route beschattet — dieses Projekt hatte den Fehler schon einmal (Memory `project_nest_route_order`).
|
||||||
|
|
||||||
|
Die drei DTOs nutzen class-validator im Stil der bestehenden DTOs: `CreateGroupDto` mit `@IsString()` und `@IsNotEmpty()` auf `name`; `UpdateGroupDto` mit optionalem `name`, optionalem `isDefault` als Boolean und optionalem `ldapDn` als String oder null; `AddGroupMembersDto` mit `@IsArray()` und `@IsString({ each: true })` auf `userIds`.
|
||||||
|
|
||||||
|
Registriere `GroupsModule` in `apps/api/src/app.module.ts` in der Import-Liste nach `ModuleRegistryModule`.
|
||||||
|
|
||||||
|
Lege `groups.service.spec.ts` an, das jeden unter `<behavior>` genannten Fall mit gemocktem PrismaService abdeckt.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/api test -- groups.service</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `pnpm --filter @tessera/api test -- groups.service` ist grün und enthält für jeden unter `<behavior>` gelisteten Fall ein eigenes `it(...)`.
|
||||||
|
- `grep -c 'tenantId' apps/api/src/groups/groups.service.ts` gibt mindestens `10` aus — jede Lookup- und Mutations-Query trägt den Mandantenfilter.
|
||||||
|
- `grep -c "source: 'MANUAL'\|source: MembershipSource.MANUAL" apps/api/src/groups/groups.service.ts` gibt mindestens `2` aus (removeMember und addMembers).
|
||||||
|
- `grep -c '@Roles(Role.ADMIN, Role.SUPER_ADMIN)' apps/api/src/groups/groups.controller.ts` gibt mindestens `8` aus — jede Route ist rollengeschützt.
|
||||||
|
- `grep -c 'GroupsModule' apps/api/src/app.module.ts` gibt mindestens `2` aus (Import und Eintrag in der Modulliste).
|
||||||
|
- Gegen die laufende lokale API: `POST /groups` mit einem bereits vergebenen Namen liefert HTTP 409; `GET /groups/:id/impact` einer Gruppe mit Mitgliedern liefert ein JSON mit den Schlüsseln `memberCount` und `grantCount`; `DELETE /groups/:id` mit einer ID eines fremden Mandanten liefert HTTP 404.
|
||||||
|
- `pnpm --filter @tessera/api run type-check` läuft fehlerfrei durch.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Gruppen eines Mandanten lassen sich über die API vollständig verwalten, die Löschauswirkung ist als Zahlenpaar abrufbar, und keine Route lässt sich mit einer ID eines fremden Mandanten bedienen.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Automatische Standardgruppen-Mitgliedschaft an genau einem Ort</name>
|
||||||
|
<files>apps/api/src/user/user.service.ts, apps/api/src/user/user.service.spec.ts, apps/api/src/user/user.module.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/api/src/user/user.service.ts — die bestehende `create()` (Zeilen 32–50), der einzige Erzeugungspunkt für Benutzer
|
||||||
|
- apps/api/src/ldap/ldap.service.ts — Zeilen 303–342 (`upsertMappedUser`, ruft `userService.create` im Neuanlage-Zweig) und die zweite Aufrufstelle in `importUsersByDn`
|
||||||
|
- apps/api/src/user/user.module.ts — providers/exports, die um den Import von GroupsModule ergänzt werden
|
||||||
|
- apps/api/src/groups/groups.service.ts — die in Task 1 entstandene Methode `addUserToDefaultGroup`
|
||||||
|
- apps/api/src/auth/auth.service.spec.ts — Vorbild für eine Testsuite mit mehreren gemockten Abhängigkeiten
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- `UserService.create` mit einem Mandanten, der eine markierte Standardgruppe besitzt, legt den Benutzer an und ruft danach genau einmal `GroupsService.addUserToDefaultGroup` mit derselben tenantId und der frisch erzeugten userId auf.
|
||||||
|
- `UserService.create` mit einem Mandanten ohne markierte Standardgruppe legt den Benutzer an; der Aufruf von `addUserToDefaultGroup` bleibt folgenlos und die Anlage schlägt nicht fehl.
|
||||||
|
- Schlägt die Zuordnung zur Standardgruppe fehl, wird der Benutzer trotzdem zurückgegeben und der Fehler protokolliert — eine gescheiterte Gruppenzuordnung darf keine Benutzeranlage und keinen LDAP-Sync-Lauf abbrechen.
|
||||||
|
- `UserService.create` gibt weiterhin den erzeugten Benutzerdatensatz zurück; die Signatur bleibt unverändert.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Erweitere `UserService.create`: nach dem bestehenden `prisma.user.create(...)` wird `GroupsService.addUserToDefaultGroup(data.tenantId, created.id)` aufgerufen und der Rückgabewert von `create` bleibt der erzeugte Benutzer. Der Aufruf liegt in einem try/catch, das den Fehler über einen `Logger` protokolliert und schluckt — ein LDAP-Sync-Durchlauf über hunderte Benutzer darf nicht daran scheitern, dass eine einzelne Gruppenzuordnung klemmt.
|
||||||
|
|
||||||
|
Dies ist bewusst die einzige Stelle im gesamten Backend, an der die Regel aus D-11 und D-12 steht. Weder `LdapService.upsertMappedUser` noch `LdapService.importUsersByDn` noch der Admin-Benutzer-Controller bekommen eine eigene Kopie: alle drei erzeugen Benutzer ausschliesslich über `UserService.create`, weshalb "eine Regel für beide Herkünfte" hier strukturell erfüllt ist statt durch Konvention.
|
||||||
|
|
||||||
|
Ergänze in `apps/api/src/user/user.module.ts` den Import von `GroupsModule`. Prüfe dabei auf eine zirkuläre Abhängigkeit: `GroupsModule` darf `UserModule` nicht importieren — `GroupsService` greift für Benutzerprüfungen direkt auf `PrismaService` zu. Sollte doch eine Zirkularität entstehen, löse sie über `forwardRef` und halte den Grund im Kommentar fest.
|
||||||
|
|
||||||
|
Lege `apps/api/src/user/user.service.spec.ts` an, das die vier unter `<behavior>` beschriebenen Fälle mit gemocktem PrismaService und gemocktem GroupsService abdeckt. Der Test für den Fehlerfall belegt insbesondere, dass `create` trotz werfendem `addUserToDefaultGroup` den Benutzer zurückgibt.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/api test -- user.service && pnpm --filter @tessera/api test -- ldap.service</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `pnpm --filter @tessera/api test -- user.service` ist grün und enthält für jeden unter `<behavior>` gelisteten Fall ein eigenes `it(...)`.
|
||||||
|
- `grep -c 'addUserToDefaultGroup' apps/api/src/user/user.service.ts` gibt `1` aus — die Regel steht an genau einer Stelle.
|
||||||
|
- `grep -c 'userService.create' apps/api/src/ldap/ldap.service.ts` ist unverändert gegenüber dem Stand vor diesem Task — der LDAP-Pfad erbt die Regel über den bestehenden Aufruf, statt eine eigene Kopie zu bekommen; `git diff --stat apps/api/src/ldap/ldap.service.ts` zeigt für diesen Task keine Änderung an der Datei.
|
||||||
|
- `pnpm --filter @tessera/api test -- ldap.service` bleibt grün — die bestehenden Sync-Tests laufen unverändert durch.
|
||||||
|
- Gegen die laufende lokale API: ein über `POST /users` angelegter Benutzer erscheint direkt danach in `GET /groups/:defaultGroupId/members`.
|
||||||
|
- `pnpm --filter @tessera/api run type-check` läuft fehlerfrei durch.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Jeder neu entstehende Benutzer landet automatisch in der markierten Standardgruppe seines Mandanten, unabhängig davon, ob er manuell angelegt oder aus dem AD importiert wurde — und die Anlage übersteht eine fehlgeschlagene Zuordnung.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Browser → GroupsController | Gruppen- und Benutzer-IDs kommen aus Pfad und Body eines Admin-Clients und sind damit nicht vertrauenswürdig; tenantId kommt ausschliesslich aus dem JWT |
|
||||||
|
| GroupsService → PostgreSQL | Jede Query trägt den Mandantenfilter explizit; RLS aus 15-01 ist das zweite Netz |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-15-02 | Information Disclosure / Tampering | `GET/PATCH/DELETE /groups/:id`, `GET /groups/:id/impact`, `POST /groups/:id/members` | high | mitigate | Jede Lookup-Query in `GroupsService` filtert zusätzlich auf die tenantId aus dem JWT nach dem Vorbild von `DashboardService.removeWidget`; eine ID eines fremden Mandanten liefert NotFoundException statt eines Treffers. `groups.service.spec.ts` deckt den Fremdmandanten-Fall pro Methode ab |
|
||||||
|
| T-15-12 | Elevation of Privilege | `POST /groups/:id/members` mit einer userId eines fremden Mandanten | high | mitigate | `addMembers` verifiziert vor jeder Mitgliedschaft, dass `user.tenantId` mit der tenantId aus dem JWT übereinstimmt, und überspringt abweichende IDs — sonst könnte ein Admin einen fremden Benutzer in eine eigene Gruppe legen und ihm darüber Modulzugriff verschaffen |
|
||||||
|
| T-15-13 | Elevation of Privilege | `PATCH /groups/:id` durch einen Benutzer mit Rolle USER | high | mitigate | Jede Route trägt `@UseGuards(RolesGuard)` mit `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`, identisch zu den bestehenden Modul-Aktivierungsrouten |
|
||||||
|
| T-15-14 | Denial of Service | Fehlgeschlagene Standardgruppen-Zuordnung während eines LDAP-Sync-Laufs über viele Benutzer | medium | mitigate | Der Aufruf in `UserService.create` liegt in try/catch mit Logger — ein Einzelfehler bricht weder die Benutzeranlage noch den gesamten Sync-Durchlauf ab |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `pnpm --filter @tessera/api test` vollständig grün.
|
||||||
|
- `pnpm --filter @tessera/api run type-check` fehlerfrei.
|
||||||
|
- Manuell gegen die lokale API: Gruppe anlegen, umbenennen, Standardmarkierung umhängen, Mitglied hinzufügen und entfernen, Löschauswirkung abrufen, Gruppe löschen — anschliessend sind in der Datenbank weder verwaiste `GroupMembership`- noch `ModuleGrant`-Zeilen zu dieser Gruppe vorhanden.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Gruppen und Mitgliedschaften sind über die API vollständig verwaltbar (PERM-01).
|
||||||
|
- Der Löschdialog kann konkrete Zahlen nennen, weil die API sie liefert (D-17).
|
||||||
|
- Neue Benutzer beider Herkünfte treten der markierten Standardgruppe bei, ohne dass die Regel doppelt im Code steht (D-11, D-12, PERM-06).
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
## Artifacts this phase produces
|
||||||
|
|
||||||
|
Von diesem Plan erzeugt beziehungsweise verändert:
|
||||||
|
|
||||||
|
**NestJS-Module, Services, Controller**
|
||||||
|
- `GroupsModule` (`apps/api/src/groups/groups.module.ts`)
|
||||||
|
- `GroupsService` mit `listForTenant`, `create`, `update`, `remove`, `getImpact`, `listMembers`, `addMembers`, `removeMember`, `addUserToDefaultGroup`
|
||||||
|
- `GroupsController` mit `GET /groups`, `POST /groups`, `PATCH /groups/:id`, `DELETE /groups/:id`, `GET /groups/:id/impact`, `GET /groups/:id/members`, `POST /groups/:id/members`, `DELETE /groups/:id/members/:userId`
|
||||||
|
- `UserService.create` (erweitert um die Standardgruppen-Mitgliedschaft)
|
||||||
|
- `AppModule` (erweitert um `GroupsModule`)
|
||||||
|
|
||||||
|
**DTOs**
|
||||||
|
- `CreateGroupDto`, `UpdateGroupDto`, `AddGroupMembersDto` in `apps/api/src/groups/dto/`
|
||||||
|
|
||||||
|
**Testdateien**
|
||||||
|
- `apps/api/src/groups/groups.service.spec.ts`
|
||||||
|
- `apps/api/src/user/user.service.spec.ts`
|
||||||
|
|
||||||
|
Die phasenweite Gesamtliste steht in `15-01-PLAN.md`.
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Erstelle `.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-02-SUMMARY.md`, wenn der Plan abgeschlossen ist.
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
---
|
||||||
|
phase: 15-modul-berechtigungen-gruppen-user-grants
|
||||||
|
plan: 03
|
||||||
|
type: execute
|
||||||
|
wave: 3
|
||||||
|
depends_on: ["15-01", "15-02"]
|
||||||
|
files_modified:
|
||||||
|
- apps/api/src/groups/module-grants.service.ts
|
||||||
|
- apps/api/src/groups/module-grants.controller.ts
|
||||||
|
- apps/api/src/groups/module-grants.service.spec.ts
|
||||||
|
- apps/api/src/groups/dto/create-module-grant.dto.ts
|
||||||
|
- apps/api/src/groups/groups.module.ts
|
||||||
|
- apps/api/src/module-registry/module-registry.controller.ts
|
||||||
|
- apps/api/src/module-registry/module-access.service.ts
|
||||||
|
- apps/api/src/module-registry/module-access.service.spec.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements: [PERM-03, PERM-04]
|
||||||
|
user_setup: []
|
||||||
|
|
||||||
|
estimate:
|
||||||
|
tokens: 62000
|
||||||
|
raw_tokens: 62000
|
||||||
|
tasks: 3
|
||||||
|
confidence: low
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Ein Admin kann ein mandantenweit aktives Modul gezielt für eine Gruppe und für einen einzelnen Benutzer freigeben und die Freigabe wieder entziehen (PERM-03)."
|
||||||
|
- "Die Matrix-Daten liefern in einer Antwort alle aktiven Module, alle Gruppen des Mandanten und die bestehenden Gruppen-Grants als Paare (D-15)."
|
||||||
|
- "Das Benutzer-Detail liefert je aktivem Modul, über welche Gruppen der Benutzer das Modul erbt und ob er zusätzlich einen Direkt-Grant hat (D-16)."
|
||||||
|
- "Vor jedem Grant-Insert wird geprüft, dass die referenzierte Gruppe beziehungsweise der referenzierte Benutzer zum Mandanten aus dem JWT gehört — die tenantId allein aus dem Token zu übernehmen genügt nicht."
|
||||||
|
- "Ein Grant zeigt auf genau eine Gruppe oder genau einen Benutzer; die DTO-Schicht lehnt beides-oder-keines mit HTTP 400 ab, bevor die CHECK-Constraint der Datenbank als letztes Netz greift."
|
||||||
|
- "Ein Grant trägt keine Rechtestufe — Module können aus dem Datensatz nichts über read, write oder admin ableiten (D-04)."
|
||||||
|
- "GET /modules/catalog liefert je registriertem Modul beide Flags in einer Antwort: ob es mandantenweit aktiv ist und ob der anfragende Benutzer Zugriff hat (D-08)."
|
||||||
|
- "Änderungen an Freigaben werden ausschliesslich ins Server-Log geschrieben; es entsteht keine Audit-Tabelle und keine Ansicht im Admin-UI (D-23)."
|
||||||
|
- "Ein Freigabe-Entzug wirkt in der API sofort, weil der Guard jede Anfrage neu auflöst — die Sidebar zieht beim nächsten Seitenaufruf nach (D-09)."
|
||||||
|
# --- Edge-Probe PERM-03 (5 Kategorien) ---
|
||||||
|
- "adjacency/PERM-03: Hält ein Benutzer für dasselbe Modul gleichzeitig einen Gruppen-Grant und einen Direkt-Grant, entzieht das Entfernen eines der beiden den Zugriff nicht — die Auflösung ist die Vereinigungsmenge, es gibt keinen Vorrang."
|
||||||
|
- "empty/PERM-03: Ein Mandant ohne Gruppen oder ohne aktive Module erhält leere Listen mit HTTP 200, nicht HTTP 404."
|
||||||
|
- "ordering/PERM-03: Die Matrix-Antwort liefert Module nach category und dann name aufsteigend sowie Gruppen nach name aufsteigend, über wiederholte Aufrufe deterministisch."
|
||||||
|
- "idempotency/PERM-03: Ein zweiter Grant auf dieselbe Kombination erzeugt keinen zweiten Datensatz und keinen HTTP 500; ein zweites Entziehen eines bereits entzogenen Grants antwortet HTTP 204 ohne Fehler."
|
||||||
|
- "concurrency/PERM-03: Zwei parallele Grant-Erstellungen für dieselbe Kombination führen wegen des partiellen Unique-Index zu genau einer Zeile; der unterlegene Request wird als Erfolg behandelt, nicht als HTTP 500."
|
||||||
|
artifacts:
|
||||||
|
- "apps/api/src/groups/module-grants.service.ts — ModuleGrantsService"
|
||||||
|
- "apps/api/src/groups/module-grants.controller.ts — ModuleGrantsController"
|
||||||
|
- "apps/api/src/groups/dto/create-module-grant.dto.ts"
|
||||||
|
- "apps/api/src/groups/module-grants.service.spec.ts"
|
||||||
|
- "apps/api/src/module-registry/module-registry.controller.ts — neuer Handler findCatalog (GET /modules/catalog)"
|
||||||
|
key_links:
|
||||||
|
- "ModuleGrantsService → dieselben ModuleGrant-Zeilen, die ModuleAccessService liest — eine Schreib- und eine Leseseite auf einem Datensatz"
|
||||||
|
- "ModuleGrantsService.assertBelongsToTenant → der Schutz gegen mandantenübergreifende Freigaben, ohne den ein Admin einem fremden Benutzer Zugriff verschaffen könnte"
|
||||||
|
- "GET /modules/catalog → MarketplaceCard (Plan 15-08) — beide Flags kommen in einer Antwort, damit keine Karte kurzzeitig ohne Sperrhinweis klickbar ist"
|
||||||
|
- "GroupsModule bindet ModuleGrantsController und ModuleGrantsService ein und importiert ModuleRegistryModule"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Die Schreibseite der Freigaben: Grants für Gruppen und für einzelne Benutzer anlegen und entziehen, die Datenlieferung für die Freigabe-Matrix und für das Benutzer-Detail, und ein Katalog-Endpoint, der dem Marketplace beide Statusinformationen in einer Antwort liefert.
|
||||||
|
|
||||||
|
Purpose: Plan 15-01 hat die Leseseite gebaut — ohne eine Schreibseite gibt es nichts zu lesen, und ohne die Mandanten-Gegenprüfung wäre die Schreibseite selbst der Weg, das ganze Modell auszuhebeln: ein Admin könnte einen Grant auf eine Gruppe eines fremden Mandanten legen. Der Katalog-Endpoint ist nötig, weil `GET /modules/active` seit 15-01 benutzergefiltert antwortet und der Marketplace laut D-08 gerade auch die nicht freigegebenen Module weiter anzeigen soll.
|
||||||
|
Output: `ModuleGrantsService` und `ModuleGrantsController`, ein DTO, eine Testsuite und der neue Handler `GET /modules/catalog`.
|
||||||
|
</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/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-01-SUMMARY.md
|
||||||
|
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-02-SUMMARY.md
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: ModuleGrantsService — Freigaben setzen und entziehen mit Mandanten-Gegenprüfung</name>
|
||||||
|
<files>apps/api/src/groups/module-grants.service.ts, apps/api/src/groups/module-grants.service.spec.ts, apps/api/src/groups/dto/create-module-grant.dto.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/api/src/module-registry/module-registry.service.ts — `activateForTenant`/`deactivateForTenant` als Vorbild für Upsert plus Existenzprüfung des referenzierten Objekts
|
||||||
|
- apps/api/src/groups/groups.service.ts — der in 15-02 etablierte Stil dieses Verzeichnisses, insbesondere der explizite tenantId-Filter je Lookup
|
||||||
|
- apps/api/src/dashboard/dashboard.service.ts — Zeilen 119–164, das Ownership-Prüfmuster vor einer Mutation
|
||||||
|
- apps/api/prisma/schema.prisma — das Modell `ModuleGrant` mit seinen zwei optionalen Fremdschlüsseln
|
||||||
|
- apps/api/prisma/migrations/*_add_groups_and_module_grants/migration.sql — die CHECK-Constraint `ModuleGrant_group_xor_user` und die beiden partiellen Unique-Indizes, deren Fehlerbild der Service abfangen muss
|
||||||
|
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-RESEARCH.md — Pitfall 2 zur DTO-Vorabprüfung und der Abschnitt "Security Domain" zur Cross-Tenant-Grant-Injection
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- `grant(tenantId, { moduleId, groupId })` legt einen Grant an und gibt ihn zurück.
|
||||||
|
- `grant(tenantId, { moduleId, userId })` legt einen Direkt-Grant an und gibt ihn zurück.
|
||||||
|
- `grant` mit gesetztem groupId UND userId wirft BadRequestException.
|
||||||
|
- `grant` ohne groupId und ohne userId wirft BadRequestException.
|
||||||
|
- `grant` mit einer groupId aus einem anderen Mandanten wirft NotFoundException und legt nichts an.
|
||||||
|
- `grant` mit einer userId aus einem anderen Mandanten wirft NotFoundException und legt nichts an.
|
||||||
|
- `grant` mit einer moduleId, zu der keine aktive `TenantModuleActivation` des Mandanten existiert, wirft BadRequestException — ein Grant auf ein nicht aktiviertes Modul wäre wirkungslos (D-02).
|
||||||
|
- `grant` auf eine bereits bestehende Kombination gibt den bestehenden Datensatz zurück, ohne einen zweiten anzulegen; ein Prisma-Fehlercode P2002 wird als Erfolg behandelt.
|
||||||
|
- `revoke(tenantId, { moduleId, groupId })` entfernt den Grant.
|
||||||
|
- `revoke` auf eine Kombination ohne bestehenden Grant ist folgenlos und wirft nicht.
|
||||||
|
- `revoke` mit einer groupId aus einem anderen Mandanten entfernt nichts.
|
||||||
|
- `getMatrix(tenantId)` liefert `{ modules, groups, grants }` mit den aktiven Modulen nach category und name sortiert, den Gruppen nach name sortiert und den Gruppen-Grants als Paare aus moduleId und groupId.
|
||||||
|
- `getMatrix` eines Mandanten ohne Gruppen liefert eine leere Gruppenliste und wirft nicht.
|
||||||
|
- `getUserAccess(tenantId, userId)` liefert je aktivem Modul die Namen der Gruppen, über die der Benutzer es erbt, und ein Kennzeichen, ob ein Direkt-Grant besteht.
|
||||||
|
- `getUserAccess` mit einer userId aus einem anderen Mandanten wirft NotFoundException.
|
||||||
|
- Jede erfolgreiche `grant`- und `revoke`-Operation schreibt eine Logzeile mit Mandant, Modul, Ziel und Aktion.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Lege `apps/api/src/groups/module-grants.service.ts` mit `ModuleGrantsService` an, injiziert wird `PrismaService`, und ein `Logger` wird als Klassenfeld gehalten.
|
||||||
|
|
||||||
|
Zentral ist eine private Methode `assertTargetBelongsToTenant(tenantId, groupId?, userId?)`. Sie prüft für die gesetzte Referenz per `findFirst` mit `id` UND `tenantId`, dass das Zielobjekt zum Mandanten aus dem JWT gehört, und wirft andernfalls `NotFoundException`. Diese Prüfung ist der Kern des Plans: die tenantId stammt zwar aus dem Token und ist damit vertrauenswürdig, aber `groupId` und `userId` kommen aus dem Request-Body eines Admin-Clients. Ohne die Gegenprüfung könnte ein Admin eines Mandanten einen Grant auf eine Gruppe oder einen Benutzer eines anderen Mandanten legen und darüber Zugriff verschaffen. Im Bestandscode gibt es dafür kein Vorbild — die bisherigen Ownership-Prüfungen betreffen nur direktes Eigentum, nicht eine zweite Mandantengrenze über eine Relation.
|
||||||
|
|
||||||
|
`grant` prüft in dieser Reihenfolge: Entweder-oder der beiden Referenzen (sonst `BadRequestException` mit Klartext, damit das Admin-UI nicht den rohen Postgres-Constraint-Namen zu sehen bekommt), dann `assertTargetBelongsToTenant`, dann die aktive `TenantModuleActivation` des Mandanten für die moduleId, dann `create`. Ein `P2002` aus dem partiellen Unique-Index wird abgefangen und in eine Rückgabe des bestehenden Datensatzes übersetzt — zwei parallele Klicks auf dieselbe Matrix-Zelle dürfen keinen HTTP 500 erzeugen.
|
||||||
|
|
||||||
|
`revoke` nutzt `deleteMany` mit `where: { tenantId, moduleId, groupId }` beziehungsweise `userId`. `deleteMany` ist hier bewusst gewählt statt `delete`: es ist folgenlos, wenn nichts passt, und braucht keinen vorherigen Lookup. Die tenantId im where ist gleichzeitig der IDOR-Schutz.
|
||||||
|
|
||||||
|
`getMatrix(tenantId)` liefert in einer Antwort drei Listen: die aktiven Module des Mandanten (über `tenantModuleActivation` mit `isActive: true` und `include: { module: true }`, sortiert nach category und dann name), die Gruppen des Mandanten nach name sortiert, und alle `moduleGrant`-Zeilen des Mandanten mit gesetztem `groupId`, reduziert auf die Paare moduleId und groupId. Die Sortierung wird explizit gesetzt, damit die Spalten- und Zeilenreihenfolge der Matrix über Aufrufe hinweg stabil bleibt.
|
||||||
|
|
||||||
|
`getUserAccess(tenantId, userId)` prüft zuerst, dass der Benutzer zum Mandanten gehört, und liefert dann je aktivem Modul einen Eintrag mit dem Modul, den Namen der Gruppen, die dem Benutzer dieses Modul gewähren (aufgelöst über eine einzige Query mit `group: { memberships: { some: { userId } } }`), und einem Kennzeichen für einen bestehenden Direkt-Grant. Die geerbten Rechte sichtbar zu machen ist der eigentliche Zweck aus D-16 — ohne diese Anzeige ist im Benutzer-Detail nicht erkennbar, warum jemand Zugriff hat.
|
||||||
|
|
||||||
|
Jede erfolgreiche Mutation schreibt eine Logzeile über den `Logger`. Das ist die vollständige Umsetzung von D-23: Änderungen an Freigaben landen im Server-Log, es entsteht bewusst keine Audit-Tabelle und keine Ansicht im Admin-UI.
|
||||||
|
|
||||||
|
Der Datensatz bekommt kein Feld für eine Rechtestufe, und der Service bietet keine Methode, die eine solche setzen könnte (D-04).
|
||||||
|
|
||||||
|
Lege `create-module-grant.dto.ts` an: `moduleId` als `@IsString()` und `@IsNotEmpty()`, `groupId` und `userId` jeweils optional als String. Die Entweder-oder-Regel wird im Service geprüft, weil sie zwei Felder zueinander in Beziehung setzt; das DTO deckt die Feldtypen ab.
|
||||||
|
|
||||||
|
Lege `module-grants.service.spec.ts` an, das jeden unter `<behavior>` genannten Fall mit gemocktem PrismaService abdeckt.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/api test -- module-grants.service</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `pnpm --filter @tessera/api test -- module-grants.service` ist grün und enthält für jeden unter `<behavior>` gelisteten Fall ein eigenes `it(...)`.
|
||||||
|
- `grep -c 'assertTargetBelongsToTenant' apps/api/src/groups/module-grants.service.ts` gibt mindestens `3` aus (Definition plus Aufruf in grant und in getUserAccess beziehungsweise revoke).
|
||||||
|
- `grep -c 'P2002' apps/api/src/groups/module-grants.service.ts` gibt mindestens `1` aus — der Doppelklick-Fall ist behandelt.
|
||||||
|
- `grep -c 'this.logger' apps/api/src/groups/module-grants.service.ts` gibt mindestens `2` aus (grant und revoke).
|
||||||
|
- `grep -cE '(moduleId|groupId|userId)[?]?: string' apps/api/src/groups/dto/create-module-grant.dto.ts` gibt `3` aus, und `grep -cE '^\s+[a-zA-Z]+[?]?:' apps/api/src/groups/dto/create-module-grant.dto.ts` gibt ebenfalls `3` aus — das DTO trägt genau diese drei Felder und kein viertes.
|
||||||
|
- `pnpm --filter @tessera/api run type-check` läuft fehlerfrei durch.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Freigaben lassen sich für Gruppen und Benutzer setzen und entziehen, mandantenübergreifende Ziele werden abgelehnt, und Matrix wie Benutzer-Detail bekommen ihre Daten in je einer Antwort.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: ModuleGrantsController und Einbindung in GroupsModule</name>
|
||||||
|
<files>apps/api/src/groups/module-grants.controller.ts, apps/api/src/groups/groups.module.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/api/src/module-registry/module-registry.controller.ts — das tenantId-aus-Request-Muster und `@UseGuards(RolesGuard)` plus `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`
|
||||||
|
- apps/api/src/groups/groups.controller.ts — der in 15-02 etablierte Routenstil dieses Verzeichnisses, inklusive der Reihenfolge statischer Segmente vor Parameter-Routen
|
||||||
|
- apps/api/src/groups/groups.module.ts — der in 15-02 entstandene Modulaufbau, der hier um Controller und Service ergänzt wird
|
||||||
|
- apps/api/src/groups/module-grants.service.ts — die in Task 1 entstandenen Methodensignaturen
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Lege `apps/api/src/groups/module-grants.controller.ts` mit `ModuleGrantsController` unter dem Pfad `module-grants` an. Routen: `GET /module-grants/matrix` liefert `getMatrix`, `GET /module-grants/users/:userId` liefert `getUserAccess`, `POST /module-grants` legt einen Grant an, `DELETE /module-grants` entzieht einen (Ziel im Body, weil die Kombination aus drei Feldern besteht und nicht sinnvoll in einen Pfadparameter passt).
|
||||||
|
|
||||||
|
Statische Segmente stehen vor Parameter-Routen: `matrix` wird vor `users/:userId` deklariert. Dieses Projekt hat den Beschattungsfehler schon einmal gehabt und Unit-Tests fangen ihn nicht.
|
||||||
|
|
||||||
|
Jede Route liest `tenantId` mit `(req as any).tenantId ?? (req as any).user?.tenantId` und wirft bei fehlendem Kontext `ForbiddenException('No tenant context')`. Jede Route trägt `@UseGuards(RolesGuard)` und `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`.
|
||||||
|
|
||||||
|
Ergänze `GroupsModule` um `ModuleGrantsController` in `controllers`, `ModuleGrantsService` in `providers` und `exports` sowie um den Import von `ModuleRegistryModule`, falls der Service dessen Registry-Methoden benötigt.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/api test -- module-grants && pnpm --filter @tessera/api run type-check</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `grep -c '@Roles(Role.ADMIN, Role.SUPER_ADMIN)' apps/api/src/groups/module-grants.controller.ts` gibt `4` aus — jede der vier Routen ist rollengeschützt.
|
||||||
|
- In `apps/api/src/groups/module-grants.controller.ts` steht die Deklaration von `matrix` vor der Deklaration von `users/:userId`, geprüft über die Zeilennummern aus `grep -n "matrix\|users/:userId"`.
|
||||||
|
- `grep -c 'ModuleGrantsController\|ModuleGrantsService' apps/api/src/groups/groups.module.ts` gibt mindestens `4` aus.
|
||||||
|
- Gegen die laufende lokale API: `GET /module-grants/matrix` als ADMIN liefert HTTP 200 mit den Schlüsseln `modules`, `groups` und `grants`; derselbe Aufruf als USER liefert HTTP 403; `POST /module-grants` mit einer groupId eines fremden Mandanten liefert HTTP 404.
|
||||||
|
- `pnpm --filter @tessera/api run type-check` läuft fehlerfrei durch.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Die Freigabe-Endpunkte sind erreichbar, rollengeschützt und in der Routenreihenfolge frei von Beschattung.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: GET /modules/catalog — beide Statusflags in einer Antwort für den Marketplace</name>
|
||||||
|
<files>apps/api/src/module-registry/module-registry.controller.ts, apps/api/src/module-registry/module-access.service.ts, apps/api/src/module-registry/module-access.service.spec.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/api/src/module-registry/module-registry.controller.ts — der in 15-01 auf Benutzersicht umgestellte `findActive`-Handler und der unveränderte `findAll`
|
||||||
|
- apps/api/src/module-registry/module-registry.service.ts — `findAll` und `findActiveForTenant`, das nach 15-01 weiterhin die mandantenweite Sicht liefert
|
||||||
|
- apps/api/src/module-registry/module-access.service.ts — `getAccessibleModuleIds` aus 15-01
|
||||||
|
- apps/web/src/app/(portal)/marketplace/page.tsx — die Zeilen 55–56, wo der Katalog heute aus zwei Aufrufen zusammengesetzt wird; dieser Handler ersetzt beide
|
||||||
|
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-UI-SPEC.md — Surface Contract 5 und die Backstop-Zeile zum Marketplace-Kartenzustand
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Ergänze `ModuleRegistryController` um den Handler `findCatalog` unter `GET /modules/catalog`, erreichbar für jeden authentifizierten Benutzer wie `GET /modules`. Er liefert je registriertem Modul den vollständigen Modul-Datensatz plus zwei Flags: `isActiveForTenant` aus `TenantModuleActivation` mit `isActive: true` und `hasAccess` aus `ModuleAccessService.getAccessibleModuleIds`. Deklariere den Handler vor jeder Parameter-Route.
|
||||||
|
|
||||||
|
Der Endpoint existiert, weil `GET /modules/active` seit 15-01 die Benutzersicht liefert, der Marketplace laut D-08 aber gerade auch nicht freigegebene Module weiter zeigen soll — gekennzeichnet, nicht ausgeblendet. Mit den bisherigen zwei Aufrufen könnte der Marketplace für einen USER nicht mehr zwischen "nicht aktiviert" und "aktiviert, aber nicht freigegeben" unterscheiden.
|
||||||
|
|
||||||
|
Beide Flags kommen bewusst in derselben Antwort. Damit kann keine Karte kurzzeitig ohne Sperrhinweis klickbar erscheinen und erst nachträglich sperren — das ist die Auflösung der Backstop-Zeile aus dem UI-SPEC zum Marketplace-Kartenzustand, und sie gehört serverseitig gelöst, nicht durch Ladezustands-Akrobatik im Browser.
|
||||||
|
|
||||||
|
Für ADMIN und SUPER_ADMIN ist `hasAccess` bei jedem aktiven Modul wahr, weil `getAccessibleModuleIds` den Rollen-Kurzschluss anwendet — das Sperr-Badge erscheint für sie nie (D-03).
|
||||||
|
|
||||||
|
Ergänze in `module-access.service.ts` eine schmale öffentliche Methode `getCatalogFlags(tenantId, userId, role)`, die die beiden Mengen einmal auflöst und dem Controller zurückgibt, damit der Handler dünn bleibt und die Zugriffsentscheidung nicht im Controller nachgebaut wird.
|
||||||
|
|
||||||
|
Ergänze `module-access.service.spec.ts` um Testfälle für `getCatalogFlags`: ein Modul, das aktiv und freigegeben ist; eines, das aktiv aber nicht freigegeben ist; eines, das nicht aktiviert ist; sowie derselbe Aufruf als ADMIN, bei dem jedes aktive Modul zugänglich ist.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/api test -- module-access.service</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `pnpm --filter @tessera/api test -- module-access.service` ist grün und enthält die vier neuen `getCatalogFlags`-Fälle.
|
||||||
|
- `grep -c "Get('catalog')" apps/api/src/module-registry/module-registry.controller.ts` gibt `1` aus.
|
||||||
|
- `grep -c 'getCatalogFlags' apps/api/src/module-registry/module-access.service.ts apps/api/src/module-registry/module-registry.controller.ts` belegt Definition und Aufruf.
|
||||||
|
- Gegen die laufende lokale API: `GET /modules/catalog` als USER mit einem aktivierten, aber nicht freigegebenen Modul liefert für dieses Modul `isActiveForTenant` wahr und `hasAccess` falsch; derselbe Aufruf als ADMIN liefert für dasselbe Modul beide Flags wahr.
|
||||||
|
- `pnpm --filter @tessera/api test` läuft vollständig grün und `pnpm --filter @tessera/api run type-check` fehlerfrei durch.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Der Marketplace kann in einem einzigen Aufruf zwischen nicht aktiviert, aktiviert-ohne-Freigabe und zugänglich unterscheiden.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Admin-Client → ModuleGrantsController | `moduleId`, `groupId` und `userId` kommen aus dem Request-Body und sind nicht vertrauenswürdig; nur `tenantId` stammt aus dem JWT |
|
||||||
|
| ModuleGrantsService → PostgreSQL | CHECK-Constraint und partielle Unique-Indizes aus 15-01 sind das letzte Netz hinter der App-Validierung |
|
||||||
|
| Beliebiger authentifizierter Benutzer → GET /modules/catalog | Der Katalog ist laut D-08 bewusst für jeden angemeldeten Benutzer sichtbar |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-15-01 | Elevation of Privilege / Tampering | `POST /module-grants` mit einer groupId oder userId eines fremden Mandanten | high | mitigate | `assertTargetBelongsToTenant` prüft das referenzierte Objekt per `findFirst` mit `id` UND `tenantId` gegen den Mandanten aus dem JWT und wirft sonst NotFoundException; zwei eigene Testfälle in `module-grants.service.spec.ts` belegen beide Richtungen |
|
||||||
|
| T-15-02 | Information Disclosure / Tampering | `DELETE /module-grants` und `GET /module-grants/users/:userId` mit fremden IDs | high | mitigate | Jede Query trägt den tenantId-Filter aus dem JWT; `revoke` nutzt `deleteMany` mit tenantId im where, sodass ein fremdes Ziel schlicht null Zeilen trifft |
|
||||||
|
| T-15-21 | Tampering | Rohe Postgres-Constraint-Fehler erreichen den Client | medium | mitigate | Die Entweder-oder-Regel wird im Service vor dem Insert geprüft und als BadRequestException mit Klartext gemeldet; P2002 wird abgefangen. Die DB-Constraint bleibt das letzte Netz, nicht der primäre Fehlerpfad |
|
||||||
|
| T-15-08 | Information Disclosure | `GET /modules/catalog` zeigt jedem angemeldeten Benutzer den vollständigen Modulkatalog des Mandanten | low | accept | Bewusste Entscheidung aus D-08: der Katalog bleibt Schaufenster. Der Endpoint liefert ausschliesslich Modul-Metadaten und zwei Boolesche, keine Modulinhalte und keine Grant-Details anderer Benutzer |
|
||||||
|
| T-15-09 | Repudiation | Freigabe-Änderungen sind nachträglich nicht in der Anwendung nachvollziehbar | low | accept | Bewusste Entscheidung aus D-23: Protokollierung ausschliesslich im Server-Log, keine Audit-Tabelle. Der Service schreibt je Mutation eine Logzeile mit Mandant, Modul, Ziel und Aktion; ein Audit-Trail in der Datenbank ist als spätere Nachrüstung vorgemerkt |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `pnpm --filter @tessera/api test` vollständig grün.
|
||||||
|
- `pnpm --filter @tessera/api run type-check` fehlerfrei.
|
||||||
|
- Manuell gegen die laufende lokale API: einen Gruppen-Grant setzen, mit einem Testbenutzer dieser Gruppe den geschützten Modul-Endpoint aufrufen (HTTP 200), den Grant entziehen, denselben Aufruf wiederholen (HTTP 403) — belegt D-09, dass der Entzug ohne Zwischenschritt wirkt.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Freigaben lassen sich für Gruppen und für einzelne Benutzer setzen und entziehen (PERM-03).
|
||||||
|
- Matrix und Benutzer-Detail bekommen ihre Daten aus je einer Antwort, inklusive der über Gruppen geerbten Rechte (D-15, D-16).
|
||||||
|
- Kein Grant kann auf ein Ziel eines fremden Mandanten zeigen.
|
||||||
|
- Der Marketplace kann alle drei Kartenzustände aus einer Antwort ableiten (D-08, PERM-04).
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
## Artifacts this phase produces
|
||||||
|
|
||||||
|
Von diesem Plan erzeugt beziehungsweise verändert:
|
||||||
|
|
||||||
|
- `ModuleGrantsService` mit `grant`, `revoke`, `getMatrix`, `getUserAccess`, `assertTargetBelongsToTenant`
|
||||||
|
- `ModuleGrantsController` mit `GET /module-grants/matrix`, `GET /module-grants/users/:userId`, `POST /module-grants`, `DELETE /module-grants`
|
||||||
|
- `CreateModuleGrantDto` (`apps/api/src/groups/dto/create-module-grant.dto.ts`)
|
||||||
|
- `ModuleRegistryController.findCatalog` (`GET /modules/catalog`)
|
||||||
|
- `ModuleAccessService.getCatalogFlags`
|
||||||
|
- `GroupsModule` (erweitert um Controller, Service und den Import von `ModuleRegistryModule`)
|
||||||
|
- `apps/api/src/groups/module-grants.service.spec.ts`
|
||||||
|
|
||||||
|
Die phasenweite Gesamtliste steht in `15-01-PLAN.md`.
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Erstelle `.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-03-SUMMARY.md`, wenn der Plan abgeschlossen ist.
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
---
|
||||||
|
phase: 15-modul-berechtigungen-gruppen-user-grants
|
||||||
|
plan: 04
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["15-01"]
|
||||||
|
files_modified:
|
||||||
|
- apps/api/src/ldap/ldap.service.ts
|
||||||
|
- apps/api/src/ldap/ldap.service.spec.ts
|
||||||
|
autonomous: false
|
||||||
|
requirements: [PERM-02]
|
||||||
|
user_setup: []
|
||||||
|
|
||||||
|
estimate:
|
||||||
|
tokens: 48000
|
||||||
|
raw_tokens: 48000
|
||||||
|
tasks: 2
|
||||||
|
confidence: low
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Eine Tessera-Gruppe kann optional an einen AD-Gruppen-DN gebunden werden; ohne gesetzten ldapDn fasst der Sync sie überhaupt nicht an (D-05, PERM-02)."
|
||||||
|
- "Der bestehende Benutzer-Sync pflegt die Gruppenmitgliedschaften im selben Durchlauf mit — es gibt keinen zweiten Sync-Job und keinen zweiten Button im Admin-UI (D-21)."
|
||||||
|
- "Verlässt ein Benutzer die gebundene AD-Gruppe, verschwindet ausschliesslich seine Mitgliedschaft mit source LDAP; eine manuell gesetzte Mitgliedschaft desselben Benutzers in derselben Gruppe bleibt bestehen (D-19)."
|
||||||
|
- "Einer AD-gebundenen Gruppe dürfen zusätzlich Benutzer von Hand hinzugefügt werden; der Sync entfernt diese nie (D-20)."
|
||||||
|
- "Die Mitgliederermittlung läuft als Reverse-Query mit memberOf als Filterkriterium, niemals über das Lesen von member- oder memberOf-Attributwerten — nur so entfällt das Range-Retrieval-Problem grosser AD-Gruppen."
|
||||||
|
- "Der Gruppen-DN wird vor der Interpolation in den LDAP-Filter durch LdapService.escapeLdapFilterValue geführt (RFC 4515)."
|
||||||
|
- "Verschachtelte AD-Gruppen werden in dieser Phase bewusst nicht aufgelöst — nur direkte Mitgliedschaft, konsistent zum bestehenden groupFilterDns-Verhalten aus Phase 2."
|
||||||
|
# --- Edge-Probe PERM-02 (6 Kategorien) ---
|
||||||
|
- "adjacency/PERM-02: Ist ein Benutzer sowohl manuell als auch über das AD Mitglied derselben Gruppe, existiert genau eine GroupMembership-Zeile; der Sync hebt eine bestehende MANUAL-Zeile nicht auf LDAP an und löscht sie nie."
|
||||||
|
- "empty/PERM-02: Liefert die memberOf-Suche einer gebundenen Gruppe null Treffer, verliert die Gruppe alle ihre LDAP-Mitgliedschaften, behält aber alle MANUAL-Mitgliedschaften; eine Gruppe ohne ldapDn wird gar nicht erst abgefragt."
|
||||||
|
- "encoding/PERM-02: Ein Gruppen-DN mit Klammern, Sternchen oder Backslash zerstört den Suchfilter nicht, weil er RFC-4515-escaped interpoliert wird."
|
||||||
|
- "ordering/PERM-02: Die Reihenfolge der AD-Treffer ist unerheblich — das Sync-Ergebnis ist eine Mengenoperation über Benutzernamen und bei umgekehrter Trefferreihenfolge identisch."
|
||||||
|
- "idempotency/PERM-02: Zwei Sync-Läufe ohne AD-Änderung erzeugen keine zusätzliche und löschen keine bestehende GroupMembership-Zeile."
|
||||||
|
- statement: "concurrency/PERM-02: Bricht der Sync mitten in der Gruppenschleife ab, bleiben die bereits verarbeiteten Gruppen konsistent, der Fehler landet in LdapSyncResult.errors, und kein Teilzustand verliert MANUAL-Mitgliedschaften."
|
||||||
|
verification: backstop
|
||||||
|
artifacts:
|
||||||
|
- "apps/api/src/ldap/ldap.service.ts — neue private Methode syncGroupMembershipsForTenant, aufgerufen aus syncUsersForTenant"
|
||||||
|
- "apps/api/src/ldap/ldap.service.spec.ts — erweitert um die AD-Gruppenbindungs-Fälle"
|
||||||
|
key_links:
|
||||||
|
- "syncUsersForTenant → syncGroupMembershipsForTenant — der Gruppen-Abgleich hängt am bestehenden Durchlauf, nicht an einem eigenen Job (D-21)"
|
||||||
|
- "GroupMembership.source = LDAP → die Lösch-Query des Sync — dieser Filter ist der einzige Schutz manueller Mitgliedschaften (D-19/D-20)"
|
||||||
|
- "Group.ldapDn → der memberOf-Filter — ohne diesen Wert ist eine Gruppe für den Sync unsichtbar"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Die AD-Bindung von Gruppen: der bestehende Benutzer-Sync liest zusätzlich für jede an eine AD-Gruppe gebundene Tessera-Gruppe deren Mitglieder und gleicht die Mitgliedschaften ab — im selben Durchlauf, manuell per Button wie über das eingestellte Intervall.
|
||||||
|
|
||||||
|
Purpose: Ohne AD-Bindung müsste ein Admin jede Personaländerung zweimal pflegen. Der Abgleich muss dabei strikt zwischen den beiden Herkünften trennen: für gebundene Gruppen ist das AD die Wahrheit über seine eigenen Einträge, aber niemals über die von Hand gesetzten (D-19, D-20).
|
||||||
|
Output: Eine neue private Methode in `LdapService`, ein Aufruf im bestehenden `syncUsersForTenant`, und eine erweiterte Testsuite.
|
||||||
|
</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/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-01-SUMMARY.md
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Gruppenmitgliedschafts-Abgleich im bestehenden LDAP-Sync-Durchlauf</name>
|
||||||
|
<files>apps/api/src/ldap/ldap.service.ts, apps/api/src/ldap/ldap.service.spec.ts</files>
|
||||||
|
<precondition>Die aus Plan 15-01 stammende Migration ist angewendet und der Prisma-Client kennt `Group`, `GroupMembership` und `MembershipSource` — ohne die generierten Typen kompiliert `ldap.service.ts` nicht.</precondition>
|
||||||
|
<read_first>
|
||||||
|
- apps/api/src/ldap/ldap.service.ts — Zeilen 559–717 (`syncUsersForTenant`: der Base-DN-No-Op-Wächter, die Benutzerschleife, die Deaktivierungsschleife, das Aktualisieren von lastSyncAt) und Zeilen 737–792 (`collectSearchEntries`: exakt das memberOf-Filtermuster, das hier wiederverwendet wird)
|
||||||
|
- apps/api/src/ldap/ldap.service.ts — Zeilen 280–293 (`mapEntry`, die Auflösung eines Eintrags auf einen kleingeschriebenen Benutzernamen) und Zeile 834 (`escapeLdapFilterValue`)
|
||||||
|
- apps/api/src/ldap/ldap.service.ts — Zeilen 197–212 (`parseBaseDns`), weil der Gruppen-Abgleich dieselben Base-DNs durchsucht
|
||||||
|
- apps/api/src/ldap/ldap.service.spec.ts — der bestehende Aufbau der Suite, insbesondere wie der ldapts-`Client` gemockt wird
|
||||||
|
- apps/api/prisma/schema.prisma — die aus 15-01 stammenden Modelle Group und GroupMembership samt `@@unique([groupId, userId])`
|
||||||
|
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-RESEARCH.md — Pattern 3 und Pitfall 1 zum Range-Retrieval
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- Ein Mandant ohne AD-gebundene Gruppen führt keine zusätzliche LDAP-Suche aus.
|
||||||
|
- Für jede Gruppe mit gesetztem `ldapDn` wird genau eine Suche je konfiguriertem Base-DN abgesetzt, mit dem Filter aus dem sanitisierten Basisfilter UND einer memberOf-Klausel auf den escapeten Gruppen-DN.
|
||||||
|
- Ein AD-Treffer, dessen Benutzername in der lokalen Datenbank existiert, erzeugt eine `GroupMembership` mit `source: LDAP`.
|
||||||
|
- Ein AD-Treffer, zu dem kein lokaler Benutzer existiert, erzeugt keine Mitgliedschaft und keinen Fehler — der Benutzer wurde entweder ausgeschlossen oder liegt ausserhalb der Sync-Basis.
|
||||||
|
- Eine bestehende Mitgliedschaft mit `source: LDAP`, deren Benutzer nicht mehr im Suchergebnis ist, wird gelöscht.
|
||||||
|
- Eine bestehende Mitgliedschaft mit `source: MANUAL` wird nie gelöscht, auch wenn der Benutzer nicht im Suchergebnis ist.
|
||||||
|
- Ist ein Benutzer sowohl im AD-Ergebnis als auch bereits mit `source: MANUAL` eingetragen, bleibt genau eine Zeile bestehen und ihre `source` bleibt `MANUAL`.
|
||||||
|
- Ein zweiter Lauf ohne AD-Änderung erzeugt und löscht keine Zeile.
|
||||||
|
- Liefert die Suche einer gebundenen Gruppe null Treffer, werden alle LDAP-Mitgliedschaften dieser Gruppe entfernt und alle MANUAL-Mitgliedschaften behalten.
|
||||||
|
- Wirft die Suche für eine Gruppe, wird der Fehler mit dem Gruppennamen in `LdapSyncResult.errors` aufgenommen und die Schleife läuft mit der nächsten Gruppe weiter.
|
||||||
|
- Ein Gruppen-DN mit den Zeichen `(`, `)`, `*` oder `\` wird escaped in den Filter interpoliert.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Erweitere `LdapSyncResult` um zwei Zählfelder `groupMembershipsAdded` und `groupMembershipsRemoved`, damit der bestehende Rückgabewert des Sync-Endpoints den Gruppen-Abgleich sichtbar macht.
|
||||||
|
|
||||||
|
Füge `LdapService` eine private Methode `syncGroupMembershipsForTenant(client, config, sanitizedFilter, attributes, tenantId, result)` hinzu. Sie lädt alle `group`-Zeilen des Mandanten mit `ldapDn: { not: null }`. Ist die Liste leer, kehrt sie sofort zurück, ohne eine Suche abzusetzen.
|
||||||
|
|
||||||
|
Für jede gebundene Gruppe baut sie den Filter nach dem Muster aus `collectSearchEntries`: `(&<sanitizedFilter>(memberOf=<escapeLdapFilterValue(group.ldapDn)>))`. Der Gruppen-DN ist Admin-Eingabe aus dem Auswahldialog und wird deshalb immer escaped, nie roh interpoliert. Die Suche läuft über jeden von `parseBaseDns(config.baseDn)` gelieferten Base-DN mit `scope: 'sub'` und derselben Attributliste wie der Benutzer-Sync. Die Treffer werden über `mapEntry` auf kleingeschriebene Benutzernamen abgebildet und in einem Set gesammelt — die Reihenfolge der Treffer spielt damit keine Rolle.
|
||||||
|
|
||||||
|
Lies memberOf niemals als Rückgabeattribut, um Mitglieder aufzuzählen. Bei AD-Gruppen mit mehr als 1500 Mitgliedern liefert das Verzeichnis stillschweigend nur einen Teilausschnitt in der Form `attribut;range=0-1499`, und der Sync würde eine gebundene Gruppe dauerhaft mit genau 1500 Mitgliedern führen, ohne dass irgendwo ein Fehler auftaucht. Die Filter-Variante umgeht das Problem strukturell.
|
||||||
|
|
||||||
|
Der Abgleich pro Gruppe läuft dann in drei Schritten. Erstens: die lokalen Benutzer des Mandanten zu den gefundenen Benutzernamen auflösen (eine einzige Query mit `username: { in: [...] }` und `tenantId`, keine Schleife). Zweitens: fehlende Mitgliedschaften mit `createMany` und `skipDuplicates: true` und `source: 'LDAP'` anlegen; `skipDuplicates` gegen `@@unique([groupId, userId])` ist genau der Mechanismus, der eine bestehende MANUAL-Zeile unangetastet lässt, statt sie auf LDAP umzuschreiben. Drittens: mit `deleteMany` und `where: { groupId, source: 'LDAP', userId: { notIn: [...] } }` die verwaisten AD-Mitgliedschaften entfernen. Der Filter auf `source: 'LDAP'` ist der einzige Schutz der manuell gesetzten Mitgliedschaften — ohne ihn würde jeder Sync-Lauf den Mischbetrieb aus D-20 zerstören.
|
||||||
|
|
||||||
|
Verschachtelte AD-Gruppen werden nicht aufgelöst: es kommt ausschliesslich der einfache `memberOf`-Filter zum Einsatz, nicht die AD-Erweiterung `LDAP_MATCHING_RULE_IN_CHAIN`. Halte das als Kommentar an der Methode fest, samt Begründung (kein Bedarf in D-19, herstellerspezifisch, würde auf einem Nicht-AD-Verzeichnis stillschweigend null Treffer liefern) — sonst wirkt es wie ein Versehen.
|
||||||
|
|
||||||
|
Jede Gruppe läuft in einem eigenen try/catch. Ein Fehler wird als `Gruppe <name>: <message>` in `result.errors` aufgenommen und die Schleife läuft weiter, exakt wie die bestehende Behandlung pro Verzeichniseintrag in der Benutzerschleife.
|
||||||
|
|
||||||
|
Rufe die neue Methode in `syncUsersForTenant` nach Schritt 5 (Deaktivierungsschleife) und vor Schritt 6 (Aktualisieren von `lastSyncAt`) auf. Sie liegt damit hinter dem Base-DN-No-Op-Wächter: ein Mandant ohne konfigurierte Base-DN führt weiterhin keinerlei LDAP-Aktion aus. Der Abgleich läuft ohne weiteres Zutun sowohl beim manuellen Sync über `POST /ldap/sync` als auch im Intervall-Scheduler, weil beide dieselbe Methode aufrufen — es entsteht kein zweiter Job und kein zweiter Button (D-21).
|
||||||
|
|
||||||
|
Erweitere `apps/api/src/ldap/ldap.service.spec.ts` um einen `describe`-Block, der jeden unter `<behavior>` genannten Fall mit gemocktem ldapts-`Client` und gemocktem PrismaService abdeckt.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/api test -- ldap.service</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `pnpm --filter @tessera/api test -- ldap.service` ist grün und enthält für jeden unter `<behavior>` gelisteten Fall ein eigenes `it(...)`; insbesondere existiert ein Test, der nach dem Sync eine verbliebene MANUAL-Mitgliedschaft eines nicht mehr im AD gefundenen Benutzers belegt.
|
||||||
|
- `grep -c 'syncGroupMembershipsForTenant' apps/api/src/ldap/ldap.service.ts` gibt `2` aus (Definition und der eine Aufruf in `syncUsersForTenant`).
|
||||||
|
- `grep -c "escapeLdapFilterValue" apps/api/src/ldap/ldap.service.ts` ist gegenüber dem Stand vor diesem Task um mindestens `1` gestiegen.
|
||||||
|
- `grep -c "source: 'LDAP'" apps/api/src/ldap/ldap.service.ts` gibt mindestens `2` aus — Anlage- und Löschpfad sind beide auf die AD-Herkunft eingeschränkt.
|
||||||
|
- Die neue Methode nimmt die Attributliste als Parameter entgegen und reicht sie unverändert an `client.search` weiter, statt eine eigene zu bilden — geprüft über `grep -c 'attributes,' apps/api/src/ldap/ldap.service.ts`, dessen Ergebnis gegenüber dem Stand vor diesem Task um mindestens `2` gestiegen ist (Parameter und Weitergabe).
|
||||||
|
- Ein Testfall in `ldap.service.spec.ts` belegt, dass der an `client.search` übergebene Filter die memberOf-Klausel enthält und die übergebene Attributliste identisch zu der des Benutzer-Sync ist — damit ist die Mitgliedschaft ein Filterkriterium und kein Rückgabeattribut.
|
||||||
|
- `pnpm --filter @tessera/api test` läuft vollständig grün — die bestehenden Sync-Tests aus den Quick-Tasks 260728-lih und 260729-d3k bleiben unverändert bestanden.
|
||||||
|
- `pnpm --filter @tessera/api run type-check` läuft fehlerfrei durch.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Der bestehende Sync-Durchlauf pflegt zusätzlich die Mitgliedschaften jeder AD-gebundenen Gruppe, entfernt dabei ausschliesslich seine eigenen Einträge, und der No-Op-Wächter für unkonfigurierte Mandanten bleibt wirksam.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="checkpoint:human-verify" gate="blocking">
|
||||||
|
<name>Task 2: Live-Abgleich der AD-Gruppenbindung gegen ein echtes Active Directory</name>
|
||||||
|
<what-built>
|
||||||
|
Der Benutzer-Sync gleicht jetzt zusätzlich die Mitgliedschaften jeder Tessera-Gruppe ab, die an einen AD-Gruppen-DN gebunden ist. Der Abgleich läuft im bestehenden Durchlauf mit — manuell über den Sync-Button wie über das Intervall — und entfernt ausschliesslich Mitgliedschaften mit der Herkunft LDAP.
|
||||||
|
</what-built>
|
||||||
|
<how-to-verify>
|
||||||
|
Diese Prüfung steht so in `15-VALIDATION.md` unter "Manual-Only Verifications" und ist nicht durch Unit-Tests ersetzbar: das Verhalten gegen ein echtes Active Directory ist laut Research nur mit MEDIUM-Confidence belegt.
|
||||||
|
|
||||||
|
1. In der Datenbank eine Tessera-Gruppe anlegen und ihren `ldapDn` auf eine reale AD-Gruppe von `balios.ctl.local` setzen (die Auswahl-Oberfläche dafür entsteht erst in Plan 15-06, bis dahin genügt ein direktes UPDATE oder `PATCH /groups/:id`).
|
||||||
|
2. `POST /ldap/sync` auslösen und die Antwort prüfen: `groupMembershipsAdded` ist grösser als 0.
|
||||||
|
3. `SELECT count(*) FROM "GroupMembership" WHERE "groupId" = '<id>';` mit der Mitgliederzahl der AD-Gruppe vergleichen — beide müssen übereinstimmen. Steht dort dauerhaft exakt 1000 oder 1500, ist das Range-Retrieval-Problem doch aufgetreten und der Task gilt als nicht bestanden.
|
||||||
|
4. Über `POST /groups/:id/members` einen Benutzer von Hand hinzufügen, der NICHT in der AD-Gruppe ist. Erneut syncen. Die manuelle Mitgliedschaft muss erhalten bleiben (D-20).
|
||||||
|
5. Im AD einen Benutzer aus der Gruppe entfernen, erneut syncen. Ausschliesslich seine Zeile mit `source = 'LDAP'` darf verschwinden; die manuelle Mitgliedschaft aus Schritt 4 bleibt unverändert (D-19).
|
||||||
|
</how-to-verify>
|
||||||
|
<resume-signal>Antworte "approved" oder beschreibe die Abweichung</resume-signal>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Admin-Eingabe → LDAP-Suchfilter | `Group.ldapDn` stammt aus einer Admin-Auswahl und wird in einen Suchfilter interpoliert |
|
||||||
|
| Active Directory → Tessera-Datenbank | Die Trefferliste des Verzeichnisses steuert das Anlegen und Löschen von Mitgliedschaften |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-15-07 | Tampering | Interpolation von `Group.ldapDn` in den memberOf-Filter | medium | mitigate | Der DN läuft immer durch `LdapService.escapeLdapFilterValue` (RFC 4515), identisch zur bestehenden Behandlung von `groupFilterDns`; ein Testfall mit Sonderzeichen im DN belegt es |
|
||||||
|
| T-15-15 | Elevation of Privilege | Ein AD-Treffer aus einem fremden Mandanten wird Mitglied einer Gruppe | high | mitigate | Die Auflösung von Benutzernamen auf lokale Benutzer filtert immer zusätzlich auf die `tenantId` des Sync-Laufs; ein Benutzername, der nur in einem anderen Mandanten existiert, erzeugt keine Mitgliedschaft |
|
||||||
|
| T-15-16 | Tampering | Der Sync löscht manuell gesetzte Mitgliedschaften | high | mitigate | Die Lösch-Query filtert ausschliesslich auf `source: 'LDAP'`; ein eigener Testfall belegt, dass eine MANUAL-Zeile eines nicht mehr gefundenen Benutzers erhalten bleibt (D-19/D-20) |
|
||||||
|
| T-15-17 | Information Disclosure | Unvollständige Mitgliederliste durch AD-Range-Retrieval | medium | mitigate | Mitglieder werden ausschliesslich per Reverse-Query mit memberOf als Filterkriterium ermittelt; die Attributliste der Suche enthält kein Mitgliedschaftsattribut. Der Human-Verify-Checkpoint prüft die Zahl gegen ein echtes AD |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `pnpm --filter @tessera/api test` vollständig grün.
|
||||||
|
- `pnpm --filter @tessera/api run type-check` fehlerfrei.
|
||||||
|
- Manueller Live-Abgleich gegen `balios.ctl.local` laut Checkpoint-Anleitung, inklusive Mischbetrieb aus manueller und AD-Mitgliedschaft.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Eine Gruppe mit gesetztem `ldapDn` bekommt ihre Mitglieder aus dem AD, im bestehenden Sync-Durchlauf (PERM-02, D-21).
|
||||||
|
- Manuell gesetzte Mitgliedschaften überleben jeden Sync-Lauf (D-19, D-20).
|
||||||
|
- Kein Codepfad liest Mitgliedschaftsattribute als Rückgabewerte.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
## Artifacts this phase produces
|
||||||
|
|
||||||
|
Von diesem Plan erzeugt beziehungsweise verändert:
|
||||||
|
|
||||||
|
- `LdapService.syncGroupMembershipsForTenant` (privat, `apps/api/src/ldap/ldap.service.ts`)
|
||||||
|
- `LdapSyncResult` erweitert um `groupMembershipsAdded` und `groupMembershipsRemoved`
|
||||||
|
- `apps/api/src/ldap/ldap.service.spec.ts` erweitert um den `describe`-Block zur AD-Gruppenbindung
|
||||||
|
|
||||||
|
Die phasenweite Gesamtliste steht in `15-01-PLAN.md`.
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Erstelle `.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-04-SUMMARY.md`, wenn der Plan abgeschlossen ist.
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
---
|
||||||
|
phase: 15-modul-berechtigungen-gruppen-user-grants
|
||||||
|
plan: 05
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["15-01"]
|
||||||
|
files_modified:
|
||||||
|
- apps/api/src/dashboard/widget-module-map.ts
|
||||||
|
- apps/api/src/dashboard/dashboard.service.ts
|
||||||
|
- apps/api/src/dashboard/dashboard.controller.ts
|
||||||
|
- apps/api/src/dashboard/dashboard.module.ts
|
||||||
|
- apps/api/src/dashboard/dashboard.service.spec.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements: [PERM-07]
|
||||||
|
user_setup: []
|
||||||
|
|
||||||
|
estimate:
|
||||||
|
tokens: 38000
|
||||||
|
raw_tokens: 38000
|
||||||
|
tasks: 2
|
||||||
|
confidence: low
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Ein Dashboard-Widget, dessen zugeordnetes Modul dem Benutzer nicht freigegeben ist, wird serverseitig aus GET /dashboard/widgets herausgefiltert und erscheint nicht auf dem Dashboard (PERM-07, D-22)."
|
||||||
|
- "Die Zuordnung Widget-Typ zu Modul lebt als statische Registrierung im Code, nicht als Feld auf WidgetInstance — es entsteht keine Schema-Migration für eine Spalte, die aktuell für jede Zeile leer wäre."
|
||||||
|
- "Am Ende dieser Phase ist die Zuordnungstabelle bewusst leer: es gibt in Tessera noch kein modulgebundenes Widget, diese Phase liefert ausschliesslich die Mechanik (D-22, 15-RESEARCH.md Pitfall 5)."
|
||||||
|
- "Die Filterung nutzt dieselbe ModuleAccessService-Auflösung wie Guard und Sidebar — es entsteht keine zweite Zugriffslogik im Dashboard (D-01)."
|
||||||
|
- "Das Widget verschwindet ersatzlos; es gibt keine Platzhalter-Kachel und keinen Gesperrt-Zustand im Grid (15-UI-SPEC.md, Abschnitt Dashboard-Widgets)."
|
||||||
|
- "Die WidgetInstance-Zeile bleibt beim Filtern erhalten — wird das Modul später wieder freigegeben, ist das Widget mit seiner Konfiguration unverändert zurück."
|
||||||
|
# --- Edge-Probe PERM-07 (5 Kategorien) ---
|
||||||
|
- "adjacency/PERM-07: Ein Widget-Typ, der in der Zuordnungstabelle steht und dessen Modul freigegeben ist, bleibt sichtbar; ein Typ, der nicht in der Tabelle steht, wird nie gefiltert."
|
||||||
|
- "empty/PERM-07: Mit leerer Zuordnungstabelle — dem Zustand am Ende dieser Phase — liefert getWidgets exakt dieselbe Menge wie zuvor; kein Bestandswidget verschwindet."
|
||||||
|
- "ordering/PERM-07: getWidgets behält die Sortierung nach createdAt aufsteigend auch nach dem Filtern bei; das Entfernen eines Widgets ändert die relative Reihenfolge der übrigen nicht."
|
||||||
|
- "idempotency/PERM-07: Die Filterung ist rein lesend — ein zweiter Aufruf löscht keine WidgetInstance-Zeile."
|
||||||
|
- "concurrency/PERM-07: Ein halb gefiltertes Ergebnis kann nicht entstehen, weil eine einzige Query und ein einziger Filterdurchlauf über deren Ergebnis läuft."
|
||||||
|
artifacts:
|
||||||
|
- "apps/api/src/dashboard/widget-module-map.ts — WIDGET_MODULE_MAP"
|
||||||
|
- "apps/api/src/dashboard/dashboard.service.ts — getWidgets mit Modulfilter"
|
||||||
|
- "apps/api/src/dashboard/dashboard.service.spec.ts"
|
||||||
|
key_links:
|
||||||
|
- "DashboardModule importiert ModuleRegistryModule — ohne diesen Import lässt sich ModuleAccessService nicht in DashboardService injizieren"
|
||||||
|
- "DashboardController.getWidgets → DashboardService.getWidgets(userId, tenantId, role) — die Signaturerweiterung muss am Controller mitgezogen werden, sonst fehlen Mandant und Rolle"
|
||||||
|
- "WIDGET_MODULE_MAP → ModuleAccessService — die Tabelle ist der einzige Auslöser für einen Zugriffs-Lookup im Dashboard"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Die Mechanik hinter D-22: Dashboard-Widgets bekommen einen optionalen Modul-Bezug, und Widgets eines für den Benutzer gesperrten Moduls werden serverseitig aus der Auslieferung entfernt.
|
||||||
|
|
||||||
|
Purpose: Ohne diesen Filter bliebe ein Modul-Widget auf dem Dashboard stehen und würde beim Datenabruf ins Leere laufen oder — schlimmer — Inhalte eines Moduls anzeigen, für das der Benutzer keine Freigabe hat. Wichtig ist die Abgrenzung: diese Phase baut ausschliesslich die Zuordnungsmechanik, keinen neuen Widget-Typ. Das einzige geplante modulgebundene Widget ist in REQUIREMENTS.md ausdrücklich zurückgestellt.
|
||||||
|
Output: Eine statische Zuordnungstabelle, ein modulgefiltertes `getWidgets` und die erste Testsuite für `DashboardService` überhaupt.
|
||||||
|
</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/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-01-SUMMARY.md
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Statische Widget-Modul-Zuordnung anlegen</name>
|
||||||
|
<files>apps/api/src/dashboard/widget-module-map.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/web/src/components/dashboard/widget-registry.tsx — die acht bestehenden Widget-Typen als Objekt-Literal; die Schlüssel dieser Registrierung sind die Werte, die in `WidgetInstance.widgetType` landen
|
||||||
|
- apps/api/prisma/schema.prisma — das Modell `WidgetInstance` mit seinem freien `widgetType`-String
|
||||||
|
- apps/api/src/tenders/source-registry.spec.ts — Vorbild für eine Testsuite über eine statische Registrierung in diesem Projekt
|
||||||
|
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-RESEARCH.md — Pattern 7 und Pitfall 5
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Lege `apps/api/src/dashboard/widget-module-map.ts` an und exportiere die Konstante `WIDGET_MODULE_MAP` vom Typ `Readonly<Record<string, string>>`, die einen Widget-Typ auf einen Modul-Slug abbildet.
|
||||||
|
|
||||||
|
Die Tabelle ist am Ende dieser Phase leer, und das ist die richtige Auslieferung: alle acht heute registrierten Widget-Typen sind Plattform-Widgets ohne Modulbezug, und das einzige geplante modulgebundene Widget steht in `.planning/REQUIREMENTS.md` unter "Future Requirements (deferred)". Registriere in diesem Task keinen neuen Widget-Typ.
|
||||||
|
|
||||||
|
Setze einen deutschen Blockkommentar über die Konstante, der drei Dinge festhält: erstens den Zweck (D-22, Widgets eines gesperrten Moduls verschwinden vom Dashboard), zweitens dass Schlüssel Werte von `WidgetInstance.widgetType` und Werte Modul-Slugs aus `Module.slug` sind, drittens die bewusste Entscheidung gegen eine Datenbankspalte — eine Migration auf einer bereits befüllten Tabelle für ein Feld, das derzeit für jede Zeile leer wäre, wiegt schwerer als eine TypeScript-Konstante mit identischer Aussagekraft.
|
||||||
|
|
||||||
|
Exportiere zusätzlich die Hilfsfunktion `getModuleSlugForWidgetType(widgetType: string): string | undefined`, damit der Zugriff auf die Tabelle an einer Stelle liegt und in Tests gezielt gemockt werden kann.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/api run type-check</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `apps/api/src/dashboard/widget-module-map.ts` existiert und exportiert `WIDGET_MODULE_MAP` sowie `getModuleSlugForWidgetType`.
|
||||||
|
- `grep -c "export const WIDGET_MODULE_MAP" apps/api/src/dashboard/widget-module-map.ts` gibt `1` aus.
|
||||||
|
- `grep -c "widgetType" apps/api/prisma/schema.prisma` ist unverändert gegenüber dem Stand vor diesem Task — es entsteht keine Schemaänderung.
|
||||||
|
- `ls apps/api/prisma/migrations/ | wc -l` ist unverändert gegenüber dem Stand vor diesem Task.
|
||||||
|
- `pnpm --filter @tessera/api run type-check` läuft fehlerfrei durch.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Die Zuordnungsmechanik existiert als Code-Konstante mit dokumentierter Begründung, ohne Schemaänderung und ohne neuen Widget-Typ.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: getWidgets filtert über dieselbe Zugriffsauflösung wie Guard und Sidebar</name>
|
||||||
|
<files>apps/api/src/dashboard/dashboard.service.ts, apps/api/src/dashboard/dashboard.service.spec.ts, apps/api/src/dashboard/dashboard.controller.ts, apps/api/src/dashboard/dashboard.module.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/api/src/dashboard/dashboard.service.ts — Zeilen 94–99 (`getWidgets`) und Zeilen 119–164 (das Ownership-Prüfmuster) als Kontext für den bestehenden Stil
|
||||||
|
- apps/api/src/dashboard/dashboard.controller.ts — `extractContext` (Zeilen 43–54) und der `@Get('widgets')`-Handler
|
||||||
|
- apps/api/src/dashboard/dashboard.module.ts — imports/providers/exports
|
||||||
|
- apps/api/src/module-registry/module-access.service.ts — die aus 15-01 stammende Signatur von `getAccessibleModuleIds`
|
||||||
|
- apps/api/src/module-registry/module-registry.module.ts — die exports, an denen DashboardModule andockt
|
||||||
|
- apps/api/src/dashboard/widget-module-map.ts — die in Task 1 entstandene Tabelle
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- Mit leerer Zuordnungstabelle liefert `getWidgets` exakt die Menge zurück, die die Prisma-Query geliefert hat — kein Widget wird entfernt und kein Zugriffs-Lookup ausgeführt.
|
||||||
|
- Steht ein Widget-Typ in der Tabelle und ist das zugehörige Modul für den Benutzer zugänglich, bleibt das Widget in der Antwort.
|
||||||
|
- Steht ein Widget-Typ in der Tabelle und ist das zugehörige Modul für den Benutzer nicht zugänglich, fehlt das Widget in der Antwort.
|
||||||
|
- Für einen ADMIN bleibt ein modulgebundenes Widget eines aktiven Moduls sichtbar, auch ohne Grant (D-03).
|
||||||
|
- Widgets, deren Typ nicht in der Tabelle steht, bleiben unabhängig von jeder Zugriffsentscheidung erhalten.
|
||||||
|
- Die Sortierung nach `createdAt` aufsteigend bleibt nach dem Filtern erhalten.
|
||||||
|
- `getWidgets` löscht keine `WidgetInstance`-Zeile — die Prisma-Mocks belegen, dass ausschliesslich `findMany` aufgerufen wird.
|
||||||
|
- Existiert zu einem in der Tabelle eingetragenen Modul-Slug kein `Module`-Datensatz, wird das Widget herausgefiltert und nicht durchgelassen (Fail-Closed).
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Ergänze `DashboardModule` um den Import von `ModuleRegistryModule`, damit `ModuleAccessService` injizierbar wird.
|
||||||
|
|
||||||
|
Erweitere die Signatur zu `getWidgets(userId: string, tenantId: string, role: Role)`. Der Ablauf: erst die bestehende `findMany`-Query mit `where: { userId }` und `orderBy: { createdAt: 'asc' }` unverändert ausführen. Dann prüfen, ob unter den geladenen Widgets überhaupt ein Typ vorkommt, der in `WIDGET_MODULE_MAP` steht. Ist das nicht der Fall — der Zustand am Ende dieser Phase — wird die Liste unverändert zurückgegeben, ohne einen einzigen Zugriffs-Lookup. Nur wenn mindestens ein modulgebundenes Widget dabei ist, wird `ModuleAccessService.getAccessibleModuleIds(tenantId, userId, role)` einmal aufgerufen und die zugehörigen Modul-Slugs über `ModuleRegistryService.findBySlug` beziehungsweise eine einzelne `module.findMany`-Query auf ihre IDs abgebildet. Anschliessend wird die geladene Liste mit `filter` durchgegangen: Widgets ohne Eintrag in der Tabelle bleiben immer erhalten, Widgets mit Eintrag nur dann, wenn die aufgelöste Modul-ID im zugänglichen Set liegt. Lässt sich der Slug nicht auf einen Modul-Datensatz auflösen, wird das Widget entfernt — im Zweifel schliessen, nicht öffnen.
|
||||||
|
|
||||||
|
Es entsteht keine eigene Zugriffslogik im Dashboard: die Entscheidung kommt vollständig aus `ModuleAccessService`, derselben Methode, die auch `ModuleGuard` und `GET /modules/active` bedienen (D-01).
|
||||||
|
|
||||||
|
Es wird nichts gelöscht: die `WidgetInstance`-Zeile bleibt bestehen, nur die Auslieferung wird gefiltert. Wird ein Modul später wieder freigegeben, ist das Widget mit seiner gespeicherten Konfiguration unverändert zurück.
|
||||||
|
|
||||||
|
Passe den Aufruf in `DashboardController` an: `extractContext` liefert bereits `userId` und `tenantId`, ergänze die Rolle über `(req as any).user?.role` und reiche alle drei durch.
|
||||||
|
|
||||||
|
Lege `apps/api/src/dashboard/dashboard.service.spec.ts` an — die Datei existiert bisher nicht, `DashboardService` ist komplett ungetestet. Decke jeden unter `<behavior>` genannten Fall ab, wobei `WIDGET_MODULE_MAP` je Testfall über `vi.mock` auf einen kontrollierten Inhalt gesetzt wird, damit die Tests unabhängig davon bleiben, welche Widget-Typen künftig real eingetragen werden.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/api test -- dashboard.service</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `pnpm --filter @tessera/api test -- dashboard.service` ist grün und enthält für jeden unter `<behavior>` gelisteten Fall ein eigenes `it(...)`.
|
||||||
|
- `grep -c 'getAccessibleModuleIds' apps/api/src/dashboard/dashboard.service.ts` gibt `1` aus — genau ein Aufruf, kein Lookup je Widget.
|
||||||
|
- `grep -c 'ModuleRegistryModule' apps/api/src/dashboard/dashboard.module.ts` gibt mindestens `2` aus (Import und Eintrag in imports).
|
||||||
|
- `grep -c "orderBy: { createdAt: 'asc' }" apps/api/src/dashboard/dashboard.service.ts` gibt mindestens `1` aus — die Sortierung der bestehenden Query bleibt unverändert.
|
||||||
|
- Gegen die laufende lokale API mit leerer Zuordnungstabelle: `GET /dashboard/widgets` liefert für einen Bestandsbenutzer dieselbe Anzahl Widgets wie vor diesem Plan.
|
||||||
|
- `pnpm --filter @tessera/api test` läuft vollständig grün und `pnpm --filter @tessera/api run type-check` fehlerfrei durch.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Ein modulgebundenes Widget verschwindet für einen Benutzer ohne Freigabe serverseitig aus der Auslieferung, während bei leerer Zuordnungstabelle nachweislich kein Bestandswidget betroffen ist.</done>
|
||||||
|
<reversibility rating="costly">Setzt D-22 um: die Widget-Auslieferung hängt danach an der Zugriffsauflösung. Der Rückbau ist überschaubar, weil die Zuordnung eine Code-Konstante ohne Schemaänderung ist — es gibt keine Daten zurückzuwickeln.</reversibility>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Browser → GET /dashboard/widgets | userId, tenantId und Rolle stammen ausschliesslich aus dem JWT über `extractContext`, nie aus Query oder Body |
|
||||||
|
| DashboardService → ModuleAccessService | Die Zugriffsentscheidung wird delegiert, nicht im Dashboard nachgebaut |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-15-18 | Information Disclosure | Ein Widget eines gesperrten Moduls bleibt ausgeliefert und zeigt Moduldaten | high | mitigate | Die Filterung läuft serverseitig in `getWidgets`, nicht im Browser; `dashboard.service.spec.ts` belegt den Entfernungsfall. Fail-Closed bei nicht auflösbarem Modul-Slug |
|
||||||
|
| T-15-19 | Elevation of Privilege | Eine zweite, abweichende Zugriffslogik im Dashboard | medium | mitigate | `DashboardService` ruft ausschliesslich `ModuleAccessService.getAccessibleModuleIds` auf und implementiert keine eigene Regel; der Grep in den Akzeptanzkriterien belegt den einen Aufruf |
|
||||||
|
| T-15-20 | Denial of Service | Ein Zugriffs-Lookup je Widget bei vielen Kacheln | low | mitigate | Genau ein Aufruf je Anfrage, und der entfällt komplett, solange kein geladenes Widget in der Zuordnungstabelle steht |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `pnpm --filter @tessera/api test` vollständig grün, inklusive der neuen `dashboard.service.spec.ts`.
|
||||||
|
- `pnpm --filter @tessera/api run type-check` fehlerfrei.
|
||||||
|
- Manuell: Dashboard eines Bestandsbenutzers vor und nach diesem Plan vergleichen — identische Kachelmenge, weil die Zuordnungstabelle leer ist.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Die Mechanik aus D-22 existiert und ist getestet (PERM-07).
|
||||||
|
- Bei leerer Zuordnungstabelle verändert sich für keinen Bestandsbenutzer etwas.
|
||||||
|
- Es entsteht kein neuer Widget-Typ und keine Schemaänderung.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
## Artifacts this phase produces
|
||||||
|
|
||||||
|
Von diesem Plan erzeugt beziehungsweise verändert:
|
||||||
|
|
||||||
|
- `WIDGET_MODULE_MAP` und `getModuleSlugForWidgetType` (`apps/api/src/dashboard/widget-module-map.ts`)
|
||||||
|
- `DashboardService.getWidgets(userId, tenantId, role)` mit Modulfilter
|
||||||
|
- `DashboardController.getWidgets` (Signaturanpassung)
|
||||||
|
- `DashboardModule` (Import von `ModuleRegistryModule`)
|
||||||
|
- `apps/api/src/dashboard/dashboard.service.spec.ts` (neu — erste Testsuite für diesen Service)
|
||||||
|
|
||||||
|
Die phasenweite Gesamtliste steht in `15-01-PLAN.md`.
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Erstelle `.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-05-SUMMARY.md`, wenn der Plan abgeschlossen ist.
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
---
|
||||||
|
phase: 15-modul-berechtigungen-gruppen-user-grants
|
||||||
|
plan: 06
|
||||||
|
type: execute
|
||||||
|
wave: 3
|
||||||
|
depends_on: ["15-02"]
|
||||||
|
files_modified:
|
||||||
|
- apps/web/src/messages/de.json
|
||||||
|
- apps/web/src/messages/en.json
|
||||||
|
- apps/web/src/components/admin/admin-sidebar.tsx
|
||||||
|
- apps/web/src/app/(portal)/admin/groups/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx
|
||||||
|
- apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.tsx
|
||||||
|
- apps/web/src/app/(portal)/admin/groups/components/DeleteGroupDialog.tsx
|
||||||
|
- apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx
|
||||||
|
autonomous: true
|
||||||
|
requirements: [PERM-01]
|
||||||
|
user_setup: []
|
||||||
|
|
||||||
|
estimate:
|
||||||
|
tokens: 76000
|
||||||
|
raw_tokens: 76000
|
||||||
|
tasks: 3
|
||||||
|
confidence: low
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Die Gruppenverwaltung ist als sechster Eintrag der Admin-Navigation unter /admin/groups erreichbar (D-14)."
|
||||||
|
- "Ein Admin kann in der Oberfläche Gruppen anlegen, umbenennen, löschen sowie Mitglieder manuell zuweisen und entfernen (PERM-01)."
|
||||||
|
- "Die AD-Gruppe wird aus einer durchsuchbaren Liste gewählt, nicht als DN abgetippt — die Liste stammt aus der bestehenden LDAP-Gruppen- und OU-Suche (D-18)."
|
||||||
|
- "Pro Tessera-Gruppe ist genau eine AD-Gruppe wählbar: die Auswahl ist eine Radio-Liste, keine Mehrfachauswahl (D-05)."
|
||||||
|
- "Der Löschdialog nennt die konkrete Zahl der Mitglieder und der Modul-Freigaben sowie den Hinweis, dass betroffene Benutzer den Zugriff verlieren, sofern sie ihn nicht anderweitig haben (D-17)."
|
||||||
|
- "Eine Mitgliedschaft mit Herkunft LDAP trägt einen deaktivierten Entfernen-Button mit dem Hinweis, dass sie über den AD-Sync verwaltet wird (D-19)."
|
||||||
|
- "Die Standardgruppen-Markierung ist ein Sterntoggle je Zeile: sie lässt sich umhängen und abschalten, und die markierte Gruppe bleibt umbenennbar, bindbar und löschbar (D-13)."
|
||||||
|
- "Alle i18n-Schlüssel dieser Phase liegen nach diesem Plan vollständig in de.json und en.json vor — auch die, die erst die Pläne 15-07 und 15-08 verwenden."
|
||||||
|
- "Die Oberfläche führt keine eigene Zugriffsentscheidung: sie zeigt für Rollen unterhalb von ADMIN den bestehenden accessDenied-Zustand und verlässt sich für die Durchsetzung auf den RolesGuard der API."
|
||||||
|
# --- UI-SPEC 'UI Considerations' — covered ---
|
||||||
|
- "empty (Gruppenliste): Der leere Zustand rendert die Copy 'Keine Gruppen vorhanden' samt Erklärtext und CTA, nach dem Muster des noUsers-Zustands der Benutzerseite."
|
||||||
|
- "loading (Matrix-Zellen-Toggle, Gruppen-Modal-Speichern): Ladezustände nutzen dasselbe Inline-Spinner-Muster wie der bestehende isToggling-Zustand der Modulseite; es entsteht kein neues Ladeverhalten und kein Vollseiten-Ladezustand."
|
||||||
|
- "zero-one-many (AD-Bindung): Zwei Zustände sind ausgeführt — ungebunden zeigt die Auswahl-Oberfläche, gebunden zeigt den DN als Chip mit einem Link zum Lösen der Bindung."
|
||||||
|
- "zero-one-many (Standardgruppe): Pro Mandant existieren genau null oder eine markierte Gruppe, DB-seitig erzwungen; die Liste zeigt je Zeile einen Sterntoggle."
|
||||||
|
- "partial (Gruppenliste, Mitgliederzahl): Es gibt keinen Teilzustand — die Mitgliederzahl kommt in derselben Antwort wie die Gruppenzeile, es existiert kein Nachladepfad, der eine Zeile ohne Zahl rendern könnte."
|
||||||
|
# --- UI-SPEC 'UI Considerations' — backstop ---
|
||||||
|
- statement: "error (Gruppe löschen schlägt fehl): Der Löschdialog zeigt bei Netzwerk- oder Serverfehler eine sichtbare Fehlermeldung im bestehenden error-Div-Muster und schliesst sich nicht — der stille Fehlschlag der bestehenden Lösch-Dialoge wird hier bewusst nicht fortgeführt, weil der Dialog konkrete Zahlen verspricht, die bei einem unbemerkten Fehlschlag veralten."
|
||||||
|
verification: backstop
|
||||||
|
- statement: "partial (AD-Gruppensuche im Create- und Rename-Modal): Liefert die LDAP-Suche einen Fehler oder ein Teilergebnis, erscheint die Ergebnisliste nicht stillschweigend leer, sondern nutzt den bestehenden Fehler-Div-Pfad der LDAP-Seite."
|
||||||
|
verification: backstop
|
||||||
|
artifacts:
|
||||||
|
- "apps/web/src/app/(portal)/admin/groups/page.tsx"
|
||||||
|
- "apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx"
|
||||||
|
- "apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.tsx"
|
||||||
|
- "apps/web/src/app/(portal)/admin/groups/components/DeleteGroupDialog.tsx"
|
||||||
|
- "apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx"
|
||||||
|
- "apps/web/src/messages/de.json und en.json — alle i18n-Schlüssel der Phase"
|
||||||
|
- "apps/web/src/components/admin/admin-sidebar.tsx — sechster Navigationseintrag"
|
||||||
|
key_links:
|
||||||
|
- "AdminSidebar-Eintrag → /admin/groups — ohne den Eintrag ist die Seite nur per direkter URL erreichbar"
|
||||||
|
- "GroupFormModal → GET /ldap/groups — die AD-Auswahl greift auf die bestehende Discovery zurück, es entsteht keine neue LDAP-Logik"
|
||||||
|
- "DeleteGroupDialog → GET /groups/:id/impact — die konkreten Zahlen aus D-17 kommen aus diesem Aufruf"
|
||||||
|
- "de.json und en.json → Pläne 15-07 und 15-08 — beide setzen voraus, dass ihre Schlüssel hier bereits angelegt wurden"
|
||||||
|
prohibitions:
|
||||||
|
- statement: "Die Standardgruppe darf nicht zu einem Sonderobjekt werden, das der Admin nicht umbenennen, ummarkieren oder löschen kann — die Markierung ist ein Attribut, kein unveränderlicher Status."
|
||||||
|
status: active
|
||||||
|
verification: flagged-unverified
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Die Gruppenverwaltung im Admin-UI: eine neue Seite unter `/admin/groups` mit Tabelle, Anlege- und Umbenennen-Dialog samt AD-Auswahl, Mitglieder-Dialog und einem Löschdialog, der konkrete Zahlen nennt. Dazu bündelt dieser Plan sämtliche i18n-Schlüssel der Phase an einer Stelle.
|
||||||
|
|
||||||
|
Purpose: Ohne diese Seite existieren Gruppen nur als API-Objekte. Die i18n-Bündelung hat einen konkreten Grund: `de.json` und `en.json` werden von jeder Oberfläche dieser Phase angefasst — würde jeder Frontend-Plan seine eigenen Schlüssel nachtragen, könnten die Pläne 15-07 und 15-08 nicht parallel laufen.
|
||||||
|
Output: Die Seite mit drei Dialogen, der sechste Navigationseintrag, die vollständigen Übersetzungsdateien und eine Komponenten-Testsuite.
|
||||||
|
</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/STATE.md
|
||||||
|
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-CONTEXT.md
|
||||||
|
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-UI-SPEC.md
|
||||||
|
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-PATTERNS.md
|
||||||
|
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-02-SUMMARY.md
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Alle i18n-Schlüssel der Phase und der sechste Admin-Navigationseintrag</name>
|
||||||
|
<files>apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/components/admin/admin-sidebar.tsx</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/web/src/messages/de.json — die bestehenden Namensräume, insbesondere `header.admin`, `admin.users`, `adminModules`, `modules` und `marketplace`
|
||||||
|
- apps/web/src/messages/en.json — dieselbe Struktur, die deckungsgleich bleiben muss
|
||||||
|
- apps/web/src/components/admin/admin-sidebar.tsx — Zeilen 14–74: die Item-Array-Struktur mit label, href, show und einem Inline-SVG je Eintrag, sowie das generische Link-Rendering darunter
|
||||||
|
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-UI-SPEC.md — der vollständige Copywriting Contract, aus dem jeder Schlüssel und jeder Text wörtlich stammt
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Trage in `de.json` und `en.json` sämtliche Schlüssel ein, die Phase 15 benötigt — auch die, die erst die Pläne 15-07 und 15-08 verwenden. Die deutschen Texte stammen wörtlich aus dem Copywriting Contract des UI-SPEC und werden nicht umformuliert; besonders die 403-Formulierung ist durch D-07 wörtlich vorgegeben. Die englischen Texte sind sinngemässe Übersetzungen im Stil der bestehenden Einträge.
|
||||||
|
|
||||||
|
Namensraum `header.admin`: der Schlüssel `groups` mit dem Label der Navigation.
|
||||||
|
|
||||||
|
Neuer Namensraum `admin.groups` als Geschwister von `admin.users`: `title`, `create`, `edit`, `delete`, `noGroups`, `noGroupsBody`, `name`, `ldapBinding`, `boundBadge`, `manualBadge`, `defaultGroup`, `setDefault`, `isDefault`, `memberCount`, `actions`, `membersButton`, `saveError`, `deleteError`; die Untergruppe `ldapBind` mit `hint`, `bound`, `unbind`, `searchPlaceholder`, `discoverError`, `noResults`; die Untergruppe `members` mit `title`, `remove`, `ldapManaged`, `sourceManual`, `sourceLdap`, `addTitle`, `searchPlaceholder`, `noMembers`, `addError`; die Untergruppe `deleteConfirm` mit `title` und `body`, wobei `body` die beiden Interpolationsparameter `memberCount` und `grantCount` trägt und den vollständigen Warntext aus D-17 enthält; die Untergruppe `grants` mit `matrixCheckboxLabel`, das die Parameter `module` und `group` trägt.
|
||||||
|
|
||||||
|
Ergänzung des bestehenden Namensraums `admin.users` um die Untergruppe `grants` mit `detailsButton`, `modalTitle`, `groupsSection`, `noGroups`, `accessSection`, `moduleColumn`, `viaGroupsColumn`, `directColumn`, `noInheritance`, `noActiveModules`, `directCheckboxLabel` (Parameter `module` und `user`) und `saveError`.
|
||||||
|
|
||||||
|
Ergänzung des bestehenden Namensraums `adminModules` um `grantsLink`; die Untergruppe `grants` mit `title`, `searchPlaceholder`, `emptyModules`, `emptyModulesLink`, `adminNote`, `saveError`; die Untergruppe `activationDialog` mit `title` (Parameter `module`), `body`, `grantNow`, `configureLater`, `cancel` und `noDefaultGroupHint`.
|
||||||
|
|
||||||
|
Ergänzung des bestehenden Namensraums `modules` um die Untergruppe `accessDenied` mit `title`, `body` und `backToDashboard`.
|
||||||
|
|
||||||
|
Ergänzung des bestehenden Namensraums `marketplace` um `statusNoAccess` und `toastNoAccess`.
|
||||||
|
|
||||||
|
Ergänze in `admin-sidebar.tsx` einen sechsten Eintrag im Item-Array mit `label: t('admin.groups')`, `href: '/admin/groups'`, `show: true` und einem neuen Inline-SVG. Das Icon muss sich optisch vom bestehenden Personen-Icon des Benutzer-Eintrags unterscheiden — sinnvoll ist ein Listen- beziehungsweise Roster-Motiv. Es folgt exakt dem Icon-Vokabular der Datei: `width="16"`, `height="16"`, `viewBox="0 0 24 24"`, `fill="none"`, `stroke="currentColor"`, `strokeWidth="2"`, `strokeLinecap="round"`, `strokeLinejoin="round"`, kein Fill. Kein bestehendes Icon der Datei wird dupliziert. Das Link-Rendering darunter bleibt unverändert, weil es das Array generisch iteriert.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>node -e "const de=require('./apps/web/src/messages/de.json'),en=require('./apps/web/src/messages/en.json');const walk=(o,p='')=>Object.entries(o).flatMap(([k,v])=>typeof v==='object'&&v!==null?walk(v,p+k+'.'):[p+k]);const a=walk(de).sort(),b=walk(en).sort();const miss=a.filter(k=>!b.includes(k)).concat(b.filter(k=>!a.includes(k)));if(miss.length){console.error('Abweichende Schluessel:',miss);process.exit(1)}console.log('i18n deckungsgleich:',a.length,'Schluessel')" && pnpm --filter @tessera/web test</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- Das oben genannte Node-Kommando läuft mit Exit-Code 0 durch: `de.json` und `en.json` tragen exakt dieselbe Schlüsselmenge.
|
||||||
|
- `node -e "const d=require('./apps/web/src/messages/de.json');['admin.groups.deleteConfirm.body','admin.groups.ldapBind.hint','admin.users.grants.directCheckboxLabel','adminModules.grants.adminNote','adminModules.activationDialog.grantNow','modules.accessDenied.title','modules.accessDenied.body','marketplace.statusNoAccess','marketplace.toastNoAccess','header.admin.groups'].forEach(p=>{if(!p.split('.').reduce((o,k)=>o&&o[k],d))throw new Error('fehlt: '+p)});console.log('ok')"` gibt `ok` aus.
|
||||||
|
- `node -e "const d=require('./apps/web/src/messages/de.json');const b=d.admin.groups.deleteConfirm.body;if(!b.includes('{memberCount}')||!b.includes('{grantCount}'))throw new Error('Interpolationsparameter fehlen');console.log('ok')"` gibt `ok` aus.
|
||||||
|
- `grep -c "/admin/groups" apps/web/src/components/admin/admin-sidebar.tsx` gibt `1` aus.
|
||||||
|
- `grep -c "href: '/admin/" apps/web/src/components/admin/admin-sidebar.tsx` gibt `6` aus — die Navigation hat sechs Einträge.
|
||||||
|
- `pnpm --filter @tessera/web test` läuft vollständig grün.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Alle Texte der Phase liegen in beiden Sprachen deckungsgleich vor, und die Gruppenverwaltung ist über die Admin-Navigation erreichbar.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Gruppenübersicht mit Anlege-, Umbenennen- und AD-Bindungs-Dialog</name>
|
||||||
|
<files>apps/web/src/app/(portal)/admin/groups/page.tsx, apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx, apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/web/src/app/(portal)/admin/users/page.tsx — die komplette Datei als Struktur-Vorlage: Seitenkopf mit primärem Button, Tabellenmarkup, Modal-Overlay-Muster, Zugriffsprüfung über den Auth-Store, Fetch- und Fehlerbehandlung
|
||||||
|
- apps/web/src/app/(portal)/admin/ldap/page.tsx — Zeilen 705–746: die Discovery-Sektion mit Suchfeld und Ergebnisliste, das Vorbild für die AD-Gruppenauswahl; sowie das Fehler-Div-Muster derselben Datei
|
||||||
|
- apps/web/src/app/(portal)/admin/modules/page.tsx — Zeilen 80–112 und 134–138: das optimistische Toggle-Muster mit Rücksetzen im Fehlerfall und das Fehler-Div
|
||||||
|
- apps/web/src/messages/de.json — die in Task 1 angelegten Schlüssel unter `admin.groups`
|
||||||
|
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-UI-SPEC.md — Surface Contract 1 sowie die Abschnitte Spacing, Typography und Color
|
||||||
|
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-02-SUMMARY.md — die tatsächlichen Routen und Antwortformate der Gruppen-API
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Lege `apps/web/src/app/(portal)/admin/groups/page.tsx` als Client-Komponente an, mit demselben Kopf wie die Benutzerseite: `useTranslations`, `useAuthStore`, `API_URL` aus `process.env.NEXT_PUBLIC_API_URL`. Übernimm die Zugriffsprüfung wörtlich aus der Benutzerseite — für Rollen unterhalb von ADMIN wird der bestehende `accessDenied`-Zustand gerendert. Das ist ausdrücklich keine Zugriffskontrolle, sondern nur Anzeige; die Durchsetzung liegt beim RolesGuard der API.
|
||||||
|
|
||||||
|
Seitenkopf: `h1` mit `admin.groups.title` in 24 Pixel und Gewicht 700, rechtsbündig der primäre Button `admin.groups.create` im `bg-primary`-Stil des bestehenden `openCreate`-Buttons.
|
||||||
|
|
||||||
|
Tabelle im etablierten Markup (`overflow-x-auto rounded-md border border-border`, `thead` mit `bg-muted/50`, `tbody` mit `divide-y divide-border`, Zellen mit `px-4 py-3`) und den Spalten Name, AD-Bindung, Standardgruppe, Mitglieder und Aktionen. Die AD-Bindung erscheint als Badge: bei gesetztem `ldapDn` das Info-Blau der bestehenden Palette mit `admin.groups.boundBadge`, sonst das neutrale Grau mit `admin.groups.manualBadge`. Die Standardgruppe ist ein Icon-Button mit Stern-Motiv im Projektmass `px-2 py-1`, gefüllt bei `isDefault`, mit `aria-label` aus `admin.groups.setDefault` beziehungsweise `admin.groups.isDefault`. Ein Klick sendet `PATCH /groups/:id` mit `isDefault` und aktualisiert optimistisch; im Fehlerfall wird der Zustand auf den Serverwert zurückgesetzt und die Meldung im Fehler-Div angezeigt — dasselbe Muster wie `toggleModule` der Modulseite. Die Mitgliederzahl kommt aus der Listenantwort und wird nicht nachgeladen; es gibt damit keinen Zustand, in dem eine Zeile ohne Zahl erscheint. Die Aktionsspalte trägt drei Textbuttons im Stil `px-2 py-1 text-xs`: Bearbeiten, Mitglieder, Löschen.
|
||||||
|
|
||||||
|
Leerer Zustand: rendert `admin.groups.noGroups` als Überschrift und `admin.groups.noGroupsBody` als Erklärtext, nach dem Vorbild des `noUsers`-Zustands der Benutzerseite, mit dem Anlege-Button als Handlungsaufforderung.
|
||||||
|
|
||||||
|
Lege `components/GroupFormModal.tsx` an — ein Dialog für Anlegen und Umbenennen im Overlay-Muster `fixed inset-0 z-50 flex items-center justify-center bg-black/50` mit einem Container `w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg`. Feld Name als Pflichtfeld. Darunter der Abschnitt AD-Bindung mit dem Hinweistext `admin.groups.ldapBind.hint`, einem Suchfeld und einer Ergebnisliste, deren Markup 1:1 aus der Discovery-Sektion der LDAP-Seite übernommen wird — mit einer Abweichung: die Einträge sind Radio-Elemente statt Checkboxen, weil pro Tessera-Gruppe genau eine AD-Gruppe gebunden wird. Die Liste wird über den bestehenden Endpoint `GET /ldap/groups` befüllt.
|
||||||
|
|
||||||
|
Für die AD-Suche gilt: schlägt der Aufruf fehl oder liefert er nichts, erscheint die Liste nicht stillschweigend leer. Ein Fehler wird im Fehler-Div-Muster der LDAP-Seite angezeigt (`admin.groups.ldapBind.discoverError`), ein leeres Ergebnis als `admin.groups.ldapBind.noResults`. Diese beiden Zustände sind der Grund, warum das UI-SPEC den Punkt als Backstop führt — sie werden hier ausgeführt und sind vom Verifikationslauf gezielt zu prüfen.
|
||||||
|
|
||||||
|
Ist die Gruppe bereits gebunden, zeigt der Dialog statt der Auswahl den Text `admin.groups.ldapBind.bound` mit dem DN und daneben `admin.groups.ldapBind.unbind` als Link, der `ldapDn` auf null setzt.
|
||||||
|
|
||||||
|
Während eines Speichervorgangs zeigt der Speichern-Button denselben Inline-Spinner-Zustand wie der bestehende `isToggling`-Zustand der Modulseite; ein Vollseiten-Ladezustand entsteht nicht.
|
||||||
|
|
||||||
|
Halte dich an das freigegebene Typografie- und Farbvokabular des UI-SPEC: vier Grössen- und Gewichtspaare, kein fünftes Gewicht; die Akzentfarbe ausschliesslich für primäre Buttons, den aktiven Toggle-Zustand und den Fokusring; Destructive ausschliesslich für Löschen und Entfernen sowie für Fehlermeldungstext.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/web test -- groups-page</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- Die Seite `/admin/groups` rendert im angemeldeten ADMIN-Zustand eine Tabelle mit den fünf beschriebenen Spalten; die Komponententests decken den leeren Zustand, eine befüllte Liste und den Sterntoggle ab.
|
||||||
|
- `grep -c "type=\"radio\"" "apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx"` gibt mindestens `1` aus — die AD-Auswahl ist eine Einfachauswahl.
|
||||||
|
- `grep -c "type=\"checkbox\"" "apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx"` gibt `0` aus.
|
||||||
|
- `grep -c "ldap/groups" "apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx"` gibt mindestens `1` aus — die AD-Liste stammt aus der bestehenden Discovery, es entsteht keine neue LDAP-Logik.
|
||||||
|
- `grep -c "discoverError\|noResults" "apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx"` gibt mindestens `2` aus — Fehlerfall und Leerfall der AD-Suche sind beide sichtbar ausgeführt.
|
||||||
|
- `grep -c "font-bold\|font-semibold\|font-medium" "apps/web/src/app/(portal)/admin/groups/page.tsx"` weist ausschliesslich diese drei Gewichtsklassen nach; `grep -c "font-light\|font-thin\|font-extrabold\|font-black" "apps/web/src/app/(portal)/admin/groups/page.tsx"` gibt `0` aus.
|
||||||
|
- `pnpm --filter @tessera/web test` läuft vollständig grün und `pnpm --filter @tessera/web run build` schliesst fehlerfrei ab.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Gruppen lassen sich in der Oberfläche anlegen, umbenennen, als Standard markieren und an eine aus einer Liste gewählte AD-Gruppe binden.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: Mitglieder-Dialog und Löschdialog mit konkreten Zahlen</name>
|
||||||
|
<files>apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.tsx, apps/web/src/app/(portal)/admin/groups/components/DeleteGroupDialog.tsx, apps/web/src/app/(portal)/admin/groups/page.tsx, apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/web/src/app/(portal)/admin/ldap/page.tsx — Zeilen 957–974: das Chip-Markup mit Entfernen-Button; sowie Zeilen 820–877: das Suchfeld mit Checkbox-Ergebnisliste zum Hinzufügen von Benutzern
|
||||||
|
- apps/web/src/app/(portal)/admin/users/page.tsx — Zeilen 383–405: das bestehende Lösch-Dialog-Muster, das hier um konkrete Zahlen und um sichtbare Fehlerbehandlung erweitert wird
|
||||||
|
- apps/web/src/app/(portal)/admin/groups/page.tsx — die in Task 2 entstandene Seite, in die beide Dialoge eingehängt werden
|
||||||
|
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-UI-SPEC.md — Surface Contract 1 sowie der Copywriting Contract zum Löschdialog
|
||||||
|
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-02-SUMMARY.md — die Routen für Mitglieder und Löschauswirkung samt Antwortformat
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Lege `components/GroupMembersModal.tsx` an, geöffnet über den Mitglieder-Button einer Tabellenzeile, im Overlay-Muster mit `max-w-md`. Der Dialog hat zwei Abschnitte.
|
||||||
|
|
||||||
|
Erster Abschnitt: die aktuellen Mitglieder als Chip-Liste im Markup der bestehenden Ausschlussliste der LDAP-Seite. Jeder Chip trägt zusätzlich einen Herkunfts-Badge mit `admin.groups.members.sourceManual` beziehungsweise `admin.groups.members.sourceLdap`. Bei Herkunft MANUAL ist der Entfernen-Button aktiv und trägt `aria-label` aus `admin.groups.members.remove`. Bei Herkunft LDAP ist derselbe Button `disabled` und trägt `title` und `aria-label` aus `admin.groups.members.ldapManaged`. Der deaktivierte Zustand ist die sichtbare Umsetzung von D-19: eine über das AD gesteuerte Mitgliedschaft entfernt ausschliesslich der Sync, und der Admin soll den Grund dafür lesen können statt vor einem wirkungslosen Button zu stehen.
|
||||||
|
|
||||||
|
Zweiter Abschnitt: ein Suchfeld mit Ergebnisliste der Mandanten-Benutzer zum manuellen Hinzufügen, im Markup der bestehenden Benutzersuche der LDAP-Seite, aber mit `POST /groups/:id/members` als Ziel statt eines LDAP-Imports. Ein bereits vorhandenes Mitglied erneut hinzuzufügen ist folgenlos — die API behandelt das als Upsert, die Oberfläche braucht dafür keine Sonderbehandlung.
|
||||||
|
|
||||||
|
Lege `components/DeleteGroupDialog.tsx` an. Beim Öffnen lädt der Dialog `GET /groups/:id/impact` und zeigt Titel und Text aus `admin.groups.deleteConfirm`, wobei `memberCount` und `grantCount` als Interpolationsparameter eingesetzt werden. Der Text nennt beide Zahlen konkret und weist darauf hin, dass betroffene Benutzer den Zugriff verlieren, sofern sie ihn nicht anderweitig haben — eine allgemeine Warnung ohne Zahlen wäre eine Verfehlung von D-17.
|
||||||
|
|
||||||
|
Der Löschdialog behandelt Fehler sichtbar. Schlägt `DELETE /groups/:id` fehl, bleibt der Dialog offen und zeigt `admin.groups.deleteError` im Fehler-Div-Muster mit `border-destructive/50 bg-destructive/10`. Damit weicht dieser Dialog bewusst vom Präzedenzfall der bestehenden Lösch-Dialoge ab, die im `catch` still scheitern: dieser Dialog nennt konkrete Zahlen, und ein unbemerkter Fehlschlag würde einen Admin in dem Glauben lassen, die Gruppe samt Freigaben sei weg, während sie weiterhin Zugriff gewährt. Halte die Abweichung als Kommentar an der Komponente fest.
|
||||||
|
|
||||||
|
Hänge beide Dialoge in `page.tsx` ein und lade die Gruppenliste nach jeder erfolgreichen Mutation neu, damit Mitgliederzahl und Badges aktuell bleiben.
|
||||||
|
|
||||||
|
Ergänze `groups-page.test.tsx` um Komponententests für beide Dialoge: der deaktivierte Entfernen-Button bei LDAP-Herkunft, der aktive bei MANUAL-Herkunft, die Anzeige beider Zahlen im Löschtext, und die sichtbare Fehlermeldung bei fehlgeschlagenem Löschaufruf.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/web test -- groups-page</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `pnpm --filter @tessera/web test -- groups-page` ist grün und enthält je einen Test für: deaktivierter Entfernen-Button bei LDAP-Herkunft, aktiver Entfernen-Button bei MANUAL-Herkunft, Löschtext mit beiden eingesetzten Zahlen, sichtbare Fehlermeldung nach fehlgeschlagenem Löschaufruf.
|
||||||
|
- `grep -c "disabled" "apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.tsx"` gibt mindestens `1` aus.
|
||||||
|
- `grep -c "ldapManaged" "apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.tsx"` gibt mindestens `1` aus.
|
||||||
|
- `grep -c "impact" "apps/web/src/app/(portal)/admin/groups/components/DeleteGroupDialog.tsx"` gibt mindestens `1` aus — die Zahlen werden aus der API geladen, nicht geschätzt.
|
||||||
|
- `grep -c "border-destructive/50 bg-destructive/10" "apps/web/src/app/(portal)/admin/groups/components/DeleteGroupDialog.tsx"` gibt mindestens `1` aus — der Fehlerfall ist sichtbar.
|
||||||
|
- `pnpm --filter @tessera/web test` läuft vollständig grün und `pnpm --filter @tessera/web run build` schliesst fehlerfrei ab.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Mitglieder lassen sich in der Oberfläche zuweisen und entfernen, AD-gesteuerte Mitgliedschaften sind als solche erkennbar und nicht von Hand entfernbar, und der Löschdialog nennt konkrete Zahlen und meldet Fehlschläge sichtbar.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Browser → Gruppen-API | Die Oberfläche schickt Gruppen- und Benutzer-IDs; jede Prüfung passiert serverseitig, die Rollenprüfung im Browser ist reine Anzeige |
|
||||||
|
| Admin-Auswahl → LDAP-DN | Der gewählte AD-Gruppen-DN wird gespeichert und später in einen LDAP-Filter interpoliert |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-15-22 | Elevation of Privilege | Die clientseitige Rollenprüfung `currentUser?.role === 'ADMIN'` als Sicherheitsgrenze missverstehen | high | mitigate | Die Prüfung blendet nur die Oberfläche aus; jede aufgerufene Route trägt serverseitig `RolesGuard` mit `@Roles(ADMIN, SUPER_ADMIN)` aus Plan 15-02. Ein Kommentar an der Prüfung hält fest, dass sie Anzeige und nicht Durchsetzung ist |
|
||||||
|
| T-15-23 | Tampering | Ein von Hand eingetippter AD-DN gelangt in den späteren LDAP-Filter | medium | mitigate | Der DN ist ausschliesslich aus der Ergebnisliste von `GET /ldap/groups` wählbar, es gibt kein freies Texteingabefeld dafür (D-18). Zusätzlich escaped Plan 15-04 den Wert vor jeder Interpolation |
|
||||||
|
| T-15-24 | Repudiation | Ein fehlgeschlagener Löschvorgang bleibt unbemerkt, der Admin hält die Gruppe für entfernt | medium | mitigate | Der Löschdialog bleibt bei einem Fehlschlag offen und zeigt eine sichtbare Fehlermeldung; der stille Fehlschlag der bestehenden Dialoge wird hier bewusst nicht fortgeführt |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `pnpm --filter @tessera/web test` vollständig grün.
|
||||||
|
- `pnpm --filter @tessera/web run build` fehlerfrei.
|
||||||
|
- Die Schlüsselmengen von `de.json` und `en.json` sind deckungsgleich (Node-Prüfung aus Task 1).
|
||||||
|
- Manuell im Browser: Gruppe anlegen, umbenennen, Standardmarkierung setzen und wieder abschalten, an eine AD-Gruppe binden und Bindung lösen, Mitglied hinzufügen und entfernen, Löschdialog mit gefüllter Gruppe öffnen und beide Zahlen prüfen. Zusätzlich mit abgeschalteter API prüfen, dass die AD-Suche und der Löschvorgang eine sichtbare Fehlermeldung erzeugen statt stumm zu bleiben.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Gruppen und Mitglieder sind vollständig über die Oberfläche verwaltbar (PERM-01).
|
||||||
|
- Der Löschdialog nennt konkrete Zahlen (D-17).
|
||||||
|
- Die AD-Gruppe wird ausgewählt, nicht getippt (D-18).
|
||||||
|
- Alle i18n-Schlüssel der Phase liegen in beiden Sprachen vor, damit 15-07 und 15-08 parallel laufen können.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
## Artifacts this phase produces
|
||||||
|
|
||||||
|
Von diesem Plan erzeugt beziehungsweise verändert:
|
||||||
|
|
||||||
|
- Route `/admin/groups` (`apps/web/src/app/(portal)/admin/groups/page.tsx`)
|
||||||
|
- `GroupFormModal`, `GroupMembersModal`, `DeleteGroupDialog` (`apps/web/src/app/(portal)/admin/groups/components/`)
|
||||||
|
- `apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx`
|
||||||
|
- `AdminSidebar` — sechster Navigationseintrag mit neuem Inline-SVG
|
||||||
|
- i18n-Namensräume in `de.json` und `en.json`: `header.admin.groups`, `admin.groups.*`, `admin.users.grants.*`, `adminModules.grantsLink`, `adminModules.grants.*`, `adminModules.activationDialog.*`, `modules.accessDenied.*`, `marketplace.statusNoAccess`, `marketplace.toastNoAccess`
|
||||||
|
|
||||||
|
Die phasenweite Gesamtliste steht in `15-01-PLAN.md`.
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Erstelle `.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-06-SUMMARY.md`, wenn der Plan abgeschlossen ist.
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
---
|
||||||
|
phase: 15-modul-berechtigungen-gruppen-user-grants
|
||||||
|
plan: 07
|
||||||
|
type: execute
|
||||||
|
wave: 4
|
||||||
|
depends_on: ["15-03", "15-06"]
|
||||||
|
files_modified:
|
||||||
|
- apps/web/src/app/(portal)/admin/modules/grants/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx
|
||||||
|
- apps/web/src/app/(portal)/admin/modules/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/admin/modules/components/ActivateModuleDialog.tsx
|
||||||
|
- apps/web/src/app/(portal)/admin/users/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx
|
||||||
|
- apps/web/src/app/(portal)/admin/users/user-access-modal.test.tsx
|
||||||
|
autonomous: true
|
||||||
|
requirements: [PERM-03]
|
||||||
|
user_setup: []
|
||||||
|
|
||||||
|
estimate:
|
||||||
|
tokens: 80000
|
||||||
|
raw_tokens: 80000
|
||||||
|
tasks: 3
|
||||||
|
confidence: low
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Gruppenfreigaben werden in einer Matrix Module mal Gruppen gepflegt; ein Häkchen setzt oder entzieht die Freigabe sofort, der gesamte Berechtigungsstand ist auf einen Blick sichtbar (D-15, PERM-03)."
|
||||||
|
- "Die Matrix ist über einen Button im Kopf von /admin/modules erreichbar und liegt als Unterseite unter /admin/modules/grants — es entsteht kein siebter Eintrag in der Admin-Navigation."
|
||||||
|
- "Einzelfreigaben werden im Benutzer-Detail unter /admin/users gepflegt, zusammen mit der Anzeige dessen, was der Benutzer über seine Gruppen erbt (D-16, PERM-03)."
|
||||||
|
- "Die Gruppenmitgliedschaften im Benutzer-Detail sind nur lesbar — bearbeitet werden sie ausschliesslich unter /admin/groups, es gibt keine zweite Bearbeitungsoberfläche für dieselbe Beziehung (D-16)."
|
||||||
|
- "Aktiviert ein Admin ein Modul, fragt ein Dialog, ob es sofort für die Standardgruppe freigegeben oder erst konfiguriert werden soll (D-10)."
|
||||||
|
- "Besitzt der Mandant keine markierte Standardgruppe, ist die Sofort-Freigabe im Aktivierungsdialog deaktiviert und ein Hinweistext nennt den Grund (D-13)."
|
||||||
|
- "Ein einzelner Freigabe-Toggle bekommt keinen Bestätigungsdialog: der Klick ist sofort wirksam und sofort umkehrbar, es geht kein Datensatz verloren."
|
||||||
|
- "Die Matrix macht transparent, dass ADMIN und SUPER_ADMIN immer Zugriff auf alle aktiven Module haben und die Matrix nur die Rolle USER betrifft (D-03)."
|
||||||
|
- "In der Matrix und im Benutzer-Detail sind ausschliesslich mandantenweit aktive Module wählbar — ein Grant auf ein inaktives Modul wäre wirkungslos (D-02)."
|
||||||
|
# --- UI-SPEC 'UI Considerations' — covered ---
|
||||||
|
- "empty (Permission-Matrix ohne aktive Module): Der leere Zustand rendert die dokumentierte Copy mit einem Link zurück auf /admin/modules."
|
||||||
|
- "empty (User-Detail ohne Gruppenmitgliedschaft): Die Chip-Liste bleibt leer und der dokumentierte Hinweistext erscheint."
|
||||||
|
- "error (Grant-Speicherfehler): Die dokumentierte Fehlermeldung erscheint im bestehenden error-Div-Muster mit Statuscode-Anzeige."
|
||||||
|
- "populated (Matrix mit vielen Modulen und Gruppen): Sticky Kopfzeile, sticky erste Spalte, Gruppierung nach Modulkategorie und ein clientseitiges Suchfeld über Modul- und Gruppennamen."
|
||||||
|
- "long-text (Modulnamen in der ersten Matrix-Spalte): Die sticky erste Spalte hat eine feste Mindestbreite und kürzt mit Auslassung, der vollständige Name steht im title-Attribut."
|
||||||
|
- "partial (Matrix-Zelle und Direkt-Checkbox): Schlägt der Request nach dem optimistischen Toggle fehl, springt die Checkbox auf den Serverzustand zurück; es darf keinen Zustand geben, in dem die Oberfläche eine Freigabe anzeigt, die in der Datenbank nicht existiert."
|
||||||
|
# --- UI-SPEC 'UI Considerations' — backstop ---
|
||||||
|
- statement: "empty (User-Detail-Modul-Zugriff-Tabelle, wenn der Mandant keine aktiven Module hat): Statt eines leeren Tabellenrahmens ohne jeden Hinweis erscheint der Hinweistext admin.users.grants.noActiveModules."
|
||||||
|
verification: backstop
|
||||||
|
- statement: "overflow (lange Gruppennamen in der Matrix-Kopfzeile): Die Spaltenköpfe kürzen mit Auslassung und tragen den vollständigen Namen im title-Attribut, analog zur Titelkürzung der Marketplace-Karte."
|
||||||
|
verification: backstop
|
||||||
|
- statement: "long-text (Modulname im Aktivierungsdialog): Ein langer interpolierter Modulname bricht innerhalb des max-w-sm-Dialogs um, statt den Dialog zu verbreitern."
|
||||||
|
verification: backstop
|
||||||
|
artifacts:
|
||||||
|
- "apps/web/src/app/(portal)/admin/modules/grants/page.tsx — Freigabe-Matrix"
|
||||||
|
- "apps/web/src/app/(portal)/admin/modules/components/ActivateModuleDialog.tsx"
|
||||||
|
- "apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx"
|
||||||
|
- "apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx"
|
||||||
|
- "apps/web/src/app/(portal)/admin/users/user-access-modal.test.tsx"
|
||||||
|
key_links:
|
||||||
|
- "Matrix-Zelle → POST/DELETE /module-grants — jede Zelle ist ein Grant-Datensatz, es gibt keinen Sammel-Speichern-Button"
|
||||||
|
- "ActivateModuleDialog → POST /modules/:id/activate gefolgt von POST /module-grants — die Sofort-Freigabe ist zwei Aufrufe, nicht ein neuer Endpoint"
|
||||||
|
- "UserAccessModal → GET /module-grants/users/:userId — geerbte und direkte Rechte kommen aus einer Antwort"
|
||||||
|
- "Alle Texte stammen aus den in Plan 15-06 angelegten i18n-Schlüsseln — dieser Plan legt keine neuen an"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Die beiden Oberflächen, über die Freigaben tatsächlich vergeben werden: die Matrix Module mal Gruppen als Gesamtübersicht und das Benutzer-Detail für Einzelfreigaben samt Anzeige der über Gruppen geerbten Rechte. Dazu die Rückfrage beim Aktivieren eines Moduls.
|
||||||
|
|
||||||
|
Purpose: Ohne die Matrix müsste ein Admin jede Freigabe einzeln über ein Formular pflegen und hätte nie den Gesamtstand vor Augen. Die Anzeige der geerbten Rechte im Benutzer-Detail ist der Teil, ohne den nicht erkennbar ist, warum jemand Zugriff hat — und damit auch nicht, was ein Entzug tatsächlich bewirkt. Die Rückfrage beim Aktivieren verhindert den häufigsten Stolperstein des neuen Modells: ein Admin aktiviert ein Modul und wundert sich, warum es niemand sieht.
|
||||||
|
Output: Die Matrix-Unterseite, ein Aktivierungsdialog mit drei Aktionen, ein Benutzer-Detail-Dialog und zwei Komponenten-Testsuites.
|
||||||
|
</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/STATE.md
|
||||||
|
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-CONTEXT.md
|
||||||
|
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-UI-SPEC.md
|
||||||
|
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-PATTERNS.md
|
||||||
|
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-03-SUMMARY.md
|
||||||
|
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-06-SUMMARY.md
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Freigabe-Matrix Module mal Gruppen</name>
|
||||||
|
<files>apps/web/src/app/(portal)/admin/modules/grants/page.tsx, apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx, apps/web/src/app/(portal)/admin/modules/page.tsx</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/web/src/app/(portal)/admin/modules/page.tsx — Zeilen 37–49 (Datenladen), Zeilen 80–112 (`toggleModule`: optimistisches Toggle mit Rücksetzen im Fehlerfall) und Zeilen 134–138 (Fehler-Div)
|
||||||
|
- apps/web/src/app/(portal)/admin/ldap/page.tsx — Zeilen 707–713: das Suchfeld-Markup, das für die Matrix-Filterung übernommen wird
|
||||||
|
- apps/web/src/app/(portal)/admin/users/page.tsx — Zugriffsprüfung über den Auth-Store und Tabellenmarkup
|
||||||
|
- apps/web/src/messages/de.json — die in Plan 15-06 angelegten Schlüssel unter `adminModules.grants` und `admin.groups.grants.matrixCheckboxLabel`
|
||||||
|
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-UI-SPEC.md — Surface Contract 2 sowie die Abschnitte Spacing, Typography und Color
|
||||||
|
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-03-SUMMARY.md — das Antwortformat von `GET /module-grants/matrix` und die Signatur der Grant-Routen
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Lege `apps/web/src/app/(portal)/admin/modules/grants/page.tsx` als Client-Komponente an, mit derselben Zugriffsanzeige über den Auth-Store wie die übrigen Admin-Seiten. Ergänze in `apps/web/src/app/(portal)/admin/modules/page.tsx` im Seitenkopf einen Button mit dem Text aus `adminModules.grantsLink`, der auf `/admin/modules/grants` verlinkt. Die Matrix bleibt bewusst eine Unterseite von Module und bekommt keinen eigenen Navigationseintrag — dasselbe Verhältnis wie zwischen einem Modul und seiner Einstellungsseite.
|
||||||
|
|
||||||
|
Die Seite lädt einmal `GET /module-grants/matrix` und bekommt daraus die drei Listen Module, Gruppen und bestehende Grant-Paare in einer Antwort.
|
||||||
|
|
||||||
|
Oberhalb der Tabelle ein Suchfeld im Markup des bestehenden Discovery-Suchfelds, das clientseitig sowohl Modul- als auch Gruppennamen filtert.
|
||||||
|
|
||||||
|
Die Tabelle liegt in einem Container mit `overflow-x-auto overflow-y-auto max-h-[70vh] rounded-md border border-border`. Die erste Spalte trägt die Modulnamen und ist mit `sticky left-0 bg-card z-10` fixiert, die Kopfzeile mit den Gruppennamen mit `sticky top-0 bg-card z-10`. Damit bleibt bei vielen Modulen und Gruppen immer erkennbar, welche Zelle zu welchem Paar gehört.
|
||||||
|
|
||||||
|
Sowohl die Modulnamen der ersten Spalte als auch die Gruppennamen der Kopfzeile bekommen eine feste Mindestbreite, kürzen überlaufenden Text mit `truncate` und tragen den vollständigen Namen im `title`-Attribut — nach dem Vorbild der Titelkürzung in der Marketplace-Karte. Ohne das verbreitert ein langer Gruppenname die Matrix ins Unlesbare.
|
||||||
|
|
||||||
|
Die Zeilen sind nach Modulkategorie gruppiert: je Kategorie eine volle Breite überspannende Zwischenzeile mit `bg-muted/50` und dem Kategorienamen, darunter die Modulzeilen dieser Kategorie. Die Sortierung kommt aus der API-Antwort und wird im Browser nicht erneut umsortiert.
|
||||||
|
|
||||||
|
Jede Zelle enthält ein natives Kontrollkästchen ohne sichtbaren Text. Weil die Zelle selbst keinen Text trägt, bekommt jedes Kästchen ein `aria-label` aus `admin.groups.grants.matrixCheckboxLabel` mit Modul- und Gruppennamen als Interpolationsparameter — sonst ist die Matrix mit einem Screenreader nicht bedienbar.
|
||||||
|
|
||||||
|
Ein Klick schaltet sofort um: der Zustand wird optimistisch aktualisiert, im Hintergrund läuft `POST /module-grants` beziehungsweise `DELETE /module-grants` mit moduleId und groupId. Während des Requests zeigt ausschliesslich die betroffene Zelle einen Inline-Spinner, gesteuert über einen Zellen-Schlüssel aus moduleId und groupId — kein Vollseiten-Ladezustand. Schlägt der Request fehl, springt die Checkbox auf den Serverzustand zurück UND die Fehlermeldung aus `adminModules.grants.saveError` erscheint im bestehenden Fehler-Div-Muster mit Statuscode. Beides zusammen ist verpflichtend: es darf keinen Zustand geben, in dem die Oberfläche eine Freigabe anzeigt, die in der Datenbank nicht existiert.
|
||||||
|
|
||||||
|
Ein einzelner Toggle bekommt keinen Bestätigungsdialog. Der Klick ist sofort umkehrbar und es geht kein Datensatz verloren — anders als beim Löschen einer Gruppe, das deshalb dort einen Dialog hat.
|
||||||
|
|
||||||
|
Unter der Tabelle steht eine Fussnote in 12 Pixel und `text-muted-foreground` mit dem Text aus `adminModules.grants.adminNote`: dass ADMIN und SUPER_ADMIN immer Zugriff auf alle aktiven Module haben und die Matrix nur die Rolle USER betrifft. Ohne diesen Hinweis entsteht regelmässig die Frage, warum Admin-Konten in der Matrix keine Rolle spielen.
|
||||||
|
|
||||||
|
Hat der Mandant keine aktiven Module, rendert die Seite statt der Tabelle den leeren Zustand aus `adminModules.grants.emptyModules` mit einem Link auf `/admin/modules`.
|
||||||
|
|
||||||
|
Lege `grants-matrix.test.tsx` an mit Tests für: befüllte Matrix mit korrekt vorbelegten Kästchen, leerer Zustand ohne aktive Module, Rücksprung der Checkbox nach fehlgeschlagenem Request samt sichtbarer Fehlermeldung, Filterung über das Suchfeld, und das Vorhandensein eines `aria-label` an jedem Kästchen.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/web test -- grants-matrix</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `pnpm --filter @tessera/web test -- grants-matrix` ist grün und enthält je einen Test für die fünf oben genannten Fälle.
|
||||||
|
- `grep -c 'sticky left-0' "apps/web/src/app/(portal)/admin/modules/grants/page.tsx"` gibt mindestens `1` aus und `grep -c 'sticky top-0' "apps/web/src/app/(portal)/admin/modules/grants/page.tsx"` gibt mindestens `1` aus.
|
||||||
|
- `grep -c 'truncate' "apps/web/src/app/(portal)/admin/modules/grants/page.tsx"` gibt mindestens `2` aus (erste Spalte und Kopfzeile) und `grep -c 'title=' "apps/web/src/app/(portal)/admin/modules/grants/page.tsx"` gibt mindestens `2` aus.
|
||||||
|
- `grep -c 'aria-label' "apps/web/src/app/(portal)/admin/modules/grants/page.tsx"` gibt mindestens `1` aus.
|
||||||
|
- `grep -c 'adminNote' "apps/web/src/app/(portal)/admin/modules/grants/page.tsx"` gibt mindestens `1` aus.
|
||||||
|
- `grep -c 'grantsLink' "apps/web/src/app/(portal)/admin/modules/page.tsx"` gibt mindestens `1` aus.
|
||||||
|
- `pnpm --filter @tessera/web test` läuft vollständig grün und `pnpm --filter @tessera/web run build` schliesst fehlerfrei ab.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Der gesamte Berechtigungsstand ist als Matrix sichtbar und jede Zelle setzt oder entzieht eine Freigabe sofort, ohne dass die Oberfläche je einen Zustand zeigt, den die Datenbank nicht kennt.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Aktivierungsdialog mit drei Aktionen in /admin/modules</name>
|
||||||
|
<files>apps/web/src/app/(portal)/admin/modules/components/ActivateModuleDialog.tsx, apps/web/src/app/(portal)/admin/modules/page.tsx, apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/web/src/app/(portal)/admin/modules/page.tsx — `toggleModule` (Zeilen 80–112) und der Aktivieren-Button, dessen Verhalten hier verzweigt wird
|
||||||
|
- apps/web/src/app/(portal)/marketplace/components/ActivationDialog.tsx — das bestehende Dialog-Markup dieses Projekts, dessen Overlay- und Container-Stil übernommen wird
|
||||||
|
- apps/web/src/messages/de.json — die in Plan 15-06 angelegten Schlüssel unter `adminModules.activationDialog`
|
||||||
|
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-UI-SPEC.md — Surface Contract 6
|
||||||
|
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-03-SUMMARY.md — die Signatur von `POST /module-grants`
|
||||||
|
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-02-SUMMARY.md — das Antwortformat von `GET /groups`, aus dem die markierte Standardgruppe abgelesen wird
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Lege `apps/web/src/app/(portal)/admin/modules/components/ActivateModuleDialog.tsx` an, im Overlay-Muster `fixed inset-0 z-50 flex items-center justify-center bg-black/50` mit einem Container `w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg` — derselbe Stil und dieselbe Breite wie der bestehende Dialog des Marketplace.
|
||||||
|
|
||||||
|
Titel aus `adminModules.activationDialog.title` mit dem Modulnamen als Interpolationsparameter, darunter der Fragetext aus `.body`. Sowohl Titel als auch Fliesstext bekommen Umbruchverhalten, damit ein langer Modulname den schmalen Dialog nicht verbreitert, sondern in die nächste Zeile läuft.
|
||||||
|
|
||||||
|
Drei Aktionen von links nach rechts: Abbrechen als Rahmen-Button, der komplett abbricht und das Modul inaktiv lässt; `configureLater` als Rahmen-Button, der ausschliesslich `POST /modules/:id/activate` aufruft; `grantNow` als primärer Button in der Akzentfarbe, der `POST /modules/:id/activate` aufruft und danach `POST /module-grants` mit der moduleId und der groupId der markierten Standardgruppe.
|
||||||
|
|
||||||
|
Die Sofort-Freigabe ist bewusst zwei Aufrufe hintereinander und kein neuer kombinierter Endpoint — die beiden Vorgänge sind fachlich getrennt und sollen es bleiben.
|
||||||
|
|
||||||
|
Der Dialog lädt beim Öffnen `GET /groups` und sucht darin die Gruppe mit gesetzter Standardmarkierung. Existiert keine, ist `grantNow` deaktiviert und darunter erscheint der Hinweistext aus `.noDefaultGroupHint`, der den Admin auf die Gruppenverwaltung verweist. Die Markierung darf laut D-13 abgeschaltet sein, und der Dialog muss diesen Zustand tragen können, statt einen wirkungslosen Button anzubieten.
|
||||||
|
|
||||||
|
Verzweige `toggleModule` in `page.tsx`: die Deaktivierung bleibt unverändert im bestehenden Pfad, die Aktivierung öffnet künftig diesen Dialog statt direkt zu aktivieren. Nach jeder der beiden aktivierenden Aktionen wird der Aktivierungszustand wie bisher optimistisch aktualisiert und die Sidebar-Aktualisierung angestossen; im Fehlerfall wird zurückgesetzt und die Meldung im bestehenden Fehler-Div angezeigt.
|
||||||
|
|
||||||
|
Ergänze `grants-matrix.test.tsx` oder eine eigene Testdatei um Tests für: alle drei Buttons sind vorhanden; bei fehlender Standardgruppe ist die Sofort-Freigabe deaktiviert und der Hinweistext sichtbar; ein Klick auf die Sofort-Freigabe löst beide Aufrufe in der richtigen Reihenfolge aus.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/web test -- grants-matrix</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- Die drei Tests aus dem Action-Abschnitt sind vorhanden und grün.
|
||||||
|
- `grep -c 'max-w-sm' "apps/web/src/app/(portal)/admin/modules/components/ActivateModuleDialog.tsx"` gibt mindestens `1` aus.
|
||||||
|
- `grep -c 'noDefaultGroupHint' "apps/web/src/app/(portal)/admin/modules/components/ActivateModuleDialog.tsx"` gibt mindestens `1` aus.
|
||||||
|
- `grep -c 'disabled' "apps/web/src/app/(portal)/admin/modules/components/ActivateModuleDialog.tsx"` gibt mindestens `1` aus.
|
||||||
|
- `grep -c 'break-words\|break-all\|wrap' "apps/web/src/app/(portal)/admin/modules/components/ActivateModuleDialog.tsx"` gibt mindestens `1` aus — der Modulname bricht um statt zu verbreitern.
|
||||||
|
- `grep -c 'ActivateModuleDialog' "apps/web/src/app/(portal)/admin/modules/page.tsx"` gibt mindestens `2` aus (Import und Verwendung).
|
||||||
|
- `pnpm --filter @tessera/web test` läuft vollständig grün und `pnpm --filter @tessera/web run build` schliesst fehlerfrei ab.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Das Aktivieren eines Moduls fragt nach, ob sofort für die Standardgruppe freigegeben werden soll, und trägt den Fall, dass gar keine Standardgruppe markiert ist.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: Benutzer-Detail mit geerbten Rechten und Direkt-Freigaben</name>
|
||||||
|
<files>apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx, apps/web/src/app/(portal)/admin/users/page.tsx, apps/web/src/app/(portal)/admin/users/user-access-modal.test.tsx</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/web/src/app/(portal)/admin/users/page.tsx — die komplette Datei: die bestehenden Aktionsbuttons je Zeile (Zeilen 246–262), das Modal-Overlay-Muster (Zeilen 271–273) und die Fetch-Behandlung
|
||||||
|
- apps/web/src/app/(portal)/admin/ldap/page.tsx — Zeilen 957–974: das Chip-Markup, hier ohne Entfernen-Button verwendet
|
||||||
|
- apps/web/src/app/(portal)/admin/modules/grants/page.tsx — das in Task 1 entstandene optimistische Zellen-Toggle, dessen Verhalten die Direkt-Checkbox eins zu eins übernimmt
|
||||||
|
- apps/web/src/messages/de.json — die in Plan 15-06 angelegten Schlüssel unter `admin.users.grants`
|
||||||
|
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-UI-SPEC.md — Surface Contract 3
|
||||||
|
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-03-SUMMARY.md — das Antwortformat von `GET /module-grants/users/:userId`
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Ergänze in `apps/web/src/app/(portal)/admin/users/page.tsx` je Tabellenzeile einen weiteren Aktionsbutton mit dem Text aus `admin.users.grants.detailsButton`, im selben Stil `px-2 py-1 text-xs` wie die bestehenden Buttons. Er öffnet die neue Komponente `components/UserAccessModal.tsx`.
|
||||||
|
|
||||||
|
Der Dialog nutzt dasselbe Overlay-Muster wie die bestehenden Modale, aber mit `max-w-2xl` statt `max-w-md`, weil er mehr Inhalt trägt. Es entsteht kein neues Dialog-Primitiv und kein Slide-over. Titel aus `admin.users.grants.modalTitle` mit dem Benutzernamen.
|
||||||
|
|
||||||
|
Der Dialog lädt einmal `GET /module-grants/users/:userId` und rendert daraus zwei Abschnitte.
|
||||||
|
|
||||||
|
Erster Abschnitt Gruppenmitgliedschaften: eine Chip-Liste im Markup der bestehenden Ausschlussliste der LDAP-Seite, aber ohne Entfernen-Button. Die Mitgliedschaften werden ausschliesslich unter `/admin/groups` bearbeitet — eine zweite Bearbeitungsoberfläche für dieselbe Beziehung würde zwei Wahrheiten über den Bearbeitungsort schaffen. Ist der Benutzer in keiner Gruppe, erscheint der Hinweistext aus `admin.users.grants.noGroups`.
|
||||||
|
|
||||||
|
Zweiter Abschnitt Modul-Zugriff: eine Tabelle mit drei Spalten. Modul; über welche Gruppen der Benutzer das Modul erbt, als Chip-Liste der Gruppennamen oder als Gedankenstrich, wenn er es über keine Gruppe erbt; und Direkt als Kontrollkästchen für einen direkten Grant. Gelistet werden ausschliesslich mandantenweit aktive Module, weil ein Grant ohne Aktivierung wirkungslos wäre.
|
||||||
|
|
||||||
|
Die Anzeige der geerbten Rechte ist der eigentliche Zweck dieses Dialogs. Ohne sie ist nicht erkennbar, warum ein Benutzer Zugriff hat, und ein Admin könnte einen Direkt-Grant entziehen und sich wundern, dass der Zugriff bestehen bleibt — weil er über eine Gruppe weiterhin besteht.
|
||||||
|
|
||||||
|
Das Direkt-Kästchen verhält sich identisch zur Matrix-Zelle: optimistisches Umschalten, `POST /module-grants` beziehungsweise `DELETE /module-grants` mit moduleId und userId im Hintergrund, Inline-Spinner nur in der betroffenen Zeile, Rücksprung auf den Serverzustand plus sichtbare Fehlermeldung aus `admin.users.grants.saveError` im Fehlerfall. Weil die Zelle keinen sichtbaren Text trägt, bekommt jedes Kästchen ein `aria-label` aus `admin.users.grants.directCheckboxLabel` mit Modul- und Benutzernamen.
|
||||||
|
|
||||||
|
Hat der Mandant überhaupt keine aktiven Module, erscheint statt eines leeren Tabellenrahmens der Hinweistext aus `admin.users.grants.noActiveModules`.
|
||||||
|
|
||||||
|
Lege `user-access-modal.test.tsx` an mit Tests für: Chip-Liste mit Gruppennamen und der Leerzustand; Modultabelle mit einem geerbten und einem nicht geerbten Modul; Rücksprung der Direkt-Checkbox nach fehlgeschlagenem Request samt sichtbarer Fehlermeldung; der Hinweistext bei einem Mandanten ohne aktive Module; das Vorhandensein eines `aria-label` an jedem Direkt-Kästchen.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/web test -- user-access-modal</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `pnpm --filter @tessera/web test -- user-access-modal` ist grün und enthält je einen Test für die fünf oben genannten Fälle.
|
||||||
|
- `grep -c 'max-w-2xl' "apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx"` gibt mindestens `1` aus.
|
||||||
|
- `grep -c 'noActiveModules' "apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx"` gibt mindestens `1` aus.
|
||||||
|
- `grep -c 'directCheckboxLabel' "apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx"` gibt mindestens `1` aus.
|
||||||
|
- `grep -c 'module-grants/users' "apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx"` gibt mindestens `1` aus — geerbte und direkte Rechte kommen aus einer Antwort.
|
||||||
|
- `grep -c 'detailsButton' "apps/web/src/app/(portal)/admin/users/page.tsx"` gibt mindestens `1` aus.
|
||||||
|
- `pnpm --filter @tessera/web test` läuft vollständig grün und `pnpm --filter @tessera/web run build` schliesst fehlerfrei ab.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Im Benutzer-Detail ist auf einen Blick sichtbar, was der Benutzer über Gruppen erbt und was ihm zusätzlich direkt gegeben wurde, und Einzelfreigaben lassen sich dort setzen und entziehen.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Browser → Grant-API | Jede Matrix-Zelle und jedes Direkt-Kästchen schickt moduleId plus groupId oder userId; die Mandantenprüfung passiert ausschliesslich serverseitig in Plan 15-03 |
|
||||||
|
| Optimistische Oberfläche → Serverzustand | Zwischen Klick und Antwort zeigt die Oberfläche kurzzeitig einen noch nicht bestätigten Zustand |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-15-25 | Tampering | Die Oberfläche zeigt dauerhaft eine Freigabe an, die der Server abgelehnt hat | high | mitigate | Jeder fehlgeschlagene Request setzt die Checkbox auf den Serverzustand zurück UND zeigt eine sichtbare Fehlermeldung; je ein Testfall in beiden Testsuites belegt den Rücksprung. Ein optimistischer Zustand ohne Korrekturpfad wäre eine falsche Aussage über die Rechtelage |
|
||||||
|
| T-15-26 | Elevation of Privilege | Die clientseitige Rollenanzeige als Sicherheitsgrenze missverstehen | high | mitigate | Beide Seiten blenden nur aus; `RolesGuard` mit `@Roles(ADMIN, SUPER_ADMIN)` aus Plan 15-03 ist der Durchsetzungspunkt jeder aufgerufenen Route |
|
||||||
|
| T-15-27 | Tampering | Ein Grant auf ein mandantenweit inaktives Modul | medium | mitigate | Matrix und Benutzer-Detail listen ausschliesslich aktive Module; zusätzlich lehnt der Service aus Plan 15-03 einen Grant auf ein inaktives Modul mit HTTP 400 ab |
|
||||||
|
| T-15-28 | Repudiation | Ein Admin entzieht einen Direkt-Grant und hält den Zugriff für beendet, obwohl er über eine Gruppe fortbesteht | medium | mitigate | Die Spalte mit den gewährenden Gruppen steht direkt neben dem Direkt-Kästchen, sodass die Vererbung beim Entzug sichtbar ist (D-16) |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `pnpm --filter @tessera/web test` vollständig grün.
|
||||||
|
- `pnpm --filter @tessera/web run build` fehlerfrei.
|
||||||
|
- Manuell im Browser: in der Matrix eine Freigabe setzen und wieder entziehen und die Wirkung mit einem Testbenutzer gegenprüfen; ein Modul aktivieren und beide Dialogwege durchspielen; im Benutzer-Detail einen Direkt-Grant setzen, während derselbe Zugriff über eine Gruppe besteht, und prüfen, dass die Gruppenspalte das anzeigt.
|
||||||
|
- Manuell mit gestoppter API: eine Matrix-Zelle und ein Direkt-Kästchen anklicken und prüfen, dass beide zurückspringen und eine Fehlermeldung erscheint.
|
||||||
|
- Manuell mit einem sehr langen Gruppen- und Modulnamen: Matrix-Kopfzeile und Aktivierungsdialog bleiben in ihren Massen.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Gruppenfreigaben werden in der Matrix gepflegt, Einzelfreigaben im Benutzer-Detail, beide mit sofort wirksamem Toggle (PERM-03, D-15, D-16).
|
||||||
|
- Die geerbten Rechte sind im Benutzer-Detail sichtbar.
|
||||||
|
- Das Aktivieren eines Moduls fragt nach der Sofort-Freigabe und trägt den Fall ohne Standardgruppe (D-10, D-13).
|
||||||
|
- Kein optimistischer Zustand überlebt einen fehlgeschlagenen Request.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
## Artifacts this phase produces
|
||||||
|
|
||||||
|
Von diesem Plan erzeugt beziehungsweise verändert:
|
||||||
|
|
||||||
|
- Route `/admin/modules/grants` (`apps/web/src/app/(portal)/admin/modules/grants/page.tsx`)
|
||||||
|
- `ActivateModuleDialog` (`apps/web/src/app/(portal)/admin/modules/components/ActivateModuleDialog.tsx`)
|
||||||
|
- `UserAccessModal` (`apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx`)
|
||||||
|
- `apps/web/src/app/(portal)/admin/modules/page.tsx` — Button zur Matrix und Verzweigung von `toggleModule`
|
||||||
|
- `apps/web/src/app/(portal)/admin/users/page.tsx` — vierter Aktionsbutton je Zeile
|
||||||
|
- `grants-matrix.test.tsx` und `user-access-modal.test.tsx`
|
||||||
|
|
||||||
|
Dieser Plan legt keine neuen i18n-Schlüssel an — alle verwendeten Schlüssel entstehen in Plan 15-06. Die phasenweite Gesamtliste steht in `15-01-PLAN.md`.
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Erstelle `.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-07-SUMMARY.md`, wenn der Plan abgeschlossen ist.
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
---
|
||||||
|
phase: 15-modul-berechtigungen-gruppen-user-grants
|
||||||
|
plan: 08
|
||||||
|
type: execute
|
||||||
|
wave: 4
|
||||||
|
depends_on: ["15-03", "15-06"]
|
||||||
|
files_modified:
|
||||||
|
- apps/web/src/lib/module-access-actions.ts
|
||||||
|
- apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-shell.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-access.test.tsx
|
||||||
|
- apps/web/src/app/(portal)/marketplace/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/marketplace/[slug]/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx
|
||||||
|
- apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.test.tsx
|
||||||
|
autonomous: true
|
||||||
|
requirements: [PERM-04]
|
||||||
|
user_setup: []
|
||||||
|
|
||||||
|
estimate:
|
||||||
|
tokens: 70000
|
||||||
|
raw_tokens: 70000
|
||||||
|
tasks: 2
|
||||||
|
confidence: low
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Ruft ein Benutzer die URL eines nicht freigegebenen Moduls direkt auf, erscheint eine 403-Seite mit dem Hinweis, sich an den Administrator zu wenden — kein stiller Rücksprung und keine Nicht-gefunden-Seite (D-07, PERM-04)."
|
||||||
|
- "Die Prüfung passiert serverseitig in der Modulseiten-Route, bevor irgendein Modulcode ausgeliefert wird; das Ausblenden in der Sidebar ist ausdrücklich keine Zugriffskontrolle (D-07)."
|
||||||
|
- "Der Marketplace zeigt auch nicht freigegebene Module weiter, gekennzeichnet mit einem Badge 'Kein Zugriff'; öffnen lassen sie sich nicht (D-08)."
|
||||||
|
- "Der Modulkatalog bleibt für jeden authentifizierten Benutzer offen — der Marketplace ist ein Schaufenster, keine Zugriffsentscheidung (D-08)."
|
||||||
|
- "ADMIN und SUPER_ADMIN sehen das Sperr-Badge nie; für sie ist jede aktivierte Karte normal anklickbar (D-03)."
|
||||||
|
- "Sidebar, Modulseite und API stützen sich auf dieselbe Zugriffsauflösung — die Modulseite fragt sie über die API ab, statt eine eigene Regel zu bauen (D-01, PERM-04)."
|
||||||
|
- "Die serverseitige Prüfung schliesst im Zweifel: fehlt das Sitzungs-Cookie oder ist die API nicht erreichbar, gilt der Zugriff als nicht gewährt."
|
||||||
|
- "Der bisherige Inhalt der Modulseite — die Whitelist-Registrierung, das Nachladen der Modulkomponente und der Nicht-gefunden-Zustand für unregistrierte Slugs — bleibt unverändert erhalten und wandert nur in eine eigene Client-Komponente."
|
||||||
|
# --- UI-SPEC 'UI Considerations' — covered ---
|
||||||
|
- "empty, loading, error, populated und zero-one-many des Marketplace-Katalogs bleiben unverändert gegenüber dem ausgelieferten Stand — dieser Plan ergänzt ausschliesslich das Badge und den nicht anklickbaren Zustand einer einzelnen Karte."
|
||||||
|
- "empty, loading und error der 403-Seite und des Aktivierungsdialogs: beide sind reine Zustandsanzeigen ohne eigenen Datenabruf; die 403-Seite rendert serverseitig fertig, Fehler beim Aktivieren laufen über den bestehenden Fehlerpfad der Modulseite."
|
||||||
|
# --- UI-SPEC 'UI Considerations' — backstop ---
|
||||||
|
- statement: "long-text und overflow (Marketplace-Karte mit langem Modulnamen plus zusätzlichem Sperr-Badge): Die Badge-Reihe bricht bei schmaler Karte um, statt aus der Karte zu laufen; die Titelkürzung deckt nur den Titel ab, nicht die Badge-Reihe."
|
||||||
|
verification: backstop
|
||||||
|
- statement: "partial (Marketplace-Karte, bevor der Freigabe-Status bekannt ist): Aktivierungs- und Freigabestatus kommen aus derselben Antwort, sodass keine Karte kurzzeitig ohne Sperr-Badge anklickbar erscheint und erst nachträglich sperrt."
|
||||||
|
verification: backstop
|
||||||
|
artifacts:
|
||||||
|
- "apps/web/src/lib/module-access-actions.ts — checkModuleAccess"
|
||||||
|
- "apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx — Server Component mit 403-Zustand"
|
||||||
|
- "apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-shell.tsx"
|
||||||
|
- "apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-access.test.tsx"
|
||||||
|
- "MarketplaceCard mit hasAccess-Prop und Sperr-Badge"
|
||||||
|
key_links:
|
||||||
|
- "page.tsx → checkModuleAccess → GET /modules/active — die Modulseite fragt dieselbe Auflösung ab, die auch Guard und Sidebar bedienen"
|
||||||
|
- "MarketplaceCard → GET /modules/catalog — beide Statusflags kommen in einer Antwort, damit kein Zwischenzustand entsteht"
|
||||||
|
- "module-shell.tsx → module-loader Whitelist — der Sicherheitsmechanismus gegen beliebige Slugs bleibt unverändert bestehen"
|
||||||
|
prohibitions:
|
||||||
|
- statement: "Sichtbarkeit ist nie Zugriffskontrolle — kein Ausblenden in Sidebar, Marketplace oder Grid darf als Sperre gelten; jede Sperre muss serverseitig durchgesetzt sein und clientseitig nur gespiegelt werden."
|
||||||
|
status: active
|
||||||
|
verification: flagged-unverified
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Die beiden Oberflächen, an denen ein Benutzer ohne Freigabe tatsächlich anschlägt: die Modulseite, die serverseitig sperrt und eine erklärende 403-Seite rendert, und der Marketplace-Katalog, der gesperrte Module weiter zeigt, aber als solche kennzeichnet.
|
||||||
|
|
||||||
|
Purpose: Die Sidebar zeigt seit Plan 15-01 nur noch freigegebene Module — das ist Bequemlichkeit, keine Sperre. Wer die URL kennt oder ein Lesezeichen hat, kommt weiterhin auf die Seite. D-07 verlangt deshalb ausdrücklich eine serverseitige Prüfung in der Modulseiten-Route selbst. Die heutige Datei ist vollständig eine Client-Komponente ohne serverseitigen Datenabruf und erfüllt das nicht.
|
||||||
|
Output: Eine Server-Action für den Zugriffscheck, die zur Server-Komponente umgebaute Modulseite mit ausgelagerter Client-Hülle, und die Marketplace-Karte mit drittem Zustand.
|
||||||
|
</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/STATE.md
|
||||||
|
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-CONTEXT.md
|
||||||
|
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-UI-SPEC.md
|
||||||
|
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-PATTERNS.md
|
||||||
|
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-03-SUMMARY.md
|
||||||
|
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-06-SUMMARY.md
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Serverseitige Modulsperre mit 403-Seite</name>
|
||||||
|
<files>apps/web/src/lib/module-access-actions.ts, apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx, apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-shell.tsx, apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-access.test.tsx</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx — die komplette Datei: der Nicht-gefunden-Block (Zeilen 30–64) als visuelle Vorlage für den 403-Zustand, die Whitelist-Prüfung gegen die Modulregistrierung und der Rücknavigations-Block
|
||||||
|
- apps/web/src/lib/auth-actions.ts — Zeilen 243–268: `fetchCurrentUser` als vollständiges Kopiermuster für die Cookie-Weiterleitung in einer Server-Action, inklusive des fehlschlagenden Pfads
|
||||||
|
- apps/web/src/lib/module-loader.ts — die Whitelist und `loadModuleComponent`, deren Verhalten unverändert bleiben muss
|
||||||
|
- apps/web/src/messages/de.json — die in Plan 15-06 angelegten Schlüssel unter `modules.accessDenied`
|
||||||
|
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-UI-SPEC.md — Surface Contract 4 und der Copywriting Contract zur 403-Seite
|
||||||
|
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-RESEARCH.md — Pattern 5 zum Server-Komponenten-Schnitt
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Lege `apps/web/src/lib/module-access-actions.ts` an, als Server-Modul mit der Direktive am Dateikopf, analog zu `auth-actions.ts`. Exportiere `checkModuleAccess(moduleSlug: string): Promise<boolean>`. Die Funktion liest das Sitzungs-Cookie über `cookies()` und ruft `GET /modules/active` mit weitergeleitetem Cookie-Header, `credentials: 'include'` und `cache: 'no-store'` auf — exakt das Muster von `fetchCurrentUser`. Aus der Antwort wird geprüft, ob der gesuchte Slug enthalten ist.
|
||||||
|
|
||||||
|
Die Funktion schliesst im Zweifel: fehlt das Cookie, antwortet die API nicht erfolgreich oder wirft der Aufruf, ist das Ergebnis falsch. Ein Fehler im Netzwerkpfad darf nie zu einem offenen Zugang führen.
|
||||||
|
|
||||||
|
Es wird bewusst der bestehende Endpoint wiederverwendet und kein eigener Zugriffs-Endpoint eingeführt. Die Modulanzahl je Mandant liegt im einstelligen bis niedrigen zweistelligen Bereich, der Umweg über die Liste ist damit unkritisch, und es entsteht kein zweiter Vertrag über dieselbe Frage.
|
||||||
|
|
||||||
|
Baue `page.tsx` in eine asynchrone Server-Komponente um. Die Datei trägt danach keine Client-Direktive mehr, liest `params` und ruft `checkModuleAccess(moduleSlug)` auf.
|
||||||
|
|
||||||
|
Ist der Zugriff nicht gewährt, rendert die Seite unmittelbar das 403-Markup als Server-Antwort. Sie ruft dabei weder die Next.js-Weiche für nicht gefundene Seiten noch eine Umleitung auf — D-07 schliesst beides ausdrücklich aus, weil der Benutzer erfahren soll, dass das Modul existiert und ihm die Freigabe fehlt, statt es für nicht vorhanden zu halten oder wortlos woanders zu landen.
|
||||||
|
|
||||||
|
Das 403-Markup übernimmt die Struktur des bestehenden Nicht-gefunden-Blocks eins zu eins: zentrierter Block, Icon-Container mit `rounded-lg bg-muted p-4`, eine Überschrift in 18 Pixel und Gewicht 600, ein Absatz in 14 Pixel `text-muted-foreground`, darunter ein Link. Nur zwei Dinge ändern sich. Erstens das Icon: statt des Kreises mit Schrägstrich ein Schloss-Motiv im selben SVG-Vokabular, damit "existiert nicht" und "gesperrt" optisch unterscheidbar bleiben. Zweitens Text und Ziel: Überschrift aus `modules.accessDenied.title`, Text aus `modules.accessDenied.body`, und ein Link auf die Startseite mit `modules.accessDenied.backToDashboard` — nicht zurück in die Modulkategorie, weil ein Rücksprung dorthin für ein Modul ohne Freigabe kein sinnvoller nächster Schritt ist.
|
||||||
|
|
||||||
|
Ist der Zugriff gewährt, rendert die Seite `<ModuleShell category={category} moduleSlug={moduleSlug} />`.
|
||||||
|
|
||||||
|
Lege `module-shell.tsx` als Client-Komponente an und verschiebe den bisherigen Inhalt von `page.tsx` unverändert dorthin: die Whitelist-Prüfung gegen die Modulregistrierung, das Nachladen der Modulkomponente, den Nicht-gefunden-Zustand für unregistrierte Slugs und den Rücknavigations-Block. Der Sicherheitskommentar zur Whitelist wandert mit. An diesem Verhalten wird nichts geändert: die Whitelist verhindert weiterhin, dass ein beliebiger Slug aus der URL einen Import auslöst, und ist eine von der Freigabeprüfung unabhängige zweite Absicherung. Statt der Hook-basierten Parameterauflösung bekommt die Komponente `category` und `moduleSlug` als Props.
|
||||||
|
|
||||||
|
Lege `module-access.test.tsx` an mit Tests für: bei verweigertem Zugriff erscheinen Titel und Text der 403-Seite und die Modulhülle wird nicht gerendert; bei gewährtem Zugriff wird die Modulhülle gerendert; bei fehlendem Sitzungs-Cookie ist das Ergebnis von `checkModuleAccess` falsch; bei einer nicht erfolgreichen API-Antwort ist das Ergebnis falsch; ein unregistrierter Slug bei gewährtem Zugriff führt weiterhin in den Nicht-gefunden-Zustand der Modulhülle.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/web test -- module-access</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `pnpm --filter @tessera/web test -- module-access` ist grün und enthält je einen Test für die fünf oben genannten Fälle.
|
||||||
|
- `grep -c "'use client'" "apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx"` gibt `0` aus.
|
||||||
|
- `grep -c "'use client'" "apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-shell.tsx"` gibt `1` aus.
|
||||||
|
- `grep -c 'notFound(\|redirect(' "apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx"` gibt `0` aus.
|
||||||
|
- `grep -c 'checkModuleAccess' "apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx" apps/web/src/lib/module-access-actions.ts` belegt Aufruf und Definition.
|
||||||
|
- `grep -c 'accessDenied' "apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx"` gibt mindestens `2` aus (Titel und Text).
|
||||||
|
- `grep -c 'MODULE_REGISTRY' "apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-shell.tsx"` gibt mindestens `1` aus — die Whitelist ist unverändert mitgewandert.
|
||||||
|
- Manuell: als USER ohne Freigabe die URL eines aktivierten Moduls direkt aufrufen; die Antwort ist die 403-Seite mit Schloss-Icon und Link auf die Startseite. Im Netzwerk-Reiter ist erkennbar, dass kein Modul-Bundle nachgeladen wurde.
|
||||||
|
- `pnpm --filter @tessera/web test` läuft vollständig grün und `pnpm --filter @tessera/web run build` schliesst fehlerfrei ab.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Der direkte URL-Aufruf eines nicht freigegebenen Moduls endet serverseitig in einer erklärenden 403-Seite, ohne dass Modulcode ausgeliefert wird, und der bestehende Whitelist-Schutz ist unverändert erhalten.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Marketplace-Karte mit drittem Zustand "Kein Zugriff"</name>
|
||||||
|
<files>apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx, apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.test.tsx, apps/web/src/app/(portal)/marketplace/page.tsx, apps/web/src/app/(portal)/marketplace/[slug]/page.tsx</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx — die komplette Datei: die Props-Schnittstelle, die Badge-Reihe (Zeilen 96–110) und der Kartenzustand samt Klickverhalten (Zeilen 88, 120–138)
|
||||||
|
- apps/web/src/app/(portal)/marketplace/page.tsx — Zeilen 34–60: der zweigeteilte Datenabruf und die Ableitung des Aktivierungszustands aus der Antwort
|
||||||
|
- apps/web/src/app/(portal)/marketplace/[slug]/page.tsx — Zeilen 45–60: derselbe zweigeteilte Abruf auf der Detailseite
|
||||||
|
- apps/web/src/app/(portal)/marketplace/components/Toast.tsx — die bestehende Toast-Komponente, die unverändert wiederverwendet wird
|
||||||
|
- apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.test.tsx — die bestehende Testsuite, die erweitert wird
|
||||||
|
- apps/web/src/messages/de.json — die in Plan 15-06 angelegten Schlüssel `marketplace.statusNoAccess` und `marketplace.toastNoAccess`
|
||||||
|
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-UI-SPEC.md — Surface Contract 5 und die Badge-Palette im Abschnitt Color
|
||||||
|
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-03-SUMMARY.md — das Antwortformat von `GET /modules/catalog`
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Stelle den Datenabruf in `marketplace/page.tsx` und in `marketplace/[slug]/page.tsx` auf den einen Aufruf `GET /modules/catalog` um, der je Modul den vollständigen Datensatz plus die beiden Flags für mandantenweite Aktivierung und Benutzerzugriff liefert. Die bisherige Kombination aus zwei Aufrufen entfällt.
|
||||||
|
|
||||||
|
Der Wechsel ist notwendig, nicht kosmetisch: seit Plan 15-01 antwortet `GET /modules/active` benutzergefiltert, und aus dieser Antwort allein könnte der Marketplace für einen USER nicht mehr zwischen "nicht aktiviert" und "aktiviert, aber nicht freigegeben" unterscheiden. Genau diese Unterscheidung ist der Kern von D-08.
|
||||||
|
|
||||||
|
Dass beide Flags aus derselben Antwort kommen, löst zugleich den Zwischenzustand: es gibt keinen Moment, in dem eine Karte bereits gerendert und anklickbar ist, ihr Sperrhinweis aber noch fehlt und nachträglich erscheint.
|
||||||
|
|
||||||
|
Erweitere `MarketplaceCard` um die Prop `hasAccess: boolean`. Bei `isActive && !hasAccess` gilt: ein zusätzliches drittes Badge mit dem Text aus `marketplace.statusNoAccess` erscheint in derselben Badge-Reihe, in Bernstein (`bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400`). Bernstein und nicht Rot ist eine bewusste Wahl: es ist kein Fehlerzustand, sondern ein Informationszustand.
|
||||||
|
|
||||||
|
Die Badge-Reihe bekommt zusätzlich Umbruchverhalten. Der bestehende Container fasst zwei Badges nebeneinander; mit einem dritten und einem langen Modulnamen läuft er bei schmaler Karte über. Die Titelkürzung deckt nur den Titel ab, nicht die Badge-Reihe — deshalb bricht die Reihe um, statt aus der Karte zu laufen.
|
||||||
|
|
||||||
|
Der äussere Kartencontainer bekommt in diesem Zustand `opacity-60 cursor-not-allowed` und verliert die Hover-Effekte, damit sichtbar ist, dass die Karte nicht interaktiv ist. Ein Klick navigiert nicht zum Moduldetail, sondern löst über die bestehende Toast-Komponente den Text aus `marketplace.toastNoAccess` aus — wortgleich zur 403-Seite, damit derselbe Sachverhalt nicht zwei Formulierungen bekommt. Es entsteht kein neues Toast-System.
|
||||||
|
|
||||||
|
Für ADMIN und SUPER_ADMIN ist `hasAccess` bei jedem aktiven Modul wahr, weil die API den Rollen-Kurzschluss anwendet. Das Badge erscheint für sie damit nie, ohne dass die Karte selbst eine Rollenabfrage braucht.
|
||||||
|
|
||||||
|
Der Katalog bleibt vollständig sichtbar: nicht freigegebene Module werden gekennzeichnet, nicht ausgeblendet. Der Marketplace ist ein Schaufenster, und die Zugriffsentscheidung liegt bei der API.
|
||||||
|
|
||||||
|
Erweitere `MarketplaceCard.test.tsx` um Tests für: bei aktivem Modul ohne Zugriff erscheint das dritte Badge und der Klick löst den Toast statt einer Navigation aus; bei aktivem Modul mit Zugriff erscheint das Badge nicht und der Klick navigiert; bei nicht aktiviertem Modul bleibt das bisherige Verhalten unverändert; die Badge-Reihe trägt Umbruchverhalten.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/web test -- MarketplaceCard</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `pnpm --filter @tessera/web test -- MarketplaceCard` ist grün und enthält je einen Test für die vier oben genannten Fälle.
|
||||||
|
- `grep -c 'hasAccess' "apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx"` gibt mindestens `3` aus (Props-Schnittstelle, Destrukturierung, Verwendung).
|
||||||
|
- `grep -c 'statusNoAccess' "apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx"` gibt mindestens `1` aus.
|
||||||
|
- `grep -c 'amber' "apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx"` gibt mindestens `2` aus (Hell- und Dunkelvariante).
|
||||||
|
- `grep -c 'flex-wrap' "apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx"` gibt mindestens `1` aus.
|
||||||
|
- `grep -c 'modules/catalog' "apps/web/src/app/(portal)/marketplace/page.tsx" "apps/web/src/app/(portal)/marketplace/[slug]/page.tsx"` belegt die Umstellung auf beiden Seiten.
|
||||||
|
- `grep -c 'fetch(' "apps/web/src/app/(portal)/marketplace/page.tsx"` ist gegenüber dem Stand vor diesem Task um mindestens `1` gesunken — der zweigeteilte Abruf ist durch einen einzigen ersetzt.
|
||||||
|
- Manuell im Browser als USER: ein aktiviertes Modul ohne Freigabe trägt in der Übersicht das Sperr-Badge, ist ausgegraut und zeigt beim Klick den Hinweis-Toast; dasselbe Modul als ADMIN trägt kein Badge und ist anklickbar. Bei sehr schmalem Fenster bleiben beide Badges innerhalb der Karte.
|
||||||
|
- `pnpm --filter @tessera/web test` läuft vollständig grün und `pnpm --filter @tessera/web run build` schliesst fehlerfrei ab.
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Der Marketplace unterscheidet sichtbar zwischen nicht aktiviert, aktiviert-ohne-Freigabe und zugänglich, zeigt gesperrte Module weiterhin an und lässt sie nicht öffnen.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Browser-URL → Modulseiten-Route | Der Slug in der URL ist Benutzereingabe; die Route entscheidet serverseitig über Auslieferung |
|
||||||
|
| Next.js-Server → API | Die Server-Action leitet ausschliesslich das Sitzungs-Cookie weiter, keinen selbst gebildeten Identitätsnachweis |
|
||||||
|
| Marketplace-Karte → Anzeigezustand | Die Sperre der Karte ist Anzeige, nicht Durchsetzung |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-15-05 | Elevation of Privilege | Frontend-seitiges Ausblenden als Sicherheitsgrenze behandeln | high | mitigate | Die Modulseite prüft serverseitig vor dem Rendern und liefert bei fehlender Freigabe kein Modul-Bundle aus; die eigentliche Durchsetzung bleibt der `ModuleGuard` auf jedem Modul-Endpoint. Sidebar- und Marketplace-Zustand sind ausdrücklich nur Spiegelung |
|
||||||
|
| T-15-29 | Elevation of Privilege | Fehlerhafter Netzwerkpfad in `checkModuleAccess` öffnet die Seite | high | mitigate | Die Funktion schliesst im Zweifel: fehlendes Cookie, nicht erfolgreiche Antwort und geworfener Fehler ergeben alle ein negatives Ergebnis; zwei eigene Testfälle belegen das |
|
||||||
|
| T-15-30 | Elevation of Privilege | Ein beliebiger Slug aus der URL löst einen Import aus | medium | mitigate | Die Whitelist-Prüfung der Modulregistrierung wandert unverändert in die Client-Hülle und bleibt eine von der Freigabeprüfung unabhängige zweite Absicherung |
|
||||||
|
| T-15-08 | Information Disclosure | `GET /modules/catalog` zeigt jedem angemeldeten Benutzer den Modulkatalog | low | accept | Bewusste Entscheidung aus D-08: der Katalog bleibt Schaufenster und liefert ausschliesslich Modul-Metadaten sowie zwei Boolesche, keine Modulinhalte |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `pnpm --filter @tessera/web test` vollständig grün.
|
||||||
|
- `pnpm --filter @tessera/web run build` fehlerfrei.
|
||||||
|
- Manuell als USER ohne Freigabe: Modul fehlt in der Sidebar, die direkte URL liefert die 403-Seite, die Marketplace-Karte trägt das Sperr-Badge und der Klick zeigt den Toast, und ein Aufruf des Modul-Endpoints der API antwortet mit HTTP 403. Alle vier Ebenen zeigen dasselbe Bild — das ist der Nachweis von PERM-04.
|
||||||
|
- Manuell als ADMIN desselben Mandanten: alle vier Ebenen zeigen das Modul als zugänglich.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Ohne Freigabe fehlt das Modul in der Sidebar, ist die Modulseite serverseitig gesperrt und antwortet die Modul-API mit 403 (PERM-04).
|
||||||
|
- Die 403-Seite nennt, was zu tun ist, statt nur zu sperren (D-07).
|
||||||
|
- Der Marketplace kennzeichnet gesperrte Module, statt sie auszublenden (D-08).
|
||||||
|
- Kein Zwischenzustand zeigt eine gesperrte Karte kurzzeitig als anklickbar.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
## Artifacts this phase produces
|
||||||
|
|
||||||
|
Von diesem Plan erzeugt beziehungsweise verändert:
|
||||||
|
|
||||||
|
- `checkModuleAccess` (`apps/web/src/lib/module-access-actions.ts`)
|
||||||
|
- `apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx` — Umbau zur Server-Komponente mit 403-Zustand
|
||||||
|
- `apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-shell.tsx` — die ausgelagerte Client-Hülle
|
||||||
|
- `apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-access.test.tsx`
|
||||||
|
- `MarketplaceCard` — Prop `hasAccess`, Sperr-Badge, nicht anklickbarer Zustand, Toast statt Navigation
|
||||||
|
- `marketplace/page.tsx` und `marketplace/[slug]/page.tsx` — Umstellung auf `GET /modules/catalog`
|
||||||
|
- `MarketplaceCard.test.tsx` — erweitert
|
||||||
|
|
||||||
|
Dieser Plan legt keine neuen i18n-Schlüssel an — alle verwendeten Schlüssel entstehen in Plan 15-06. Die phasenweite Gesamtliste steht in `15-01-PLAN.md`.
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Erstelle `.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-08-SUMMARY.md`, wenn der Plan abgeschlossen ist.
|
||||||
|
</output>
|
||||||
Reference in New Issue
Block a user