32 KiB
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):
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):
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):
@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):
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):
@Post(':moduleId/activate')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
Core Pattern — findActive (Zeilen 47-54):
@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):
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):
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):
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):
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:
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):
'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):
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):
<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):
{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:
{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:
{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):
<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):
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):
{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):
<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):
<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):
<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:
{
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
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
@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
<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()
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
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)
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
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