import { PrismaClient } from '@prisma/client'; /** * Creates a tenant-scoped Prisma client that sets the app.current_tenant * PostgreSQL session variable before every query via RLS. * * WARUM DIE ARRAY-FORM VON $transaction PFLICHT IST (WINDOWS #20, gemessen * 2026-09-09, siehe .planning/quick/260909-eor-.../260909-eor-PLAN.md): * * Die vorherige Fassung nutzte die INTERAKTIVE Callback-Form * (`prisma.$transaction(async (tx) => { await tx.$executeRawUnsafe(...); return query(args); })`) * und rief `query(args)` — die eigentliche Datenbankoperation — auf dem * AEUSSEREN `prisma`-Client auf, nicht auf `tx`. Ein Nachbau dieses exakten * Musters gegen die lokale Datenbank ergab: * * inside tx : {"pid":254999,"t":"TENANT-A"} * actual qry : {"pid":255000,"t":null} * SAME CONNECTION? false * * `set_config('app.current_tenant', ..., true)` mit `local=true` gilt nur * transaktions- UND verbindungslokal. Die interaktive Callback-Form haelt * fuer `tx` eine eigene Verbindung; `query(args)` lief auf einer ANDEREN, * unter Postgres-Poolern austauschbaren Verbindung und sah den Kontext nie. * Unter einer Rolle ohne BYPASSRLS waere die Folge nicht "zu viele Zeilen", * sondern NULL Zeilen — die Policy vergleicht gegen NULL. * * Die Array-Form (`prisma.$transaction([a, b])`) fuehrt alle Eintraege als * EINE Transaktion auf EINER Verbindung aus — das ist das von Prisma selbst * fuer RLS-ueber-Extensions vorgesehene Muster. `query(args)` sieht damit * denselben Kontext, den `set_config` unmittelbar zuvor auf derselben * Verbindung gesetzt hat. * * GRENZFAELLE — benannter Vorbehalt fuer Etappe 2 (nicht stillschweigend * uebergangen, siehe 260909-eor-SUMMARY.md): * * - Ruft aufrufender Code selbst `$transaction` auf einem mit `forTenant()` * gebundenen Client auf: `$transaction` ist keine Modell-Operation und * laeuft NICHT durch `$allOperations`. Der Mandantenkontext wird in einem * solchen Fall nicht automatisch gesetzt — jede einzelne im * `$transaction`-Array enthaltene Modell-Operation dispatcht zwar durch * `$allOperations` (weil sie auf dem extended Client aufgerufen wird) und * bekommt dadurch ihre EIGENE Ein-Element-Transaktion mit eigenem * `set_config` — mehrere solche Operationen liefen dann aber auf * MEHREREN Teiltransaktionen statt einer gemeinsamen, was Atomaritaet * ueber die gesamte aeussere Transaktion hinweg verletzen kann. Heute * ruft kein `forTenant()`-Aufrufer eine verschachtelte `$transaction` auf * (gemessen: alle 9 tatsaechlichen mandantengebundenen Abfragen in * `ldap.service.ts` sind Einzeloperationen) — vor jedem neuen * `forTenant()`-Aufruf mit eigener Transaktion in Etappe 2 erneut pruefen. * - `$queryRaw`/`$executeRaw` DIREKT auf dem gebundenen Client laufen * weiterhin normal durch `$allOperations` (Prisma behandelt sie wie jede * andere Operation) und werden daher korrekt an dieselbe Verbindung * gebunden wie `set_config`. * * Parametrisiert ueber ein getaggtes `$executeRaw`-Template (kein * `$executeRawUnsafe` mit zusammengebautem Text mehr) — die * Injektionsfestigkeit aus T-02-05 bleibt beim Umbau erhalten. * * ERNEUTE PRUEFUNG FUER ETAPPE 2, BEREICH `groups` (260909-jts, gemessen * 2026-09-09 gegen die echte Datenbank, siehe Aufgabe 1 in * `.planning/quick/260909-jts-.../260909-jts-PLAN.md` und den Abschnitt * "Bereich groups" in `docs/mandantentrennung-etappe2-fehlerrichtung.md`): * `groups.service.ts` ist die einzige Datei im gesamten API-Quelltext mit * einer interaktiven Callback-Transaktion (`ensureDefaultGroup`), dazu zwei * Array-Transaktionen (`update`, `reassignDefaultBeforeDelete`) — genau der * im Absatz oben benannte neue Fall. Ergebnis der Messung, mit * `pg_backend_pid()` und `current_tenant_id()` je Teilschritt: * * Form (i) — Array-Form auf dem gebundenen Client: FAELLT DURCH. Zwei * Teilschritte liefen auf ZWEI verschiedenen Verbindungen * (`step1.pid=276749`, `step2.pid=276750`) — exakt die im * Absatz oben beschriebene Aufspaltung in mehrere * Teiltransaktionen. Jeder Teilschritt sah zwar noch den * richtigen Kontext (kein Datenleck), aber die Atomaritaet * der aeusseren Transaktion ist nicht mehr gegeben. * Form (ii) — interaktive Callback-Form auf dem gebundenen Client * (`forTenant(prisma, tenantId).$transaction(async (tx) => ...)`): * besteht die Einzelmessung (gleiche Verbindung, richtiger * Kontext, richtige Zeilenzahl), faellt aber unter Last aus. * Ursache: jeder `tx`-Aufruf innerhalb der interaktiven * Transaktion loest selbst wieder eine VERSCHACHTELTE * Array-Transaktion aus (weil `$allOperations` bei jedem * Aufruf erneut feuert), und die aeussere plus jede innere * Verschachtelung belegen gleichzeitig eine Verbindung aus * demselben, endlichen Pool. * Form (iii) — interaktive Callback-Form auf dem UNGEBUNDENEN Client, * `set_config` als ERSTE Anweisung direkt auf `tx` (nicht auf * dem aeusseren Client): besteht Einzelmessung UND Lastprobe — * sie belegt pro Aufruf genau EINE Verbindung, ohne * Verschachtelung. * * Die Lastprobe steht als `runConcurrencyProbe` in * `apps/api/scripts/rls-scratch-check.mjs` und ist jederzeit wiederholbar: * 40 gleichzeitige Aufrufe ueber EINEN Client, abwechselnd fuer zwei * Mandanten. Gepruft wird nur die Eigenschaft, auf die dieser Code sich * stuetzt — Form (iii) ohne Verletzung; Form (ii) laeuft daneben als * ausgedruckte Beobachtung mit, weil ihr Bruchpunkt an Verbindungsvorrat * und Maschine haengt und deshalb keine Bedingung fuer einen gruenen Lauf * sein darf. Die konkreten Zahlen eines Laufs stehen daher NICHT hier, * sondern fallen bei jeder Ausfuehrung neu an. Diese Probe wurde * nachgereicht: die Aussage stand zunaechst nur als Fliesstext hier, ohne * dass sie jemand haette nachvollziehen koennen. * * Entscheidung: `withTenantTransaction()` unten baut Form (iii) nach und * ist das Hilfsmittel fuer alle mehrschrittigen, mandantengebundenen * Aenderungen dieses Bereichs. Fuer den naechsten Bereich mit einer eigenen * Transaktion gilt weiterhin: vor jedem neuen Fall erneut pruefen, nicht * von hier abschreiben — eine andere Lastform oder ein anderer Pool koennte * ein anderes Ergebnis liefern. */ export function forTenant(prisma: PrismaClient, tenantId: string) { return prisma.$extends({ query: { $allOperations({ args, query }: { args: any; query: (args: any) => any }) { const setTenantContext = (prisma as any) .$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`; return (prisma as any) .$transaction([setTenantContext, query(args)]) .then((results: any[]) => results[1]); }, }, }); } /** * Fuehrt `fn` als EINE mehrschrittige, mandantengebundene Transaktion aus * (260909-jts, Befund A/Aufgabe 1). Anders als `forTenant()` bindet diese * Funktion NICHT ueber `$extends`/`$allOperations`, sondern oeffnet direkt * eine interaktive Transaktion auf dem UNGEBUNDENEN Basisclient und setzt * `app.current_tenant` als allererste Anweisung ueber ein getaggtes * Roh-Template DIREKT AUF `tx` — nicht auf `prisma`. Jede weitere Anweisung * innerhalb von `fn` bekommt denselben `tx`-Parameter uebergeben und laeuft * dadurch auf DERSELBEN Verbindung wie das `set_config` davor. * * Parametrisiert wie `forTenant()` (getaggtes Template, kein * zusammengebauter Text — T-02-05 bleibt erhalten). * * Fuer Einzeloperationen bleibt `forTenant()` das richtige Werkzeug; dieses * Hilfsmittel ist ausschliesslich fuer Aufrufstellen gedacht, die mehrere * Schritte als EINE Transaktion brauchen (Array- oder interaktive * Callback-Form). */ export function withTenantTransaction( prisma: PrismaClient, tenantId: string, fn: (tx: any) => Promise, ): Promise { return (prisma as any).$transaction(async (tx: any) => { await tx.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`; return fn(tx); }); }