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:
Martin
2026-07-02 12:35:19 +02:00
co-authored by Claude Fable 5
parent 880afed391
commit 392bb246cf
41 changed files with 6085 additions and 149 deletions
+174
View File
@@ -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}`);
}
+192
View File
@@ -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
View File
@@ -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);