Files
tessera-ctl/apps/api/src/prisma/prisma-tenant.extension.ts
T

154 lines
8.1 KiB
TypeScript

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<T>(
prisma: PrismaClient,
tenantId: string,
fn: (tx: any) => Promise<T>,
): Promise<T> {
return (prisma as any).$transaction(async (tx: any) => {
await tx.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`;
return fn(tx);
});
}