#!/usr/bin/env node // WINDOWS #20 (260909-eor) — richtet sich eine eigene Wegwerf-Datenbank ein // und misst dort live, ob das reparierte forTenant()-Muster (Aufgabe 1) // tatsaechlich das tut, was es behauptet. Aufgabe 2 erweitert dieses // Werkzeug um einen zweiten Abschnitt fuer die auth_lookup_*-Funktionen. // // Ruehrt die Datenbank "tessera" NICHT an (T-EOR-07): der Name der // Wegwerf-Datenbank ist fest im Werkzeug verdrahtet, nicht ueber eine // Umgebungsvariable steuerbar, damit ein Tippfehler nicht in der echten // Datenbank landet. Verbindungsangaben kommen ausschliesslich ueber // TESSERA_SCRATCH_ADMIN_URL (Verbindung zu einer Wartungsdatenbank wie // "postgres" mit Rechten, um eine neue Datenbank/Rolle anzulegen und wieder // abzuraeumen). Ohne diese Variable bricht das Werkzeug mit einer Anleitung // ab, statt eine Vorgabe zu raten. // // Nutzt ausschliesslich @prisma/client (bereits Abhaengigkeit der API) — // kein neues Paket. Fuer DDL (CREATE DATABASE/ROLE mit festen, im Werkzeug // hartkodierten Namen) ist Interpolation unvermeidlich, da PostgreSQL // Identifier nicht parametrisieren kann; es fliesst dabei nirgends // Nutzereingabe ein. // // Dupliziert bewusst das forTenant()-Verbindungsmuster statt die // TypeScript-Quelle unter apps/api/src zu importieren — dasselbe Vorgehen // wie im bestehenden apps/api/scripts/rls-preflight.mjs, weil ein reines // Node-Skript ohne Build-Schritt kein .ts importieren kann. // // Meldet je Pruefung eine Zeile und beendet sich mit Rueckgabewert 1, sobald // eine Pruefung scheitert. Gibt kein Kennwort und keine vollstaendige // Verbindungszeichenkette aus. import { PrismaClient } from '@prisma/client'; const ADMIN_ENV_VAR = 'TESSERA_SCRATCH_ADMIN_URL'; const SCRATCH_DB_NAME = 'tessera_rls_scratch'; const SCRATCH_ROLE_NAME = 'tessera_rls_scratch_role'; const SCRATCH_ROLE_PASSWORD = 'scratch_only_local_never_reused'; function fail(message) { console.error(`FEHLER: ${message}`); process.exit(1); } function parseAdminUrl() { const raw = process.env[ADMIN_ENV_VAR]; if (!raw) { fail( `${ADMIN_ENV_VAR} ist nicht gesetzt. Beispiel: ` + `${ADMIN_ENV_VAR}="postgresql://tessera:tessera_dev@172.19.0.2:5432/postgres" ` + `node apps/api/scripts/rls-scratch-check.mjs`, ); } return raw; } function urlForDatabase(adminUrl, dbName) { const url = new URL(adminUrl); url.pathname = `/${dbName}`; return url; } async function withAdminPrisma(adminUrl, fn) { const prisma = new PrismaClient({ datasourceUrl: adminUrl }); try { return await fn(prisma); } finally { await prisma.$disconnect(); } } async function setupScratchDatabase(adminUrl) { await withAdminPrisma(adminUrl, async (admin) => { await admin.$executeRawUnsafe( `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${SCRATCH_DB_NAME}' AND pid <> pg_backend_pid()`, ); await admin.$executeRawUnsafe(`DROP DATABASE IF EXISTS ${SCRATCH_DB_NAME}`); await admin.$executeRawUnsafe(`DROP ROLE IF EXISTS ${SCRATCH_ROLE_NAME}`); await admin.$executeRawUnsafe(`CREATE DATABASE ${SCRATCH_DB_NAME}`); await admin.$executeRawUnsafe( `CREATE ROLE ${SCRATCH_ROLE_NAME} WITH LOGIN NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE PASSWORD '${SCRATCH_ROLE_PASSWORD}'`, ); }); const scratchAdminUrl = urlForDatabase(adminUrl, SCRATCH_DB_NAME).toString(); await withAdminPrisma(scratchAdminUrl, async (db) => { await db.$executeRawUnsafe(` CREATE TABLE probe ( id serial PRIMARY KEY, "tenantId" text NOT NULL, label text NOT NULL ); `); await db.$executeRawUnsafe(` CREATE OR REPLACE FUNCTION current_tenant_id() RETURNS TEXT AS $$ SELECT current_setting('app.current_tenant', true); $$ LANGUAGE sql STABLE; `); await db.$executeRawUnsafe(`ALTER TABLE probe ENABLE ROW LEVEL SECURITY;`); await db.$executeRawUnsafe(`ALTER TABLE probe FORCE ROW LEVEL SECURITY;`); await db.$executeRawUnsafe(` CREATE POLICY tenant_isolation_policy ON probe USING ("tenantId" = current_tenant_id()); `); await db.$executeRawUnsafe(`GRANT USAGE ON SCHEMA public TO ${SCRATCH_ROLE_NAME}`); await db.$executeRawUnsafe( `GRANT SELECT, INSERT, UPDATE, DELETE ON probe TO ${SCRATCH_ROLE_NAME}`, ); await db.$executeRawUnsafe( `GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ${SCRATCH_ROLE_NAME}`, ); await db.$executeRawUnsafe( `GRANT EXECUTE ON FUNCTION current_tenant_id() TO ${SCRATCH_ROLE_NAME}`, ); await db.$executeRawUnsafe( `INSERT INTO probe ("tenantId", label) VALUES ('TENANT-A', 'a-row'), ('TENANT-B', 'b-row')`, ); }); } async function teardownScratchDatabase(adminUrl) { await withAdminPrisma(adminUrl, async (admin) => { await admin.$executeRawUnsafe( `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${SCRATCH_DB_NAME}' AND pid <> pg_backend_pid()`, ); await admin.$executeRawUnsafe(`DROP DATABASE IF EXISTS ${SCRATCH_DB_NAME}`); await admin.$executeRawUnsafe(`DROP ROLE IF EXISTS ${SCRATCH_ROLE_NAME}`); }); } function report(results, kennung, passed, detail) { const status = passed ? 'bestanden' : 'FEHLGESCHLAGEN'; console.log(`${kennung}: ${status} — ${detail}`); results.push({ kennung, passed, detail }); } /** * Repliziert exakt das reparierte forTenant()-Muster aus * apps/api/src/prisma/prisma-tenant.extension.ts: set_config und die * 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)]); return result; } /** * Aufgabe 1 — misst die fuenf im Plan genannten Verhaltensweisen von * forTenant() unter der Rolle ohne BYPASSRLS. */ async function runForTenantChecks(scratchRoleUrl, results) { const prisma = new PrismaClient({ datasourceUrl: scratchRoleUrl }); try { // 1+2: gleiche Verbindung UND gesetzter Kontext — als zwei Teilmessungen // einer einzigen Array-Transaktion, im selben Format wie die urspruengliche // Fehlerreproduktion (Backend-PID beim set_config-Schritt vs. Backend-PID // bei der eigentlichen Abfrage; siehe Kopfkommentar von // prisma-tenant.extension.ts: "inside tx"/"actual qry"). const [setStepRow, queryStepRow] = await prisma.$transaction([ prisma.$queryRaw`SELECT pg_backend_pid() AS pid, set_config('app.current_tenant', 'TENANT-A', true) AS applied`, prisma.$queryRaw`SELECT pg_backend_pid() AS pid, current_tenant_id() AS t`, ]).then(([setRows, queryRows]) => [setRows[0], queryRows[0]]); report( results, 'gleiche-backend-verbindung', setStepRow.pid === queryStepRow.pid, `set_config-Schritt pg_backend_pid()=${setStepRow.pid}, Abfrage-Schritt pg_backend_pid()=${queryStepRow.pid}`, ); report( results, 'mandantenkontext-waehrend-abfrage-gesetzt', queryStepRow.t === 'TENANT-A', `current_tenant_id() waehrend der eigentlichen Abfrage=${JSON.stringify(queryStepRow.t)}`, ); // 3+4: forTenant(A) liefert ausschliesslich Zeilen von A, keine von B. const rowsForA = await forTenantQuery(prisma, 'TENANT-A', (tx) => tx.$queryRaw`SELECT "tenantId" FROM probe ORDER BY id`, ); const onlyA = rowsForA.length > 0 && rowsForA.every((r) => r.tenantId === 'TENANT-A'); report( results, 'nur-eigene-mandanten-zeilen', onlyA, `forTenant(TENANT-A) liefert ${rowsForA.length} Zeile(n): ${JSON.stringify(rowsForA.map((r) => r.tenantId))}`, ); const leaksB = rowsForA.some((r) => r.tenantId === 'TENANT-B'); report( results, 'keine-fremdmandanten-zeilen', !leaksB, leaksB ? 'Zeile von TENANT-B sichtbar unter forTenant(TENANT-A)' : 'keine Zeile von TENANT-B sichtbar', ); // 5: ungebundener Zugriff derselben Rolle liefert null Zeilen. const unbound = await prisma.$queryRaw`SELECT "tenantId" FROM probe`; report( results, 'ungebunden-liefert-null-zeilen', unbound.length === 0, `ungebundener SELECT liefert ${unbound.length} Zeile(n)`, ); } finally { await prisma.$disconnect(); } } async function main() { const adminUrl = parseAdminUrl(); const results = []; console.log(`Richte Wegwerf-Datenbank "${SCRATCH_DB_NAME}" ein...`); await setupScratchDatabase(adminUrl); try { const scratchRoleUrl = urlForDatabase(adminUrl, SCRATCH_DB_NAME); scratchRoleUrl.username = SCRATCH_ROLE_NAME; scratchRoleUrl.password = SCRATCH_ROLE_PASSWORD; await runForTenantChecks(scratchRoleUrl.toString(), results); } finally { console.log(`Raeume Wegwerf-Datenbank "${SCRATCH_DB_NAME}" ab...`); await teardownScratchDatabase(adminUrl); } const allPassed = results.every((r) => r.passed); console.log( allPassed ? `Alle ${results.length} Pruefungen bestanden.` : `${results.filter((r) => !r.passed).length} von ${results.length} Pruefungen fehlgeschlagen.`, ); process.exit(allPassed ? 0 : 1); } main().catch((err) => { console.error('FEHLER beim Ausfuehren der Wegwerf-Pruefung:', err.message); process.exit(1); });