// 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 { addManualTimeEntry, approveTimeEntry } from "../src/server/services/field/time-entries"; 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" }) }, // L12 Zeiterfassung { permission: "field:record_own_time", label: "Eigene Zeit nachtragen", run: (c) => addManualTimeEntry(c, { workOrderId: shared.id, type: "work", startedAt: new Date(Date.now() - 26 * 3600_000), durationMinutes: 30, reason: "Rollenprüfung" }), }, { permission: "time:approve", label: "Zeitnachtrag freigeben", run: async (c) => { const s = await prisma.workSession.create({ data: { tenantId: A.tenantId, workOrderId: shared.id, userId: A.users.tech2.id, status: "ended", manual: true, startedAt: new Date(Date.now() - 7200_000), endedAt: new Date(Date.now() - 3600_000) } }); const e = await prisma.timeEntry.create({ data: { tenantId: A.tenantId, workSessionId: s.id, userId: A.users.tech2.id, startedAt: s.startedAt, endedAt: s.endedAt, source: "manual", approvalStatus: "pending", correctionReason: "Rollenprüfung" } }); return approveTimeEntry(c, e.id); }, }, { 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"); });