--- phase: quick-260909-jts plan: 01 subsystem: database tags: [prisma, postgresql, row-level-security, groups, module-grants, multi-tenancy, nestjs] requires: - phase: quick-260909-ipc provides: "ldap area fully bound via forTenant(), distinguishable-client test pattern, rls-access-inventory.spec.ts with bound/unbound Stand column, rls-scratch-check.mjs scratch-database tool" provides: - "groups.service.ts (12 methods) and module-grants.service.ts (5 methods) fully converted to forTenant()/withTenantTransaction() — 34 previously visible plus 9 previously invisible model accesses" - "withTenantTransaction(prisma, tenantId, fn) in prisma-tenant.extension.ts — the interactive-transaction-on-unbound-client helper, empirically the only one of three measured forms that survives real concurrency (the array-form-on-bound-client and interactive-form-on-bound-client both proved unreliable when measured against the live database)" - "rls-access-inventory.spec.ts detects model access routed through an interactive-transaction callback parameter (Befund B) — surfaces the (groups.service.ts, tenantModuleActivation) pair that no check in this project had ever seen" - "closed the T-JTS-02 gap in addUserToDefaultGroup (Befund E): a foreign-tenant target user is now rejected by the application, since the GroupMembership RLS policy only checks the group side" - "docs/mandantentrennung-etappe2-fehlerrichtung.md — groups section with the measured transaction-shape decision, a per-path signal table, and the four code paths that read emptiness as absence" - "the Etappe-4 ordering condition from the ldap area (Befund D — default-group handoff before deletion) is closed" affects: [mandantentrennung-etappe-2-tenders, mandantentrennung-etappe-3, mandantentrennung-etappe-4] actuals: tokens: 26773 tasks: 3 commits: 3 plan_head_before: b532eaa tech-stack: added: [] patterns: - "withTenantTransaction(prisma, tenantId, fn): interactive $transaction on the UNbound client, set_config as the first raw statement directly on tx, same tx handed to fn — the empirically-safe pattern for any multi-step, tenant-bound change; forTenant() remains correct for single operations" - "Transaction-shape decisions must be measured against the live database before conversion, not assumed from reading the extension code — the array-form-on-bound-client silently splits into multiple sub-transactions (broken atomicity, not a data leak), and the interactive-form-on-bound-client passes a narrow single-shot check but throws P2028 under real concurrent load" - "rls-access-inventory.spec.ts's third detection (interactive-transaction callback parameters) generalizes to two receiver forms: .$transaction(async (tx) => ...) and withTenantTransaction(, tenantId, async (tx) => ...) — the latter is always bound by construction" key-files: created: [] modified: - apps/api/scripts/rls-scratch-check.mjs - apps/api/src/groups/groups.service.ts - apps/api/src/groups/groups.service.spec.ts - apps/api/src/groups/module-grants.service.ts - apps/api/src/groups/module-grants.service.spec.ts - apps/api/src/prisma/prisma-tenant.extension.ts - apps/api/src/prisma/prisma-tenant.extension.spec.ts - apps/api/src/prisma/rls-access-inventory.spec.ts - docs/mandantentrennung-etappe2-fehlerrichtung.md - docs/mandantentrennung-zugriffsklassifikation.md key-decisions: - "withTenantTransaction() built and used for ALL THREE transaction sites (update()'s isDefault:true branch, reassignDefaultBeforeDelete(), ensureDefaultGroup()), not just the one the plan's narrow single-shot measurement required a fallback for. Task 1's single-shot measurement showed both the interactive-form-on-bound-client AND the interactive-form-on-unbound-client passing the plan's three stated conditions (same connection, correct context, correct row count). An additional due-diligence stress test (40 parallel calls, alternating tenants, not required by the plan but directly motivated by threat T-JTS-08) showed the bound-client form throwing P2028 (Transaction API error: Unable to start a transaction in the given time) under real concurrency, while the unbound-client form showed 0 violations. Given ensureDefaultGroup() runs from a startup-repair loop over multiple tenants (exactly the T-JTS-08 scenario), the more fragile form was rejected in favor of the one that survived both tests." - "addUserToDefaultGroup() gained a tenant check on the target user (Befund E, T-JTS-02), following the addMembers() precedent two methods above — a foreign user is silently skipped, the method does not throw. This changed two pre-existing tests' fixtures (both needed a __seedUser call they'd been missing) but not their assertions." - "The stale comment above the membership query in getUserAccess() ('Kein forTenant hier — dieselbe Begruendung wie bei den drei Abfragen oben') was replaced, not left standing — after binding, the old comment would have actively misled the next reader about the actual state." - "listForTenant()'s intermediate `groups` variable needed an explicit `: any[]` annotation (not just `: any`) — TypeScript's noImplicitAny flags callback parameters on a bare `any`-typed value when passed through Array.prototype methods across a function boundary, but not on an explicitly-array-typed one. Confirmed empirically with a minimal repro before applying the fix broadly." patterns-established: - "Distinguishable-client test pattern (from 260909-ipc) extended to the interactive-transaction case: __makeBoundClient(tenantId) wraps each of the five relevant models with a call-logging proxy over the SAME backing Maps, and __withTenantTransaction(tenantId, fn) hands that same bound client to fn as the transaction parameter — a forgotten forTenant()/withTenantTransaction() call now fails a specific, per-operation assertion instead of merely 'forTenant was called at some point'." requirements-completed: [WINDOWS-20, ETAPPE-2-GROUPS] duration: ~70min completed: 2026-09-09 status: complete --- # Quick Task 260909-jts: Mandantentrennung Etappe 2, Bereich groups — Summary **34 sichtbare und 9 zuvor fuer jede Pruefung unsichtbare Datenbankzugriffe in `groups.service.ts` (12 Methoden) und `module-grants.service.ts` (5 Methoden) auf `forTenant()`/`withTenantTransaction()` umgestellt — die Umstellung, auf der die gesamte Etappe ruht, wurde per Messung entschieden, nicht angenommen, und die Reihenfolgebedingung fuer Etappe 4 aus dem ldap-Durchlauf ist geschlossen.** ## Performance - **Duration:** ~70 min - **Tasks:** 3/3 completed - **Files modified:** 10 (0 created, 10 modified) - **Commits:** 3 ## Accomplishments - Der Bereich `groups` (37 Rohtreffer, davon 34 tatsaechliche Modellzugriffe, plus 9 bislang unsichtbare Zugriffe ueber den Rueckgabeparameter einer interaktiven Transaktion) laeuft jetzt vollstaendig ueber den Mandantenkontext. - Die einzige offene Architekturfrage der gesamten Umstellung — welche Transaktionsform den Mandantenkontext auf derselben Verbindung traegt — ist an der echten Datenbank gemessen: die Array-Form auf dem gebundenen Client versagt nachweisbar (zwei verschiedene `pg_backend_pid()` fuer zwei Teilschritte einer angeblich gemeinsamen Transaktion), und von den beiden bestehenden Formen ueberlebt nur eine (`withTenantTransaction`, interaktiv auf dem ungebundenen Client) eine zusaetzliche Lastprobe mit 40 parallelen Aufrufen. - Zwei latente Mandantenluecken sind gemessen und dokumentiert: die `GroupMembership`-Regel prueft nur die Gruppenseite (Befund E, T-JTS-02 — jetzt in `addUserToDefaultGroup` durch eine Anwendungspruefung geschlossen), und die `ModuleGrant`-Regel prueft nur die Mandantenkennung der Zeile, nicht die referenzierte Gruppe (Befund F, T-JTS-03 — die bestehende `assertTargetBelongsToTenant`-Pruefung bleibt deshalb der primaere Schutz). - Die maschinelle Absicherung (`rls-access-inventory.spec.ts`) sieht jetzt Modellzugriffe ueber den Rueckgabeparameter einer interaktiven Transaktion — macht das Paar (`groups.service.ts`, `tenantModuleActivation`) erstmals sichtbar, das genau auf der Schreibstelle mit der groessten Wirkung sass (Modulfreigaben fuer neu angelegte Standardgruppen). - Die Reihenfolgebedingung fuer Etappe 4 aus dem ldap-Durchlauf (Befund D: die Standardgruppen-Uebergabe vor einer Gruppenloeschung) ist geschlossen und in beiden Dokumenten als erledigt vermerkt. - `docs/mandantentrennung-etappe2-fehlerrichtung.md` bekommt einen eigenen `groups`-Abschnitt mit den tatsaechlich beobachteten Messwerten (nicht erwarteten), einer Signaltabelle je Pfad und der Stelle, an der zu wenig Lesen zu viel Schreiben ausloest. ## Task Commits 1. **Aufgabe 1: Fehlerrichtung fuer groups schreiben und die Transaktionsfrage messen** - `fd0b9f7` (feat) 2. **Aufgabe 2: groups.service.ts binden, die Transaktionen tragfaehig machen, die Absicherung sehend machen** - `7f08b27` (feat) 3. **Aufgabe 3: module-grants.service.ts binden und beide Dokumente schliessen** - `abb6c8b` (feat) **Plan metadata:** committed separately by the orchestrator after this SUMMARY. ## Files Created/Modified - `apps/api/scripts/rls-scratch-check.mjs` - neuer Abschnitt `runGroupsAreaChecks` (9 Messungen gegen die aus zwei ausgelieferten Migrationen extrahierten Group/GroupMembership/ModuleGrant/TenantModuleActivation-Policies) plus `runTransactionShapeMeasurement` (die eine Transaktionspruefung, benennt namentlich welche der drei Formen tragen) - `apps/api/src/prisma/prisma-tenant.extension.ts` - neues `withTenantTransaction(prisma, tenantId, fn)`, Kopfkommentar um die gemessene Entscheidung fuer diesen Bereich erweitert - `apps/api/src/prisma/prisma-tenant.extension.spec.ts` - bestehender "keine interaktive Form"-Test auf `forTenant()` selbst verengt (nicht mehr die ganze Datei), 4 neue Tests fuer `withTenantTransaction` - `apps/api/src/prisma/rls-access-inventory.spec.ts` - dritte Erkennung fuer Modellzugriffe ueber Transaktionsparameter (zwei Empfaengerformen), neue Vollstaendigkeits-Pruefung, Ausnahmeliste startet leer - `apps/api/src/groups/groups.service.ts` - alle 12 Methoden gebunden, drei Transaktionsstellen auf `withTenantTransaction()` umgestellt, `addUserToDefaultGroup` prueft neu den Zielbenutzer-Mandanten (Befund E) - `apps/api/src/groups/groups.service.spec.ts` - zwei unterscheidbare Clients ueber demselben Speicher-Fake, 13 neue Bindungsnachweise, alle 42 Bestandstests unveraendert gruen (zwei Fixture-Ergaenzungen fuer den neuen Mandantencheck) - `apps/api/src/groups/module-grants.service.ts` - alle 5 Methoden gebunden, T-JTS-03-Begruendung an `grant()` ergaenzt, veralteter Kommentar in `getUserAccess()` ersetzt - `apps/api/src/groups/module-grants.service.spec.ts` - derselbe Bindungsnachweis-Mock, 6 neue Tests, alle 28 Bestandstests unveraendert gruen - `docs/mandantentrennung-etappe2-fehlerrichtung.md` - neuer Abschnitt "Bereich groups" (Messbeleg, Transaktionsform-Entscheidung inkl. Lastprobe, Signaltabelle, vier Pflichtpunkte, was nicht geloest wird), ldap-Abschnitt (e) um Nachtrag zu Befund D erweitert - `docs/mandantentrennung-zugriffsklassifikation.md` - neun Zeilen fuer `groups.service.ts`/`module-grants.service.ts` auf `gebunden`, neue Zeile fuer das bislang unsichtbare Paar (`groups.service.ts`, `tenantModuleActivation`), Bereichsuebersicht neu gemessen (0/31, mit dokumentiertem methodischen Bodensatz), Klassen-Verteilung auf 62 Paare, "Hintergrunddienst als Falle" fuer `ldap.service.ts` auf geschlossen aktualisiert ## Decisions Made - **`withTenantTransaction()` fuer alle drei Transaktionsstellen gewaehlt, nicht nur wo der Plan es zwingend verlangte.** Die Einzelmessung aus Aufgabe 1 liess technisch zwei Formen bestehen (interaktiv auf gebundenem Client; interaktiv auf ungebundenem Client). Eine zusaetzliche, ueber den Plan hinausgehende Lastprobe (40 parallele Aufrufe) zeigte, dass die erste Form unter echter Nebenlaeufigkeit mit `P2028` (Transaction API error) abbricht — ein Denial-of-Service-Risiko genau an der von T-JTS-08 benannten Stelle (Startreparatur ueber mehrere Mandanten). Die zweite Form zeigte 0 Verletzungen unter derselben Last und wurde deshalb durchgehend gewaehlt. - **`addUserToDefaultGroup()` prueft jetzt den Mandanten des Zielbenutzers** (Befund E, T-JTS-02), nach dem Vorbild von `addMembers()`. Zwei Bestandstests brauchten dafuer einen zusaetzlichen `__seedUser`-Aufruf (die Methode wurde vorher mit einer nie existierenden `userId` getestet — eine Luecke im urspruenglichen Testaufbau, die die Umstellung sichtbar machte). - **Der veraltete Kommentar in `ModuleGrantsService.getUserAccess()` wurde ersetzt, nicht stehen gelassen** — nach der Bindung waere die alte Begruendung ("kein forTenant hier") aktiv irrefuehrend gewesen. - **`listForTenant()` bekam eine explizite `any[]`-Annotation** statt der impliziten `any`, nachdem eine minimale Reproduktion zeigte, dass TypeScript sonst `noImplicitAny` in JEDEM Aufrufer auslöst, der `.find()`/`.map()` auf dem Rueckgabewert aufruft — ein reiner `any`-Typ propagiert diese Pruefung nicht ab, ein `any[]`-Typ schon. ## Deviations from Plan None (Rule 1-3) — plan executed as written, with one auto-fixed mechanical issue: **1. [Rule 1 - Bug] TypeScript implicit-any errors in twelve test-file call sites after binding** - **Found during:** Task 2, type-check - **Issue:** `listForTenant()`'s inferred return type collapsed from a concrete Prisma-generated shape to a bare `any` once its underlying query ran through the `any`-cast `forTenant()` client; every caller in `groups.service.spec.ts` chaining `.find()`/`.map()` on that result tripped `noImplicitAny` (TS7006), confirmed via a minimal standalone repro before fixing broadly. - **Fix:** Annotated the intermediate `groups` variable inside `listForTenant()` as `any[]` instead of leaving it unannotated. - **Files modified:** apps/api/src/groups/groups.service.ts - **Verification:** `npm --prefix apps/api run type-check` returns 0. - **Committed in:** 7f08b27 (part of task commit) --- **Total deviations:** 1 auto-fixed (Rule 1, mechanical/type-inference correctness, no scope creep). **Impact on plan:** None — the fix was necessary to make the plan's own conversion type-clean; it touched no production behavior. ## Issues Encountered None beyond the deviation above. The RED-first TDD cycle for Aufgabe 2 and Aufgabe 3 both ran cleanly: the new binding-proof tests failed against the pre-conversion code for the expected reason (missing bound-call-log entries), then passed after conversion, without needing a second red/green iteration. ## User Setup Required None - no external service configuration required. The scratch-database check requires `TESSERA_SCRATCH_ADMIN_URL` (existing convention, not new to this plan). ## Measured Numbers (for the record) - `npm --prefix apps/api run test` → **743 tests green** (53 test files), baseline was 719 (+24: 13 new groups.service.ts binding tests, 6 new module-grants.service.ts binding tests, 4 new withTenantTransaction tests, 1 new inventory completeness test). - `npm --prefix apps/api run type-check` → **0**. - `node apps/api/scripts/rls-scratch-check.mjs` → **22/22 Pruefungen bestanden** (13 aus den Vorlaeufern + 9 neue Verhaltenspruefungen des Bereichs groups). Belegzeile: `group-ungebunden-null-zeilen: bestanden — ungebundener SELECT auf "Group" liefert 0 Zeile(n)`. - Transaktionsmessung, namentlich: `bestanden: [Form (ii) — interaktive Callback-Form auf gebundenem Client ; Form (iii) — interaktive Callback-Form auf ungebundenem Client (set_config auf tx)] — nicht bestanden: [Form (i) — Array-Form auf gebundenem Client]`. Zusaetzliche, ueber den Plan hinausgehende Lastprobe (40 parallele Aufrufe, nicht im Werkzeug persistiert, manuell im Kritikschrift-Abschnitt dokumentiert): Form (ii) brach mit `P2028` ab, Form (iii) zeigte 0 Verletzungen. - Klassifikationsdokument: 62 (Datei, Modell)-Paare (war 61), Klasse `muss-mandantengebunden` jetzt 33 (war 32). Bereichsuebersicht `groups`: 0 ungebunden / 31 gebunden (Rohtrefferzaehlung, dokumentierter Bodensatz — 9 weitere ueber `tx` gebundene Zugriffe zaehlt die einfache Grep-Konvention strukturell nicht, die rigorose (Datei,Modell)-Bestandsaufnahme sieht sie). - `git status --porcelain -- apps/api/prisma/schema.prisma apps/api/prisma/migrations docker-compose*.yml` → leer, bestaetigt ueber den gesamten Plan. - `DATABASE_URL` / Rolle `tessera` (BYPASSRLS) unveraendert — der Schalter bleibt AUS. ## Deferred to Later Stages 1. **Die offene Architekturfrage `req.tenantPrisma`** — auch `groups` entscheidet sie nicht; bindet dienst-intern wie `ldap` und `auth.service.ts`. 2. **Die zwei gemessenen Regel-Luecken (Befund E: `GroupMembership` prueft nur die Gruppenseite; Befund F: `ModuleGrant` prueft nur die eigene Mandantenkennung, nicht die referenzierte Gruppe)** bleiben Anwendungspruefungen — sie werden durch dieses Vorgehen NICHT durch eine Datenbankregel ersetzt. Eine etwaige Schemaerweiterung (z. B. eine zusammengesetzte Fremdschluessel-Regel) ist ausdruecklich nicht Teil dieses Durchlaufs (Schema-Tor greift nicht, aber eine Erweiterung waere ein Abbruchgrund gewesen — trat nicht ein). 3. **Der naechste Bereich der Etappe 2 ist `tenders`** (62 Rohtreffer, groesster verbleibender Bereich) — die in diesem Durchlauf gebaute `withTenantTransaction()`-Infrastruktur und die dritte Erkennung in `rls-access-inventory.spec.ts` sind wiederverwendbar, falls `tenders` ebenfalls mehrschrittige Transaktionen enthaelt (noch nicht gemessen). ## Next Phase Readiness Der Bereich `groups` der Etappe 2 ist per Erfolgskriterien dieses Plans vollstaendig geschlossen. Die Reihenfolgebedingung fuer Etappe 4 aus dem ldap-Durchlauf (Befund D) ist erfuellt. Naechster Bereich laut Klassen-Verteilung: `tenders` (62 Rohtreffer, groesster verbleibender Bereich der Etappe 2) — noch nicht dahingehend gemessen, ob dort eigene Transaktionen vorkommen; falls ja, ist `withTenantTransaction()` bereits vorhanden und muss nicht neu gebaut werden. --- *Phase: quick-260909-jts* *Completed: 2026-09-09* ## Self-Check: PASSED All 11 claimed files verified present on disk; all 3 claimed commit hashes (fd0b9f7, 7f08b27, abb6c8b) verified present in git history.