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:
@@ -6,8 +6,9 @@
|
||||
// Fall), noch mit `include`, ohne `select` oder über einen Compound-Unique-Key.
|
||||
// Der legitime Eigenzugriff muss unverändert funktionieren.
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-tenant-isolation.ts
|
||||
// Nutzt die lokale Postgres-DB (Container isms-tool-postgres-1); .env liegt im Worktree.
|
||||
// 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";
|
||||
@@ -36,6 +37,7 @@ async function expectNull(fn: () => Promise<unknown>, msg: string) {
|
||||
|
||||
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({
|
||||
@@ -44,7 +46,8 @@ async function cleanup() {
|
||||
});
|
||||
const ids = tenants.map((t) => t.id);
|
||||
if (ids.length) {
|
||||
await prisma.risk.deleteMany({ where: { tenantId: { in: ids } } });
|
||||
await prisma.auditLog.deleteMany({ where: { tenantId: { in: ids } } });
|
||||
await prisma.role.deleteMany({ where: { tenantId: { in: ids } } });
|
||||
await prisma.tenant.deleteMany({ where: { id: { in: ids } } });
|
||||
}
|
||||
}
|
||||
@@ -53,57 +56,53 @@ async function main() {
|
||||
// Idempotenz: eventuelle Reste eines früheren Laufs entfernen.
|
||||
await cleanup();
|
||||
|
||||
// Zwei Test-Mandanten mit je einem Risiko anlegen (roher Client = ohne Guard).
|
||||
// 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 riskA = await prisma.risk.create({
|
||||
data: { tenantId: tenantA.id, refNo: 900001, title: "Risiko A (eigen)", likelihood: 3, impact: 3, score: 9 },
|
||||
});
|
||||
const riskB = await prisma.risk.create({
|
||||
data: { tenantId: tenantB.id, refNo: 900001, title: "GEHEIM-B (fremd)", likelihood: 4, impact: 4, score: 16 },
|
||||
});
|
||||
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 Risiko von B) muss scheitern —");
|
||||
console.log("\n— Fremdzugriff (Mandant A liest Rolle von B) muss scheitern —");
|
||||
|
||||
// (1) Der ursprüngliche Exploit: select-Projektion OHNE tenantId.
|
||||
await expectNull(
|
||||
() => dbA.risk.findUnique({ where: { id: riskB.id }, select: { refNo: true, title: true } }),
|
||||
() => 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.risk.findUnique({ where: { id: riskB.id }, select: { title: true, tenantId: true } }),
|
||||
() => dbA.role.findUnique({ where: { id: roleB.id }, select: { name: true, tenantId: true } }),
|
||||
"findUnique + select MIT tenantId → null"
|
||||
);
|
||||
|
||||
// (3) include (tenantId wäre ohnehin enthalten).
|
||||
// (3) include.
|
||||
await expectNull(
|
||||
() => dbA.risk.findUnique({ where: { id: riskB.id }, include: { riskMeasures: true } }),
|
||||
() => dbA.role.findUnique({ where: { id: roleB.id }, include: { rolePermissions: true } }),
|
||||
"findUnique + include → null"
|
||||
);
|
||||
|
||||
// (4) ohne select/include.
|
||||
await expectNull(
|
||||
() => dbA.risk.findUnique({ where: { id: riskB.id } }),
|
||||
() => dbA.role.findUnique({ where: { id: roleB.id } }),
|
||||
"findUnique ohne Projektion → null"
|
||||
);
|
||||
|
||||
// (5) findUniqueOrThrow → muss werfen statt fremden Datensatz zu liefern.
|
||||
await expectThrow(
|
||||
() => dbA.risk.findUniqueOrThrow({ where: { id: riskB.id }, select: { title: true } }),
|
||||
() => dbA.role.findUniqueOrThrow({ where: { id: roleB.id }, select: { name: true } }),
|
||||
"findUniqueOrThrow + select OHNE tenantId → Throw"
|
||||
);
|
||||
|
||||
// (6) Compound-Unique-Key (tenantId_refNo) mit fremdem tenantId, select ohne tenantId.
|
||||
// (6) Compound-Unique-Key (tenantId_key) mit fremdem tenantId, select ohne tenantId.
|
||||
await expectThrow(
|
||||
() =>
|
||||
dbA.risk.findUnique({
|
||||
where: { tenantId_refNo: { tenantId: tenantB.id, refNo: riskB.refNo } },
|
||||
select: { title: true },
|
||||
dbA.role.findUnique({
|
||||
where: { tenantId_key: { tenantId: tenantB.id, key: roleB.key } },
|
||||
select: { name: true },
|
||||
}),
|
||||
"findUnique über Compound-Key (fremd) + select → Throw (fail-closed)"
|
||||
);
|
||||
@@ -111,34 +110,50 @@ async function main() {
|
||||
// (7) Compound-Unique-Key mit fremdem tenantId, ohne select.
|
||||
await expectThrow(
|
||||
() =>
|
||||
dbA.risk.findUnique({
|
||||
where: { tenantId_refNo: { tenantId: tenantB.id, refNo: riskB.refNo } },
|
||||
dbA.role.findUnique({
|
||||
where: { tenantId_key: { tenantId: tenantB.id, key: roleB.key } },
|
||||
}),
|
||||
"findUnique über Compound-Key (fremd) ohne select → Throw"
|
||||
);
|
||||
|
||||
console.log("\n— Legitimer Eigenzugriff (Mandant A liest eigenes Risiko A) muss funktionieren —");
|
||||
// (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.risk.findUnique({ where: { id: riskA.id }, select: { title: true } });
|
||||
ok(own1?.title === riskA.title, "Eigenzugriff findUnique + select → Treffer");
|
||||
ok(own1 !== null && !("tenantId" in (own1 as object)), "Eigenzugriff select {title} → Rückgabe OHNE tenantId");
|
||||
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.risk.findUnique({
|
||||
where: { tenantId_refNo: { tenantId: tenantA.id, refNo: riskA.refNo } },
|
||||
select: { title: true },
|
||||
const own2 = await dbA.role.findUnique({
|
||||
where: { tenantId_key: { tenantId: tenantA.id, key: roleA.key } },
|
||||
select: { name: true },
|
||||
});
|
||||
ok(own2?.title === riskA.title, "Eigenzugriff über Compound-Key + select → Treffer");
|
||||
ok(own2 !== null && !("tenantId" in (own2 as object)), "Compound-Key select {title} → Rückgabe OHNE tenantId (Injektion bereinigt)");
|
||||
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.risk.findUnique({ where: { id: riskA.id } });
|
||||
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.risk.findUniqueOrThrow({ where: { id: riskA.id }, select: { title: true } });
|
||||
ok(own4.title === riskA.title, "Eigenzugriff findUniqueOrThrow → Treffer");
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user