154 lines
8.1 KiB
TypeScript
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);
|
|
});
|
|
}
|