--- phase: 15-modul-berechtigungen-gruppen-user-grants plan: 03 type: execute wave: 3 depends_on: ["15-01", "15-02"] files_modified: - apps/api/src/groups/module-grants.service.ts - apps/api/src/groups/module-grants.controller.ts - apps/api/src/groups/module-grants.service.spec.ts - apps/api/src/groups/dto/create-module-grant.dto.ts - apps/api/src/groups/groups.module.ts - apps/api/src/module-registry/module-registry.controller.ts - apps/api/src/module-registry/module-access.service.ts - apps/api/src/module-registry/module-access.service.spec.ts autonomous: true requirements: [PERM-03, PERM-04] user_setup: [] estimate: tokens: 62000 raw_tokens: 62000 tasks: 3 confidence: low must_haves: truths: - "Ein Admin kann ein mandantenweit aktives Modul gezielt für eine Gruppe und für einen einzelnen Benutzer freigeben und die Freigabe wieder entziehen (PERM-03)." - "Die Matrix-Daten liefern in einer Antwort alle aktiven Module, alle Gruppen des Mandanten und die bestehenden Gruppen-Grants als Paare (D-15)." - "Das Benutzer-Detail liefert je aktivem Modul, über welche Gruppen der Benutzer das Modul erbt und ob er zusätzlich einen Direkt-Grant hat (D-16)." - "Vor jedem Grant-Insert wird geprüft, dass die referenzierte Gruppe beziehungsweise der referenzierte Benutzer zum Mandanten aus dem JWT gehört — die tenantId allein aus dem Token zu übernehmen genügt nicht." - "Ein Grant zeigt auf genau eine Gruppe oder genau einen Benutzer; die DTO-Schicht lehnt beides-oder-keines mit HTTP 400 ab, bevor die CHECK-Constraint der Datenbank als letztes Netz greift." - "Ein Grant trägt keine Rechtestufe — Module können aus dem Datensatz nichts über read, write oder admin ableiten (D-04)." - "GET /modules/catalog liefert je registriertem Modul beide Flags in einer Antwort: ob es mandantenweit aktiv ist und ob der anfragende Benutzer Zugriff hat (D-08)." - "Änderungen an Freigaben werden ausschliesslich ins Server-Log geschrieben; es entsteht keine Audit-Tabelle und keine Ansicht im Admin-UI (D-23)." - "Ein Freigabe-Entzug wirkt in der API sofort, weil der Guard jede Anfrage neu auflöst — die Sidebar zieht beim nächsten Seitenaufruf nach (D-09)." # --- Edge-Probe PERM-03 (5 Kategorien) --- - "adjacency/PERM-03: Hält ein Benutzer für dasselbe Modul gleichzeitig einen Gruppen-Grant und einen Direkt-Grant, entzieht das Entfernen eines der beiden den Zugriff nicht — die Auflösung ist die Vereinigungsmenge, es gibt keinen Vorrang." - "empty/PERM-03: Ein Mandant ohne Gruppen oder ohne aktive Module erhält leere Listen mit HTTP 200, nicht HTTP 404." - "ordering/PERM-03: Die Matrix-Antwort liefert Module nach category und dann name aufsteigend sowie Gruppen nach name aufsteigend, über wiederholte Aufrufe deterministisch." - "idempotency/PERM-03: Ein zweiter Grant auf dieselbe Kombination erzeugt keinen zweiten Datensatz und keinen HTTP 500; ein zweites Entziehen eines bereits entzogenen Grants antwortet HTTP 204 ohne Fehler." - "concurrency/PERM-03: Zwei parallele Grant-Erstellungen für dieselbe Kombination führen wegen des partiellen Unique-Index zu genau einer Zeile; der unterlegene Request wird als Erfolg behandelt, nicht als HTTP 500." artifacts: - "apps/api/src/groups/module-grants.service.ts — ModuleGrantsService" - "apps/api/src/groups/module-grants.controller.ts — ModuleGrantsController" - "apps/api/src/groups/dto/create-module-grant.dto.ts" - "apps/api/src/groups/module-grants.service.spec.ts" - "apps/api/src/module-registry/module-registry.controller.ts — neuer Handler findCatalog (GET /modules/catalog)" key_links: - "ModuleGrantsService → dieselben ModuleGrant-Zeilen, die ModuleAccessService liest — eine Schreib- und eine Leseseite auf einem Datensatz" - "ModuleGrantsService.assertBelongsToTenant → der Schutz gegen mandantenübergreifende Freigaben, ohne den ein Admin einem fremden Benutzer Zugriff verschaffen könnte" - "GET /modules/catalog → MarketplaceCard (Plan 15-08) — beide Flags kommen in einer Antwort, damit keine Karte kurzzeitig ohne Sperrhinweis klickbar ist" - "GroupsModule bindet ModuleGrantsController und ModuleGrantsService ein und importiert ModuleRegistryModule" --- Die Schreibseite der Freigaben: Grants für Gruppen und für einzelne Benutzer anlegen und entziehen, die Datenlieferung für die Freigabe-Matrix und für das Benutzer-Detail, und ein Katalog-Endpoint, der dem Marketplace beide Statusinformationen in einer Antwort liefert. Purpose: Plan 15-01 hat die Leseseite gebaut — ohne eine Schreibseite gibt es nichts zu lesen, und ohne die Mandanten-Gegenprüfung wäre die Schreibseite selbst der Weg, das ganze Modell auszuhebeln: ein Admin könnte einen Grant auf eine Gruppe eines fremden Mandanten legen. Der Katalog-Endpoint ist nötig, weil `GET /modules/active` seit 15-01 benutzergefiltert antwortet und der Marketplace laut D-08 gerade auch die nicht freigegebenen Module weiter anzeigen soll. Output: `ModuleGrantsService` und `ModuleGrantsController`, ein DTO, eine Testsuite und der neue Handler `GET /modules/catalog`. @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.planning/PROJECT.md @.planning/STATE.md @.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-CONTEXT.md @.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-RESEARCH.md @.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-PATTERNS.md @.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-01-SUMMARY.md @.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-02-SUMMARY.md Task 1: ModuleGrantsService — Freigaben setzen und entziehen mit Mandanten-Gegenprüfung apps/api/src/groups/module-grants.service.ts, apps/api/src/groups/module-grants.service.spec.ts, apps/api/src/groups/dto/create-module-grant.dto.ts - apps/api/src/module-registry/module-registry.service.ts — `activateForTenant`/`deactivateForTenant` als Vorbild für Upsert plus Existenzprüfung des referenzierten Objekts - apps/api/src/groups/groups.service.ts — der in 15-02 etablierte Stil dieses Verzeichnisses, insbesondere der explizite tenantId-Filter je Lookup - apps/api/src/dashboard/dashboard.service.ts — Zeilen 119–164, das Ownership-Prüfmuster vor einer Mutation - apps/api/prisma/schema.prisma — das Modell `ModuleGrant` mit seinen zwei optionalen Fremdschlüsseln - apps/api/prisma/migrations/*_add_groups_and_module_grants/migration.sql — die CHECK-Constraint `ModuleGrant_group_xor_user` und die beiden partiellen Unique-Indizes, deren Fehlerbild der Service abfangen muss - .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-RESEARCH.md — Pitfall 2 zur DTO-Vorabprüfung und der Abschnitt "Security Domain" zur Cross-Tenant-Grant-Injection - `grant(tenantId, { moduleId, groupId })` legt einen Grant an und gibt ihn zurück. - `grant(tenantId, { moduleId, userId })` legt einen Direkt-Grant an und gibt ihn zurück. - `grant` mit gesetztem groupId UND userId wirft BadRequestException. - `grant` ohne groupId und ohne userId wirft BadRequestException. - `grant` mit einer groupId aus einem anderen Mandanten wirft NotFoundException und legt nichts an. - `grant` mit einer userId aus einem anderen Mandanten wirft NotFoundException und legt nichts an. - `grant` mit einer moduleId, zu der keine aktive `TenantModuleActivation` des Mandanten existiert, wirft BadRequestException — ein Grant auf ein nicht aktiviertes Modul wäre wirkungslos (D-02). - `grant` auf eine bereits bestehende Kombination gibt den bestehenden Datensatz zurück, ohne einen zweiten anzulegen; ein Prisma-Fehlercode P2002 wird als Erfolg behandelt. - `revoke(tenantId, { moduleId, groupId })` entfernt den Grant. - `revoke` auf eine Kombination ohne bestehenden Grant ist folgenlos und wirft nicht. - `revoke` mit einer groupId aus einem anderen Mandanten entfernt nichts. - `getMatrix(tenantId)` liefert `{ modules, groups, grants }` mit den aktiven Modulen nach category und name sortiert, den Gruppen nach name sortiert und den Gruppen-Grants als Paare aus moduleId und groupId. - `getMatrix` eines Mandanten ohne Gruppen liefert eine leere Gruppenliste und wirft nicht. - `getUserAccess(tenantId, userId)` liefert je aktivem Modul die Namen der Gruppen, über die der Benutzer es erbt, und ein Kennzeichen, ob ein Direkt-Grant besteht. - `getUserAccess` mit einer userId aus einem anderen Mandanten wirft NotFoundException. - Jede erfolgreiche `grant`- und `revoke`-Operation schreibt eine Logzeile mit Mandant, Modul, Ziel und Aktion. Lege `apps/api/src/groups/module-grants.service.ts` mit `ModuleGrantsService` an, injiziert wird `PrismaService`, und ein `Logger` wird als Klassenfeld gehalten. Zentral ist eine private Methode `assertTargetBelongsToTenant(tenantId, groupId?, userId?)`. Sie prüft für die gesetzte Referenz per `findFirst` mit `id` UND `tenantId`, dass das Zielobjekt zum Mandanten aus dem JWT gehört, und wirft andernfalls `NotFoundException`. Diese Prüfung ist der Kern des Plans: die tenantId stammt zwar aus dem Token und ist damit vertrauenswürdig, aber `groupId` und `userId` kommen aus dem Request-Body eines Admin-Clients. Ohne die Gegenprüfung könnte ein Admin eines Mandanten einen Grant auf eine Gruppe oder einen Benutzer eines anderen Mandanten legen und darüber Zugriff verschaffen. Im Bestandscode gibt es dafür kein Vorbild — die bisherigen Ownership-Prüfungen betreffen nur direktes Eigentum, nicht eine zweite Mandantengrenze über eine Relation. `grant` prüft in dieser Reihenfolge: Entweder-oder der beiden Referenzen (sonst `BadRequestException` mit Klartext, damit das Admin-UI nicht den rohen Postgres-Constraint-Namen zu sehen bekommt), dann `assertTargetBelongsToTenant`, dann die aktive `TenantModuleActivation` des Mandanten für die moduleId, dann `create`. Ein `P2002` aus dem partiellen Unique-Index wird abgefangen und in eine Rückgabe des bestehenden Datensatzes übersetzt — zwei parallele Klicks auf dieselbe Matrix-Zelle dürfen keinen HTTP 500 erzeugen. `revoke` nutzt `deleteMany` mit `where: { tenantId, moduleId, groupId }` beziehungsweise `userId`. `deleteMany` ist hier bewusst gewählt statt `delete`: es ist folgenlos, wenn nichts passt, und braucht keinen vorherigen Lookup. Die tenantId im where ist gleichzeitig der IDOR-Schutz. `getMatrix(tenantId)` liefert in einer Antwort drei Listen: die aktiven Module des Mandanten (über `tenantModuleActivation` mit `isActive: true` und `include: { module: true }`, sortiert nach category und dann name), die Gruppen des Mandanten nach name sortiert, und alle `moduleGrant`-Zeilen des Mandanten mit gesetztem `groupId`, reduziert auf die Paare moduleId und groupId. Die Sortierung wird explizit gesetzt, damit die Spalten- und Zeilenreihenfolge der Matrix über Aufrufe hinweg stabil bleibt. `getUserAccess(tenantId, userId)` prüft zuerst, dass der Benutzer zum Mandanten gehört, und liefert dann je aktivem Modul einen Eintrag mit dem Modul, den Namen der Gruppen, die dem Benutzer dieses Modul gewähren (aufgelöst über eine einzige Query mit `group: { memberships: { some: { userId } } }`), und einem Kennzeichen für einen bestehenden Direkt-Grant. Die geerbten Rechte sichtbar zu machen ist der eigentliche Zweck aus D-16 — ohne diese Anzeige ist im Benutzer-Detail nicht erkennbar, warum jemand Zugriff hat. Jede erfolgreiche Mutation schreibt eine Logzeile über den `Logger`. Das ist die vollständige Umsetzung von D-23: Änderungen an Freigaben landen im Server-Log, es entsteht bewusst keine Audit-Tabelle und keine Ansicht im Admin-UI. Der Datensatz bekommt kein Feld für eine Rechtestufe, und der Service bietet keine Methode, die eine solche setzen könnte (D-04). Lege `create-module-grant.dto.ts` an: `moduleId` als `@IsString()` und `@IsNotEmpty()`, `groupId` und `userId` jeweils optional als String. Die Entweder-oder-Regel wird im Service geprüft, weil sie zwei Felder zueinander in Beziehung setzt; das DTO deckt die Feldtypen ab. Lege `module-grants.service.spec.ts` an, das jeden unter `` genannten Fall mit gemocktem PrismaService abdeckt. pnpm --filter @tessera/api test -- module-grants.service - `pnpm --filter @tessera/api test -- module-grants.service` ist grün und enthält für jeden unter `` gelisteten Fall ein eigenes `it(...)`. - `grep -c 'assertTargetBelongsToTenant' apps/api/src/groups/module-grants.service.ts` gibt mindestens `3` aus (Definition plus Aufruf in grant und in getUserAccess beziehungsweise revoke). - `grep -c 'P2002' apps/api/src/groups/module-grants.service.ts` gibt mindestens `1` aus — der Doppelklick-Fall ist behandelt. - `grep -c 'this.logger' apps/api/src/groups/module-grants.service.ts` gibt mindestens `2` aus (grant und revoke). - `grep -cE '(moduleId|groupId|userId)[?]?: string' apps/api/src/groups/dto/create-module-grant.dto.ts` gibt `3` aus, und `grep -cE '^\s+[a-zA-Z]+[?]?:' apps/api/src/groups/dto/create-module-grant.dto.ts` gibt ebenfalls `3` aus — das DTO trägt genau diese drei Felder und kein viertes. - `pnpm --filter @tessera/api run type-check` läuft fehlerfrei durch. Freigaben lassen sich für Gruppen und Benutzer setzen und entziehen, mandantenübergreifende Ziele werden abgelehnt, und Matrix wie Benutzer-Detail bekommen ihre Daten in je einer Antwort. Task 2: ModuleGrantsController und Einbindung in GroupsModule apps/api/src/groups/module-grants.controller.ts, apps/api/src/groups/groups.module.ts - apps/api/src/module-registry/module-registry.controller.ts — das tenantId-aus-Request-Muster und `@UseGuards(RolesGuard)` plus `@Roles(Role.ADMIN, Role.SUPER_ADMIN)` - apps/api/src/groups/groups.controller.ts — der in 15-02 etablierte Routenstil dieses Verzeichnisses, inklusive der Reihenfolge statischer Segmente vor Parameter-Routen - apps/api/src/groups/groups.module.ts — der in 15-02 entstandene Modulaufbau, der hier um Controller und Service ergänzt wird - apps/api/src/groups/module-grants.service.ts — die in Task 1 entstandenen Methodensignaturen Lege `apps/api/src/groups/module-grants.controller.ts` mit `ModuleGrantsController` unter dem Pfad `module-grants` an. Routen: `GET /module-grants/matrix` liefert `getMatrix`, `GET /module-grants/users/:userId` liefert `getUserAccess`, `POST /module-grants` legt einen Grant an, `DELETE /module-grants` entzieht einen (Ziel im Body, weil die Kombination aus drei Feldern besteht und nicht sinnvoll in einen Pfadparameter passt). Statische Segmente stehen vor Parameter-Routen: `matrix` wird vor `users/:userId` deklariert. Dieses Projekt hat den Beschattungsfehler schon einmal gehabt und Unit-Tests fangen ihn nicht. Jede Route liest `tenantId` mit `(req as any).tenantId ?? (req as any).user?.tenantId` und wirft bei fehlendem Kontext `ForbiddenException('No tenant context')`. Jede Route trägt `@UseGuards(RolesGuard)` und `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`. Ergänze `GroupsModule` um `ModuleGrantsController` in `controllers`, `ModuleGrantsService` in `providers` und `exports` sowie um den Import von `ModuleRegistryModule`, falls der Service dessen Registry-Methoden benötigt. pnpm --filter @tessera/api test -- module-grants && pnpm --filter @tessera/api run type-check - `grep -c '@Roles(Role.ADMIN, Role.SUPER_ADMIN)' apps/api/src/groups/module-grants.controller.ts` gibt `4` aus — jede der vier Routen ist rollengeschützt. - In `apps/api/src/groups/module-grants.controller.ts` steht die Deklaration von `matrix` vor der Deklaration von `users/:userId`, geprüft über die Zeilennummern aus `grep -n "matrix\|users/:userId"`. - `grep -c 'ModuleGrantsController\|ModuleGrantsService' apps/api/src/groups/groups.module.ts` gibt mindestens `4` aus. - Gegen die laufende lokale API: `GET /module-grants/matrix` als ADMIN liefert HTTP 200 mit den Schlüsseln `modules`, `groups` und `grants`; derselbe Aufruf als USER liefert HTTP 403; `POST /module-grants` mit einer groupId eines fremden Mandanten liefert HTTP 404. - `pnpm --filter @tessera/api run type-check` läuft fehlerfrei durch. Die Freigabe-Endpunkte sind erreichbar, rollengeschützt und in der Routenreihenfolge frei von Beschattung. Task 3: GET /modules/catalog — beide Statusflags in einer Antwort für den Marketplace apps/api/src/module-registry/module-registry.controller.ts, apps/api/src/module-registry/module-access.service.ts, apps/api/src/module-registry/module-access.service.spec.ts - apps/api/src/module-registry/module-registry.controller.ts — der in 15-01 auf Benutzersicht umgestellte `findActive`-Handler und der unveränderte `findAll` - apps/api/src/module-registry/module-registry.service.ts — `findAll` und `findActiveForTenant`, das nach 15-01 weiterhin die mandantenweite Sicht liefert - apps/api/src/module-registry/module-access.service.ts — `getAccessibleModuleIds` aus 15-01 - apps/web/src/app/(portal)/marketplace/page.tsx — die Zeilen 55–56, wo der Katalog heute aus zwei Aufrufen zusammengesetzt wird; dieser Handler ersetzt beide - .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-UI-SPEC.md — Surface Contract 5 und die Backstop-Zeile zum Marketplace-Kartenzustand Ergänze `ModuleRegistryController` um den Handler `findCatalog` unter `GET /modules/catalog`, erreichbar für jeden authentifizierten Benutzer wie `GET /modules`. Er liefert je registriertem Modul den vollständigen Modul-Datensatz plus zwei Flags: `isActiveForTenant` aus `TenantModuleActivation` mit `isActive: true` und `hasAccess` aus `ModuleAccessService.getAccessibleModuleIds`. Deklariere den Handler vor jeder Parameter-Route. Der Endpoint existiert, weil `GET /modules/active` seit 15-01 die Benutzersicht liefert, der Marketplace laut D-08 aber gerade auch nicht freigegebene Module weiter zeigen soll — gekennzeichnet, nicht ausgeblendet. Mit den bisherigen zwei Aufrufen könnte der Marketplace für einen USER nicht mehr zwischen "nicht aktiviert" und "aktiviert, aber nicht freigegeben" unterscheiden. Beide Flags kommen bewusst in derselben Antwort. Damit kann keine Karte kurzzeitig ohne Sperrhinweis klickbar erscheinen und erst nachträglich sperren — das ist die Auflösung der Backstop-Zeile aus dem UI-SPEC zum Marketplace-Kartenzustand, und sie gehört serverseitig gelöst, nicht durch Ladezustands-Akrobatik im Browser. Für ADMIN und SUPER_ADMIN ist `hasAccess` bei jedem aktiven Modul wahr, weil `getAccessibleModuleIds` den Rollen-Kurzschluss anwendet — das Sperr-Badge erscheint für sie nie (D-03). Ergänze in `module-access.service.ts` eine schmale öffentliche Methode `getCatalogFlags(tenantId, userId, role)`, die die beiden Mengen einmal auflöst und dem Controller zurückgibt, damit der Handler dünn bleibt und die Zugriffsentscheidung nicht im Controller nachgebaut wird. Ergänze `module-access.service.spec.ts` um Testfälle für `getCatalogFlags`: ein Modul, das aktiv und freigegeben ist; eines, das aktiv aber nicht freigegeben ist; eines, das nicht aktiviert ist; sowie derselbe Aufruf als ADMIN, bei dem jedes aktive Modul zugänglich ist. pnpm --filter @tessera/api test -- module-access.service - `pnpm --filter @tessera/api test -- module-access.service` ist grün und enthält die vier neuen `getCatalogFlags`-Fälle. - `grep -c "Get('catalog')" apps/api/src/module-registry/module-registry.controller.ts` gibt `1` aus. - `grep -c 'getCatalogFlags' apps/api/src/module-registry/module-access.service.ts apps/api/src/module-registry/module-registry.controller.ts` belegt Definition und Aufruf. - Gegen die laufende lokale API: `GET /modules/catalog` als USER mit einem aktivierten, aber nicht freigegebenen Modul liefert für dieses Modul `isActiveForTenant` wahr und `hasAccess` falsch; derselbe Aufruf als ADMIN liefert für dasselbe Modul beide Flags wahr. - `pnpm --filter @tessera/api test` läuft vollständig grün und `pnpm --filter @tessera/api run type-check` fehlerfrei durch. Der Marketplace kann in einem einzigen Aufruf zwischen nicht aktiviert, aktiviert-ohne-Freigabe und zugänglich unterscheiden. ## Trust Boundaries | Boundary | Description | |----------|-------------| | Admin-Client → ModuleGrantsController | `moduleId`, `groupId` und `userId` kommen aus dem Request-Body und sind nicht vertrauenswürdig; nur `tenantId` stammt aus dem JWT | | ModuleGrantsService → PostgreSQL | CHECK-Constraint und partielle Unique-Indizes aus 15-01 sind das letzte Netz hinter der App-Validierung | | Beliebiger authentifizierter Benutzer → GET /modules/catalog | Der Katalog ist laut D-08 bewusst für jeden angemeldeten Benutzer sichtbar | ## STRIDE Threat Register | Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | |-----------|----------|-----------|----------|-------------|-----------------| | T-15-01 | Elevation of Privilege / Tampering | `POST /module-grants` mit einer groupId oder userId eines fremden Mandanten | high | mitigate | `assertTargetBelongsToTenant` prüft das referenzierte Objekt per `findFirst` mit `id` UND `tenantId` gegen den Mandanten aus dem JWT und wirft sonst NotFoundException; zwei eigene Testfälle in `module-grants.service.spec.ts` belegen beide Richtungen | | T-15-02 | Information Disclosure / Tampering | `DELETE /module-grants` und `GET /module-grants/users/:userId` mit fremden IDs | high | mitigate | Jede Query trägt den tenantId-Filter aus dem JWT; `revoke` nutzt `deleteMany` mit tenantId im where, sodass ein fremdes Ziel schlicht null Zeilen trifft | | T-15-21 | Tampering | Rohe Postgres-Constraint-Fehler erreichen den Client | medium | mitigate | Die Entweder-oder-Regel wird im Service vor dem Insert geprüft und als BadRequestException mit Klartext gemeldet; P2002 wird abgefangen. Die DB-Constraint bleibt das letzte Netz, nicht der primäre Fehlerpfad | | T-15-08 | Information Disclosure | `GET /modules/catalog` zeigt jedem angemeldeten Benutzer den vollständigen Modulkatalog des Mandanten | low | accept | Bewusste Entscheidung aus D-08: der Katalog bleibt Schaufenster. Der Endpoint liefert ausschliesslich Modul-Metadaten und zwei Boolesche, keine Modulinhalte und keine Grant-Details anderer Benutzer | | T-15-09 | Repudiation | Freigabe-Änderungen sind nachträglich nicht in der Anwendung nachvollziehbar | low | accept | Bewusste Entscheidung aus D-23: Protokollierung ausschliesslich im Server-Log, keine Audit-Tabelle. Der Service schreibt je Mutation eine Logzeile mit Mandant, Modul, Ziel und Aktion; ein Audit-Trail in der Datenbank ist als spätere Nachrüstung vorgemerkt | - `pnpm --filter @tessera/api test` vollständig grün. - `pnpm --filter @tessera/api run type-check` fehlerfrei. - Manuell gegen die laufende lokale API: einen Gruppen-Grant setzen, mit einem Testbenutzer dieser Gruppe den geschützten Modul-Endpoint aufrufen (HTTP 200), den Grant entziehen, denselben Aufruf wiederholen (HTTP 403) — belegt D-09, dass der Entzug ohne Zwischenschritt wirkt. - Freigaben lassen sich für Gruppen und für einzelne Benutzer setzen und entziehen (PERM-03). - Matrix und Benutzer-Detail bekommen ihre Daten aus je einer Antwort, inklusive der über Gruppen geerbten Rechte (D-15, D-16). - Kein Grant kann auf ein Ziel eines fremden Mandanten zeigen. - Der Marketplace kann alle drei Kartenzustände aus einer Antwort ableiten (D-08, PERM-04). ## Artifacts this phase produces Von diesem Plan erzeugt beziehungsweise verändert: - `ModuleGrantsService` mit `grant`, `revoke`, `getMatrix`, `getUserAccess`, `assertTargetBelongsToTenant` - `ModuleGrantsController` mit `GET /module-grants/matrix`, `GET /module-grants/users/:userId`, `POST /module-grants`, `DELETE /module-grants` - `CreateModuleGrantDto` (`apps/api/src/groups/dto/create-module-grant.dto.ts`) - `ModuleRegistryController.findCatalog` (`GET /modules/catalog`) - `ModuleAccessService.getCatalogFlags` - `GroupsModule` (erweitert um Controller, Service und den Import von `ModuleRegistryModule`) - `apps/api/src/groups/module-grants.service.spec.ts` Die phasenweite Gesamtliste steht in `15-01-PLAN.md`. Erstelle `.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-03-SUMMARY.md`, wenn der Plan abgeschlossen ist.