--- 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" --- 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. @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.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 Task 1: Statische Widget-Modul-Zuordnung anlegen apps/api/src/dashboard/widget-module-map.ts - 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 Lege `apps/api/src/dashboard/widget-module-map.ts` an und exportiere die Konstante `WIDGET_MODULE_MAP` vom Typ `Readonly>`, 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. pnpm --filter @tessera/api run type-check - `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. Die Zuordnungsmechanik existiert als Code-Konstante mit dokumentierter Begründung, ohne Schemaänderung und ohne neuen Widget-Typ. Task 2: getWidgets filtert über dieselbe Zugriffsauflösung wie Guard und Sidebar 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 - 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 - 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). 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 `` 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. pnpm --filter @tessera/api test -- dashboard.service - `pnpm --filter @tessera/api test -- dashboard.service` ist grün und enthält für jeden unter `` 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. Ein modulgebundenes Widget verschwindet für einen Benutzer ohne Freigabe serverseitig aus der Auslieferung, während bei leerer Zuordnungstabelle nachweislich kein Bestandswidget betroffen ist. 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. ## 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 | - `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. - 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. ## 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`. Erstelle `.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-05-SUMMARY.md`, wenn der Plan abgeschlossen ist.