Files
tessera-ctl/apps/api/src/prisma/prisma-tenant.extension.ts
T
schalli b188946e31 refactor(quick-260921-m34): Aufgabe 1 - Mandantenbindung entzaubert, 105 unnoetige any-Zusicherungen entfernt
- 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
2026-09-21 16:19:16 +02:00

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);
});
}