Iteration 2: Assets & BIA als gemeinsames Modul
- App-Shell mit Seitennavigation (alle 12 Module, kommende ausgegraut), shadcn/ui-Setup (Base-UI-Variante) mit hellem Theme - Asset-Inventar: filterbare Liste (Suche/Typ/Status), Anlegen/Bearbeiten/ Löschen, Detailansicht mit Schutzbedarf (C/I/A 1–4), Abhängigkeiten (beide Richtungen, hinzufügen/entfernen), zugeordneten Prozessen mit Primär-/Sekundär-Rolle und Platzhalter für zugeordnete Risiken - Prozesse & BIA: Liste mit Kritikalität/RTO/MTD, Prozess-Detail mit Asset-Zuordnung nach Rolle (primär/sekundär), BIA-Formular (RTO/RPO/MTD, Schadenshöhe je Schutzziel, Kritikalität nach Max-Prinzip) - Server-Actions mit Zod-Validierung, requirePermission und Audit-Log für jede schreibende Aktion; Tenant-Guard um upsert-Injektion erweitert - Prisma: Asset, AssetRelation, Process, ProcessAsset, BiaEntry inkl. RLS-Policies; Seed mit Beispiel-Assets, Prozessen und BIA - Im Browser verifiziert: CRUD, Relationen, BIA-Speichern, Audit-Einträge Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
"use server";
|
||||
|
||||
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 { writeAuditLog } from "@/server/audit";
|
||||
|
||||
const level = z.coerce.number().int().min(1).max(4);
|
||||
|
||||
const assetSchema = z.object({
|
||||
name: z.string().trim().min(1).max(200),
|
||||
description: z.string().trim().max(5000).optional(),
|
||||
type: z.enum(["INFORMATION", "SYSTEM", "APPLICATION", "LOCATION", "SUPPLIER", "PERSON", "DATA"]),
|
||||
status: z.enum(["ACTIVE", "PLANNED", "RETIRED"]),
|
||||
ownerId: z.string().optional(),
|
||||
location: z.string().trim().max(200).optional(),
|
||||
tags: z.string().optional(),
|
||||
confidentiality: level,
|
||||
integrity: level,
|
||||
availability: level,
|
||||
});
|
||||
|
||||
function parseAssetForm(formData: FormData) {
|
||||
const parsed = assetSchema.parse({
|
||||
name: formData.get("name"),
|
||||
description: formData.get("description") || undefined,
|
||||
type: formData.get("type"),
|
||||
status: formData.get("status"),
|
||||
ownerId: formData.get("ownerId") || undefined,
|
||||
location: formData.get("location") || undefined,
|
||||
tags: formData.get("tags") || undefined,
|
||||
confidentiality: formData.get("confidentiality"),
|
||||
integrity: formData.get("integrity"),
|
||||
availability: formData.get("availability"),
|
||||
});
|
||||
return {
|
||||
...parsed,
|
||||
ownerId: parsed.ownerId || null,
|
||||
description: parsed.description || null,
|
||||
location: parsed.location || null,
|
||||
tags: parsed.tags
|
||||
? parsed.tags.split(",").map((t) => t.trim()).filter(Boolean)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function createAsset(formData: FormData) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "asset:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const data = parseAssetForm(formData);
|
||||
// tenantId doppelt gemoppelt: explizit für die Typen, der Guard injiziert ohnehin
|
||||
const asset = await db.asset.create({
|
||||
data: { ...data, tenantId: session.user.tenantId, createdBy: session.user.id },
|
||||
});
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId: session.user.tenantId,
|
||||
actorId: session.user.id,
|
||||
action: "create",
|
||||
entity: "asset",
|
||||
entityId: asset.id,
|
||||
after: data,
|
||||
});
|
||||
revalidatePath("/assets");
|
||||
redirect(`/assets/${asset.id}`);
|
||||
}
|
||||
|
||||
export async function updateAsset(assetId: string, formData: FormData) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "asset:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const before = await db.asset.findUnique({ where: { id: assetId } });
|
||||
if (!before) throw new Error("Asset nicht gefunden");
|
||||
|
||||
const data = parseAssetForm(formData);
|
||||
await db.asset.update({ where: { id: assetId }, data });
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId: session.user.tenantId,
|
||||
actorId: session.user.id,
|
||||
action: "update",
|
||||
entity: "asset",
|
||||
entityId: assetId,
|
||||
before,
|
||||
after: data,
|
||||
});
|
||||
revalidatePath("/assets");
|
||||
revalidatePath(`/assets/${assetId}`);
|
||||
redirect(`/assets/${assetId}`);
|
||||
}
|
||||
|
||||
export async function deleteAsset(assetId: string) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "asset:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const before = await db.asset.findUnique({ where: { id: assetId } });
|
||||
if (!before) throw new Error("Asset nicht gefunden");
|
||||
|
||||
await db.asset.delete({ where: { id: assetId } });
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId: session.user.tenantId,
|
||||
actorId: session.user.id,
|
||||
action: "delete",
|
||||
entity: "asset",
|
||||
entityId: assetId,
|
||||
before,
|
||||
});
|
||||
revalidatePath("/assets");
|
||||
redirect("/assets");
|
||||
}
|
||||
|
||||
export async function addAssetRelation(assetId: string, formData: FormData) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "asset:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const relatedAssetId = z.string().min(1).parse(formData.get("relatedAssetId"));
|
||||
if (relatedAssetId === assetId) return;
|
||||
|
||||
// Beide Assets müssen zum Mandanten gehören (Guard filtert, count prüft)
|
||||
const count = await db.asset.count({ where: { id: { in: [assetId, relatedAssetId] } } });
|
||||
if (count !== 2) throw new Error("Asset nicht gefunden");
|
||||
|
||||
await db.assetRelation.upsert({
|
||||
where: {
|
||||
assetId_relatedAssetId_type: { assetId, relatedAssetId, type: "depends_on" },
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
assetId,
|
||||
relatedAssetId,
|
||||
type: "depends_on",
|
||||
tenantId: session.user.tenantId,
|
||||
},
|
||||
});
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId: session.user.tenantId,
|
||||
actorId: session.user.id,
|
||||
action: "create",
|
||||
entity: "asset_relation",
|
||||
entityId: assetId,
|
||||
after: { assetId, relatedAssetId, type: "depends_on" },
|
||||
});
|
||||
revalidatePath(`/assets/${assetId}`);
|
||||
}
|
||||
|
||||
export async function removeAssetRelation(assetId: string, relationId: string) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "asset:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const before = await db.assetRelation.findUnique({ where: { id: relationId } });
|
||||
if (!before) return;
|
||||
await db.assetRelation.delete({ where: { id: relationId } });
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId: session.user.tenantId,
|
||||
actorId: session.user.id,
|
||||
action: "delete",
|
||||
entity: "asset_relation",
|
||||
entityId: relationId,
|
||||
before,
|
||||
});
|
||||
revalidatePath(`/assets/${assetId}`);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
"use server";
|
||||
|
||||
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 { writeAuditLog } from "@/server/audit";
|
||||
|
||||
const processSchema = z.object({
|
||||
name: z.string().trim().min(1).max(200),
|
||||
description: z.string().trim().max(5000).optional(),
|
||||
ownerId: z.string().optional(),
|
||||
});
|
||||
|
||||
function parseProcessForm(formData: FormData) {
|
||||
const parsed = processSchema.parse({
|
||||
name: formData.get("name"),
|
||||
description: formData.get("description") || undefined,
|
||||
ownerId: formData.get("ownerId") || undefined,
|
||||
});
|
||||
return {
|
||||
...parsed,
|
||||
description: parsed.description || null,
|
||||
ownerId: parsed.ownerId || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createProcess(formData: FormData) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "bia:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
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/${process.id}`);
|
||||
}
|
||||
|
||||
export async function updateProcess(processId: string, formData: FormData) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "bia:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
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");
|
||||
revalidatePath(`/processes/${processId}`);
|
||||
redirect(`/processes/${processId}`);
|
||||
}
|
||||
|
||||
export async function deleteProcess(processId: string) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "bia:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
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 = await requireSession();
|
||||
requirePermission(session, "bia:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
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/${processId}`);
|
||||
}
|
||||
|
||||
export async function unassignAsset(processId: string, processAssetId: string) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "bia:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
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/${processId}`);
|
||||
}
|
||||
|
||||
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 = await requireSession();
|
||||
requirePermission(session, "bia:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
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");
|
||||
revalidatePath(`/processes/${processId}`);
|
||||
}
|
||||
+15
-1
@@ -24,7 +24,16 @@ export const prisma =
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
|
||||
/** Models that carry a tenantId column and must never be queried without one. */
|
||||
const TENANT_MODELS = new Set<string>(["User", "Role", "AuditLog"]);
|
||||
const TENANT_MODELS = new Set<string>([
|
||||
"User",
|
||||
"Role",
|
||||
"AuditLog",
|
||||
"Asset",
|
||||
"AssetRelation",
|
||||
"Process",
|
||||
"ProcessAsset",
|
||||
"BiaEntry",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Returns a Prisma client that transparently enforces the tenant scope:
|
||||
@@ -44,8 +53,10 @@ export function dbForTenant(tenantId: string) {
|
||||
if (
|
||||
operation === "findMany" ||
|
||||
operation === "findFirst" ||
|
||||
operation === "findFirstOrThrow" ||
|
||||
operation === "count" ||
|
||||
operation === "aggregate" ||
|
||||
operation === "groupBy" ||
|
||||
operation === "updateMany" ||
|
||||
operation === "deleteMany"
|
||||
) {
|
||||
@@ -87,6 +98,9 @@ export function dbForTenant(tenantId: string) {
|
||||
`Tenant isolation violation: ${model} belongs to another tenant`
|
||||
);
|
||||
}
|
||||
if (operation === "upsert") {
|
||||
a.create = { ...(a.create as object), tenantId };
|
||||
}
|
||||
}
|
||||
|
||||
return query(args);
|
||||
|
||||
Reference in New Issue
Block a user