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

16 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, user_setup, estimate, must_haves
phase plan type wave depends_on files_modified autonomous requirements user_setup estimate must_haves
15-modul-berechtigungen-gruppen-user-grants 05 execute 2
15-01
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
true
PERM-07
tokens raw_tokens tasks confidence
38000 38000 2 low
truths artifacts key_links
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.
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.
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
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.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_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 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 `<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.
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.

<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>
- `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.

<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.

Erstelle `.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-05-SUMMARY.md`, wenn der Plan abgeschlossen ist.