From 8348764c9998fc5ecd9d2e1fd2b29beaa7129ec1 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 20 Jul 2026 20:27:13 +0200 Subject: [PATCH] =?UTF-8?q?API-seitige=20Modul-Durchsetzung=20vervollst?= =?UTF-8?q?=C3=A4ndigt=20(=C2=A73.4,=20Phase-1-H=C3=A4rtung=20Paket=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der requireModule-Guard war bislang nur exemplarisch in den Policy-Actions gesetzt. Jetzt läuft jede mutierende Server-Action eines gegateten Moduls über einen zentralen, modul- und rechteprüfenden Guard — ein für den Mandanten deaktiviertes Modul weist damit auch Writes serverseitig ab (nicht nur Reads über die Route-Layouts). - action-guard.ts: moduleGuard("") erzeugt je Modul einen guard(...perms), der Session → Modul-Aktivierung → RBAC prüft und {session, db} zurückgibt. - modules.ts: assertModuleEnabled(session, key) wirft (statt Redirect) bei Deaktivierung und protokolliert die Verweigerung (Audit-Aktion "denied"). - audit.ts: Audit-Aktion "denied" + optionaler scope-Parameter. - Alle gegateten Action-Dateien umgestellt: assets→assets, processes→bia, risks→risk, measures→measures, suppliers/services→suppliers, policies→policies. Freigabe/Ablehnung der Richtlinien laufen jetzt ebenfalls über den Modul-Guard. - Vollständigkeitscheck scripts/check-module-guards.ts (Registry Action→Modul): schlägt fehl bei nicht zugeordneter Datei, fehlendem moduleGuard oder einer Action ohne guard(...). Als prebuild verdrahtet ⇒ Build failt bei vergessenem Endpoint. admin/tenant-settings sind als EXEMPT (eigene Auth) markiert. Verifiziert: tsc sauber, lint sauber, Guard-Check grün (9 Dateien), Negativ-Probe (ungemappte Action-Datei) failt den Check wie erwartet. Co-Authored-By: Claude Opus 4.8 --- package.json | 2 + scripts/check-module-guards.ts | 89 +++++++++++++++++++++++++++++++++ src/server/action-guard.ts | 25 +++++++++ src/server/actions/assets.ts | 26 +++------- src/server/actions/measures.ts | 36 ++++--------- src/server/actions/policies.ts | 23 +++------ src/server/actions/processes.ts | 34 ++++--------- src/server/actions/risks.ts | 27 ++++------ src/server/actions/services.ts | 31 ++++-------- src/server/actions/suppliers.ts | 62 +++++++++-------------- src/server/audit.ts | 4 +- src/server/modules.ts | 35 +++++++++++++ 12 files changed, 233 insertions(+), 161 deletions(-) create mode 100644 scripts/check-module-guards.ts create mode 100644 src/server/action-guard.ts diff --git a/package.json b/package.json index d7ef884..da022ca 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,8 @@ "private": true, "scripts": { "dev": "next dev", + "check:guards": "tsx scripts/check-module-guards.ts", + "prebuild": "tsx scripts/check-module-guards.ts", "build": "next build", "start": "next start", "lint": "eslint" diff --git a/scripts/check-module-guards.ts b/scripts/check-module-guards.ts new file mode 100644 index 0000000..969cb31 --- /dev/null +++ b/scripts/check-module-guards.ts @@ -0,0 +1,89 @@ +/** + * Vollständigkeitscheck der serverseitigen Modul-Durchsetzung (§3.4, Phase-1-Härtung). + * + * Jede mutierende Server-Action eines gegateten Moduls MUSS über einen + * `moduleGuard("")`-Guard laufen (siehe src/server/action-guard.ts), damit ein + * für den Mandanten deaktiviertes Modul auch Writes serverseitig abweist. + * + * Dieses Script erzwingt das statisch: Es kennt die Zuordnung Action-Datei → Modul + * und schlägt fehl (Exit 1 → Build/Test rot), sobald + * - eine neue Action-Datei nicht zugeordnet ist ("vergessener Endpoint"), + * - eine gegatete Datei den erwarteten moduleGuard nicht verwendet, oder + * - eine exportierte Action nicht über `await guard(...)` läuft. + * + * Neue Action-Datei anlegen ⇒ hier eintragen (Modul-Key oder "EXEMPT"). + */ +import { readdirSync, readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { MODULE_KEYS } from "../src/lib/modules"; + +const ACTIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "server", "actions"); + +/** Zuordnung Action-Datei → Modul-Key. "EXEMPT" = kein gegatetes Fachmodul (eigene Auth). */ +const ACTION_MODULE: Record = { + "assets.ts": "assets", + "processes.ts": "bia", + "risks.ts": "risk", + "measures.ts": "measures", + "suppliers.ts": "suppliers", + "services.ts": "suppliers", + "policies.ts": "policies", + // Plattform-Admin (isPlatformAdmin) und Kunden-Einstellungen (tenant:manage) sind + // keine per TenantModule gegateten Fachmodule — eigene Autorisierung, kein moduleGuard. + "admin.ts": "EXEMPT", + "tenant-settings.ts": "EXEMPT", +}; + +const errors: string[] = []; +const files = readdirSync(ACTIONS_DIR).filter((f) => f.endsWith(".ts")); + +for (const file of files) { + const mapped = ACTION_MODULE[file]; + if (!mapped) { + errors.push( + `Nicht zugeordnete Action-Datei: ${file} — in scripts/check-module-guards.ts eintragen (Modul-Key oder "EXEMPT").` + ); + continue; + } + + const src = readFileSync(join(ACTIONS_DIR, file), "utf8"); + + if (mapped === "EXEMPT") { + if (!/require(Session|PlatformAdmin|Permission)/.test(src)) { + errors.push(`${file}: als EXEMPT markiert, aber keine erkennbare Auth-Prüfung.`); + } + continue; + } + + if (!MODULE_KEYS.includes(mapped)) { + errors.push(`${file}: unbekannter Modul-Key "${mapped}" (nicht in src/lib/modules.ts).`); + } + if (!src.includes(`moduleGuard("${mapped}")`)) { + errors.push(`${file}: erwartet moduleGuard("${mapped}") — Modul-Gating fehlt oder falscher Key.`); + } + + // Jede exportierte Server-Action muss über await guard(...) laufen. + const exportRe = /export async function (\w+)\s*\(/g; + const positions: { name: string; index: number }[] = []; + let m: RegExpExecArray | null; + while ((m = exportRe.exec(src))) positions.push({ name: m[1], index: m.index }); + for (let i = 0; i < positions.length; i++) { + const start = positions[i].index; + const end = i + 1 < positions.length ? positions[i + 1].index : src.length; + if (!/await guard\(/.test(src.slice(start, end))) { + errors.push( + `${file}: Action "${positions[i].name}" läuft nicht über await guard(...) — Modul-/Rechte-Guard fehlt.` + ); + } + } +} + +if (errors.length) { + console.error("✗ Modul-Guard-Vollständigkeitscheck fehlgeschlagen:"); + for (const e of errors) console.error(" - " + e); + process.exit(1); +} +console.log( + `✓ Modul-Guard-Vollständigkeitscheck: ${files.length} Action-Dateien geprüft — alle mutierenden Actions sind modul- und rechtegegated.` +); diff --git a/src/server/action-guard.ts b/src/server/action-guard.ts new file mode 100644 index 0000000..293d70b --- /dev/null +++ b/src/server/action-guard.ts @@ -0,0 +1,25 @@ +import { requireSession } from "@/server/auth"; +import { dbForTenant } from "@/server/db"; +import { requirePermission, type Permission } from "@/server/rbac"; +import { assertModuleEnabled } from "@/server/modules"; + +/** + * Einheitlicher Einstieg für mutierende Server-Actions eines gegateten Moduls (§3.4). + * Reihenfolge: Session → Modul-Aktivierung (wirft bei Deaktivierung + Audit) → RBAC. + * Gibt Session und den mandantengebundenen Prisma-Client zurück. + * + * `moduleGuard("assets")` erzeugt den Guard eines Moduls; jede Action ruft ihn mit + * den benötigten Rechten auf, z. B. `const { session, db } = await guard("asset:write")`. + * Mehrere Rechte werden alle geprüft (z. B. `guard("risk:write", "measure:write")`). + * + * Der Vollständigkeitscheck (`scripts/check-module-guards.ts`) verlässt sich darauf, + * dass jede Action eines gegateten Moduls über einen so erzeugten `guard(...)` läuft. + */ +export function moduleGuard(moduleKey: string) { + return async function guard(...permissions: Permission[]) { + const session = await requireSession(); + await assertModuleEnabled(session, moduleKey); + for (const permission of permissions) requirePermission(session, permission); + return { session, db: dbForTenant(session.user.tenantId) }; + }; +} diff --git a/src/server/actions/assets.ts b/src/server/actions/assets.ts index b33b962..7fc8291 100644 --- a/src/server/actions/assets.ts +++ b/src/server/actions/assets.ts @@ -3,11 +3,11 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { z } from "zod"; -import { requireSession } from "@/server/auth"; -import { dbForTenant } from "@/server/db"; -import { requirePermission } from "@/server/rbac"; +import { moduleGuard } from "@/server/action-guard"; import { writeAuditLog } from "@/server/audit"; +const guard = moduleGuard("assets"); + const level = z.coerce.number().int().min(1).max(4); const assetSchema = z.object({ @@ -48,9 +48,7 @@ function parseAssetForm(formData: FormData) { } export async function createAsset(formData: FormData) { - const session = await requireSession(); - requirePermission(session, "asset:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("asset:write"); const data = parseAssetForm(formData); // tenantId doppelt gemoppelt: explizit für die Typen, der Guard injiziert ohnehin @@ -71,9 +69,7 @@ export async function createAsset(formData: FormData) { } export async function updateAsset(assetId: string, formData: FormData) { - const session = await requireSession(); - requirePermission(session, "asset:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("asset:write"); const before = await db.asset.findUnique({ where: { id: assetId } }); if (!before) throw new Error("Asset nicht gefunden"); @@ -95,9 +91,7 @@ export async function updateAsset(assetId: string, formData: FormData) { } export async function deleteAsset(assetId: string) { - const session = await requireSession(); - requirePermission(session, "asset:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("asset:write"); const before = await db.asset.findUnique({ where: { id: assetId } }); if (!before) throw new Error("Asset nicht gefunden"); @@ -117,9 +111,7 @@ export async function deleteAsset(assetId: string) { } export async function addAssetRelation(assetId: string, formData: FormData) { - const session = await requireSession(); - requirePermission(session, "asset:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("asset:write"); const relatedAssetId = z.string().min(1).parse(formData.get("relatedAssetId")); if (relatedAssetId === assetId) return; @@ -153,9 +145,7 @@ export async function addAssetRelation(assetId: string, formData: FormData) { } export async function removeAssetRelation(assetId: string, relationId: string) { - const session = await requireSession(); - requirePermission(session, "asset:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("asset:write"); const before = await db.assetRelation.findUnique({ where: { id: relationId } }); if (!before) return; diff --git a/src/server/actions/measures.ts b/src/server/actions/measures.ts index db27783..f6d2ffb 100644 --- a/src/server/actions/measures.ts +++ b/src/server/actions/measures.ts @@ -3,10 +3,11 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { z } from "zod"; -import { requireSession } from "@/server/auth"; -import { dbForTenant, prisma } from "@/server/db"; -import { requirePermission } from "@/server/rbac"; +import { prisma } from "@/server/db"; +import { moduleGuard } from "@/server/action-guard"; import { writeAuditLog } from "@/server/audit"; + +const guard = moduleGuard("measures"); import { recomputeResidualRisk } from "@/server/risk-calc"; const measureSchema = z.object({ @@ -54,9 +55,7 @@ async function nextRefNo(tenantId: string) { } export async function createMeasure(formData: FormData) { - const session = await requireSession(); - requirePermission(session, "measure:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("measure:write"); const data = parseMeasureForm(formData); const measure = await db.measure.create({ @@ -81,9 +80,7 @@ export async function createMeasure(formData: FormData) { } export async function updateMeasure(measureId: string, formData: FormData) { - const session = await requireSession(); - requirePermission(session, "measure:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("measure:write"); const before = await db.measure.findUnique({ where: { id: measureId } }); if (!before) throw new Error("Maßnahme nicht gefunden"); @@ -107,9 +104,7 @@ export async function updateMeasure(measureId: string, formData: FormData) { /** Status-Wechsel per Drag-and-Drop auf dem Kanban-Board. */ export async function updateMeasureStatus(measureId: string, status: string) { - const session = await requireSession(); - requirePermission(session, "measure:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("measure:write"); const parsed = z.enum(["OPEN", "IN_PROGRESS", "DONE"]).parse(status); const before = await db.measure.findUnique({ where: { id: measureId } }); @@ -131,9 +126,7 @@ export async function updateMeasureStatus(measureId: string, status: string) { } export async function deleteMeasure(measureId: string) { - const session = await requireSession(); - requirePermission(session, "measure:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("measure:write"); const before = await db.measure.findUnique({ where: { id: measureId }, @@ -162,9 +155,7 @@ export async function deleteMeasure(measureId: string) { /** Bestehende Maßnahme mit einem Risiko verknüpfen (inkl. Minderung). */ export async function linkMeasureToRisk(riskId: string, formData: FormData) { - const session = await requireSession(); - requirePermission(session, "risk:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("risk:write"); const measureId = z.string().min(1).parse(formData.get("measureId")); const reductionLikelihood = reduction.parse(formData.get("reductionLikelihood") ?? 0); @@ -201,10 +192,7 @@ export async function linkMeasureToRisk(riskId: string, formData: FormData) { /** Neue Maßnahme direkt aus dem Risiko heraus anlegen und verknüpfen (SPEC §4.2). */ export async function createMeasureForRisk(riskId: string, formData: FormData) { - const session = await requireSession(); - requirePermission(session, "risk:write"); - requirePermission(session, "measure:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("risk:write", "measure:write"); const riskCount = await db.risk.count({ where: { id: riskId } }); if (riskCount !== 1) throw new Error("Risiko nicht gefunden"); @@ -245,9 +233,7 @@ export async function createMeasureForRisk(riskId: string, formData: FormData) { } export async function unlinkMeasureFromRisk(riskId: string, riskMeasureId: string) { - const session = await requireSession(); - requirePermission(session, "risk:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("risk:write"); const before = await db.riskMeasure.findUnique({ where: { id: riskMeasureId } }); if (!before) return; diff --git a/src/server/actions/policies.ts b/src/server/actions/policies.ts index 826d01a..f514e0c 100644 --- a/src/server/actions/policies.ts +++ b/src/server/actions/policies.ts @@ -3,20 +3,17 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { z } from "zod"; -import { requireSession } from "@/server/auth"; -import { dbForTenant } from "@/server/db"; -import { requirePermission } from "@/server/rbac"; -import { requireModule } from "@/server/modules"; +import { moduleGuard } from "@/server/action-guard"; +import { type Permission } from "@/server/rbac"; import { writeAuditLog } from "@/server/audit"; const optDate = (v: FormDataEntryValue | null) => (v && String(v) ? new Date(String(v)) : null); const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : ""); -async function guard() { - const session = await requireSession(); - await requireModule("policies"); // API-Ebene: deaktiviertes Modul sperrt auch Actions - requirePermission(session, "policy:write"); - return { session, db: dbForTenant(session.user.tenantId) }; +const guardModule = moduleGuard("policies"); +/** Modul-Gating „policies" + RBAC. Default-Recht policy:write; Freigabe nutzt policy:approve. */ +async function guard(permission: Permission = "policy:write") { + return guardModule(permission); } /* ── Krypto-Register (VA-07) ── */ @@ -244,9 +241,7 @@ export async function submitForApproval(code: string) { } export async function approvePolicy(code: string) { - const session = await requireSession(); - requirePermission(session, "policy:approve"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("policy:approve"); const doc = await db.policyDocument.findFirst({ where: { code } }); if (!doc) throw new Error("Dokument nicht gefunden"); if (doc.status !== "IN_FREIGABE") throw new Error("Dokument ist nicht in Freigabe"); @@ -261,9 +256,7 @@ export async function approvePolicy(code: string) { } export async function rejectPolicy(code: string, formData: FormData) { - const session = await requireSession(); - requirePermission(session, "policy:approve"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("policy:approve"); const doc = await db.policyDocument.findFirst({ where: { code } }); if (!doc) throw new Error("Dokument nicht gefunden"); const reason = str(formData.get("reason")); diff --git a/src/server/actions/processes.ts b/src/server/actions/processes.ts index 168d01c..0c43457 100644 --- a/src/server/actions/processes.ts +++ b/src/server/actions/processes.ts @@ -3,11 +3,11 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { z } from "zod"; -import { requireSession } from "@/server/auth"; -import { dbForTenant } from "@/server/db"; -import { requirePermission } from "@/server/rbac"; +import { moduleGuard } from "@/server/action-guard"; import { writeAuditLog } from "@/server/audit"; +const guard = moduleGuard("bia"); + const processSchema = z.object({ name: z.string().trim().min(1).max(200), description: z.string().trim().max(5000).optional(), @@ -30,9 +30,7 @@ function parseProcessForm(formData: FormData) { } export async function createProcess(formData: FormData) { - const session = await requireSession(); - requirePermission(session, "bia:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("bia:write"); const data = parseProcessForm(formData); // tenantId explizit für die Typen, der Guard injiziert ohnehin @@ -53,9 +51,7 @@ export async function createProcess(formData: FormData) { } export async function updateProcess(processId: string, formData: FormData) { - const session = await requireSession(); - requirePermission(session, "bia:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("bia:write"); const before = await db.process.findUnique({ where: { id: processId } }); if (!before) throw new Error("Prozess nicht gefunden"); @@ -77,9 +73,7 @@ export async function updateProcess(processId: string, formData: FormData) { } export async function deleteProcess(processId: string) { - const session = await requireSession(); - requirePermission(session, "bia:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("bia:write"); const before = await db.process.findUnique({ where: { id: processId } }); if (!before) throw new Error("Prozess nicht gefunden"); @@ -99,9 +93,7 @@ export async function deleteProcess(processId: string) { } export async function assignAsset(processId: string, formData: FormData) { - const session = await requireSession(); - requirePermission(session, "bia:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("bia:write"); const assetId = z.string().min(1).parse(formData.get("assetId")); const role = z.enum(["PRIMARY", "SECONDARY"]).parse(formData.get("role")); @@ -128,9 +120,7 @@ export async function assignAsset(processId: string, formData: FormData) { } export async function unassignAsset(processId: string, processAssetId: string) { - const session = await requireSession(); - requirePermission(session, "bia:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("bia:write"); const before = await db.processAsset.findUnique({ where: { id: processAssetId } }); if (!before) return; @@ -152,9 +142,7 @@ const hours = z .transform((v) => (v === "" ? null : v)); export async function saveBia(processId: string, formData: FormData) { - const session = await requireSession(); - requirePermission(session, "bia:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("bia:write"); const processCount = await db.process.count({ where: { id: processId } }); if (processCount !== 1) throw new Error("Prozess nicht gefunden"); @@ -197,9 +185,7 @@ export async function saveBia(processId: string, formData: FormData) { * weiter über die inkrementellen Aktionen assignAsset/unassignAsset. */ export async function saveProcessAll(processId: string, formData: FormData) { - const session = await requireSession(); - requirePermission(session, "bia:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("bia:write"); const before = await db.process.findUnique({ where: { id: processId }, diff --git a/src/server/actions/risks.ts b/src/server/actions/risks.ts index 8d3d994..ce8d009 100644 --- a/src/server/actions/risks.ts +++ b/src/server/actions/risks.ts @@ -3,10 +3,11 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { z } from "zod"; -import { requireSession } from "@/server/auth"; -import { dbForTenant, prisma } from "@/server/db"; -import { requirePermission } from "@/server/rbac"; +import { prisma } from "@/server/db"; +import { moduleGuard } from "@/server/action-guard"; import { writeAuditLog } from "@/server/audit"; + +const guard = moduleGuard("risk"); import { recomputeResidualRisk } from "@/server/risk-calc"; const scale = z.coerce.number().int().min(1).max(5); @@ -53,9 +54,7 @@ function parseRiskForm(formData: FormData) { } export async function createRisk(formData: FormData) { - const session = await requireSession(); - requirePermission(session, "risk:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("risk:write"); const data = parseRiskForm(formData); // Laufende Nummer je Mandant (MVP: max+1; bei Kollision greift der Unique-Index) @@ -97,9 +96,7 @@ export async function createRisk(formData: FormData) { } export async function updateRisk(riskId: string, formData: FormData) { - const session = await requireSession(); - requirePermission(session, "risk:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("risk:write"); const before = await db.risk.findUnique({ where: { id: riskId } }); if (!before) throw new Error("Risiko nicht gefunden"); @@ -123,9 +120,7 @@ export async function updateRisk(riskId: string, formData: FormData) { } export async function deleteRisk(riskId: string) { - const session = await requireSession(); - requirePermission(session, "risk:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("risk:write"); const before = await db.risk.findUnique({ where: { id: riskId } }); if (!before) throw new Error("Risiko nicht gefunden"); @@ -145,9 +140,7 @@ export async function deleteRisk(riskId: string) { } export async function addRiskAsset(riskId: string, formData: FormData) { - const session = await requireSession(); - requirePermission(session, "risk:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("risk:write"); const assetId = z.string().min(1).parse(formData.get("assetId")); const assetCount = await db.asset.count({ where: { id: assetId } }); @@ -173,9 +166,7 @@ export async function addRiskAsset(riskId: string, formData: FormData) { } export async function removeRiskAsset(riskId: string, riskAssetId: string) { - const session = await requireSession(); - requirePermission(session, "risk:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("risk:write"); const before = await db.riskAsset.findUnique({ where: { id: riskAssetId } }); if (!before) return; diff --git a/src/server/actions/services.ts b/src/server/actions/services.ts index 8b8337a..904a0b0 100644 --- a/src/server/actions/services.ts +++ b/src/server/actions/services.ts @@ -3,10 +3,11 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { z } from "zod"; -import { requireSession } from "@/server/auth"; -import { dbForTenant, prisma } from "@/server/db"; -import { requirePermission } from "@/server/rbac"; +import { prisma } from "@/server/db"; +import { moduleGuard } from "@/server/action-guard"; import { writeAuditLog } from "@/server/audit"; + +const guard = moduleGuard("suppliers"); import { withQuery } from "@/lib/supplier"; const level = z.coerce.number().int().min(1).max(4); @@ -45,9 +46,7 @@ function parse(formData: FormData) { } export async function createService(formData: FormData) { - const session = await requireSession(); - requirePermission(session, "supplier:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("supplier:write"); const { asset, profile } = parse(formData); const created = await db.asset.create({ data: { ...asset, type: "IT_SERVICE", tenantId: session.user.tenantId, createdBy: session.user.id }, @@ -63,9 +62,7 @@ export async function createService(formData: FormData) { } export async function updateService(assetId: string, formData: FormData) { - const session = await requireSession(); - requirePermission(session, "supplier:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("supplier:write"); const before = await db.asset.findUnique({ where: { id: assetId }, include: { serviceProfile: true } }); if (!before) throw new Error("Service nicht gefunden"); const { asset, profile } = parse(formData); @@ -79,9 +76,7 @@ export async function updateService(assetId: string, formData: FormData) { } export async function deleteService(assetId: string, returnTo: string = "/suppliers?tab=services") { - const session = await requireSession(); - requirePermission(session, "supplier:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("supplier:write"); const before = await db.asset.findUnique({ where: { id: assetId } }); if (!before) throw new Error("Service nicht gefunden"); await db.asset.delete({ where: { id: assetId } }); @@ -92,9 +87,7 @@ export async function deleteService(assetId: string, returnTo: string = "/suppli } export async function addRaci(assetId: string, formData: FormData) { - const session = await requireSession(); - requirePermission(session, "supplier:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("supplier:write"); if ((await db.asset.count({ where: { id: assetId } })) !== 1) throw new Error("Nicht gefunden"); await db.serviceControlResponsibility.create({ data: { @@ -114,9 +107,7 @@ export async function addRaci(assetId: string, formData: FormData) { /** Verantwortung einer RACI-Zeile per Klick ändern (PROVIDER/US/SHARED durchschalten). */ export async function cycleRaci(assetId: string, raciId: string) { - const session = await requireSession(); - requirePermission(session, "supplier:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("supplier:write"); const row = await db.serviceControlResponsibility.findUnique({ where: { id: raciId } }); if (!row) return; const next = { PROVIDER: "US", US: "SHARED", SHARED: "PROVIDER" } as const; @@ -127,9 +118,7 @@ export async function cycleRaci(assetId: string, raciId: string) { } export async function deleteRaci(assetId: string, raciId: string) { - const session = await requireSession(); - requirePermission(session, "supplier:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("supplier:write"); await db.serviceControlResponsibility.delete({ where: { id: raciId } }); await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "service_raci", entityId: assetId }); revalidatePath("/suppliers"); diff --git a/src/server/actions/suppliers.ts b/src/server/actions/suppliers.ts index 3b56b82..aa5c3a8 100644 --- a/src/server/actions/suppliers.ts +++ b/src/server/actions/suppliers.ts @@ -3,12 +3,13 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { z } from "zod"; -import { requireSession } from "@/server/auth"; -import { dbForTenant, prisma } from "@/server/db"; -import { requirePermission } from "@/server/rbac"; +import { prisma, type TenantDb } from "@/server/db"; +import { moduleGuard } from "@/server/action-guard"; import { writeAuditLog } from "@/server/audit"; import { withQuery } from "@/lib/supplier"; +const guard = moduleGuard("suppliers"); + const level = z.coerce.number().int().min(1).max(4); const supplierSchema = z.object({ @@ -67,9 +68,7 @@ function parse(formData: FormData) { } export async function createSupplier(formData: FormData) { - const session = await requireSession(); - requirePermission(session, "supplier:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("supplier:write"); const { asset, profile } = parse(formData); const created = await db.asset.create({ @@ -100,9 +99,7 @@ export async function createSupplier(formData: FormData) { } export async function updateSupplier(assetId: string, formData: FormData) { - const session = await requireSession(); - requirePermission(session, "supplier:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("supplier:write"); const before = await db.asset.findUnique({ where: { id: assetId }, include: { supplierProfile: true } }); if (!before) throw new Error("Lieferant nicht gefunden"); const { asset, profile } = parse(formData); @@ -116,9 +113,7 @@ export async function updateSupplier(assetId: string, formData: FormData) { } export async function deleteSupplier(assetId: string, returnTo: string = "/suppliers") { - const session = await requireSession(); - requirePermission(session, "supplier:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("supplier:write"); const before = await db.asset.findUnique({ where: { id: assetId } }); if (!before) throw new Error("Lieferant nicht gefunden"); await db.asset.delete({ where: { id: assetId } }); // Profil + Kinder kaskadieren @@ -128,17 +123,16 @@ export async function deleteSupplier(assetId: string, returnTo: string = "/suppl redirect(returnTo); } -async function ensure(session: Awaited>, assetId: string) { - const db = dbForTenant(session.user.tenantId); +/** Stellt sicher, dass das Asset zum Mandanten gehört (Guard hat Modul/Recht bereits geprüft). */ +async function ensure(db: TenantDb, assetId: string) { if ((await db.asset.count({ where: { id: assetId } })) !== 1) throw new Error("Nicht gefunden"); return db; } const optDate = (v: FormDataEntryValue | null) => (v && String(v) ? new Date(String(v)) : null); export async function addContract(assetId: string, formData: FormData) { - const session = await requireSession(); - requirePermission(session, "supplier:write"); - const db = await ensure(session, assetId); + const { session, db } = await guard("supplier:write"); + await ensure(db, assetId); const bool = (n: string) => formData.get(n) === "on"; await db.contract.create({ data: { @@ -160,9 +154,8 @@ export async function addContract(assetId: string, formData: FormData) { } export async function addNda(assetId: string, formData: FormData) { - const session = await requireSession(); - requirePermission(session, "supplier:write"); - const db = await ensure(session, assetId); + const { session, db } = await guard("supplier:write"); + await ensure(db, assetId); await db.nda.create({ data: { assetId, @@ -181,9 +174,8 @@ export async function addNda(assetId: string, formData: FormData) { } export async function addEvidence(assetId: string, formData: FormData) { - const session = await requireSession(); - requirePermission(session, "supplier:write"); - const db = await ensure(session, assetId); + const { session, db } = await guard("supplier:write"); + await ensure(db, assetId); await db.supplierEvidence.create({ data: { assetId, @@ -201,9 +193,8 @@ export async function addEvidence(assetId: string, formData: FormData) { } export async function addAssessment(assetId: string, formData: FormData) { - const session = await requireSession(); - requirePermission(session, "supplier:write"); - const db = await ensure(session, assetId); + const { session, db } = await guard("supplier:write"); + await ensure(db, assetId); await db.supplierAssessment.create({ data: { assetId, @@ -221,9 +212,8 @@ export async function addAssessment(assetId: string, formData: FormData) { } export async function addDecision(assetId: string, formData: FormData) { - const session = await requireSession(); - requirePermission(session, "supplier:write"); - const db = await ensure(session, assetId); + const { session, db } = await guard("supplier:write"); + await ensure(db, assetId); await db.managementDecision.create({ data: { assetId, @@ -241,10 +231,7 @@ export async function addDecision(assetId: string, formData: FormData) { /** Gate-Kompensation: echtes Risiko im Risikomodul anlegen und mit dem Lieferanten verknüpfen. */ export async function createGateRisk(assetId: string) { - const session = await requireSession(); - requirePermission(session, "supplier:write"); - requirePermission(session, "risk:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("supplier:write", "risk:write"); const asset = await db.asset.findUnique({ where: { id: assetId } }); if (!asset) throw new Error("Lieferant nicht gefunden"); @@ -274,9 +261,8 @@ export async function createGateRisk(assetId: string) { /** ISB-Freigabe des Reifegrads (Abweichung nur mit Begründung → Audit-Log). */ export async function approveMaturity(assetId: string, computed: number, formData: FormData) { - const session = await requireSession(); - requirePermission(session, "supplier:write"); - const db = await ensure(session, assetId); + const { session, db } = await guard("supplier:write"); + await ensure(db, assetId); const isbValue = z.coerce.number().min(0).max(3).parse(formData.get("isbValue")); const justification = (formData.get("justification") as string)?.trim() || null; if (Math.abs(isbValue - computed) > 0.01 && !justification) { @@ -296,9 +282,7 @@ export async function deleteChild( kind: "contract" | "nda" | "evidence" | "assessment" | "decision" | "subcontractor", id: string ) { - const session = await requireSession(); - requirePermission(session, "supplier:write"); - const db = dbForTenant(session.user.tenantId); + const { session, db } = await guard("supplier:write"); const map = { contract: db.contract, nda: db.nda, diff --git a/src/server/audit.ts b/src/server/audit.ts index 2e5defe..80639d5 100644 --- a/src/server/audit.ts +++ b/src/server/audit.ts @@ -8,7 +8,8 @@ import { prisma } from "./db"; export async function writeAuditLog(entry: { tenantId: string; actorId?: string; - action: "create" | "update" | "delete" | "login" | "logout" | "export" | "import"; + action: "create" | "update" | "delete" | "login" | "logout" | "export" | "import" | "denied"; + scope?: "tenant" | "platform"; entity: string; entityId?: string; before?: unknown; @@ -17,6 +18,7 @@ export async function writeAuditLog(entry: { await prisma.auditLog.create({ data: { tenantId: entry.tenantId, + scope: entry.scope ?? "tenant", actorId: entry.actorId, action: entry.action, entity: entry.entity, diff --git a/src/server/modules.ts b/src/server/modules.ts index c3d6e2c..79af632 100644 --- a/src/server/modules.ts +++ b/src/server/modules.ts @@ -1,6 +1,8 @@ import { redirect } from "next/navigation"; +import type { Session } from "next-auth"; import { requireSession } from "@/server/auth"; import { dbForTenant } from "@/server/db"; +import { writeAuditLog } from "@/server/audit"; /** * Serverseitige Modul-Durchsetzung (§3.4): blockiert den Zugriff auf ein für den @@ -16,3 +18,36 @@ export async function requireModule(moduleKey: string) { if (row && !row.enabled) redirect("/dashboard?module=disabled"); return session; } + +/** Wird geworfen, wenn eine Server-Action ein für den Mandanten deaktiviertes Modul betrifft. */ +export class ModuleDisabledError extends Error { + constructor(public readonly moduleKey: string) { + super(`Modul „${moduleKey}" ist für diesen Mandanten deaktiviert.`); + this.name = "ModuleDisabledError"; + } +} + +/** + * Action-Ebene der Modul-Durchsetzung (§3.4): prüft für eine bereits ermittelte + * Session, ob das Modul aktiv ist. Anders als `requireModule` (Redirect, für + * Layouts/Reads) **wirft** diese Variante bei Deaktivierung — der Layout-Guard + * greift bei Server-Actions erst nach der Mutation, daher hier vor dem Schreiben. + * Ein deaktiviertes Modul wird zusätzlich als Zugriffsverweigerung protokolliert. + * Fehlende TenantModule-Zeile ⇒ Modul gilt als aktiv (Default an). + */ +export async function assertModuleEnabled(session: Session, moduleKey: string) { + const tenantId = session.user.tenantId; + const row = await dbForTenant(tenantId).tenantModule.findUnique({ + where: { tenantId_moduleKey: { tenantId, moduleKey } }, + }); + if (row && !row.enabled) { + await writeAuditLog({ + tenantId, + actorId: session.user.id, + action: "denied", + entity: "module", + entityId: moduleKey, + }); + throw new ModuleDisabledError(moduleKey); + } +}