// Regressionstest der Mandantentrennung (Sicherheitsbefund F-02). // // Prüft den Hybrid-Guard aus `src/server/db.ts` (`dbForTenant`): ein Fremdzugriff // über `findUnique` darf keinen Datensatz eines anderen Mandanten preisgeben — // weder mit `select`-Projektion ohne `tenantId` (der ursprünglich ausnutzbare // Fall), noch mit `include`, ohne `select` oder über einen Compound-Unique-Key. // Der legitime Eigenzugriff muss unverändert funktionieren. // // Fixture: Fundament-Modell `Role` (tenantId + Compound-Unique `tenantId_key`). // // Lauf: npx tsx scripts/test-tenant-isolation.ts (lokale Postgres-DB aus .env) import "dotenv/config"; import { prisma, dbForTenant } from "../src/server/db"; let failures = 0; const ok = (cond: boolean, msg: string) => { console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); if (!cond) failures++; }; /** Erwartet, dass `fn` wirft (z. B. Isolationsverletzung oder NotFound). */ async function expectThrow(fn: () => Promise, msg: string) { try { await fn(); ok(false, `${msg} — kein Throw (Datenabfluss möglich!)`); } catch { ok(true, msg); } } /** * Compound keys with a foreign tenantId: in owner mode the tenant guard throws (fail-closed); * with RLS_ENFORCED=true the database already hides the row, so the query returns null. * Both outcomes mean "no data leak" — anything else fails. */ async function expectThrowOrNullUnderRls(fn: () => Promise, msg: string) { if (process.env.RLS_ENFORCED !== "true") return expectThrow(fn, msg); try { const r = await fn(); ok(r === null, `${msg} (RLS: null)${r === null ? "" : ` — statt null: ${JSON.stringify(r)}`}`); } catch { ok(true, `${msg} (RLS: Throw)`); } } /** Erwartet, dass `fn` `null` liefert (kein Datensatz, kein Abfluss). */ async function expectNull(fn: () => Promise, msg: string) { const r = await fn(); ok(r === null, `${msg}${r === null ? "" : ` — statt null: ${JSON.stringify(r)}`}`); } const SLUG_A = "zz-sec-test-a"; const SLUG_B = "zz-sec-test-b"; const KEY = "zz-isolation-role"; async function cleanup() { const tenants = await prisma.tenant.findMany({ where: { slug: { in: [SLUG_A, SLUG_B] } }, select: { id: true }, }); const ids = tenants.map((t) => t.id); if (ids.length) { await prisma.auditLog.deleteMany({ where: { tenantId: { in: ids } } }); await prisma.role.deleteMany({ where: { tenantId: { in: ids } } }); await prisma.tenant.deleteMany({ where: { id: { in: ids } } }); } } async function main() { // Idempotenz: eventuelle Reste eines früheren Laufs entfernen. await cleanup(); // Zwei Test-Mandanten mit je einer Rolle anlegen (roher Client = ohne Guard). const tenantA = await prisma.tenant.create({ data: { name: "SEC-Test A", slug: SLUG_A } }); const tenantB = await prisma.tenant.create({ data: { name: "SEC-Test B", slug: SLUG_B } }); const roleA = await prisma.role.create({ data: { tenantId: tenantA.id, key: KEY, name: "Rolle A (eigen)" } }); const roleB = await prisma.role.create({ data: { tenantId: tenantB.id, key: KEY, name: "GEHEIM-B (fremd)" } }); const dbA = dbForTenant(tenantA.id); console.log("\n— Fremdzugriff (Mandant A liest Rolle von B) muss scheitern —"); // (1) Der ursprüngliche Exploit: select-Projektion OHNE tenantId. await expectNull( () => dbA.role.findUnique({ where: { id: roleB.id }, select: { key: true, name: true } }), "findUnique + select OHNE tenantId → null (F-02-Kernfall)" ); // (2) select MIT tenantId. await expectNull( () => dbA.role.findUnique({ where: { id: roleB.id }, select: { name: true, tenantId: true } }), "findUnique + select MIT tenantId → null" ); // (3) include. await expectNull( () => dbA.role.findUnique({ where: { id: roleB.id }, include: { rolePermissions: true } }), "findUnique + include → null" ); // (4) ohne select/include. await expectNull( () => dbA.role.findUnique({ where: { id: roleB.id } }), "findUnique ohne Projektion → null" ); // (5) findUniqueOrThrow → muss werfen statt fremden Datensatz zu liefern. await expectThrow( () => dbA.role.findUniqueOrThrow({ where: { id: roleB.id }, select: { name: true } }), "findUniqueOrThrow + select OHNE tenantId → Throw" ); // (6) Compound-Unique-Key (tenantId_key) mit fremdem tenantId, select ohne tenantId. await expectThrowOrNullUnderRls( () => dbA.role.findUnique({ where: { tenantId_key: { tenantId: tenantB.id, key: roleB.key } }, select: { name: true }, }), "findUnique über Compound-Key (fremd) + select → Throw (fail-closed)" ); // (7) Compound-Unique-Key mit fremdem tenantId, ohne select. await expectThrowOrNullUnderRls( () => dbA.role.findUnique({ where: { tenantId_key: { tenantId: tenantB.id, key: roleB.key } }, }), "findUnique über Compound-Key (fremd) ohne select → Throw" ); // (7b) Mutation auf fremden Datensatz → Ownership-Vorprüfung wirft. await expectThrow( () => dbA.role.update({ where: { id: roleB.id }, data: { name: "gekapert" } }), "update auf fremde Rolle → Throw (Ownership-Vorprüfung)" ); const bAfter = await prisma.role.findUnique({ where: { id: roleB.id } }); ok(bAfter?.name === roleB.name, "fremde Rolle bleibt unverändert"); // (7c) findMany liefert nie fremde Zeilen. const listA = await dbA.role.findMany({ where: { key: KEY } }); ok(listA.length === 1 && listA[0].id === roleA.id, "findMany(key) liefert nur die eigene Rolle"); console.log("\n— Legitimer Eigenzugriff (Mandant A liest eigene Rolle A) muss funktionieren —"); // (8) skalarer Key + select: Treffer, und tenantId darf NICHT auftauchen (kein Injektions-Leck). const own1 = await dbA.role.findUnique({ where: { id: roleA.id }, select: { name: true } }); ok(own1?.name === roleA.name, "Eigenzugriff findUnique + select → Treffer"); ok(own1 !== null && !("tenantId" in (own1 as object)), "Eigenzugriff select {name} → Rückgabe OHNE tenantId"); // (9) Compound-Key (eigen) + select: Treffer, injiziertes tenantId wieder entfernt. const own2 = await dbA.role.findUnique({ where: { tenantId_key: { tenantId: tenantA.id, key: roleA.key } }, select: { name: true }, }); ok(own2?.name === roleA.name, "Eigenzugriff über Compound-Key + select → Treffer"); ok(own2 !== null && !("tenantId" in (own2 as object)), "Compound-Key select {name} → Rückgabe OHNE tenantId (Injektion bereinigt)"); // (10) ohne Projektion: voller Datensatz inkl. tenantId (Normalfall). const own3 = await dbA.role.findUnique({ where: { id: roleA.id } }); ok(own3?.tenantId === tenantA.id, "Eigenzugriff ohne Projektion → voller Datensatz inkl. tenantId"); // (11) findUniqueOrThrow auf eigenen Datensatz → kein Throw. const own4 = await dbA.role.findUniqueOrThrow({ where: { id: roleA.id }, select: { name: true } }); ok(own4.name === roleA.name, "Eigenzugriff findUniqueOrThrow → Treffer"); // (12) create injiziert den eigenen Mandanten. const created = await dbA.role.create({ data: { key: `${KEY}-2`, name: "Neu A", tenantId: tenantB.id } }); ok(created.tenantId === tenantA.id, "create über dbForTenant(A) setzt tenantId=A (auch bei fremdem Input)"); } main() .then(async () => { await cleanup(); await prisma.$disconnect(); if (failures > 0) { console.error(`\n✗ ${failures} Testfall/-fälle fehlgeschlagen.`); process.exit(1); } console.log("\nOK"); }) .catch(async (e) => { console.error(e); await cleanup().catch(() => {}); await prisma.$disconnect(); process.exit(1); });