Files
schalli 44a90e5bfd feat(quick-260909-dgj): Pruefwerkzeug fuer den Nachweis plus Betriebsanleitung (Task 3/4)
- apps/api/scripts/rls-preflight.mjs misst fuenf benannte Eigenschaften
  (rollenrechte, kontext-setzbar, ohne-kontext-leer, mit-kontext-sichtbar,
  schreibrechte) jeweils in einer eigenen Transaktion gegen eine per
  TESSERA_PREFLIGHT_DATABASE_URL angegebene Verbindung; --print-plan
  verbindet nicht, das Werkzeug schreibt in keiner Betriebsart
- 5 Tests in rls-preflight.spec.ts (rot vor dem Werkzeug, jetzt gruen)
- docs/mandantentrennung-datenbankrolle.md: Befund, Sperrgrund (182
  unskalierte Zugriffe, Anmeldeweg), Handgriffe des Betreibers samt
  Kennwortsetzung, Freigabebedingung und Rueckweg; docs/README.md verweist
  darauf

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
2026-09-09 10:03:25 +02:00

193 lines
6.8 KiB
JavaScript
Executable File

#!/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);
});