// Korrektheitsnachweis für F-04: Row Level Security scharfschalten. // // Beweist, dass die scharfe RLS (FORCE + WITH CHECK + Kontext, Rolle craftvia_app) // die Mandantentrennung erzwingt, OHNE den lokalen Owner-Betrieb zu brechen. // Nachweise (jeweils Assertion; am Ende "OK" oder Exit-Code != 0): // (0) Baseline: die RLS-Funktion enable_tenant_rls() existiert und alle Tabellen der // TENANT_MODELS tragen FORCE RLS + Policy tenant_isolation. // (1) Owner-Betrieb bleibt heil: Owner (Superuser/BYPASSRLS) sieht ohne // app.tenant_id weiterhin ALLE Zeilen über Mandanten hinweg. // (2) RLS greift für craftvia_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). // // Fixture: Fundament-Modell `Role`. // // Voraussetzung (einmalig lokal, nach `prisma migrate deploy`): // docker compose exec postgres psql -U craftvia -d craftvia \ // -c "ALTER ROLE craftvia_app WITH LOGIN PASSWORD 'craftvia_app_local';" // Ohne Login-Recht der Rolle wird der Test mit klarer Meldung übersprungen (Exit 0), // es sei denn RLS_TEST_REQUIRED=true. // // Lauf: npx tsx scripts/test-rls-enforcement.ts import "dotenv/config"; import { PrismaClient } from "@prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; import { buildTenantTopology, TENANT_MODELS } from "../src/server/backup/topology"; 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://craftvia_app:craftvia_app_local@localhost:5432/craftvia?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 craftvia_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.role.deleteMany({ where: { tenantId: { in: ids } } }); await owner.tenant.deleteMany({ where: { id: { in: ids } } }); } } async function main() { // ── (0) Baseline-Struktur ───────────────────────────────────────────────── const fn = await owner.$queryRawUnsafe<{ n: bigint }[]>( "SELECT count(*)::bigint AS n FROM pg_proc WHERE proname = 'enable_tenant_rls'", ); ok(Number(fn[0]?.n ?? 0) === 1, "(0a) SQL-Funktion enable_tenant_rls(text) existiert"); const topo = buildTenantTopology(); const tables = TENANT_MODELS.map((m) => topo.nodes.get(m)!.table); const rls = await owner.$queryRawUnsafe<{ relname: string; relrowsecurity: boolean; relforcerowsecurity: boolean; policies: bigint }[]>( `SELECT c.relname, c.relrowsecurity, c.relforcerowsecurity, (SELECT count(*) FROM pg_policies p WHERE p.tablename = c.relname AND p.policyname = 'tenant_isolation')::bigint AS policies FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'public' AND c.relkind = 'r'`, ); const byTable = new Map(rls.map((r) => [r.relname, r])); const missing = tables.filter((t) => { const r = byTable.get(t); return !r || !r.relrowsecurity || !r.relforcerowsecurity || Number(r.policies) !== 1; }); ok(missing.length === 0, `(0b) alle ${tables.length} Tenant-Tabellen haben ENABLE+FORCE RLS + tenant_isolation${missing.length ? ` — fehlt: ${missing.join(", ")}` : ""}`); // Login-Fähigkeit der App-Rolle prüfen (lokal ggf. noch nicht eingerichtet). try { await app.$queryRawUnsafe("SELECT 1"); } catch (e) { const msg = e instanceof Error ? e.message : String(e); if (process.env.RLS_TEST_REQUIRED === "true") throw e; console.log(`\n⚠ ÜBERSPRUNGEN (1)-(5): Verbindung als craftvia_app nicht möglich (${msg.split("\n")[0]}).`); console.log(" Einmalig: ALTER ROLE craftvia_app WITH LOGIN PASSWORD 'craftvia_app_local';"); if (failures > 0) process.exit(1); return; } await cleanup(); // Setup: zwei Test-Mandanten mit je einer Rolle (ü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 roleA = await owner.role.create({ data: { tenantId: tenantA.id, key: "zz-rls", name: "Rolle A" } }); const roleB = await owner.role.create({ data: { tenantId: tenantB.id, key: "zz-rls", name: "Rolle B" } }); // ── (1) Owner-Betrieb bleibt heil ──────────────────────────────────────── const ownerCount = await owner.role.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.role.count(); ok(ownerTotal > 0, `(1b) Owner sieht global Zeilen (${ownerTotal} > 0)`); // ── (2) RLS greift für craftvia_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.role.findMany({ select: { id: true, tenantId: true } }); }); ok( seenA.length > 0 && seenA.every((r) => r.tenantId === tenantA.id) && seenA.some((r) => r.id === roleA.id) && !seenA.some((r) => r.id === roleB.id), `(2) craftvia_app mit Kontext=A sieht nur A-Zeilen (${seenA.length}), keine von B`, ); // ── (3) Ohne Kontext = null Zeilen (fail-closed) ─────────────────────────── const seenNone = await app.role.count(); ok(seenNone === 0, `(3) craftvia_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.role.create({ data: { tenantId: tenantA.id, key: "zz-rls-insert", name: "Rolle 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)`; return tx.role.create({ data: { tenantId: tenantB.id, key: "zz-rls-foreign", name: "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 ─────────────────────── 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).role.findMany({ select: { id: true, tenantId: true } }); ok( e2eA.length > 0 && e2eA.every((r) => r.tenantId === tenantA.id) && !e2eA.some((r) => r.id === roleB.id), `(5a) dbForTenant(A).role.findMany() liefert nur A (${e2eA.length})`, ); const foreign = await db.dbForTenant(tenantA.id).role.findFirst({ where: { id: roleB.id } }); ok(foreign === null, "(5b) dbForTenant(A).role.findFirst({ id: B-Rolle }) → null (kein Fremdzugriff)"); await cleanup(); } main() .then(() => { if (failures > 0) { console.error(`\n${failures} Nachweis(e) fehlgeschlagen.`); process.exit(1); } console.log("\nOK — alle F-04-Nachweise erfüllt."); }) .catch((e) => { console.error(e); process.exit(1); }) .finally(async () => { await owner.$disconnect(); await app.$disconnect(); });