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

62 KiB
Raw Blame History

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)                │
   └───────────────────────────────────────────────────────────────────┘
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:

  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()):

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:

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

  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)

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)