From 32c4ec3fc806ad176e1f6893281de30e5309ea27 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 4 Aug 2026 13:30:53 +0200 Subject: [PATCH] docs(15): research phase domain --- .../15-RESEARCH.md | 630 ++++++++++++++++++ 1 file changed, 630 insertions(+) create mode 100644 .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-RESEARCH.md diff --git a/.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-RESEARCH.md b/.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-RESEARCH.md new file mode 100644 index 0000000..21b280a --- /dev/null +++ b/.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-RESEARCH.md @@ -0,0 +1,630 @@ +# Phase 15: Modul-Berechtigungen: Gruppen & User-Grants - Research + +**Researched:** 2026-08-04 +**Domain:** NestJS-Autorisierung (Access-Resolution), Prisma/PostgreSQL-Schemamodellierung (Entweder-oder-Beziehung, Migrations-Backfill), LDAP/Active-Directory-Gruppensynchronisation, Next.js App-Router Server-seitiges Gating +**Confidence:** MEDIUM (Kern-Architekturmuster HIGH aus eigenem Code verifiziert; AD-spezifisches Detailverhalten MEDIUM aus Microsoft-Learn-Doku, nicht live gegen ein AD getestet) + +## Summary + +Phase 15 baut die Zugriffskontrolle um ein zweites Standbein: über der bestehenden Mandanten-Aktivierung (`TenantModuleActivation`) entscheidet künftig eine Kombination aus Rolle, direkten Benutzer-Grants und Gruppen-Grants, ob ein Benutzer ein Modul sieht und nutzen darf. Der bestehende Code liefert für fast jeden Baustein bereits ein Vorbild: `ModuleGuard`/`ModuleRegistryService.isModuleActive` ist der einzige heutige Prüfpunkt und muss um die Benutzer-Dimension erweitert werden; `LdapService.collectSearchEntries` zeigt bereits das korrekte `memberOf`-Filtermuster für Gruppenmitgliedschaft (Reverse-Query, keine Attribut-Range-Probleme); `UserService.create()` ist der einzige Ort, an dem sowohl manuell angelegte als auch LDAP-importierte Benutzer entstehen, und damit der richtige Ort für die automatische Standardgruppen-Mitgliedschaft (D-11/D-12); und die bestehenden hand-editierten Migrationen (`20260618112133_rls_policies`, `20260721150000_tender_cpv_divisions_backfill`) belegen, dass dieses Projekt bereits raw-SQL-Ergänzungen in generierten Migrationsdateien für Constraints und Backfills nutzt — genau das Werkzeug, das die Entweder-oder-Beziehung von `ModuleGrant` (Gruppe XOR Benutzer) und der Migrations-Backfill für D-06 brauchen. + +Zwei Befunde verdienen besondere Aufmerksamkeit, weil sie von der CLAUDE.md-Zielarchitektur abweichen bzw. eine im Kontext offene Diskretionsfrage mit hoher Sicherheit beantworten: Erstens laufen tatsächlich **Prisma 6.19.3** und **Next.js 15.5.19** (nicht die in CLAUDE.md genannten 7.8.x/16.2.x) — Prisma 6 hat kein natives partial-unique-index-Feature ohne Preview-Flag, was die Hand-SQL-Empfehlung zusätzlich stützt. Zweitens läuft `prisma migrate deploy` automatisch beim Container-Start (`apps/api/Dockerfile:38`, `CMD ["sh", "-c", "... prisma migrate deploy && node apps/api/dist/main.js"]`) — die Migration für D-06 muss also unbeaufsichtigt und transaktionssicher laufen, kein manuelles Backfill-Skript, das der Betreiber separat ausführen müsste. + +**Primary recommendation:** Eine einzige `ModuleAccessService.getAccessibleModuleIds(tenantId, userId, role)` in `apps/api/src/module-registry/` speist sowohl `ModuleGuard` als auch `GET /modules/active`; das Entweder-oder von `ModuleGrant` wird über zwei nullable FK-Spalten + eine hand-editierte `CHECK (num_nonnulls(...) = 1)`-Constraint plus zwei partielle Unique-Indizes in der generierten `migration.sql` erzwungen (exakt das im Repo etablierte Verfahren); der Migrations-Backfill für D-06 ist reines relationales `INSERT ... SELECT` und gehört direkt in die `migration.sql`, nicht in ein separates Skript, weil er beim automatischen Container-Boot laufen muss. + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| Zugriffsauflösung (Rolle/Grant/Gruppe → Modul-IDs) | API / Backend | — | Einzige Wahrheitsquelle; muss von Guard UND Controller identisch aufgerufen werden (D-01) | +| Sidebar-Modul-Filterung | Frontend Server (Datenquelle) / Browser (Rendering) | API / Backend | `GET /modules/active` liefert bereits userbezogen gefilterte Liste; Sidebar ist reiner Konsument, keine eigene Zugriffslogik (D-01) | +| Modulseiten-Sperre (403) | Frontend Server (SSR) | API / Backend | D-07 verlangt serverseitige Prüfung VOR dem Rendern des Moduls — Server Component, nicht der bisherige Client-Fetch | +| Modul-API-Zugriffskontrolle | API / Backend | — | `ModuleGuard` bleibt der einzige Durchsetzungspunkt für jeden Modul-Endpoint | +| Gruppenverwaltung (CRUD, Mitglieder) | API / Backend | Frontend Server (Formulare) | Neue `/admin/groups`-Seite folgt dem etablierten Admin-Formularmuster | +| AD-Gruppenbindung & Mitgliedschafts-Sync | API / Backend | — | Läuft im bestehenden `LdapService.syncUsersForTenant`-Durchlauf mit, kein neuer Dienst | +| Freigabe-Matrix (Module × Gruppen) | Frontend Server (Datenladen) / Browser (Interaktion) | API / Backend | Reine CRUD-Oberfläche über `ModuleGrant`; keine eigene Zugriffslogik im Frontend | +| Widget-Sichtbarkeit nach Modul-Zugriff | API / Backend | Browser (Rendering) | `DashboardService.getWidgets()` filtert serverseitig über dieselbe `ModuleAccessService`; Frontend rendert nur das gefilterte Ergebnis | +| Persistenz (Group/GroupMembership/ModuleGrant) | Database / Storage | — | Neue Tabellen mit FKs auf `User`, `Tenant`, `Module`; Konsistenz (Entweder-oder, ein Default pro Mandant) wird per DB-Constraint erzwungen, nicht nur app-seitig | + +## Standard Stack + +Diese Phase führt **keine neuen npm-Pakete** ein. Alle benötigten Bausteine (Prisma, ldapts, NestJS Guards/DI, Next.js App Router, next-intl) sind bereits installiert und im Projekt etabliert. + +### Tatsächlich installierte Versionen (abweichend von CLAUDE.md-Zielarchitektur) + +| Paket | CLAUDE.md nennt | Tatsächlich installiert | Quelle | +|-------|------------------|--------------------------|--------| +| `@prisma/client` / `prisma` | 7.8.x | **6.19.3** (Constraint `^6.0.0`) | `[VERIFIED: apps/api/node_modules/@prisma/client/package.json]`, `[VERIFIED: apps/api/package.json:1]` | +| `next` | 16.2.x | **15.5.19** (Constraint `^15.3.0`) | `[VERIFIED: apps/web/node_modules/next/package.json]` | +| `ldapts` | — | **8.1.8** (Constraint `^8.1.8`) | `[VERIFIED: apps/api/package.json:39]` `"ldapts": "^8.1.8",` | +| `vitest` | 3.x | **3.2.6** | `[VERIFIED: apps/api/node_modules/vitest/package.json]` | + +**Konsequenz für die Planung:** Jede Prisma-Schema-Empfehlung muss gegen Prisma 6 geprüft werden, nicht gegen 7 — insbesondere Preview-Feature-Verfügbarkeit (siehe Don't-Hand-Roll-Abschnitt zu partial indexes). Der `generator client`-Block in `apps/api/prisma/schema.prisma:6-8` hat aktuell **kein** `previewFeatures`-Array: +```prisma +generator client { + provider = "prisma-client-js" +} +``` +`[VERIFIED: apps/api/prisma/schema.prisma:6-8]` + +### Core (bereits vorhanden, in dieser Phase erweitert) +| Library | Version | Zweck in dieser Phase | Warum Standard | +|---------|---------|------------------------|-----------------| +| `@prisma/client` | 6.19.3 | Neue Modelle `Group`, `GroupMembership`, `ModuleGrant`; Access-Resolution-Queries | Bereits projektweiter ORM-Standard, kein Wechsel | +| `ldapts` | 8.1.8 | `memberOf`-Reverse-Query für AD-Gruppenbindung (D-19) | Bereits etablierter LDAP-Client, `Client.search()` deckt den Anwendungsfall vollständig ab | +| `@nestjs/passport` / Guards | 11.x | `ModuleGuard`-Erweiterung, `RolesGuard`-Wiederverwendung für Gruppen-/Grant-Endpoints | Etabliertes Guard-Muster im Projekt | +| `next-intl` | ^4.13.0 | i18n für `/admin/groups`, Matrix-Seite, 403-Seite | Bereits projektweiter i18n-Standard | + +### Alternativen erwogen +| Statt | Könnte man nutzen | Tradeoff | +|-------|--------------------|----------| +| Hand-editierte raw-SQL-Migration für CHECK-Constraint + partielle Indizes | Prisma `partialIndexes`-Preview-Feature aktivieren | Preview-Feature auf Prisma 6.19 ist NICHT stabil und würde einen globalen `previewFeatures`-Flag-Wechsel für das ganze Schema erzwingen — unverhältnismäßig für zwei Tabellen; die Hand-SQL-Route ist im Repo bereits etabliert (RLS-Migration, CPV-Backfill-Migration) und funktioniert mit der installierten Version ohne Flag | +| Separates `INSERT`-Backfill direkt in `migration.sql` | Eigenständiges TS-Skript wie `backfill-tender-source.ts` | Ein TS-Skript wird NICHT automatisch beim Container-Start ausgeführt (`Dockerfile:38` ruft nur `prisma migrate deploy`) — für D-06 ("kein bestehender Benutzer verliert beim Deploy Zugriff") ist automatisches Laufen ohne manuellen Zusatzschritt Pflicht. Ein TS-Skript ist nur nötig, wenn Business-Logik (z. B. Normalisierung) gebraucht wird, die in SQL unpraktikabel ist — hier nicht der Fall (reine relationale Kopie) | +| Request-Objekt-Memoisierung für Guard→Controller | NestJS `REQUEST`-Scope-Provider | REQUEST-Scope macht den gesamten DI-Teilbaum (Controller + alle injizierten Provider) pro Request neu instanziierbar — spürbarer Overhead für einen Wert, der sich mit einer einfachen `request`-Objekt-Eigenschaft genauso cachen lässt | + +**Installation:** keine — keine neuen Abhängigkeiten. + +## Package Legitimacy Audit + +**Diese Phase installiert keine externen Pakete.** Alle Bausteine (Prisma, ldapts, NestJS, Next.js) sind bereits im Projekt vorhanden und wurden in früheren Phasen geprüft. Der Package-Legitimacy-Gate entfällt damit für diese Phase. + +## Architecture Patterns + +### System Architecture Diagram + +``` + ┌─────────────────────────────────────────────┐ + │ Admin-UI (/admin/groups, Matrix-Seite, │ + │ User-Detail-Grants) │ + └───────────────────┬───────────────────────--┘ + │ CRUD (RolesGuard: ADMIN/SUPER_ADMIN) + ▼ + ┌────────────────────────────────────────────────────────────┐ + │ GroupController / ModuleGrantController (neu) │ + │ - Group CRUD, Mitglieder zuweisen/entfernen │ + │ - ModuleGrant CRUD (Gruppe- oder User-Ziel) │ + └───────────────────────┬───────────────────────────────────-┘ + │ schreibt + ▼ + ┌────────────────────────────────────────────────────────────┐ + │ PostgreSQL: Group / GroupMembership / ModuleGrant │ + │ (CHECK num_nonnulls, partielle Unique-Indizes, │ + │ ein isDefault=true je tenantId) │ + └───────────────────────┬───────────────────────────────────-┘ + │ liest + ▼ + ┌────────────────────────────────────────────────────────────┐ + │ ModuleAccessService.getAccessibleModuleIds(tenant,user,role)│ + │ (SINGLE SOURCE OF TRUTH — D-01) │ + └───────┬───────────────────────────────────┬─────────────--─┘ + │ │ + ▼ ▼ + ┌─────────────────────────┐ ┌──────────────────────────────┐ + │ ModuleGuard (API) │ │ GET /modules/active (API) │ + │ → 403 bei fehlendem Grant│ │ → gefilterte Modulliste │ + └─────────────────────────┘ └───────────────┬──────────────┘ + │ fetch (Browser) + ▼ + ┌──────────────────────────────┐ + │ Sidebar.tsx (Client) │ + │ zeigt nur zugängliche Module │ + └──────────────────────────────┘ + + Server-seitiges Seiten-Gating (D-07, separater Pfad, NICHT über Sidebar-Fetch): + ┌───────────────────────────────────────────────────────────────────┐ + │ [category]/[moduleSlug]/page.tsx (Server Component, NEU) │ + │ 1. cookies() → session-Cookie lesen (Muster: auth-actions.ts) │ + │ 2. fetch API mit Cookie-Header → ModuleAccessService-Ergebnis │ + │ 3. kein Zugriff → 403-Markup rendern (kein notFound(), kein Redir.)│ + │ 4. Zugriff → (Client Component) rendern │ + └───────────────────────────────────────────────────────────────────┘ + + LDAP-Sync (D-21, im bestehenden Durchlauf, kein neuer Job): + ┌───────────────────────────────────────────────────────────────────┐ + │ LdapService.syncUsersForTenant() │ + │ … bestehende User-Schleife (unverändert) … │ + │ + NEU: für jede AD-gebundene Group (ldapDn gesetzt): │ + │ Reverse-Query (memberOf=) über collectSearchEntries- │ + │ Muster → Set → GroupMembership(source=LDAP) upsert, │ + │ LDAP-Mitgliedschaften außerhalb des Sets werden gelöscht, │ + │ MANUAL-Mitgliedschaften bleiben unberührt (D-19) │ + └───────────────────────────────────────────────────────────────────┘ +``` + +### Recommended Project Structure +``` +apps/api/src/ +├── module-registry/ +│ ├── module-access.service.ts # NEU — Single Source of Truth (D-01) +│ ├── module.guard.ts # ERWEITERT — nutzt ModuleAccessService statt isModuleActive +│ ├── module-registry.service.ts # unverändert (isModuleActive bleibt für Kompatibilität, ggf. intern von ModuleAccessService genutzt) +│ └── module-registry.controller.ts # ERWEITERT — findActive() nutzt ModuleAccessService +├── groups/ # NEU +│ ├── groups.module.ts +│ ├── groups.service.ts # CRUD Group, GroupMembership (manuell) +│ ├── groups.controller.ts # /admin/groups Endpoints +│ ├── module-grants.service.ts # CRUD ModuleGrant +│ └── module-grants.controller.ts # Matrix + User-Detail Endpoints +├── ldap/ +│ └── ldap.service.ts # ERWEITERT — syncUsersForTenant liest AD-gebundene Groups mit +└── dashboard/ + └── dashboard.service.ts # ERWEITERT — getWidgets() filtert über ModuleAccessService + Widget→Modul-Map + +apps/web/src/ +├── lib/ +│ └── module-access-actions.ts # NEU — Server-Action-Pendant zu auth-actions.ts::fetchCurrentUser() +├── app/(portal)/ +│ ├── admin/groups/page.tsx # NEU — sechster Admin-Nav-Punkt (D-14) +│ ├── admin/modules/grants/page.tsx # NEU (oder Unterseite) — Matrix Module × Gruppen (D-15) +│ └── modules/[category]/[moduleSlug]/ +│ ├── page.tsx # UMGEBAUT zu Server Component (D-07) +│ └── module-shell.tsx # NEU — verschiebt den bisherigen Client-Code hierhin +└── components/admin/admin-sidebar.tsx # ERWEITERT — sechster Eintrag "Gruppen" +``` + +### Pattern 1: Single-Source-of-Truth Access Resolution (D-01) + +**Was:** Eine Methode berechnet die Menge zugänglicher `moduleId`s für einen Benutzer und wird sowohl vom Guard als auch vom Listing-Endpoint aufgerufen — niemals wird Zugriffslogik dupliziert. +**Wann:** Für jede Stelle, an der geprüft wird, ob ein Benutzer ein Modul sehen/nutzen darf. +**Beispiel** (eigener Entwurf, basierend auf dem bestehenden `ModuleRegistryService`-Muster, `[VERIFIED: apps/api/src/module-registry/module-registry.service.ts]` als Vorlage für Prisma-Query-Stil): +```typescript +// apps/api/src/module-registry/module-access.service.ts +@Injectable() +export class ModuleAccessService { + constructor(private readonly prisma: PrismaService) {} + + async getAccessibleModuleIds( + tenantId: string, + userId: string, + role: Role, + ): Promise> { + // D-03: ADMIN/SUPER_ADMIN umgehen Grants vollständig + if (role === 'ADMIN' || role === 'SUPER_ADMIN') { + const activations = await this.prisma.tenantModuleActivation.findMany({ + where: { tenantId, isActive: true }, + select: { moduleId: true }, + }); + return new Set(activations.map((a) => a.moduleId)); + } + + const [direct, viaGroup] = await Promise.all([ + this.prisma.moduleGrant.findMany({ + where: { tenantId, userId }, + select: { moduleId: true }, + }), + this.prisma.moduleGrant.findMany({ + where: { tenantId, group: { memberships: { some: { userId } } } }, + select: { moduleId: true }, + }), + ]); + const grantedIds = new Set([...direct, ...viaGroup].map((g) => g.moduleId)); + + // D-02: Grant allein genügt nicht — Modul muss weiterhin mandantenweit aktiv sein + const activations = await this.prisma.tenantModuleActivation.findMany({ + where: { tenantId, isActive: true, moduleId: { in: [...grantedIds] } }, + select: { moduleId: true }, + }); + return new Set(activations.map((a) => a.moduleId)); + } +} +``` + +### Pattern 2: Per-Request-Memoisierung statt REQUEST-Scope + +**Was:** `ModuleGuard.canActivate` und der nachfolgende Controller-Handler laufen in derselben HTTP-Anfrage; das Ergebnis von `getAccessibleModuleIds` wird einmalig auf das Express-`request`-Objekt geschrieben statt zweimal berechnet. +**Wann:** Immer, wenn Guard und Controller-Handler denselben teuren Wert brauchen — vermeidet den DI-weiten Overhead von `Scope.REQUEST` `[CITED: docs.nestjs.com/fundamentals/injection-scopes]`. +**Beispiel:** +```typescript +// im Guard: +const accessibleIds = await this.moduleAccessService.getAccessibleModuleIds(tenantId, userId, role); +request.__moduleAccessCache = accessibleIds; // Symbol/private key in Praxis statt String + +// im Controller, falls derselbe Request den Guard bereits durchlaufen hat: +const cached = request.__moduleAccessCache as Set | undefined; +const accessibleIds = cached ?? await this.moduleAccessService.getAccessibleModuleIds(...); +``` +Da `GET /modules/active` selbst KEIN `@UseModule(slug)` trägt (es ist der Listing-Endpoint, kein einzelner Modul-Endpoint), läuft `ModuleGuard` dort nicht — die Memoisierung hilft konkret dort, wo ein Modul-Endpoint zusätzlich einen `GET /modules/active`-artigen Aufruf im selben Request bräuchte. Für den Normalfall (ein Guard-Aufruf pro Request) ist die Memoisierung ohnehin unkritisch; sie zahlt sich vor allem aus, wenn später mehrere modul-geschützte Sub-Ressourcen im selben Request geprüft werden. + +### Pattern 3: LDAP-Gruppenmitgliedschaft per Reverse-Query (kein Ranged-Attribute-Problem) + +**Was:** Statt `memberOf` (oder `member`) als Attribut vom Benutzer/der Gruppe zu LESEN (was bei > 1500 Werten in Chunks `attribut;range=0-1499` aufgeteilt wird und mehrfache Nachfolgeabfragen braucht `[CITED: learn.microsoft.com/en-us/previous-versions/windows/desktop/ldap/searching-using-range-retrieval]`), wird `memberOf` als FILTER-Kriterium in einer Suche verwendet: `(memberOf=)`. AD wertet das als indexierten Filter aus, nicht als Attribut-Rückgabewert — das Ranged-Attribute-Problem tritt nur bei der RÜCKGABE vieler Werte auf, nicht beim Filtern danach. +**Wann:** Für D-19 (AD-gebundene Gruppe → Mitgliederliste ermitteln). +**Bereits im Code etabliert:** `LdapService.collectSearchEntries` baut exakt dieses Muster bereits für `groupFilterDns` auf (`[VERIFIED: apps/api/src/ldap/ldap.service.ts:756-763]`): +```typescript +if (groupDns.length > 0) { + const memberOfClauses = groupDns + .map((dn) => `(memberOf=${LdapService.escapeLdapFilterValue(dn)})`) + .join(''); + baseFilter = `(&${sanitizedFilter}(|${memberOfClauses}))`; +} +``` +**Empfehlung für D-19/D-21:** Pro AD-gebundener `Group` (Feld `ldapDn` gesetzt) im selben `syncUsersForTenant`-Durchlauf eine Suche mit Filter `(&(objectClass=person)(memberOf=))` über die konfigurierten Base-DNs ausführen (dieselbe `collectSearchEntries`-Mechanik, nur mit einer einzelnen Gruppen-DN statt der Liste aus `groupFilterDns`), das Ergebnis in `username`s auflösen (über `mapEntry`) und daraus `GroupMembership(source: LDAP)`-Zeilen upserten; jede vorhandene `LDAP`-Mitgliedschaft dieser Gruppe, die NICHT im aktuellen Suchergebnis ist, wird gelöscht (nicht deaktiviert — `GroupMembership` hat keinen `isActive`-Zustand). `MANUAL`-Mitgliedschaften werden dabei nie berührt, weil die Lösch-Query explizit auf `source: 'LDAP'` filtert. + +### Pattern 4: Nested-Group-Entscheidung — Empfehlung: NICHT unterstützen (Standard) + +**Was:** `LDAP_MATCHING_RULE_IN_CHAIN` (OID `1.2.840.113556.1.4.1941`) würde `(memberOf:1.2.840.113556.1.4.1941:=)` erlauben und damit auch indirekte Mitglieder (Gruppe-in-Gruppe) finden `[CITED: learn.microsoft.com — via Atlassian/Cloudera-Doku zu nested LDAP group resolution]`. +**Empfehlung:** Für diese Phase NICHT einbauen. Begründung: +1. D-19 im Kontext spricht nur von "verlässt der Benutzer die AD-Gruppe" — keine explizite Anforderung für verschachtelte Gruppen. +2. Die Matching-Rule ist laut Recherche zwar schneller als eine rekursive Client-Auflösung, aber bei tiefen/breiten Ketten weiterhin spürbar teurer als ein einfacher indexierter Filter `[CITED: 389-ds/Cloudera-Dokumentation zu matching-rule-in-chain]`. +3. Sie ist eine AD-spezifische Erweiterung (Windows Server 2003 SP2+) — falls ein Tenant einen anderen LDAP-Server (OpenLDAP, 389-ds ohne Passthru-Plugin) betreibt, würde der Filter schlicht keine Treffer liefern, ohne Fehler zu werfen — ein stilles Verhaltensloch. +4. Die bestehende `groupFilterDns`-Funktionalität aus Phase 2 nutzt ebenfalls direkte `memberOf`-Filterung ohne Matching-Rule — Konsistenz mit etabliertem Verhalten. + +**Als Open Question festgehalten** (siehe unten) — falls der Betreiber tatsächlich verschachtelte AD-Gruppen für die Gruppenbindung nutzen will, ist dies ein expliziter Nachtrag, kein automatischer Teil dieser Phase. + +### Pattern 5: Server-seitiges 403-Gating (D-07) via Server-Component-Split + +**Was:** Next.js App Router erlaubt eine `async`-Server-Component-Page, die serverseitig Daten lädt (inkl. `cookies()`) und einen Client-Component-Kind mit den Ergebnissen als Props rendert `[CITED: nextjs.org/docs/app/getting-started/server-and-client-components — "Passing data from Server to Client Components"]`. Das Projekt hat dieses Cookie-Weiterleitungsmuster für Server Actions bereits etabliert (`[VERIFIED: apps/web/src/lib/auth-actions.ts:243-268]`, `fetchCurrentUser()`): +```typescript +export async function fetchCurrentUser(): Promise { + const cookieStore = await cookies(); + const session = cookieStore.get('session')?.value; + if (!session) return null; + const response = await fetch(`${API_URL}/auth/me`, { + headers: { Cookie: `session=${session}` }, + credentials: 'include', + cache: 'no-store', + }); + if (!response.ok) return null; + return await response.json(); +} +``` +**Anwendung auf `[moduleSlug]/page.tsx`:** Die heutige Datei ist vollständig `'use client'` (`[VERIFIED: apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx:1]`) und lädt nichts serverseitig — das genügt D-07 NICHT (der Client-Fetch von `GET /modules/active` in `sidebar.tsx`/`category/page.tsx` ist reine UI-Anzeige, keine Zugriffskontrolle, exakt wie D-07 explizit klarstellt: "das Ausblenden in der Sidebar ist ausdrücklich keine Zugriffskontrolle"). Empfohlener Umbau: +1. `page.tsx` wird eine `async`-Server-Component (kein `'use client'` mehr), liest `params`, ruft eine neue Server-Action `checkModuleAccess(moduleSlug)` (Datei `apps/web/src/lib/module-access-actions.ts`, exakt nach dem `fetchCurrentUser()`-Muster) auf, die serverseitig `GET /modules/active` (oder einen neuen `GET /modules/:slug/access`-Endpoint — siehe Open Question) mit weitergeleitetem Session-Cookie aufruft. +2. Kein Zugriff → die Seite rendert direkt das 403-Markup (D-07: "Kein Zugriff auf dieses Modul — wende dich an deinen Administrator"), KEIN `notFound()` (das würde die generische 404-Seite zeigen, D-07 verbietet das explizit), KEIN Redirect. +3. Zugriff vorhanden → die Seite rendert ``, eine neue Client Component, die den kompletten bisherigen Inhalt von `page.tsx` (MODULE_REGISTRY-Whitelist, `loadModuleComponent`, Not-Found-State für unregistrierte Slugs) unverändert übernimmt. + +### Pattern 6: Automatische Standardgruppen-Mitgliedschaft an EINEM Ort (D-11/D-12) + +**Was:** `UserService.create()` ist die einzige Stelle im Code, an der ein `User`-Datensatz entsteht — sowohl für manuell angelegte Benutzer als auch für neu importierte LDAP-Benutzer (`[VERIFIED: apps/api/src/ldap/ldap.service.ts:333-341]` ruft `this.userService.create(...)` im `upsertMappedUser`-Zweig "neu anlegen"; `[VERIFIED: apps/api/src/ldap/ldap.service.ts:516-523]` ruft dieselbe Methode in `importUsersByDn`). +**Empfehlung:** Die Logik "füge den neuen Benutzer der markierten Standardgruppe des Mandanten hinzu, falls eine existiert" gehört in `UserService.create()` selbst (oder einen unmittelbar danach aufgerufenen Wrapper, den beide Aufrufer — Admin-Controller UND `LdapService` — durchlaufen), NICHT separat in den LDAP-Sync-Code und den Admin-User-Controller dupliziert. Das erfüllt D-12 ("eine Regel für beide Herkünfte") strukturell statt durch Konvention. + +### Pattern 7: Widget→Modul-Zuordnung — statische Registrierung im Code (D-22, Diskretionsfrage) + +**Befund:** Aktuell sind ALLE acht Widget-Typen (`clock`, `search`, `calendar`, `note`, `calculator`, `favorites`, `link`, `stopwatch`, `[VERIFIED: apps/web/src/components/dashboard/widget-registry.tsx:8-16]`) Plattform-Widgets ohne Modulbezug. Das einzige geplante modulgebundene Widget ("Ausschreibungen mit naher Frist") ist laut REQUIREMENTS.md **deferred**, nicht Teil dieser Phase (`[VERIFIED: .planning/REQUIREMENTS.md:78]`, Future Requirements). D-22 baut also eine Zuordnungs-MECHANIK, die zum Zeitpunkt dieser Phase de facto für keinen einzigen Widget-Typ einen echten Modul-Bezug hat. +**Empfehlung:** Statische Registrierung im Code, KEIN Datenbankfeld auf `WidgetInstance` in dieser Phase: +```typescript +// apps/api/src/dashboard/widget-module-map.ts (neu) +export const WIDGET_MODULE_MAP: Partial> = { + // Platzhalter — aktuell KEIN Widget-Typ ist an ein Modul gebunden. + // Ein künftiges modulgebundenes Widget (z. B. 'tender-deadline') + // trägt hier seinen Modul-Slug ein. +}; +``` +`DashboardService.getWidgets(userId, tenantId, role)` filtert dann: für jede `WidgetInstance` mit `widgetType` in `WIDGET_MODULE_MAP`, prüfe `ModuleAccessService`; alle anderen (aktuell: alle 8 bestehenden Typen) werden ungefiltert durchgereicht. Begründung gegen ein DB-Feld: eine Schema-Migration auf einer bereits befüllten `WidgetInstance`-Tabelle für ein Feld, das aktuell für 100 % der Zeilen `NULL`/leer wäre, ist unverhältnismäßiger Aufwand gegenüber einer TypeScript-Konstante, die exakt dieselbe Aussagekraft hat und ohne Migration erweiterbar ist, sobald ein echtes modulgebundenes Widget gebaut wird. + +### Anti-Patterns to Avoid +- **Zugriffsprüfung im Frontend als Sicherheitsgrenze behandeln:** Die Sidebar/Marketplace-Filterung ist reine UX (D-08, D-09 explizit) — jede echte Durchsetzung MUSS über `ModuleGuard` auf der API laufen. Der Client-`fetch('/modules/active')`-Aufruf in `sidebar.tsx` bleibt unverändert bestehen, ist aber niemals der Ort für eine neue Zugriffsentscheidung. +- **`memberOf`/`member` als Attribut lesen, um Gruppenmitglieder aufzulisten:** Löst sofort das Range-Retrieval-Problem bei > 1500 Mitgliedern aus. Immer als Filter-Kriterium in einer Suche verwenden (Pattern 3). +- **Separates Backfill-Skript für D-06, das der Betreiber manuell ausführen muss:** Widerspricht D-06 ("kein bestehender Benutzer verliert beim DEPLOY Zugriff") UND der Deploy-Realität (`Dockerfile:38` führt nur `prisma migrate deploy` automatisch aus, kein zusätzliches Node-Skript). +- **CHECK-Constraint für die Entweder-oder-Beziehung ohne App-seitige Vorab-Validierung:** Prisma gibt Constraint-Verletzungen als generische `PrismaClientKnownRequestError` (Code `P2010` bei raw Constraint) zurück, keine benutzerfreundliche Fehlermeldung — die DTO/Service-Schicht muss "genau eines von groupId/userId" schon vor dem Insert prüfen, die DB-Constraint ist das letzte Sicherheitsnetz, nicht der primäre UX-Pfad. + +## Don't Hand-Roll + +| Problem | Nicht selbst bauen | Stattdessen nutzen | Warum | +|---------|---------------------|----------------------|-------| +| Entweder-oder-Beziehung (Gruppe XOR Benutzer) | Eigene Validierungs-Middleware, die bei jedem Prisma-Call manuell prüft | Postgres `CHECK (num_nonnulls(...) = 1)` in hand-editierter `migration.sql`, ergänzt um DTO-seitige Validierung | DB-Constraint ist race-condition-sicher (zwei gleichzeitige Requests können nicht beide "durchrutschen"); App-Validierung allein nicht | +| Ein-Default-Gruppe-pro-Mandant (D-13) | `beforeUpdate`-Hook, der bei jedem `isDefault: true`-Set alle anderen manuell auf `false` setzt (Race Condition bei Parallel-Requests) | Partieller Unique-Index `WHERE "isDefault" = true` in `migration.sql` | DB erzwingt die Invariante atomar; App-Code muss bei Verletzung nur den (seltenen) Constraint-Fehler abfangen, nicht selbst für Konsistenz sorgen | +| LDAP-Range-Retrieval für große AD-Gruppen | Eigene Range-Paginierungs-Logik (`memberOf;range=0-1499`, `range=1500-2999`, …) für das Lesen von Mitgliederlisten | Reverse-Query-Filter `(memberOf=)` (Pattern 3) | Filterbasierte Suche umgeht das Range-Problem komplett — kein Paginierungscode nötig | +| Zugriffsprüfung dupliziert in Guard + Controller | Zwei getrennte Implementierungen der "hat Zugriff"-Logik | `ModuleAccessService` als einzige Quelle (Pattern 1, D-01 explizit) | Divergenz zwischen Guard und Listing ist genau das Sicherheitsloch, das D-01 ausdrücklich verhindern soll | + +**Key insight:** In dieser Phase ist fast jedes "Don't Hand-Roll"-Element eine Postgres-Datenbank-Garantie statt einer neuen Bibliothek — die richtige Antwort auf "Entweder-oder" und "genau ein Default" ist strukturelle DB-Konsistenz, nicht zusätzlicher Anwendungscode. + +## Runtime State Inventory + +> Diese Phase ist keine Umbenennung, aber sie enthält eine produktionswirksame Datenmigration (D-06) und eine LDAP-Sync-Erweiterung — beide Kategorien sind unten explizit geprüft. + +| Category | Items Found | Action Required | +|----------|-------------|------------------| +| Stored data | Bestehende `User`- und `TenantModuleActivation`-Zeilen pro Mandant müssen zum Migrationszeitpunkt gelesen werden, um die Standardgruppe zu befüllen (D-06). Keine externe Datenquelle (kein Mem0, kein Vector-Store) betroffen. | Reine SQL-`INSERT ... SELECT`-Backfill in der `migration.sql` (kein separates Skript, siehe Pattern/Don't-Hand-Roll oben) | +| Live service config | Der LDAP-Sync-Durchlauf selbst ist "live service config" im Sinne von Nicht-in-git — `LdapConfig`-Zeilen pro Mandant sind DB-Zustand, keine externe Service-UI. Keine n8n/Datadog/Tailscale-artigen externen Systeme betroffen. | Keine — GroupMembership-Sync läuft rein innerhalb des bestehenden `syncUsersForTenant`-Aufrufs, keine externe Config-Quelle nötig | +| OS-registered state | Kein Bezug — keine Task-Scheduler-, pm2-, launchd- oder systemd-Registrierung betroffen. | Keine | +| Secrets/env vars | Kein Bezug — keine neuen Secrets, kein Umbenennen bestehender Env-Var-Namen. | Keine | +| Build artifacts | `prisma generate` muss nach dem Schema-Update neu laufen (neue `Group`/`GroupMembership`/`ModuleGrant`-Typen), sonst schlägt der TypeScript-Build fehl. Bereits Teil des Standard-Dev-Workflows (`pnpm --filter @tessera/api build` triggert `prisma generate` via `postinstall`/Build-Skript — nicht separat verifiziert, aber laut bestehendem `backfill-tender-source.ts`-Kommentar üblicher Ablauf im Projekt). | `pnpm --filter @tessera/api exec prisma generate` nach jeder Schema-Änderung, wie bei jeder vorherigen Migration im Projekt | + +**Wichtigster Befund dieses Abschnitts:** `apps/api/Dockerfile:38` führt `prisma migrate deploy` automatisch vor jedem Container-Start aus (`[VERIFIED: apps/api/Dockerfile:38]`, `CMD ["sh", "-c", "apps/api/node_modules/.bin/prisma migrate deploy --schema apps/api/prisma/schema.prisma && node apps/api/dist/main.js"]`). Das bedeutet: die D-06-Migration läuft beim nächsten `docker compose pull && up -d` des Betreibers auf dem Testserver **automatisch und unbeaufsichtigt** — sie muss daher (a) innerhalb der von Prisma pro Migrationsdatei automatisch geöffneten Transaktion sicher laufen, (b) idempotent genug sein, um einen fehlgeschlagenen Neustart-Versuch zu überleben, und (c) keine interaktive Bestätigung oder externen Zustand voraussetzen. + +## Common Pitfalls + +### Pitfall 1: AD-Gruppenmitglieder-Liste durch Attribut-Lesen statt Filtern ermitteln +**What goes wrong:** Bei > 1500 (Win2003+) bzw. > 1000 (Win2000) Mitgliedern liefert AD nur `member;range=0-1499` statt `member` — ein naiver `entry['member']`-Zugriff sieht dann nur die ersten 1500 Mitglieder, ohne Fehler. +**Why it happens:** AD begrenzt die Anzahl zurückgegebener Werte pro Multi-Value-Attribut serverseitig; das ist bei kleinen Testverzeichnissen (< 1500 Mitglieder) nie sichtbar und fällt erst in Produktion mit großen Gruppen auf. +**How to avoid:** Niemals `member`/`memberOf` als Rückgabeattribut für Mitgliederlisten verwenden — immer Reverse-Query mit `memberOf` als Filter (Pattern 3), wie es der bestehende Code für `groupFilterDns` bereits tut. +**Warning signs:** Eine gebundene AD-Gruppe mit vielen Mitgliedern zeigt nach dem Sync konstant genau 1000/1500 `GroupMembership`-Zeilen, nie mehr, unabhängig von der tatsächlichen AD-Gruppengröße. + +### Pitfall 2: CHECK-Constraint ohne DTO-Vorab-Validierung +**What goes wrong:** Ein Request mit sowohl `groupId` als auch `userId` (oder keinem von beiden) gesetzt schlägt erst beim `INSERT` mit einem rohen Postgres-Fehler fehl, den Prisma als generischen `PrismaClientKnownRequestError` durchreicht — schlechte Fehlermeldung im Admin-UI. +**Why it happens:** Prisma modelliert die Exklusivität nicht im Typsystem (zwei unabhängige nullable Felder), der Compiler verhindert den fehlerhaften Zustand nicht. +**How to avoid:** DTO-Validierung (`class-validator`, z. B. ein custom `@ValidateIf`/`@Exists`-Paar) VOR dem `prisma.moduleGrant.create()`-Aufruf, die Constraint bleibt als letztes Sicherheitsnetz. +**Warning signs:** 500er-Antworten mit rohem Postgres-Constraint-Namen im Response-Body statt einer 400 mit Klartext-Meldung. + +### Pitfall 3: N+1-Queries in der Access-Resolution bei vielen Gruppen +**What goes wrong:** Eine naive Implementierung, die für jede Gruppe eines Benutzers einzeln nach `ModuleGrant`s sucht, erzeugt O(Gruppenanzahl) Datenbank-Roundtrips pro Request — bei jedem modul-geschützten API-Call. +**Why it happens:** Es ist der intuitivste erste Entwurf, besonders wenn man von der einzelnen `TenderTriage`/`FavoriteLink`-userId-Scoping-Konvention im Projekt kommt, die keine Gruppenauflösung kennt. +**How to avoid:** Eine einzige Prisma-Query mit `group: { memberships: { some: { userId } } }` (siehe Pattern 1) statt einer Schleife über Gruppen. +**Warning signs:** Antwortzeiten von `GET /modules/active` oder modul-geschützten Endpoints skalieren sichtbar mit der Anzahl Gruppen eines Benutzers. + +### Pitfall 4: RLS-Inkonsistenz bei den drei neuen Tabellen +**What goes wrong:** Das Projekt hat zwei etablierte Muster — RLS für Auth-Kern-Tabellen (`User`, `LdapConfig`, `LdapFieldMapping`, `PasswordResetToken`, `[VERIFIED: apps/api/prisma/migrations/20260618112133_rls_policies/migration.sql]`) versus reine App-Layer-`tenantId`-Filterung ohne RLS für alles andere (`Tender*`-Modelle, `WidgetInstance`, `FavoriteLink`). Für `Group`/`GroupMembership`/`ModuleGrant` ist unklar, welchem Muster gefolgt werden soll, wenn es nicht bewusst entschieden wird. +**Why it happens:** `Group`/`ModuleGrant` sind sicherheitskritisch (Zugriffskontrolle) wie `LdapConfig`, aber strukturell näher an "Admin-verwaltete Konfiguration" als an den User-scoped `Tender*`-Tabellen — die Analogie ist nicht eindeutig. +**How to avoid:** Explizit entscheiden (siehe Assumptions Log — als `[ASSUMED]` markiert, da nicht in CONTEXT.md D-01–D-23 festgelegt): Empfehlung ist RLS für `Group`/`GroupMembership`/`ModuleGrant`, weil sie — wie `LdapConfig` — direkt steuern, WER auf WAS Zugriff hat, nicht nur Anzeige-/Nutzerdaten sind wie `Tender*`. Der Planner sollte dies als expliziten Task behandeln (RLS-Policy-SQL nach demselben Muster wie `20260618112133_rls_policies`), nicht stillschweigend auslassen. +**Warning signs:** Ein Controller-Endpoint, der `tenantId` korrekt aus dem JWT liest, aber eine ungeschützte (nicht-`forTenant()`-gewrappte) Prisma-Query nutzt, würde ohne RLS Cross-Tenant-Daten zurückgeben, falls irgendwo ein `where: { tenantId }`-Filter vergessen wird — mit RLS ist das ein zweites Sicherheitsnetz (Defense-in-Depth, wie in `code_context` von CONTEXT.md bereits als Prinzip für ADMIN-Rolle beschrieben). + +### Pitfall 5: Widget-Modul-Map wird für Widgets gepflegt, die es noch nicht gibt +**What goes wrong:** Ein Entwickler versucht, in dieser Phase bereits einen `tender-deadline`-Widget-Typ samt Modul-Bindung zu bauen, weil D-22 danach klingt — obwohl das laut REQUIREMENTS.md explizit "Future Requirements (deferred)" ist. +**Why it happens:** D-22s Formulierung ("Dashboard-Widgets bekommen einen optionalen Modul-Bezug") klingt nach einer vollständigen Feature-Auslieferung, ist aber laut Kontext bewusst nur die MECHANIK. +**How to avoid:** Die Mechanik (Pattern 7) so bauen, dass sie mit einer leeren `WIDGET_MODULE_MAP` korrekt funktioniert (keine Widgets werden gefiltert) — kein neuer Widget-Typ in dieser Phase. +**Warning signs:** Ein Plan-Task, der einen neuen Widget-Typ registriert, obwohl die Roadmap-Success-Criteria dafür in einer anderen Phase liegen. + +## Code Examples + +### Prisma-Schema-Erweiterung (neue Modelle) +```prisma +// apps/api/prisma/schema.prisma — Ergänzung + +enum MembershipSource { + MANUAL + LDAP +} + +model Group { + id String @id @default(uuid()) + tenantId String + tenant Tenant @relation(fields: [tenantId], references: [id]) + name String + ldapDn String? // optionale AD-Bindung (D-05) + isDefault Boolean @default(false) // D-13 — genau eine pro Mandant, DB-erzwungen (s.u.) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + memberships GroupMembership[] + grants ModuleGrant[] + + @@unique([tenantId, ldapDn]) // NULL ist in Postgres je Zeile distinct — mehrere ungebundene Gruppen sind erlaubt + @@index([tenantId]) +} + +model GroupMembership { + id String @id @default(uuid()) + groupId String + group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + source MembershipSource @default(MANUAL) + createdAt DateTime @default(now()) + + @@unique([groupId, userId]) // Upsert-Target, wie TenderTriage @@unique([userId, tenderId]) + @@index([userId]) + @@index([groupId]) +} + +model ModuleGrant { + id String @id @default(uuid()) + tenantId String + tenant Tenant @relation(fields: [tenantId], references: [id]) + moduleId String + module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade) + groupId String? + group Group? @relation(fields: [groupId], references: [id], onDelete: Cascade) + userId String? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + + // Entweder-oder (Prisma-seitig NICHT erzwingbar) + Duplikat-Schutz je Variante + // werden per hand-editierter migration.sql ergänzt (siehe unten) — Prisma 6.19 + // hat kein stabiles partial-index-Feature ohne previewFeatures-Flag. + @@index([tenantId]) + @@index([moduleId]) +} +``` + +### Hand-editierte Migrationsergänzung (Entweder-oder + Ein-Default-pro-Mandant) +Nach `pnpm --filter @tessera/api exec prisma migrate dev --name add_groups_and_module_grants --create-only` generiert Prisma die `CREATE TABLE`-Statements. Vor dem Anwenden werden folgende Statements angehängt — exakt im Stil von `20260618112133_rls_policies/migration.sql` und `20260721150000_tender_cpv_divisions_backfill/migration.sql`: +```sql +-- Genau eine Standardgruppe pro Mandant (D-13) +CREATE UNIQUE INDEX "Group_one_default_per_tenant" + ON "Group"("tenantId") WHERE "isDefault" = true; + +-- ModuleGrant: exakt eines von groupId/userId muss gesetzt sein +ALTER TABLE "ModuleGrant" + ADD CONSTRAINT "ModuleGrant_group_xor_user" + CHECK (num_nonnulls("groupId", "userId") = 1); + +-- Keine doppelten Grants je Variante (nullable Spalten in @@unique wären +-- sonst wirkungslos, da Postgres NULL <> NULL in normalen Unique-Constraints) +CREATE UNIQUE INDEX "ModuleGrant_tenant_module_group_unique" + ON "ModuleGrant"("tenantId", "moduleId", "groupId") WHERE "groupId" IS NOT NULL; +CREATE UNIQUE INDEX "ModuleGrant_tenant_module_user_unique" + ON "ModuleGrant"("tenantId", "moduleId", "userId") WHERE "userId" IS NOT NULL; +``` + +### Migrations-Backfill für D-06 (läuft automatisch beim Deploy) +```sql +-- D-06: pro Mandant eine Standardgruppe "Alle Benutzer" mit allen Bestandsbenutzern +-- und Grants für alle aktuell aktiven Module dieses Mandanten. Reine relationale +-- Kopie (kein umlaut-/normalisierungspflichtiges Feld) — gehört direkt hierher, +-- nicht in ein separates TS-Skript (läuft sonst NICHT automatisch, siehe +-- Dockerfile:38 / Runtime State Inventory). +INSERT INTO "Group" (id, "tenantId", name, "isDefault", "createdAt", "updatedAt") +SELECT gen_random_uuid(), t.id, 'Alle Benutzer', true, now(), now() +FROM "Tenant" t; + +INSERT INTO "GroupMembership" (id, "groupId", "userId", source, "createdAt") +SELECT gen_random_uuid(), g.id, u.id, 'MANUAL', now() +FROM "Group" g +JOIN "User" u ON u."tenantId" = g."tenantId" +WHERE g."isDefault" = true; + +INSERT INTO "ModuleGrant" (id, "tenantId", "moduleId", "groupId", "createdAt") +SELECT gen_random_uuid(), tma."tenantId", tma."moduleId", g.id, now() +FROM "TenantModuleActivation" tma +JOIN "Group" g ON g."tenantId" = tma."tenantId" AND g."isDefault" = true +WHERE tma."isActive" = true; +``` +`gen_random_uuid()` erfordert die `pgcrypto`-Extension (bereits verfügbar, da Prisma `@default(uuid())` in diesem Projekt bereits durchgängig auf Postgres-UUIDs abbildet — zu verifizieren durch den Planner mittels `SELECT gen_random_uuid();` gegen die tatsächliche DB, `[ASSUMED]`, siehe Assumptions Log). + +### Server-seitige Modul-Zugriffsprüfung (Web, Muster nach `fetchCurrentUser()`) +```typescript +// apps/web/src/lib/module-access-actions.ts (neu) +'use server'; +import { cookies } from 'next/headers'; + +const API_URL = process.env.API_INTERNAL_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +export async function checkModuleAccess(moduleSlug: string): Promise { + const cookieStore = await cookies(); + const session = cookieStore.get('session')?.value; + if (!session) return false; + + const response = await fetch(`${API_URL}/modules/active`, { + headers: { Cookie: `session=${session}` }, + cache: 'no-store', + }); + if (!response.ok) return false; + + const active: Array<{ slug: string }> = await response.json(); + return active.some((m) => m.slug === moduleSlug); +} +``` + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|---------------|--------| +| `ModuleGuard` prüft ausschließlich `TenantModuleActivation.isActive` | `ModuleGuard` prüft zusätzlich `ModuleAccessService.getAccessibleModuleIds` (Rolle/Grant/Gruppe) | Phase 15 | Jeder existierende Modul-Endpoint mit `@UseModule(slug)` ist betroffen — Regressionsgefahr für ADMIN/SUPER_ADMIN muss explizit getestet werden (D-03: dürfen NICHT ausgesperrt werden) | +| `GET /modules/active` liefert mandantenweit aktive Module (alle Benutzer sehen dasselbe) | `GET /modules/active` liefert benutzerspezifisch gefilterte Module | Phase 15 | Sidebar-Verhalten ändert sich sichtbar für jeden nicht-privilegierten Benutzer — muss vor/nach der Migration (D-06) konsistent bleiben | +| Modulseiten-Route ist rein clientseitig (`'use client'`, kein Server-Datenfetch) | Modulseiten-Route wird Server Component mit vorgelagertem Zugriffscheck | Phase 15 | Erste serverseitige Datenladung in dieser Route — Fehlerbehandlung (API nicht erreichbar) muss neu bedacht werden, bisher fing der Client-`try/catch` das ab | + +**Deprecated/outdated:** +- Kein Element in dieser Phase ersetzt eine vorher als "State of the Art" geltende Bibliothek — es ist reine Erweiterung bestehender, weiterhin aktueller Muster. + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|-----------------| +| A1 | RLS sollte für `Group`/`GroupMembership`/`ModuleGrant` aktiviert werden (Analogie zu `LdapConfig`, nicht zu `Tender*`) | Common Pitfalls #4 | Falls der Nutzer stattdessen das `Tender*`-App-Layer-Muster (kein RLS) bevorzugt, ist die Empfehlung überdimensioniert — aber das Risiko der Unterlassung (Cross-Tenant-Datenleck bei einer vergessenen `where: tenantId`-Klausel in einer sicherheitskritischen Tabelle) wiegt schwerer als der Zusatzaufwand einer RLS-Policy | +| A2 | `gen_random_uuid()` (pgcrypto) ist in der Ziel-Postgres-Instanz bereits aktiv, weil alle `@default(uuid())`-Felder im Schema Postgres-generierte UUIDs erwarten | Code Examples — Migrations-Backfill | Falls die Extension fehlt, schlägt die automatisch beim Deploy laufende Migration fehl — Planner sollte einen `CREATE EXTENSION IF NOT EXISTS pgcrypto;`-Sicherheitsschritt voranstellen oder gegen die reale DB verifizieren | +| A3 | Nested-AD-Gruppen (`LDAP_MATCHING_RULE_IN_CHAIN`) werden in dieser Phase bewusst NICHT unterstützt (nur direkte Mitgliedschaft) | Architecture Patterns #4 | Falls der Betreiber tatsächlich verschachtelte AD-Gruppen für die Gruppenbindung nutzt, sehen gebundene Tessera-Gruppen nach dem Sync weniger Mitglieder als im AD sichtbar — stiller, nicht offensichtlicher Fehlzustand, der erst bei einem konkreten Support-Fall auffällt | +| A4 | `prisma generate` wird als Teil des bestehenden API-Build-Prozesses automatisch nach einer Schema-Änderung ausgeführt (nicht separat verifiziert, aus Projektkonvention abgeleitet) | Runtime State Inventory | Falls nicht, schlägt der TypeScript-Build mit fehlenden `Group`/`GroupMembership`/`ModuleGrant`-Typen fehl — sollte im ersten Plan-Task explizit als Schritt stehen | +| A5 | Per-Request-Memoisierung über eine `request`-Objekt-Eigenschaft ist ausreichend performant und keine `Scope.REQUEST`-Provider nötig | Architecture Patterns #2 | Bei sehr hoher Last könnte der Zusatz-Query-Overhead bei mehrfachem Guard-Aufruf pro Request relevant werden — aktuell hat das Projekt keinen Endpoint mit mehreren `@UseModule`-Prüfungen im selben Request, das Risiko ist damit gering | + +## Open Questions + +1. **Neuer dedizierter Endpoint für den Server-seitigen 403-Check, oder Wiederverwendung von `GET /modules/active`?** + - What we know: `GET /modules/active` liefert bereits die vollständige, benutzergefilterte Modulliste; ein Server-Component-Aufruf könnte einfach darin nach dem Slug suchen (wie im Code-Beispiel oben). + - What's unclear: Ob ein schlankerer `GET /modules/:slug/access` (nur `{ hasAccess: boolean }`) für die Modulseiten-Route sinnvoller ist (kleinere Payload, klareres Vertragsverhalten) — insbesondere da diese Route bei JEDEM Seitenaufruf eines Moduls läuft (kein Client-Cache wie bei der Sidebar). + - Recommendation: Für den ersten Wurf `GET /modules/active` wiederverwenden (kein neuer Endpoint, kleinerer Diff); der Planner kann einen dedizierten Endpoint als Optimierung nachziehen, falls die Payload-Größe (typischerweise wenige bis niedrige zweistellige Modulanzahl pro Mandant) sich als Problem erweist. + +2. **Nested AD groups tatsächlich relevant für den Betreiber?** + - What we know: Die bestehende `groupFilterDns`-Funktionalität nutzt bereits ausschließlich direkte `memberOf`-Filterung; kein Hinweis in CONTEXT.md oder ROADMAP.md auf verschachtelte Gruppenstrukturen. + - What's unclear: Ob CTLs (oder eines künftigen Kunden-Mandanten) AD-Struktur verschachtelte Gruppen für Modul-Freigaben nutzen würde. + - Recommendation: Direkte Mitgliedschaft als Standard (Pattern 4), im Discuss-Phase-Gespräch für Phase 15 explizit gegenprüfen, falls noch nicht geschehen. + +3. **RLS für die drei neuen Tabellen — Ja/Nein/Umfang?** + - What we know: Zwei etablierte, aber gegensätzliche Präzedenzfälle im Code (siehe Pitfall #4). + - What's unclear: Ob der Nutzer eine bewusste Präferenz hat oder dies an Claudes Diskretion delegiert (CONTEXT.md nennt es nicht explizit unter "Claude's Discretion", sondern nur "Schema-Details … Kaskadenregeln"). + - Recommendation: RLS aktivieren (Empfehlung A1), aber im Plan als expliziten, separat review-baren Task markieren, nicht in der allgemeinen Modell-Migration verstecken. + +## Environment Availability + +| Dependency | Required By | Available | Version | Fallback | +|------------|--------------|-----------|---------|----------| +| PostgreSQL (lokal, via Container-IP) | Neue Migration + Backfill lokal testen | ✓ | laut `db`-Service (nicht separat neu geprüft, `[VERIFIED: memory/project_local_db_migrations.md]` beschreibt den etablierten Zugriffsweg über Container-IP) | — | +| Ein AD/LDAP-Server mit > 1500 Gruppenmitgliedern zum Testen des Range-Retrieval-Verhaltens | Verifikation von Pattern 3/Pitfall 1 | ✗ (kein Test-AD dieser Größe bekannt) | — | Manuelle Code-Review des Filtermusters statt Live-Test; Pattern 3 umgeht das Problem strukturell, ein Live-Test mit großer Gruppe bleibt aber die einzige echte Bestätigung | +| Ein AD-Server mit verschachtelten Gruppen zum Testen von `LDAP_MATCHING_RULE_IN_CHAIN` | Nur relevant, falls Open Question #2 mit "ja" beantwortet wird | ✗ | — | Feature bewusst nicht Teil dieser Phase (Pattern 4) | + +**Missing dependencies with no fallback:** keine — beide fehlenden AD-Testumgebungen sind für Features, die in dieser Phase bewusst nicht gebaut werden (Range-Handling wird strukturell umgangen, Nested-Groups sind explizit out-of-scope). + +## Validation Architecture + +### Test Framework +| Property | Value | +|----------|-------| +| Framework | Vitest 3.2.6 `[VERIFIED: apps/api/node_modules/vitest/package.json]` | +| Config file | `apps/api/vitest.config.ts` / `apps/web/vitest.config.ts` | +| Quick run command | `pnpm --filter @tessera/api test -- module-access` (bzw. `-- groups`) | +| Full suite command | `pnpm --filter @tessera/api test` / `pnpm --filter @tessera/web test` | + +### Phase Requirements → Test Map +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|---------------------|--------------| +| PERM-01 | Gruppen anlegen/umbenennen/löschen, Mitglieder zuweisen/entfernen, Lösch-Warndialog | unit (Service) + component (Dialog) | `vitest run groups.service` | ❌ Wave 0 | +| PERM-02 | AD-Gruppenbindung, `memberOf`-Sync, MANUAL bleibt erhalten | unit (LdapService-Erweiterung, gemockter `Client`) | `vitest run ldap.service` | ✅ `apps/api/src/ldap/ldap.service.spec.ts` existiert bereits, muss um Gruppen-Sync-Fälle erweitert werden | +| PERM-03 | Grant/Entzug für Gruppe und Benutzer, Matrix, geerbte Rechte im User-Detail | unit (Service) + component (Matrix-UI) | `vitest run module-grants.service` | ❌ Wave 0 | +| PERM-04 | Kein Grant → Sidebar/Seite/API konsistent 403 | integration (Guard) + component (403-Seite) | `vitest run module-access.service` und `vitest run module.guard` | ❌ Wave 0 (kein bestehender Test für `module.guard.ts`/`module-registry.service.ts`) | +| PERM-05 | ADMIN/SUPER_ADMIN umgehen Grants | unit (`ModuleAccessService`, Rollen-Branch) | `vitest run module-access.service` | ❌ Wave 0 | +| PERM-06 | Migration erzeugt Standardgruppe + Memberships + Grants ohne Zugriffsverlust | integration (SQL gegen Test-DB, oder dediziertes Migrations-Testskript) | manueller/skriptgestützter Lauf gegen eine seed-befüllte Test-DB (kein Standard-Vitest-Unit-Test für raw-SQL-Migrationen im Projekt üblich, siehe `backfill-tender-source.ts` — kein zugehöriger `.spec.ts`) | ❌ Wave 0 — Migrations-Verifikation braucht eigenes Vorgehen, kein reiner Unit-Test | +| PERM-07 | Widget ohne Modul-Zugriff verschwindet vom Dashboard | unit (`DashboardService.getWidgets`) | `vitest run dashboard.service` | ❌ Wave 0 (kein bestehender `dashboard.service.spec.ts`) | + +### Sampling Rate +- **Per task commit:** betroffene Einzeldatei-Testsuite (`vitest run `) +- **Per wave merge:** `pnpm --filter @tessera/api test` und `pnpm --filter @tessera/web test` +- **Phase gate:** volle Suite grün vor `/gsd-verify-work`, PLUS ein manueller Migrations-Trockenlauf gegen eine mit Bestandsdaten befüllte lokale Test-DB (PERM-06 ist nicht sinnvoll allein durch Unit-Tests abgedeckt) + +### Wave 0 Gaps +- [ ] `apps/api/src/module-registry/module-access.service.spec.ts` — deckt PERM-04/05 +- [ ] `apps/api/src/module-registry/module.guard.spec.ts` — kein bestehender Test für den Guard selbst +- [ ] `apps/api/src/groups/groups.service.spec.ts` — deckt PERM-01 +- [ ] `apps/api/src/groups/module-grants.service.spec.ts` — deckt PERM-03 +- [ ] `apps/api/src/dashboard/dashboard.service.spec.ts` — bisher komplett ungetestet, deckt PERM-07 zusätzlich ab +- [ ] Erweiterung von `apps/api/src/ldap/ldap.service.spec.ts` um AD-Gruppenbindungs-Testfälle (PERM-02) +- [ ] Manuelles Migrations-Verifikationsvorgehen für PERM-06 (kein automatisiertes Framework im Projekt für raw-SQL-Migrationsdaten-Checks vorhanden — Planner sollte ein Nachher-Zählungs-Skript oder SQL-Assertions vorsehen, analog zum bestehenden `backfill-tender-source.ts`-Muster ohne zugehörigen Vitest-Test) + +## Security Domain + +### Applicable ASVS Categories + +| ASVS Category | Applies | Standard Control | +|----------------|---------|--------------------| +| V2 Authentication | nein (unverändert) | Bestehender JWT-Cookie-Mechanismus, nicht Teil dieser Phase | +| V3 Session Management | nein (unverändert) | — | +| V4 Access Control | **ja — Kern dieser Phase** | Zentrale `ModuleAccessService` als Single-Enforcement-Point (Pattern 1); serverseitige Durchsetzung auf JEDER Schicht (API-Guard UND Seiten-Server-Component), niemals nur clientseitig (Anti-Pattern oben) | +| V5 Input Validation | ja | `class-validator`-DTOs für Group-/ModuleGrant-Endpoints (bestehendes Projektmuster), plus Entweder-oder-Vorabprüfung (Pitfall 2) | +| V6 Cryptography | nein | Keine neuen Secrets/Verschlüsselung in dieser Phase | + +### Known Threat Patterns for NestJS/Prisma/PostgreSQL Multi-Tenant + +| Pattern | STRIDE | Standard Mitigation | +|---------|--------|------------------------| +| Cross-Tenant-Grant-Injection: ein Admin eines Mandanten erstellt einen `ModuleGrant` mit `groupId`/`userId` eines ANDEREN Mandanten | Elevation of Privilege / Tampering | Service-seitig prüfen, dass `group.tenantId === tenantId` bzw. `user.tenantId === tenantId` vor jedem `ModuleGrant`-Insert — nicht nur auf das JWT-`tenantId` vertrauen, sondern das Ziel-Objekt gegenprüfen (analog zum bestehenden T-03-04-Prinzip: tenantId kommt aus dem JWT, nie aus Benutzereingaben — hier zusätzlich: das REFERENZIERTE Objekt muss zum selben Mandanten gehören) | +| IDOR bei Gruppen-/Grant-IDs in Admin-Endpoints (`DELETE /groups/:id`) | Information Disclosure / Tampering | Jede Lookup-Query filtert zusätzlich auf `tenantId` (wie bereits bei `DashboardService.removeWidget`/`updateWidgetConfig` durch Ownership-Check etabliert, `[VERIFIED: apps/api/src/dashboard/dashboard.service.ts:150-159]`) | +| Guard-Bypass durch fehlendes `@UseModule()` auf einem neuen Modul-Endpoint | Elevation of Privilege | Code-Review-Checkliste (bereits etabliertes Projektmuster: jeder neue Modul-Controller MUSS `@UseModule(slug)` tragen) | +| RLS-Umgehung durch direkten `this.prisma.*`-Zugriff statt `forTenant(this.prisma, tenantId)` in einem neuen Group-/Grant-Service | Tampering / Information Disclosure | Falls RLS aktiviert wird (Empfehlung A1): jede Query in `groups.service.ts`/`module-grants.service.ts` MUSS durch `forTenant()` laufen, wie es `LdapService.syncUsersForTenant` bereits tut (`[VERIFIED: apps/api/src/ldap/ldap.service.ts:588-589]`) | + +## Sources + +### Primary (HIGH confidence — aus eigenem Code verifiziert) +- `apps/api/prisma/schema.prisma` — bestehende Modelle, Role-Enum, RLS-Vorbilder +- `apps/api/src/module-registry/module-registry.service.ts`, `module.guard.ts`, `module-registry.controller.ts` — heutiger Ein-Punkt-Prüfmechanismus +- `apps/api/src/ldap/ldap.service.ts` — `collectSearchEntries`/`memberOf`-Filtermuster, `upsertMappedUser`/`importUsersByDn` als einzige User-Erzeugungspfade +- `apps/api/src/user/user.service.ts` — `create()` als einziger Erzeugungspunkt +- `apps/api/src/dashboard/dashboard.service.ts`, `apps/web/src/components/dashboard/widget-registry.tsx` — aktueller Widget-Bestand (kein modulgebundenes Widget) +- `apps/web/src/lib/auth-actions.ts` — Server-Action-Cookie-Weiterleitungsmuster (`fetchCurrentUser`) +- `apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx`, `.../[category]/page.tsx` — heutiger rein-clientseitiger Zustand +- `apps/api/Dockerfile` — automatischer `prisma migrate deploy` beim Container-Start +- `apps/api/prisma/migrations/20260618112133_rls_policies/migration.sql`, `20260721150000_tender_cpv_divisions_backfill/migration.sql` — etabliertes Hand-SQL-Migrationsmuster +- `apps/api/package.json`, `apps/web/package.json`, `node_modules/*/package.json` (Prisma, Next.js, ldapts, Vitest) — tatsächlich installierte Versionen + +### Secondary (MEDIUM confidence — WebSearch/WebFetch, gegen offizielle Quellen geprüft) +- [Searching Using Range Retrieval (Microsoft Learn)](https://learn.microsoft.com/en-us/previous-versions/windows/desktop/ldap/searching-using-range-retrieval) — AD Range-Retrieval-Mechanismus +- [Enumerating Members of a Group (Microsoft Learn)](https://learn.microsoft.com/en-us/previous-versions/ms180906(v=vs.90)) — 1000/1500-Grenze +- [389 Directory Server — Matching rule in chain](https://www.port389.org/docs/389ds/design/matching-rule-in-chain.html) — LDAP_MATCHING_RULE_IN_CHAIN Kosten/Verfügbarkeit +- [Nested LDAP Group Resolution (Cloudera)](https://docs.cloudera.com/cdp-private-cloud-base/latest/security-how-to-guides/topics/cm-security-nested-ldapgroup-resolution.html) +- [API with NestJS #146 — Polymorphic associations with PostgreSQL and Prisma (wanago.io)](https://wanago.io/2024/02/19/api-nestjs-postgresql-prisma-polymorphic-associations/) — CHECK-Constraint-Muster (`num_nonnulls`), per WebFetch mit exaktem SQL-Snippet verifiziert +- [Prisma Docs — Indexes](https://www.prisma.io/docs/orm/prisma-schema/data-model/indexes) — `partialIndexes` ist Preview-Feature, nicht stabil in installierter Version, per WebFetch verifiziert +- [NestJS Docs — Injection Scopes](https://docs.nestjs.com/fundamentals/injection-scopes) — REQUEST-Scope-Overhead-Verhalten +- [Next.js Docs — Server and Client Components](https://nextjs.org/docs/app/getting-started/server-and-client-components) — Server→Client-Props-Komposition, per WebFetch verifiziert (Next.js-Doku-Version 16.3.0 — Projekt läuft auf 15.5.19, Komposition ist App-Router-Grundverhalten, versionsunabhängig) +- Prisma-Migrations-Produktionsmuster (Expand-Backfill-Contract) — mehrere WebSearch-Treffer (dev.to/whoffagents-Artikelserie), inhaltlich konsistent mit bereits im Projekt etabliertem Muster (`20260721150000_tender_cpv_divisions_backfill`) + +### Tertiary (LOW confidence — nicht gegenverifiziert) +- Keine — alle WebSearch-Funde wurden entweder gegen eine Microsoft-Learn-/Prisma-/NestJS-/Next.js-offizielle Quelle abgeglichen oder als `[ASSUMED]` im Assumptions Log markiert. + +## Metadata + +**Confidence breakdown:** +- Standard Stack / installierte Versionen: HIGH — direkt aus `package.json`/`node_modules` gelesen +- Architektur (Access-Resolution, LDAP-Reverse-Query, Server-Component-Split): HIGH — auf bereits im Code etablierte Muster gestützt, keine Neuerfindung +- AD-spezifisches Detailverhalten (Range-Retrieval, Matching-Rule-Kosten): MEDIUM — aus offizieller/quasi-offizieller Doku, aber nicht live gegen ein AD dieser Größenordnung getestet (kein Test-AD verfügbar, siehe Environment Availability) +- Prisma-Entweder-oder-Muster: MEDIUM-HIGH — Kernmechanismus (`num_nonnulls`-CHECK) aus einem Community-Artikel mit exaktem SQL, aber gegen offizielle Prisma-Docs zur `partialIndexes`-Preview-Feature-Einschränkung abgesichert +- Pitfalls: HIGH für projekteigene Muster (N+1, RLS-Inkonsistenz, Widget-Map), MEDIUM für AD-spezifische Pitfalls + +**Research date:** 2026-08-04 +**Valid until:** 2026-09-03 (30 Tage — Kernbefunde sind stabile Architekturmuster, kein schnelllebiger Ökosystem-Bereich; AD-spezifische Microsoft-Learn-Inhalte sind seit Jahren unverändert und nicht befristet)