Files
tessera-ctl/.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-RESEARCH.md
T

631 lines
62 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 → <ModuleShell> (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=<ldapDn>) über collectSearchEntries- │
│ Muster → Set<username> → 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<Set<string>> {
// 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<string> | 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=<groupDn>)`. 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=<Group.ldapDn>))` ü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:=<groupDn>)` 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<AuthUser | null> {
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 `<ModuleShell category={category} moduleSlug={moduleSlug} />`, 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<Record<string, string>> = {
// 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=<groupDn>)` (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<boolean> {
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 <file>`)
- **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)