Files
tessera-ctl/.planning/quick/260909-jts-mandantentrennung-etappe-2-bereich-group/260909-jts-SUMMARY.md
T

18 KiB

phase, plan, subsystem, tags, requires, provides, affects, actuals, tech-stack, key-files, key-decisions, patterns-established, requirements-completed, duration, completed, status
phase plan subsystem tags requires provides affects actuals tech-stack key-files key-decisions patterns-established requirements-completed duration completed status
quick-260909-jts 01 database
prisma
postgresql
row-level-security
groups
module-grants
multi-tenancy
nestjs
phase provides
quick-260909-ipc 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
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
mandantentrennung-etappe-2-tenders
mandantentrennung-etappe-3
mandantentrennung-etappe-4
tokens tasks commits plan_head_before
26773 3 3 b532eaa
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: <bound-name>.$transaction(async (tx) => ...) and withTenantTransaction(<any>, tenantId, async (tx) => ...) — the latter is always bound by construction
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
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.
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'.
WINDOWS-20
ETAPPE-2-GROUPS
~70min 2026-09-09 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.