#!/usr/bin/env node // WINDOWS #18 — misst, statt zu behaupten, ob die Mandantentrennung unter // einer angegebenen Datenbankrolle tatsaechlich greift (Task 3). // // Verbindet mit der ueber TESSERA_PREFLIGHT_DATABASE_URL angegebenen Rolle // (new PrismaClient({ datasourceUrl: url })), damit gegen eine andere Rolle // gemessen werden kann als die, mit der die Anwendung laeuft. Kein neues // Paket noetig — Prisma ist bereits Abhaengigkeit der API. // // Jede Pruefung laeuft in einer eigenen interaktiven Transaktion // (prisma.$transaction), und jedes Setzen des Mandantenkontexts geschieht // transaktionslokal (set_config(..., true)) — genau wie // apps/api/src/prisma/prisma-tenant.extension.ts es tut. Ausserhalb einer // Transaktion kann Prismas Verbindungspool die Folgeabfrage auf eine andere // physische Verbindung legen, auf der die Einstellung nie gesetzt wurde; // die Messung waere dann wertlos. // // Das Werkzeug schreibt nichts. Es liest ausschliesslich. import { PrismaClient } from '@prisma/client'; const CHECKS = [ ['rollenrechte', 'current_user traegt weder rolsuper noch rolbypassrls (WINDOWS #18, der eigentliche Kern dieser Pruefung)'], ['kontext-setzbar', 'set_config(app.current_tenant, ...) laesst sich ohne besonderes Recht setzen und lesen'], ['ohne-kontext-leer', 'ohne gesetzten Mandantenkontext liefert jede Tabelle mit Policy null Zeilen'], ['mit-kontext-sichtbar', 'mit gesetztem Mandantenkontext ist mindestens eine Tabelle wieder sichtbar'], ['schreibrechte', 'current_user hat auf jede Tabelle im Schema public alle vier Zugriffsarten'], ]; const ENV_VAR = 'TESSERA_PREFLIGHT_DATABASE_URL'; function printPlan() { console.log('Pruefplan (verbindet nicht):'); for (const [kennung, beschreibung] of CHECKS) { console.log(`- ${kennung}: ${beschreibung}`); } console.log(`Verbindung wird aus der Umgebungsvariablen ${ENV_VAR} gelesen.`); } async function checkRollenrechte(tx) { const rows = await tx.$queryRaw`SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user`; const row = rows[0]; const passed = Boolean(row) && row.rolsuper === false && row.rolbypassrls === false; return { kennung: 'rollenrechte', passed, detail: row ? `Rolle ${row.rolname}: rolsuper=${row.rolsuper}, rolbypassrls=${row.rolbypassrls}` : 'current_user nicht in pg_roles gefunden', }; } async function checkKontextSetzbar(tx) { await tx.$executeRawUnsafe(`SELECT set_config('app.current_tenant', $1, true)`, 'probe'); const rows = await tx.$queryRaw`SELECT current_tenant_id() AS tenant`; const value = rows[0]?.tenant; return { kennung: 'kontext-setzbar', passed: value === 'probe', detail: `current_tenant_id() lieferte: ${JSON.stringify(value)}`, }; } async function getPolicyTables(prisma) { const rows = await prisma.$queryRaw`SELECT DISTINCT tablename FROM pg_policies WHERE schemaname = 'public' ORDER BY tablename`; return rows.map((r) => r.tablename); } async function countRows(tx, table) { const rows = await tx.$queryRawUnsafe(`SELECT count(*)::int AS count FROM "${table}"`); return rows[0]?.count ?? 0; } async function checkOhneKontextLeer(tx, policyTables) { const nonZero = []; for (const table of policyTables) { const count = await countRows(tx, table); if (count !== 0) nonZero.push(`${table}=${count}`); } return { kennung: 'ohne-kontext-leer', passed: nonZero.length === 0, detail: nonZero.length === 0 ? `alle ${policyTables.length} Tabellen mit Policy liefern 0 Zeilen` : `sichtbar ohne Kontext (Beweis fuer wirkungslose Trennung): ${nonZero.join(', ')}`, }; } async function checkMitKontextSichtbar(prisma, tx, policyTables) { const tenantRows = await prisma.$queryRaw`SELECT id FROM "Tenant" LIMIT 1`; const tenantId = tenantRows[0]?.id; if (!tenantId) { return { kennung: 'mit-kontext-sichtbar', passed: null, detail: 'nicht durchfuehrbar — kein Mandant in der Tabelle Tenant gefunden', }; } await tx.$executeRawUnsafe(`SELECT set_config('app.current_tenant', $1, true)`, tenantId); const visible = []; for (const table of policyTables) { const count = await countRows(tx, table); if (count > 0) visible.push(`${table}=${count}`); } return { kennung: 'mit-kontext-sichtbar', passed: visible.length > 0, detail: visible.length > 0 ? `mit Mandant ${tenantId} sichtbar: ${visible.join(', ')}` : `mit Mandant ${tenantId} liefert weiterhin keine Tabelle Zeilen`, }; } async function checkSchreibrechte(tx) { const tableRows = await tx.$queryRaw`SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE' ORDER BY table_name`; const privileges = ['SELECT', 'INSERT', 'UPDATE', 'DELETE']; const gaps = []; for (const { table_name: table } of tableRows) { const missing = []; for (const priv of privileges) { const rows = await tx.$queryRawUnsafe( `SELECT has_table_privilege(current_user, $1, $2) AS allowed`, table, priv, ); if (!rows[0]?.allowed) missing.push(priv); } if (missing.length > 0) gaps.push(`${table} fehlt ${missing.join('/')}`); } return { kennung: 'schreibrechte', passed: gaps.length === 0, detail: gaps.length === 0 ? `alle ${tableRows.length} Tabellen vollstaendig` : gaps.join('; '), }; } async function runChecks(url) { const prisma = new PrismaClient({ datasourceUrl: url }); const results = []; try { results.push(await prisma.$transaction((tx) => checkRollenrechte(tx))); results.push(await prisma.$transaction((tx) => checkKontextSetzbar(tx))); const policyTables = await getPolicyTables(prisma); results.push(await prisma.$transaction((tx) => checkOhneKontextLeer(tx, policyTables))); results.push(await prisma.$transaction((tx) => checkMitKontextSichtbar(prisma, tx, policyTables))); results.push(await prisma.$transaction((tx) => checkSchreibrechte(tx))); } finally { await prisma.$disconnect(); } return results; } async function main() { const args = process.argv.slice(2); if (args[0] === '--print-plan') { printPlan(); process.exit(0); } const url = process.env[ENV_VAR]; if (!url) { console.error(`FEHLER: ${ENV_VAR} ist nicht gesetzt.`); process.exit(1); } const results = await runChecks(url); console.log('Ergebnis der Mandantentrennungs-Pruefung:'); let allPassed = true; for (const { kennung, passed, detail } of results) { const status = passed === true ? 'bestanden' : passed === false ? 'FEHLGESCHLAGEN' : 'nicht durchfuehrbar'; if (passed === false) allPassed = false; console.log(`${kennung}: ${status} — ${detail}`); } process.exit(allPassed ? 0 : 1); } main().catch((err) => { console.error('FEHLER beim Ausfuehren der Pruefung:', err.message); process.exit(1); });