- ISMS-Routen, Actions, Server-/Lib-Code, Komponenten, Prisma-Modelle, Seeds, Importer, Skripte und ISMS-Tests entfernt (Fundament bleibt: Auth, Identity, MFA/WebAuthn, RBAC, Audit, Mail, Storage, Backup/DSGVO, Plattform-Admin) - Schema auf Fundament-Modelle reduziert; TenantSettings generisch (+phone/email) - TENANT_MODELS (db.ts, backup/topology.ts) und PII-Felder ausgedünnt - RBAC: Rollen tenant-admin/backoffice/team-lead/technician + Craftvia-Permissions - Modul-Katalog (customers, sites, teams, work_orders, imports, field, reports, emergency, documents, notifications, lotse) + Navigation aus src/lib/nav.ts - Modul-Routen mit requireModule-Layout und Platzhalterseite - Message-Katalog je Namespace (messages/<locale>/<namespace>.json), fs-Loader - check-module-guards: Modul-Key aus src/server/actions/<moduleKey>/ - Provisionierung, Admin-Konsole, Einstellungen, Files-Route, Mail entkoppelt - Seed minimal (demo/demo2, Nutzer je Rolle); Fundament-Tests auf Role/ NotificationPreference-Fixtures umgestellt Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
187 lines
8.6 KiB
TypeScript
187 lines
8.6 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);
|
|
}
|
|
}
|
|
|
|
const RLS_URL =
|
|
process.env.RLS_DATABASE_URL ??
|
|
"postgresql://craftvia_app:craftvia_app_local@localhost:5432/craftvia?schema=public";
|
|
|
|
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();
|
|
});
|