Iteration 4 (Maßnahmen) + Risikoanalyse nachgeschärft
Risikoanalyse:
- Risiko direkt aus dem Asset-Popup erstellbar ("+ Risiko erstellen"
→ Anlege-Popup mit vorverknüpftem Asset)
- Brutto-/Rest-Risiko detailliert: Eintrittswahrscheinlichkeit und
Schaden als beschriftete Werte (X × Y = Score-Pille)
- Rest-Risiko wird NICHT mehr manuell erfasst, sondern automatisch aus
den verknüpften Maßnahmen berechnet (Minderung je Dimension, Summe,
Untergrenze 1; src/server/risk-calc.ts); Neuberechnung bei jeder
Änderung an Bewertung oder Maßnahmen-Verknüpfungen
- Risikoregister unterhalb der Heatmap in voller Breite
Maßnahmen-Modul (SPEC §4.4):
- Measure/RiskMeasure-Schema mit RLS, laufende Nummer (M-001),
Status/Priorität/Owner/Fälligkeit
- Kanban-Board mit Drag-and-Drop (dnd-kit): Karten zwischen Offen /
In Umsetzung / Erledigt verschieben aktualisiert den Status per
Server-Action inkl. Audit-Log; überfällige Karten rot markiert
- Maßnahmen-Popups (Detail read-only / Bearbeiten / Anlegen) nach
App-Muster; Detail zeigt verknüpfte Risiken mit Minderung
- Im Risiko-Bearbeiten: bestehende Maßnahme verknüpfen ODER neue
Maßnahme direkt anlegen & verknüpfen (jeweils mit Minderung
Wahrscheinlichkeit/Schaden); "Notwendige Maßnahmen" im Risiko-Detail
jetzt echt
- Seed: 4 Beispiel-Maßnahmen, Rest-Risiken daraus berechnet
Verifiziert im Browser: DnD-Statuswechsel (beide Richtungen, Audit-
Einträge), Rest-Risiko-Berechnung (R-001: 4×5=20 → 2×4=8), Risiko-
Anlage aus dem Asset inkl. automatischer Verknüpfung.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "MeasureStatus" AS ENUM ('OPEN', 'IN_PROGRESS', 'DONE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "MeasurePriority" AS ENUM ('LOW', 'MEDIUM', 'HIGH');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "measures" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"ref_no" INTEGER NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"status" "MeasureStatus" NOT NULL DEFAULT 'OPEN',
|
||||
"priority" "MeasurePriority" NOT NULL DEFAULT 'MEDIUM',
|
||||
"owner_id" TEXT,
|
||||
"due_date" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_by" TEXT,
|
||||
|
||||
CONSTRAINT "measures_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "risk_measures" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"risk_id" TEXT NOT NULL,
|
||||
"measure_id" TEXT NOT NULL,
|
||||
"reduction_likelihood" INTEGER NOT NULL DEFAULT 0,
|
||||
"reduction_impact" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "risk_measures_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "measures_tenant_id_idx" ON "measures"("tenant_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "measures_tenant_id_status_idx" ON "measures"("tenant_id", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "measures_tenant_id_ref_no_key" ON "measures"("tenant_id", "ref_no");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "risk_measures_tenant_id_idx" ON "risk_measures"("tenant_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "risk_measures_risk_id_measure_id_key" ON "risk_measures"("risk_id", "measure_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "measures" ADD CONSTRAINT "measures_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "risk_measures" ADD CONSTRAINT "risk_measures_risk_id_fkey" FOREIGN KEY ("risk_id") REFERENCES "risks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "risk_measures" ADD CONSTRAINT "risk_measures_measure_id_fkey" FOREIGN KEY ("measure_id") REFERENCES "measures"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- RLS-Policies für die Maßnahmen-Tabellen
|
||||
|
||||
ALTER TABLE "measures" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "risk_measures" ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation ON "measures"
|
||||
USING ("tenant_id" = current_setting('app.tenant_id', true));
|
||||
CREATE POLICY tenant_isolation ON "risk_measures"
|
||||
USING ("tenant_id" = current_setting('app.tenant_id', true));
|
||||
+65
-3
@@ -61,6 +61,7 @@ model User {
|
||||
ownedAssets Asset[] @relation("assetOwner")
|
||||
ownedProcesses Process[] @relation("processOwner")
|
||||
ownedRisks Risk[] @relation("riskOwner")
|
||||
ownedMeasures Measure[] @relation("measureOwner")
|
||||
|
||||
@@unique([tenantId, email])
|
||||
@@index([tenantId])
|
||||
@@ -292,9 +293,10 @@ model Risk {
|
||||
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[]
|
||||
owner User? @relation("riskOwner", fields: [ownerId], references: [id])
|
||||
process Process? @relation(fields: [processId], references: [id])
|
||||
riskAssets RiskAsset[]
|
||||
riskMeasures RiskMeasure[]
|
||||
|
||||
@@unique([tenantId, refNo])
|
||||
@@index([tenantId])
|
||||
@@ -316,6 +318,66 @@ model RiskAsset {
|
||||
@@map("risk_assets")
|
||||
}
|
||||
|
||||
// ── Maßnahmen (SPEC §4.4) ───────────────────────────────────────────────────
|
||||
|
||||
enum MeasureStatus {
|
||||
OPEN
|
||||
IN_PROGRESS
|
||||
DONE
|
||||
}
|
||||
|
||||
enum MeasurePriority {
|
||||
LOW
|
||||
MEDIUM
|
||||
HIGH
|
||||
}
|
||||
|
||||
model Measure {
|
||||
id String @id @default(cuid())
|
||||
tenantId String @map("tenant_id")
|
||||
refNo Int @map("ref_no") // Anzeige als "M-001"
|
||||
|
||||
title String
|
||||
description String?
|
||||
status MeasureStatus @default(OPEN)
|
||||
priority MeasurePriority @default(MEDIUM)
|
||||
ownerId String? @map("owner_id")
|
||||
dueDate DateTime? @map("due_date")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
createdBy String? @map("created_by")
|
||||
|
||||
owner User? @relation("measureOwner", fields: [ownerId], references: [id])
|
||||
riskMeasures RiskMeasure[]
|
||||
|
||||
@@unique([tenantId, refNo])
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, status])
|
||||
@@map("measures")
|
||||
}
|
||||
|
||||
// Verknüpfung Risiko ↔ Maßnahme inkl. erwarteter Risikominderung:
|
||||
// Das Rest-Risiko wird aus likelihood/impact minus der Summe der
|
||||
// Minderungen aller verknüpften Maßnahmen berechnet (siehe src/server/risk-calc.ts).
|
||||
model RiskMeasure {
|
||||
id String @id @default(cuid())
|
||||
tenantId String @map("tenant_id")
|
||||
riskId String @map("risk_id")
|
||||
measureId String @map("measure_id")
|
||||
|
||||
// Minderung in Stufen (0–4) je Dimension
|
||||
reductionLikelihood Int @default(0) @map("reduction_likelihood")
|
||||
reductionImpact Int @default(0) @map("reduction_impact")
|
||||
|
||||
risk Risk @relation(fields: [riskId], references: [id], onDelete: Cascade)
|
||||
measure Measure @relation(fields: [measureId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([riskId, measureId])
|
||||
@@index([tenantId])
|
||||
@@map("risk_measures")
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
tenantId String @map("tenant_id")
|
||||
|
||||
+104
@@ -303,6 +303,110 @@ async function main() {
|
||||
}
|
||||
console.log(`✔ ${risks.length} Beispiel-Risiken angelegt`);
|
||||
}
|
||||
|
||||
// 7. Beispiel-Maßnahmen (nur wenn noch keine existieren) — bestimmen das Rest-Risiko
|
||||
const measureCount = await prisma.measure.count({ where: { tenantId: tenant.id } });
|
||||
if (measureCount === 0) {
|
||||
const owner = await prisma.user.findUniqueOrThrow({
|
||||
where: { tenantId_email: { tenantId: tenant.id, email: "owner@demo.example" } },
|
||||
});
|
||||
const riskByTitle = (title: string) =>
|
||||
prisma.risk.findFirst({ where: { tenantId: tenant.id, title } });
|
||||
|
||||
const measures: {
|
||||
title: string;
|
||||
status: "OPEN" | "IN_PROGRESS" | "DONE";
|
||||
priority: "LOW" | "MEDIUM" | "HIGH";
|
||||
dueInDays?: number;
|
||||
riskTitle?: string;
|
||||
reduction?: [number, number]; // [Wahrscheinlichkeit, Schaden]
|
||||
}[] = [
|
||||
{
|
||||
title: "Offline-Backups einführen (3-2-1-Regel)",
|
||||
status: "IN_PROGRESS",
|
||||
priority: "HIGH",
|
||||
dueInDays: 14,
|
||||
riskTitle: "Ransomware auf ERP",
|
||||
reduction: [1, 1],
|
||||
},
|
||||
{
|
||||
title: "Makro-Ausführung per GPO einschränken",
|
||||
status: "OPEN",
|
||||
priority: "HIGH",
|
||||
dueInDays: 30,
|
||||
riskTitle: "Ransomware auf ERP",
|
||||
reduction: [1, 0],
|
||||
},
|
||||
{
|
||||
title: "Awareness-Schulung Phishing (alle Mitarbeitenden)",
|
||||
status: "IN_PROGRESS",
|
||||
priority: "MEDIUM",
|
||||
dueInDays: 45,
|
||||
riskTitle: "Phishing / Social Engineering",
|
||||
reduction: [1, 0],
|
||||
},
|
||||
{
|
||||
title: "Notfallhandbuch aktualisieren",
|
||||
status: "DONE",
|
||||
priority: "MEDIUM",
|
||||
},
|
||||
];
|
||||
|
||||
let mRef = 1;
|
||||
for (const m of measures) {
|
||||
const measure = await prisma.measure.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
refNo: mRef++,
|
||||
title: m.title,
|
||||
status: m.status,
|
||||
priority: m.priority,
|
||||
ownerId: owner.id,
|
||||
dueDate: m.dueInDays
|
||||
? new Date(Date.now() + m.dueInDays * 24 * 3600 * 1000)
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
if (m.riskTitle && m.reduction) {
|
||||
const risk = await riskByTitle(m.riskTitle);
|
||||
if (risk) {
|
||||
await prisma.riskMeasure.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
riskId: risk.id,
|
||||
measureId: measure.id,
|
||||
reductionLikelihood: m.reduction[0],
|
||||
reductionImpact: m.reduction[1],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rest-Risiken aus den Maßnahmen ableiten (manuelle Alt-Werte ersetzen)
|
||||
const allRisks = await prisma.risk.findMany({
|
||||
where: { tenantId: tenant.id },
|
||||
include: { riskMeasures: true },
|
||||
});
|
||||
for (const r of allRisks) {
|
||||
if (r.riskMeasures.length === 0) {
|
||||
await prisma.risk.update({
|
||||
where: { id: r.id },
|
||||
data: { residualLikelihood: null, residualImpact: null, residualScore: null },
|
||||
});
|
||||
} else {
|
||||
const redL = r.riskMeasures.reduce((s, x) => s + x.reductionLikelihood, 0);
|
||||
const redI = r.riskMeasures.reduce((s, x) => s + x.reductionImpact, 0);
|
||||
const rl = Math.max(1, r.likelihood - redL);
|
||||
const ri = Math.max(1, r.impact - redI);
|
||||
await prisma.risk.update({
|
||||
where: { id: r.id },
|
||||
data: { residualLikelihood: rl, residualImpact: ri, residualScore: rl * ri },
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log(`✔ ${measures.length} Beispiel-Maßnahmen angelegt, Rest-Risiken berechnet`);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user