// Korrektheitsnachweis für F-04: Row Level Security scharfschalten. // // Beweist, dass die scharfe RLS (FORCE + WITH CHECK + Kontext, Rolle isms_app) // die Mandantentrennung erzwingt, OHNE den lokalen Owner-Betrieb zu brechen. // Fünf Nachweise (jeweils Assertion; am Ende "OK" oder Exit-Code != 0): // (1) Owner-Betrieb bleibt heil: Owner (Superuser/BYPASSRLS) sieht ohne // app.tenant_id weiterhin ALLE Zeilen über Mandanten hinweg. // (2) RLS greift für isms_app: mit Kontext=A nur A-Zeilen, keine von B. // (3) Ohne Kontext = null Zeilen (fail-closed). // (4) WITH CHECK wirkt: INSERT mit eigenem Mandanten gelingt, mit fremdem // Mandanten wird abgelehnt. // (5) dbForTenant end-to-end mit RLS_ENFORCED=true (dynamischer Import). // // Voraussetzung (einmalig lokal): // docker exec isms-tool-postgres-1 psql -U isms -d isms \ // -c "ALTER ROLE isms_app WITH LOGIN PASSWORD 'isms_app_local';" // npx prisma migrate deploy // // Lauf: npx tsx scripts/test-rls-enforcement.ts // Nutzt die lokale Postgres-DB (Container isms-tool-postgres-1); .env im Worktree. import "dotenv/config"; import { PrismaClient } from "@prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; let failures = 0; const ok = (cond: boolean, msg: string) => { console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); if (!cond) failures++; }; async function expectThrow(fn: () => Promise, msg: string) { try { await fn(); ok(false, `${msg} — kein Throw (Isolationsbruch möglich!)`); } catch { ok(true, msg); } } const RLS_URL = process.env.RLS_DATABASE_URL ?? "postgresql://isms_app:isms_app_local@localhost:5432/isms?schema=public"; const SLUG_A = "zz-rls-test-a"; const SLUG_B = "zz-rls-test-b"; // Eigener Owner-Client (DATABASE_URL) für Setup/Cleanup — unabhängig von db.ts, // damit der spätere dynamische Import von db.ts (mit RLS_ENFORCED=true) sauber // mit der bereits gesetzten Flag-Umgebung evaluiert. const owner = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }), }); // Client der eingeschränkten App-Rolle isms_app (unterliegt der scharfen RLS). const app = new PrismaClient({ adapter: new PrismaPg({ connectionString: RLS_URL }), }); async function cleanup() { const tenants = await owner.tenant.findMany({ where: { slug: { in: [SLUG_A, SLUG_B] } }, select: { id: true }, }); const ids = tenants.map((t) => t.id); if (ids.length) { await owner.risk.deleteMany({ where: { tenantId: { in: ids } } }); await owner.tenant.deleteMany({ where: { id: { in: ids } } }); } } async function main() { await cleanup(); // Setup: zwei Test-Mandanten mit je einem Risk (über den Owner angelegt). const tenantA = await owner.tenant.create({ data: { name: "ZZ RLS Test A", slug: SLUG_A }, }); const tenantB = await owner.tenant.create({ data: { name: "ZZ RLS Test B", slug: SLUG_B }, }); const riskA = await owner.risk.create({ data: { tenantId: tenantA.id, refNo: 9001, title: "Risiko A" }, }); const riskB = await owner.risk.create({ data: { tenantId: tenantB.id, refNo: 9001, title: "Risiko B" }, }); // ── (1) Owner-Betrieb bleibt heil (kritisch für lokal + devB) ────────────── // Owner ist Superuser/BYPASSRLS → sieht trotz FORCE alle Mandanten, OHNE dass // app.tenant_id gesetzt ist. const ownerCount = await owner.risk.count({ where: { tenantId: { in: [tenantA.id, tenantB.id] } }, }); ok( ownerCount === 2, `(1) Owner sieht ohne Kontext beide Mandanten (${ownerCount}/2) — FORCE bricht Owner-Betrieb nicht`, ); const ownerTotal = await owner.risk.count(); ok(ownerTotal > 0, `(1b) Owner sieht global Zeilen (${ownerTotal} > 0)`); // ── (2) RLS greift für isms_app: mit Kontext=A nur A-Zeilen ──────────────── const seenA = await app.$transaction(async (tx) => { await tx.$executeRaw`SELECT set_config('app.tenant_id', ${tenantA.id}, true)`; return tx.risk.findMany({ select: { id: true, tenantId: true } }); }); ok( seenA.length > 0 && seenA.every((r) => r.tenantId === tenantA.id) && seenA.some((r) => r.id === riskA.id) && !seenA.some((r) => r.id === riskB.id), `(2) isms_app mit Kontext=A sieht nur A-Zeilen (${seenA.length}), keine von B`, ); // ── (3) Ohne Kontext = null Zeilen (fail-closed) ─────────────────────────── const seenNone = await app.risk.count(); ok(seenNone === 0, `(3) isms_app ohne Kontext sieht 0 Zeilen (${seenNone})`); // ── (4) WITH CHECK wirkt: eigener Mandant erlaubt, fremder abgelehnt ──────── const inserted = await app.$transaction(async (tx) => { await tx.$executeRaw`SELECT set_config('app.tenant_id', ${tenantA.id}, true)`; return tx.risk.create({ data: { tenantId: tenantA.id, refNo: 9002, title: "Risiko A insert" }, }); }); ok( inserted.tenantId === tenantA.id, "(4a) INSERT mit eigenem Mandanten (A) unter Kontext=A gelingt", ); await expectThrow( () => app.$transaction(async (tx) => { await tx.$executeRaw`SELECT set_config('app.tenant_id', ${tenantA.id}, true)`; // Fremder Mandant B unter Kontext A → WITH-CHECK-Policy lehnt ab. return tx.risk.create({ data: { tenantId: tenantB.id, refNo: 9003, title: "Fremd-Insert" }, }); }), "(4b) INSERT mit fremdem Mandanten (B) unter Kontext=A wird von WITH CHECK abgelehnt", ); // ── (5) dbForTenant end-to-end mit RLS_ENFORCED=true ─────────────────────── // Flag + URL VOR dem Import von db.ts setzen (dynamischer Import). process.env.RLS_ENFORCED = "true"; process.env.RLS_DATABASE_URL = RLS_URL; const db = await import("../src/server/db"); const e2eA = await db.dbForTenant(tenantA.id).risk.findMany({ select: { id: true, tenantId: true }, }); ok( e2eA.length > 0 && e2eA.every((r) => r.tenantId === tenantA.id) && !e2eA.some((r) => r.id === riskB.id), `(5a) dbForTenant(A).risk.findMany() liefert nur A (${e2eA.length})`, ); const foreign = await db.dbForTenant(tenantA.id).risk.findFirst({ where: { id: riskB.id }, }); ok( foreign === null, "(5b) dbForTenant(A).risk.findFirst({ id: B-Risk }) → null (kein Fremdzugriff)", ); await cleanup(); if (failures > 0) { console.error(`\n${failures} Nachweis(e) fehlgeschlagen.`); process.exit(1); } console.log("\nOK — alle F-04-Nachweise (1)-(5) erfüllt."); } main() .catch((e) => { console.error(e); process.exit(1); }) .finally(async () => { await owner.$disconnect(); await app.$disconnect(); });