--- quick_id: 260907-e8k slug: zugriffs-guard-greift-nicht-auf-modul-ei date: 2026-09-07 status: planned relates_to: 15-modul-berechtigungen-gruppen-user-grants windows_ref: 10 severity: high phase: quick-260907-e8k plan: 01 type: execute wave: 1 depends_on: [] files_modified: - apps/web/src/components/modules/module-access-denied.tsx - apps/web/src/components/modules/module-access-gate.tsx - apps/web/src/components/modules/module-access-gate.test.tsx - apps/web/src/app/(portal)/modules/cert-manager/layout.tsx - apps/web/src/app/(portal)/modules/dkv-fleet/layout.tsx - apps/web/src/app/(portal)/modules/domaincheck/layout.tsx - apps/web/src/app/(portal)/modules/tender-radar/layout.tsx - apps/web/src/app/(portal)/modules/module-layouts.test.tsx - apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx - apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-access.test.tsx autonomous: true requirements: [PERM-04] estimate: tokens: 45000 raw_tokens: 45000 tasks: 2 confidence: low must_haves: truths: - "Ein USER ohne Modulfreigabe bekommt auf /modules/tender-radar, /modules/tender-radar/my-sources, /modules/tender-radar/settings, /modules/dkv-fleet, /modules/dkv-fleet/settings, /modules/dkv-fleet/vehicles, /modules/cert-manager und /modules/domaincheck die 403-Seite als Serverantwort statt der Modulseite." - "Ein USER MIT Freigabe sowie jeder ADMIN/SUPER_ADMIN sieht genau dieselben Adressen unveraendert vollstaendig — die Sperre trifft niemanden zu viel." - "Die 403-Darstellung ist die Serverantwort selbst: keine Weiterleitung, kein Not-Found (D-07). Der Nutzer erfaehrt, dass das Modul existiert und ihm die Freigabe fehlt." - "Die Zugriffspruefung bleibt geschlossen bei fehlendem Sitzungscookie, nicht-ok-Antwort der API, Netzfehler UND geworfener Ausnahme." - "Die 403-Markierung steht genau einmal im Code — generische Route und modul-eigene Routen rendern dieselbe Komponente." artifacts: - apps/web/src/components/modules/module-access-denied.tsx - apps/web/src/components/modules/module-access-gate.tsx - apps/web/src/app/(portal)/modules/cert-manager/layout.tsx - apps/web/src/app/(portal)/modules/dkv-fleet/layout.tsx - apps/web/src/app/(portal)/modules/domaincheck/layout.tsx - apps/web/src/app/(portal)/modules/tender-radar/layout.tsx - apps/web/src/components/modules/module-access-gate.test.tsx - apps/web/src/app/(portal)/modules/module-layouts.test.tsx key_links: - "layout.tsx jedes Modulverzeichnisses -> ModuleAccessGate mit dem Slug, der exakt dem Verzeichnisnamen entspricht (cert-manager, dkv-fleet, domaincheck, tender-radar sind zugleich die DB-Slugs)" - "ModuleAccessGate -> checkModuleAccess -> GET /modules/active — dieselbe Zugriffsaufloesung wie Sidebar und ModuleGuard (D-01), keine zweite Rollenlogik im Frontend" - "Ein Layout deckt in Next.js App Router alle verschachtelten Unterrouten mit ab — genau darueber werden my-sources, settings und vehicles ohne eigene Datei geschlossen" --- Der serverseitige Zugriffs-Guard fuer Module greift heute nur auf der generischen Route `modules/[category]/[moduleSlug]/page.tsx`. Die vier Module mit fest verdrahteten eigenen Routen laufen daran vorbei und rendern fuer einen USER ohne Freigabe die volle Seite (WINDOWS #10, gemessen am 2026-09-07). Dieser Plan zieht die Pruefung in ein wiederverwendbares Gate und haengt sie ueber je ein `layout.tsx` vor jedes Modulverzeichnis. Ein Layout deckt alle Unterrouten mit ab — damit schliessen sich `my-sources`, `settings` und `vehicles` ohne eigene Datei. Purpose: PERM-04 gilt bisher nur fuer eine von zwei Routenformen. Es werden zwar keine Daten preisgegeben (die API antwortet ueberall mit 403), aber der Nutzer sieht bedienbare Knoepfe, die ins Leere laufen, und rohe englische Fehlertexte mitten in der deutschen Oberflaeche. Output: Eine gemeinsame 403-Komponente, ein Zugriffs-Gate als Server Component, vier Layout-Dateien, die zugehoerigen Tests und die auf die gemeinsamen Bausteine umgestellte generische Route. @~/.claude/gsd-core/workflows/execute-plan.md @~/.claude/gsd-core/templates/summary.md @.planning/STATE.md @CLAUDE.md @.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-UAT-2026-09-07.md @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-access.test.tsx Task 1: Gemeinsame 403-Komponente und ModuleAccessGate mit Tests apps/web/src/components/modules/module-access-denied.tsx, apps/web/src/components/modules/module-access-gate.tsx, apps/web/src/components/modules/module-access-gate.test.tsx apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx (das heute inline stehende 403-Markup, wird 1:1 uebernommen), apps/web/src/lib/module-access-actions.ts (checkModuleAccess, faellt bereits geschlossen aus — T-15-29), apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-access.test.tsx (Mock-Muster fuer next-intl/server und vi.doMock, wird uebernommen) - Gate mit gewaehrter Freigabe: rendert die uebergebenen Kinder, und die 403-Ueberschrift kommt nicht vor. - Gate mit verweigerter Freigabe: rendert Ueberschrift, Erlaeuterungstext und Rueckweg der 403-Seite, und die Kinder kommen NICHT vor. - Gate, wenn die Zugriffspruefung eine Ausnahme wirft: rendert die 403-Seite, nicht die Kinder (geschlossen fallen). - Gate uebergibt den Slug unveraendert an die Zugriffspruefung: bei Aufruf mit "tender-radar" wird die Pruefung genau einmal und genau mit "tender-radar" aufgerufen. - Die erwarteten deutschen Texte stehen im Test handgeschrieben ("Kein Zugriff auf dieses Modul", "Zur Startseite") und werden NICHT ueber dieselbe Uebersetzungsfunktion gebildet, die der Produktivcode nutzt — der Uebersetzungszugriff wird wie in module-access.test.tsx mit einer handgepflegten Schluesseltabelle nachgebildet. Erstelle `apps/web/src/components/modules/module-access-denied.tsx` mit der Komponente `ModuleAccessDenied`. Sie ist bewusst synchron und uebersetzungsfrei: sie nimmt die drei fertigen Texte als Eigenschaften `title`, `body` und `backToDashboard` entgegen und rendert das 403-Markup, das heute inline in `[category]/[moduleSlug]/page.tsx` steht — dieselbe Aussenhuelle, dasselbe Schloss-Symbol, derselbe `Link` auf `/`. Kopiere das Markup unveraendert, damit die bereits im Browser bestaetigte Ebene 2 der UAT optisch identisch bleibt. Die Komponente traegt keine Client-Direktive am Dateikopf. Erstelle `apps/web/src/components/modules/module-access-gate.tsx` mit der asynchronen Server-Komponente `ModuleAccessGate({ moduleSlug, children })`. Sie ruft `checkModuleAccess(moduleSlug)` aus `@/lib/module-access-actions` auf. Bei gewaehrtem Zugriff gibt sie die Kinder zurueck. Andernfalls holt sie die Uebersetzungen ueber `getTranslations('modules')` aus `next-intl/server` und rendert `ModuleAccessDenied` mit `accessDenied.title`, `accessDenied.body` und `accessDenied.backToDashboard`. Es entstehen KEINE neuen i18n-Schluessel — die drei existieren bereits in `src/messages/de.json` und `src/messages/en.json` unter `modules.accessDenied`. Umschliesse den Aufruf der Zugriffspruefung mit einer Fehlerbehandlung, die jede geworfene Ausnahme als "kein Zugriff" wertet. `checkModuleAccess` faengt heute selbst ab und wirft nicht; die Absicherung im Gate ist die zweite Verteidigungslinie, damit ein spaeterer Umbau der Pruefung das Gate nicht versehentlich oeffnet. Die Bedingung ist ausdruecklich "nur bei explizit gewaehrtem Zugriff durchlassen", niemals "nur bei explizit verweigertem Zugriff sperren". Keine Rollenabfrage im Frontend: ADMIN und SUPER_ADMIN loesen ueber `GET /modules/active` ohnehin als zugriffsberechtigt auf (D-01). Das Gate leitet nicht weiter und ruft kein Not-Found auf — die 403-Darstellung ist die Serverantwort (D-07). Die Datei traegt keine Client-Direktive am Dateikopf. Erstelle `apps/web/src/components/modules/module-access-gate.test.tsx` nach dem Muster von `module-access.test.tsx`: `next-intl/server` per `vi.mock` mit einer handgeschriebenen Schluesseltabelle, `@/lib/module-access-actions` per `vi.doMock` je Testfall, `afterEach` mit `cleanup`, `vi.resetModules` und `vi.doUnmock`. Da das Gate eine asynchrone Server-Komponente ist, wird es im Test als Funktion aufgerufen und das Ergebnis abgewartet, bevor es an `render` uebergeben wird — genau so, wie der bestehende Test die Seitenkomponente behandelt. Decke die vier Faelle aus dem Verhaltensblock ab. cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/modules/module-access-gate.test.tsx cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web type-check `module-access-gate.test.tsx` ist gruen mit mindestens vier Faellen (durchgelassen, verweigert, Ausnahme, Slug-Weitergabe). `ModuleAccessDenied` enthaelt das 403-Markup, `ModuleAccessGate` enthaelt die Zugriffslogik, und die Typpruefung des Web-Pakets ist fehlerfrei. Task 2: Vier Modul-Layouts, Strukturtest und Umstellung der generischen Route apps/web/src/app/(portal)/modules/cert-manager/layout.tsx, apps/web/src/app/(portal)/modules/dkv-fleet/layout.tsx, apps/web/src/app/(portal)/modules/domaincheck/layout.tsx, apps/web/src/app/(portal)/modules/tender-radar/layout.tsx, apps/web/src/app/(portal)/modules/module-layouts.test.tsx, apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx, apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-access.test.tsx apps/web/src/components/modules/module-access-gate.tsx (aus Task 1), apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-access.test.tsx (die beiden Testfaelle der Seitenkomponente werden umgeschrieben, die Bloecke zu checkModuleAccess und ModuleShell bleiben unangetastet) - Jedes der vier Layouts gibt ein ModuleAccessGate zurueck, dessen Slug exakt dem Verzeichnisnamen entspricht: cert-manager, dkv-fleet, domaincheck, tender-radar. Die erwarteten Slugs stehen im Test handgeschrieben. - Jedes Layout reicht die empfangenen Kinder unveraendert an das Gate weiter. - Jedes Unterverzeichnis von modules/, dessen Name nicht mit einer eckigen Klammer beginnt, besitzt eine layout.tsx — ein kuenftig hinzugefuegtes Modulverzeichnis ohne Layout laesst den Test rot werden. - Die generische Route uebergibt dem Gate den Slug aus den Routenparametern und als Kind die ModuleShell mit Kategorie und Slug. Lege je eine `layout.tsx` in `apps/web/src/app/(portal)/modules/cert-manager/`, `dkv-fleet/`, `domaincheck/` und `tender-radar/` an. Jede Datei exportiert eine gewoehnliche, synchrone Standardfunktion, die `children` entgegennimmt und ein `ModuleAccessGate` mit dem als Zeichenkette fest eingetragenen Slug des jeweiligen Verzeichnisses zurueckgibt. Die Verzeichnisnamen sind zugleich die Modul-Slugs in der Datenbank — nicht umbenennen, nicht ableiten, nicht aus dem Pfad berechnen. Die Layouts bleiben Server Components: keine Client-Direktive am Dateikopf, keine Hooks, keine Ereignisbehandler. Die Datei enthaelt ausser dem Import des Gates und der Funktion nichts weiter; alle bestehenden Seiten bleiben unveraendert. Der entscheidende Effekt: ein Layout im App Router umschliesst automatisch alle verschachtelten Unterrouten. `tender-radar/layout.tsx` schliesst damit auch `tender-radar/my-sources` und `tender-radar/settings`, `dkv-fleet/layout.tsx` auch `dkv-fleet/settings` und `dkv-fleet/vehicles`. Es werden keine weiteren Layout-Dateien in den Unterverzeichnissen angelegt. Erstelle `apps/web/src/app/(portal)/modules/module-layouts.test.tsx`. Der Test importiert die vier Layouts relativ, ruft jedes als Funktion mit einem Platzhalter-Kind auf und prueft am zurueckgegebenen Element, dass die Eigenschaft `moduleSlug` dem handgeschriebenen Erwartungswert entspricht und die Kinder durchgereicht werden. Ergaenze einen zweiten Fall, der das Modulverzeichnis mit `node:fs` ausliest (Pfad ueber `import.meta.url`, die Testdatei liegt selbst im Modulverzeichnis), alle Unterverzeichnisse einsammelt, deren Name nicht mit einer eckigen Klammer beginnt, und fuer jedes das Vorhandensein einer `layout.tsx` verlangt. Damit faellt ein kuenftig hinzugefuegtes Modulverzeichnis ohne Gate auf. Stelle `[category]/[moduleSlug]/page.tsx` auf die gemeinsamen Bausteine um: die Seite liest weiterhin die Routenparameter und gibt danach ein `ModuleAccessGate` mit dem Slug zurueck, in dem die `ModuleShell` als Kind steht. Das bisher inline stehende 403-Markup und der direkte Aufruf der Zugriffspruefung entfallen dort ersatzlos, ebenso die dann ungenutzten Importe. Der erklaerende Kommentarkopf der Datei wird auf den neuen Aufbau angepasst und behaelt den Hinweis auf D-07 und die gemeinsame Zugriffsaufloesung. Passe `module-access.test.tsx` an: die beiden Faelle der Seitenkomponente pruefen jetzt die Weitergabe statt der Darstellung — die Seite gibt ein Element zurueck, dessen `moduleSlug` dem Slug aus den Routenparametern entspricht und dessen Kind die ModuleShell mit derselben Kategorie und demselben Slug ist. Die Faelle zu `checkModuleAccess` (faellt geschlossen) und zur ModuleShell-Whitelist bleiben unveraendert erhalten; das Verhalten von 403 gegenueber Durchlassen ist seit Task 1 im Gate-Test abgedeckt und wird hier nicht doppelt geprueft. cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/app/\(portal\)/modules/module-layouts.test.tsx src/app/\(portal\)/modules/\[category\]/\[moduleSlug\]/module-access.test.tsx cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web type-check cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web build Browser-Gegenprobe nach Neubau des Web-Containers — die vollstaendige Adress- und Kontenmatrix steht unten im Abschnitt "Browser-Gegenprobe". Wird vom Orchestrator ausgefuehrt, nicht vom Ausfuehrenden. Die vier Layout-Dateien existieren, tragen jeweils den korrekten Slug und keine Client-Direktive. `module-layouts.test.tsx` und `module-access.test.tsx` sind gruen, die vollstaendige Web-Testsuite ist gruen, Typpruefung und Produktionsbau des Web-Pakets laufen fehlerfrei durch. Das 403-Markup steht nur noch in `module-access-denied.tsx`. ## Trust Boundaries | Boundary | Description | |----------|-------------| | Browser -> Next.js Server Component | Der Nutzer bestimmt die Adresse frei. Sidebar und Marktplatz sind Bequemlichkeit, keine Zugriffskontrolle — ein Lesezeichen oder eine getippte Adresse umgeht beide. | | Next.js Server Component -> NestJS API | Das Gate leitet das Sitzungscookie an `GET /modules/active` weiter; die verbindliche Entscheidung faellt in der API (ModuleAccessService), nicht im Frontend. | ## STRIDE Threat Register | Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | |-----------|----------|-----------|----------|-------------|-----------------| | T-e8k-01 | Elevation of Privilege | modul-eigene Routen unter `(portal)/modules/{cert-manager,dkv-fleet,domaincheck,tender-radar}` samt Unterseiten | high | mitigate | Je ein `layout.tsx` pro Modulverzeichnis ruft `ModuleAccessGate`; das Layout umschliesst alle verschachtelten Unterrouten automatisch (Task 2) | | T-e8k-02 | Information Disclosure | `ModuleAccessGate` bei Netzfehler oder geworfener Ausnahme der Zugriffspruefung | medium | mitigate | Fehlerbehandlung im Gate wertet jede Ausnahme als "kein Zugriff"; durchgelassen wird nur bei explizit gewaehrtem Zugriff (Task 1, Testfall 3) | | T-e8k-03 | Tampering | Falscher Slug in einem der vier Layouts (Kopierfehler zwischen fast identischen Dateien) | medium | mitigate | `module-layouts.test.tsx` prueft je Verzeichnis den handgeschriebenen Erwartungs-Slug (Task 2) | | T-e8k-04 | Elevation of Privilege | Kuenftig hinzugefuegtes Modulverzeichnis ohne Layout laeuft erneut am Gate vorbei | medium | mitigate | `module-layouts.test.tsx` liest das Modulverzeichnis aus und verlangt fuer jedes nicht-dynamische Unterverzeichnis eine `layout.tsx` (Task 2) | | T-e8k-05 | Denial of Service | Ueberschiessende Sperre trifft ADMIN/SUPER_ADMIN oder freigegebene USER | medium | mitigate | Keine Rollenlogik im Frontend; das Gate fragt ausschliesslich `checkModuleAccess` (D-01). Browser-Gegenprobe deckt `admin` und `nutzer1` ausdruecklich ab | Keine Paketinstallationen in diesem Plan — das Package-Legitimacy-Gate entfaellt. ## Automatisiert 1. `pnpm --filter @tessera/web exec vitest run` — vollstaendige Web-Suite gruen, inklusive der neuen Dateien `module-access-gate.test.tsx` und `module-layouts.test.tsx`. 2. `pnpm --filter @tessera/web type-check` — fehlerfrei. 3. `pnpm --filter @tessera/web build` — der Produktionsbau muss durchlaufen. Das ist zugleich die Probe darauf, dass die Layouts echte Server Components geblieben sind: eine Client-Direktive wuerde am Import der Server-Aktion scheitern. ## Browser-Gegenprobe (human-check, vom Orchestrator auszufuehren) Die Weboberflaeche laeuft aus einem gebauten Docker-Image. Vor jeder Pruefung neu bauen und ersetzen, ein blosses Hochfahren baut nicht neu: docker compose build web && docker compose up -d --force-recreate web Danach `http://localhost:3000` im echten Browser. Zwischen den Konten jeweils sauber abmelden. **Jede Adresse direkt eintippen**, nicht ueber die Sidebar navigieren — geprueft wird genau der Weg an der Sidebar vorbei. Bei jedem Adresswechsel innerhalb desselben Moduls einmal neu laden. **A — `nutzer2` / `Test1234!` (USER, KEINE Freigabe fuer tender-radar, HAT cert-manager ueber die Gruppe "Alle Benutzer")** | Adresse | Erwartung | |---|---| | `/modules/procurement/tender-radar` | 403-Seite "Kein Zugriff auf dieses Modul" (unveraendert gegenueber heute) | | `/modules/tender-radar` | 403-Seite statt Trefferliste — der eigentliche Befund | | `/modules/tender-radar/my-sources` | 403-Seite statt Postfach- und Feed-Formular | | `/modules/tender-radar/settings` | 403-Seite | | `/modules/dkv-fleet` | 403-Seite, insbesondere ohne den Knopf "Jetzt pruefen" | | `/modules/dkv-fleet/settings` | 403-Seite | | `/modules/dkv-fleet/vehicles` | 403-Seite | | `/modules/cert-manager` | **volle Seite** — hier besteht eine Freigabe, die Sperre darf nicht ueberschiessen | | `/modules/domaincheck` | Ergebnis muss zu dem passen, was die Freigaben-Matrix in der Administration fuer `nutzer2` und domaincheck ausweist: mit Freigabe volle Seite, ohne Freigabe die 403-Seite | Zusaetzlich auf allen 403-Seiten pruefen: die Adresszeile bleibt unveraendert stehen (keine Weiterleitung, D-07), die Seite ist deutsch, und es erscheinen keine rohen englischen Techniktexte. **B — `nutzer1` / `Test1234!` (USER, HAT tender-radar ueber Gruppe und Direkt-Grant)** | Adresse | Erwartung | |---|---| | `/modules/tender-radar` | volle Seite mit Trefferliste | | `/modules/tender-radar/my-sources` | volle Seite mit Postfach- und Feed-Formular | | `/modules/tender-radar/settings` | Seite laedt (keine 403-Seite); die bereits bestehende Rollenunterscheidung innerhalb der Seite bleibt davon unberuehrt | **C — `admin` / `admin123` (SUPER_ADMIN)** | Adresse | Erwartung | |---|---| | `/modules/tender-radar` | volle Seite | | `/modules/tender-radar/my-sources` | volle Seite | | `/modules/dkv-fleet` | volle Seite | | `/modules/cert-manager` | volle Seite | | `/modules/domaincheck` | volle Seite | Faellt hier auch nur eine Seite auf 403, ist die Aenderung zu scharf und muss zurueck — SUPER_ADMIN umgeht Grants (D-01). ## Ledger `WINDOWS.md` #10 bleibt offen, bis Abschnitt A, B und C im Browser bestanden sind. Erst dann auf geschlossen setzen, mit Datum und Belegbild. - Ein USER ohne Freigabe erhaelt auf allen acht in Abschnitt A gelisteten Adressen die 403-Seite als Serverantwort, nicht die Modulseite. - Ein USER mit Freigabe und jeder SUPER_ADMIN erreichen dieselben Adressen unveraendert vollstaendig. - Adresszeile bleibt bei der 403-Seite stehen: keine Weiterleitung, kein Not-Found (D-07). - Die Zugriffspruefung faellt weiterhin geschlossen aus — fehlendes Cookie, nicht-ok-Antwort, Netzfehler und geworfene Ausnahme fuehren alle zur 403-Seite. - Das 403-Markup existiert genau einmal im Code; die generische Route und die vier modul-eigenen Routen rendern dieselbe Komponente. - Vollstaendige Web-Testsuite, Typpruefung und Produktionsbau sind gruen. - Ein neu angelegtes Modulverzeichnis ohne `layout.tsx` laesst `module-layouts.test.tsx` fehlschlagen. Erstelle `.planning/quick/260907-e8k-zugriffs-guard-greift-nicht-auf-modul-ei/260907-e8k-SUMMARY.md`, wenn fertig.