fix(quick-260909-eor): forTenant() bindet Mandantenkontext auf dieselbe Verbindung
WINDOWS #20: set_config() lief auf einer anderen Postgres-Verbindung als die eigentliche Abfrage, weil die interaktive Callback-Form von $transaction verwendet wurde. Ersetzt durch die Array-Form, die set_config und Abfrage als eine Transaktion auf einer Verbindung ausfuehrt (Prismas empfohlenes Muster fuer RLS-ueber-Extensions). Injektionsfestigkeit (T-02-05) bleibt ueber ein getaggtes $executeRaw-Template statt $executeRawUnsafe erhalten. - prisma-tenant.extension.spec.ts: prueft die Form des Aufrufs (Array mit zwei Eintraegen, Rueckgabewert ist der zweite Eintrag) ohne laufende Datenbank - rls-scratch-check.mjs: neues Werkzeug, das eine Wegwerf-Datenbank anlegt und live misst — gleiche Backend-Verbindung, gesetzter Kontext, keine Fremdmandanten-Zeilen, keine Zeilen ohne Kontext. Alle 5 Pruefungen bestehen gegen die lokale Datenbank. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
This commit is contained in:
@@ -4,20 +4,68 @@ 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.
|
||||
*
|
||||
* Uses parameterized set_config to prevent SQL injection (T-02-05).
|
||||
* 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.
|
||||
*/
|
||||
export function forTenant(prisma: PrismaClient, tenantId: string) {
|
||||
return prisma.$extends({
|
||||
query: {
|
||||
$allOperations({ args, query }: { args: any; query: (args: any) => any }) {
|
||||
return (prisma as any).$transaction(async (tx: any) => {
|
||||
// Use parameterized query to avoid SQL injection
|
||||
await tx.$executeRawUnsafe(
|
||||
`SELECT set_config('app.current_tenant', $1, true)`,
|
||||
tenantId,
|
||||
);
|
||||
return query(args);
|
||||
});
|
||||
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]);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user