From f0b531b712518699d2b7f77371147cd942c8b968 Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 11 Sep 2026 17:25:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(quick-260911-nke):=20current=5Fuser=5Fid()?= =?UTF-8?q?,=20Benutzerdimension=20in=20den=20Regeln,=20forTenant()=20mit?= =?UTF-8?q?=20userId=20=E2=80=94=20ein=20Pfad?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Neue Migration 20260911120000_rls_user_dimension_personal_tables: current_user_id() (NULLIF-gefaltet), zehn persoenliche Tabellen umgestellt (acht als eine Regel, SearchProvider/TenderRssFeedSource als je vier befehlsgetrennte Regeln), vier Verwaltungstabellen bewusst unveraendert. Lokal angewendet (migrate deploy, Prisma-Binary aus apps/api/node_modules/.bin), schema.prisma unveraendert. - forTenant(prisma, tenantId, userId?): beide set_config in EINER getaggten Anweisung, $transaction-Array bleibt bei zwei Eintraegen (WINDOWS #20), Leerstring ohne Benutzer statt Weglassen. - tender-saved-search.service.ts: alle vier forTenant()-Aufrufe reichen userId durch; Detektor-Regex bestaetigt 4 Treffer. - rls-scratch-check.mjs: current_user_id() aus der neuen Migration geschnitten (nicht getippt), drei Funktionsfaelle gemessen, neue runUserDimensionChecks() mit generiertem Client fuer TenderSavedSearch (vier Wahrheiten + Spaltenabgleich), die alte Loch-Pruefung tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar umgedreht (alte Messung unter neuem Namen erhalten, neue Umkehrung MIT Benutzer). sqlStateOf() um Message-Fallback ergaenzt (RLS-Ablehnung ueber generierten Client traegt den SQLSTATE nur im Fehlertext, nicht in .meta.code). - Baseline: 1020/62 Tests, Typpruefung sauber, Werkzeug 146/146 bestanden. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR --- .../migration.sql | 221 +++++++++++ apps/api/scripts/rls-scratch-check.mjs | 351 +++++++++++++++++- apps/api/src/groups/migration-sql.spec.ts | 82 ++++ .../prisma/prisma-tenant.extension.spec.ts | 67 ++++ .../api/src/prisma/prisma-tenant.extension.ts | 47 ++- .../tender-saved-search.service.spec.ts | 45 +++ .../tenders/tender-saved-search.service.ts | 14 +- 7 files changed, 800 insertions(+), 27 deletions(-) create mode 100644 apps/api/prisma/migrations/20260911120000_rls_user_dimension_personal_tables/migration.sql diff --git a/apps/api/prisma/migrations/20260911120000_rls_user_dimension_personal_tables/migration.sql b/apps/api/prisma/migrations/20260911120000_rls_user_dimension_personal_tables/migration.sql new file mode 100644 index 0000000..6af1430 --- /dev/null +++ b/apps/api/prisma/migrations/20260911120000_rls_user_dimension_personal_tables/migration.sql @@ -0,0 +1,221 @@ +-- 260911-nke, Etappe 3b — die Benutzerdimension in den Regeln der zehn +-- persoenlichen Tabellen. Schliesst die Klasse von Befunden, die in sieben +-- Bereichs-Kritiken als "Policy hat keine Benutzerdimension" festgehalten +-- wurde (docs/mandantentrennung-etappe2-fehlerrichtung.md). +-- +-- Die betroffenen Dateien (20260618112133_rls_policies fuer die urspruengliche +-- Tabellenform der acht Ein-Regel-Tabellen, 20260909140000_rls_remaining_ +-- tenant_tables fuer deren zuletzt ausgelieferte Fassung, 20260910120000_rls_ +-- widen_membership_grant_and_platform_read fuer TenderRssFeedSource) bleiben +-- UNVERAENDERT stehen — Prisma fuehrt ihre Pruefsumme, eine Aenderung braechte +-- "prisma migrate deploy" zum Abbruch. Praezedenzfall und Kopfform: +-- 20260910120000_rls_widen_membership_grant_and_platform_read. +-- +-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst, +-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (siehe +-- 20260909130000_rls_app_role und docs/mandantentrennung-datenbankrolle.md). +-- Die Verbindung ist zum Zeitpunkt dieser Migration weiterhin NICHT +-- umgestellt — `DATABASE_URL` zeigt unveraendert auf die Rolle `tessera` +-- (BYPASSRLS). Der Schalter bleibt AUS: diese Regeln sind fuer jeden +-- heutigen Aufrufer wirkungslos, bis Etappe 4 scharfschaltet. + +-- Zweite Sitzungsvariable `app.current_user`, Funktion nach dem Muster von +-- `current_tenant_id()` (20260618112133). NULLIF ist Pflicht: `forTenant()` +-- sendet "kein Benutzer" ausdruecklich als Leerstring (nicht als +-- weggelassene Variable) — ohne NULLIF wuerde current_user_id() bei einem +-- Aufruf ohne Benutzer den Leerstring statt NULL liefern, und +-- "userId" = '' waere fuer jede Zeile falsch, nicht gleichbedeutend mit +-- "kein Benutzer gesetzt". Kein GRANT EXECUTE noetig — wie bei +-- current_tenant_id() (20260909130000_rls_app_role vergibt dafuer keines): +-- PostgreSQL vergibt EXECUTE auf Funktionen standardmaessig an PUBLIC. +CREATE OR REPLACE FUNCTION current_user_id() RETURNS TEXT AS $$ + SELECT NULLIF(current_setting('app.current_user', true), ''); +$$ LANGUAGE sql STABLE; + +-- Vierzehn Tabellen tragen eine `userId`-Spalte, zehn davon sind +-- persoenliche Daten und bekommen unten eine Regel. Die vier Ausnahmen +-- bekommen KEINE Anweisung in dieser Migration: +-- +-- - GroupMembership: Verwaltungsobjekt — ein Admin muss Mitgliedschaften +-- anderer Nutzer sehen und pflegen koennen, das ist keine persoenliche +-- Zeile des referenzierten Benutzers. +-- - ModuleGrant: Verwaltungsobjekt — dieselbe Begruendung, ein Admin +-- vergibt und sieht Freigaben fuer andere. +-- - PasswordResetToken: Anmelde-Artefakt — wird gelesen, BEVOR ein +-- Benutzer im Sinne von `app.current_user` bekannt ist (der Token IST +-- der Weg, den Benutzer erst zu ermitteln); eine Benutzerdimension hier +-- waere zirkulaer. +-- - TenderMatch: wird vom Hintergrunddienst (tender-matching.service.ts) +-- je Treffer geschrieben, nicht von einem eingeloggten Benutzer direkt; +-- Etappe 3c behandelt Hintergrunddienste gesondert (Systemkontext). + +-- Acht Tabellen mit NOT-NULL-`userId`: ein einzelner USING-Ausdruck genuegt, +-- weil Lesen und Schreiben dieselbe Bedingung haben sollen — WITH CHECK +-- folgt USING bei einer Policy ohne FOR-Klausel, ein Einfuegen/Aendern als +-- Benutzer A mit fremder Kennung B faellt damit durch. Die `IS NULL OR`-Form +-- macht die Aenderung fuer jeden Aufruf OHNE gesetzten Benutzer (Admin, +-- Hintergrunddienst) wirkungslos: der sieht weiterhin den ganzen Mandanten, +-- exakt wie vor dieser Migration (bewusste, offene Flanke — siehe +-- .planning/WINDOWS.md). Die Regelnamen bleiben `tenant_isolation_policy` +-- (wie jab bei GroupMembership/ModuleGrant): `extractPolicySql` und +-- `pg_policies` behalten je Tabelle genau eine Regel. + +DROP POLICY tenant_isolation_policy ON "CalendarSource"; +CREATE POLICY tenant_isolation_policy ON "CalendarSource" + USING ( + "tenantId" = current_tenant_id() + AND (current_user_id() IS NULL OR "userId" = current_user_id()) + ); + +DROP POLICY tenant_isolation_policy ON "DashboardLayout"; +CREATE POLICY tenant_isolation_policy ON "DashboardLayout" + USING ( + "tenantId" = current_tenant_id() + AND (current_user_id() IS NULL OR "userId" = current_user_id()) + ); + +DROP POLICY tenant_isolation_policy ON "FavoriteLink"; +CREATE POLICY tenant_isolation_policy ON "FavoriteLink" + USING ( + "tenantId" = current_tenant_id() + AND (current_user_id() IS NULL OR "userId" = current_user_id()) + ); + +DROP POLICY tenant_isolation_policy ON "TenderEmailConfig"; +CREATE POLICY tenant_isolation_policy ON "TenderEmailConfig" + USING ( + "tenantId" = current_tenant_id() + AND (current_user_id() IS NULL OR "userId" = current_user_id()) + ); + +DROP POLICY tenant_isolation_policy ON "TenderNotificationPref"; +CREATE POLICY tenant_isolation_policy ON "TenderNotificationPref" + USING ( + "tenantId" = current_tenant_id() + AND (current_user_id() IS NULL OR "userId" = current_user_id()) + ); + +DROP POLICY tenant_isolation_policy ON "TenderSavedSearch"; +CREATE POLICY tenant_isolation_policy ON "TenderSavedSearch" + USING ( + "tenantId" = current_tenant_id() + AND (current_user_id() IS NULL OR "userId" = current_user_id()) + ); + +DROP POLICY tenant_isolation_policy ON "TenderTriage"; +CREATE POLICY tenant_isolation_policy ON "TenderTriage" + USING ( + "tenantId" = current_tenant_id() + AND (current_user_id() IS NULL OR "userId" = current_user_id()) + ); + +DROP POLICY tenant_isolation_policy ON "WidgetInstance"; +CREATE POLICY tenant_isolation_policy ON "WidgetInstance" + USING ( + "tenantId" = current_tenant_id() + AND (current_user_id() IS NULL OR "userId" = current_user_id()) + ); + +-- SearchProvider — `userId` ist NULL-faehig (eine gemeinsame, mandanten- +-- gebundene Zeile ohne Besitzer ist erlaubt), die Mandantenhaelfte ist NICHT +-- gelockert: `SearchProvider` bleibt mandantenstreng (260910-jab (4), +-- widerlegte Praemisse aus WINDOWS #19 — es gibt keinen Codeweg, der eine +-- mandantenlose Zeile erzeugt). Vier nach Befehl getrennte Regeln +-- (Praezedenz 260910-jab (3)): ein einzelner USING-Ausdruck, der die +-- gemeinsame Zeile (`userId IS NULL`) zum Lesen einschliesst, wuerde sie +-- ohne Trennung auch zum Aendern/Entfernen freigeben. +DROP POLICY tenant_isolation_policy ON "SearchProvider"; + +CREATE POLICY tenant_user_read_policy ON "SearchProvider" + FOR SELECT + USING ( + "tenantId" = current_tenant_id() + AND ( + current_user_id() IS NULL + OR "userId" IS NULL + OR "userId" = current_user_id() + ) + ); + +CREATE POLICY tenant_user_insert_policy ON "SearchProvider" + FOR INSERT + WITH CHECK ( + "tenantId" = current_tenant_id() + AND (current_user_id() IS NULL OR "userId" = current_user_id()) + ); + +CREATE POLICY tenant_user_update_policy ON "SearchProvider" + FOR UPDATE + USING ( + "tenantId" = current_tenant_id() + AND (current_user_id() IS NULL OR "userId" = current_user_id()) + ) + WITH CHECK ( + "tenantId" = current_tenant_id() + AND (current_user_id() IS NULL OR "userId" = current_user_id()) + ); + +CREATE POLICY tenant_user_delete_policy ON "SearchProvider" + FOR DELETE + USING ( + "tenantId" = current_tenant_id() + AND (current_user_id() IS NULL OR "userId" = current_user_id()) + ); + +-- TenderRssFeedSource — loest die vier Regeln aus 20260910120000 ab (WINDOWS +-- #19), unter DENSELBEN NAMEN neu angelegt. Die plattformweite Lesezulassung +-- (`tenantId IS NULL`) und die Mandantenpflicht beim Schreiben aus jener +-- Migration bleiben unveraendert bestehen — hier kommt ausschliesslich die +-- Benutzerdimension hinzu. WINDOWS #24 (Admin-Erstellung/-Entfernen +-- plattformweiter Zeilen bleibt ungebunden) ist von dieser Migration +-- UNBERUEHRT. +DROP POLICY tenant_platform_read_policy ON "TenderRssFeedSource"; +DROP POLICY tenant_insert_policy ON "TenderRssFeedSource"; +DROP POLICY tenant_update_policy ON "TenderRssFeedSource"; +DROP POLICY tenant_delete_policy ON "TenderRssFeedSource"; + +CREATE POLICY tenant_platform_read_policy ON "TenderRssFeedSource" + FOR SELECT + USING ( + ("tenantId" = current_tenant_id() OR "tenantId" IS NULL) + AND ( + current_user_id() IS NULL + OR "userId" IS NULL + OR "userId" = current_user_id() + ) + ); + +CREATE POLICY tenant_insert_policy ON "TenderRssFeedSource" + FOR INSERT + WITH CHECK ( + "tenantId" = current_tenant_id() + AND (current_user_id() IS NULL OR "userId" = current_user_id()) + ); + +CREATE POLICY tenant_update_policy ON "TenderRssFeedSource" + FOR UPDATE + USING ( + "tenantId" = current_tenant_id() + AND (current_user_id() IS NULL OR "userId" = current_user_id()) + ) + WITH CHECK ( + "tenantId" = current_tenant_id() + AND (current_user_id() IS NULL OR "userId" = current_user_id()) + ); + +CREATE POLICY tenant_delete_policy ON "TenderRssFeedSource" + FOR DELETE + USING ( + "tenantId" = current_tenant_id() + AND (current_user_id() IS NULL OR "userId" = current_user_id()) + ); + +-- Was diese Migration bewusst NICHT tut: +-- - Kein Systemkontext fuer Hintergrunddienste (Etappe 3c) — die `IS NULL +-- OR`-Form macht das fuer heutige Aufrufer unnoetig. +-- - Keine SECURITY-DEFINER-Funktion (Etappe 3a, Anmeldenamen pro Mandant). +-- - Ein Aufrufer, der den Benutzer vergisst (drittes Argument an +-- `forTenant()` nicht setzt), sieht den ganzen Mandanten — heute exakt +-- der Stand VOR dieser Migration, also keine Verschlechterung, aber auch +-- kein Netz dagegen. Siehe .planning/WINDOWS.md fuer den Nachweis, dass +-- dieser Zustand aufgezeichnet, nicht uebersehen wurde. diff --git a/apps/api/scripts/rls-scratch-check.mjs b/apps/api/scripts/rls-scratch-check.mjs index 6484894..3830c38 100644 --- a/apps/api/scripts/rls-scratch-check.mjs +++ b/apps/api/scripts/rls-scratch-check.mjs @@ -143,6 +143,27 @@ async function setupScratchDatabase(adminUrl) { await db.$executeRawUnsafe( `INSERT INTO probe ("tenantId", label) VALUES ('TENANT-A', 'a-row'), ('TENANT-B', 'b-row')`, ); + + // Benutzerdimension (Etappe 3b, 260911-nke): current_user_id() wird aus + // der neuen Migration GESCHNITTEN, nicht getippt (Muster ldap, + // T-IPC-08) — findet die Extraktion nichts, bricht das Werkzeug hier + // ab, statt mit einem geratenen Funktionstext weiterzumessen. + const userDimensionMigrationSql = readRlsUserDimensionMigrationSql(); + if (!userDimensionMigrationSql) { + fail( + 'Migration "_rls_user_dimension_personal_tables" nicht gefunden — current_user_id() kann nicht geschnitten werden.', + ); + } + const currentUserIdFunctionSql = extractCurrentUserIdFunctionSql(userDimensionMigrationSql); + if (!currentUserIdFunctionSql) { + fail( + 'CREATE OR REPLACE FUNCTION current_user_id() nicht in der neuen Migration gefunden.', + ); + } + await db.$executeRawUnsafe(currentUserIdFunctionSql); + await db.$executeRawUnsafe( + `GRANT EXECUTE ON FUNCTION current_user_id() TO ${SCRATCH_ROLE_NAME}`, + ); }); } @@ -168,9 +189,9 @@ function report(results, kennung, passed, detail) { * eigentliche Abfrage als Array-Form von $transaction, also auf einer * gemeinsamen Verbindung. */ -async function forTenantQuery(prisma, tenantId, queryFn) { - const setTenantContext = prisma.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`; - const [, result] = await prisma.$transaction([setTenantContext, queryFn(prisma)]); +async function forTenantQuery(prisma, tenantId, queryFn, userId) { + const setContext = prisma.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.current_user', ${userId ?? ''}, true)`; + const [, result] = await prisma.$transaction([setContext, queryFn(prisma)]); return result; } @@ -233,6 +254,41 @@ async function runForTenantChecks(scratchRoleUrl, results) { unbound.length === 0, `ungebundener SELECT liefert ${unbound.length} Zeile(n)`, ); + + // Benutzerdimension (Etappe 3b, 260911-nke): die drei Funktionsfaelle + // von current_user_id(), je innerhalb einer eigenen Transaktion ueber + // $queryRaw gemessen. + const [ungesetztRow] = await prisma.$transaction([ + prisma.$queryRaw`SELECT current_user_id() AS v`, + ]); + report( + results, + 'current-user-id-ungesetzt-ist-null', + ungesetztRow[0].v === null, + `current_user_id() ohne gesetzte Variable=${JSON.stringify(ungesetztRow[0].v)}`, + ); + + const [, leerQueryRows] = await prisma.$transaction([ + prisma.$executeRaw`SELECT set_config('app.current_user', '', true)`, + prisma.$queryRaw`SELECT current_user_id() AS v`, + ]); + report( + results, + 'current-user-id-leer-ist-null', + leerQueryRows[0].v === null, + `current_user_id() nach set_config('app.current_user', '', true)=${JSON.stringify(leerQueryRows[0].v)}`, + ); + + const [, gesetztRows] = await prisma.$transaction([ + prisma.$executeRaw`SELECT set_config('app.current_user', 'user-a1', true)`, + prisma.$queryRaw`SELECT current_user_id() AS v`, + ]); + report( + results, + 'current-user-id-gesetzt-liefert-wert', + gesetztRows[0].v === 'user-a1', + `current_user_id() nach set_config('app.current_user', 'user-a1', true)=${JSON.stringify(gesetztRows[0].v)}`, + ); } finally { await prisma.$disconnect(); } @@ -424,6 +480,38 @@ function extractAllPolicySql(migrationSql, tableName) { return [...migrationSql.matchAll(re)].map((m) => m[0]); } +/** + * Liest die Migration der Benutzerdimension (Etappe 3b, 260911-nke, + * Dateiname endet auf "_rls_user_dimension_personal_tables"). Die Regeln der + * zehn persoenlichen Tabellen UND die Funktion `current_user_id()` MUESSEN + * ab hier aus dieser Datei geschnitten werden, nicht getippt und nicht aus + * einer aelteren Migration gelesen — sonst misst dieses Werkzeug ein + * getipptes Wunschbild oder die abgeloeste Regel (Befund D, Praezedenz + * 260910-jab). + */ +function readRlsUserDimensionMigrationSql() { + const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && entry.name.endsWith('_rls_user_dimension_personal_tables'), + ) + .map((entry) => entry.name); + if (dirs.length !== 1) return null; + return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8'); +} + +/** + * Schneidet die Definition von `current_user_id()` wortgleich aus der neuen + * Migration. Liefert `null`, wenn nichts gefunden wird — der Aufrufer bricht + * dann mit einer FEHLGESCHLAGENEN Pruefung ab, statt die Funktion im + * Werkzeug selbst zu tippen (Muster ldap, T-IPC-08). + */ +function extractCurrentUserIdFunctionSql(migrationSql) { + const re = /CREATE OR REPLACE FUNCTION current_user_id\(\)[\s\S]*?LANGUAGE sql STABLE;/; + const match = migrationSql.match(re); + return match ? match[0] : null; +} + /** * Aufgabe 1 (260909-ipc) — misst die fuenf im Plan genannten Verhaltensweisen * des Bereichs ldap unter der Rolle ohne BYPASSRLS, mit den beiden Policies @@ -970,6 +1058,7 @@ async function runGroupsAreaChecks(adminUrl, scratchRoleUrl, results) { async function runTendersAreaChecks(adminUrl, scratchRoleUrl, results) { const remainingMigrationSql = readRemainingTenantTablesMigrationSql(); const widenMigrationSql = readRlsWidenMigrationSql(); + const userDimensionMigrationSql = readRlsUserDimensionMigrationSql(); const emailConfigPolicy = remainingMigrationSql ? extractPolicySql(remainingMigrationSql, 'TenderEmailConfig') @@ -980,8 +1069,12 @@ async function runTendersAreaChecks(adminUrl, scratchRoleUrl, results) { const rssFeedPolicies = widenMigrationSql ? extractAllPolicySql(widenMigrationSql, 'TenderRssFeedSource') : []; - const savedSearchPolicy = remainingMigrationSql - ? extractPolicySql(remainingMigrationSql, 'TenderSavedSearch') + // Extraktionsstelle 984 (260911-nke, Aufgabe 1): TenderSavedSearch wird + // ab der Benutzerdimension-Migration gelesen, nicht mehr aus + // *_rls_remaining_tenant_tables — sonst misst dieser Abschnitt die + // abgeloeste Regel ohne Benutzerdimension (Befund D, Praezedenz jab). + const savedSearchPolicy = userDimensionMigrationSql + ? extractPolicySql(userDimensionMigrationSql, 'TenderSavedSearch') : null; const triagePolicy = remainingMigrationSql ? extractPolicySql(remainingMigrationSql, 'TenderTriage') @@ -1131,21 +1224,36 @@ async function runTendersAreaChecks(adminUrl, scratchRoleUrl, results) { `ungebundener SELECT auf "TenderSavedSearch" liefert ${unboundSavedSearchRows.length} Zeile(n)`, ); - // tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar (Befund - // E): das GELINGEN — beide Nutzer von TENANT-A sind sichtbar — ist das - // bestandene Ergebnis. Es belegt, dass die Policy keine - // Benutzerdimension hat; die anwendungsseitige userId-Filterung bleibt - // deshalb der einzige Schutz gegen Quer-Lesen zwischen Nutzern - // desselben Mandanten und darf bei der Umstellung nicht entfallen. + // Umgedreht (260911-nke, Aufgabe 1): die alte Pruefung + // "tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar" (Befund + // E, 260909-laa) mass OHNE Benutzer — nach der Migration + // 20260911120000 ist das GELINGEN (beide Nutzer sichtbar) weiterhin der + // Fall, aber jetzt als GEWOLLTE Eigenschaft der IS-NULL-Form fuer einen + // Aufruf ohne gesetzten Benutzer (Admin, Hintergrunddienst), nicht mehr + // als Luecke. Die alte Messung bleibt unter neuem Namen bestehen; die + // Umkehrung misst MIT Benutzer und erwartet das GEGENTEIL. const usersVisibleForA = new Set(savedSearchRowsForA.map((r) => r.userId)); const bothUsersVisible = usersVisibleForA.has('user-a1') && usersVisibleForA.has('user-a2'); report( results, - 'tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar', + 'tendersavedsearch-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten', bothUsersVisible, - bothUsersVisible - ? `forTenant(TENANT-A) liefert AUCH die Zeile des zweiten Nutzers (user-a2) — die Policy auf TenderSavedSearch prueft nur die Mandantenkennung, nicht die Benutzerkennung; die anwendungsseitige userId-Filterung bleibt der einzige Schutz gegen Quer-Lesen zwischen Nutzern desselben Mandanten und darf nicht entfallen` - : `sichtbare Nutzer unter forTenant(TENANT-A): ${JSON.stringify([...usersVisibleForA])}`, + `bis 260911-nke als Befund E unter dem Namen tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar gefuehrt — jetzt die gewollte Eigenschaft der IS-NULL-Form fuer Admin und Hintergrunddienst. forTenant(TENANT-A) OHNE Benutzer liefert sichtbare Nutzer: ${JSON.stringify([...usersVisibleForA])}`, + ); + + const savedSearchRowsForA1 = await forTenantQuery( + prisma, + 'TENANT-A', + (tx) => tx.$queryRaw`SELECT "tenantId", "userId" FROM "TenderSavedSearch" ORDER BY id`, + 'user-a1', + ); + const usersVisibleForA1 = new Set(savedSearchRowsForA1.map((r) => r.userId)); + const kollegeNichtSichtbar = usersVisibleForA1.has('user-a1') && !usersVisibleForA1.has('user-a2'); + report( + results, + 'tendersavedsearch-benutzer-a-sieht-kollegen-nicht-gebunden', + kollegeNichtSichtbar, + `forTenant(TENANT-A, user-a1) liefert sichtbare Nutzer: ${JSON.stringify([...usersVisibleForA1])}`, ); // tenderemailconfig-gebunden-nur-eigener-mandant @@ -1744,7 +1852,17 @@ const AUTH_LOOKUP_USER_POLICY_SQL = * `.meta.code` gelesen, nicht `.code`. */ function sqlStateOf(err) { - return err?.meta?.code ?? null; + if (err?.meta?.code) return err.meta.code; + // 260911-nke, Aufgabe 1: ein RLS-abgewiesenes .create() ueber den + // GENERIERTEN Client (Batch-Insert-Pfad) wirft PrismaClientUnknownRequestError + // OHNE `.meta.code` — der PostgreSQL-SQLSTATE steckt stattdessen nur im + // Fehlertext (`code: "42501"` innerhalb der ConnectorError-Ausgabe). + // Empirisch geprueft gegen tessera-ctl-db-1, 2026-09-11: + // `tenderSavedSearch.create()` unter einer RLS-Verletzung liefert genau + // diese Form. Fallback-Regex, damit die Pruefung den tatsaechlichen + // Datenbankfehler misst statt eines falschen "unbekannt". + const match = /code:\s*"(\d{5})"/.exec(err?.message ?? ''); + return match ? match[1] : null; } /** @@ -4351,6 +4469,193 @@ async function runSettingsAreaChecks(adminUrl, scratchRoleUrl, results) { } } +/** + * Innere Tabellenroutine, wiederverwendet fuer alle zehn persoenlichen + * Tabellen der Benutzerdimension (Etappe 3b, 260911-nke). Jede Tabelle + * bekommt IHRE EIGENE, VOLLSTAENDIGE Wegwerf-Tabelle unter ihrem + * Produktionsnamen (`DROP TABLE IF EXISTS` davor — andere Bereichsfunktionen + * legen Tabellen desselben Namens mit REDUZIERTEN Spalten fuer reine + * Roh-SQL-Messungen an; dieser Abschnitt braucht den VOLLEN Spaltensatz fuer + * den GENERIERTEN Client und laeuft deshalb nach allen anderen + * Bereichsabschnitten). Misst je Tabelle vier Wahrheiten ueber den + * generierten Client: Benutzer A sieht die eigene Zeile; Benutzer A sieht + * die Zeile eines Kollegen DESSELBEN Mandanten nicht; ein Aufruf OHNE + * Benutzer sieht beide; ein Schreiben als Benutzer A mit der Kennung von B + * wird von der Datenbank abgewiesen (SQLSTATE 42501). + */ +async function runSingleRulePersonalTableCheck(config) { + const { + adminUrl, + scratchRoleUrl, + results, + slug, + tableName, + modelName, + userDimensionMigrationSql, + createTableSql, + seedSql, + ownRowId, + colleagueRowId, + createAttempt, + } = config; + + const policySql = extractPolicySql(userDimensionMigrationSql, tableName); + if (!policySql) { + report( + results, + `${slug}-policy-aus-migration-gefunden`, + false, + `CREATE POLICY tenant_isolation_policy ON "${tableName}" nicht in der Benutzerdimension-Migration (20260911120000) gefunden`, + ); + return; + } + + await withAdminPrisma(urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), async (db) => { + await db.$executeRawUnsafe(`DROP TABLE IF EXISTS "${tableName}" CASCADE;`); + await db.$executeRawUnsafe(createTableSql); + await db.$executeRawUnsafe(`ALTER TABLE "${tableName}" ENABLE ROW LEVEL SECURITY;`); + await db.$executeRawUnsafe(`ALTER TABLE "${tableName}" FORCE ROW LEVEL SECURITY;`); + await db.$executeRawUnsafe(policySql); + await db.$executeRawUnsafe( + `GRANT SELECT, INSERT, UPDATE, DELETE ON "${tableName}" TO ${SCRATCH_ROLE_NAME}`, + ); + await db.$executeRawUnsafe(seedSql); + }); + + // Pruefung 1 zuerst — Spaltenvergleich gegen schema.prisma (260910-krx- + // Falle): faellt sie durch, sind die Client-Messungen darunter wertlos. + const schemaFields = readSchemaModelScalarFieldNames(modelName); + const tableColumns = await withAdminPrisma( + urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(), + async (db) => { + const rows = await db.$queryRawUnsafe( + `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = '${tableName}'`, + ); + return rows.map((r) => r.column_name).sort(); + }, + ); + const schemaFieldsSorted = [...schemaFields].sort(); + const columnsMatch = + schemaFieldsSorted.length > 0 && + schemaFieldsSorted.length === tableColumns.length && + schemaFieldsSorted.every((f, i) => f === tableColumns[i]); + report( + results, + `${slug}-wegwerftabelle-deckt-alle-spalten-des-generierten-clients`, + columnsMatch, + `Schema-Felder aus schema.prisma (model ${modelName}, skalare Felder ohne Relation, ${schemaFieldsSorted.length}): ${JSON.stringify(schemaFieldsSorted)}; Spalten der Wegwerf-Tabelle (${tableColumns.length}): ${JSON.stringify(tableColumns)}`, + ); + if (!columnsMatch) { + return; + } + + const modelAccessor = modelName.charAt(0).toLowerCase() + modelName.slice(1); + const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl }); + try { + const boundA1 = buildInlineExtendedClient(prisma, 'TENANT-A', 'user-a1'); + + const ownRow = await boundA1[modelAccessor].findUnique({ where: { id: ownRowId } }); + report( + results, + `${slug}-benutzer-a-sieht-eigene-zeile`, + Boolean(ownRow) && ownRow.id === ownRowId, + `bound(TENANT-A, user-a1).${modelAccessor}.findUnique({ id: '${ownRowId}' }) liefert ${JSON.stringify(ownRow)}`, + ); + + const colleagueRow = await boundA1[modelAccessor].findUnique({ where: { id: colleagueRowId } }); + report( + results, + `${slug}-benutzer-a-sieht-kollegen-nicht`, + colleagueRow === null, + `bound(TENANT-A, user-a1).${modelAccessor}.findUnique({ id: '${colleagueRowId}' }) (gehoert user-a2) liefert ${JSON.stringify(colleagueRow)}`, + ); + + const boundNoUser = buildInlineExtendedClient(prisma, 'TENANT-A'); + const bothRows = await boundNoUser[modelAccessor].findMany({ + where: { id: { in: [ownRowId, colleagueRowId] } }, + }); + report( + results, + `${slug}-ohne-benutzer-sieht-beide`, + bothRows.length === 2, + `bound(TENANT-A, ohne Benutzer).${modelAccessor}.findMany liefert ${bothRows.length} Zeile(n): ${JSON.stringify(bothRows.map((r) => r.id))} — gewollte Eigenschaft der IS-NULL-Form fuer Admin/Hintergrunddienst`, + ); + + let writeRejected = false; + let writeDetail = ''; + try { + await boundA1[modelAccessor].create({ + data: { + id: createAttempt.id, + userId: 'user-a2', + tenantId: 'TENANT-A', + ...createAttempt.extraData, + }, + }); + writeDetail = `bound(TENANT-A, user-a1).${modelAccessor}.create mit userId='user-a2' ist NICHT fehlgeschlagen`; + } catch (err) { + const sqlState = sqlStateOf(err); + const ctor = err?.constructor?.name ?? 'unbekannt'; + writeRejected = sqlState === '42501'; + writeDetail = `bound(TENANT-A, user-a1).${modelAccessor}.create mit userId='user-a2' wirft ${ctor}, SQLSTATE ${sqlState ?? 'unbekannt'}: ${(err.message ?? '').toString().trim()}`; + } + report(results, `${slug}-schreiben-als-a-mit-kennung-b-abgelehnt`, writeRejected, writeDetail); + } finally { + await prisma.$disconnect(); + } +} + +/** + * Etappe 3b (260911-nke) — misst die Benutzerdimension der zehn + * persoenlichen Tabellen ueber den GENERIERTEN Client. Aufgabe 1: nur + * "TenderSavedSearch"; die uebrigen neun folgen in Aufgabe 2 ueber + * dieselbe innere Routine (`runSingleRulePersonalTableCheck`). + */ +async function runUserDimensionChecks(adminUrl, scratchRoleUrl, results) { + const userDimensionMigrationSql = readRlsUserDimensionMigrationSql(); + if (!userDimensionMigrationSql) { + report( + results, + 'user-dimension-migration-gefunden', + false, + 'Migration "_rls_user_dimension_personal_tables" nicht gefunden', + ); + return; + } + + await runSingleRulePersonalTableCheck({ + adminUrl, + scratchRoleUrl, + results, + slug: 'tendersavedsearch', + tableName: 'TenderSavedSearch', + modelName: 'TenderSavedSearch', + userDimensionMigrationSql, + createTableSql: ` + CREATE TABLE "TenderSavedSearch" ( + id text PRIMARY KEY, + "userId" text NOT NULL, + "tenantId" text NOT NULL, + name text NOT NULL, + filters jsonb NOT NULL DEFAULT '{}', + "instantAlert" boolean NOT NULL DEFAULT false, + "createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE ("userId", name) + ); + `, + seedSql: ` + INSERT INTO "TenderSavedSearch" (id, "userId", "tenantId", name, filters) VALUES + ('ss-a1', 'user-a1', 'TENANT-A', 'Profil A1', '{}'), + ('ss-a2', 'user-a2', 'TENANT-A', 'Profil A2', '{}'), + ('ss-b', 'user-b', 'TENANT-B', 'Profil B', '{}'); + `, + ownRowId: 'ss-a1', + colleagueRowId: 'ss-a2', + createAttempt: { id: 'ss-a-schreibversuch', extraData: { name: 'Schreibversuch', filters: {} } }, + }); +} + /** * Aufgabe 1 (260909-jts), TEIL 2 — misst, welche der drei Transaktionsformen * den Mandantenkontext auf DERSELBEN Verbindung ueber alle Teilschritte @@ -4362,12 +4667,19 @@ async function runSettingsAreaChecks(adminUrl, scratchRoleUrl, results) { * Setzt auf die Tabelle "Group" auf, die runGroupsAreaChecks() bereits * angelegt und mit je einer Zeile fuer TENANT-A/TENANT-B befuellt hat. */ -function buildInlineExtendedClient(prisma, tenantId) { +/** + * Spiegelbildlich zu `forTenant()` in apps/api/src/prisma/prisma-tenant.extension.ts + * — bei jeder Aenderung dort HIER nachziehen. Seit Etappe 3b (260911-nke) + * optionaler dritter Parameter `userId`: sendet dieselbe Ein-Anweisungs-Form + * wie der Helfer (beide `set_config` kommasepariert in EINER getaggten + * Anweisung, Leerstring ohne Benutzer). + */ +function buildInlineExtendedClient(prisma, tenantId, userId) { return prisma.$extends({ query: { $allOperations({ args, query }) { - const setTenantContext = prisma.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`; - return prisma.$transaction([setTenantContext, query(args)]).then((res) => res[1]); + const setContext = prisma.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.current_user', ${userId ?? ''}, true)`; + return prisma.$transaction([setContext, query(args)]).then((res) => res[1]); }, }, }); @@ -4593,6 +4905,7 @@ async function main() { await runFavoritesAreaChecks(adminUrl, scratchRoleUrlString, results); await runSettingsAreaChecks(adminUrl, scratchRoleUrlString, results); await runTransactionShapeMeasurement(scratchRoleUrlString, results); + await runUserDimensionChecks(adminUrl, scratchRoleUrlString, results); await runConcurrencyProbe(scratchRoleUrlString, results); } finally { console.log(`Raeume Wegwerf-Datenbank "${SCRATCH_DB_NAME}" ab...`); diff --git a/apps/api/src/groups/migration-sql.spec.ts b/apps/api/src/groups/migration-sql.spec.ts index db3516f..2aaf4a2 100644 --- a/apps/api/src/groups/migration-sql.spec.ts +++ b/apps/api/src/groups/migration-sql.spec.ts @@ -173,6 +173,88 @@ describe('rls_widen_membership_grant_and_platform_read migration.sql (T-JTS-02, }); }); +describe('rls_user_dimension_personal_tables migration.sql (Etappe 3b, 260911-nke)', () => { + const sql = readMigrationSql('_rls_user_dimension_personal_tables'); + const PERSONAL_TABLES = [ + 'CalendarSource', + 'DashboardLayout', + 'FavoriteLink', + 'SearchProvider', + 'TenderEmailConfig', + 'TenderNotificationPref', + 'TenderRssFeedSource', + 'TenderSavedSearch', + 'TenderTriage', + 'WidgetInstance', + ]; + const EXCLUDED_TABLES = ['GroupMembership', 'ModuleGrant', 'PasswordResetToken', 'TenderMatch']; + + function nonCommentLines(source: string): string { + return source + .split('\n') + .filter((line) => !line.trim().startsWith('--')) + .join('\n'); + } + + it('legt current_user_id() mit NULLIF an', () => { + expect(sql).toContain('CREATE OR REPLACE FUNCTION current_user_id() RETURNS TEXT AS $$'); + expect(sql).toContain("NULLIF(current_setting('app.current_user', true), '')"); + }); + + it('nennt fuer jede der zehn persoenlichen Tabellen mindestens eine CREATE POLICY-Anweisung', () => { + for (const table of PERSONAL_TABLES) { + expect(sql).toMatch(new RegExp(`CREATE POLICY [\\w]+ ON "${table}"`)); + } + }); + + it('jede CREATE-POLICY-Anweisung der zehn Tabellen enthaelt current_user_id() IS NULL OR', () => { + for (const table of PERSONAL_TABLES) { + const re = /CREATE POLICY [\w]+[\s\S]*?ON "([A-Za-z]+)"[\s\S]*?;/g; + let match: RegExpExecArray | null; + let found = 0; + while ((match = re.exec(sql)) !== null) { + if (match[1] !== table) continue; + found += 1; + expect(match[0].replace(/\s+/g, ' ')).toContain('current_user_id() IS NULL OR'); + } + expect(found).toBeGreaterThan(0); + } + }); + + it('legt genau 8 DROP POLICY tenant_isolation_policy auf den NOT-NULL-Tabellen, einen weiteren auf SearchProvider, und 4 DROPs auf TenderRssFeedSource an', () => { + const NOT_NULL_TABLES = [ + 'CalendarSource', + 'DashboardLayout', + 'FavoriteLink', + 'TenderEmailConfig', + 'TenderNotificationPref', + 'TenderSavedSearch', + 'TenderTriage', + 'WidgetInstance', + ]; + const dropIsolationOnNotNullTables = NOT_NULL_TABLES.filter((table) => + sql.includes(`DROP POLICY tenant_isolation_policy ON "${table}"`), + ).length; + expect(dropIsolationOnNotNullTables).toBe(8); + expect(sql).toContain('DROP POLICY tenant_isolation_policy ON "SearchProvider"'); + const dropRssFeed = (sql.match(/DROP POLICY \w+ ON "TenderRssFeedSource"/g) ?? []).length; + expect(dropRssFeed).toBe(4); + }); + + it('nennt die vier Ausnahmen namentlich im Kopf', () => { + for (const table of EXCLUDED_TABLES) { + expect(sql).toContain(table); + } + }); + + it('enthaelt KEINE Anweisung auf GroupMembership/ModuleGrant/PasswordResetToken/TenderMatch (ausserhalb von Kommentaren)', () => { + const codeOnly = nonCommentLines(sql); + for (const table of EXCLUDED_TABLES) { + expect(codeOnly).not.toMatch(new RegExp(`(DROP|CREATE) POLICY [\\w ]*ON "${table}"`)); + } + }); +}); + describe('add_group_internal_name_and_object_guid migration.sql (D-04)', () => { const sql = readMigrationSql('_add_group_internal_name_and_object_guid'); diff --git a/apps/api/src/prisma/prisma-tenant.extension.spec.ts b/apps/api/src/prisma/prisma-tenant.extension.spec.ts index aaffc79..1df7883 100644 --- a/apps/api/src/prisma/prisma-tenant.extension.spec.ts +++ b/apps/api/src/prisma/prisma-tenant.extension.spec.ts @@ -132,6 +132,73 @@ describe('forTenant() — Array-Form von $transaction (WINDOWS #20)', () => { expect(forTenantSource).toMatch(/\$transaction\(\s*\[/); expect(forTenantSource).not.toMatch(/\$transaction\(\s*async/); }); + + // Benutzerdimension (Etappe 3b, 260911-nke): drei neue Tests fuer den + // optionalen dritten Parameter `userId`. + it('ohne userId: die Parameterliste des Templates enthaelt den Leerstring an zweiter Stelle, der Template-Text nennt app.current_user', async () => { + const fakePrisma: any = { + $transaction: vi.fn(() => Promise.resolve(['set-config-result', 'query-result'])), + $extends: (config: any) => ({ + async __invoke(args: unknown, query: (args: unknown) => unknown) { + return config.query.$allOperations({ args, query }); + }, + }), + $executeRaw: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => { + expect(strings.join('')).toContain('app.current_user'); + expect(values[1]).toBe(''); + return 'set-config-promise'; + }), + }; + + const scoped = forTenant(fakePrisma, 'tenant-a') as any; + await scoped.__invoke({}, () => 'query-result'); + + expect(fakePrisma.$executeRaw).toHaveBeenCalledTimes(1); + }); + + it('mit userId: der Wert geht als Template-PARAMETER (values), nicht im Text (T-02-05 bleibt gewahrt)', async () => { + const fakePrisma: any = { + $transaction: vi.fn(() => Promise.resolve(['set-config-result', 'query-result'])), + $extends: (config: any) => ({ + async __invoke(args: unknown, query: (args: unknown) => unknown) { + return config.query.$allOperations({ args, query }); + }, + }), + $executeRaw: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => { + expect(Array.isArray(strings)).toBe(true); + expect(strings.join('')).not.toContain("user-with-quote-' OR 1=1"); + expect(values).toContain("user-with-quote-' OR 1=1"); + return 'set-config-promise'; + }), + }; + + const scoped = forTenant(fakePrisma, 'tenant-a', "user-with-quote-' OR 1=1") as any; + await scoped.__invoke({}, () => 'query-result'); + + expect(fakePrisma.$executeRaw).toHaveBeenCalledTimes(1); + }); + + it('mit userId: das $transaction-Feld behaelt weiterhin genau zwei Eintraege', async () => { + const transactionCalls: unknown[] = []; + const fakePrisma: any = { + $transaction: vi.fn((arg: unknown) => { + transactionCalls.push(arg); + return Promise.resolve(['set-config-result', 'query-result']); + }), + $extends: (config: any) => ({ + async __invoke(args: unknown, query: (args: unknown) => unknown) { + return config.query.$allOperations({ args, query }); + }, + }), + $executeRaw: vi.fn(() => 'set-config-promise'), + }; + + const scoped = forTenant(fakePrisma, 'tenant-a', 'user-a') as any; + await scoped.__invoke({}, () => 'query-result'); + + expect(transactionCalls).toHaveLength(1); + expect((transactionCalls[0] as unknown[]).length).toBe(2); + }); }); describe('withTenantTransaction() — interaktive Callback-Form auf dem UNgebundenen Client (260909-jts, Aufgabe 1)', () => { diff --git a/apps/api/src/prisma/prisma-tenant.extension.ts b/apps/api/src/prisma/prisma-tenant.extension.ts index 11c6176..09aa665 100644 --- a/apps/api/src/prisma/prisma-tenant.extension.ts +++ b/apps/api/src/prisma/prisma-tenant.extension.ts @@ -107,16 +107,55 @@ import { PrismaClient } from '@prisma/client'; * 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. */ -export function forTenant(prisma: PrismaClient, tenantId: string) { +export function forTenant(prisma: PrismaClient, tenantId: string, userId?: 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)`; + const setContext = (prisma as any) + .$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.current_user', ${userId ?? ''}, true)`; return (prisma as any) - .$transaction([setTenantContext, query(args)]) + .$transaction([setContext, query(args)]) .then((results: any[]) => results[1]); }, }, diff --git a/apps/api/src/tenders/tender-saved-search.service.spec.ts b/apps/api/src/tenders/tender-saved-search.service.spec.ts index ec8a7dd..c599303 100644 --- a/apps/api/src/tenders/tender-saved-search.service.spec.ts +++ b/apps/api/src/tenders/tender-saved-search.service.spec.ts @@ -1,6 +1,7 @@ import { ConflictException, NotFoundException } from '@nestjs/common'; import { describe, expect, it, vi } from 'vitest'; import { TenderSavedSearchService } from './tender-saved-search.service'; +import { forTenant } from '../prisma/prisma-tenant.extension'; /** * TenderSavedSearchService.spec — RED-first (TDD) proof for FILTER-06 @@ -311,4 +312,48 @@ describe('TenderSavedSearchService', () => { expectBoundCall(prisma, 't1', 'delete'); }); }); + + // --- Benutzerdimension (Etappe 3b, 260911-nke): forTenant() bekommt den + // Benutzer als drittes Argument — je Methode mindestens ein dreistelliger + // Aufruf festgenagelt, damit ein vergessenes drittes Argument den Test + // bricht statt still zu verschwinden. + describe('Benutzerdimension: forTenant() bekommt userId als drittes Argument (260911-nke)', () => { + it('list() ruft forTenant(prisma, tenantId, userId) auf', async () => { + const prisma = makeFakePrisma(); + const service = new TenderSavedSearchService(prisma as any); + + await service.list('u1', 't1'); + + expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'u1'); + }); + + it('create() ruft forTenant(prisma, tenantId, userId) auf', async () => { + const prisma = makeFakePrisma(); + const service = new TenderSavedSearchService(prisma as any); + + await service.create('u1', 't1', { name: 'A', filters: {} }); + + expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'u1'); + }); + + it('update() ruft forTenant(prisma, tenantId, userId) auf', async () => { + const prisma = makeFakePrisma(); + const service = new TenderSavedSearchService(prisma as any); + const created = await service.create('u1', 't1', { name: 'A', filters: {} }); + + await service.update(created.id, 'u1', 't1', { name: 'B' }); + + expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'u1'); + }); + + it('remove() ruft forTenant(prisma, tenantId, userId) auf', async () => { + const prisma = makeFakePrisma(); + const service = new TenderSavedSearchService(prisma as any); + const created = await service.create('u1', 't1', { name: 'A', filters: {} }); + + await service.remove(created.id, 'u1', 't1'); + + expect(forTenant).toHaveBeenCalledWith(prisma, 't1', 'u1'); + }); + }); }); diff --git a/apps/api/src/tenders/tender-saved-search.service.ts b/apps/api/src/tenders/tender-saved-search.service.ts index 8a2ff68..4cb0a73 100644 --- a/apps/api/src/tenders/tender-saved-search.service.ts +++ b/apps/api/src/tenders/tender-saved-search.service.ts @@ -24,6 +24,12 @@ import { CreateSavedSearchDto, UpdateSavedSearchDto } from './dto/saved-search.d * method call, never shared across methods (same convention as * `groups.service.ts`). * + * Benutzerdimension seit 20260911120000 (Etappe 3b, 260911-nke): every + * `forTenant()` call above also passes `userId` as the third argument, so + * the database-level `tenant_isolation_policy` on TenderSavedSearch now + * ALSO enforces `userId = current_user_id()` — a second net alongside the + * application-level scoping above, which stays exactly as it was. + * * @@unique([userId, name]) (T-11-14): a second profile with the same name * for the same user is rejected by Postgres (P2002) — this service * translates that into a 409 ConflictException so the frontend can show a @@ -38,7 +44,7 @@ export class TenderSavedSearchService { * strictly by userId (V4/IDOR) — a foreign userId sees nothing. */ async list(userId: string, tenantId: string) { - const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any; return tenantPrisma.tenderSavedSearch.findMany({ where: { userId }, orderBy: { name: 'asc' }, @@ -52,7 +58,7 @@ export class TenderSavedSearchService { * users, since the uniqueness is scoped per-user. */ async create(userId: string, tenantId: string, dto: CreateSavedSearchDto) { - const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any; try { return await tenantPrisma.tenderSavedSearch.create({ data: { @@ -81,7 +87,7 @@ export class TenderSavedSearchService { * leaking whether another user's profile exists). */ async update(id: string, userId: string, tenantId: string, dto: UpdateSavedSearchDto) { - const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any; const existing = await tenantPrisma.tenderSavedSearch.findUnique({ where: { id }, }); @@ -118,7 +124,7 @@ export class TenderSavedSearchService { * update(). */ async remove(id: string, userId: string, tenantId: string) { - const tenantPrisma = forTenant(this.prisma, tenantId) as any; + const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any; const existing = await tenantPrisma.tenderSavedSearch.findUnique({ where: { id }, });