24 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| quick-260805-fok | 01 | execute | 1 |
|
true |
|
|
Dieser Plan verlegt die Invariante dorthin, wo Mandanten entstehen, und repariert einmalig die Installationen, die bereits ohne Gruppe dastehen.
Purpose: Eine frische Tessera-Installation ist ab dem ersten API-Start benutzbar, ohne dass jemand von Hand eine Gruppe anlegen und als Standard markieren muss. Output: ensureDefaultGroup(tenantId) auf GroupsService, aufgerufen aus beiden Mandanten-Entstehungspfaden, plus eine Startup-Reparatur über alle Mandanten ohne jede Gruppe.
<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>
@.planning/STATE.md @CLAUDE.mdAchtung: die Stack-Tabelle in CLAUDE.md ist veraltet — real laufen Prisma 6.19.3 und Next.js 15.5.19.
D-06 (Migration legt pro Mandant „Alle Benutzer" an), D-11/D-12 (jeder neue Benutzer wird
Mitglied der Standardgruppe), D-13 (die Markierung entscheidet, nicht der Name — der Admin
darf sie umhängen oder abschalten).
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-CONTEXT.md
Der Soll-Endzustand pro Mandant steht als SQL in den drei Backfill-INSERTs am Dateiende.
@apps/api/prisma/migrations/20260804130130_add_groups_and_module_grants/migration.sql
@apps/api/src/groups/groups.service.ts @apps/api/src/groups/groups.service.spec.ts @apps/api/src/user/admin-seed.service.ts @apps/api/src/user/user.service.ts @apps/api/src/tenant/tenant.service.ts
Task 1: GroupsService.ensureDefaultGroup(tenantId) + Spec apps/api/src/groups/groups.service.ts, apps/api/src/groups/groups.service.spec.ts - ensureDefaultGroup('t-neu') bei einem Mandanten ohne jede Gruppe legt genau eine Gruppe namens 'Alle Benutzer' mit isDefault:true an und gibt sie zurück. - Dabei werden alle Benutzer DIESES Mandanten als GroupMembership mit source MANUAL aufgenommen; ein Benutzer eines fremden Mandanten wird nicht Mitglied. - Für jede TenantModuleActivation des Mandanten mit isActive:true entsteht genau ein ModuleGrant auf die neue Gruppe (tenantId gesetzt, groupId gesetzt, userId null); eine Aktivierung mit isActive:false erzeugt keinen Grant, eine Aktivierung eines fremden Mandanten ebenfalls nicht. - Ein Mandant, der mindestens eine Gruppe hat, aber KEINE davon mit isDefault:true, wird nicht angefasst: Rückgabe null, keine neue Gruppe, keine neue Mitgliedschaft, kein neuer Grant (D-13 — die abgehängte Markierung ist eine Admin-Entscheidung). - Ein Mandant, der bereits eine Gruppe mit isDefault:true hat, wird ebenfalls nicht angefasst: Rückgabe null, Gruppenzahl unverändert. - Zwei aufeinanderfolgende Aufrufe für denselben Mandanten hinterlassen genau eine Gruppe und genau eine Mitgliedschaft pro Benutzer (Idempotenz über Neustarts hinweg). - Wirft group.create einen P2002 (paralleler Aufruf gewinnt das Rennen gegen den partiellen Index Group_one_default_per_tenant), wirft ensureDefaultGroup nicht, sondern liefert null. - Ein Mandant ganz ohne Benutzer und ohne aktive Module bekommt trotzdem die leere Standardgruppe. Ergänze GroupsService (apps/api/src/groups/groups.service.ts) um eine öffentliche Methode ensureDefaultGroup(tenantId: string), platziert direkt vor addUserToDefaultGroup, damit die beiden Standardgruppen-Methoden beieinanderstehen. Rückgabetyp: das angelegte Group-Objekt, oder null, wenn nichts zu tun war.Ablauf: Zuerst prisma.group.count mit where { tenantId }. Ist das Ergebnis groesser als 0, gib sofort null zurueck — der Wächter prüft AUSSCHLIESSLICH auf das Fehlen jeglicher Gruppe, niemals auf das Fehlen der Markierung. Begründe das im Doc-Kommentar mit D-13: ein Mandant, dessen Admin die Markierung abgehängt oder auf eine andere Gruppe umgehängt hat, hat Gruppen — ihn hier nachträglich zu bedienen würde diese Entscheidung überschreiben.
Ist die Gruppenzahl 0, führe die drei Schritte in einer interaktiven Transaktion aus (this.prisma.$transaction mit async-Callback und tx-Client — die Array-Form aus update() reicht nicht, weil Schritt 2 und 3 die id der in Schritt 1 erzeugten Gruppe brauchen). Reihenfolge und Semantik spiegeln die drei Backfill-INSERTs der Migration:
- tx.group.create mit data { tenantId, name: 'Alle Benutzer', isDefault: true }. Der Name ist zeichengleich zu dem in der Migration, damit reparierte und migrierte Installationen dieselbe Gruppe zeigen.
- tx.user.findMany mit where { tenantId } und select { id: true }; bei mindestens einem Treffer tx.groupMembership.createMany mit einem Eintrag je Benutzer (groupId der neuen Gruppe, userId, source: MembershipSource.MANUAL) und skipDuplicates: true. Das entspricht D-11/D-12 — dieselbe Herkunft, die addUserToDefaultGroup setzt.
- tx.tenantModuleActivation.findMany mit where { tenantId, isActive: true } und select { moduleId: true }; bei mindestens einem Treffer tx.moduleGrant.createMany mit einem Eintrag je Modul (tenantId, moduleId, groupId der neuen Gruppe, userId: null) und skipDuplicates: true. Das entspricht D-06.
Umschliesse den gesamten $transaction-Aufruf mit try/catch. Fange ausschliesslich err.code === 'P2002' ab und gib in diesem Fall null zurueck; alles andere wird weitergeworfen. Erklaere im Kommentar, dass der partielle Index Group_one_default_per_tenant aus 15-01 der eigentliche Durchsetzungspunkt bleibt und dieser Abfang nur den Fall abdeckt, dass zwei gleichzeitige Aufrufe (Startup-Reparatur und Mandanten-Anlage) beide eine Gruppenzahl von 0 gesehen haben.
Jede where- und data-Angabe traegt die uebergebene tenantId explizit; die Methode leitet sie nirgends aus einem gelesenen Datensatz oder einer Benutzereingabe ab. Bleib bei this.prisma mit expliziten Mandantenfiltern, wie der Rest der Datei — kein forTenant, keine RLS-Session-Variable.
Erweitere in apps/api/src/groups/groups.service.spec.ts den vorhandenen makeFakePrisma-Fake, ohne bestehende Tests zu brechen:
- group.count hinzufuegen: zaehlt die Gruppen mit passender tenantId.
- user.findMany so umbauen, dass es sowohl die bisherige Form (where.id.in kombiniert mit where.tenantId, genutzt von addMembers) als auch die neue Form (nur where.tenantId) bedient — where.id darf undefined sein, ohne dass der Zugriff auf .in fehlschlaegt.
- tenantModuleActivation.findMany ergaenzen (filtert ueber eine neue Map nach tenantId und, falls angegeben, isActive) plus einen __seedActivation-Helfer im Stil von __seedUser/__seedGrant.
- moduleGrant um findMany (filtert nach tenantId und/oder groupId) und createMany (mit skipDuplicates auf der Kombination tenantId+moduleId+groupId) erweitern; count bleibt wie es ist.
- group.create um eine zusaetzliche Pruefung ergaenzen, die einen P2002 wirft, wenn bereits eine Gruppe desselben Mandanten mit isDefault:true existiert und die neue Zeile ebenfalls isDefault:true traegt — damit bildet der Fake den partiellen Index Group_one_default_per_tenant nach.
- $transaction so erweitern, dass es beide Formen akzeptiert: bei einem Array wie bisher Promise.all, bei einer Funktion den Aufruf dieser Funktion mit dem Fake selbst als tx-Client.
Fuege danach am Dateiende einen Testblock fuer ensureDefaultGroup an, der jeden Punkt des behavior-Blocks abdeckt. Nutze fuer den P2002-Fall einen Mandanten, dessen isDefault-Gruppe direkt im Fake vorbelegt wird, waehrend group.count auf 0 gestellt ist (z.B. per Monkey-Patch von prisma.group.count auf eine Funktion, die 0 liefert) — so laesst sich das verlorene Rennen ohne echte Nebenlaeufigkeit nachstellen. pnpm --filter @tessera/api exec vitest run src/groups/groups.service.spec.ts pnpm --filter @tessera/api run type-check Alle Specs in groups.service.spec.ts sind gruen, inklusive der neuen ensureDefaultGroup-Tests fuer Anlage, Mitglieder, Grants, den Null-Gruppen-Waechter, den Nicht-Anfassen-Fall bei vorhandenen Gruppen ohne Markierung, Idempotenz und den P2002-Abfang. type-check ist sauber.
Task 2: Beide Mandanten-Entstehungspfade verdrahten + Startup-Reparatur + Specs apps/api/src/tenant/tenant.service.ts, apps/api/src/tenant/tenant.module.ts, apps/api/src/tenant/tenant.service.spec.ts, apps/api/src/user/admin-seed.service.ts, apps/api/src/user/admin-seed.service.spec.ts - TenantService.create legt den Mandanten an und ruft danach genau einmal groupsService.ensureDefaultGroup mit der id des neu angelegten Mandanten auf; der Rueckgabewert bleibt der Mandant. - Wirft ensureDefaultGroup, liefert TenantService.create den Mandanten trotzdem zurueck und wirft nicht (Fehler wird protokolliert). - AdminSeedService.onApplicationBootstrap ruft auf einer frischen Installation in genau dieser Reihenfolge auf: tenant.upsert, dann ensureDefaultGroup mit der id des Mandanten, erst danach user.create fuer den Super-Admin. - Fehlen die ENV-Variablen TESSERA_ADMIN_USER/EMAIL/PASSWORD, wird kein Benutzer angelegt, die Reparatur ueber alle Mandanten laeuft aber trotzdem. - Existiert der Admin-Benutzer bereits (der Fall auf dem Testserver), wird kein Benutzer angelegt, die Reparatur ueber alle Mandanten laeuft aber trotzdem. - Die Reparatur ruft ensureDefaultGroup fuer JEDEN von prisma.tenant.findMany gelieferten Mandanten auf, auch fuer solche, die nicht 'default' heissen. - Wirft ensureDefaultGroup fuer einen Mandanten, werden die uebrigen Mandanten trotzdem abgearbeitet und onApplicationBootstrap wirft nicht. - Ein zweiter onApplicationBootstrap-Lauf (Neustart) ruft ensureDefaultGroup erneut auf, loest aber keinen zusaetzlichen user.create aus. apps/api/src/tenant/tenant.service.ts: Injiziere GroupsService zusaetzlich zu PrismaService und lege einen Logger im Stil von UserService an (private readonly logger = new Logger(TenantService.name)). In create() das Ergebnis von prisma.tenant.create in eine Konstante nehmen, danach ensureDefaultGroup mit der id des neuen Mandanten in einem try/catch aufrufen, im catch mit logger.error protokollieren und anschliessend den Mandanten zurueckgeben. Formuliere den Doc-Kommentar auf Deutsch wie in GroupsService/UserService und halte fest, warum der Fehler nicht propagiert wird: die Mandanten-Anlage selbst ist erfolgreich, und die Startup-Reparatur holt eine gescheiterte Gruppenanlage beim naechsten API-Start nach.apps/api/src/tenant/tenant.module.ts: GroupsModule importieren. Vermerke im Kommentar, dass GroupsModule selbst nichts importiert und deshalb keine Zirkularitaet entsteht — UserModule bindet GroupsModule bereits nach demselben Muster ein.
apps/api/src/user/admin-seed.service.ts: Injiziere GroupsService zusaetzlich zu PrismaService und ConfigService. Verschiebe den kompletten bisherigen Rumpf von onApplicationBootstrap unveraendert in eine neue private Methode seedAdmin(), inklusive beider frueher Rueckkehrpfade. onApplicationBootstrap besteht danach aus zwei sequenziellen await-Aufrufen: erst seedAdmin(), dann die neue private Methode ensureDefaultGroupsForAllTenants(). Genau diese sequenzielle Reihenfolge ist der Ordering-Garant — begruende im Klassen-Doc-Kommentar, warum die Reparatur NICHT als eigener onApplicationBootstrap-Hook in GroupsModule sitzt: GroupsModule ist eine Dependency von UserModule, seine Hooks laufen deshalb frueher, und die Reparatur wuerde vor dem Anlegen des Default-Mandanten greifen. Verweise dabei auf die ausfuehrlich dokumentierte Variante desselben Problems in tenders/tender-scheduler.service.ts.
In seedAdmin() unmittelbar nach dem tenant.upsert und VOR dem user.create ein await auf ensureDefaultGroup mit tenant.id einfuegen. Damit existiert die markierte Standardgruppe, bevor UserService.create laeuft, und der Super-Admin wird ueber den normalen addUserToDefaultGroup-Pfad Mitglied statt ueber die Reparatur.
ensureDefaultGroupsForAllTenants() implementieren: prisma.tenant.findMany mit select { id: true, slug: true }, dann eine Schleife, die je Mandant ensureDefaultGroup in einem eigenen try/catch aufruft und die Anzahl der tatsaechlich erzeugten Gruppen mitzaehlt (Rueckgabe ungleich null). Ein Fehler bei einem Mandanten wird per logger.error mit slug protokolliert und beendet die Schleife nicht. Wurde mindestens eine Gruppe erzeugt, eine zusammenfassende logger.warn-Zeile mit der Anzahl schreiben, sonst eine logger.log-Zeile auf debug-artigem Niveau oder gar keine. Die gesamte Methode zusaetzlich in ein aeusseres try/catch legen, damit auch ein fehlgeschlagenes tenant.findMany nur protokolliert wird und den API-Start nicht abbricht. Die Log-Texte in dieser Datei bleiben englisch, wie die vorhandenen Zeilen. seedAdmin() bleibt bewusst UNgekapselt: schlaegt der Admin-Seed fehl, soll der Start weiterhin laut scheitern — das ist bestehendes Verhalten und wird hier nicht aufgeweicht.
apps/api/src/tenant/tenant.service.spec.ts (NEU): Vitest-Spec im Stil von user.service.spec.ts — vi.fn-Mocks fuer prisma.tenant.create und fuer groupsService.ensureDefaultGroup, Service per new TenantService(prisma, groupsService) instanziiert. Deckt die beiden TenantService-Punkte des behavior-Blocks ab.
apps/api/src/user/admin-seed.service.spec.ts (NEU): Vitest-Spec mit vi.fn-Mocks fuer prisma.tenant.upsert, prisma.tenant.findMany, prisma.user.findUnique, prisma.user.create, fuer configService.get (liefert die ENV-Werte je Testfall) und fuer groupsService.ensureDefaultGroup. Service per new AdminSeedService(prisma, configService, groupsService) instanziiert — Konstruktor-Reihenfolge an die Implementierung angleichen. Deckt die sechs AdminSeedService-Punkte des behavior-Blocks ab. Die Reihenfolgepruefung ueber die vi.fn-Aufrufreihenfolge fuehren (z.B. mock.invocationCallOrder oder ein gemeinsames Aufruf-Log-Array, in das jeder Mock seinen Namen schiebt), nicht ueber blosse Aufrufzaehler. pnpm --filter @tessera/api exec vitest run src/tenant/tenant.service.spec.ts src/user/admin-seed.service.spec.ts pnpm --filter @tessera/api run test pnpm --filter @tessera/api run type-check test "$(ls -1d apps/api/prisma/migrations/*/ | wc -l)" -eq 24 && git diff --quiet -- apps/api/prisma/schema.prisma && echo schema-unveraendert grep -q 'ensureDefaultGroup' apps/api/src/tenant/tenant.service.ts && grep -q 'ensureDefaultGroup' apps/api/src/user/admin-seed.service.ts && grep -q 'GroupsModule' apps/api/src/tenant/tenant.module.ts && echo verdrahtung-ok Beide neuen Specs sind gruen, die komplette API-Suite laeuft durch und type-check ist sauber. TenantService.create und AdminSeedService rufen beide ensureDefaultGroup auf, AdminSeedService fuehrt die Reparatur ueber alle Mandanten unabhaengig von seinen fruehen Rueckkehrpfaden aus, und weder schema.prisma noch die Migrationsverzeichnisse wurden angefasst.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| Admin-API -> TenantService.create | SUPER_ADMIN legt einen Mandanten an; name/slug sind Benutzereingabe, die entstehende tenantId ist es nicht |
| Startup-Prozess -> Datenbank | Die Reparatur schreibt unbeaufsichtigt in JEDEN Mandanten, ohne Request-Kontext und ohne JWT |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-fok-01 | Elevation of Privilege | GroupsService.ensureDefaultGroup | high | mitigate | tenantId kommt ausschliesslich aus dem Funktionsargument (TenantService: id des soeben erzeugten Mandanten; Reparatur: id aus tenant.findMany). Weder Gruppen-, Mitgliedschafts- noch Grant-Schreibvorgang leitet die tenantId aus einem gelesenen Fremdobjekt oder aus Request-Daten ab. |
| T-fok-02 | Elevation of Privilege | Mitglieder-Backfill in ensureDefaultGroup | high | mitigate | user.findMany filtert auf where { tenantId }; ein Benutzer eines fremden Mandanten kann nie Mitglied werden. Spec-Fall im behavior-Block deckt das explizit ab. |
| T-fok-03 | Elevation of Privilege | Grant-Backfill in ensureDefaultGroup | high | mitigate | tenantModuleActivation.findMany filtert auf where { tenantId, isActive: true }; ein Grant entsteht nur fuer ein Modul, das fuer genau diesen Mandanten aktiv ist — nie fuer ein bloss katalogisiertes oder fuer einen fremden Mandanten aktiviertes Modul. |
| T-fok-04 | Tampering | Startup-Reparatur vs. D-13 | medium | mitigate | Der Waechter prueft auf null Gruppen, nicht auf die fehlende Markierung. Ein Admin, der die Standardmarkierung bewusst abgehaengt oder umgehaengt hat, wird von der Reparatur nicht ueberstimmt. |
| T-fok-05 | Denial of Service | onApplicationBootstrap | medium | mitigate | Die Reparatur laeuft je Mandant in try/catch und zusaetzlich als Ganzes gekapselt; ein Datenbankfehler wird protokolliert und blockiert den API-Start nicht. |
| T-fok-06 | Tampering | Nebenlaeufige Gruppenanlage | low | accept | Zwei gleichzeitige Aufrufe koennen beide die Gruppenzahl 0 lesen; der partielle Unique-Index Group_one_default_per_tenant laesst nur einen gewinnen, der Verlierer faengt P2002 ab und liefert null. Kein weiterer Sperrmechanismus noetig. |
| </threat_model> |
<success_criteria>
- Ein ueber POST /tenants angelegter Mandant hat unmittelbar danach genau eine Gruppe, und diese traegt isDefault:true.
- Eine frische Installation (leere Datenbank, erster Container-Start) hat nach dem API-Start eine Standardgruppe im Mandanten „Default", und der geseedete Super-Admin ist deren Mitglied.
- Eine bestehende Installation mit Mandanten ohne jede Gruppe erhaelt diese beim naechsten API-Start nachtraeglich, samt Mitgliedschaften aller Bestandsbenutzer und Grants aller aktiven Module.
- Ein Mandant, dessen Gruppen existieren, aber keine davon als Standard markiert ist, bleibt beim Neustart unveraendert.
- Zwei aufeinanderfolgende API-Starts erzeugen nicht zwei Gruppen und keine doppelten Mitgliedschaften.
- Keine Schemaaenderung, keine neue Migration, kein
prisma db push. </success_criteria>