docs(quick-260805-fok): Standardgruppe bei Mandanten-Anlage + Startup-Reparatur
Tessera CI/CD / Lint & Type Check (push) Successful in 48s
Tessera CI/CD / Tests (push) Successful in 49s
Tessera CI/CD / Build & Publish Images (push) Successful in 28s

This commit is contained in:
2026-08-05 11:37:43 +02:00
parent 0d7d8a597e
commit ff70b4efba
4 changed files with 386 additions and 7 deletions
@@ -0,0 +1,231 @@
---
phase: quick-260805-fok
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- apps/api/src/groups/groups.service.ts
- apps/api/src/groups/groups.service.spec.ts
- 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
autonomous: true
requirements:
- 260805-fok
must_haves:
truths:
- "GroupsService.ensureDefaultGroup(tenantId) legt für einen Mandanten OHNE jede Gruppe die Standardgruppe 'Alle Benutzer' (isDefault: true) an, nimmt alle bestehenden Benutzer dieses Mandanten als MANUAL-Mitglieder auf und erzeugt Gruppen-Grants für alle aktuell aktiven Module — derselbe Endzustand, den die drei Backfill-INSERTs in 20260804130130_add_groups_and_module_grants pro Mandant herstellen."
- "Der Wächter ist 'null Gruppen im Mandanten'. Ein Mandant mit mindestens einer Gruppe wird NIE angefasst — auch dann nicht, wenn keine davon isDefault trägt. D-13 erlaubt dem Admin ausdrücklich, die Markierung abzuhängen oder umzuhängen; diese Entscheidung darf nicht überschrieben werden."
- "ensureDefaultGroup ist idempotent und race-sicher: ein zweiter Aufruf ist folgenlos, und eine P2002-Verletzung des partiellen Index Group_one_default_per_tenant durch einen parallelen Aufruf wird abgefangen statt propagiert."
- "TenantService.create ruft ensureDefaultGroup für den frisch angelegten Mandanten auf. Schlägt das fehl, wird der Fehler protokolliert und die Mandanten-Anlage kehrt trotzdem erfolgreich zurück (Muster aus UserService.create)."
- "AdminSeedService ruft ensureDefaultGroup direkt nach dem Tenant-Upsert auf, sodass die Standardgruppe existiert, BEVOR der Super-Admin über UserService.create angelegt wird und dort per addUserToDefaultGroup beitritt — genau die Reihenfolge, die auf dem Testserver gefehlt hat."
- "AdminSeedService führt am Ende von onApplicationBootstrap — nach dem Admin-Seed und unabhängig von dessen frühen Rückkehrpfaden (ENV fehlt / Admin existiert bereits) — die einmalige Reparatur über ALLE Mandanten aus. Sie läuft je Mandant in try/catch und kann den API-Start nie blockieren."
- "Jeder Schreibvorgang trägt eine explizite tenantId, die ausschließlich aus dem übergebenen Funktionsargument bzw. aus tenant.findMany stammt — nie aus Benutzereingaben."
- "Kein Schemawechsel und keine neue Migration: apps/api/prisma/schema.prisma bleibt unverändert, es bleiben genau 24 Migrationsverzeichnisse."
artifacts:
- "apps/api/src/groups/groups.service.ts — neue Methode ensureDefaultGroup(tenantId) mit Null-Gruppen-Wächter, Transaktion über Group + GroupMembership + ModuleGrant und P2002-Abfang."
- "apps/api/src/groups/groups.service.spec.ts — erweiterter In-Memory-Fake (group.count, user.findMany per tenantId, tenantModuleActivation, moduleGrant.createMany/findMany, $transaction mit Callback-Form) plus ensureDefaultGroup-Testblock."
- "apps/api/src/tenant/tenant.service.ts + tenant.module.ts — GroupsService injiziert, ensureDefaultGroup nach prisma.tenant.create, Logger für den nicht-fatalen Fehlerpfad."
- "apps/api/src/tenant/tenant.service.spec.ts — NEU: beweist den Aufruf und den nicht-fatalen Fehlerpfad."
- "apps/api/src/user/admin-seed.service.ts — seedAdmin() extrahiert, ensureDefaultGroup nach dem Tenant-Upsert, ensureDefaultGroupsForAllTenants() als abschließende Reparatur."
- "apps/api/src/user/admin-seed.service.spec.ts — NEU: Reihenfolge, Reparatur über alle Mandanten, Idempotenz über zwei Bootstrap-Läufe, Fehlerisolation je Mandant."
key_links:
- "ensureDefaultGroup <-> partieller Unique-Index Group_one_default_per_tenant (15-01): der Index ist der eigentliche Durchsetzungspunkt, der P2002-Abfang macht die Methode race-sicher."
- "AdminSeedService-Reihenfolge <-> sequenzieller await statt Verlass auf die Nest-Hook-Reihenfolge: GroupsModule ist Dependency von UserModule, ein eigener onApplicationBootstrap-Hook in GroupsModule liefe deshalb VOR dem Admin-Seed (dieselbe Falle, die tender-scheduler.service.ts ausführlich dokumentiert)."
- "ensureDefaultGroup <-> GroupsService.addUserToDefaultGroup (Z. 260): letztere kehrt still zurück, wenn keine markierte Standardgruppe existiert — deshalb muss die Gruppe vor jedem user.create stehen."
- "TenantModule -> GroupsModule Import: GroupsModule importiert selbst nichts, es entsteht keine Zirkularität (UserModule macht dasselbe bereits)."
---
<objective>
Die Invariante „jeder Mandant hat eine Standardgruppe" gehört heute allein der einmaligen
Datenmigration 20260804130130_add_groups_and_module_grants. Auf einer frischen Installation
lief diese Migration gegen eine leere Datenbank, ihre drei Backfill-INSERTs trafen null Zeilen,
und der drei Sekunden später von AdminSeedService angelegte Mandant „Default" blieb ohne
Gruppe zurück (live nachgewiesen auf 192.168.13.12: tenants=1 users=4 groups=0 memberships=0
grants=0). Weil D-02 den Zugriff auf „geschlossen ohne Grant" umkehrt, sieht dort kein
einziger USER ein Modul, und die Berechtigungsmatrix hat keine Spalten.
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.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@CLAUDE.md
# Achtung: 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
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: GroupsService.ensureDefaultGroup(tenantId) + Spec</name>
<files>apps/api/src/groups/groups.service.ts, apps/api/src/groups/groups.service.spec.ts</files>
<behavior>
- 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.
</behavior>
<action>
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:
1. 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.
2. 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.
3. 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.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/groups/groups.service.spec.ts</automated>
<automated>pnpm --filter @tessera/api run type-check</automated>
</verify>
<done>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.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Beide Mandanten-Entstehungspfade verdrahten + Startup-Reparatur + Specs</name>
<files>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</files>
<behavior>
- 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.
</behavior>
<action>
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.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/tenant/tenant.service.spec.ts src/user/admin-seed.service.spec.ts</automated>
<automated>pnpm --filter @tessera/api run test</automated>
<automated>pnpm --filter @tessera/api run type-check</automated>
<automated>test "$(ls -1d apps/api/prisma/migrations/*/ | wc -l)" -eq 24 && git diff --quiet -- apps/api/prisma/schema.prisma && echo schema-unveraendert</automated>
<automated>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</automated>
</verify>
<done>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.</done>
</task>
</tasks>
<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>
<verification>
- `pnpm --filter @tessera/api run test` — komplette API-Suite gruen (Ausgangsstand: 387 Tests aus Phase 14 plus die Phase-15-Specs; keine bestehende Spec darf brechen).
- `pnpm --filter @tessera/api run type-check` — sauber.
- `git diff --stat` zeigt ausschliesslich die sieben in files_modified gelisteten Dateien; insbesondere keine Aenderung an apps/api/prisma/schema.prisma und kein neues Verzeichnis unter apps/api/prisma/migrations/.
- Kein Deploy auf den Testserver aus diesem Plan heraus — das uebernimmt der Benutzer. Der erwartete Effekt dort nach dem naechsten Container-Start: der Mandant „Default" bekommt die Gruppe „Alle Benutzer" mit isDefault:true, die vier vorhandenen Benutzer werden Mitglieder, und fuer jedes aktive Modul entsteht ein Gruppen-Grant.
</verification>
<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>
<output>
Create `.planning/quick/260805-fok-standardgruppe-bei-mandanten-anlage-und-/260805-fok-SUMMARY.md` when done
</output>
@@ -0,0 +1,145 @@
---
phase: quick-260805-fok
plan: 01
subsystem: api
tags: [nestjs, prisma, groups, multi-tenancy, onapplicationbootstrap]
requires:
- phase: 15-modul-berechtigungen-gruppen-user-grants
provides: "Group/GroupMembership/ModuleGrant-Schema, partieller Unique-Index Group_one_default_per_tenant, GroupsService.addUserToDefaultGroup"
provides:
- "GroupsService.ensureDefaultGroup(tenantId) — race-sichere, idempotente Standardgruppen-Anlage mit Null-Gruppen-Wächter (D-13)"
- "TenantService.create verdrahtet mit ensureDefaultGroup"
- "AdminSeedService.ensureDefaultGroupsForAllTenants() — Startup-Reparatur über alle Mandanten"
affects: [groups, tenant, user, admin-seed]
tech-stack:
added: []
patterns:
- "Sequenzielle await-Kette in onApplicationBootstrap statt Verlass auf Nest-Hook-Reihenfolge zwischen Modulen (Muster aus tender-scheduler.service.ts übernommen)"
- "Interaktive Prisma-Transaktion ($transaction mit async-Callback) statt Array-Form, wenn spätere Schritte die id eines vorherigen Schritts brauchen"
key-files:
created:
- apps/api/src/tenant/tenant.service.spec.ts
- apps/api/src/user/admin-seed.service.spec.ts
modified:
- apps/api/src/groups/groups.service.ts
- apps/api/src/groups/groups.service.spec.ts
- apps/api/src/tenant/tenant.service.ts
- apps/api/src/tenant/tenant.module.ts
- apps/api/src/user/admin-seed.service.ts
key-decisions:
- "Wächter prüft ausschließlich group.count === 0, nie das Fehlen der isDefault-Markierung — ein Mandant mit Gruppen ohne Markierung ist eine bewusste Admin-Entscheidung (D-13) und wird nie überschrieben"
- "Reparatur läuft als zweiter sequenzieller await-Schritt in AdminSeedService.onApplicationBootstrap, NICHT als eigener Hook in GroupsModule — GroupsModule ist Dependency von UserModule, seine Hooks liefen sonst vor der Tenant-Anlage"
- "P2002 aus dem partiellen Index Group_one_default_per_tenant wird in ensureDefaultGroup abgefangen und liefert null, propagiert nicht — der Index bleibt der eigentliche Durchsetzungspunkt"
requirements-completed: [260805-fok]
coverage:
- id: D1
description: "GroupsService.ensureDefaultGroup(tenantId) legt für einen Mandanten ohne jede Gruppe die Standardgruppe 'Alle Benutzer' an, mit Mitgliedern und Modul-Grants, race-sicher und idempotent"
requirement: "260805-fok"
verification:
- kind: unit
ref: "apps/api/src/groups/groups.service.spec.ts#ensureDefaultGroup()"
status: pass
human_judgment: false
- id: D2
description: "TenantService.create ruft ensureDefaultGroup auf; Fehler werden protokolliert, nicht propagiert"
requirement: "260805-fok"
verification:
- kind: unit
ref: "apps/api/src/tenant/tenant.service.spec.ts"
status: pass
human_judgment: false
- id: D3
description: "AdminSeedService führt die Standardgruppen-Anlage vor dem Super-Admin-Anlegen aus und repariert am Ende von onApplicationBootstrap alle Mandanten ohne Gruppe, unabhängig von den frühen Rückkehrpfaden"
requirement: "260805-fok"
verification:
- kind: unit
ref: "apps/api/src/user/admin-seed.service.spec.ts"
status: pass
human_judgment: false
- id: D4
description: "Kein Schemawechsel, keine neue Migration — 24 Migrationsverzeichnisse unverändert, schema.prisma unverändert"
verification:
- kind: other
ref: "test \"$(ls -1d apps/api/prisma/migrations/*/ | wc -l)\" -eq 24 && git diff --quiet -- apps/api/prisma/schema.prisma"
status: pass
human_judgment: false
duration: 6min
completed: 2026-08-05
status: complete
---
# Quick Task 260805-fok: Standardgruppe bei Mandanten-Anlage und Startup-Reparatur Summary
**GroupsService.ensureDefaultGroup(tenantId) verlegt die "jeder Mandant hat eine Standardgruppe"-Invariante von der einmaligen Migration in beide Mandanten-Entstehungspfade (TenantService.create, AdminSeedService) plus eine Startup-Reparatur über alle Bestandsmandanten.**
## Performance
- **Duration:** 6 min
- **Started:** 2026-08-05T11:25:00+02:00 (circa)
- **Completed:** 2026-08-05T11:30:49+02:00
- **Tasks:** 2
- **Files modified:** 7 (2 neu, 5 geändert)
## Accomplishments
- `GroupsService.ensureDefaultGroup(tenantId)`: legt für einen Mandanten ohne jede Gruppe "Alle Benutzer" (isDefault:true) an, nimmt alle Bestandsbenutzer als MANUAL-Mitglieder auf und erzeugt Grants für alle aktiven Module — derselbe Endzustand wie die drei Backfill-INSERTs der Migration `20260804130130_add_groups_and_module_grants`. Wächter prüft ausschließlich `group.count === 0` (D-13), fängt P2002 aus dem partiellen Index `Group_one_default_per_tenant` ab und liefert `null` statt zu werfen.
- `TenantService.create` ruft `ensureDefaultGroup` nach der Mandanten-Anlage auf; ein Fehler wird protokolliert, die Mandanten-Anlage schlägt trotzdem nicht fehl.
- `AdminSeedService.onApplicationBootstrap` läuft jetzt als zwei sequenzielle await-Schritte: `seedAdmin()` (bisheriger Rumpf, jetzt mit `ensureDefaultGroup` nach dem Tenant-Upsert und VOR `user.create`) und danach `ensureDefaultGroupsForAllTenants()` — die einmalige Reparatur über jeden Mandanten aus `prisma.tenant.findMany`, unabhängig von `seedAdmin()`s beiden frühen Rückkehrpfaden (fehlende ENV-Variablen, Admin existiert bereits — genau der Fall auf dem Testserver).
## Task Commits
Each task was committed atomically:
1. **Task 1: GroupsService.ensureDefaultGroup(tenantId) + Spec** - `9d1254c` (feat)
2. **Task 2: Beide Mandanten-Entstehungspfade verdrahten + Startup-Reparatur + Specs** - `0d7d8a5` (feat)
**Plan metadata:** wird vom Orchestrator committet (SUMMARY.md, STATE.md)
## Files Created/Modified
- `apps/api/src/groups/groups.service.ts` - neue Methode `ensureDefaultGroup(tenantId)` vor `addUserToDefaultGroup`
- `apps/api/src/groups/groups.service.spec.ts` - Fake erweitert (`group.count`, `tenantModuleActivation`, `moduleGrant.findMany/createMany`, `$transaction`-Callback-Form) plus voller `ensureDefaultGroup`-Testblock
- `apps/api/src/tenant/tenant.service.ts` - `GroupsService` injiziert, `create()` ruft `ensureDefaultGroup` in try/catch mit Logger auf
- `apps/api/src/tenant/tenant.module.ts` - `GroupsModule` importiert
- `apps/api/src/tenant/tenant.service.spec.ts` (neu) - beweist Aufruf und nicht-fatalen Fehlerpfad
- `apps/api/src/user/admin-seed.service.ts` - `seedAdmin()` extrahiert, `ensureDefaultGroup` nach dem Tenant-Upsert eingefügt, `ensureDefaultGroupsForAllTenants()` als abschließende Reparatur
- `apps/api/src/user/admin-seed.service.spec.ts` (neu) - Reihenfolge, Reparatur über alle Mandanten, Idempotenz über zwei Bootstrap-Läufe, Fehlerisolation je Mandant
## Decisions Made
- Wächter prüft ausschließlich `group.count === 0`, nie das Fehlen der `isDefault`-Markierung — D-13 erlaubt dem Admin ausdrücklich, die Markierung abzuhängen oder umzuhängen; die Reparatur darf diese Entscheidung nie überschreiben.
- Die Reparatur läuft als zweiter sequenzieller `await`-Schritt in `AdminSeedService.onApplicationBootstrap`, nicht als eigener Hook in `GroupsModule`: `GroupsModule` ist Dependency von `UserModule`, seine `onApplicationBootstrap`-Hooks liefen deshalb vor dem Admin-Seed und würden auf einer frischen Installation den noch nicht existierenden Default-Mandanten übergehen — dieselbe Falle, die `tender-scheduler.service.ts` für den DÖE-Poll-Config-Seed dokumentiert.
- `ensureDefaultGroup` nutzt eine interaktive Transaktion (`$transaction` mit async-Callback) statt der Array-Form, weil Schritt 2 (Mitgliedschaften) und Schritt 3 (Grants) die `id` der in Schritt 1 erzeugten Gruppe brauchen.
- P2002 aus `group.create` wird ausschließlich anhand `err.code === 'P2002'` abgefangen und in `null` übersetzt; jeder andere Fehler wird weitergeworfen. Der partielle Unique-Index `Group_one_default_per_tenant` (15-01) bleibt der eigentliche Durchsetzungspunkt.
## Deviations from Plan
None — Plan exakt wie geschrieben umgesetzt.
## Issues Encountered
Keine funktionalen Probleme. Beim ersten Testlauf für `admin-seed.service.spec.ts` waren zwei Testerwartungen selbst fehlerhaft formuliert (Reihenfolge-Filter schloss `user.findUnique` nicht aus; Aufrufzähler für den Neustart-Test berücksichtigte nicht, dass `seedAdmin()` beim ersten Lauf zusätzlich zur Reparatur einen eigenen `ensureDefaultGroup`-Aufruf auslöst) — beide Assertions korrigiert, die Implementierung war in beiden Fällen bereits korrekt.
## User Setup Required
None - keine externe Konfiguration nötig.
**Kein Deploy auf den Testserver aus diesem Plan heraus** — das übernimmt der Benutzer laut Vorgabe. Erwarteter Effekt dort nach dem nächsten Container-Start (unbeaufsichtigter `prisma migrate deploy` + API-Start): der Mandant „Default" bekommt die Gruppe „Alle Benutzer" mit `isDefault:true`, die vier vorhandenen Benutzer werden Mitglieder, und für jedes aktive Modul entsteht ein Gruppen-Grant.
## Next Phase Readiness
- Lokaler Stack geprüft: der einzige lokale Mandant hat bereits genau eine Gruppe (`docker exec tessera-ctl-db-1 psql` bestätigt `groups=1` für den Tenant `default`) — die Startup-Reparatur lässt ihn beim nächsten API-Neustart unangetastet, genau wie im behavior-Block gefordert.
- Live-Testserver (192.168.13.12, `tenants=1 users=4 groups=0`) profitiert vom nächsten Container-Neustart automatisch — kein manueller Eingriff nötig, keine neue Migration.
- Keine offenen Punkte.
---
*Phase: quick-260805-fok*
*Completed: 2026-08-05*
## Self-Check: PASSED
Alle 7 in `files_modified` gelisteten Dateien vorhanden, beide Task-Commits (`9d1254c`, `0d7d8a5`) im Git-Log gefunden.