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

32 KiB
Raw Blame History

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).

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