Iteration 3: Risikoanalyse + Menü-Trennung + Prozesskategorie

- Risiko-Modul (SPEC §4.2): Risk/RiskAsset-Schema mit RLS, laufende
  Nummer je Mandant (R-001), Brutto-/Rest-Bewertung (5×5), Behandlung,
  Status, Owner- und Prozess-Bezug, n:m betroffene Assets
- /risks im Mockup-Layout: 5×5-Heatmap (Farb-Bänder + R-Chips, Legende,
  Achsen) und Risikoregister mit Score-Pillen, Behandlungs-Tags, Status
- Risiko-Popups nach App-Muster: Read-only-Detail (Bewertungs-Band
  Brutto vs. Rest, betroffene Assets, Maßnahmen-Platzhalter für
  Iteration 4), Bearbeiten im Popup (Bewertung, Assets verknüpfen,
  Löschen), Anlegen; Server-Actions mit Zod, RBAC und Audit-Log
- Menü aufgetrennt: eigene Punkte "Asset-Inventar", "Business Impact
  Analyse" und "Risikoanalyse" (Modul-Tabs entfernt)
- Prozesskategorie (Kernprozess/Managementprozess/Unterstützender
  Prozess): Schema-Feld, Formulare, Listen-Spalte, Popup-Tag
- Asset-Detail-Popup optisch ans Prozess-Popup angeglichen (Stammdaten-
  Karte mit violettem Rand, Abhängigkeiten-Tabelle, Risiken-Band)
- "Zugeordnete Risiken" jetzt echt: im Asset-Popup (verknüpfte Risiken)
  und im Prozess-Popup (direkt + über Assets aggregiert); Dashboard-KPI
  "Offene Risiken" mit echten Zahlen; Seed mit 4 Beispiel-Risiken

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Martin
2026-07-02 15:43:25 +02:00
co-authored by Claude Fable 5
parent c5a1f0095b
commit df634e356d
20 changed files with 1633 additions and 106 deletions
@@ -0,0 +1,74 @@
-- CreateEnum
CREATE TYPE "ProcessCategory" AS ENUM ('CORE', 'MANAGEMENT', 'SUPPORT');
-- CreateEnum
CREATE TYPE "RiskTreatment" AS ENUM ('AVOID', 'MITIGATE', 'TRANSFER', 'ACCEPT');
-- CreateEnum
CREATE TYPE "RiskStatus" AS ENUM ('OPEN', 'IN_TREATMENT', 'ACCEPTED', 'CLOSED');
-- AlterTable
ALTER TABLE "processes" ADD COLUMN "category" "ProcessCategory" NOT NULL DEFAULT 'CORE';
-- CreateTable
CREATE TABLE "risks" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"ref_no" INTEGER NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"threat" TEXT,
"vulnerability" TEXT,
"likelihood" INTEGER NOT NULL DEFAULT 1,
"impact" INTEGER NOT NULL DEFAULT 1,
"score" INTEGER NOT NULL DEFAULT 1,
"residual_likelihood" INTEGER,
"residual_impact" INTEGER,
"residual_score" INTEGER,
"treatment" "RiskTreatment" NOT NULL DEFAULT 'MITIGATE',
"status" "RiskStatus" NOT NULL DEFAULT 'OPEN',
"owner_id" TEXT,
"process_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"created_by" TEXT,
CONSTRAINT "risks_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "risk_assets" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"risk_id" TEXT NOT NULL,
"asset_id" TEXT NOT NULL,
CONSTRAINT "risk_assets_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "risks_tenant_id_idx" ON "risks"("tenant_id");
-- CreateIndex
CREATE INDEX "risks_tenant_id_status_idx" ON "risks"("tenant_id", "status");
-- CreateIndex
CREATE UNIQUE INDEX "risks_tenant_id_ref_no_key" ON "risks"("tenant_id", "ref_no");
-- CreateIndex
CREATE INDEX "risk_assets_tenant_id_idx" ON "risk_assets"("tenant_id");
-- CreateIndex
CREATE UNIQUE INDEX "risk_assets_risk_id_asset_id_key" ON "risk_assets"("risk_id", "asset_id");
-- AddForeignKey
ALTER TABLE "risks" ADD CONSTRAINT "risks_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "risks" ADD CONSTRAINT "risks_process_id_fkey" FOREIGN KEY ("process_id") REFERENCES "processes"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "risk_assets" ADD CONSTRAINT "risk_assets_risk_id_fkey" FOREIGN KEY ("risk_id") REFERENCES "risks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "risk_assets" ADD CONSTRAINT "risk_assets_asset_id_fkey" FOREIGN KEY ("asset_id") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,9 @@
-- RLS-Policies für die Risiko-Tabellen (analog zu den vorherigen RLS-Migrationen)
ALTER TABLE "risks" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "risk_assets" ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON "risks"
USING ("tenant_id" = current_setting('app.tenant_id', true));
CREATE POLICY tenant_isolation ON "risk_assets"
USING ("tenant_id" = current_setting('app.tenant_id', true));
+83 -3
View File
@@ -60,6 +60,7 @@ model User {
userRoles UserRole[]
ownedAssets Asset[] @relation("assetOwner")
ownedProcesses Process[] @relation("processOwner")
ownedRisks Risk[] @relation("riskOwner")
@@unique([tenantId, email])
@@index([tenantId])
@@ -135,6 +136,12 @@ enum ProcessAssetRole {
SECONDARY
}
enum ProcessCategory {
CORE // Kernprozess
MANAGEMENT // Managementprozess
SUPPORT // Unterstützender Prozess
}
model Asset {
id String @id @default(cuid())
tenantId String @map("tenant_id")
@@ -157,6 +164,7 @@ model Asset {
relationsFrom AssetRelation[] @relation("relationFrom")
relationsTo AssetRelation[] @relation("relationTo")
processAssets ProcessAsset[]
riskAssets RiskAsset[]
@@index([tenantId])
@@index([tenantId, type])
@@ -180,11 +188,12 @@ model AssetRelation {
}
model Process {
id String @id @default(cuid())
tenantId String @map("tenant_id")
id String @id @default(cuid())
tenantId String @map("tenant_id")
name String
description String?
ownerId String? @map("owner_id")
category ProcessCategory @default(CORE)
ownerId String? @map("owner_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
createdBy String? @map("created_by")
@@ -192,6 +201,7 @@ model Process {
owner User? @relation("processOwner", fields: [ownerId], references: [id])
processAssets ProcessAsset[]
bia BiaEntry?
risks Risk[]
@@index([tenantId])
@@map("processes")
@@ -236,6 +246,76 @@ model BiaEntry {
@@map("bia_entries")
}
// ── Risikoanalyse (SPEC §4.2) ───────────────────────────────────────────────
enum RiskTreatment {
AVOID // Vermeiden
MITIGATE // Vermindern
TRANSFER // Übertragen
ACCEPT // Akzeptieren
}
enum RiskStatus {
OPEN
IN_TREATMENT
ACCEPTED
CLOSED
}
model Risk {
id String @id @default(cuid())
tenantId String @map("tenant_id")
// Laufende Nummer je Mandant → Anzeige als "R-001"
refNo Int @map("ref_no")
title String
description String?
threat String? // Bedrohung (Katalog folgt, SPEC §5)
vulnerability String? // Schwachstelle
// Brutto-Bewertung: Eintrittswahrscheinlichkeit × Auswirkung (je 15)
likelihood Int @default(1)
impact Int @default(1)
score Int @default(1) // redundant für Listen/Heatmap
// Rest-Risiko nach Maßnahmen
residualLikelihood Int? @map("residual_likelihood")
residualImpact Int? @map("residual_impact")
residualScore Int? @map("residual_score")
treatment RiskTreatment @default(MITIGATE)
status RiskStatus @default(OPEN)
ownerId String? @map("owner_id")
processId String? @map("process_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
createdBy String? @map("created_by")
owner User? @relation("riskOwner", fields: [ownerId], references: [id])
process Process? @relation(fields: [processId], references: [id])
riskAssets RiskAsset[]
@@unique([tenantId, refNo])
@@index([tenantId])
@@index([tenantId, status])
@@map("risks")
}
model RiskAsset {
id String @id @default(cuid())
tenantId String @map("tenant_id")
riskId String @map("risk_id")
assetId String @map("asset_id")
risk Risk @relation(fields: [riskId], references: [id], onDelete: Cascade)
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
@@unique([riskId, assetId])
@@index([tenantId])
@@map("risk_assets")
}
model AuditLog {
id String @id @default(cuid())
tenantId String @map("tenant_id")
+108
View File
@@ -138,6 +138,7 @@ async function main() {
tenantId: tenant.id,
name: "Auftragsabwicklung",
description: "Vom Kundenauftrag bis zur Auslieferung.",
category: "CORE",
ownerId: owner.id,
},
});
@@ -169,6 +170,7 @@ async function main() {
tenantId: tenant.id,
name: "Kundenbetreuung",
description: "Anfragen, Angebote, Support.",
category: "SUPPORT",
ownerId: isb.id,
},
});
@@ -195,6 +197,112 @@ async function main() {
console.log("✔ Beispiel-Assets, -Prozesse und BIA angelegt");
}
// 6. Beispiel-Risiken (nur wenn noch keine existieren)
const riskCount = await prisma.risk.count({ where: { tenantId: tenant.id } });
if (riskCount === 0) {
const isb = await prisma.user.findUniqueOrThrow({
where: { tenantId_email: { tenantId: tenant.id, email: "admin@demo.example" } },
});
const byName = async (name: string) =>
prisma.asset.findFirstOrThrow({ where: { tenantId: tenant.id, name } });
const erp = await byName("ERP-System");
const crm = await byName("Kundendatenbank");
const hoster = await byName("Cloud-Hoster (IaaS)");
const auftrag = await prisma.process.findFirstOrThrow({
where: { tenantId: tenant.id, name: "Auftragsabwicklung" },
});
const risks: {
title: string;
threat: string;
vulnerability: string;
likelihood: number;
impact: number;
residual?: [number, number];
treatment: "AVOID" | "MITIGATE" | "TRANSFER" | "ACCEPT";
status: "OPEN" | "IN_TREATMENT" | "ACCEPTED" | "CLOSED";
assets: string[];
processId?: string;
description?: string;
}[] = [
{
title: "Ransomware auf ERP",
threat: "Schadsoftware / Verschlüsselungstrojaner",
vulnerability: "Fehlende Offline-Backups, Makro-Ausführung erlaubt",
likelihood: 4,
impact: 5,
residual: [2, 4],
treatment: "MITIGATE",
status: "IN_TREATMENT",
assets: [erp.id, crm.id],
processId: auftrag.id,
description: "Verschlüsselung der ERP-Datenbank würde die Auftragsabwicklung stoppen.",
},
{
title: "Ausfall Cloud-Hoster",
threat: "Ausfall des Rechenzentrums / Insolvenz Dienstleister",
vulnerability: "Kein Ausweich-Standort, Single Provider",
likelihood: 4,
impact: 4,
treatment: "TRANSFER",
status: "OPEN",
assets: [hoster.id, erp.id],
processId: auftrag.id,
},
{
title: "Phishing / Social Engineering",
threat: "Gezielte Phishing-Kampagnen",
vulnerability: "Fehlende Awareness-Schulungen",
likelihood: 3,
impact: 3,
residual: [2, 3],
treatment: "MITIGATE",
status: "IN_TREATMENT",
assets: [crm.id],
},
{
title: "Verlust mobiler Geräte",
threat: "Diebstahl/Verlust von Notebooks",
vulnerability: "Unvollständige Festplattenverschlüsselung",
likelihood: 2,
impact: 3,
treatment: "ACCEPT",
status: "ACCEPTED",
assets: [],
},
];
let refNo = 1;
for (const r of risks) {
const risk = await prisma.risk.create({
data: {
tenantId: tenant.id,
refNo: refNo++,
title: r.title,
description: r.description,
threat: r.threat,
vulnerability: r.vulnerability,
likelihood: r.likelihood,
impact: r.impact,
score: r.likelihood * r.impact,
residualLikelihood: r.residual?.[0],
residualImpact: r.residual?.[1],
residualScore: r.residual ? r.residual[0] * r.residual[1] : undefined,
treatment: r.treatment,
status: r.status,
ownerId: isb.id,
processId: r.processId,
},
});
for (const assetId of r.assets) {
await prisma.riskAsset.create({
data: { tenantId: tenant.id, riskId: risk.id, assetId },
});
}
}
console.log(`${risks.length} Beispiel-Risiken angelegt`);
}
}
main()