From 8e70f55c8c8ccfdcf8597b1b6a850ded2b6f8dc0 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 4 Aug 2026 19:54:13 +0200 Subject: [PATCH] docs(15): add pattern map from planning --- .../15-PATTERNS.md | 580 ++++++++++++++++++ 1 file changed, 580 insertions(+) create mode 100644 .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-PATTERNS.md diff --git a/.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-PATTERNS.md b/.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-PATTERNS.md new file mode 100644 index 0000000..a5112c1 --- /dev/null +++ b/.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-PATTERNS.md @@ -0,0 +1,580 @@ +# Phase 15: Modul-Berechtigungen: Gruppen & User-Grants - Pattern Map + +**Mapped:** 2026-08-04 +**Files analyzed:** 20 +**Analogs found:** 20 / 20 + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|--------------------|------|-----------|-----------------|----------------| +| `apps/api/prisma/schema.prisma` (Ergänzung: `Group`, `GroupMembership`, `ModuleGrant`, `MembershipSource`) | model | CRUD | bestehende Modelle `TenantModuleActivation`/`WidgetInstance` im selben File | exact | +| `apps/api/prisma/migrations/_add_groups_and_module_grants/migration.sql` (Hand-SQL-Ergänzung: CHECK-Constraint, partielle Unique-Indizes, D-06-Backfill) | migration | batch | `apps/api/prisma/migrations/20260618112133_rls_policies/migration.sql`, `20260721150000_tender_cpv_divisions_backfill/migration.sql` | exact | +| `apps/api/src/module-registry/module-access.service.ts` (neu) | service | CRUD | `apps/api/src/module-registry/module-registry.service.ts` | exact | +| `apps/api/src/module-registry/module.guard.ts` (erweitert) | middleware | request-response | sich selbst (unveränderter Grundaufbau, D-01 erweitert `isModuleActive`-Aufruf zu `ModuleAccessService`) | exact | +| `apps/api/src/module-registry/module-registry.controller.ts` (`findActive` erweitert) | controller | request-response | sich selbst | exact | +| `apps/api/src/groups/groups.module.ts` (neu) | config | — | `apps/api/src/module-registry/module-registry.module.ts` (NestJS-Modul-Boilerplate, nicht extra gelesen — Standardmuster: `@Module({ imports, controllers, providers, exports })`) | role-match | +| `apps/api/src/groups/groups.service.ts` (neu — Group/GroupMembership CRUD) | service | CRUD | `apps/api/src/module-registry/module-registry.service.ts` (Prisma-Query-Stil, upsert/findUnique-Fehlerbehandlung) + `apps/api/src/user/user.service.ts` (Create/Update-Struktur) | role-match | +| `apps/api/src/groups/module-grants.service.ts` (neu — ModuleGrant CRUD, Matrix, User-Detail) | service | CRUD | `apps/api/src/module-registry/module-registry.service.ts` (`activateForTenant`/`deactivateForTenant` als Vorbild für Upsert+Soft-Toggle) | role-match | +| `apps/api/src/groups/groups.controller.ts` (neu — `/groups`, Admin-Endpoints) | controller | request-response | `apps/api/src/module-registry/module-registry.controller.ts` (RolesGuard+Roles-Dekorator, tenantId-aus-Request-Muster) | exact | +| `apps/api/src/groups/module-grants.controller.ts` (neu — Matrix + User-Detail Endpoints) | controller | request-response | `apps/api/src/module-registry/module-registry.controller.ts` | exact | +| `apps/api/src/ldap/ldap.service.ts` (`syncUsersForTenant` erweitert — `memberOf`-Reverse-Query je AD-gebundener Group) | service | event-driven | `collectSearchEntries` (Zeilen 737–795) im selben File — bereits exaktes `memberOf`-Filtermuster | exact | +| `apps/api/src/user/user.service.ts` (`create()` erweitert — Standardgruppen-Mitgliedschaft) | service | CRUD | sich selbst (`create()`, Zeilen 32–50) | exact | +| `apps/api/src/dashboard/dashboard.service.ts` (`getWidgets` erweitert — Modul-Filter) | service | CRUD | sich selbst (`getWidgets`/`removeWidget`, Zeilen 94–164) | exact | +| `apps/api/src/dashboard/widget-module-map.ts` (neu — statische Widget→Modul-Registrierung) | config | transform | `apps/web/src/components/dashboard/widget-registry.tsx` (statisches Registrierungs-Objekt-Muster, nicht separat gelesen — laut RESEARCH.md Zeilen 8–16 ein `Record`-Literal) | role-match | +| `apps/web/src/lib/module-access-actions.ts` (neu — Server Action `checkModuleAccess`) | utility | request-response | `apps/web/src/lib/auth-actions.ts` (`fetchCurrentUser()`, Zeilen 243–268) | exact | +| `apps/web/src/app/(portal)/admin/groups/page.tsx` (neu) | component | CRUD | `apps/web/src/app/(portal)/admin/users/page.tsx` (komplette Datei — Tabelle, Create/Edit-Modal, Lösch-Dialog) + `apps/web/src/app/(portal)/admin/ldap/page.tsx` (AD-Gruppen-Discovery Zeilen 705–746, Mitglieder-Chip-Liste Zeilen 957–974, User-Suche Zeilen 841–877) | exact | +| `apps/web/src/app/(portal)/admin/modules/grants/page.tsx` (neu — Permission-Matrix) | component | CRUD | `apps/web/src/app/(portal)/admin/modules/page.tsx` (`toggleModule`, Zeilen 80–112 — optimistisches Toggle-Muster) + `apps/web/src/app/(portal)/admin/ldap/page.tsx` (`discoverSearch`-Eingabefeld Zeile 707–713) | exact | +| `apps/web/src/app/(portal)/admin/users/page.tsx` (erweitert — vierter Aktions-Button "Details", User-Detail-Grant-Modal) | component | CRUD | sich selbst (komplette Datei als Ausgangsbasis für das breitere Detail-Modal) | exact | +| `apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx` (umgebaut zu Server Component) + `module-shell.tsx` (neu) | component | request-response | sich selbst (bisheriger Not-Found-Block, Zeilen 30–65) für das 403-Visualmuster; `apps/web/src/lib/auth-actions.ts` (`fetchCurrentUser`) für den Server-Fetch-mit-Cookie-Pfad | exact | +| `apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx` (erweitert — `hasGrant`-Prop, "Kein Zugriff"-Badge) | component | request-response | sich selbst (Status-Badge-Muster Zeilen 96–110, Button-Zustand Zeilen 120–138) | exact | +| `apps/web/src/components/admin/admin-sidebar.tsx` (erweitert — sechster Nav-Eintrag "Gruppen") | component | request-response | sich selbst (Item-Array-Struktur, Zeilen 14–74) | exact | + +## Pattern Assignments + +### `apps/api/src/module-registry/module-access.service.ts` (service, CRUD) + +**Analog:** `apps/api/src/module-registry/module-registry.service.ts` + +**Imports pattern** (Zeilen 1-2): +```typescript +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +``` + +**Core Pattern — Prisma-Query-Stil für Aktivierungsprüfung** (Zeilen 133-152, `isModuleActive`): +```typescript +async isModuleActive(tenantId: string, moduleSlug: string): Promise { + const module = await this.prisma.module.findUnique({ where: { slug: moduleSlug } }); + if (!module) return false; + const activation = await this.prisma.tenantModuleActivation.findUnique({ + where: { tenantId_moduleId: { tenantId, moduleId: module.id } }, + }); + return activation?.isActive === true; +} +``` +Für `getAccessibleModuleIds` gilt dasselbe Muster: erst Rollen-Kurzschluss (D-03), dann eine einzige Query mit `group: { memberships: { some: { userId } } }` statt einer Schleife über Gruppen (Pitfall 3 aus RESEARCH.md) — kein Analog im Bestandscode für die Nested-`some`-Query, aber der übrige Query-Stil (destructured `where`, `select`) ist 1:1 aus `findActiveForTenant` (Zeilen 35-47) übernehmbar. + +**Fehlerbehandlung:** Kein `try/catch` im Service selbst — Prisma-Fehler propagieren zum Controller/Guard, exakt wie im gesamten `module-registry.service.ts`. + +--- + +### `apps/api/src/module-registry/module.guard.ts` (middleware, request-response) + +**Analog:** sich selbst — der bestehende Guard wird erweitert, nicht ersetzt. + +**Voller Bestandscode als Ausgangsbasis** (komplette Datei, 80 Zeilen): +```typescript +@Injectable() +export class ModuleGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly moduleRegistryService: ModuleRegistryService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const moduleSlug = this.reflector.getAllAndOverride( + MODULE_SLUG_KEY, + [context.getHandler(), context.getClass()], + ); + if (!moduleSlug) return true; + + const request = context.switchToHttp().getRequest(); + const tenantId = request.tenantId ?? request.user?.tenantId; + if (!tenantId) throw new ForbiddenException('No tenant context'); + + const isActive = await this.moduleRegistryService.isModuleActive(tenantId, moduleSlug); + if (!isActive) { + throw new ForbiddenException(`Module '${moduleSlug}' is not activated for this tenant`); + } + return true; + } +} +``` +**Erweiterung (D-01):** `isModuleActive`-Aufruf wird zu `ModuleAccessService.getAccessibleModuleIds(tenantId, userId, role)` + `.has(module.id)`-Prüfung; `userId`/`role` kommen aus `request.user`, exakt derselbe Herkunftsweg wie `tenantId` heute (`request.user?.tenantId`). `@UseModule(slug)`-Dekorator (Zeilen 66-80) bleibt unverändert. + +--- + +### `apps/api/src/module-registry/module-registry.controller.ts` (`findActive`, controller, request-response) + +**Analog:** sich selbst. + +**Imports pattern** (Zeilen 1-14): +```typescript +import { Controller, ForbiddenException, Get, Param, Post, Req, UseGuards } from '@nestjs/common'; +import { Role } from '@prisma/client'; +import { Request } from 'express'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { ModuleRegistryService } from './module-registry.service'; +``` + +**Auth/Guard-Pattern** (Zeilen 62-64, für Admin-geschützte Routen wie Group-/Grant-CRUD): +```typescript +@Post(':moduleId/activate') +@UseGuards(RolesGuard) +@Roles(Role.ADMIN, Role.SUPER_ADMIN) +``` + +**Core Pattern — `findActive`** (Zeilen 47-54): +```typescript +@Get('active') +async findActive(@Req() req: Request) { + const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId; + if (!tenantId) throw new ForbiddenException('No tenant context'); + return this.moduleRegistryService.findActiveForTenant(tenantId); +} +``` +**Erweiterung (D-01):** ruft zusätzlich `userId`/`role` aus `req.user` ab und delegiert an `ModuleAccessService.getAccessibleModuleIds`, ersetzt die reine `tenantId`-Filterung. + +--- + +### `apps/api/src/groups/groups.controller.ts` und `module-grants.controller.ts` (controller, request-response) + +**Analog:** `apps/api/src/module-registry/module-registry.controller.ts` + +Gleiches Muster wie oben: `tenantId` aus `req.tenantId ?? req.user?.tenantId` (nie aus Body/Params, T-03-04), `@UseGuards(RolesGuard)` + `@Roles(Role.ADMIN, Role.SUPER_ADMIN)` für alle schreibenden Endpoints. **Zusätzliche Sicherheitsprüfung** (aus RESEARCH.md Security Domain, Cross-Tenant-Grant-Injection): jede referenzierte `groupId`/`userId` in `ModuleGrant`-Erstellungsrouten muss servicetseitig gegen `tenantId` verifiziert werden (kein Analog im Bestandscode — neue, explizite Prüfung analog zum IDOR-Schutz in `DashboardService.removeWidget`, Zeilen 150-159, das per `widget.userId !== userId` scoped statt der `tenantId` blind zu vertrauen). + +--- + +### `apps/api/src/ldap/ldap.service.ts` (`syncUsersForTenant`-Erweiterung, service, event-driven) + +**Analog:** `collectSearchEntries` im selben File (Zeilen 737-795) — bereits das exakte Reverse-Query-Muster. + +**Core Pattern — `memberOf`-Filter statt Attribut-Lesen** (Zeilen 753-763): +```typescript +let baseFilter = sanitizedFilter; +if (groupDns.length > 0) { + const memberOfClauses = groupDns + .map((dn) => `(memberOf=${LdapService.escapeLdapFilterValue(dn)})`) + .join(''); + baseFilter = `(&${sanitizedFilter}(|${memberOfClauses}))`; +} +``` +**Anwendung für D-19/D-21:** pro AD-gebundener `Group` (Feld `ldapDn` gesetzt) eine Suche mit Filter `(&(objectClass=person)(memberOf=))` über `parseBaseDns(config.baseDn)` (Zeile 743) — dieselbe `Client.search()`-Mechanik, einzelne DN statt Liste. Ergebnis wird zu `GroupMembership(source: LDAP)`-Upserts; jede vorhandene `LDAP`-Mitgliedschaft außerhalb des Ergebnisses wird gelöscht (Filter explizit auf `source: 'LDAP'`), `MANUAL`-Mitgliedschaften bleiben unberührt. + +**Escape-Utility** (Zeile 834, `escapeLdapFilterValue`) — wiederzuverwenden ohne Änderung. + +--- + +### `apps/api/src/user/user.service.ts` (`create()`-Erweiterung, service, CRUD) + +**Analog:** sich selbst. + +**Core Pattern — bestehende `create()`** (Zeilen 32-50): +```typescript +async create(data: { + username: string; + email: string; + password?: string; + displayName?: string; + role?: 'SUPER_ADMIN' | 'ADMIN' | 'USER'; + tenantId: string; + mustChangePassword?: boolean; + ldapDn?: string; +}) { + const { password, ...rest } = data; + return this.prisma.user.create({ + data: { + ...rest, + username: rest.username.toLowerCase(), + passwordHash: password ? await argon2.hash(password) : null, + }, + }); +} +``` +**Erweiterung (D-11/D-12, Pattern 6 aus RESEARCH.md):** nach dem `prisma.user.create(...)`-Aufruf zusätzlich die als Standard markierte Gruppe des Mandanten (`isDefault: true`) suchen und `GroupMembership(source: MANUAL)` anlegen, falls vorhanden — an genau dieser einen Stelle, weil sowohl `LdapService.upsertMappedUser` als auch `LdapService.importUsersByDn` und der Admin-`UsersController` ausschließlich hierüber Benutzer erzeugen. + +--- + +### `apps/api/src/dashboard/dashboard.service.ts` (`getWidgets`-Erweiterung, service, CRUD) + +**Analog:** sich selbst. + +**Core Pattern — bestehende `getWidgets`** (Zeilen 94-99): +```typescript +async getWidgets(userId: string) { + return this.prisma.widgetInstance.findMany({ + where: { userId }, + orderBy: { createdAt: 'asc' }, + }); +} +``` +**Ownership-Check-Muster für IDOR-Schutz** (Zeilen 150-159, `removeWidget`, als Vorbild für jede neue Group-/Grant-Lookup-Query mit zusätzlichem `tenantId`-Filter): +```typescript +const widget = await this.prisma.widgetInstance.findUnique({ where: { id } }); +if (!widget || widget.userId !== userId) { + throw new NotFoundException(`Widget with id '${id}' not found`); +} +``` +**Erweiterung (D-22):** `getWidgets(userId, tenantId, role)` filtert nach dem `findMany` zusätzlich per `WIDGET_MODULE_MAP`: Widgets mit `widgetType` in der Map werden gegen `ModuleAccessService.getAccessibleModuleIds` geprüft, alle anderen (aktuell alle 8 bestehenden Typen) ungefiltert durchgereicht. + +--- + +### `apps/web/src/lib/module-access-actions.ts` (utility, request-response) + +**Analog:** `apps/web/src/lib/auth-actions.ts::fetchCurrentUser()` (Zeilen 243-268) + +**Vollständiges Kopiermuster:** +```typescript +export async function fetchCurrentUser(): Promise { + const cookieStore = await cookies(); + const session = cookieStore.get('session')?.value; + if (!session) return null; + try { + 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(); + } catch { + return null; + } +} +``` +`checkModuleAccess(moduleSlug)` übernimmt exakt dieses Cookie-Weiterleitungs- und `try/catch`-Fail-closed-Muster, ruft `GET /modules/active` statt `/auth/me` auf (siehe RESEARCH.md Open Question 1 — kein neuer Endpoint für den ersten Wurf). + +--- + +### `apps/web/src/app/(portal)/admin/groups/page.tsx` (component, CRUD) + +**Analog 1 (Gerüst, Tabelle, Modals):** `apps/web/src/app/(portal)/admin/users/page.tsx` — komplette Datei als Struktur-Vorlage. + +**Imports pattern** (Zeilen 1-7): +```typescript +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { useAuthStore } from '@/lib/stores/auth-store'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; +``` + +**Access-Guard-Pattern** (Zeilen 51-53, 154-160): +```typescript +const hasAccess = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN'; +// ... +if (!hasAccess) { + return ( +
+

{tCommon('accessDenied')}

+
+ ); +} +``` + +**Tabellen-Pattern** (Zeilen 192-267, Kopf + Body): +```typescript +
+ + + + + + + + {users.map((user) => ( + + ... + + ))} + +
...
+
+``` + +**Create/Edit-Modal-Overlay-Pattern** (Zeilen 271-273): +```typescript +{showForm && ( +
+
+``` + +**Lösch-Dialog-Pattern** (Zeilen 383-405) — für PERM-01/D-17 um konkrete Zahlen (`{memberCount}`, `{grantCount}`) als next-intl-Interpolation erweitern statt der generischen Ein-Satz-Warnung: +```typescript +{deleteConfirm && ( +
+
+

{t('deleteConfirm')}

+
+ + +
+
+
+)} +``` + +**Fehlerbehandlung:** durchgehend "silent fail" (`catch { /* silently fail */ }`, Zeilen 63-65, 134-136, 149-151) — für PERM-01 laut UI-SPEC Backstop-Punkt zu prüfen, ob dieser Präzedenzfall für den Lösch-Dialog beibehalten wird oder eine sichtbare Fehlermeldung nötig ist (offene Entscheidung für den Planner, siehe UI-SPEC "UI Considerations"). + +**Analog 2 (AD-Gruppen-Discovery, Radio statt Checkbox):** `apps/web/src/app/(portal)/admin/ldap/page.tsx`, Zeilen 705-746: +```typescript +{discovered && discovered.length > 0 && ( +
+ setDiscoverSearch(e.target.value)} + placeholder={t('groupFilter.searchPlaceholder')} + className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm" + /> +
+ {filteredDiscovered.map((entry) => ( + + ))} +
+
+)} +``` +Für die Gruppen-AD-Bindung (D-05, D-18) `type="checkbox"` → `type="radio"` (genau eine AD-Gruppe pro Tessera-Gruppe), Rest des Markups unverändert übernehmbar. + +**Analog 3 (Mitglieder-Chip-Liste mit Entfernen-Button):** `admin/ldap/page.tsx`, Zeilen 957-974 (`userExcludeList`): +```typescript +
    + {userExcludeList.map((name) => ( +
  • + {name} + +
  • + ))} +
+``` +Für Gruppenmitglieder: Chip bekommt zusätzlich einen Herkunfts-Badge (`MANUAL`/`LDAP`); bei `LDAP`-Herkunft ist der Entfernen-Button `disabled` mit Tooltip "Wird über AD-Sync verwaltet" statt aktiv (D-19). + +**Analog 4 (Benutzer-Suche zum manuellen Hinzufügen):** `admin/ldap/page.tsx`, Zeilen 820-877 (`userSearchResults`), identisches Such-Input + Checkbox-Ergebnisliste, Ziel-Mutation wird `GroupMembership`-Erstellung statt LDAP-Import. + +--- + +### `apps/web/src/app/(portal)/admin/modules/grants/page.tsx` (component, CRUD — Permission-Matrix) + +**Analog 1 (optimistisches Toggle):** `apps/web/src/app/(portal)/admin/modules/page.tsx::toggleModule` (Zeilen 80-112): +```typescript +const toggleModule = async (moduleId: string, currentlyActive: boolean) => { + setToggling(moduleId); + setError(null); + try { + const action = currentlyActive ? 'deactivate' : 'activate'; + const res = await fetch(`${API_URL}/modules/${moduleId}/${action}`, { + method: 'POST', + credentials: 'include', + }); + if (res.ok) { + setActivations((prev) => { + const next = new Map(prev); + if (currentlyActive) next.delete(moduleId); else next.set(moduleId, true); + return next; + }); + bumpSidebarRefresh(); + } else { + const body = await res.text().catch(() => ''); + setError(`${res.status}: ${body}`); + } + } catch (err) { + setError(String(err)); + } finally { + setToggling(null); + } +}; +``` +Für die Matrix-Zelle: identisches Muster, aber PATCH gegen `ModuleGrant`-Endpoint mit `moduleId` + `groupId`; Fehlerfall setzt die Checkbox auf den Serverzustand zurück (UI-SPEC "partial"-Zeile für E2), zusätzlich zur Fehlermeldung — kein Vollseiten-Ladezustand, nur `isToggling === cellKey` als Inline-Spinner. + +**Fehler-Div-Pattern** (Zeilen 134-138): +```typescript +{error && ( +
+ {error} +
+)} +``` + +**Analog 2 (Suchfeld):** `admin/ldap/page.tsx` Zeile 707-713, identisches ``-Markup für die Matrix-Modul-/Gruppennamen-Filterung. + +--- + +### `apps/web/src/app/(portal)/admin/users/page.tsx` (erweitert — User-Detail-Grants, component, CRUD) + +**Analog:** sich selbst. + +Vierter Aktions-Button analog zu den bestehenden zwei (Zeilen 246-262): +```typescript + +``` +Neuer "Details"-Button im selben `px-2 py-1 text-xs`-Stil öffnet ein breiteres Modal (`max-w-2xl` statt `max-w-md`, gleiches Overlay-Muster wie Zeilen 271-273). Chip-Liste für Gruppenmitgliedschaften folgt demselben `userExcludeList`-Muster aus `admin/ldap/page.tsx` (Zeilen 957-974), aber ohne Entfernen-Button (read-only, D-16: Bearbeitung nur unter `/admin/groups`). + +--- + +### `apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx` (umgebaut, component, request-response) + +**Analog:** sich selbst — bestehender Not-Found-Block als visuelles Vorbild für die 403-Seite. + +**Bestehendes Not-Found-Markup** (Zeilen 30-64), 1:1 Struktur-Vorlage für den 403-Zustand (nur Icon + Copy ändern sich, D-07): +```typescript +
+
+
+ + {/* Not-Found: Kreis mit Schrägstrich — für 403 stattdessen Schloss-Icon */} + +
+

{t('notFound')}

+

{t('notFoundDescription')}

+ + {t('backToCategory')} + +
+
+``` +**403-Variante:** Titel/Body aus Copywriting Contract (`modules.accessDenied.title`/`.body`, wörtlich D-07), Rücklink zu `/` statt `/modules/${category}` (Copywriting Contract, bewusste Abweichung vom Not-Found-Muster). + +**Umbau-Pattern (Server-Component-Split):** aktuell komplett `'use client'` (Zeile 1) ohne Server-Datenfetch. Der bisherige Inhalt (Zeilen 1-Ende, `loadModuleComponent`/`MODULE_REGISTRY`-Whitelist, T-03-09-Sicherheitskommentar) wandert unverändert in `module-shell.tsx`; `page.tsx` wird `async`-Server-Component nach dem `fetchCurrentUser()`-Cookie-Muster (siehe `module-access-actions.ts` oben), ruft `checkModuleAccess(moduleSlug)` auf, rendert bei `false` das 403-Markup direkt (kein `notFound()`, kein Redirect — D-07 explizit), bei `true` ``. + +--- + +### `apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx` (erweitert, component, request-response) + +**Analog:** sich selbst. + +**Status-Badge-Reihe** (Zeilen 96-110): +```typescript +
+ {category} + + {isActive ? t('statusActive') : t('statusAvailable')} + +
+``` +**Erweiterung (D-08):** drittes Badge in derselben `flex items-center gap-2`-Reihe, wenn `isActive && !hasGrant && role === 'USER'`: `bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400` mit Text `t('statusNoAccess')`. + +**Kartenzustand + Klick-Verhalten** (Zeilen 88, 120-138) — äußerer Container bekommt bei `isActive && !hasGrant` `opacity-60 cursor-not-allowed` statt `hover:shadow-md hover:border-primary/30`; Klick löst Toast statt Navigation aus (bestehende `Toast.tsx`-Komponente im Marketplace-Ordner, nicht separat gelesen, da laut UI-SPEC bereits vorhanden und wiederzuverwenden ohne Änderung). + +--- + +### `apps/web/src/components/admin/admin-sidebar.tsx` (erweitert, component, request-response) + +**Analog:** sich selbst. + +**Item-Array-Pattern** (Zeilen 14-74), ein Eintrag als Vorlage: +```typescript +{ + label: t('admin.modules'), + href: '/admin/modules', + show: true, + icon: ( + + + + ), +}, +``` +Neuer sechster Eintrag `{ label: t('admin.groups'), href: '/admin/groups', show: true, icon: <...Roster-Icon.../> }`; `Link`-Rendering (Zeilen 91-104) bleibt unverändert, da es das Array generisch iteriert — keine strukturelle Änderung außer dem neuen Item + neuem i18n-Key `admin.groups`. + +--- + +## Shared Patterns + +### Tenant-Scoping (T-03-04) +**Source:** `apps/api/src/module-registry/module-registry.controller.ts`, Zeilen 49, 69, 89 +**Apply to:** `groups.controller.ts`, `module-grants.controller.ts`, jeder neue Modul-geschützte Endpoint +```typescript +const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId; +if (!tenantId) throw new ForbiddenException('No tenant context'); +``` +`tenantId` kommt IMMER aus dem JWT/Request, nie aus Body/Params. Für Group-/Grant-Erstellung zusätzlich: referenzierte `groupId`/`userId` servicetseitig gegen dasselbe `tenantId` verifizieren (Cross-Tenant-Grant-Injection-Schutz, siehe RESEARCH.md Security Domain). + +### RolesGuard + @Roles-Dekorator +**Source:** `apps/api/src/module-registry/module-registry.controller.ts`, Zeilen 62-64 +**Apply to:** alle schreibenden Group-/ModuleGrant-Endpoints +```typescript +@UseGuards(RolesGuard) +@Roles(Role.ADMIN, Role.SUPER_ADMIN) +``` + +### Optimistisches Toggle mit Rollback bei Fehler +**Source:** `apps/web/src/app/(portal)/admin/modules/page.tsx`, `toggleModule` (Zeilen 80-112) +**Apply to:** Matrix-Zellen-Checkbox, User-Detail-"Direkt"-Checkbox +Zustand wird sofort optimistisch aktualisiert, bei `!res.ok` zurückgesetzt und Fehlermeldung im `error`-State-Div (Zeilen 134-138) angezeigt. + +### Modal-Overlay-Grundmuster +**Source:** `apps/web/src/app/(portal)/admin/users/page.tsx`, Zeilen 271-273, 384-385 +**Apply to:** Gruppen-Create/Rename-Modal, Mitglieder-Modal, User-Detail-Grant-Modal, Lösch-Dialog +```typescript +
+
+``` +Breitenvarianten: `max-w-md` (Standardformular), `max-w-sm` (Lösch-Dialog/Aktivierungs-Dialog), `max-w-2xl` (User-Detail, mehr Inhalt). + +### Server-seitige Cookie-Weiterleitung für Server Actions +**Source:** `apps/web/src/lib/auth-actions.ts`, `fetchCurrentUser()` (Zeilen 243-268) +**Apply to:** `module-access-actions.ts::checkModuleAccess()` +```typescript +const cookieStore = await cookies(); +const session = cookieStore.get('session')?.value; +if (!session) return null; // bzw. false für boolean-Checks +const response = await fetch(`${API_URL}/...`, { + headers: { Cookie: `session=${session}` }, + credentials: 'include', + cache: 'no-store', +}); +if (!response.ok) return null; +return await response.json(); +``` + +### `memberOf`-Reverse-Query statt Attribut-Lesen (Range-Retrieval vermeiden) +**Source:** `apps/api/src/ldap/ldap.service.ts::collectSearchEntries` (Zeilen 753-763) +**Apply to:** AD-Gruppenbindungs-Sync in `syncUsersForTenant` +```typescript +const memberOfClauses = groupDns.map((dn) => `(memberOf=${LdapService.escapeLdapFilterValue(dn)})`).join(''); +baseFilter = `(&${sanitizedFilter}(|${memberOfClauses}))`; +``` +Niemals `member`/`memberOf` als Rückgabeattribut verwenden (Pitfall 1 aus RESEARCH.md). + +### Hand-SQL-Migrationsergänzung (Constraint + Backfill in generierter migration.sql) +**Source:** `apps/api/prisma/migrations/20260618112133_rls_policies/migration.sql` +**Apply to:** die neue `add_groups_and_module_grants`-Migration (Entweder-oder-CHECK, partielle Unique-Indizes, D-06-Backfill) +```sql +ALTER TABLE "User" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "User" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_policy ON "User" + USING ("tenantId" = current_tenant_id()); +``` +Gleiches Verfahren (Statements manuell an die von `prisma migrate dev --create-only` generierte Datei anhängen) für `Group`/`GroupMembership`/`ModuleGrant`-RLS-Policies, falls Empfehlung A1 aus RESEARCH.md umgesetzt wird — Bezugsobjekt jeweils via `tenantId`-Spalte direkt (`Group`, `ModuleGrant`) bzw. via Join (`GroupMembership` über `groupId → Group.tenantId`), analog zum `PasswordResetToken`-Join-Beispiel (Zeilen 15-20 derselben Migration). + +### Ownership-/Tenant-Check vor jeder Lookup-Query (IDOR-Schutz) +**Source:** `apps/api/src/dashboard/dashboard.service.ts::removeWidget`/`updateWidgetConfig` (Zeilen 119-164) +**Apply to:** jede `DELETE`/`PATCH`-Route in `groups.service.ts`/`module-grants.service.ts` +```typescript +const widget = await this.prisma.widgetInstance.findUnique({ where: { id } }); +if (!widget || widget.userId !== userId) { + throw new NotFoundException(`Widget with id '${id}' not found`); +} +``` + +## No Analog Found + +Keine Datei ohne Analog — jede der 20 klassifizierten Dateien hat mindestens einen role-match oder exact Analog im bestehenden Code. Für zwei Aspekte gibt es kein direktes Bestandsvorbild, nur strukturelle Nähe: + +| File/Aspekt | Role | Data Flow | Grund | +|-------------|------|-----------|-------| +| `Group`/`ModuleGrant`-RLS-Policy-SQL (falls Empfehlung A1 umgesetzt wird) | migration | batch | Kein bestehendes Beispiel für RLS über eine Nested-Join-Kette (`GroupMembership → Group.tenantId`) hinter zwei Fremdschlüsseln — `PasswordResetToken`-Join (ein Fremdschlüssel) ist die nächstliegende, aber nicht identische Vorlage | +| Cross-Tenant-Grant-Injection-Prüfung (`group.tenantId === tenantId` vor `ModuleGrant`-Insert) | service | CRUD | Kein bestehender Service prüft ein REFERENZIERTES Fremdobjekt gegen `tenantId` — bisherige Ownership-Checks (`DashboardService`) prüfen nur direktes `userId`-Eigentum, nicht eine zweite Tenant-Grenze über eine Relation | + +## Metadata + +**Analog search scope:** `apps/api/src/module-registry/`, `apps/api/src/ldap/`, `apps/api/src/user/`, `apps/api/src/dashboard/`, `apps/api/prisma/migrations/`, `apps/web/src/app/(portal)/admin/`, `apps/web/src/app/(portal)/modules/`, `apps/web/src/app/(portal)/marketplace/`, `apps/web/src/components/admin/`, `apps/web/src/lib/` +**Files scanned:** 14 (vollständig gelesen) + 6 (per Grep lokalisiert, UI-SPEC-Zitate gegen Zeilennummern verifiziert) +**Pattern extraction date:** 2026-08-04