From c297cfbe832e56e9dc343bafdaac64264611210e Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 18:25:55 +0200 Subject: [PATCH] =?UTF-8?q?L10a=20Qualit=C3=A4t=20&=20Abnahmetests:=20Sich?= =?UTF-8?q?erheitstests=20(Rollen-Matrix,=20sch=C3=A4dliche=20Uploads,=20L?= =?UTF-8?q?ogin-Sperre/Rate-Limit/Sessions)=20und=20Audit-Log=20append-onl?= =?UTF-8?q?y=20f=C3=BCr=20craftvia=5Fapp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- .../migration.sql | 7 + scripts/test-e2e-tenant-isolation.ts | 40 ++++- scripts/test-security-auth.ts | 164 ++++++++++++++++++ scripts/test-security-roles.ts | 138 +++++++++++++++ scripts/test-security-uploads.ts | Bin 0 -> 11223 bytes 5 files changed, 340 insertions(+), 9 deletions(-) create mode 100644 prisma/migrations/20260914200000_qualitaet_audit_append_only/migration.sql create mode 100644 scripts/test-security-auth.ts create mode 100644 scripts/test-security-roles.ts create mode 100644 scripts/test-security-uploads.ts 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 0000000000000000000000000000000000000000..3b70db6188607a91887997e6e06b5e2e692ca815 GIT binary patch literal 11223 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 YMRQxENat7&*=xmW@|&F&o5|(>A13Fy^#A|> literal 0 HcmV?d00001