3d645674f0
- Helfer forSystem(prisma) in prisma-tenant.extension.ts (Array-Form, setzt app.system_context='true' und die beiden anderen Variablen ausdruecklich leer); forTenant()/withTenantTransaction() setzen app.system_context='' als Literal (4 neue Spec-Tests) - Migration 20260914120000_rls_system_context_read: is_system_context() (COALESCE, STABLE) und system_read_policy FOR SELECT auf DkvModuleConfig, LdapConfig, LdapFieldMapping, TenderMatch, TenderSavedSearch — lokal angewendet (36 Migrationen, pg_proc 1, 5 system_read_policy, 34 Regeln) - migration-sql.spec.ts: describe-Block fuer die neue Migration (6 Tests) - rls-scratch-check.mjs: Funktion aus der Migration geschnitten, forSystemQuery/buildInlineSystemClient, Reset in forTenantQuery/ buildInlineExtendedClient, runSystemContextChecks (4 Funktionsfaelle + 9 Kennungen DkvModuleConfig) -> Alle 216 Pruefungen bestanden - rls-access-inventory.spec.ts: fuenfte Erkennungsform const X = forSystem(, Stand system-gebunden mit Vorrangregel, FORSYSTEM_ALLOWED_CALL_SITES (exakte Zahl je Datei, 3 Tests), Proben C/D/E - DKV: loadActiveConfigsForScheduler() ueber forSystem (findMany isActive, CONFIG_SAFE_SELECT, orderBy tenantId); DkvSchedulerService mit Auftrag je Mandant dkv-inbox-poll:<tenantId>, activeTenantId ersatzlos entfernt, setInterval/stopJob je Mandant, registeredTenantIds(); Controller stopJob(tenantId); neue dkv-scheduler.service.spec.ts (7 Tests), dkv.service.spec.ts Tests 6/7 umgestellt - Klassifikation: dkv.service.ts/dkvModuleConfig system-gebunden, Header mit fuenfter Erkennungsform und viertem Stand-Wert - Baseline: 63 Dateien / 1051 Tests, tsc 0, Werkzeug 216 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
263 lines
14 KiB
TypeScript
263 lines
14 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.
|
|
*
|
|
* 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.
|
|
*
|
|
* 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: `<slug>-fortenant-a-nach-systemkontext-nur-a`,
|
|
* `<slug>-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 }: { args: any; query: (args: any) => any }) {
|
|
const setContext = (prisma as any)
|
|
.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.current_user', ${userId ?? ''}, true), set_config('app.system_context', '', true)`;
|
|
|
|
return (prisma as any)
|
|
.$transaction([setContext, query(args)])
|
|
.then((results: any[]) => 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 }: { args: any; query: (args: any) => any }) {
|
|
const setContext = (prisma as any)
|
|
.$executeRaw`SELECT set_config('app.system_context', 'true', true), set_config('app.current_tenant', '', true), set_config('app.current_user', '', true)`;
|
|
|
|
return (prisma as any)
|
|
.$transaction([setContext, 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), set_config('app.system_context', '', true)`;
|
|
return fn(tx);
|
|
});
|
|
}
|