Files
tessera-ctl/.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-05-PLAN.md
T
schalli 8a4bb32847
Tessera CI/CD / Lint & Type Check (push) Successful in 47s
Tessera CI/CD / Tests (push) Successful in 46s
Tessera CI/CD / Build & Publish Images (push) Successful in 6s
docs(15): create phase plan — 8 plans, 4 waves, PERM-01..07
2026-08-04 14:39:46 +02:00

195 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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>