62 KiB
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:
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 moduleIds 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):
// 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:
// 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]):
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 usernames 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:
- D-19 im Kontext spricht nur von "verlässt der Benutzer die AD-Gruppe" — keine explizite Anforderung für verschachtelte Gruppen.
- 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]. - 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.
- Die bestehende
groupFilterDns-Funktionalität aus Phase 2 nutzt ebenfalls direktememberOf-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()):
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:
page.tsxwird eineasync-Server-Component (kein'use client'mehr), liestparams, ruft eine neue Server-ActioncheckModuleAccess(moduleSlug)(Dateiapps/web/src/lib/module-access-actions.ts, exakt nach demfetchCurrentUser()-Muster) auf, die serverseitigGET /modules/active(oder einen neuenGET /modules/:slug/access-Endpoint — siehe Open Question) mit weitergeleitetem Session-Cookie aufruft.- 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. - Zugriff vorhanden → die Seite rendert
<ModuleShell category={category} moduleSlug={moduleSlug} />, eine neue Client Component, die den kompletten bisherigen Inhalt vonpage.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:
// 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
ModuleGuardauf der API laufen. Der Client-fetch('/modules/active')-Aufruf insidebar.tsxbleibt unverändert bestehen, ist aber niemals der Ort für eine neue Zugriffsentscheidung. memberOf/memberals 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:38führt nurprisma migrate deployautomatisch 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(CodeP2010bei 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 ModuleGrants 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)
// 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:
-- 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)
-- 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())
// 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
-
Neuer dedizierter Endpoint für den Server-seitigen 403-Check, oder Wiederverwendung von
GET /modules/active?- What we know:
GET /modules/activeliefert 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/activewiederverwenden (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.
- What we know:
-
Nested AD groups tatsächlich relevant für den Betreiber?
- What we know: Die bestehende
groupFilterDns-Funktionalität nutzt bereits ausschließlich direktememberOf-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.
- What we know: Die bestehende
-
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 testundpnpm --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/05apps/api/src/module-registry/module.guard.spec.ts— kein bestehender Test für den Guard selbstapps/api/src/groups/groups.service.spec.ts— deckt PERM-01apps/api/src/groups/module-grants.service.spec.ts— deckt PERM-03apps/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.tsum 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-Vorbilderapps/api/src/module-registry/module-registry.service.ts,module.guard.ts,module-registry.controller.ts— heutiger Ein-Punkt-Prüfmechanismusapps/api/src/ldap/ldap.service.ts—collectSearchEntries/memberOf-Filtermuster,upsertMappedUser/importUsersByDnals einzige User-Erzeugungspfadeapps/api/src/user/user.service.ts—create()als einziger Erzeugungspunktapps/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 Zustandapps/api/Dockerfile— automatischerprisma migrate deploybeim Container-Startapps/api/prisma/migrations/20260618112133_rls_policies/migration.sql,20260721150000_tender_cpv_divisions_backfill/migration.sql— etabliertes Hand-SQL-Migrationsmusterapps/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) — AD Range-Retrieval-Mechanismus
- Enumerating Members of a Group (Microsoft Learn) — 1000/1500-Grenze
- 389 Directory Server — Matching rule in chain — LDAP_MATCHING_RULE_IN_CHAIN Kosten/Verfügbarkeit
- Nested LDAP Group Resolution (Cloudera)
- API with NestJS #146 — Polymorphic associations with PostgreSQL and Prisma (wanago.io) — CHECK-Constraint-Muster (
num_nonnulls), per WebFetch mit exaktem SQL-Snippet verifiziert - Prisma Docs — Indexes —
partialIndexesist Preview-Feature, nicht stabil in installierter Version, per WebFetch verifiziert - NestJS Docs — Injection Scopes — REQUEST-Scope-Overhead-Verhalten
- Next.js Docs — 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_modulesgelesen - 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 zurpartialIndexes-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)