Fundament: ISMS-Module entfernt; Craftvia-Rollen, Module, Navigation, i18n-Split
- 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>
This commit is contained in:
@@ -1,27 +1,32 @@
|
||||
// Korrektheitsnachweis für F-04: Row Level Security scharfschalten.
|
||||
//
|
||||
// Beweist, dass die scharfe RLS (FORCE + WITH CHECK + Kontext, Rolle isms_app)
|
||||
// Beweist, dass die scharfe RLS (FORCE + WITH CHECK + Kontext, Rolle craftvia_app)
|
||||
// die Mandantentrennung erzwingt, OHNE den lokalen Owner-Betrieb zu brechen.
|
||||
// Fünf Nachweise (jeweils Assertion; am Ende "OK" oder Exit-Code != 0):
|
||||
// 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 isms_app: mit Kontext=A nur A-Zeilen, keine von B.
|
||||
// (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).
|
||||
//
|
||||
// Voraussetzung (einmalig lokal):
|
||||
// docker exec isms-tool-postgres-1 psql -U isms -d isms \
|
||||
// -c "ALTER ROLE isms_app WITH LOGIN PASSWORD 'isms_app_local';"
|
||||
// npx prisma migrate deploy
|
||||
// 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
|
||||
// Nutzt die lokale Postgres-DB (Container isms-tool-postgres-1); .env im Worktree.
|
||||
|
||||
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) => {
|
||||
@@ -39,7 +44,7 @@ async function expectThrow(fn: () => Promise<unknown>, msg: string) {
|
||||
|
||||
const RLS_URL =
|
||||
process.env.RLS_DATABASE_URL ??
|
||||
"postgresql://isms_app:isms_app_local@localhost:5432/isms?schema=public";
|
||||
"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";
|
||||
@@ -50,7 +55,7 @@ const SLUG_B = "zz-rls-test-b";
|
||||
const owner = new PrismaClient({
|
||||
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
|
||||
});
|
||||
// Client der eingeschränkten App-Rolle isms_app (unterliegt der scharfen RLS).
|
||||
// Client der eingeschränkten App-Rolle craftvia_app (unterliegt der scharfen RLS).
|
||||
const app = new PrismaClient({
|
||||
adapter: new PrismaPg({ connectionString: RLS_URL }),
|
||||
});
|
||||
@@ -62,114 +67,115 @@ async function cleanup() {
|
||||
});
|
||||
const ids = tenants.map((t) => t.id);
|
||||
if (ids.length) {
|
||||
await owner.risk.deleteMany({ where: { tenantId: { in: ids } } });
|
||||
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 einem Risk (ü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 riskA = await owner.risk.create({
|
||||
data: { tenantId: tenantA.id, refNo: 9001, title: "Risiko A" },
|
||||
});
|
||||
const riskB = await owner.risk.create({
|
||||
data: { tenantId: tenantB.id, refNo: 9001, title: "Risiko B" },
|
||||
});
|
||||
// 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 (kritisch für lokal + devB) ──────────────
|
||||
// Owner ist Superuser/BYPASSRLS → sieht trotz FORCE alle Mandanten, OHNE dass
|
||||
// app.tenant_id gesetzt ist.
|
||||
const ownerCount = await owner.risk.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.risk.count();
|
||||
// ── (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 isms_app: mit Kontext=A nur A-Zeilen ────────────────
|
||||
// ── (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.risk.findMany({ select: { id: true, tenantId: 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 === riskA.id) &&
|
||||
!seenA.some((r) => r.id === riskB.id),
|
||||
`(2) isms_app mit Kontext=A sieht nur A-Zeilen (${seenA.length}), keine von B`,
|
||||
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.risk.count();
|
||||
ok(seenNone === 0, `(3) isms_app ohne Kontext sieht 0 Zeilen (${seenNone})`);
|
||||
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.risk.create({
|
||||
data: { tenantId: tenantA.id, refNo: 9002, title: "Risiko A insert" },
|
||||
});
|
||||
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",
|
||||
);
|
||||
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)`;
|
||||
// Fremder Mandant B unter Kontext A → WITH-CHECK-Policy lehnt ab.
|
||||
return tx.risk.create({
|
||||
data: { tenantId: tenantB.id, refNo: 9003, title: "Fremd-Insert" },
|
||||
});
|
||||
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 ───────────────────────
|
||||
// Flag + URL VOR dem Import von db.ts setzen (dynamischer Import).
|
||||
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).risk.findMany({
|
||||
select: { id: true, tenantId: true },
|
||||
});
|
||||
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 === riskB.id),
|
||||
`(5a) dbForTenant(A).risk.findMany() liefert nur A (${e2eA.length})`,
|
||||
);
|
||||
const foreign = await db.dbForTenant(tenantA.id).risk.findFirst({
|
||||
where: { id: riskB.id },
|
||||
});
|
||||
ok(
|
||||
foreign === null,
|
||||
"(5b) dbForTenant(A).risk.findFirst({ id: B-Risk }) → null (kein Fremdzugriff)",
|
||||
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();
|
||||
|
||||
if (failures > 0) {
|
||||
console.error(`\n${failures} Nachweis(e) fehlgeschlagen.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nOK — alle F-04-Nachweise (1)-(5) erfüllt.");
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user