b188946e31
- prisma-tenant.extension.ts: (prisma as any) und die Handannotation an
$allOperations in forTenant()/forSystem() entfernt; Kopfkommentar
unveraendert. .then((results: any[]) => ...) auf unknown[] umgestellt.
- 105 Aufrufstellen `const X = forTenant(...) as any` / `forSystem(...) as
any` von der Zusicherung befreit, Zuweisungsform woertlich erhalten
(rls-access-inventory.spec.ts bleibt scharf, 30/30 gruen einzeln
geprueft).
- withTenantTransaction(): Prisma.TransactionClient fuer tx probiert,
gemessen verworfen - bricht das Testdoppel in
prisma-tenant.extension.spec.ts (TS2322 auf einem absichtlich
unvollstaendigen Fake-Objekt). tx bleibt any, mit Begruendung am Typ.
- Gefolge des jetzt getypten Klienten entfernt: any[]-Annotationen und
.map((x: any) => ...) in groups.service.ts, module-grants.service.ts,
dkv.service.ts, ldap-config.service.ts, tenders.controller.ts:270.
- Befund (D-03): tender-matching.service.ts:159 trug eine Handannotation
(match: { tender: unknown }), die den Wert nur deshalb auf unknown
verengte, um TS7006 unter dem alten any-Klienten zu vermeiden - mit dem
getypten Klienten war das falsch. Annotation geloescht, kein Ersatz
durch Zusicherung.
- Zwei any bleiben gezielt in groups.service.ts (u/a in
ensureDefaultGroup(), gefolge von tx: any) - Begruendung am Code.
noExplicitAny apps/api/src: 288 -> 149 (Schranke 155). type-check 4/4,
lint 5/5 (0 error). apps/api 72/1143 gruen, apps/web 73/531 gruen,
rls-access-inventory.spec.ts 30/30 gruen.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
271 lines
15 KiB
TypeScript
271 lines
15 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. 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: `<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 }) {
|
|
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<T>(
|
|
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<T>,
|
|
): Promise<T> {
|
|
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);
|
|
});
|
|
}
|