docs(quick-260909-jts): Etappe 2 Bereich groups abgeschlossen und verifiziert

This commit is contained in:
2026-09-09 15:19:27 +02:00
parent 604428a91d
commit 4cf7cea01f
3 changed files with 322 additions and 2 deletions
+3 -2
View File
@@ -374,6 +374,7 @@ None yet.
| 260909-cx0 | Dateisicherung nachgeruestet und Versionsangaben geradegezogen. **user-files** liegt jetzt in einem benannten Volume (docker-compose.yml und .prod.yml) — vorher lag der Ordner nur in der fluechtigen Container-Schicht, hochgeladene Profilbilder und DKV-Exporte waeren bei jedem --force-recreate weg gewesen. Benanntes Volume statt Bind-Mount, weil das Image /app/user-files an uid 1001 uebereignet; ein frisch angelegtes Host-Verzeichnis gehoert root und haette aus dem Datenverlust einen kaputten Upload gemacht. docker-compose.dev.yml blieb bewusst unveraendert (Compose fuehrt Mount-Listen ueber das Ziel zusammen). **CLAUDE.md** nennt jetzt die installierten Fassungen statt der urspruenglich empfohlenen (Next.js 15.5.19 statt 16, Prisma 6.19.3 statt 7); neu ist ein Abschnitt 'Recommended But Not Adopted', der sechs nie eingebaute Empfehlungen benennt — darunter Keycloak, Redis und shadcn/ui. Der Block ist generiert, deshalb traegt er einen Herkunftsvermerk und die Recherchedatei eine datierte Hinweiszeile; ihre Zahlen blieben unangetastet. Keine Abhaengigkeit angefasst (per git diff gegengeprueft). **WINDOWS #17 am 2026-09-09 geschlossen.** Beim Nachtragen auf dem Server kam heraus, dass dort gar nicht `docker-compose.yml` gilt: die `.env` setzt `COMPOSE_FILE=docker-compose.prod.yml`. Die drei Zeilen wurden in dieser Datei ergaenzt (Sicherung `docker-compose.prod.yml.bak.20260909-0818`). Nach dem Neuerstellen durch den User belegt: Mount `tessera_user-files -> /app/user-files` vorhanden, Ordner gehoert uid 1001 (das benannte Volume hat die Eigentuemerschaft uebernommen), Schreiben als Dienstnutzer funktioniert, und eine Probedatei lag tatsaechlich unter /var/lib/docker/volumes/tessera_user-files/_data auf dem Host — also ausserhalb des Containers | 2026-09-09 | c807049 | [260909-cx0-dateisicherung-nachruesten-und-versionsa](./quick/260909-cx0-dateisicherung-nachruesten-und-versionsa/) | | 260909-cx0 | Dateisicherung nachgeruestet und Versionsangaben geradegezogen. **user-files** liegt jetzt in einem benannten Volume (docker-compose.yml und .prod.yml) — vorher lag der Ordner nur in der fluechtigen Container-Schicht, hochgeladene Profilbilder und DKV-Exporte waeren bei jedem --force-recreate weg gewesen. Benanntes Volume statt Bind-Mount, weil das Image /app/user-files an uid 1001 uebereignet; ein frisch angelegtes Host-Verzeichnis gehoert root und haette aus dem Datenverlust einen kaputten Upload gemacht. docker-compose.dev.yml blieb bewusst unveraendert (Compose fuehrt Mount-Listen ueber das Ziel zusammen). **CLAUDE.md** nennt jetzt die installierten Fassungen statt der urspruenglich empfohlenen (Next.js 15.5.19 statt 16, Prisma 6.19.3 statt 7); neu ist ein Abschnitt 'Recommended But Not Adopted', der sechs nie eingebaute Empfehlungen benennt — darunter Keycloak, Redis und shadcn/ui. Der Block ist generiert, deshalb traegt er einen Herkunftsvermerk und die Recherchedatei eine datierte Hinweiszeile; ihre Zahlen blieben unangetastet. Keine Abhaengigkeit angefasst (per git diff gegengeprueft). **WINDOWS #17 am 2026-09-09 geschlossen.** Beim Nachtragen auf dem Server kam heraus, dass dort gar nicht `docker-compose.yml` gilt: die `.env` setzt `COMPOSE_FILE=docker-compose.prod.yml`. Die drei Zeilen wurden in dieser Datei ergaenzt (Sicherung `docker-compose.prod.yml.bak.20260909-0818`). Nach dem Neuerstellen durch den User belegt: Mount `tessera_user-files -> /app/user-files` vorhanden, Ordner gehoert uid 1001 (das benannte Volume hat die Eigentuemerschaft uebernommen), Schreiben als Dienstnutzer funktioniert, und eine Probedatei lag tatsaechlich unter /var/lib/docker/volumes/tessera_user-files/_data auf dem Host — also ausserhalb des Containers | 2026-09-09 | c807049 | [260909-cx0-dateisicherung-nachruesten-und-versionsa](./quick/260909-cx0-dateisicherung-nachruesten-und-versionsa/) |
| 260909-dgj | Mandantentrennung auf Datenbankebene vorbereitet (WINDOWS #18). Ausloeser war ein gemessener Befund: die Anwendung verbindet als Rolle mit Superuser- und BYPASSRLS-Recht, daher greifen die sieben vorhandenen Policies gar nicht — ohne gesetzten Mandantenkontext lieferte 'SELECT count(*) FROM Group' zwei statt null Zeilen. Gebaut wurden: Rolle `tessera_app` ohne Umgehungsrecht (wiederholbare Migration, kein Kennwort im SQL), Trennung von Migrations- und Laufzeitverbindung ueber TESSERA_MIGRATE_DATABASE_URL, ein Pruefwerkzeug mit fuenf transaktionssicheren Nachweisen, Policies fuer die 16 fehlenden Tabellen und eine Betriebsanleitung. **Der Schalter bleibt bewusst aus, #18 bleibt offen:** im Code stehen 182 Datenbankzugriffe ohne Mandantenkontext gegen 19 mit — darunter zwingend der Anmeldeweg, der die Benutzerzeile liest, bevor der Mandant bekannt ist (er kommt erst aus dieser Zeile). Ein Umschalten wuerde die Anmeldung fuer alle sperren. Dabei fiel #19 an: SearchProvider und TenderRssFeedSource haben ein nullable tenantId; die einfache Policy wuerde die plattformweiten Zeilen nach dem Scharfschalten fuer jeden Mandanten unsichtbar machen. 673/673 Tests gruen | 2026-09-09 | efaabc9 | [260909-dgj-mandantentrennung-auf-alle-tabellen-mit-](./quick/260909-dgj-mandantentrennung-auf-alle-tabellen-mit-/) | | 260909-dgj | Mandantentrennung auf Datenbankebene vorbereitet (WINDOWS #18). Ausloeser war ein gemessener Befund: die Anwendung verbindet als Rolle mit Superuser- und BYPASSRLS-Recht, daher greifen die sieben vorhandenen Policies gar nicht — ohne gesetzten Mandantenkontext lieferte 'SELECT count(*) FROM Group' zwei statt null Zeilen. Gebaut wurden: Rolle `tessera_app` ohne Umgehungsrecht (wiederholbare Migration, kein Kennwort im SQL), Trennung von Migrations- und Laufzeitverbindung ueber TESSERA_MIGRATE_DATABASE_URL, ein Pruefwerkzeug mit fuenf transaktionssicheren Nachweisen, Policies fuer die 16 fehlenden Tabellen und eine Betriebsanleitung. **Der Schalter bleibt bewusst aus, #18 bleibt offen:** im Code stehen 182 Datenbankzugriffe ohne Mandantenkontext gegen 19 mit — darunter zwingend der Anmeldeweg, der die Benutzerzeile liest, bevor der Mandant bekannt ist (er kommt erst aus dieser Zeile). Ein Umschalten wuerde die Anmeldung fuer alle sperren. Dabei fiel #19 an: SearchProvider und TenderRssFeedSource haben ein nullable tenantId; die einfache Policy wuerde die plattformweiten Zeilen nach dem Scharfschalten fuer jeden Mandanten unsichtbar machen. 673/673 Tests gruen | 2026-09-09 | efaabc9 | [260909-dgj-mandantentrennung-auf-alle-tabellen-mit-](./quick/260909-dgj-mandantentrennung-auf-alle-tabellen-mit-/) |
| 260909-ipc | Mandantentrennung Etappe 2, Bereich ldap: alle 21 klassifizierten Zugriffe in `ldap-config.service.ts` (9) und `ldap.service.ts` (12) an `forTenant()` gebunden. **Zuerst gemessen, dann gebaut:** `rls-scratch-check.mjs` um `runLdapAreaChecks` erweitert (13/13 bestanden), Beleg ist die Zeile `ldapconfig-ungebunden-null-zeilen` gegen die echte, ausgelieferte Policy — die Umkehr der Fehlerrichtung ist damit gemessen, nicht behauptet. Die geforderte Kritikschrift liegt in `docs/mandantentrennung-etappe2-fehlerrichtung.md` mit Signaltabelle je Pfad und vier namentlich benannten Stellen, die Leere als Abwesenheit deuten. **Sicherheitsluecke nebenbei geschlossen (T-IPC-01):** `DELETE /ldap/config/mappings/:id` nahm nur die Kennung — ein Administrator von Mandant A konnte die Feldzuordnung von B loeschen; der Mandant kommt jetzt aus der Sitzung. **Drei Stellen bleiben bewusst ungebunden, jede mit Begruendung im Code:** `getAllActiveConfigs` und die Start-Nachverschluesselung lesen zwingend uebergreifend; `resolveEmailForWrite` darf nicht gebunden werden, weil `email`/`username` plattformweit eindeutig sind — gebunden saehe die Kollisionspruefung keinen fremden Halter, meldete 'frei', und aus einer sauber berichteten Kollision wuerde ein P2002-Abbruch (Produktfrage fuer Etappe 3). **Befund, der die Testlage aendert:** `forTenant` war in `ldap.service.spec.ts` als Identitaet gemockt — die Tests haetten den Umbau in keiner Richtung bemerkt; ersetzt durch zwei unterscheidbare Clients. `rls-access-inventory.spec.ts` um eine `Stand`-Spalte und Erkennung gebundener Fundstellen erweitert, dabei zwei bisher unbekannte Paare gefunden (`auth.service.ts`/`passwordResetToken`, `ldap.service.ts`/`groupMembership`), Klassifikationsdokument auf 61 Paare nachgezogen. Schema, Migrationen, Compose und Umgebungsdateien unberuehrt, Schalter weiter aus. **Verifiziert 7/7** (unabhaengig nachgemessen: 719/719 Tests, Typpruefung sauber, 13/13 Live-Pruefungen gegen den echten Container) | 2026-09-09 | a0c9ef0,9a57fa7,e1586a4 | [260909-ipc-mandantentrennung-etappe-2-bereich-ldap-](./quick/260909-ipc-mandantentrennung-etappe-2-bereich-ldap-/) | | 260909-ipc | Mandantentrennung Etappe 2, Bereich ldap: alle 21 klassifizierten Zugriffe in `ldap-config.service.ts` (9) und `ldap.service.ts` (12) an `forTenant()` gebunden. **Zuerst gemessen, dann gebaut:** `rls-scratch-check.mjs` um `runLdapAreaChecks` erweitert (13/13 bestanden), Beleg ist die Zeile `ldapconfig-ungebunden-null-zeilen` gegen die echte, ausgelieferte Policy — die Umkehr der Fehlerrichtung ist damit gemessen, nicht behauptet. Die geforderte Kritikschrift liegt in `docs/mandantentrennung-etappe2-fehlerrichtung.md` mit Signaltabelle je Pfad und vier namentlich benannten Stellen, die Leere als Abwesenheit deuten. **Sicherheitsluecke nebenbei geschlossen (T-IPC-01):** `DELETE /ldap/config/mappings/:id` nahm nur die Kennung — ein Administrator von Mandant A konnte die Feldzuordnung von B loeschen; der Mandant kommt jetzt aus der Sitzung. **Drei Stellen bleiben bewusst ungebunden, jede mit Begruendung im Code:** `getAllActiveConfigs` und die Start-Nachverschluesselung lesen zwingend uebergreifend; `resolveEmailForWrite` darf nicht gebunden werden, weil `email`/`username` plattformweit eindeutig sind — gebunden saehe die Kollisionspruefung keinen fremden Halter, meldete 'frei', und aus einer sauber berichteten Kollision wuerde ein P2002-Abbruch (Produktfrage fuer Etappe 3). **Befund, der die Testlage aendert:** `forTenant` war in `ldap.service.spec.ts` als Identitaet gemockt — die Tests haetten den Umbau in keiner Richtung bemerkt; ersetzt durch zwei unterscheidbare Clients. `rls-access-inventory.spec.ts` um eine `Stand`-Spalte und Erkennung gebundener Fundstellen erweitert, dabei zwei bisher unbekannte Paare gefunden (`auth.service.ts`/`passwordResetToken`, `ldap.service.ts`/`groupMembership`), Klassifikationsdokument auf 61 Paare nachgezogen. Schema, Migrationen, Compose und Umgebungsdateien unberuehrt, Schalter weiter aus. **Verifiziert 7/7** (unabhaengig nachgemessen: 719/719 Tests, Typpruefung sauber, 13/13 Live-Pruefungen gegen den echten Container) | 2026-09-09 | a0c9ef0,9a57fa7,e1586a4 | [260909-ipc-mandantentrennung-etappe-2-bereich-ldap-](./quick/260909-ipc-mandantentrennung-etappe-2-bereich-ldap-/) |
| 260909-jts | Mandantentrennung Etappe 2, Bereich groups — die Berechtigungsschicht. Alle 34 echten Zugriffe in `groups.service.ts` (21) und `module-grants.service.ts` (13) gebunden, dazu fuenf Zugriffe, die **keine Pruefung dieses Projekts je gesehen hatte**: sie laufen innerhalb einer Transaktion ueber den Callback-Parameter, den der Detektor der Inventarpruefung nicht kannte — einer davon ist der Schreibvorgang, der Modulfreigaben vergibt. Das Paar `(groups.service.ts, tenantModuleActivation)` fehlte im Klassifikationsdokument komplett und ist ergaenzt; der Detektor sieht jetzt auch Transaktionsparameter. **Kernbefund — neues Hilfsmittel `withTenantTransaction()`:** die Frage, welche Transaktionsform den Mandantenkontext auf derselben Verbindung traegt, wurde an der lebenden Datenbank gemessen statt angenommen. Form (i) faellt durch (zwei verschiedene `pg_backend_pid()`), Form (ii) besteht die Einzelmessung, bricht aber unter 40 gleichzeitigen Aufrufen mit P2028 ab, weil jeder Aufruf eine verschachtelte Transaktion aus demselben endlichen Verbindungsvorrat oeffnet; Form (iii) besteht beides. Alle weiteren Bereiche bauen darauf auf. **Die Lastprobe war zunaechst nur Fliesstext** — eine Zahl, die eine Entscheidung trug, ohne nachvollziehbar zu sein; vom Verifizierer beanstandet und als `runConcurrencyProbe` nachgereicht (604428a), laeuft seither bei jedem Werkzeuglauf mit: 24 Verletzungen von 40 fuer Form (ii), 0 von 40 fuer Form (iii). **Zwei Datenbankregeln greifen kuerzer als gedacht** und sind bewusst nur gemessen und festgehalten, nicht repariert: die Regel fuer Gruppenmitgliedschaften prueft nur die Gruppen-, nicht die Benutzerseite (T-JTS-02), die fuer Modulfreigaben nur die Mandantenkennung, nicht die referenzierte Gruppe (T-JTS-03) — dort haengt der Schutz allein an `assertTargetBelongsToTenant`. Umgekehrter Gefahrenfall geschlossen: `ensureDefaultGroup` deutet Leere als 'Mandant hat noch keine Gruppe' und baut alles neu auf, eine halb umgestellte Fassung haette eine zweite Standardgruppe samt Freigaben erzeugt — Zaehler und Transaktion sind deshalb gemeinsam gebunden. Beide Testdateien hatten gar keine Attrappe fuer den Helfer, waeren nach der Umstellung also aus dem falschen Grund rot gewesen; ersetzt durch den Zwei-Client-Nachweis, vom Verifizierer durch Rueckbau falsifiziert. Schema, Migrationen, Compose und Umgebungsdateien unberuehrt, Schalter aus. **Verifiziert 9/9** (743/743 Tests, Typpruefung sauber, 23/23 Live-Pruefungen) | 2026-09-09 | fd0b9f7,7f08b27,abb6c8b,604428a | [260909-jts-mandantentrennung-etappe-2-bereich-group](./quick/260909-jts-mandantentrennung-etappe-2-bereich-group/) |
| 260909-eor | Etappe 1 der Mandantentrennung: Anmeldeweg mandantenfaehig gemacht und alle Zugriffe klassifiziert. **Kernfund (#20):** `forTenant()` setzte den Mandantenkontext per set_config auf der Transaktionsverbindung, dispatchte die Abfrage aber ueber den aeusseren Client — empirisch reproduziert (set_config auf Backend-PID 254999, Abfrage auf 255000, Kontext dort NULL). Die Trennung hat damit nie funktioniert, auch nicht an den Stellen, die sie scheinbar nutzten; nach dem Scharfschalten haetten diese Abfragen NULL Zeilen geliefert, was der LDAP-Loeschzweig als 'Gruppe im Verzeichnis verschwunden' gedeutet und geloescht haette. Behoben und live nachgewiesen. Der Anmeldeweg bekam drei SECURITY-DEFINER-Funktionen als schmale Ausnahme (feste Spaltenliste, Gleichheitsbedingung, LIMIT 1) — eine Policy haette nicht gereicht, weil sie zwangslaeufig die ganze Tabelle freigibt. Browser-Gegenprobe lokal bestanden: Anmeldung laedt das Portal, falsches Kennwort verraet weiterhin nicht welches Feld, Kennwort-vergessen laeuft durch (der einzige Protokollfehler war ein lokal fehlender Mailserver, also NACH dem Datenbankzugriff). Klassifikation aller 227 Zugriffe in 59 Einheiten, maschinell gegen Abdriften abgesichert: 31 muessen mandantengebunden werden, 9 teilweise, 16 betreffen keine mandantengebundene Tabelle, 3 bleiben bewusst uebergreifend. 701 Tests gruen | 2026-09-09 | da0ac04 | [260909-eor-anmeldeweg-mandantenfaehig-machen-und-al](./quick/260909-eor-anmeldeweg-mandantenfaehig-machen-und-al/) | | 260909-eor | Etappe 1 der Mandantentrennung: Anmeldeweg mandantenfaehig gemacht und alle Zugriffe klassifiziert. **Kernfund (#20):** `forTenant()` setzte den Mandantenkontext per set_config auf der Transaktionsverbindung, dispatchte die Abfrage aber ueber den aeusseren Client — empirisch reproduziert (set_config auf Backend-PID 254999, Abfrage auf 255000, Kontext dort NULL). Die Trennung hat damit nie funktioniert, auch nicht an den Stellen, die sie scheinbar nutzten; nach dem Scharfschalten haetten diese Abfragen NULL Zeilen geliefert, was der LDAP-Loeschzweig als 'Gruppe im Verzeichnis verschwunden' gedeutet und geloescht haette. Behoben und live nachgewiesen. Der Anmeldeweg bekam drei SECURITY-DEFINER-Funktionen als schmale Ausnahme (feste Spaltenliste, Gleichheitsbedingung, LIMIT 1) — eine Policy haette nicht gereicht, weil sie zwangslaeufig die ganze Tabelle freigibt. Browser-Gegenprobe lokal bestanden: Anmeldung laedt das Portal, falsches Kennwort verraet weiterhin nicht welches Feld, Kennwort-vergessen laeuft durch (der einzige Protokollfehler war ein lokal fehlender Mailserver, also NACH dem Datenbankzugriff). Klassifikation aller 227 Zugriffe in 59 Einheiten, maschinell gegen Abdriften abgesichert: 31 muessen mandantengebunden werden, 9 teilweise, 16 betreffen keine mandantengebundene Tabelle, 3 bleiben bewusst uebergreifend. 701 Tests gruen | 2026-09-09 | da0ac04 | [260909-eor-anmeldeweg-mandantenfaehig-machen-und-al](./quick/260909-eor-anmeldeweg-mandantenfaehig-machen-und-al/) |
## Deferred Items ## Deferred Items
@@ -416,6 +417,6 @@ sind. Kein Anlass, sie vorher erneut vorzulegen.
Last session: 2026-09-09T12:13:05.681Z Last session: 2026-09-09T12:13:05.681Z
Resumed: 2026-09-09 — Sitzung ueber /gsd-resume-work fortgesetzt; Einstiegspunkt Etappe 2 (Bereich ldap), Auswahl des Nutzers steht aus. Resumed: 2026-09-09 — Sitzung ueber /gsd-resume-work fortgesetzt; Einstiegspunkt Etappe 2 (Bereich ldap), Auswahl des Nutzers steht aus.
Stopped at: Etappe 2 Bereich ldap abgeschlossen (Quick 260909-ipc). Naechster Bereich laut .continue-here.md: groups (37 Zugriffe). Stopped at: Etappe 2, zwei von elf Bereichen durch (ldap 260909-ipc, groups 260909-jts). Naechster Bereich: tenders (62 Zugriffe), danach dkv, user, module-registry, dashboard, calendar, tenant, favorites, settings. Der User hat am 2026-09-09 gesagt, die restlichen Bereiche sollen ohne Rueckfrage durchlaufen; beim Scharfschalten (Etappe 4) wird ausdruecklich angehalten.
Resume file: None Resume file: None
Last activity: 2026-09-09 - Completed quick task 260909-ipc: Mandantentrennung Etappe 2, Bereich ldap (verifiziert 7/7) Last activity: 2026-09-09 - Completed quick task 260909-jts: Mandantentrennung Etappe 2, Bereich groups (verifiziert 9/9)
@@ -0,0 +1,161 @@
---
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: <bound-name>.$transaction(async (tx) => ...) and withTenantTransaction(<any>, 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.
@@ -0,0 +1,158 @@
---
phase: quick-260909-jts
verified: 2026-09-09T15:20:00Z
status: human_needed
score: 9/9 must-haves verified
covered_files: [".planning/quick/260909-jts-mandantentrennung-etappe-2-bereich-group/260909-jts-PLAN.md", ".planning/quick/260909-jts-mandantentrennung-etappe-2-bereich-group/260909-jts-SUMMARY.md", "apps/api/scripts/rls-scratch-check.mjs", "apps/api/src/groups/groups.service.spec.ts", "apps/api/src/groups/groups.service.ts", "apps/api/src/groups/module-grants.service.spec.ts", "apps/api/src/groups/module-grants.service.ts", "apps/api/src/prisma/prisma-tenant.extension.spec.ts", "apps/api/src/prisma/prisma-tenant.extension.ts", "apps/api/src/prisma/rls-access-inventory.spec.ts", "docs/mandantentrennung-etappe2-fehlerrichtung.md", "docs/mandantentrennung-zugriffsklassifikation.md"]
covered_digest: "v1:sha256:dea34c100463f58bc502305488bdafde6a28c9aa59645e0c276a985d19ebdc56"
behavior_unverified: 0
overrides_applied: 0
human_verification:
- test: "Decide whether the 40-parallel-call concurrency probe (Form (ii) fails with P2028, Form (iii) shows 0 violations) is acceptable as permanent, uncommitted evidence baked into prisma-tenant.extension.ts's header comment and docs/mandantentrennung-etappe2-fehlerrichtung.md — or whether it must be committed as a reproducible script (e.g. a fifth section in rls-scratch-check.mjs) before the claim stands as fact for later stages."
expected: "Either: (a) a maintainer accepts the uncommitted claim as sufficient given the chosen implementation (withTenantTransaction/Form iii) is independently, reproducibly verified correct by the single-shot measurement regardless of the load-probe outcome, and the language in the two documents is left as-is or softened to 'observed once, not reproducible from committed code'; or (b) the load probe is committed as an executable script so a later verifier (or CI) can reproduce it."
why_human: "This is a documentation-trust judgment call, not a code defect. The single-shot measurement (committed, reproducible, independently re-run by this verifier — see below) genuinely shows Form (ii) and Form (iii) both carry the tenant context on the same connection, and the code correctly uses Form (iii) via withTenantTransaction() everywhere the plan required a transaction. But the SPECIFIC reason given for preferring Form (iii) over Form (ii) — a 40-parallel-call load test throwing PrismaClientKnownRequestError P2028 — exists ONLY as prose in the SUMMARY and in two permanent documents (prisma-tenant.extension.ts's header comment, docs/mandantentrennung-etappe2-fehlerrichtung.md); no test file, script, or fixture implementing this load probe was committed in any of the three task commits (fd0b9f7, 7f08b27, abb6c8b) or is present anywhere in the current tree. The SUMMARY itself admits this ('nicht im Werkzeug persistiert, manuell im Kritikschrift-Abschnitt dokumentiert' / 'gegen eine separate Experiment-Datenbank'). Per this project's documented anti-pattern of numbers that turn out not to be what they claim, an unreproducible claim stated as measured fact (with specific PIDs and an exact error code) in a header comment that future stages are told to trust ('vor jedem neuen Fall erneut pruefen, nicht von hier abschreiben') deserves a maintainer decision, not a silent pass."
---
# Quick Task 260909-jts: Mandantentrennung Etappe 2, Bereich `groups` — Verification Report
**Task Goal:** Convert every tenant-bound database access in `groups.service.ts` and
`module-grants.service.ts` to run through a bound client, including the five accesses
inside `ensureDefaultGroup`'s interactive transaction that no inventory check in this
project had ever seen; keep the classification document and its machine guard in sync
with the code.
**Verified:** 2026-09-09
**Status:** human_needed (all 9 must-have truths independently verified; one
documentation-trust item flagged for a maintainer decision — see Human Verification
below. No code, test, or coverage gap found.)
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
|---|---|---|---|
| 1 | Every tenant-bound DB access in `groups` runs through a bound client, including the five accesses inside `ensureDefaultGroup`'s interactive transaction | ✓ VERIFIED | `grep -c "this\.prisma\." groups.service.ts module-grants.service.ts` → 0/0. All 5 `ensureDefaultGroup` callback-parameter accesses (`tx.group.create`, `tx.user.findMany`, `tx.groupMembership.createMany`, `tx.tenantModuleActivation.findMany`, `tx.moduleGrant.createMany`) confirmed read at `groups.service.ts:365-397`, all running on `tx` handed in by `withTenantTransaction()`. Independently reverted one of the five to `this.prisma.tenantModuleActivation.findMany` — both `rls-access-inventory.spec.ts` and `groups.service.spec.ts`'s `ensureDefaultGroup()` binding test failed with a specific, named assertion; reverted back, re-ran, both green (see Behavioral Spot-Checks). |
| 2 | Which transaction form carries the tenant context on the SAME connection is measured under a non-BYPASSRLS role BEFORE the three transaction sites are converted | ✓ VERIFIED | `runTransactionShapeMeasurement()` in `apps/api/scripts/rls-scratch-check.mjs:903-936` independently re-run against `tessera-ctl-db-1` (see Probe Execution) — reproduces the exact named result documented in the plan/SUMMARY: Form (i) fails (two different `pg_backend_pid()`), Form (ii) and Form (iii) both pass the single-shot check. The header comment of `prisma-tenant.extension.ts:59-103` records this result before any of the three conversion sites in `groups.service.ts` were touched (task ordering: Aufgabe 1 commit fd0b9f7 precedes Aufgabe 2 commit 7f08b27). |
| 3 | The default-group handoff immediately before a group deletion (`reassignDefaultBeforeDelete`, then `ensureDefaultGroup`) is bound; ldap-critique Befund D is closed and marked closed | ✓ VERIFIED | `reassignDefaultBeforeDelete()` (`groups.service.ts:437-478`) and `ensureDefaultGroup()` (`groups.service.ts:356-408`) both fully bound. `docs/mandantentrennung-etappe2-fehlerrichtung.md:149-156` appends a "Nachtrag (260909-jts, Aufgabe 3): GESCHLOSSEN" note under the original Befund D text — original text preserved, not rewritten. |
| 4 | A written critique exists for `groups` naming the concrete signal per path, including the one path where a too-small read causes too MUCH write | ✓ VERIFIED | `docs/mandantentrennung-etappe2-fehlerrichtung.md:169-358`, section "## Bereich groups" — (g1) measurement with actually-observed values, (g2) 7-row signal table, (g3) names the inverted `ensureDefaultGroup` guard explicitly ("die einzige Stelle des Bereichs, an der zu wenig Lesen zu ZU VIEL Schreiben führt"), (g4) what remains unsolved, (g5) ldap cross-reference. Substantive, not a stub. |
| 5 | The machine safeguard sees model access through an interactive transaction's callback parameter; a missing tenant context there can no longer go undetected | ✓ VERIFIED | `rls-access-inventory.spec.ts:23-37, 133-189` — third detection for `<receiver>.$transaction(async (tx) => ...)` and `withTenantTransaction(<receiver>, tenantId, async (tx) => ...)`. Confirmed by reversion test (see Truth 1): reverting one access to unbound fails the inventory spec with a named diff. |
| 6 | Both spec files can go red on an unbound finding, proven via two distinguishable clients, not merely asserted | ✓ VERIFIED | `groups.service.spec.ts:37-323` and equivalent in `module-grants.service.spec.ts` build `__makeBoundClient`/`__withTenantTransaction` as a second, distinguishable object over the same backing Maps; `expectBoundCall()` asserts a call-log entry exists. Reversion test above shows the specific `ensureDefaultGroup()` binding test fails with `erwarteter gebundener Aufruf tenantModuleActivation.findMany(tenant=t1) fehlt im Protokoll` when the binding is removed — a genuine, not tautological, red. |
| 7 | `addUserToDefaultGroup` checks the target user's tenant; that the `GroupMembership` policy does NOT do this is measured | ✓ VERIFIED | `groups.service.ts:495-515` adds `tenantPrisma.user.findFirst({ where: { id: userId, tenantId } })` before creating the membership. Test `addUserToDefaultGroup() mit einem Zielbenutzer eines fremden Mandanten...` (`groups.service.spec.ts:1057-1066`) passes. Scratch-check `groupmembership-schreiben-fremder-benutzer-nicht-verhindert: bestanden` reproduced live (see Probe Execution), confirming the policy gap this application check exists to cover. |
| 8 | Classification document and machine safeguard show the same, measured state `gebunden` for all pairs of the area | ✓ VERIFIED | 10 rows for `groups.service.ts`/`module-grants.service.ts` in `docs/mandantentrennung-zugriffsklassifikation.md:195-204`, all `gebunden`, including the previously-invisible `(groups.service.ts, tenantModuleActivation)` pair. `npm run test -- rls-access-inventory.spec.ts` passes (10/10), including the "stand stimmt mit dem im Quelltext gemessenen ueberein" check that would fail on any mismatch. |
| 9 | 719+ tests and type-check green; schema, migrations, all four compose files unchanged; the cutover switch stays OFF | ✓ VERIFIED | Independently re-ran the affected test files (107/107 pass) and `type-check` (exit 0). Orchestrator-measured full suite: 743/743 (53 files), matches SUMMARY. `git diff --name-only b532eaa..HEAD -- apps/api/prisma docker-compose*.yml .env*` → empty. `.env`/`docker-compose.yml` confirm `DATABASE_URL` still on role `tessera`. |
**Score:** 9/9 truths verified (0 present-but-behavior-unverified)
### Investigation Notes — The Concurrency (Load-Probe) Claim
The SUMMARY and the two permanent documents (`prisma-tenant.extension.ts` header
comment, `docs/mandantentrennung-etappe2-fehlerrichtung.md` §g1) assert, as measured
fact, that a 40-parallel-call load test showed Form (ii) — interactive transaction on
a *bound* client — failing with `PrismaClientKnownRequestError ... P2028`, while Form
(iii) — the chosen `withTenantTransaction()` pattern — showed 0 violations. This claim
is **not reproducible from the committed codebase**: `rls-scratch-check.mjs` contains
only `runTransactionShapeMeasurement()`, the single-shot measurement (reproduced
below), with no load/concurrency test. `grep -rn "40 parallel\|P2028"` across the repo
finds it only in prose (the two documents above), never in code. The SUMMARY concedes
this directly: *"nicht im Werkzeug persistiert, manuell im Kritikschrift-Abschnitt
dokumentiert"* and *"gegen eine separate Experiment-Datenbank"* (a database that no
longer exists and was never part of any commit).
This does **not** invalidate the implementation: the actually-shipped pattern
(`withTenantTransaction()`, Form iii, used consistently across all three transaction
sites) is independently, reproducibly verified correct by the committed single-shot
measurement — Form (iii) genuinely carries the tenant context on the same connection,
confirmed by this verifier's own re-run (see Probe Execution). The concern is narrower:
a specific, dramatic, unreproducible number (`P2028`, "0 violations under 40 parallel
calls") is stated as settled fact in a header comment that explicitly tells future
readers not to re-derive it ("nicht von hier abschreiben" notwithstanding — the
instruction is to re-measure for the *next* area, not to distrust *this* area's
number). Per this project's documented anti-pattern of inflated/unverifiable figures,
this is flagged for a maintainer decision rather than silently accepted or silently
used to fail the phase. See `human_verification` above.
### Required Artifacts
| Artifact | Expected | Status | Details |
|---|---|---|---|
| `apps/api/src/prisma/prisma-tenant.extension.ts` | `withTenantTransaction()` helper, sets context on `tx` itself | ✓ VERIFIED | Read in full (lines 119-146). Sets `set_config` as the first statement directly on `tx` via a tagged template, then hands the same `tx` to `fn` — every subsequent statement in `fn` runs on the same connection. This is precisely the fix for the stage-1 defect (context on one PID, query on another). |
| `apps/api/scripts/rls-scratch-check.mjs` | `runGroupsAreaChecks` + `runTransactionShapeMeasurement` | ✓ VERIFIED | Both present and independently re-run against the live `tessera-ctl-db-1` container — 22/22 checks passed (see Probe Execution). |
| `apps/api/src/prisma/rls-access-inventory.spec.ts` | Third detection for transaction-callback-parameter access | ✓ VERIFIED | Present, logic read in full, reversion-tested (fails as expected). |
| `apps/api/src/groups/groups.service.ts` | All 12 methods bound, 3 transaction sites on `withTenantTransaction()` | ✓ VERIFIED | Read in full; 0 remaining `this.prisma.<model>` occurrences. |
| `apps/api/src/groups/module-grants.service.ts` | All 5 methods bound | ✓ VERIFIED | Read in full; 0 remaining `this.prisma.<model>` occurrences. |
| `docs/mandantentrennung-etappe2-fehlerrichtung.md` | `## Bereich groups` section | ✓ VERIFIED | Substantive, ~190 lines, matches plan's (g1)-(g5) structure. |
| `docs/mandantentrennung-zugriffsklassifikation.md` | 9+ rows for the area, all `gebunden`, previously-invisible pair present | ✓ VERIFIED | 10 rows present, all `gebunden`; overview table recount matches `grep` reproduction (0 ungebunden / 31 gebunden). |
### Key Link Verification
| From | To | Via | Status | Details |
|---|---|---|---|---|
| Bound client (`forTenant`/`withTenantTransaction`) | `tenant_isolation_policy` on `Group`/`GroupMembership`/`ModuleGrant`/`TenantModuleActivation` | Policies extracted verbatim from shipped migrations, not retyped | ✓ WIRED | `runGroupsAreaChecks` extracts policy SQL from `20260804130918_groups_rls_policies` and `20260909140000_rls_remaining_tenant_tables`; independently re-run, all 8 area-specific checks passed against the live DB. |
| Interactive transaction in `ensureDefaultGroup` | `set_config` on the same connection | `withTenantTransaction()` | ✓ WIRED | Confirmed by code read and by the reversion experiment: removing the binding on any of the 5 accesses breaks both the unit-test binding proof and the machine inventory. |
| `reassignDefaultBeforeDelete` | Deletion branch in `ldap.service.ts` | Silent-`false` handoff (Befund D) | ✓ WIRED | `ldap.service.ts`'s `syncBoundGroupsForTenant` calls `reassignDefaultBeforeDelete` before deleting; both are bound (this task's scope was the `groups.service.ts` side only — the `ldap.service.ts` call site itself was already bound in the prior 260909-ipc task, not re-verified here as it is out of this task's file scope). |
| `ensureDefaultGroup` | Its 4 callers (`ldap.service.ts`, `tenant.service.ts`, `admin-seed.service.ts` x2) | Startup must not break | ✓ WIRED | All pre-existing tests of these paths remain green (part of the 107/107 targeted re-run and the orchestrator's 743/743 full-suite measurement); no caller signature changed. |
| `rls-access-inventory.spec.ts` | `Stand` column of the classification document | Comparison of measured vs. documented state | ✓ WIRED | `der eingetragene Stand stimmt mit dem im Quelltext gemessenen ueberein` test passes; reversion-tested to confirm it is a real check, not a tautology. |
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|---|---|---|---|
| Reverting one of the 5 previously-invisible transaction-parameter accesses to a direct, unbound `this.prisma.X` call breaks the machine inventory | `sed -i` revert `tx.tenantModuleActivation.findMany` → `this.prisma.tenantModuleActivation.findMany`; `npm test -- rls-access-inventory.spec.ts` | `apps/api/src/groups/groups.service.ts::tenantModuleActivation — dokumentiert=gebunden, gemessen=ungebunden` — 1 failed, 9 passed | ✓ PASS (regression fails as expected) |
| Same revert breaks the `ensureDefaultGroup()` unit-test binding proof | `npm test -- groups.service.spec.ts -t ensureDefaultGroup` | `erwarteter gebundener Aufruf tenantModuleActivation.findMany(tenant=t1) fehlt im Protokoll` — 1 failed, 8 passed, 46 skipped | ✓ PASS (regression fails as expected) |
| Revert undone, both suites green again | restore from backup; re-run both | 10/10 and 55/55 pass | ✓ PASS |
| `type-check` clean after restore | `npm --prefix apps/api run type-check` | exit 0 | ✓ PASS |
| No remaining unbound model access in either converted file | `grep -c "this\.prisma\.[a-zA-Z]\+"` on both files | `0` / `0` | ✓ PASS |
| No new migration, schema, or compose change | `git diff --name-only b532eaa..HEAD -- apps/api/prisma docker-compose*.yml .env*` | empty | ✓ PASS |
| `DATABASE_URL` still on role `tessera` (switch OFF) | `grep DATABASE_URL .env docker-compose.yml` | `postgresql://tessera:...` in all files | ✓ PASS |
### Probe Execution
| Probe | Command | Result | Status |
|---|---|---|---|
| `apps/api/scripts/rls-scratch-check.mjs` | `TESSERA_SCRATCH_ADMIN_URL=... node apps/api/scripts/rls-scratch-check.mjs` against live `tessera-ctl-db-1` (172.19.0.2, freshly re-resolved) | `Alle 22 Pruefungen bestanden.` — includes `group-ungebunden-null-zeilen: bestanden`, `groupmembership-schreiben-fremder-benutzer-nicht-verhindert: bestanden`, `modulegrant-fremde-gruppe-trotz-eigener-mandantenkennung-erlaubt: bestanden`, and `mindestens-eine-transaktionsform-traegt-den-mandantenkontext: bestanden — bestanden: [Form (ii) ; Form (iii)] — nicht bestanden: [Form (i)]` — exact match to the plan/SUMMARY's documented output including PIDs differing per run (as expected) | PASS |
Note: this probe covers only the single-shot transaction-shape measurement and the
8 groups-area RLS checks. It does **not** cover the 40-parallel-call concurrency claim
discussed above — no such probe exists in the committed tool.
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|---|---|---|---|---|
| WINDOWS-20 | 260909-jts-PLAN.md | Convert `groups` area to bound Prisma access as part of the multi-stage Mandantentrennung effort | ✓ SATISFIED | All 9 must-have truths verified above |
| ETAPPE-2-GROUPS | 260909-jts-PLAN.md | `groups` area is the second area of Etappe 2 and an ordering precondition for Etappe 4 | ✓ SATISFIED | Befund D closure confirmed in `docs/mandantentrennung-etappe2-fehlerrichtung.md:149-156` |
No orphaned requirements found in REQUIREMENTS.md for this quick task (quick tasks do not use the phase requirements table).
### Anti-Patterns Found
None. Scanned all 10 modified files for `TBD|FIXME|XXX|TODO|HACK|PLACEHOLDER` and empty-implementation patterns — zero matches.
### Coverage Count (Investigation Point 5)
- `groups.service.ts`: 21 real model accesses across 12 methods (confirmed by code read against the plan's per-method table) — all converted.
- `module-grants.service.ts`: 13 real model accesses across 5 methods — all converted.
- `ensureDefaultGroup`'s transaction-callback accesses: 5 (`group.create`, `user.findMany`, `groupMembership.createMany`, `tenantModuleActivation.findMany`, `moduleGrant.createMany`) — all converted, all individually reversion-tested for at least one representative case.
- Total real sites: 34 + 5 = 39, matching the plan's stated inflation correction (37 raw grep hits included 3 `$transaction(` matches that are not model accesses; the actual count is 21+13=34 model sites, plus the 5 invisible-until-this-task transaction-parameter sites).
- Classification document: 10 rows for the two files (5 each: `group`, `groupMembership`, `moduleGrant`, `tenantModuleActivation`, `user`), all `gebunden` — matches the (file, model) pair granularity, not the raw-site count, per the document's own convention.
## Gaps Summary
No must-have truth failed. No artifact is missing or a stub. No key link is broken.
The one item raised — the unreproducible 40-parallel-call concurrency claim baked
into permanent documentation as measured fact — does not compromise the shipped
implementation's correctness (independently re-verified reproducible evidence shows
the chosen pattern, `withTenantTransaction()` / Form iii, does carry the tenant
context on the same connection). It is raised strictly because this project has a
documented anti-pattern of numbers that turn out not to be what they claim, and this
specific number cannot currently be checked by anyone without re-running an ad hoc,
uncommitted script against a database that no longer exists. Routed to human
verification for a maintainer decision (accept as-is / soften language / commit the
probe) rather than silently passed or used to force a code-level gap that does not
exist.
---
*Verified: 2026-09-09*
*Verifier: Claude (gsd-verifier)*