Basis: Certvia dev@a48c5fb als Fundament für Craftvia
Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
// Korrektheitsnachweis für F-04: Row Level Security scharfschalten.
|
||||
//
|
||||
// Beweist, dass die scharfe RLS (FORCE + WITH CHECK + Kontext, Rolle isms_app)
|
||||
// die Mandantentrennung erzwingt, OHNE den lokalen Owner-Betrieb zu brechen.
|
||||
// Fünf Nachweise (jeweils Assertion; am Ende "OK" oder Exit-Code != 0):
|
||||
// (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.
|
||||
// (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
|
||||
//
|
||||
// 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";
|
||||
|
||||
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://isms_app:isms_app_local@localhost:5432/isms?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 isms_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.risk.deleteMany({ where: { tenantId: { in: ids } } });
|
||||
await owner.tenant.deleteMany({ where: { id: { in: ids } } });
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
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" },
|
||||
});
|
||||
|
||||
// ── (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();
|
||||
ok(ownerTotal > 0, `(1b) Owner sieht global Zeilen (${ownerTotal} > 0)`);
|
||||
|
||||
// ── (2) RLS greift für isms_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 } });
|
||||
});
|
||||
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`,
|
||||
);
|
||||
|
||||
// ── (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})`);
|
||||
|
||||
// ── (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" },
|
||||
});
|
||||
});
|
||||
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" },
|
||||
});
|
||||
}),
|
||||
"(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 },
|
||||
});
|
||||
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)",
|
||||
);
|
||||
|
||||
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()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await owner.$disconnect();
|
||||
await app.$disconnect();
|
||||
});
|
||||
Reference in New Issue
Block a user