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("<key>") 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 <noreply@anthropic.com>
228 lines
7.3 KiB
TypeScript
228 lines
7.3 KiB
TypeScript
"use server";
|
|
|
|
import { revalidatePath } from "next/cache";
|
|
import { redirect } from "next/navigation";
|
|
import { z } from "zod";
|
|
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(),
|
|
category: z.enum(["CORE", "MANAGEMENT", "SUPPORT"]),
|
|
ownerId: z.string().optional(),
|
|
});
|
|
|
|
function parseProcessForm(formData: FormData) {
|
|
const parsed = processSchema.parse({
|
|
name: formData.get("name"),
|
|
description: formData.get("description") || undefined,
|
|
category: formData.get("category"),
|
|
ownerId: formData.get("ownerId") || undefined,
|
|
});
|
|
return {
|
|
...parsed,
|
|
description: parsed.description || null,
|
|
ownerId: parsed.ownerId || null,
|
|
};
|
|
}
|
|
|
|
export async function createProcess(formData: FormData) {
|
|
const { session, db } = await guard("bia:write");
|
|
|
|
const data = parseProcessForm(formData);
|
|
// tenantId explizit für die Typen, der Guard injiziert ohnehin
|
|
const process = await db.process.create({
|
|
data: { ...data, tenantId: session.user.tenantId, createdBy: session.user.id },
|
|
});
|
|
|
|
await writeAuditLog({
|
|
tenantId: session.user.tenantId,
|
|
actorId: session.user.id,
|
|
action: "create",
|
|
entity: "process",
|
|
entityId: process.id,
|
|
after: data,
|
|
});
|
|
revalidatePath("/processes");
|
|
redirect(`/processes?edit=${process.id}`);
|
|
}
|
|
|
|
export async function updateProcess(processId: string, formData: FormData) {
|
|
const { session, db } = await guard("bia:write");
|
|
|
|
const before = await db.process.findUnique({ where: { id: processId } });
|
|
if (!before) throw new Error("Prozess nicht gefunden");
|
|
|
|
const data = parseProcessForm(formData);
|
|
await db.process.update({ where: { id: processId }, data });
|
|
|
|
await writeAuditLog({
|
|
tenantId: session.user.tenantId,
|
|
actorId: session.user.id,
|
|
action: "update",
|
|
entity: "process",
|
|
entityId: processId,
|
|
before,
|
|
after: data,
|
|
});
|
|
revalidatePath("/processes");
|
|
redirect(`/processes?detail=${processId}`);
|
|
}
|
|
|
|
export async function deleteProcess(processId: string) {
|
|
const { session, db } = await guard("bia:write");
|
|
|
|
const before = await db.process.findUnique({ where: { id: processId } });
|
|
if (!before) throw new Error("Prozess nicht gefunden");
|
|
|
|
await db.process.delete({ where: { id: processId } });
|
|
|
|
await writeAuditLog({
|
|
tenantId: session.user.tenantId,
|
|
actorId: session.user.id,
|
|
action: "delete",
|
|
entity: "process",
|
|
entityId: processId,
|
|
before,
|
|
});
|
|
revalidatePath("/processes");
|
|
redirect("/processes");
|
|
}
|
|
|
|
export async function assignAsset(processId: string, formData: FormData) {
|
|
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"));
|
|
|
|
const assetCount = await db.asset.count({ where: { id: assetId } });
|
|
const processCount = await db.process.count({ where: { id: processId } });
|
|
if (assetCount !== 1 || processCount !== 1) throw new Error("Nicht gefunden");
|
|
|
|
await db.processAsset.upsert({
|
|
where: { processId_assetId: { processId, assetId } },
|
|
update: { role },
|
|
create: { processId, assetId, role, tenantId: session.user.tenantId },
|
|
});
|
|
|
|
await writeAuditLog({
|
|
tenantId: session.user.tenantId,
|
|
actorId: session.user.id,
|
|
action: "update",
|
|
entity: "process_asset",
|
|
entityId: processId,
|
|
after: { processId, assetId, role },
|
|
});
|
|
revalidatePath("/processes");
|
|
}
|
|
|
|
export async function unassignAsset(processId: string, processAssetId: string) {
|
|
const { session, db } = await guard("bia:write");
|
|
|
|
const before = await db.processAsset.findUnique({ where: { id: processAssetId } });
|
|
if (!before) return;
|
|
await db.processAsset.delete({ where: { id: processAssetId } });
|
|
|
|
await writeAuditLog({
|
|
tenantId: session.user.tenantId,
|
|
actorId: session.user.id,
|
|
action: "delete",
|
|
entity: "process_asset",
|
|
entityId: processAssetId,
|
|
before,
|
|
});
|
|
revalidatePath("/processes");
|
|
}
|
|
|
|
const hours = z
|
|
.union([z.literal(""), z.coerce.number().int().min(0).max(100000)])
|
|
.transform((v) => (v === "" ? null : v));
|
|
|
|
export async function saveBia(processId: string, formData: FormData) {
|
|
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");
|
|
|
|
const level = z.coerce.number().int().min(1).max(4);
|
|
const data = {
|
|
rtoHours: hours.parse(formData.get("rtoHours") ?? ""),
|
|
rpoHours: hours.parse(formData.get("rpoHours") ?? ""),
|
|
mtdHours: hours.parse(formData.get("mtdHours") ?? ""),
|
|
impactC: level.parse(formData.get("impactC")),
|
|
impactI: level.parse(formData.get("impactI")),
|
|
impactA: level.parse(formData.get("impactA")),
|
|
notes: (formData.get("notes") as string)?.trim() || null,
|
|
};
|
|
// Kritikalität nach Max-Prinzip aus den Schadenshöhen (SPEC §4.1.2)
|
|
const criticality = Math.max(data.impactC, data.impactI, data.impactA);
|
|
|
|
const before = await db.biaEntry.findUnique({ where: { processId } });
|
|
await db.biaEntry.upsert({
|
|
where: { processId },
|
|
update: { ...data, criticality },
|
|
create: { ...data, criticality, processId, tenantId: session.user.tenantId },
|
|
});
|
|
|
|
await writeAuditLog({
|
|
tenantId: session.user.tenantId,
|
|
actorId: session.user.id,
|
|
action: before ? "update" : "create",
|
|
entity: "bia_entry",
|
|
entityId: processId,
|
|
before: before ?? undefined,
|
|
after: { ...data, criticality },
|
|
});
|
|
revalidatePath("/processes");
|
|
}
|
|
|
|
/**
|
|
* Kombiniertes Speichern für den aufgeräumten Bearbeiten-Dialog: Stammdaten
|
|
* und BIA in einem Vorgang (ein Speichern-Button). Asset-Zuordnung läuft
|
|
* weiter über die inkrementellen Aktionen assignAsset/unassignAsset.
|
|
*/
|
|
export async function saveProcessAll(processId: string, formData: FormData) {
|
|
const { session, db } = await guard("bia:write");
|
|
|
|
const before = await db.process.findUnique({
|
|
where: { id: processId },
|
|
include: { bia: true },
|
|
});
|
|
if (!before) throw new Error("Prozess nicht gefunden");
|
|
|
|
const master = parseProcessForm(formData);
|
|
await db.process.update({ where: { id: processId }, data: master });
|
|
|
|
const level = z.coerce.number().int().min(1).max(4);
|
|
const biaData = {
|
|
rtoHours: hours.parse(formData.get("rtoHours") ?? ""),
|
|
rpoHours: hours.parse(formData.get("rpoHours") ?? ""),
|
|
mtdHours: hours.parse(formData.get("mtdHours") ?? ""),
|
|
impactC: level.parse(formData.get("impactC")),
|
|
impactI: level.parse(formData.get("impactI")),
|
|
impactA: level.parse(formData.get("impactA")),
|
|
notes: (formData.get("notes") as string)?.trim() || null,
|
|
};
|
|
const criticality = Math.max(biaData.impactC, biaData.impactI, biaData.impactA);
|
|
await db.biaEntry.upsert({
|
|
where: { processId },
|
|
update: { ...biaData, criticality },
|
|
create: { ...biaData, criticality, processId, tenantId: session.user.tenantId },
|
|
});
|
|
|
|
await writeAuditLog({
|
|
tenantId: session.user.tenantId,
|
|
actorId: session.user.id,
|
|
action: "update",
|
|
entity: "process",
|
|
entityId: processId,
|
|
before,
|
|
after: { ...master, bia: { ...biaData, criticality } },
|
|
});
|
|
revalidatePath("/processes");
|
|
redirect(`/processes?detail=${processId}`);
|
|
}
|