L10a Qualität & Abnahmetests: Sicherheitstests (Rollen-Matrix, schädliche Uploads, Login-Sperre/Rate-Limit/Sessions) und Audit-Log append-only für craftvia_app
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Record<Permission, string>> = {
|
||||
"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/<id> (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<RoleKey, Persona> = { "tenant-admin": "admin", backoffice: "backoffice", "team-lead": "lead", technician: "tech" };
|
||||
|
||||
type Probe = { permission: Permission; label: string; run: (ctx: ServiceCtx, role: RoleKey) => Promise<unknown>; denied?: string };
|
||||
|
||||
async function rawOrder(A: TenantFixture, status: string, extra: Record<string, unknown> = {}) {
|
||||
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");
|
||||
});
|
||||
Reference in New Issue
Block a user