Richtlinien Phase 2a: verwaltete Register-Tabellen + Anwender-Handbuch (§7b, §9.7)

Vier neue, in die Bibliothek integrierte Dokumente (öffnen als interaktive Popups):

- Krypto-Register (VA-07): editierbare Tabelle der eingesetzten Verschlüsselung
  mit Ablaufüberwachung (läuft-ab/abgelaufen-Markierung), Baseline-Bezug
  (BL-CRY-…), voller CRUD (add/edit/delete).
- Risiko-Bewertungsmatrix (R03/VA-09): zentrale Pflegestelle mit FB-80-04-Defaults
  — 4×4-Heatmap (Schadensausmaß × EW), Risikoklassen + Akzeptanzinstanz
  (Niedrig≤3 / Mittel≤6 / Hoch≤9 / Kritisch≥12, editierbar), EW-Skala,
  7 Schadenskategorien. (Rückkopplung in die 5×5-Heatmap der Risikoanalyse folgt.)
- Klassifizierungs-Handhabungsmatrix (R02/VA-08): AA-80-20-Defaults, 4 Schutzklassen
  × 14 Aspekte mit eskalierenden Regeln; Zellen inline editierbar, Aspekt/Klasse
  hinzufügbar.
- Anwender-Handbuch (§9.7): kuratierte Themen (Passwörter/MFA, Zugriffsrechte,
  Klassifizierung, mobiles Arbeiten, E-Mail, Vorfälle) in verständlicher Sprache;
  Werte via {{VARIABLE}}-Templating → automatisch synchron zur Baseline; Deep-Links
  in die Quellabschnitte (z. B. R08 §4.1.2).

Zusätzlich: Richtlinien/Verfahren, die eine verwaltete Tabelle einbetten
(R09/VA-07, R03/VA-09, R02/VA-08), zeigen im Lesemodus einen „Vollständige Tabelle
öffnen"-Callout auf das jeweilige Register (§7b).

Datenmodell: CryptoEntry, ClassificationClass/HandlingAspect/HandlingRule,
RiskMatrixClass/RiskEwLevel/RiskDamageDimension, HandbookTopic (+ RLS).
Server-Actions mit RBAC (policy:write) + Audit-Log. Seed aus FB-80-04/AA-80-20.

Verifiziert im Browser: alle vier Register gerendert, Ablauf-/Farb-/Deep-Link-Logik,
Krypto-CRUD (add + delete) end-to-end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 15:55:46 +02:00
co-authored by Claude Opus 4.8
parent 89b0e3a5a5
commit e2b6dee788
11 changed files with 1056 additions and 17 deletions
+2 -1
View File
@@ -536,6 +536,7 @@
"kpiControls": "Abgedeckte Controls", "kpiControls": "Abgedeckte Controls",
"kpiControlsTrend": "VDA-ISA 2027", "kpiControlsTrend": "VDA-ISA 2027",
"kpiMust": "MUSS-Anforderungen", "kpiMust": "MUSS-Anforderungen",
"kpiShould": "SOLL-Anforderungen" "kpiShould": "SOLL-Anforderungen",
"openFullTable": "Vollständige Tabelle öffnen"
} }
} }
+2 -1
View File
@@ -536,6 +536,7 @@
"kpiControls": "Covered controls", "kpiControls": "Covered controls",
"kpiControlsTrend": "VDA-ISA 2027", "kpiControlsTrend": "VDA-ISA 2027",
"kpiMust": "MUST requirements", "kpiMust": "MUST requirements",
"kpiShould": "SHOULD requirements" "kpiShould": "SHOULD requirements",
"openFullTable": "Open full table"
} }
} }
+171
View File
@@ -0,0 +1,171 @@
import type { PrismaClient } from "@prisma/client";
/**
* Seed der verwalteten Register-Tabellen (§7b) und des Anwender-Handbuchs (§9.7).
* Defaults aus den Auftraggeber-Vorgaben FB-80-04 (Risikomatrix) und
* AA-80-20 (Klassifizierung/Handhabung). Idempotent pro Mandant.
*/
const NOW = "2026-07-07T00:00:00.000Z";
const daysFrom = (base: string, d: number) => new Date(new Date(base).getTime() + d * 86400000);
// Klassifizierungs-Handhabungsmatrix (AA-80-20) — Reihenfolge: Offen · Intern · Vertraulich · Streng vertraulich
const CLASSES = [
{ name: "Offen", description: "Öffentlich; keine Schutzanforderungen." },
{ name: "Intern", description: "Nur für Mitarbeitende; geringer Schutzbedarf." },
{ name: "Vertraulich", description: "Begrenzter Personenkreis; hoher Schutzbedarf." },
{ name: "Streng vertraulich", description: "Namentlich Berechtigte; sehr hoher Schutzbedarf." },
];
// [Offen, Intern, Vertraulich, Streng vertraulich]
const ASPECTS: { name: string; category: string; rules: [string, string, string, string] }[] = [
{ name: "Kennzeichnung (Papier/elektronisch)", category: "Kennzeichnung", rules: ["nicht nötig", "„Intern“ empfohlen", "„Vertraulich“ verpflichtend", "„Streng vertraulich“ + Bearbeiter"] },
{ name: "Vervielfältigung", category: "Handhabung", rules: ["frei", "im Bedarfsfall", "nur mit Freigabe", "nur mit Freigabe, protokolliert"] },
{ name: "Weitergabe intern", category: "Weitergabe", rules: ["frei", "an Mitarbeitende", "an Berechtigte, need-to-know", "namentlich Berechtigte, dokumentiert"] },
{ name: "Postweg / Versand", category: "Weitergabe", rules: ["Standard", "Standard", "verschlossen, persönlich/Einschreiben", "Einschreiben eigenhändig, Empfangsbestätigung"] },
{ name: "E-Mail", category: "Weitergabe", rules: ["ohne Auflagen", "intern ohne Auflagen", "extern verschlüsselt (BL-CRY-04)", "immer verschlüsselt, nur an Berechtigte"] },
{ name: "Fax intern/extern", category: "Weitergabe", rules: ["zulässig", "zulässig", "nur mit Ankündigung/Abholung", "unzulässig"] },
{ name: "Mobile Datenträger", category: "Speicherung", rules: ["zulässig", "nur freigegebene", "nur verschlüsselt (BL-CRY-03)", "nur verschlüsselt, freigegeben, protokolliert"] },
{ name: "Internet / Blogs / Social Media", category: "Weitergabe", rules: ["zulässig", "kein interner Bezug", "unzulässig", "unzulässig"] },
{ name: "Entsorgung Papier", category: "Entsorgung", rules: ["Altpapier", "Datentonne", "Aktenvernichter ≥ P-4", "Aktenvernichter ≥ P-5, protokolliert"] },
{ name: "Löschung elektronisch / Hardware", category: "Entsorgung", rules: ["normal löschen", "normal löschen", "sicheres Löschen (BL-DEL-01)", "sicheres Löschen/Vernichten, Nachweis"] },
{ name: "Speicherung (IT-Systeme)", category: "Speicherung", rules: ["beliebig", "interne Systeme", "zugriffsbeschränkte Ablagen", "verschlüsselt, streng zugriffsbeschränkt"] },
{ name: "Homeoffice / mobiles Arbeiten", category: "Nutzung", rules: ["zulässig", "zulässig", "nur mit Sichtschutz, gesperrt bei Abwesenheit", "nur nach Freigabe, keine Papierform"] },
{ name: "Öffentlicher Bereich / Dienstreise", category: "Nutzung", rules: ["zulässig", "diskret", "Sichtschutz, nicht unbeaufsichtigt", "unzulässig (kritische Länder)"] },
{ name: "Verbale Weitergabe", category: "Weitergabe", rules: ["frei", "unter Kollegen", "nur in geschützter Umgebung", "nur namentlich Berechtigte, abhörsicher"] },
];
// Risiko-Bewertungsmatrix (FB-80-04): Schadensausmaß(14) × EW(14) = 116
const RISK_CLASSES = [
{ name: "Niedrig", maxScore: 3, acceptance: "wird akzeptiert", tone: "ok" },
{ name: "Mittel", maxScore: 6, acceptance: "Maßnahmen; Akzeptanz durch Risk Owner", tone: "warn" },
{ name: "Hoch", maxScore: 9, acceptance: "Akzeptanz durch Risikomanagement-Gremium", tone: "orange" },
{ name: "Kritisch", maxScore: 16, acceptance: "Akzeptanz durch Geschäftsführung", tone: "risk" },
];
const EW_LEVELS = [
{ level: 1, label: "Unwahrscheinlich", definition: "Ereignis in absehbarer Zeit kaum zu erwarten (seltener als alle 5 Jahre)." },
{ level: 2, label: "Gelegentlich", definition: "Ereignis denkbar, aber nicht häufig (ca. alle 15 Jahre)." },
{ level: 3, label: "Wahrscheinlich", definition: "Ereignis tritt voraussichtlich ein (mehrmals pro Jahr)." },
{ level: 4, label: "Sehr wahrscheinlich", definition: "Ereignis tritt regelmäßig/häufig ein (monatlich oder öfter)." },
];
const DAMAGE_DIMS: { name: string; levels: Record<string, string> }[] = [
{ name: "Gesetzes-/Vertragsverstöße", levels: { "1": "unerheblich", "2": "geringfügige Verstöße", "3": "erhebliche Verstöße/Bußgelder", "4": "gravierende Rechtsfolgen/Straftatbestand" } },
{ name: "Datenschutz", levels: { "1": "kein Personenbezug", "2": "geringe Betroffenheit", "3": "sensible Daten betroffen", "4": "massenhaft sensible Daten / hohe Betroffenheit" } },
{ name: "Persönliche Unversehrtheit", levels: { "1": "keine", "2": "leichte Beeinträchtigung", "3": "Gesundheitsgefahr", "4": "Gefahr für Leib und Leben" } },
{ name: "Aufgabenerfüllung", levels: { "1": "keine Einschränkung", "2": "geringe Einschränkung", "3": "erhebliche Einschränkung", "4": "Kernaufgaben nicht erfüllbar" } },
{ name: "Innen-/Außenwirkung (Reputation)", levels: { "1": "keine", "2": "intern begrenzt", "3": "öffentlich wahrnehmbar", "4": "nachhaltiger Reputationsschaden" } },
{ name: "Störungs-/Ausfallzeit", levels: { "1": "< 1 Std.", "2": "bis 1 Tag", "3": "bis 1 Woche", "4": "> 1 Woche" } },
{ name: "Finanzielle Auswirkungen", levels: { "1": "bis 10 T€", "2": "bis 50 T€", "3": "bis 200 T€", "4": "über 500 T€" } },
];
// Krypto-Register (VA-07) — Demo-Datensätze inkl. Ablaufüberwachung
const CRYPTO = [
{ dienst: "TLS Web-Portal (portal.gefim.example)", schluessel: "RSA 3072 · Let's Encrypt", algorithmus: "TLS 1.3 / AES-256", ablaufOffset: 45, verantwortlich: "IT-Leitung", speicherort: "Reverse Proxy", baselineRef: "BL-CRY-01" },
{ dienst: "VPN-Gateway", schluessel: "ECDSA P-256 · interne CA", algorithmus: "IKEv2 / AES-256-GCM", ablaufOffset: 12, verantwortlich: "IT-Leitung", speicherort: "Firewall/HSM", baselineRef: "BL-CRY-02" },
{ dienst: "Festplattenverschlüsselung Notebooks", schluessel: "BitLocker · TPM 2.0", algorithmus: "AES-256-XTS", ablaufOffset: null, verantwortlich: "IT-Betrieb", speicherort: "MDM-Recovery", baselineRef: "BL-CRY-03" },
{ dienst: "S/MIME Zertifikate Geschäftsführung", schluessel: "RSA 2048 · öffentliche CA", algorithmus: "S/MIME", ablaufOffset: -8, verantwortlich: "IT-Leitung", speicherort: "Smartcard", baselineRef: "BL-CRY-04" },
];
// Anwender-Handbuch (§9.7) — kuratierte Themen, Werte via {{VARIABLE}} synchron zur Baseline
const HANDBOOK: { category: string; title: string; bodyMd: string; sourceRefs: string[] }[] = [
{
category: "Zugang & Passwörter", title: "Passwörter & Mehr-Faktor-Anmeldung",
bodyMd: "**Das Wichtigste kurz:**\n\n- Nutze Passwörter mit **mindestens {{PW_MIN_LENGTH}} Zeichen** ({{PW_COMPLEXITY}}).\n- Aktiviere **Mehr-Faktor-Authentifizierung (MFA)** über {{TECH_MFA}} für {{MFA_SCOPE}}.\n- Gib Passwörter **nie** weiter und nutze für jeden Dienst ein eigenes.\n- Bei Verdacht auf Kompromittierung: Passwort sofort ändern und melden.",
sourceRefs: ["R08#4.1.2"],
},
{
category: "Zugang & Passwörter", title: "Zugriffsrechte nur was du brauchst",
bodyMd: "- Zugriff erhältst du **nach dem Minimalprinzip** (need-to-know) über {{TOOL_TICKET}}.\n- Nicht mehr benötigte Rechte werden entzogen; deine Rechte werden regelmäßig **überprüft (Rezertifizierung, {{RECERT_FREQ}})**.\n- Sperre deinen Bildschirm bei jedem Verlassen des Arbeitsplatzes.",
sourceRefs: ["R08#4.2.1"],
},
{
category: "Umgang mit Informationen", title: "Daten richtig klassifizieren & handhaben",
bodyMd: "- Behandle Informationen gemäß ihrer **Schutzklasse** (Offen · Intern · Vertraulich · Streng vertraulich).\n- **Vertrauliche** Inhalte extern nur **verschlüsselt** versenden.\n- Dokumente nicht offen liegen lassen (Clean Desk); vertrauliche Ausdrucke sicher vernichten.",
sourceRefs: ["R02"],
},
{
category: "Sicheres Arbeiten", title: "Mobiles Arbeiten & Homeoffice",
bodyMd: "- Nutze nur **freigegebene Geräte** (verwaltet über {{TECH_MDM}}).\n- Achte auf **Sichtschutz** in der Öffentlichkeit; keine vertraulichen Gespräche in Bahn/Café.\n- Verbinde dich extern nur über {{TECH_VPN}} mit MFA.",
sourceRefs: ["R06"],
},
{
category: "Sicheres Arbeiten", title: "E-Mail, Internet & Schadsoftware",
bodyMd: "- Öffne **keine unerwarteten Anhänge/Links**; im Zweifel nachfragen.\n- Melde verdächtige E-Mails (Phishing) über den Meldeweg.\n- {{TECH_MALWARE}} schützt deine Geräte deaktiviere den Schutz nie.",
sourceRefs: ["R10", "R09"],
},
{
category: "Vorfälle", title: "Sicherheitsvorfall? So meldest du ihn",
bodyMd: "**Sofort melden** bei Verdacht auf Vorfall, Datenverlust oder verlorenem Gerät:\n\n- an die {{ROLE_ISB}} bzw. den definierten Meldeweg.\n- Lieber einmal zu viel melden schnelle Meldung begrenzt den Schaden.\n- Nichts vertuschen, nichts eigenmächtig „reparieren“.",
sourceRefs: ["R04"],
},
];
export async function importManaged(prisma: PrismaClient, tenantId: string) {
// Idempotent: alte Datensätze pro Mandant entfernen
await prisma.handlingRule.deleteMany({ where: { tenantId } });
await prisma.handlingAspect.deleteMany({ where: { tenantId } });
await prisma.classificationClass.deleteMany({ where: { tenantId } });
await prisma.cryptoEntry.deleteMany({ where: { tenantId } });
await prisma.riskMatrixClass.deleteMany({ where: { tenantId } });
await prisma.riskEwLevel.deleteMany({ where: { tenantId } });
await prisma.riskDamageDimension.deleteMany({ where: { tenantId } });
await prisma.handbookTopic.deleteMany({ where: { tenantId } });
// Register-/Handbuch-Dokumente in der Bibliothek (interaktiv gerendert)
const registerDocs = [
{ code: "CRYPTO", type: "REGISTER" as const, title: "Verschlüsselungsmechanismen-Register", orderIdx: 902, intro: "Verwaltetes Register der eingesetzten Verschlüsselung (VA-07). Ablaufüberwachung aktiv." },
{ code: "RISKMATRIX", type: "REGISTER" as const, title: "Risiko-Bewertungsmatrix", orderIdx: 903, intro: "Zentrale Bewertungsmatrix Schadensausmaß × Eintrittswahrscheinlichkeit (R03 / VA-09), Defaults nach FB-80-04." },
{ code: "CLASSIFICATION", type: "REGISTER" as const, title: "Klassifizierung & Handhabungsmatrix", orderIdx: 904, intro: "Schutzklassen und Handhabungsregeln (R02 / VA-08), Defaults nach AA-80-20." },
{ code: "HANDBUCH", type: "HANDBUCH" as const, title: "Anwender-Handbuch Informationssicherheit", orderIdx: 950, intro: "Kompakte Zusammenfassung der wichtigsten Regeln für Mitarbeitende mit Deep-Links in die Quelldokumente." },
];
for (const d of registerDocs) {
await prisma.policyDocument.upsert({
where: { tenantId_code: { tenantId, code: d.code } },
update: { title: d.title, type: d.type, orderIdx: d.orderIdx, rawMarkdown: `# ${d.title}\n\n${d.intro}` },
create: { tenantId, code: d.code, type: d.type, title: d.title, version: "1.0", status: "FREIGEGEBEN", orderIdx: d.orderIdx, rawMarkdown: `# ${d.title}\n\n${d.intro}` },
});
}
// Krypto-Register
for (let i = 0; i < CRYPTO.length; i++) {
const c = CRYPTO[i];
await prisma.cryptoEntry.create({
data: {
tenantId, dienst: c.dienst, schluessel: c.schluessel, algorithmus: c.algorithmus,
ablaufdatum: c.ablaufOffset === null ? null : daysFrom(NOW, c.ablaufOffset),
verantwortlich: c.verantwortlich, speicherort: c.speicherort, baselineRef: c.baselineRef, orderIdx: i,
},
});
}
// Klassifizierung + Handhabungsmatrix
const classIds: string[] = [];
for (let i = 0; i < CLASSES.length; i++) {
const cl = await prisma.classificationClass.create({ data: { tenantId, name: CLASSES[i].name, description: CLASSES[i].description, orderIdx: i } });
classIds.push(cl.id);
}
for (let a = 0; a < ASPECTS.length; a++) {
const asp = await prisma.handlingAspect.create({ data: { tenantId, name: ASPECTS[a].name, category: ASPECTS[a].category, orderIdx: a } });
for (let c = 0; c < classIds.length; c++) {
await prisma.handlingRule.create({ data: { tenantId, classId: classIds[c], aspectId: asp.id, text: ASPECTS[a].rules[c] } });
}
}
// Risiko-Bewertungsmatrix
for (let i = 0; i < RISK_CLASSES.length; i++) {
const r = RISK_CLASSES[i];
await prisma.riskMatrixClass.create({ data: { tenantId, name: r.name, maxScore: r.maxScore, acceptance: r.acceptance, tone: r.tone, orderIdx: i } });
}
for (const ew of EW_LEVELS) await prisma.riskEwLevel.create({ data: { tenantId, level: ew.level, label: ew.label, definition: ew.definition } });
for (let i = 0; i < DAMAGE_DIMS.length; i++) {
await prisma.riskDamageDimension.create({ data: { tenantId, name: DAMAGE_DIMS[i].name, levels: DAMAGE_DIMS[i].levels, orderIdx: i } });
}
// Anwender-Handbuch
for (let i = 0; i < HANDBOOK.length; i++) {
const h = HANDBOOK[i];
await prisma.handbookTopic.create({ data: { tenantId, category: h.category, title: h.title, bodyMd: h.bodyMd, sourceRefs: h.sourceRefs, orderIdx: i } });
}
return { crypto: CRYPTO.length, classes: CLASSES.length, aspects: ASPECTS.length, damage: DAMAGE_DIMS.length, handbook: HANDBOOK.length };
}
@@ -0,0 +1,156 @@
-- CreateTable
CREATE TABLE "crypto_entries" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"dienst" TEXT NOT NULL,
"schluessel" TEXT NOT NULL,
"algorithmus" TEXT,
"ablauf_datum" TIMESTAMP(3),
"verantwortlich" TEXT NOT NULL,
"speicherort" TEXT,
"baseline_ref" TEXT,
"notes" TEXT,
"order_idx" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "crypto_entries_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "classification_classes" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"order_idx" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "classification_classes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "handling_aspects" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"category" TEXT,
"order_idx" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "handling_aspects_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "handling_rules" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"class_id" TEXT NOT NULL,
"aspect_id" TEXT NOT NULL,
"text" TEXT NOT NULL,
CONSTRAINT "handling_rules_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "risk_matrix_classes" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"max_score" INTEGER NOT NULL,
"acceptance" TEXT NOT NULL,
"tone" TEXT NOT NULL,
"order_idx" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "risk_matrix_classes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "risk_ew_levels" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"level" INTEGER NOT NULL,
"label" TEXT NOT NULL,
"definition" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "risk_ew_levels_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "risk_damage_dimensions" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"levels" JSONB NOT NULL,
"order_idx" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "risk_damage_dimensions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "handbook_topics" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"category" TEXT NOT NULL,
"title" TEXT NOT NULL,
"body_md" TEXT NOT NULL,
"source_refs" TEXT[] DEFAULT ARRAY[]::TEXT[],
"order_idx" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "handbook_topics_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "crypto_entries_tenant_id_idx" ON "crypto_entries"("tenant_id");
-- CreateIndex
CREATE INDEX "classification_classes_tenant_id_idx" ON "classification_classes"("tenant_id");
-- CreateIndex
CREATE INDEX "handling_aspects_tenant_id_idx" ON "handling_aspects"("tenant_id");
-- CreateIndex
CREATE INDEX "handling_rules_tenant_id_idx" ON "handling_rules"("tenant_id");
-- CreateIndex
CREATE UNIQUE INDEX "handling_rules_class_id_aspect_id_key" ON "handling_rules"("class_id", "aspect_id");
-- CreateIndex
CREATE INDEX "risk_matrix_classes_tenant_id_idx" ON "risk_matrix_classes"("tenant_id");
-- CreateIndex
CREATE INDEX "risk_ew_levels_tenant_id_idx" ON "risk_ew_levels"("tenant_id");
-- CreateIndex
CREATE INDEX "risk_damage_dimensions_tenant_id_idx" ON "risk_damage_dimensions"("tenant_id");
-- CreateIndex
CREATE INDEX "handbook_topics_tenant_id_idx" ON "handbook_topics"("tenant_id");
-- AddForeignKey
ALTER TABLE "handling_rules" ADD CONSTRAINT "handling_rules_class_id_fkey" FOREIGN KEY ("class_id") REFERENCES "classification_classes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "handling_rules" ADD CONSTRAINT "handling_rules_aspect_id_fkey" FOREIGN KEY ("aspect_id") REFERENCES "handling_aspects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- RLS-Policies für die verwalteten Register-Tabellen
DO $$
DECLARE t text;
BEGIN
FOREACH t IN ARRAY ARRAY[
'crypto_entries','classification_classes','handling_aspects','handling_rules',
'risk_matrix_classes','risk_ew_levels','risk_damage_dimensions','handbook_topics'
] LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t);
EXECUTE format(
'CREATE POLICY tenant_isolation ON %I USING (tenant_id = current_setting(''app.tenant_id'', true))',
t
);
END LOOP;
END $$;
+121
View File
@@ -765,3 +765,124 @@ model PolicyEvidence {
@@index([tenantId]) @@index([tenantId])
@@map("policy_evidence") @@map("policy_evidence")
} }
// ── Verwaltete Register-Tabellen (§7b) ───────────────────────────────────────
// Verschlüsselungsmechanismen-Register (VA-07 Kryptokonzept & Schlüsselverwaltung)
model CryptoEntry {
id String @id @default(cuid())
tenantId String @map("tenant_id")
dienst String
schluessel String
algorithmus String?
ablaufdatum DateTime? @map("ablauf_datum")
verantwortlich String
speicherort String?
baselineRef String? @map("baseline_ref") // z. B. BL-CRY-02
notes String?
orderIdx Int @default(0) @map("order_idx")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([tenantId])
@@map("crypto_entries")
}
// Klassifizierungs-Handhabungsmatrix (R02 / VA-08) — Schutzklassen × Aspekte
model ClassificationClass {
id String @id @default(cuid())
tenantId String @map("tenant_id")
name String
description String?
orderIdx Int @default(0) @map("order_idx")
createdAt DateTime @default(now()) @map("created_at")
rules HandlingRule[]
@@index([tenantId])
@@map("classification_classes")
}
model HandlingAspect {
id String @id @default(cuid())
tenantId String @map("tenant_id")
name String
category String?
orderIdx Int @default(0) @map("order_idx")
createdAt DateTime @default(now()) @map("created_at")
rules HandlingRule[]
@@index([tenantId])
@@map("handling_aspects")
}
model HandlingRule {
id String @id @default(cuid())
tenantId String @map("tenant_id")
classId String @map("class_id")
aspectId String @map("aspect_id")
text String
class ClassificationClass @relation(fields: [classId], references: [id], onDelete: Cascade)
aspect HandlingAspect @relation(fields: [aspectId], references: [id], onDelete: Cascade)
@@unique([classId, aspectId])
@@index([tenantId])
@@map("handling_rules")
}
// Risiko-Bewertungsmatrix (R03 / VA-09) — zentrale Pflegestelle (Defaults FB-80-04)
model RiskMatrixClass {
id String @id @default(cuid())
tenantId String @map("tenant_id")
name String // Niedrig | Mittel | Hoch | Kritisch
maxScore Int @map("max_score") // obere Schwelle des Risikowerts
acceptance String // Akzeptanzinstanz
tone String // ok | warn | orange | risk
orderIdx Int @default(0) @map("order_idx")
createdAt DateTime @default(now()) @map("created_at")
@@index([tenantId])
@@map("risk_matrix_classes")
}
model RiskEwLevel {
id String @id @default(cuid())
tenantId String @map("tenant_id")
level Int
label String
definition String
createdAt DateTime @default(now()) @map("created_at")
@@index([tenantId])
@@map("risk_ew_levels")
}
model RiskDamageDimension {
id String @id @default(cuid())
tenantId String @map("tenant_id")
name String
levels Json // { "1": "…", "2": "…", "3": "…", "4": "…" }
orderIdx Int @default(0) @map("order_idx")
createdAt DateTime @default(now()) @map("created_at")
@@index([tenantId])
@@map("risk_damage_dimensions")
}
// Anwender-Handbuch (§9.7) — kuratierte Themen mit Deep-Links; Werte via Templating
model HandbookTopic {
id String @id @default(cuid())
tenantId String @map("tenant_id")
category String
title String
bodyMd String @map("body_md") // Markdown mit {{VARIABLE}} (bleibt via Baseline synchron)
sourceRefs String[] @default([]) @map("source_refs") // Deep-Links, z. B. R08#4.1.2
orderIdx Int @default(0) @map("order_idx")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([tenantId])
@@map("handbook_topics")
}
+4
View File
@@ -5,6 +5,7 @@ import { hash } from "@node-rs/argon2";
import { join } from "node:path"; import { join } from "node:path";
import { PERMISSIONS, ROLE_DEFS } from "../src/server/rbac"; import { PERMISSIONS, ROLE_DEFS } from "../src/server/rbac";
import { importPolicies } from "./import-policies"; import { importPolicies } from "./import-policies";
import { importManaged } from "./import-managed";
/** /**
* Seed: global permission catalog + demo tenant with roles and users. * Seed: global permission catalog + demo tenant with roles and users.
@@ -522,6 +523,9 @@ async function main() {
await prisma.policyVariable.updateMany({ where: { tenantId: tenant.id, key }, data: { value } }); await prisma.policyVariable.updateMany({ where: { tenantId: tenant.id, key }, data: { value } });
} }
console.log(`✔ Richtlinienpaket importiert: ${pc.documents} Dokumente, ${pc.requirements} Anforderungen, ${pc.variables} Variablen, ${pc.baseline} Baseline-Parameter`); console.log(`✔ Richtlinienpaket importiert: ${pc.documents} Dokumente, ${pc.requirements} Anforderungen, ${pc.variables} Variablen, ${pc.baseline} Baseline-Parameter`);
const mc = await importManaged(prisma, tenant.id);
console.log(`✔ Verwaltete Register: Krypto ${mc.crypto}, Klassifizierung ${mc.classes}×${mc.aspects}, Risikomatrix (${mc.damage} Schadensdim.), Handbuch ${mc.handbook} Themen`);
} }
main() main()
+37 -8
View File
@@ -2,10 +2,11 @@ import Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { requireSession } from "@/server/auth"; import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db"; import { dbForTenant } from "@/server/db";
import { requirePermission } from "@/server/rbac"; import { hasPermission, requirePermission } from "@/server/rbac";
import { PageHead, Pill, Tag, KpiCard } from "@/components/mockup-ui"; import { PageHead, Pill, Tag, KpiCard } from "@/components/mockup-ui";
import { FilterTabs } from "@/components/filter-tabs"; import { FilterTabs } from "@/components/filter-tabs";
import { PolicyReadModal } from "@/components/policy-modals"; import { PolicyReadModal } from "@/components/policy-modals";
import { PolicyRegisterModal } from "@/components/policy-registers";
import { import {
Table, Table,
TableBody, TableBody,
@@ -15,7 +16,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
const DOC_TYPES = ["LEITLINIE", "RICHTLINIE", "VERFAHREN", "REGISTER"] as const; const DOC_TYPES = ["LEITLINIE", "RICHTLINIE", "VERFAHREN", "REGISTER", "HANDBUCH"] as const;
const TYPE_LABEL: Record<string, string> = { const TYPE_LABEL: Record<string, string> = {
LEITLINIE: "Leitlinie", LEITLINIE: "Leitlinie",
RICHTLINIE: "Richtlinie", RICHTLINIE: "Richtlinie",
@@ -62,18 +63,46 @@ export default async function PoliciesPage({
r.vaCodes.forEach((v) => e.vas.add(v)); r.vaCodes.forEach((v) => e.vas.add(v));
} }
/* ── Modal (Lesemodus) ── */ const isCoverage = params.view === "coverage";
const canWrite = hasPermission(session, "policy:write");
const backHref = isCoverage ? "/policies?view=coverage" : "/policies";
/* ── Modal: Lesemodus (Markdown) oder interaktives Register (§7b) ── */
const modalDoc = params.doc ? docs.find((d) => d.code === params.doc) : null; const modalDoc = params.doc ? docs.find((d) => d.code === params.doc) : null;
const modalData = modalDoc const REGISTER_CODES = new Set(["CRYPTO", "RISKMATRIX", "CLASSIFICATION", "HANDBUCH"]);
let modalNode: React.ReactNode = null;
if (modalDoc && REGISTER_CODES.has(modalDoc.code)) {
const registerData =
modalDoc.code === "CRYPTO"
? { crypto: await db.cryptoEntry.findMany({ orderBy: { orderIdx: "asc" } }) }
: modalDoc.code === "CLASSIFICATION"
? { ? {
classes: await db.classificationClass.findMany({ orderBy: { orderIdx: "asc" } }),
aspects: await db.handlingAspect.findMany({ orderBy: { orderIdx: "asc" } }),
rules: await db.handlingRule.findMany(),
}
: modalDoc.code === "RISKMATRIX"
? {
riskClasses: await db.riskMatrixClass.findMany({ orderBy: { orderIdx: "asc" } }),
ewLevels: await db.riskEwLevel.findMany({ orderBy: { level: "asc" } }),
damage: await db.riskDamageDimension.findMany({ orderBy: { orderIdx: "asc" } }),
}
: { handbook: await db.handbookTopic.findMany({ orderBy: { orderIdx: "asc" } }), variables: await db.policyVariable.findMany({ orderBy: { orderIdx: "asc" } }) };
modalNode = <PolicyRegisterModal doc={modalDoc} data={registerData} canWrite={canWrite} backHref={backHref} />;
} else if (modalDoc) {
modalNode = (
<PolicyReadModal
data={{
doc: modalDoc, doc: modalDoc,
variables: await db.policyVariable.findMany({ orderBy: { orderIdx: "asc" } }), variables: await db.policyVariable.findMany({ orderBy: { orderIdx: "asc" } }),
controls: [...(byPolicy.get(modalDoc.code)?.controls ?? [])].sort(), controls: [...(byPolicy.get(modalDoc.code)?.controls ?? [])].sort(),
relatedVas: [...(byPolicy.get(modalDoc.code)?.vas ?? [])].sort(), relatedVas: [...(byPolicy.get(modalDoc.code)?.vas ?? [])].sort(),
}}
backHref={backHref}
/>
);
} }
: null;
const isCoverage = params.view === "coverage";
const head = ( const head = (
<PageHead <PageHead
crumb={t("crumb")} crumb={t("crumb")}
@@ -146,7 +175,7 @@ export default async function PoliciesPage({
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{modalData && <PolicyReadModal data={modalData} backHref="/policies?view=coverage" />} {modalNode}
</main> </main>
); );
} }
@@ -227,7 +256,7 @@ export default async function PoliciesPage({
</Table> </Table>
</div> </div>
{modalData && <PolicyReadModal data={modalData} backHref="/policies" />} {modalNode}
</main> </main>
); );
} }
+23
View File
@@ -27,6 +27,16 @@ const STATUS_LABEL: Record<string, string> = {
ARCHIVIERT: "Archiviert", ARCHIVIERT: "Archiviert",
}; };
// Dokumente, die eine verwaltete Register-Tabelle einbetten (§7b) → „Vollständige Tabelle öffnen"
const EMBEDDED_REGISTER: Record<string, { code: string; label: string }> = {
R09: { code: "CRYPTO", label: "Verschlüsselungsmechanismen-Register" },
"VA-07": { code: "CRYPTO", label: "Verschlüsselungsmechanismen-Register" },
R03: { code: "RISKMATRIX", label: "Risiko-Bewertungsmatrix" },
"VA-09": { code: "RISKMATRIX", label: "Risiko-Bewertungsmatrix" },
R02: { code: "CLASSIFICATION", label: "Klassifizierung & Handhabungsmatrix" },
"VA-08": { code: "CLASSIFICATION", label: "Klassifizierung & Handhabungsmatrix" },
};
export interface PolicyReadData { export interface PolicyReadData {
doc: PolicyDocument; doc: PolicyDocument;
variables: PolicyVariable[]; variables: PolicyVariable[];
@@ -102,6 +112,19 @@ export async function PolicyReadModal({ data, backHref }: { data: PolicyReadData
</details> </details>
)} )}
{/* Eingebettete verwaltete Tabelle (§7b) */}
{EMBEDDED_REGISTER[doc.code] && (
<Link
href={`/policies?doc=${EMBEDDED_REGISTER[doc.code].code}`}
className="mb-4 flex items-center justify-between gap-2 rounded-xl border border-[var(--band-brd)] bg-[var(--band)] px-4 py-2.5 text-[12.5px] text-[var(--band-text)] hover:opacity-90"
>
<span>
<b>Verwaltete Tabelle:</b> {EMBEDDED_REGISTER[doc.code].label}
</span>
<span className="font-semibold">{t("openFullTable")} </span>
</Link>
)}
{/* Gerendertes Dokument */} {/* Gerendertes Dokument */}
<article className="policy-prose" dangerouslySetInnerHTML={{ __html: bodyHtml }} /> <article className="policy-prose" dangerouslySetInnerHTML={{ __html: bodyHtml }} />
</div> </div>
+401
View File
@@ -0,0 +1,401 @@
import Link from "next/link";
import { getFormatter, getTranslations } from "next-intl/server";
import { Pencil, Plus, Trash2 } from "lucide-react";
import type {
ClassificationClass,
CryptoEntry,
HandbookTopic,
HandlingAspect,
HandlingRule,
PolicyDocument,
PolicyVariable,
RiskDamageDimension,
RiskEwLevel,
RiskMatrixClass,
} from "@prisma/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Modal } from "@/components/modal";
import { Pill, Tag } from "@/components/mockup-ui";
import { isExpired, isExpiring } from "@/lib/supplier";
import { buildContext, renderPolicyHtml, resolveLink } from "@/lib/policy-render";
import {
addClassificationClass,
addCryptoEntry,
addHandlingAspect,
deleteCryptoEntry,
updateCryptoEntry,
updateHandlingRule,
updateRiskClass,
} from "@/server/actions/policies";
const TONE_HEX: Record<string, string> = { ok: "#2ea86b", warn: "#e3b427", orange: "#e2802e", risk: "#d63c5e" };
export interface RegisterData {
crypto?: CryptoEntry[];
classes?: ClassificationClass[];
aspects?: HandlingAspect[];
rules?: HandlingRule[];
riskClasses?: RiskMatrixClass[];
ewLevels?: RiskEwLevel[];
damage?: RiskDamageDimension[];
handbook?: HandbookTopic[];
variables?: PolicyVariable[];
}
/** Popup für die verwalteten Register (§7b) und das Anwender-Handbuch (§9.7). */
export async function PolicyRegisterModal({
doc,
data,
canWrite,
backHref,
}: {
doc: PolicyDocument;
data: RegisterData;
canWrite: boolean;
backHref: string;
}) {
const t = await getTranslations("policies");
return (
<Modal
title={`${doc.code} · ${doc.title}`}
sub={doc.type === "HANDBUCH" ? "Handbuch" : "Register"}
headerExtra={<Tag>{doc.type === "HANDBUCH" ? "Handbuch" : "Register"}</Tag>}
closeHref={backHref}
closeLabel={t("close")}
footer={<Button nativeButton={false} render={<Link href={backHref} />}>{t("close")}</Button>}
>
<div className="max-h-[74vh] overflow-y-auto p-5">
{doc.code === "CRYPTO" && <CryptoRegister entries={data.crypto ?? []} canWrite={canWrite} />}
{doc.code === "CLASSIFICATION" && (
<ClassificationMatrix classes={data.classes ?? []} aspects={data.aspects ?? []} rules={data.rules ?? []} canWrite={canWrite} />
)}
{doc.code === "RISKMATRIX" && (
<RiskMatrix classes={data.riskClasses ?? []} ew={data.ewLevels ?? []} damage={data.damage ?? []} canWrite={canWrite} />
)}
{doc.code === "HANDBUCH" && <Handbook topics={data.handbook ?? []} variables={data.variables ?? []} />}
</div>
</Modal>
);
}
/* ─────────────── Krypto-Register ─────────────── */
async function CryptoRegister({ entries, canWrite }: { entries: CryptoEntry[]; canWrite: boolean }) {
const fmt = await getFormatter();
const date = (d: Date | null) => (d ? fmt.dateTime(d, { dateStyle: "medium" }) : "—");
const expiring = entries.filter((e) => isExpiring(e.ablaufdatum) || isExpired(e.ablaufdatum)).length;
return (
<div>
<div className="mb-3 flex items-center justify-between gap-2">
<p className="text-[12.5px] text-muted-foreground">
Eingesetzte Verschlüsselung mit Ablaufüberwachung.{" "}
{expiring > 0 && <span className="font-semibold text-[var(--warn)]">{expiring} laufen bald ab / abgelaufen.</span>}
</p>
{canWrite && (
<details className="relative">
<summary className="bg-grad-soft inline-flex h-8 cursor-pointer list-none items-center gap-1.5 rounded-lg px-2.5 font-heading text-sm font-semibold text-white select-none hover:opacity-90 [&::-webkit-details-marker]:hidden">
<Plus className="size-4" /> Eintrag
</summary>
<form action={addCryptoEntry} className="shadow-card absolute right-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card p-3 text-sm">
<Input name="dienst" required placeholder="Dienst" />
<Input name="schluessel" required placeholder="Schlüssel" />
<Input name="algorithmus" placeholder="Algorithmus / Länge" />
<Input name="ablaufdatum" type="date" />
<Input name="verantwortlich" placeholder="Verantwortlich" />
<Input name="speicherort" placeholder="Speicherort" />
<Input name="baselineRef" placeholder="Baseline (BL-CRY-…)" />
<Button type="submit" variant="secondary" size="sm">Hinzufügen</Button>
</form>
</details>
)}
</div>
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-[var(--elevated)] text-[10.5px] tracking-[.04em] text-muted-foreground uppercase">
<th className="p-2.5 text-left">Dienst</th>
<th className="p-2.5 text-left">Schlüssel</th>
<th className="p-2.5 text-left">Algorithmus</th>
<th className="p-2.5 text-left">Ablauf</th>
<th className="p-2.5 text-left">Verantwortlich</th>
<th className="p-2.5 text-left">Baseline</th>
{canWrite && <th className="p-2.5" />}
</tr>
</thead>
<tbody>
{entries.length === 0 && (
<tr><td colSpan={7} className="p-6 text-center text-muted-foreground">Noch keine Einträge.</td></tr>
)}
{entries.map((e) => {
const exp = isExpired(e.ablaufdatum);
const soon = !exp && isExpiring(e.ablaufdatum);
return (
<tr key={e.id} className="border-b last:border-0">
<td className="p-2.5 font-medium">{e.dienst}</td>
<td className="p-2.5 text-muted-foreground">{e.schluessel}</td>
<td className="p-2.5 text-muted-foreground">{e.algorithmus ?? "—"}</td>
<td className="p-2.5">
<span className={exp ? "font-semibold text-[var(--risk)]" : soon ? "font-semibold text-[var(--warn)]" : "text-muted-foreground"}>
{date(e.ablaufdatum)}
</span>
{exp && <Pill tone="risk">abgelaufen</Pill>}
{soon && <Pill tone="warn">läuft ab</Pill>}
</td>
<td className="p-2.5 text-muted-foreground">{e.verantwortlich}</td>
<td className="p-2.5">{e.baselineRef ? <Pill tone="violet">{e.baselineRef}</Pill> : <span className="text-muted-foreground"></span>}</td>
{canWrite && (
<td className="p-2.5">
<div className="flex items-center justify-end gap-1">
<details className="relative">
<summary className="grid size-7 cursor-pointer list-none place-items-center rounded-md text-muted-foreground hover:bg-muted [&::-webkit-details-marker]:hidden"><Pencil className="size-3.5" /></summary>
<form action={updateCryptoEntry.bind(null, e.id)} className="shadow-card absolute right-0 z-10 mt-2 w-72 space-y-2 rounded-xl border bg-card p-3 text-sm">
<Input name="dienst" required defaultValue={e.dienst} />
<Input name="schluessel" required defaultValue={e.schluessel} />
<Input name="algorithmus" defaultValue={e.algorithmus ?? ""} placeholder="Algorithmus" />
<Input name="ablaufdatum" type="date" defaultValue={e.ablaufdatum ? e.ablaufdatum.toISOString().slice(0, 10) : ""} />
<Input name="verantwortlich" defaultValue={e.verantwortlich} />
<Input name="speicherort" defaultValue={e.speicherort ?? ""} placeholder="Speicherort" />
<Input name="baselineRef" defaultValue={e.baselineRef ?? ""} placeholder="BL-CRY-…" />
<Button type="submit" variant="secondary" size="sm">Speichern</Button>
</form>
</details>
<form action={deleteCryptoEntry.bind(null, e.id)}>
<button type="submit" title="Löschen" className="grid size-7 place-items-center rounded-md text-muted-foreground hover:text-destructive"><Trash2 className="size-3.5" /></button>
</form>
</div>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
/* ─────────────── Klassifizierungs-Handhabungsmatrix ─────────────── */
function ClassificationMatrix({
classes, aspects, rules, canWrite,
}: { classes: ClassificationClass[]; aspects: HandlingAspect[]; rules: HandlingRule[]; canWrite: boolean }) {
const ruleOf = (classId: string, aspectId: string) => rules.find((r) => r.classId === classId && r.aspectId === aspectId)?.text ?? "";
return (
<div>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<p className="text-[12.5px] text-muted-foreground">Handhabungsregeln je Schutzklasse. Assets erben die Regeln aus ihrer Klasse.</p>
{canWrite && (
<div className="flex gap-2">
<details className="relative">
<summary className="inline-flex h-8 cursor-pointer list-none items-center gap-1.5 rounded-lg border px-2.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground [&::-webkit-details-marker]:hidden"><Plus className="size-3.5" /> Aspekt</summary>
<form action={addHandlingAspect} className="shadow-card absolute right-0 z-10 mt-2 w-72 space-y-2 rounded-xl border bg-card p-3 text-sm">
<Input name="name" required placeholder="Aspekt (Zeile)" />
<Input name="category" placeholder="Kategorie" />
<Button type="submit" variant="secondary" size="sm">Hinzufügen</Button>
</form>
</details>
<details className="relative">
<summary className="inline-flex h-8 cursor-pointer list-none items-center gap-1.5 rounded-lg border px-2.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground [&::-webkit-details-marker]:hidden"><Plus className="size-3.5" /> Schutzklasse</summary>
<form action={addClassificationClass} className="shadow-card absolute right-0 z-10 mt-2 w-72 space-y-2 rounded-xl border bg-card p-3 text-sm">
<Input name="name" required placeholder="Schutzklasse (Spalte)" />
<Input name="description" placeholder="Beschreibung" />
<Button type="submit" variant="secondary" size="sm">Hinzufügen</Button>
</form>
</details>
</div>
)}
</div>
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-[12.5px]">
<thead>
<tr className="border-b bg-[var(--elevated)]">
<th className="p-2.5 text-left text-[10.5px] tracking-[.04em] text-muted-foreground uppercase">Aspekt</th>
{classes.map((c) => (
<th key={c.id} className="min-w-40 p-2.5 text-left font-heading font-bold" title={c.description ?? ""}>{c.name}</th>
))}
</tr>
</thead>
<tbody>
{aspects.map((a) => (
<tr key={a.id} className="border-b last:border-0 align-top">
<td className="p-2.5 font-medium">{a.name}</td>
{classes.map((c) => (
<td key={c.id} className="p-1.5">
{canWrite ? (
<details className="group">
<summary className="cursor-pointer list-none rounded-md px-2 py-1.5 hover:bg-muted/50 [&::-webkit-details-marker]:hidden">{ruleOf(c.id, a.id) || <span className="text-muted-foreground"></span>}</summary>
<form action={updateHandlingRule.bind(null, c.id, a.id)} className="mt-1 flex gap-1">
<Input name="text" defaultValue={ruleOf(c.id, a.id)} className="h-8" />
<Button type="submit" variant="secondary" size="sm">OK</Button>
</form>
</details>
) : (
<span className="px-2">{ruleOf(c.id, a.id) || "—"}</span>
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
/* ─────────────── Risiko-Bewertungsmatrix ─────────────── */
function RiskMatrix({
classes, ew, damage, canWrite,
}: { classes: RiskMatrixClass[]; ew: RiskEwLevel[]; damage: RiskDamageDimension[]; canWrite: boolean }) {
const sorted = [...classes].sort((a, b) => a.maxScore - b.maxScore);
const classFor = (score: number) => sorted.find((c) => score <= c.maxScore) ?? sorted[sorted.length - 1];
const rows = [4, 3, 2, 1]; // Schadensausmaß (oben = 4)
const cols = [1, 2, 3, 4]; // Eintrittswahrscheinlichkeit
return (
<div className="space-y-5">
{/* 4×4 Heatmap */}
<section>
<p className="mb-2 font-heading text-sm font-semibold">Bewertungsmatrix (Schadensausmaß × Eintrittswahrscheinlichkeit)</p>
<div className="inline-block overflow-hidden rounded-xl border">
<table className="text-center text-[12.5px]">
<tbody>
{rows.map((s) => (
<tr key={s}>
<th className="bg-[var(--elevated)] px-3 py-2 text-muted-foreground">S {s}</th>
{cols.map((e) => {
const score = s * e;
const cls = classFor(score);
return (
<td key={e} className="px-4 py-2 font-bold text-white" style={{ background: TONE_HEX[cls?.tone] ?? "#555" }} title={`${cls?.name} (${score})`}>
{score}
</td>
);
})}
</tr>
))}
<tr>
<th className="bg-[var(--elevated)] px-3 py-2" />
{cols.map((e) => (
<th key={e} className="bg-[var(--elevated)] px-4 py-2 text-muted-foreground">EW {e}</th>
))}
</tr>
</tbody>
</table>
</div>
</section>
{/* Risikoklassen + Akzeptanzinstanz */}
<section>
<p className="mb-2 font-heading text-sm font-semibold">Risikoklassen & Akzeptanz</p>
<div className="overflow-hidden rounded-xl border">
<table className="w-full text-sm">
<thead><tr className="border-b bg-[var(--elevated)] text-[10.5px] tracking-[.04em] text-muted-foreground uppercase">
<th className="p-2.5 text-left">Klasse</th><th className="p-2.5 text-left">Bis Risikowert</th><th className="p-2.5 text-left">Akzeptanzinstanz</th>{canWrite && <th className="p-2.5" />}
</tr></thead>
<tbody>
{sorted.map((c) => (
<tr key={c.id} className="border-b last:border-0">
<td className="p-2.5"><Pill tone={(c.tone as "ok" | "warn" | "orange" | "risk")}>{c.name}</Pill></td>
<td className="p-2.5 font-medium"> {c.maxScore}</td>
<td className="p-2.5 text-muted-foreground">{c.acceptance}</td>
{canWrite && (
<td className="p-2.5 text-right">
<details className="relative">
<summary className="grid size-7 cursor-pointer list-none place-items-center rounded-md text-muted-foreground hover:bg-muted [&::-webkit-details-marker]:hidden"><Pencil className="size-3.5" /></summary>
<form action={updateRiskClass.bind(null, c.id)} className="shadow-card absolute right-0 z-10 mt-2 w-72 space-y-2 rounded-xl border bg-card p-3 text-sm">
<Input name="name" required defaultValue={c.name} />
<Input name="maxScore" type="number" required defaultValue={c.maxScore} placeholder="Bis Risikowert" />
<Input name="acceptance" defaultValue={c.acceptance} placeholder="Akzeptanzinstanz" />
<Button type="submit" variant="secondary" size="sm">Speichern</Button>
</form>
</details>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</section>
{/* Eintrittswahrscheinlichkeit */}
<section>
<p className="mb-2 font-heading text-sm font-semibold">Eintrittswahrscheinlichkeit</p>
<ul className="space-y-1 text-[12.5px]">
{ew.sort((a, b) => a.level - b.level).map((l) => (
<li key={l.id}><b>{l.level} · {l.label}</b> <span className="text-muted-foreground"> {l.definition}</span></li>
))}
</ul>
</section>
{/* Schadenskategorien */}
<section>
<p className="mb-2 font-heading text-sm font-semibold">Schadenskategorien</p>
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-[12px]">
<thead><tr className="border-b bg-[var(--elevated)] text-[10.5px] tracking-[.04em] text-muted-foreground uppercase">
<th className="p-2.5 text-left">Dimension</th><th className="p-2.5 text-left">1 Niedrig</th><th className="p-2.5 text-left">2 Normal</th><th className="p-2.5 text-left">3 Hoch</th><th className="p-2.5 text-left">4 Sehr hoch</th>
</tr></thead>
<tbody>
{damage.map((d) => {
const lv = d.levels as Record<string, string>;
return (
<tr key={d.id} className="border-b last:border-0 align-top">
<td className="p-2.5 font-medium">{d.name}</td>
<td className="p-2.5 text-muted-foreground">{lv["1"]}</td>
<td className="p-2.5 text-muted-foreground">{lv["2"]}</td>
<td className="p-2.5 text-muted-foreground">{lv["3"]}</td>
<td className="p-2.5 text-muted-foreground">{lv["4"]}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
</div>
);
}
/* ─────────────── Anwender-Handbuch ─────────────── */
function Handbook({ topics, variables }: { topics: HandbookTopic[]; variables: PolicyVariable[] }) {
const ctx = buildContext(variables);
const cats = [...new Set(topics.map((t) => t.category))];
return (
<div className="space-y-5">
<p className="text-[12.5px] text-muted-foreground">
Kompakte Regeln für Mitarbeitende. Werte (z. B. Passwortlänge) stammen aus der zentralen Baseline und bleiben automatisch synchron.
</p>
{cats.map((cat) => (
<section key={cat}>
<p className="mb-2 font-heading text-sm font-semibold text-[var(--band-text)]">{cat}</p>
<div className="space-y-3">
{topics.filter((t) => t.category === cat).map((topic) => (
<div key={topic.id} className="rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4">
<p className="font-heading text-[14px] font-bold">{topic.title}</p>
<div className="policy-prose mt-1" dangerouslySetInnerHTML={{ __html: renderPolicyHtml(topic.bodyMd, ctx, { readMode: false }) }} />
{topic.sourceRefs.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-2 text-[12px] text-muted-foreground">
Details:
{topic.sourceRefs.map((ref) => {
const { href, label } = resolveLink(ref);
return (
<Link key={ref} href={href} className="font-semibold text-[var(--info)] hover:underline">{label} </Link>
);
})}
</div>
)}
</div>
))}
</div>
</section>
))}
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
"use server";
import { revalidatePath } from "next/cache";
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 optDate = (v: FormDataEntryValue | null) => (v && String(v) ? new Date(String(v)) : null);
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
async function guard() {
const session = await requireSession();
requirePermission(session, "policy:write");
return { session, db: dbForTenant(session.user.tenantId) };
}
/* ── Krypto-Register (VA-07) ── */
export async function addCryptoEntry(formData: FormData) {
const { session, db } = await guard();
const last = await db.cryptoEntry.aggregate({ _max: { orderIdx: true } });
await db.cryptoEntry.create({
data: {
tenantId: session.user.tenantId,
dienst: z.string().trim().min(1).parse(formData.get("dienst")),
schluessel: z.string().trim().min(1).parse(formData.get("schluessel")),
algorithmus: str(formData.get("algorithmus")) || null,
ablaufdatum: optDate(formData.get("ablaufdatum")),
verantwortlich: str(formData.get("verantwortlich")) || "—",
speicherort: str(formData.get("speicherort")) || null,
baselineRef: str(formData.get("baselineRef")) || null,
orderIdx: (last._max.orderIdx ?? 0) + 1,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "crypto_entry" });
revalidatePath("/policies");
}
export async function updateCryptoEntry(id: string, formData: FormData) {
const { session, db } = await guard();
await db.cryptoEntry.update({
where: { id },
data: {
dienst: z.string().trim().min(1).parse(formData.get("dienst")),
schluessel: z.string().trim().min(1).parse(formData.get("schluessel")),
algorithmus: str(formData.get("algorithmus")) || null,
ablaufdatum: optDate(formData.get("ablaufdatum")),
verantwortlich: str(formData.get("verantwortlich")) || "—",
speicherort: str(formData.get("speicherort")) || null,
baselineRef: str(formData.get("baselineRef")) || null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "crypto_entry", entityId: id });
revalidatePath("/policies");
}
export async function deleteCryptoEntry(id: string) {
const { session, db } = await guard();
await db.cryptoEntry.delete({ where: { id } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "crypto_entry", entityId: id });
revalidatePath("/policies");
}
/* ── Klassifizierungs-Handhabungsmatrix (R02 / VA-08) ── */
export async function updateHandlingRule(classId: string, aspectId: string, formData: FormData) {
const { session, db } = await guard();
const text = str(formData.get("text"));
await db.handlingRule.upsert({
where: { classId_aspectId: { classId, aspectId } },
update: { text },
create: { tenantId: session.user.tenantId, classId, aspectId, text },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "handling_rule" });
revalidatePath("/policies");
}
export async function addHandlingAspect(formData: FormData) {
const { session, db } = await guard();
const last = await db.handlingAspect.aggregate({ _max: { orderIdx: true } });
await db.handlingAspect.create({
data: {
tenantId: session.user.tenantId,
name: z.string().trim().min(1).parse(formData.get("name")),
category: str(formData.get("category")) || null,
orderIdx: (last._max.orderIdx ?? 0) + 1,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "handling_aspect" });
revalidatePath("/policies");
}
export async function addClassificationClass(formData: FormData) {
const { session, db } = await guard();
const last = await db.classificationClass.aggregate({ _max: { orderIdx: true } });
await db.classificationClass.create({
data: {
tenantId: session.user.tenantId,
name: z.string().trim().min(1).parse(formData.get("name")),
description: str(formData.get("description")) || null,
orderIdx: (last._max.orderIdx ?? 0) + 1,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "classification_class" });
revalidatePath("/policies");
}
/* ── Risiko-Bewertungsmatrix (R03 / VA-09) ── */
export async function updateRiskClass(id: string, formData: FormData) {
const { session, db } = await guard();
await db.riskMatrixClass.update({
where: { id },
data: {
name: z.string().trim().min(1).parse(formData.get("name")),
maxScore: z.coerce.number().int().min(1).max(999).parse(formData.get("maxScore")),
acceptance: str(formData.get("acceptance")),
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "risk_matrix_class", entityId: id });
revalidatePath("/policies");
}
+8
View File
@@ -52,6 +52,14 @@ const TENANT_MODELS = new Set<string>([
"PolicyVariable", "PolicyVariable",
"PolicyBaselineParam", "PolicyBaselineParam",
"PolicyEvidence", "PolicyEvidence",
"CryptoEntry",
"ClassificationClass",
"HandlingAspect",
"HandlingRule",
"RiskMatrixClass",
"RiskEwLevel",
"RiskDamageDimension",
"HandbookTopic",
]); ]);
/** /**