195 lines
16 KiB
Markdown
195 lines
16 KiB
Markdown
---
|
||
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>
|