# 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