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. * * BENUTZERDIMENSION (Etappe 3b, 260911-nke): * * `forTenant()` bekommt einen OPTIONALEN dritten Parameter `userId` statt * eines Schwesterhelfers (`forTenantAndUser()`). Grund: der Detektor der * Bestandsaufnahme (`rls-access-inventory.spec.ts`) erkennt gebundene * Aufrufstellen ueber den Regex `const X = forTenant(` — ein anders * benannter Schwesterhelfer waere fuer ihn UNSICHTBAR, jeder damit * gebundene Zugriff wuerde faelschlich als ungebunden gezaehlt. Ein * dritter Parameter aendert am Match des Regex nichts, weil er nur den * Funktionsnamen und das oeffnende `(` prueft. Praezedenz fuer "Helfer * erweitern statt zweiten bauen": `withTenantTransaction()` oben, das * ebenfalls keinen Zwilling bekam. * * Ohne `userId` wird `app.current_user` auf den LEERSTRING gesetzt, nicht * weggelassen. Grund: `set_config(..., true)` gilt nur transaktionslokal * (siehe WINDOWS-#20-Herleitung oben) — ein Aufruf ohne Benutzer koennte * sonst theoretisch einen Benutzer aus einer fruaheren Transaktion * DERSELBEN Verbindung erben, sollte spaeter jemand `local=false` * einfuehren. Der Leerstring schliesst das aus. `current_user_id()` * (neue Migration 20260911120000) faltet den Leerstring per `NULLIF` auf * NULL — die Regeln der zehn persoenlichen Tabellen behandeln "ungesetzt" * und "leer" dadurch gleich. * * Beide `set_config`-Aufrufe stehen in EINER getaggten Anweisung * (kommasepariert) — das `$transaction`-Array behaelt weiterhin GENAU ZWEI * Eintraege (Kontext-Anweisung, eigentliche Abfrage), das WINDOWS-#20-Muster * bleibt unangetastet. * * Wer den Benutzer setzt: NUR Nutzer-CRUD-Aufrufer (die zehn persoenlichen * Tabellen betreffende Methoden in calendar/dashboard/favorites/tenders). * Hintergrunddienste (`tender-digest.scheduler.ts`) und Verwaltungswege * (ldap, groups, user, tenant, auth, dkv, module-registry) rufen weiterhin * OHNE Benutzer — die `IS NULL OR`-Form der Regeln macht das zu einer * bewussten Eigenschaft (Admin/Hintergrunddienst sieht den ganzen * Mandanten), nicht zu einer Luecke. `withTenantTransaction()` bekommt * KEINEN dritten Parameter: kein Nutzer-CRUD-Aufrufer nutzt diese Funktion * (nur `groups`, ein Verwaltungsweg) — ein unbenutzter Parameter waere * Spekulation ohne heutigen Aufrufer. Nachtrag (260917-jdd): * `favorites.service.ts` (`reorder`) ist seither der erste Nutzer-CRUD- * Aufrufer — er kommt OHNE Benutzerdimension in der Sitzung aus und * traegt `userId` UND `widgetId` in jeder Bedingung innerhalb der * Transaktion selbst (zweites Netz). Ein dritter Parameter kommt erst, * wenn ein Aufrufer die Benutzerdimension INNERHALB der Transaktion * braucht. * * SYSTEMKONTEXT (Etappe 3c, 260914-eym): * * `forSystem(prisma)` ist ein SCHWESTERHELFER von `forTenant()`, kein * vierter Parameter — die UMKEHRUNG der 3b-Begruendung oben, ausdruecklich * so gewollt: der Systemkontext ist eine EIGENE Zugriffsklasse (liest ueber * ALLE Mandanten), und genau deshalb bekommt der Detektor der * Bestandsaufnahme (`rls-access-inventory.spec.ts`) fuer ihn eine EIGENE, * fuenfte Erkennungsform (`const X = forSystem(`) mit dem Stand * `system-gebunden`. Ein vierter Parameter an `forTenant()` haette diese * Klasse fuer den Detektor UNSICHTBAR gemacht — ein ueber alle Mandanten * lesender Zugriff waere als `gebunden` gezaehlt worden. * * Alle DREI Sitzungsvariablen werden in JEDER Form gesetzt: * `forSystem()` setzt `app.system_context = 'true'` und AUSDRUECKLICH * `app.current_tenant = ''` und `app.current_user = ''`; `forTenant()` und * `withTenantTransaction()` setzen umgekehrt AUSDRUECKLICH * `app.system_context = ''`. Kein Kontext darf vom anderen erben. * `set_config(..., true)` (transaktionslokal) ist das ERSTE Netz — deshalb * sieht `forTenant(A)` unmittelbar nach `forSystem` auf demselben Client * nur A (gemessen im Werkzeug: `-fortenant-a-nach-systemkontext-nur-a`, * `-is-system-context-unter-fortenant-false`). Der ausdrueckliche * Reset ist das ZWEITE Netz fuer eine hypothetische `local=false`-Aenderung * — durch Rueckbau falsifiziert (Reset entfernt UND local=false -> rot). * Alle Werte von `forSystem()` stehen als LITERALE im Template-Text (es * fliesst nichts Variables ein); in `forTenant()` bleibt die Parameterliste * `[tenantId, userId ?? '']` unveraendert. * * Unter Systemkontext kann NUR GELESEN werden: die Regel * `system_read_policy` (Migration 20260914120000_rls_system_context_read) * ist `FOR SELECT`; permissive Regeln werden ODER-verknuepft, fuer * INSERT/UPDATE/DELETE gilt weiter NUR die Mandantenregel, und unter * Systemkontext ist `current_tenant_id()` der Leerstring — kein Mandant * passt. Gemessen: INSERT -> SQLSTATE 42501, `updateMany`/`deleteMany` -> * count 0, `update` per id -> P2025. * * Wer `forSystem()` rufen darf: AUSSCHLIESSLICH die in * `FORSYSTEM_ALLOWED_CALL_SITES` (rls-access-inventory.spec.ts) genannten * Stellen mit der dort genannten EXAKTEN Zahl je Datei. Jeder weitere * Aufruf — in einer fremden Datei oder als zweiter in einer erlaubten — * macht die Spec rot. Ein Anfrageweg darf diesen Helfer NIE rufen. */ export function forTenant(prisma: PrismaClient, tenantId: string, userId?: string) { return prisma.$extends({ query: { $allOperations({ args, query }) { const setContext = prisma.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.current_user', ${userId ?? ''}, true), set_config('app.system_context', '', true)`; return prisma.$transaction([setContext, query(args)]) .then((results: unknown[]) => results[1]); }, }, }); } /** * Systemkontext (Etappe 3c, 260914-eym): ein Client, der ueber ALLE * Mandanten LIEST — fuer die Hintergrunddienste, die einmal ueber alles * lesen und dann je Mandant gebunden handeln (DKV-Planer, ldap, * tender-digest, tender-matching). Gleiche Array-Form-`$transaction`-Bauart * wie `forTenant()` (Kontext und Abfrage auf EINER Verbindung, WINDOWS #20). * * EINE getaggte Anweisung setzt `app.system_context = 'true'` und * AUSDRUECKLICH `app.current_tenant = ''` und `app.current_user = ''` — * alle drei als Literale im Template-Text, es fliesst nichts Variables ein. * Nur Lesen ist geoeffnet (`system_read_policy ... FOR SELECT`); jedes * Schreiben scheitert an der Mandantenregel. Aufrufer: ausschliesslich die * Stellen aus `FORSYSTEM_ALLOWED_CALL_SITES` (siehe Kopfkommentar). */ export function forSystem(prisma: PrismaClient) { return prisma.$extends({ query: { $allOperations({ args, query }) { const setContext = prisma.$executeRaw`SELECT set_config('app.system_context', 'true', true), set_config('app.current_tenant', '', true), set_config('app.current_user', '', true)`; return prisma.$transaction([setContext, query(args)]) .then((results: unknown[]) => 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, // tx bleibt `any` (gemessen, Aufgabe 1 260921-m34): `Prisma.TransactionClient` // erzwingt an den vier Aufrufstellen (groups.service.ts, favorites.service.ts) // vollstaendige Prisma-Erzeugungstypen und bricht deren Testdoppel in // prisma-tenant.extension.spec.ts (TS2322 auf einem absichtlich unvollstaendigen // Fake-Objekt). Das waere eine Verhaltensaenderung an einer Teststruktur, // nicht ehrliches Typisieren (D-02/D-03) - bleibt. fn: (tx: any) => Promise, ): Promise { return prisma.$transaction(async (tx: any) => { await tx.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.system_context', '', true)`; return fn(tx); }); }