diff --git a/prisma/migrations/20260914200000_qualitaet_audit_append_only/migration.sql b/prisma/migrations/20260914200000_qualitaet_audit_append_only/migration.sql new file mode 100644 index 0000000..35add57 --- /dev/null +++ b/prisma/migrations/20260914200000_qualitaet_audit_append_only/migration.sql @@ -0,0 +1,7 @@ +-- L10a Qualität & Abnahmetests: Audit-Log append-only für die eingeschränkte App-Rolle. +-- Spec §26 (revisionsfähige Protokollierung) und §43.4 („Audit-Log-Manipulation"): +-- Die App schreibt Audit-Einträge ausschließlich per INSERT (writeAuditLog, Owner-Client) und liest sie +-- im Audit-Viewer. Mit RLS_ENFORCED=true soll auch ein kompromittierter Fachpfad über die Rolle +-- craftvia_app bestehende Einträge weder ändern noch löschen können. Backup/Restore und DSGVO-Löschung +-- laufen über die Owner-Rolle und sind nicht betroffen. +REVOKE UPDATE, DELETE, TRUNCATE ON TABLE audit_logs FROM craftvia_app; diff --git a/scripts/test-e2e-tenant-isolation.ts b/scripts/test-e2e-tenant-isolation.ts index 9d2212b..e1c4472 100644 --- a/scripts/test-e2e-tenant-isolation.ts +++ b/scripts/test-e2e-tenant-isolation.ts @@ -149,6 +149,15 @@ async function threw(fn: () => Promise): Promise { } } +/** Bulk write without effect: count 0, or refused by the database (append-only audit_logs under RLS). */ +async function noEffect(fn: () => Promise): Promise { + try { + return ((await fn()) as { count: number }).count === 0; + } catch (err) { + return /permission denied/i.test((err as Error).message); + } +} + function defaultRlsUrl(): string { const base = new URL(process.env.DATABASE_URL ?? "postgresql://localhost:5432/craftvia?schema=public"); base.username = "craftvia_app"; @@ -178,8 +187,8 @@ runSuite("E2E Mandantentrennung (systematisch)", [SLUG_A, SLUG_B], async () => { findFirst: (await d.findFirst({ where })) === null, findUnique: (await d.findUnique({ where }).catch(() => null)) === null, count: (await d.count({ where })) === 0, - updateMany: ((await d.updateMany({ where, data: row.update })) as { count: number }).count === 0, - deleteMany: ((await d.deleteMany({ where })) as { count: number }).count === 0, + updateMany: await noEffect(() => d.updateMany({ where, data: row.update })), + deleteMany: await noEffect(() => d.deleteMany({ where })), update: await threw(() => d.update({ where, data: row.update })), delete: await threw(() => d.delete({ where })), }; @@ -202,13 +211,26 @@ runSuite("E2E Mandantentrennung (systematisch)", [SLUG_A, SLUG_B], async () => { const row = rows[model]; if (!row) continue; const table = topo.nodes.get(model)!.table; - const res = await app.$transaction(async (tx) => { - await tx.$executeRaw`SELECT set_config('app.tenant_id', ${B.tenantId}, true)`; - const seen = await tx.$queryRawUnsafe<{ n: number }[]>(`SELECT count(*)::int AS n FROM "${table}" WHERE id = $1`, row.id); - const updated = await tx.$executeRawUnsafe(`UPDATE "${table}" SET id = id WHERE id = $1`, row.id); - const deleted = await tx.$executeRawUnsafe(`DELETE FROM "${table}" WHERE id = $1`, row.id); - return { seen: seen[0].n, updated, deleted }; - }); + /** Affected rows under tenant context B; a missing privilege (append-only audit_logs) counts as 0. */ + const asB = async (sql: string): Promise => { + try { + return await app.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.tenant_id', ${B.tenantId}, true)`; + return tx.$executeRawUnsafe(sql, row.id); + }); + } catch (err) { + if (/permission denied/i.test((err as Error).message)) return 0; + throw err; + } + }; + const res = { + seen: await app.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.tenant_id', ${B.tenantId}, true)`; + return (await tx.$queryRawUnsafe<{ n: number }[]>(`SELECT count(*)::int AS n FROM "${table}" WHERE id = $1`, row.id))[0].n; + }), + updated: await asB(`UPDATE "${table}" SET id = id WHERE id = $1`), + deleted: await asB(`DELETE FROM "${table}" WHERE id = $1`), + }; const own = await app.$transaction(async (tx) => { await tx.$executeRaw`SELECT set_config('app.tenant_id', ${A.tenantId}, true)`; return (await tx.$queryRawUnsafe<{ n: number }[]>(`SELECT count(*)::int AS n FROM "${table}" WHERE id = $1`, row.id))[0].n; diff --git a/scripts/test-security-auth.ts b/scripts/test-security-auth.ts new file mode 100644 index 0000000..5929453 --- /dev/null +++ b/scripts/test-security-auth.ts @@ -0,0 +1,164 @@ +// L10a Sicherheitstest §43.4 „Rate Limiting", „Session-Handling", „Audit-Log-Manipulation" (Service-/DB-Ebene). +// +// Login: 5 Fehlversuche → Identität gesperrt (auch das richtige Passwort wird abgewiesen), Fehlversuche +// und Sperre auditiert, unbekannte E-Mail ohne Orakel, Ausnahme „letzter aktiver Mandanten-Admin" +// (keine DoS-Sperre, aber protokolliert). Rate-Limit der Wiederherstellungs-Abläufe je IP und Konto. +// Session-Kill-Switch (isTokenStillValid, fail-closed ohne iat). Audit-Log: kein Fachpfad (Services, +// Actions, Route Handler) ändert oder löscht Audit-Einträge (statische Prüfung), die App-Rolle +// craftvia_app hat auf audit_logs weder UPDATE noch DELETE (Migration *_qualitaet_audit_append_only), +// der Mandanten-Guard verhindert fremde Zugriffe. Passwort-Reset/MFA-Härtung: test-reset-flow.ts, +// test-mfa-hardening.ts; Session-Cookies über HTTP: test-security-http.ts. +// +// Lauf: npx tsx scripts/test-security-auth.ts + +import "dotenv/config"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { PrismaClient } from "@prisma/client"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { prisma, dbForTenant } from "../src/server/db"; +import { hashPassword } from "../src/server/password"; +import { authorizeTenantCredentials } from "../src/server/auth"; +import { checkRateLimit, resetRateLimits } from "../src/server/rate-limit"; +import { isTokenStillValid } from "../src/server/sessions"; +import { writeAuditLog } from "../src/server/audit"; +import { createTenant, ok, runSuite, section } from "./lib/e2e-fixture"; + +const SLUG = "zz-q-sec-auth"; +const SLUG_B = "zz-q-sec-auth-b"; +const PASSWORD = "Pruef-Passwort-2026!"; + +function walk(dir: string): string[] { + return readdirSync(dir).flatMap((name) => { + const p = join(dir, name); + return statSync(p).isDirectory() ? walk(p) : /\.(ts|tsx)$/.test(name) ? [p] : []; + }); +} + +function defaultRlsUrl(): string { + const base = new URL(process.env.DATABASE_URL ?? "postgresql://localhost:5432/craftvia?schema=public"); + base.username = "craftvia_app"; + base.password = "craftvia_app_local"; + return base.toString(); +} + +runSuite("Sicherheit: Login-Sperre, Rate-Limit, Sessions, Audit-Log", [SLUG, SLUG_B], async () => { + const A = await createTenant(SLUG); + const B = await createTenant(SLUG_B); + const hash = await hashPassword(PASSWORD); + await prisma.identity.updateMany({ where: { id: { in: [A.users.tech.identityId, A.users.admin.identityId] } }, data: { passwordHash: hash, failedLogins: 0, lockedUntil: null } }); + const techEmail = A.users.tech.email; + + section("Login: Sperre nach Fehlversuchen"); + ok((await authorizeTenantCredentials({ email: techEmail, password: PASSWORD, tenant: SLUG })) !== null, "richtiges Passwort → Anmeldung möglich"); + for (let i = 1; i <= 5; i++) { + const res = await authorizeTenantCredentials({ email: techEmail, password: `falsch-${i}`, tenant: SLUG }); + if (res !== null) ok(false, `Fehlversuch ${i} wurde angenommen`); + } + const locked = await prisma.identity.findUniqueOrThrow({ where: { id: A.users.tech.identityId } }); + ok(!!locked.lockedUntil && locked.lockedUntil.getTime() > Date.now() + 10 * 60_000, `nach 5 Fehlversuchen gesperrt bis ${locked.lockedUntil?.toISOString()}`); + ok((await authorizeTenantCredentials({ email: techEmail, password: PASSWORD, tenant: SLUG })) === null, "gesperrt: auch das richtige Passwort wird abgewiesen"); + const denied = await prisma.auditLog.findMany({ where: { tenantId: A.tenantId, entity: "login", action: "denied", entityId: A.users.tech.identityId } }); + ok(denied.length >= 6 && denied.some((d) => (d.after as { locked?: boolean } | null)?.locked === true) && denied.some((d) => (d.after as { reason?: string } | null)?.reason === "locked"), `Fehlversuche, Sperre und Versuch während der Sperre auditiert (${denied.length})`); + await prisma.identity.update({ where: { id: locked.id }, data: { lockedUntil: new Date(Date.now() - 1000) } }); + ok((await authorizeTenantCredentials({ email: techEmail, password: PASSWORD, tenant: SLUG })) !== null, "nach Ablauf der Sperre wieder anmeldbar, Zähler zurückgesetzt"); + ok((await prisma.identity.findUniqueOrThrow({ where: { id: locked.id } })).failedLogins === 0, "Fehlversuchszähler nach erfolgreicher Anmeldung 0"); + ok((await authorizeTenantCredentials({ email: `unbekannt${Date.now()}@zz-qualitaet.test`, password: PASSWORD, tenant: SLUG })) === null, "unbekannte E-Mail → gleiche Antwort (null), kein Konto-Orakel"); + ok((await authorizeTenantCredentials({ email: techEmail, password: PASSWORD, tenant: SLUG_B })) === null, "Anmeldung in einem Mandanten ohne Mitgliedschaft abgewiesen"); + + section("Login: letzter aktiver Mandanten-Admin wird nicht ausgesperrt (DoS-Schutz)"); + for (let i = 0; i < 6; i++) await authorizeTenantCredentials({ email: A.users.admin.email, password: "falsch", tenant: SLUG }); + const admin = await prisma.identity.findUniqueOrThrow({ where: { id: A.users.admin.identityId } }); + ok(!admin.lockedUntil || admin.lockedUntil.getTime() < Date.now(), "letzter Admin nicht gesperrt"); + ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "login", action: "denied", entityId: admin.id } })) >= 6, "Fehlversuche des Admins trotzdem auditiert"); + console.log(" Hinweis: Das Login zählt Fehlversuche je Identität (Sperre); ein IP-basiertes Limit gegen Password-Spraying über viele Konten gibt es nur für Reset/Einladung (siehe Bericht)."); + + section("Rate-Limit Wiederherstellung (je IP und je Konto)"); + resetRateLimits(); + const results = Array.from({ length: 6 }, () => checkRateLimit("passwordResetRequest", { ip: "203.0.113.7", account: "opfer@zz-qualitaet.test" }).allowed); + ok(results.slice(0, 5).every(Boolean) && results[5] === false, "Reset-Anfrage: 5 erlaubt, 6. blockiert"); + const blocked = checkRateLimit("passwordResetRequest", { ip: "203.0.113.7", account: "anderes@zz-qualitaet.test" }); + ok(!blocked.allowed && blocked.retryAfterSeconds > 0, "gleiche IP, anderes Konto → weiter blockiert (mit Wartezeit)"); + ok(!checkRateLimit("passwordResetRequest", { ip: "198.51.100.1", account: "opfer@zz-qualitaet.test" }).allowed, "anderes Konto-Ziel über neue IP → Konto-Zähler blockiert"); + ok(checkRateLimit("passwordResetRequest", { ip: "198.51.100.2", account: "frei@zz-qualitaet.test" }).allowed, "unbeteiligte IP + Konto → erlaubt"); + resetRateLimits(); + const redeem = Array.from({ length: 11 }, () => checkRateLimit("passwordResetRedeem", { ip: "203.0.113.9" }).allowed); + ok(redeem.filter(Boolean).length === 10 && !redeem[10], "Reset-Einlösung: 10 Versuche je IP, dann blockiert"); + resetRateLimits(); + + section("Session-Kill-Switch"); + const now = Math.floor(Date.now() / 1000); + ok(isTokenStillValid(now, null), "ohne Invalidierungsmarke gültig"); + ok(!isTokenStillValid(now - 60, new Date(now * 1000)), "Token vor der Marke ausgestellt → ungültig"); + ok(isTokenStillValid(now + 1, new Date(now * 1000)), "Token nach der Marke → gültig"); + ok(!isTokenStillValid(undefined, new Date()), "Token ohne iat bei gesetzter Marke → fail-closed"); + + section("Audit-Log: kein Fachpfad ändert oder löscht Einträge"); + const root = process.cwd(); + const scanned = ["src/app", "src/server/actions", "src/server/services", "src/server/api", "src/server/jobs", "src/lib"].flatMap((d) => walk(join(root, d))); + const offenders = scanned.filter((file) => { + const text = readFileSync(file, "utf8"); + return /auditLog\s*\.\s*(update|updateMany|delete|deleteMany|upsert)\b/.test(text) || /(UPDATE|DELETE\s+FROM|TRUNCATE)\s+"?audit_logs/i.test(text); + }); + ok(scanned.length > 100 && offenders.length === 0, `${scanned.length} Dateien geprüft, keine Mutation von audit_logs${offenders.length ? ` — ${offenders.map((f) => relative(root, f)).join(", ")}` : ""}`); + const apiRoutes = walk(join(root, "src/app/api")); + ok(!apiRoutes.some((f) => /audit/i.test(relative(root, f))), "keine /api-Route für Audit-Einträge"); + const viewer = readFileSync(join(root, "src/server/services/audit/viewer.ts"), "utf8"); + ok(!/\.(create|update|delete|upsert)\w*\(/.test(viewer.replace(/writeAuditLog/g, "")), "Audit-Viewer-Service ist rein lesend"); + + const privileges = await prisma.$queryRawUnsafe<{ sel: boolean; ins: boolean; upd: boolean; del: boolean }[]>( + `SELECT has_table_privilege('craftvia_app', 'audit_logs', 'SELECT') AS sel, has_table_privilege('craftvia_app', 'audit_logs', 'INSERT') AS ins, + has_table_privilege('craftvia_app', 'audit_logs', 'UPDATE') AS upd, has_table_privilege('craftvia_app', 'audit_logs', 'DELETE') AS del`, + ); + ok(privileges[0].sel && privileges[0].ins, "App-Rolle darf Audit-Einträge lesen und anlegen"); + ok(!privileges[0].upd && !privileges[0].del, "App-Rolle darf Audit-Einträge weder ändern noch löschen (append-only)"); + + await writeAuditLog({ tenantId: A.tenantId, actorId: A.users.backoffice.id, action: "update", entity: "zz_audit_probe", entityId: "p1", before: { v: 1 }, after: { v: 2 } }); + const probe = await prisma.auditLog.findFirstOrThrow({ where: { tenantId: A.tenantId, entity: "zz_audit_probe" } }); + ok(probe.actorId === A.users.backoffice.id && (probe.before as { v: number }).v === 1, "writeAuditLog protokolliert Akteur und before/after"); + let foreignBlocked = false; + try { + await dbForTenant(B.tenantId).auditLog.update({ where: { id: probe.id }, data: { entity: "manipuliert" } }); + } catch { + foreignBlocked = true; + } + ok(foreignBlocked && (await prisma.auditLog.count({ where: { tenantId: B.tenantId, entity: "manipuliert" } })) === 0, "fremder Mandant kann den Eintrag nicht ändern (Tenant-Guard)"); + let deletedByB = -1; + try { + deletedByB = (await dbForTenant(B.tenantId).auditLog.deleteMany({ where: { id: probe.id } })).count; + } catch (err) { + // RLS_ENFORCED=true: the app role has no DELETE privilege on audit_logs at all + if (/permission denied/i.test((err as Error).message)) deletedByB = 0; + else throw err; + } + ok(deletedByB === 0, "fremder Mandant kann den Eintrag nicht löschen"); + + const app = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.RLS_DATABASE_URL ?? defaultRlsUrl() }) }); + try { + await app.$queryRawUnsafe("SELECT 1"); + const attempt = async (sql: string) => { + try { + await app.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.tenant_id', ${A.tenantId}, true)`; + await tx.$executeRawUnsafe(sql, probe.id); + }); + return "executed"; + } catch (err) { + return /permission denied/i.test((err as Error).message) ? "denied" : `error:${(err as Error).message.split("\n")[0]}`; + } + }; + ok((await attempt(`UPDATE audit_logs SET entity = 'manipuliert' WHERE id = $1`)) === "denied", "craftvia_app im eigenen Mandanten: UPDATE audit_logs → permission denied"); + ok((await attempt(`DELETE FROM audit_logs WHERE id = $1`)) === "denied", "craftvia_app im eigenen Mandanten: DELETE audit_logs → permission denied"); + const readable = await app.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.tenant_id', ${A.tenantId}, true)`; + return tx.auditLog.count({ where: { id: probe.id } }); + }); + ok(readable === 1, "craftvia_app liest den eigenen Eintrag weiterhin"); + } catch (err) { + if (process.env.RLS_TEST_REQUIRED === "true") throw err; + console.log(`⚠ DB-Rollenprüfung übersprungen: craftvia_app nicht verbindbar (${(err as Error).message.split("\n")[0]})`); + } finally { + await app.$disconnect(); + } + ok((await prisma.auditLog.findUniqueOrThrow({ where: { id: probe.id } })).entity === "zz_audit_probe", "Audit-Eintrag nach allen Versuchen unverändert"); +}); diff --git a/scripts/test-security-roles.ts b/scripts/test-security-roles.ts new file mode 100644 index 0000000..6c8e0f5 --- /dev/null +++ b/scripts/test-security-roles.ts @@ -0,0 +1,138 @@ +// L10a Sicherheitstest §43.4 „Unzulässige Rollenaktionen" — Matrix aus src/server/rbac.ts. +// +// Für jede Permission des Katalogs mit einem fachlichen Schreib-/Lesepfad wird ein repräsentativer +// Service-Aufruf mit allen vier Standardrollen ausgeführt. Erwartung wird UNABHÄNGIG aus ROLE_DEFS +// abgeleitet: fehlt die Permission → `forbidden` (bzw. bei Sichtbarkeitsrechten `not_found`), ist sie +// vorhanden → niemals `forbidden`. Neue Permissions ohne Matrix-Eintrag lassen den Test scheitern +// (bewusste Ausnahmen sind unten begründet). +// +// Lauf: npx tsx scripts/test-security-roles.ts + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { prisma } from "../src/server/db"; +import { PERMISSIONS, ROLE_DEFS, ROLE_KEYS, type Permission, type RoleKey } from "../src/server/rbac"; +import type { ServiceCtx } from "../src/server/services/context"; +import { createCustomer, listCustomers } from "../src/server/services/customers/customers"; +import { mergeCustomers } from "../src/server/services/customers/merge"; +import { createSite, listSites } from "../src/server/services/sites/sites"; +import { createTeam, listTeams } from "../src/server/services/teams/teams"; +import { getWorkOrderDetail } from "../src/server/services/work-orders/detail"; +import { updateWorkOrder } from "../src/server/services/work-orders/update"; +import { assignWorkOrder } from "../src/server/services/work-orders/assign"; +import { releaseForBilling } from "../src/server/services/work-orders/release-billing"; +import { cancelWorkOrder } from "../src/server/services/work-orders/cancel"; +import { createOrderType } from "../src/server/services/work-orders/settings"; +import { listImports } from "../src/server/services/imports/queries"; +import { createNote } from "../src/server/services/field/notes"; +import { correctTimeEntry } from "../src/server/services/field/time-correction"; +import { createDailyReport } from "../src/server/services/reports/create"; +import { approveReport } from "../src/server/services/reports/approve"; +import { listReports } from "../src/server/services/reports/queries"; +import { searchCustomersForEmergency } from "../src/server/services/emergency/lookup"; +import { listEmergencyReviews } from "../src/server/services/emergency/review"; +import { authorizeDocumentAccess, updateDocumentMeta } from "../src/server/services/documents/access"; +import { listNotifications } from "../src/server/services/notifications/inbox"; +import { queryAuditLog } from "../src/server/services/audit/viewer"; +import { getAiGenerationContent } from "../src/server/services/lotse/protocol"; +import { checkCompleteness } from "../src/server/services/lotse/completeness"; +import { noteCreatePayload } from "../src/lib/sync/ops"; +import { codeOf, createTenant, ok, runSuite, section, type Persona, type TenantFixture } from "./lib/e2e-fixture"; + +const SLUG = "zz-q-sec-roles"; + +/** Permissions whose enforcement lives outside the domain services (covered by other test scripts). */ +const COVERED_ELSEWHERE: Partial> = { + "user:read": "Nutzerverwaltung (Fundament) — scripts/test-tenant-users-authz.ts", + "user:manage": "Nutzerverwaltung (Fundament) — scripts/test-tenant-users-authz.ts", + "role:manage": "Rollenverwaltung (Fundament) — scripts/test-tenant-users-authz.ts", + "document:read": "Download-Route /files/ (requireApiContext) — scripts/test-security-http.ts", + "report:read": "Lesepfad über reportScope — unten als Sichtbarkeitsprüfung", + "work_order:read_team": "Scope-Recht — unten als Sichtbarkeitsprüfung (read_all vs. read_team)", +}; + +const PERSONA_OF: Record = { "tenant-admin": "admin", backoffice: "backoffice", "team-lead": "lead", technician: "tech" }; + +type Probe = { permission: Permission; label: string; run: (ctx: ServiceCtx, role: RoleKey) => Promise; denied?: string }; + +async function rawOrder(A: TenantFixture, status: string, extra: Record = {}) { + return prisma.workOrder.create({ + data: { tenantId: A.tenantId, number: `R-${randomUUID().slice(0, 8)}`, customerId: A.customerId, siteId: A.siteId, title: "Rollenprüfung", status: status as never, assignedTeamId: A.teamId, teamLeadUserId: A.users.lead.id, ...extra }, + }); +} + +runSuite("Sicherheit: Rollen-Matrix", [SLUG], async () => { + const A = await createTenant(SLUG); + const shared = await rawOrder(A, "in_progress", { assignees: { create: [{ tenantId: A.tenantId, userId: A.users.tech.id }] } }); + const session = await prisma.workSession.create({ data: { tenantId: A.tenantId, workOrderId: shared.id, userId: A.users.tech.id, status: "ended", startedAt: new Date(Date.now() - 7200_000), endedAt: new Date(Date.now() - 3600_000) } }); + const entry = await prisma.timeEntry.create({ data: { tenantId: A.tenantId, workSessionId: session.id, userId: A.users.tech.id, startedAt: session.startedAt, endedAt: session.endedAt } }); + const draftReport = await prisma.report.create({ data: { tenantId: A.tenantId, workOrderId: shared.id, type: "daily", reportDate: new Date(), lineageId: randomUUID(), status: "draft", content: {} } }); + const teamDoc = await prisma.document.create({ data: { tenantId: A.tenantId, workOrderId: shared.id, category: "photo", fileName: "a.jpg", storageKey: `${A.tenantId}/a.jpg`, mimeType: "image/jpeg", fileSize: 1, checksum: "0".repeat(64), lineageId: randomUUID(), visibility: "team" } }); + const internalDoc = await prisma.document.create({ data: { tenantId: A.tenantId, workOrderId: shared.id, category: "order_confirmation", fileName: "intern.pdf", storageKey: `${A.tenantId}/intern.pdf`, mimeType: "application/pdf", fileSize: 1, checksum: "0".repeat(64), lineageId: randomUUID(), visibility: "backoffice_only" } }); + const leadDoc = await prisma.document.create({ data: { tenantId: A.tenantId, workOrderId: shared.id, category: "other", fileName: "tl.pdf", storageKey: `${A.tenantId}/tl.pdf`, mimeType: "application/pdf", fileSize: 1, checksum: "0".repeat(64), lineageId: randomUUID(), visibility: "team_lead" } }); + const unassigned = await prisma.workOrder.create({ data: { tenantId: A.tenantId, number: "R-FREE", customerId: A.customerId, title: "Ohne Team", status: "planned" } }); + + const probes: Probe[] = [ + { permission: "tenant:manage", label: "KI-Protokoll mit Inhalten", run: (c) => getAiGenerationContent(c, "unknown-id") }, + { permission: "audit:read", label: "Audit-Protokoll lesen", run: (c) => queryAuditLog(c, {}) }, + { permission: "customer:read", label: "Kundenliste", run: (c) => listCustomers(c) }, + { permission: "customer:write", label: "Kunde anlegen", run: (c, r) => createCustomer(c, { companyName: `Rollen ${r} ${randomUUID()}` }, { acknowledgeDuplicates: true }) }, + { + permission: "customer:merge", + label: "Kunden zusammenführen", + run: async (c) => { + const s = await prisma.customer.create({ data: { tenantId: A.tenantId, companyName: `Quelle ${randomUUID()}` } }); + const t = await prisma.customer.create({ data: { tenantId: A.tenantId, companyName: `Ziel ${randomUUID()}` } }); + return mergeCustomers(c, { sourceId: s.id, targetId: t.id, confirm: true }); + }, + }, + { permission: "site:read", label: "Objektliste", run: (c) => listSites(c) }, + { permission: "site:write", label: "Objekt anlegen", run: (c) => createSite(c, { customerId: A.customerId, name: `Objekt ${randomUUID()}` }) }, + { permission: "team:read", label: "Teamliste", run: (c) => listTeams(c) }, + { permission: "team:manage", label: "Team anlegen", run: (c) => createTeam(c, { name: `Team ${randomUUID()}` }) }, + { permission: "work_order:read_all", label: "Auftrag ohne Team/Zuweisung lesen", run: (c) => getWorkOrderDetail(c, unassigned.id), denied: "not_found" }, + { permission: "work_order:write", label: "Auftrag bearbeiten", run: (c) => updateWorkOrder(c, shared.id, { technicianNotes: `Rolle ${randomUUID()}` }) }, + { permission: "work_order:assign", label: "Auftrag zuweisen", run: async (c) => assignWorkOrder(c, { workOrderId: (await rawOrder(A, "planned")).id, teamId: A.teamId }) }, + { permission: "work_order:release_billing", label: "Zur Abrechnung freigeben", run: async (c) => releaseForBilling(c, { workOrderId: (await rawOrder(A, "in_review")).id }) }, + { permission: "work_order:cancel", label: "Auftrag stornieren", run: async (c) => cancelWorkOrder(c, { workOrderId: (await rawOrder(A, "assigned")).id, reason: "Rollenprüfung" }) }, + { permission: "import:write", label: "Importe", run: (c) => listImports(c) }, + { permission: "field:execute", label: "Tätigkeitsnotiz erfassen", run: (c) => createNote(c, noteCreatePayload.parse({ workOrderId: shared.id, text: "Rollenprüfung" })) }, + { permission: "field:correct_time", label: "Arbeitszeit korrigieren", run: (c) => correctTimeEntry(c, { timeEntryId: entry.id, startedAt: session.startedAt, endedAt: session.endedAt, reason: "Rollenprüfung" }) }, + { permission: "report:write", label: "Tagesbericht anlegen", run: async (c) => createDailyReport(c, { workOrderId: (await rawOrder(A, "planned")).id }) }, + { permission: "report:approve_team", label: "Bericht freigeben (Teamleiter oder Backoffice)", run: (c) => approveReport(c, { reportId: draftReport.id }, { dispatchPdf: async () => undefined }) }, + { permission: "emergency:create", label: "Notdienst-Kundensuche", run: (c) => searchCustomersForEmergency(c, "Hausverwaltung") }, + { permission: "emergency:review", label: "Notdienst-Prüfliste", run: (c) => listEmergencyReviews(c) }, + { permission: "document:write", label: "Dokument-Metadaten ändern", run: (c) => updateDocumentMeta(c, teamDoc.id, { title: `Titel ${randomUUID()}` }) }, + { permission: "document:read_internal", label: "internes Dokument (backoffice_only) öffnen", run: (c) => authorizeDocumentAccess(c, internalDoc.id), denied: "not_found" }, + { permission: "notification:read", label: "Benachrichtigungen", run: (c) => listNotifications(c, {}) }, + { permission: "settings:templates", label: "Auftragsart anlegen", run: (c) => createOrderType(c, { key: `rolle_${randomUUID().slice(0, 8).replace(/-/g, "")}`, name: "Rollenprüfung" }) }, + { permission: "lotse:use", label: "Lotse-Vollständigkeitsprüfung", run: (c) => checkCompleteness(c, shared.id) }, + ]; + + section("Matrix-Abdeckung"); + const probed = new Set(probes.map((p) => p.permission)); + probed.add("report:approve"); // shares the approval probe (report:approve OR report:approve_team) + const uncovered = PERMISSIONS.filter((p) => !probed.has(p) && !COVERED_ELSEWHERE[p]); + ok(uncovered.length === 0, `jede Permission hat eine Prüfung oder begründete Ausnahme${uncovered.length ? ` — fehlt: ${uncovered.join(", ")}` : ""}`); + + for (const probe of probes) { + section(`${probe.permission} — ${probe.label}`); + for (const role of ROLE_KEYS) { + const perms = ROLE_DEFS[role].permissions as readonly string[]; + const allowed = probe.permission === "report:approve_team" ? perms.includes("report:approve_team") || perms.includes("report:approve") : perms.includes(probe.permission); + const got = await codeOf(probe.run(A.ctx[PERSONA_OF[role]], role)); + const denied = probe.denied ?? "forbidden"; + ok(allowed ? got !== "forbidden" && got !== denied : got === denied, `${role.padEnd(12)} ${allowed ? "erlaubt " : "verboten"} → ${got}`); + } + } + + section("Sichtbarkeit: read_team / report:read / Teamleiter-Dokumente"); + ok((await codeOf(getWorkOrderDetail(A.ctx.tech, shared.id))) === "ok", "Monteur mit Zuweisung sieht den Auftrag"); + ok((await codeOf(getWorkOrderDetail(A.ctx.outsider, shared.id))) === "not_found", "Monteur ohne Zuweisung (anderes Team) → not_found"); + ok((await codeOf(getWorkOrderDetail(A.ctx.lead2, shared.id))) === "not_found", "Teamleiter eines anderen Teams → not_found"); + ok((await listReports(A.ctx.outsider, { status: "all" })).items.length === 0, "Berichtsliste des fremden Monteurs leer"); + ok((await listReports(A.ctx.tech, { status: "all" })).items.some((r) => r.id === draftReport.id), "Berichtsliste des zugewiesenen Monteurs enthält den Bericht"); + ok((await codeOf(authorizeDocumentAccess(A.ctx.lead, leadDoc.id))) === "ok", "Teamleiter öffnet Teamleiter-Dokument"); + ok((await codeOf(authorizeDocumentAccess(A.ctx.tech, leadDoc.id))) === "not_found", "Monteur öffnet Teamleiter-Dokument nicht"); + ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: shared.id } })).status === "in_progress", "Auftrag durch verbotene Aktionen nicht verändert"); +}); diff --git a/scripts/test-security-uploads.ts b/scripts/test-security-uploads.ts new file mode 100644 index 0000000..3b70db6 Binary files /dev/null and b/scripts/test-security-uploads.ts differ