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
@@ -0,0 +1,132 @@
-- CreateEnum
CREATE TYPE "AssetType" AS ENUM ('INFORMATION', 'SYSTEM', 'APPLICATION', 'LOCATION', 'SUPPLIER', 'PERSON', 'DATA');
-- CreateEnum
CREATE TYPE "AssetStatus" AS ENUM ('ACTIVE', 'PLANNED', 'RETIRED');
-- CreateEnum
CREATE TYPE "ProcessAssetRole" AS ENUM ('PRIMARY', 'SECONDARY');
-- CreateTable
CREATE TABLE "assets" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"type" "AssetType" NOT NULL,
"status" "AssetStatus" NOT NULL DEFAULT 'ACTIVE',
"owner_id" TEXT,
"location" TEXT,
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
"confidentiality" INTEGER NOT NULL DEFAULT 1,
"integrity" INTEGER NOT NULL DEFAULT 1,
"availability" INTEGER NOT NULL DEFAULT 1,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"created_by" TEXT,
CONSTRAINT "assets_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "asset_relations" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"asset_id" TEXT NOT NULL,
"related_asset_id" TEXT NOT NULL,
"type" TEXT NOT NULL DEFAULT 'depends_on',
CONSTRAINT "asset_relations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "processes" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"owner_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"created_by" TEXT,
CONSTRAINT "processes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "process_assets" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"process_id" TEXT NOT NULL,
"asset_id" TEXT NOT NULL,
"role" "ProcessAssetRole" NOT NULL DEFAULT 'SECONDARY',
CONSTRAINT "process_assets_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "bia_entries" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"process_id" TEXT NOT NULL,
"rto_hours" INTEGER,
"rpo_hours" INTEGER,
"mtd_hours" INTEGER,
"impact_c" INTEGER NOT NULL DEFAULT 1,
"impact_i" INTEGER NOT NULL DEFAULT 1,
"impact_a" INTEGER NOT NULL DEFAULT 1,
"criticality" INTEGER NOT NULL DEFAULT 1,
"notes" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "bia_entries_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "assets_tenant_id_idx" ON "assets"("tenant_id");
-- CreateIndex
CREATE INDEX "assets_tenant_id_type_idx" ON "assets"("tenant_id", "type");
-- CreateIndex
CREATE INDEX "asset_relations_tenant_id_idx" ON "asset_relations"("tenant_id");
-- CreateIndex
CREATE UNIQUE INDEX "asset_relations_asset_id_related_asset_id_type_key" ON "asset_relations"("asset_id", "related_asset_id", "type");
-- CreateIndex
CREATE INDEX "processes_tenant_id_idx" ON "processes"("tenant_id");
-- CreateIndex
CREATE INDEX "process_assets_tenant_id_idx" ON "process_assets"("tenant_id");
-- CreateIndex
CREATE UNIQUE INDEX "process_assets_process_id_asset_id_key" ON "process_assets"("process_id", "asset_id");
-- CreateIndex
CREATE UNIQUE INDEX "bia_entries_process_id_key" ON "bia_entries"("process_id");
-- CreateIndex
CREATE INDEX "bia_entries_tenant_id_idx" ON "bia_entries"("tenant_id");
-- AddForeignKey
ALTER TABLE "assets" ADD CONSTRAINT "assets_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "asset_relations" ADD CONSTRAINT "asset_relations_asset_id_fkey" FOREIGN KEY ("asset_id") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "asset_relations" ADD CONSTRAINT "asset_relations_related_asset_id_fkey" FOREIGN KEY ("related_asset_id") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "processes" ADD CONSTRAINT "processes_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "process_assets" ADD CONSTRAINT "process_assets_process_id_fkey" FOREIGN KEY ("process_id") REFERENCES "processes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "process_assets" ADD CONSTRAINT "process_assets_asset_id_fkey" FOREIGN KEY ("asset_id") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "bia_entries" ADD CONSTRAINT "bia_entries_process_id_fkey" FOREIGN KEY ("process_id") REFERENCES "processes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,18 @@
-- RLS-Policies für die Assets-&-BIA-Tabellen (analog zur Migration row_level_security)
ALTER TABLE "assets" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "asset_relations" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "processes" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "process_assets" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "bia_entries" ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON "assets"
USING ("tenant_id" = current_setting('app.tenant_id', true));
CREATE POLICY tenant_isolation ON "asset_relations"
USING ("tenant_id" = current_setting('app.tenant_id', true));
CREATE POLICY tenant_isolation ON "processes"
USING ("tenant_id" = current_setting('app.tenant_id', true));
CREATE POLICY tenant_isolation ON "process_assets"
USING ("tenant_id" = current_setting('app.tenant_id', true));
CREATE POLICY tenant_isolation ON "bia_entries"
USING ("tenant_id" = current_setting('app.tenant_id', true));
+128 -2
View File
@@ -56,8 +56,10 @@ model User {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
userRoles UserRole[]
tenant Tenant @relation(fields: [tenantId], references: [id])
userRoles UserRole[]
ownedAssets Asset[] @relation("assetOwner")
ownedProcesses Process[] @relation("processOwner")
@@unique([tenantId, email])
@@index([tenantId])
@@ -110,6 +112,130 @@ model RolePermission {
@@map("role_permissions")
}
// ── Assets & BIA (ein Modul, SPEC §4.1) ─────────────────────────────────────
enum AssetType {
INFORMATION
SYSTEM
APPLICATION
LOCATION
SUPPLIER
PERSON
DATA
}
enum AssetStatus {
ACTIVE
PLANNED
RETIRED
}
enum ProcessAssetRole {
PRIMARY
SECONDARY
}
model Asset {
id String @id @default(cuid())
tenantId String @map("tenant_id")
name String
description String?
type AssetType
status AssetStatus @default(ACTIVE)
ownerId String? @map("owner_id")
location String?
tags String[] @default([])
// Schutzbedarf 14 je Schutzziel (C/I/A)
confidentiality Int @default(1)
integrity Int @default(1)
availability Int @default(1)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
createdBy String? @map("created_by")
owner User? @relation("assetOwner", fields: [ownerId], references: [id])
relationsFrom AssetRelation[] @relation("relationFrom")
relationsTo AssetRelation[] @relation("relationTo")
processAssets ProcessAsset[]
@@index([tenantId])
@@index([tenantId, type])
@@map("assets")
}
model AssetRelation {
id String @id @default(cuid())
tenantId String @map("tenant_id")
assetId String @map("asset_id")
relatedAssetId String @map("related_asset_id")
// z. B. depends_on, hosts, processes_data_of
type String @default("depends_on")
asset Asset @relation("relationFrom", fields: [assetId], references: [id], onDelete: Cascade)
relatedAsset Asset @relation("relationTo", fields: [relatedAssetId], references: [id], onDelete: Cascade)
@@unique([assetId, relatedAssetId, type])
@@index([tenantId])
@@map("asset_relations")
}
model Process {
id String @id @default(cuid())
tenantId String @map("tenant_id")
name String
description String?
ownerId String? @map("owner_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
createdBy String? @map("created_by")
owner User? @relation("processOwner", fields: [ownerId], references: [id])
processAssets ProcessAsset[]
bia BiaEntry?
@@index([tenantId])
@@map("processes")
}
model ProcessAsset {
id String @id @default(cuid())
tenantId String @map("tenant_id")
processId String @map("process_id")
assetId String @map("asset_id")
role ProcessAssetRole @default(SECONDARY)
process Process @relation(fields: [processId], references: [id], onDelete: Cascade)
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
@@unique([processId, assetId])
@@index([tenantId])
@@map("process_assets")
}
model BiaEntry {
id String @id @default(cuid())
tenantId String @map("tenant_id")
processId String @unique @map("process_id")
// Wiederanlaufparameter in Stunden (SPEC §4.1.2)
rtoHours Int? @map("rto_hours")
rpoHours Int? @map("rpo_hours")
mtdHours Int? @map("mtd_hours")
// Schadenshöhe 14 je Schutzziel
impactC Int @default(1) @map("impact_c")
impactI Int @default(1) @map("impact_i")
impactA Int @default(1) @map("impact_a")
// Kritikalität 14, abgeleitet (Max-Prinzip), redundant gespeichert für Listen/Filter
criticality Int @default(1)
notes String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
process Process @relation(fields: [processId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@map("bia_entries")
}
model AuditLog {
id String @id @default(cuid())
tenantId String @map("tenant_id")
+114
View File
@@ -81,6 +81,120 @@ async function main() {
}
}
console.log(`${demoUsers.length} Demo-Nutzer (Passwort: ${DEMO_PASSWORD})`);
// 5. Beispiel-Assets, -Prozesse und BIA (nur wenn noch keine Assets existieren)
const assetCount = await prisma.asset.count({ where: { tenantId: tenant.id } });
if (assetCount === 0) {
const isb = await prisma.user.findUniqueOrThrow({
where: { tenantId_email: { tenantId: tenant.id, email: "admin@demo.example" } },
});
const owner = await prisma.user.findUniqueOrThrow({
where: { tenantId_email: { tenantId: tenant.id, email: "owner@demo.example" } },
});
const mk = (data: {
name: string;
type: "INFORMATION" | "SYSTEM" | "APPLICATION" | "LOCATION" | "SUPPLIER" | "PERSON" | "DATA";
c: number;
i: number;
a: number;
ownerId?: string;
location?: string;
tags?: string[];
description?: string;
}) =>
prisma.asset.create({
data: {
tenantId: tenant.id,
name: data.name,
type: data.type,
confidentiality: data.c,
integrity: data.i,
availability: data.a,
ownerId: data.ownerId,
location: data.location,
tags: data.tags ?? [],
description: data.description,
},
});
const erp = await mk({ name: "ERP-System", type: "SYSTEM", c: 3, i: 4, a: 3, ownerId: owner.id, location: "RZ Frankfurt", tags: ["kritisch", "kern"] });
const crm = await mk({ name: "Kundendatenbank", type: "DATA", c: 4, i: 3, a: 2, ownerId: isb.id, tags: ["dsgvo"] });
const auftraege = await mk({ name: "Auftragsdaten", type: "INFORMATION", c: 3, i: 4, a: 3, ownerId: owner.id });
const hoster = await mk({ name: "Cloud-Hoster (IaaS)", type: "SUPPLIER", c: 2, i: 3, a: 4, description: "Betreibt das Rechenzentrum für ERP und CRM." });
const buero = await mk({ name: "Bürostandort München", type: "LOCATION", c: 2, i: 2, a: 2 });
const itTeam = await mk({ name: "IT-Administration", type: "PERSON", c: 3, i: 3, a: 3 });
await prisma.assetRelation.createMany({
data: [
{ tenantId: tenant.id, assetId: erp.id, relatedAssetId: hoster.id, type: "depends_on" },
{ tenantId: tenant.id, assetId: crm.id, relatedAssetId: hoster.id, type: "depends_on" },
{ tenantId: tenant.id, assetId: erp.id, relatedAssetId: itTeam.id, type: "depends_on" },
],
});
const auftrag = await prisma.process.create({
data: {
tenantId: tenant.id,
name: "Auftragsabwicklung",
description: "Vom Kundenauftrag bis zur Auslieferung.",
ownerId: owner.id,
},
});
await prisma.processAsset.createMany({
data: [
{ tenantId: tenant.id, processId: auftrag.id, assetId: auftraege.id, role: "PRIMARY" },
{ tenantId: tenant.id, processId: auftrag.id, assetId: erp.id, role: "SECONDARY" },
{ tenantId: tenant.id, processId: auftrag.id, assetId: crm.id, role: "SECONDARY" },
{ tenantId: tenant.id, processId: auftrag.id, assetId: itTeam.id, role: "SECONDARY" },
],
});
await prisma.biaEntry.create({
data: {
tenantId: tenant.id,
processId: auftrag.id,
rtoHours: 8,
rpoHours: 4,
mtdHours: 24,
impactC: 2,
impactI: 4,
impactA: 3,
criticality: 4,
notes: "Ausfall > 1 Tag führt zu Lieferverzug und Vertragsstrafen.",
},
});
const vertrieb = await prisma.process.create({
data: {
tenantId: tenant.id,
name: "Kundenbetreuung",
description: "Anfragen, Angebote, Support.",
ownerId: isb.id,
},
});
await prisma.processAsset.createMany({
data: [
{ tenantId: tenant.id, processId: vertrieb.id, assetId: crm.id, role: "PRIMARY" },
{ tenantId: tenant.id, processId: vertrieb.id, assetId: buero.id, role: "SECONDARY" },
],
});
await prisma.biaEntry.create({
data: {
tenantId: tenant.id,
processId: vertrieb.id,
rtoHours: 24,
rpoHours: 24,
mtdHours: 72,
impactC: 3,
impactI: 2,
impactA: 2,
criticality: 3,
notes: "Kundenkommunikation kann kurzfristig über Ausweichkanäle laufen.",
},
});
console.log("✔ Beispiel-Assets, -Prozesse und BIA angelegt");
}
}
main()