Files
craftvia/scripts/test-rls-enforcement.ts
T
msolarczekandClaude Opus 5 4a25f2cc3b Fundament: atomare Mandanten-Transaktionen, iframe-Vorschau, Uploads bis 25 MB, DSGVO-Felder
- db.ts: tenantTransaction() – atomar auch bei RLS_ENFORCED=true (AsyncLocalStorage
  bindet Operationen an eine craftvia_app-Transaktion, Kontext einmal gesetzt,
  verschachtelte Aufrufe treten bei, fremder Mandant wird abgewiesen)
- services/context.ts: inTransaction(ctx, fn); imports/confirm.ts umgestellt
- next.config.ts: EMBEDDABLE_FILE_ROUTES mit frame-ancestors 'self'/SAMEORIGIN
  (PDF-Vorschau Prüfmaske), proxyClientMaxBodySize 26mb (Import bis 25 MB)
- test-rls-enforcement: RLS-URL-Default aus DATABASE_URL (Lane-DBs)
- dsgvo/pii-fields: 26 Personenreferenzen des Craftvia-Domänenmodells
- ARCHITEKTUR §4.8: Transaktions-, Header-, Upload-, Versions- und PII-Regeln
- Test test-tenant-transaction (Commit/Rollback/Fremdmandant/Verschachtelung),
  grün im Owner- und im RLS-Modus

Gate: tsc, lint, build, 31/31 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:29:57 +02:00

195 lines
9.0 KiB
TypeScript

// Korrektheitsnachweis für F-04: Row Level Security scharfschalten.
//
// Beweist, dass die scharfe RLS (FORCE + WITH CHECK + Kontext, Rolle craftvia_app)
// die Mandantentrennung erzwingt, OHNE den lokalen Owner-Betrieb zu brechen.
// Nachweise (jeweils Assertion; am Ende "OK" oder Exit-Code != 0):
// (0) Baseline: die RLS-Funktion enable_tenant_rls() existiert und alle Tabellen der
// TENANT_MODELS tragen FORCE RLS + Policy tenant_isolation.
// (1) Owner-Betrieb bleibt heil: Owner (Superuser/BYPASSRLS) sieht ohne
// app.tenant_id weiterhin ALLE Zeilen über Mandanten hinweg.
// (2) RLS greift für craftvia_app: mit Kontext=A nur A-Zeilen, keine von B.
// (3) Ohne Kontext = null Zeilen (fail-closed).
// (4) WITH CHECK wirkt: INSERT mit eigenem Mandanten gelingt, mit fremdem
// Mandanten wird abgelehnt.
// (5) dbForTenant end-to-end mit RLS_ENFORCED=true (dynamischer Import).
//
// Fixture: Fundament-Modell `Role`.
//
// Voraussetzung (einmalig lokal, nach `prisma migrate deploy`):
// docker compose exec postgres psql -U craftvia -d craftvia \
// -c "ALTER ROLE craftvia_app WITH LOGIN PASSWORD 'craftvia_app_local';"
// Ohne Login-Recht der Rolle wird der Test mit klarer Meldung übersprungen (Exit 0),
// es sei denn RLS_TEST_REQUIRED=true.
//
// Lauf: npx tsx scripts/test-rls-enforcement.ts
import "dotenv/config";
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import { buildTenantTopology, TENANT_MODELS } from "../src/server/backup/topology";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
async function expectThrow(fn: () => Promise<unknown>, msg: string) {
try {
await fn();
ok(false, `${msg} — kein Throw (Isolationsbruch möglich!)`);
} catch {
ok(true, msg);
}
}
/**
* Default: same host/port/database as DATABASE_URL, but as the restricted role craftvia_app.
* Keeps the test pointed at the database that was actually migrated/seeded (e.g. lane DBs).
*/
function defaultRlsUrl(): string {
const base = new URL(process.env.DATABASE_URL ?? "postgresql://localhost:5432/craftvia?schema=public");
base.username = "craftvia_app";
base.password = "craftvia_app_local";
return base.toString();
}
const RLS_URL = process.env.RLS_DATABASE_URL ?? defaultRlsUrl();
const SLUG_A = "zz-rls-test-a";
const SLUG_B = "zz-rls-test-b";
// Eigener Owner-Client (DATABASE_URL) für Setup/Cleanup — unabhängig von db.ts,
// damit der spätere dynamische Import von db.ts (mit RLS_ENFORCED=true) sauber
// mit der bereits gesetzten Flag-Umgebung evaluiert.
const owner = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
// Client der eingeschränkten App-Rolle craftvia_app (unterliegt der scharfen RLS).
const app = new PrismaClient({
adapter: new PrismaPg({ connectionString: RLS_URL }),
});
async function cleanup() {
const tenants = await owner.tenant.findMany({
where: { slug: { in: [SLUG_A, SLUG_B] } },
select: { id: true },
});
const ids = tenants.map((t) => t.id);
if (ids.length) {
await owner.role.deleteMany({ where: { tenantId: { in: ids } } });
await owner.tenant.deleteMany({ where: { id: { in: ids } } });
}
}
async function main() {
// ── (0) Baseline-Struktur ─────────────────────────────────────────────────
const fn = await owner.$queryRawUnsafe<{ n: bigint }[]>(
"SELECT count(*)::bigint AS n FROM pg_proc WHERE proname = 'enable_tenant_rls'",
);
ok(Number(fn[0]?.n ?? 0) === 1, "(0a) SQL-Funktion enable_tenant_rls(text) existiert");
const topo = buildTenantTopology();
const tables = TENANT_MODELS.map((m) => topo.nodes.get(m)!.table);
const rls = await owner.$queryRawUnsafe<{ relname: string; relrowsecurity: boolean; relforcerowsecurity: boolean; policies: bigint }[]>(
`SELECT c.relname, c.relrowsecurity, c.relforcerowsecurity,
(SELECT count(*) FROM pg_policies p WHERE p.tablename = c.relname AND p.policyname = 'tenant_isolation')::bigint AS policies
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind = 'r'`,
);
const byTable = new Map(rls.map((r) => [r.relname, r]));
const missing = tables.filter((t) => {
const r = byTable.get(t);
return !r || !r.relrowsecurity || !r.relforcerowsecurity || Number(r.policies) !== 1;
});
ok(missing.length === 0, `(0b) alle ${tables.length} Tenant-Tabellen haben ENABLE+FORCE RLS + tenant_isolation${missing.length ? ` — fehlt: ${missing.join(", ")}` : ""}`);
// Login-Fähigkeit der App-Rolle prüfen (lokal ggf. noch nicht eingerichtet).
try {
await app.$queryRawUnsafe("SELECT 1");
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (process.env.RLS_TEST_REQUIRED === "true") throw e;
console.log(`\n⚠ ÜBERSPRUNGEN (1)-(5): Verbindung als craftvia_app nicht möglich (${msg.split("\n")[0]}).`);
console.log(" Einmalig: ALTER ROLE craftvia_app WITH LOGIN PASSWORD 'craftvia_app_local';");
if (failures > 0) process.exit(1);
return;
}
await cleanup();
// Setup: zwei Test-Mandanten mit je einer Rolle (über den Owner angelegt).
const tenantA = await owner.tenant.create({ data: { name: "ZZ RLS Test A", slug: SLUG_A } });
const tenantB = await owner.tenant.create({ data: { name: "ZZ RLS Test B", slug: SLUG_B } });
const roleA = await owner.role.create({ data: { tenantId: tenantA.id, key: "zz-rls", name: "Rolle A" } });
const roleB = await owner.role.create({ data: { tenantId: tenantB.id, key: "zz-rls", name: "Rolle B" } });
// ── (1) Owner-Betrieb bleibt heil ────────────────────────────────────────
const ownerCount = await owner.role.count({ where: { tenantId: { in: [tenantA.id, tenantB.id] } } });
ok(ownerCount === 2, `(1) Owner sieht ohne Kontext beide Mandanten (${ownerCount}/2) — FORCE bricht Owner-Betrieb nicht`);
const ownerTotal = await owner.role.count();
ok(ownerTotal > 0, `(1b) Owner sieht global Zeilen (${ownerTotal} > 0)`);
// ── (2) RLS greift für craftvia_app: mit Kontext=A nur A-Zeilen ─────────────
const seenA = await app.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.tenant_id', ${tenantA.id}, true)`;
return tx.role.findMany({ select: { id: true, tenantId: true } });
});
ok(
seenA.length > 0 &&
seenA.every((r) => r.tenantId === tenantA.id) &&
seenA.some((r) => r.id === roleA.id) &&
!seenA.some((r) => r.id === roleB.id),
`(2) craftvia_app mit Kontext=A sieht nur A-Zeilen (${seenA.length}), keine von B`,
);
// ── (3) Ohne Kontext = null Zeilen (fail-closed) ───────────────────────────
const seenNone = await app.role.count();
ok(seenNone === 0, `(3) craftvia_app ohne Kontext sieht 0 Zeilen (${seenNone})`);
// ── (4) WITH CHECK wirkt: eigener Mandant erlaubt, fremder abgelehnt ────────
const inserted = await app.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.tenant_id', ${tenantA.id}, true)`;
return tx.role.create({ data: { tenantId: tenantA.id, key: "zz-rls-insert", name: "Rolle A insert" } });
});
ok(inserted.tenantId === tenantA.id, "(4a) INSERT mit eigenem Mandanten (A) unter Kontext=A gelingt");
await expectThrow(
() =>
app.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.tenant_id', ${tenantA.id}, true)`;
return tx.role.create({ data: { tenantId: tenantB.id, key: "zz-rls-foreign", name: "Fremd-Insert" } });
}),
"(4b) INSERT mit fremdem Mandanten (B) unter Kontext=A wird von WITH CHECK abgelehnt",
);
// ── (5) dbForTenant end-to-end mit RLS_ENFORCED=true ───────────────────────
process.env.RLS_ENFORCED = "true";
process.env.RLS_DATABASE_URL = RLS_URL;
const db = await import("../src/server/db");
const e2eA = await db.dbForTenant(tenantA.id).role.findMany({ select: { id: true, tenantId: true } });
ok(
e2eA.length > 0 && e2eA.every((r) => r.tenantId === tenantA.id) && !e2eA.some((r) => r.id === roleB.id),
`(5a) dbForTenant(A).role.findMany() liefert nur A (${e2eA.length})`,
);
const foreign = await db.dbForTenant(tenantA.id).role.findFirst({ where: { id: roleB.id } });
ok(foreign === null, "(5b) dbForTenant(A).role.findFirst({ id: B-Rolle }) → null (kein Fremdzugriff)");
await cleanup();
}
main()
.then(() => {
if (failures > 0) {
console.error(`\n${failures} Nachweis(e) fehlgeschlagen.`);
process.exit(1);
}
console.log("\nOK — alle F-04-Nachweise erfüllt.");
})
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await owner.$disconnect();
await app.$disconnect();
});