bbf179503c
WINDOWS #20: set_config() lief auf einer anderen Postgres-Verbindung als die eigentliche Abfrage, weil die interaktive Callback-Form von $transaction verwendet wurde. Ersetzt durch die Array-Form, die set_config und Abfrage als eine Transaktion auf einer Verbindung ausfuehrt (Prismas empfohlenes Muster fuer RLS-ueber-Extensions). Injektionsfestigkeit (T-02-05) bleibt ueber ein getaggtes $executeRaw-Template statt $executeRawUnsafe erhalten. - prisma-tenant.extension.spec.ts: prueft die Form des Aufrufs (Array mit zwei Eintraegen, Rueckgabewert ist der zweite Eintrag) ohne laufende Datenbank - rls-scratch-check.mjs: neues Werkzeug, das eine Wegwerf-Datenbank anlegt und live misst — gleiche Backend-Verbindung, gesetzter Kontext, keine Fremdmandanten-Zeilen, keine Zeilen ohne Kontext. Alle 5 Pruefungen bestehen gegen die lokale Datenbank. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
242 lines
9.2 KiB
JavaScript
242 lines
9.2 KiB
JavaScript
#!/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);
|
|
});
|