581 lines
32 KiB
Markdown
581 lines
32 KiB
Markdown
# 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
|