Files
tessera-ctl/.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-PATTERNS.md
T
schalli 8e70f55c8c
Tessera CI/CD / Lint & Type Check (push) Successful in 42s
Tessera CI/CD / Tests (push) Successful in 50s
Tessera CI/CD / Build & Publish Images (push) Successful in 2m46s
docs(15): add pattern map from planning
2026-08-04 19:54:13 +02:00

581 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 15: Modul-Berechtigungen: Gruppen & User-Grants - 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/<neu>_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<boolean> {
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<boolean> {
const moduleSlug = this.reflector.getAllAndOverride<string>(
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=<Group.ldapDn>))` ü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<AuthUser | null> {
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 (
<div className="flex items-center justify-center min-h-[60vh]">
<p className="text-lg text-muted-foreground">{tCommon('accessDenied')}</p>
</div>
);
}
```
**Tabellen-Pattern** (Zeilen 192-267, Kopf + Body):
```typescript
<div className="overflow-x-auto rounded-md border border-border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">...</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{users.map((user) => (
<tr key={user.id} className="hover:bg-muted/30 transition-colors">
...
</tr>
))}
</tbody>
</table>
</div>
```
**Create/Edit-Modal-Overlay-Pattern** (Zeilen 271-273):
```typescript
{showForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
```
**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 && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg">
<p className="text-sm text-foreground mb-4">{t('deleteConfirm')}</p>
<div className="flex justify-end gap-3">
<button onClick={() => setDeleteConfirm(null)} className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors">{tCommon('cancel')}</button>
<button onClick={() => handleDelete(deleteConfirm)} className="rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground hover:opacity-90 transition-opacity">{tCommon('delete')}</button>
</div>
</div>
</div>
)}
```
**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 && (
<div className="mb-4 space-y-2">
<input
type="text"
value={discoverSearch}
onChange={(e) => 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"
/>
<div className="max-h-64 overflow-y-auto rounded-md border border-border divide-y divide-border">
{filteredDiscovered.map((entry) => (
<label key={entry.dn} className="flex items-center gap-3 px-4 py-2 text-sm hover:bg-muted/30 cursor-pointer">
<input type="checkbox" checked={groupFilterDns.includes(entry.dn)} onChange={() => toggleGroupFilterDn(entry.dn)} />
<span className="rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">{entry.type === 'ou' ? t('groupFilter.typeOu') : t('groupFilter.typeGroup')}</span>
<span className="font-medium text-foreground">{entry.name}</span>
<span className="font-mono text-xs text-muted-foreground truncate">{entry.dn}</span>
</label>
))}
</div>
</div>
)}
```
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
<ul className="flex flex-wrap gap-2">
{userExcludeList.map((name) => (
<li key={name} className="flex items-center gap-2 rounded-md border border-border px-3 py-1.5 text-sm">
<span className="font-mono text-xs text-foreground">{name}</span>
<button type="button" onClick={() => handleRemoveExcludeUser(name)} className="shrink-0 rounded px-1.5 py-0.5 text-xs text-destructive hover:bg-destructive/10 transition-colors">
{t('fieldMapping.remove')}
</button>
</li>
))}
</ul>
```
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 && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
```
**Analog 2 (Suchfeld):** `admin/ldap/page.tsx` Zeile 707-713, identisches `<input type="text">`-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
<button
onClick={() => openEdit(user)}
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
>
{tCommon('edit')}
</button>
```
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
<div className="mx-auto max-w-2xl space-y-6 p-6">
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="rounded-lg bg-muted p-4 mb-4">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-muted-foreground">
{/* Not-Found: Kreis mit Schrägstrich — für 403 stattdessen Schloss-Icon */}
</svg>
</div>
<h2 className="text-xl font-semibold mb-2">{t('notFound')}</h2>
<p className="text-sm text-muted-foreground mb-6">{t('notFoundDescription')}</p>
<Link href={`/modules/${category}`} className="text-sm font-medium text-primary hover:underline">
{t('backToCategory')}
</Link>
</div>
</div>
```
**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` `<ModuleShell category={category} moduleSlug={moduleSlug} />`.
---
### `apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx` (erweitert, component, request-response)
**Analog:** sich selbst.
**Status-Badge-Reihe** (Zeilen 96-110):
```typescript
<div className="flex items-center gap-2 mt-1">
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">{category}</span>
<span role="status" className={`rounded-full px-2 py-0.5 text-xs ${isActive ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-muted text-muted-foreground'}`}>
{isActive ? t('statusActive') : t('statusAvailable')}
</span>
</div>
```
**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: (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="..." />
</svg>
),
},
```
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
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
```
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