fix(quick-260909-eor): forTenant() bindet Mandantenkontext auf dieselbe Verbindung

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
This commit is contained in:
2026-09-09 10:50:37 +02:00
parent d0393ac4af
commit bbf179503c
3 changed files with 414 additions and 9 deletions
+241
View File
@@ -0,0 +1,241 @@
#!/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);
});