diff --git a/messages/de.json b/messages/de.json index fd353f5..d674d72 100644 --- a/messages/de.json +++ b/messages/de.json @@ -536,6 +536,7 @@ "kpiControls": "Abgedeckte Controls", "kpiControlsTrend": "VDA-ISA 2027", "kpiMust": "MUSS-Anforderungen", - "kpiShould": "SOLL-Anforderungen" + "kpiShould": "SOLL-Anforderungen", + "openFullTable": "Vollständige Tabelle öffnen" } } \ No newline at end of file diff --git a/messages/en.json b/messages/en.json index 380fb5e..cff2734 100644 --- a/messages/en.json +++ b/messages/en.json @@ -536,6 +536,7 @@ "kpiControls": "Covered controls", "kpiControlsTrend": "VDA-ISA 2027", "kpiMust": "MUST requirements", - "kpiShould": "SHOULD requirements" + "kpiShould": "SHOULD requirements", + "openFullTable": "Open full table" } } \ No newline at end of file diff --git a/prisma/import-managed.ts b/prisma/import-managed.ts new file mode 100644 index 0000000..538d7a4 --- /dev/null +++ b/prisma/import-managed.ts @@ -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ß(1–4) × EW(1–4) = 1–16 +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 1–5 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 }[] = [ + { 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 }; +} diff --git a/prisma/migrations/20260707153909_managed_tables/migration.sql b/prisma/migrations/20260707153909_managed_tables/migration.sql new file mode 100644 index 0000000..ec3afb1 --- /dev/null +++ b/prisma/migrations/20260707153909_managed_tables/migration.sql @@ -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 $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 598eb0a..b2c5fd3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -765,3 +765,124 @@ model PolicyEvidence { @@index([tenantId]) @@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") +} diff --git a/prisma/seed.ts b/prisma/seed.ts index 3e31f5e..7006008 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -5,6 +5,7 @@ import { hash } from "@node-rs/argon2"; import { join } from "node:path"; import { PERMISSIONS, ROLE_DEFS } from "../src/server/rbac"; import { importPolicies } from "./import-policies"; +import { importManaged } from "./import-managed"; /** * 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 } }); } 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() diff --git a/src/app/(app)/policies/page.tsx b/src/app/(app)/policies/page.tsx index 3b4d06c..9df06e8 100644 --- a/src/app/(app)/policies/page.tsx +++ b/src/app/(app)/policies/page.tsx @@ -2,10 +2,11 @@ import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { requireSession } from "@/server/auth"; 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 { FilterTabs } from "@/components/filter-tabs"; import { PolicyReadModal } from "@/components/policy-modals"; +import { PolicyRegisterModal } from "@/components/policy-registers"; import { Table, TableBody, @@ -15,7 +16,7 @@ import { TableRow, } 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 = { LEITLINIE: "Leitlinie", RICHTLINIE: "Richtlinie", @@ -62,18 +63,46 @@ export default async function PoliciesPage({ r.vaCodes.forEach((v) => e.vas.add(v)); } - /* ── Modal (Lesemodus) ── */ - const modalDoc = params.doc ? docs.find((d) => d.code === params.doc) : null; - const modalData = modalDoc - ? { - doc: modalDoc, - variables: await db.policyVariable.findMany({ orderBy: { orderIdx: "asc" } }), - controls: [...(byPolicy.get(modalDoc.code)?.controls ?? [])].sort(), - relatedVas: [...(byPolicy.get(modalDoc.code)?.vas ?? [])].sort(), - } - : null; - 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 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 = ; + } else if (modalDoc) { + modalNode = ( + + ); + } + const head = ( - {modalData && } + {modalNode} ); } @@ -227,7 +256,7 @@ export default async function PoliciesPage({ - {modalData && } + {modalNode} ); } diff --git a/src/components/policy-modals.tsx b/src/components/policy-modals.tsx index a466357..387e41a 100644 --- a/src/components/policy-modals.tsx +++ b/src/components/policy-modals.tsx @@ -27,6 +27,16 @@ const STATUS_LABEL: Record = { ARCHIVIERT: "Archiviert", }; +// Dokumente, die eine verwaltete Register-Tabelle einbetten (§7b) → „Vollständige Tabelle öffnen" +const EMBEDDED_REGISTER: Record = { + 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 { doc: PolicyDocument; variables: PolicyVariable[]; @@ -102,6 +112,19 @@ export async function PolicyReadModal({ data, backHref }: { data: PolicyReadData )} + {/* Eingebettete verwaltete Tabelle (§7b) */} + {EMBEDDED_REGISTER[doc.code] && ( + + + Verwaltete Tabelle: {EMBEDDED_REGISTER[doc.code].label} + + {t("openFullTable")} → + + )} + {/* Gerendertes Dokument */}
diff --git a/src/components/policy-registers.tsx b/src/components/policy-registers.tsx new file mode 100644 index 0000000..fd5a036 --- /dev/null +++ b/src/components/policy-registers.tsx @@ -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 = { 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 ( + {doc.type === "HANDBUCH" ? "Handbuch" : "Register"}} + closeHref={backHref} + closeLabel={t("close")} + footer={} + > +
+ {doc.code === "CRYPTO" && } + {doc.code === "CLASSIFICATION" && ( + + )} + {doc.code === "RISKMATRIX" && ( + + )} + {doc.code === "HANDBUCH" && } +
+
+ ); +} + +/* ─────────────── 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 ( +
+
+

+ Eingesetzte Verschlüsselung mit Ablaufüberwachung.{" "} + {expiring > 0 && {expiring} laufen bald ab / abgelaufen.} +

+ {canWrite && ( +
+ + Eintrag + +
+ + + + + + + + +
+
+ )} +
+
+ + + + + + + + + + {canWrite && + + + {entries.length === 0 && ( + + )} + {entries.map((e) => { + const exp = isExpired(e.ablaufdatum); + const soon = !exp && isExpiring(e.ablaufdatum); + return ( + + + + + + + + {canWrite && ( + + )} + + ); + })} + +
DienstSchlüsselAlgorithmusAblaufVerantwortlichBaseline} +
Noch keine Einträge.
{e.dienst}{e.schluessel}{e.algorithmus ?? "—"} + + {date(e.ablaufdatum)} + + {exp && abgelaufen} + {soon && läuft ab} + {e.verantwortlich}{e.baselineRef ? {e.baselineRef} : } +
+
+ +
+ + + + + + + + +
+
+
+ +
+
+
+
+
+ ); +} + +/* ─────────────── 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 ( +
+
+

Handhabungsregeln je Schutzklasse. Assets erben die Regeln aus ihrer Klasse.

+ {canWrite && ( +
+
+ Aspekt +
+ + + +
+
+
+ Schutzklasse +
+ + + +
+
+
+ )} +
+
+ + + + + {classes.map((c) => ( + + ))} + + + + {aspects.map((a) => ( + + + {classes.map((c) => ( + + ))} + + ))} + +
Aspekt{c.name}
{a.name} + {canWrite ? ( +
+ {ruleOf(c.id, a.id) || } +
+ + +
+
+ ) : ( + {ruleOf(c.id, a.id) || "—"} + )} +
+
+
+ ); +} + +/* ─────────────── 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 ( +
+ {/* 4×4 Heatmap */} +
+

Bewertungsmatrix (Schadensausmaß × Eintrittswahrscheinlichkeit)

+
+ + + {rows.map((s) => ( + + + {cols.map((e) => { + const score = s * e; + const cls = classFor(score); + return ( + + ); + })} + + ))} + + + ))} + + +
S {s} + {score} +
+ {cols.map((e) => ( + EW {e}
+
+
+ + {/* Risikoklassen + Akzeptanzinstanz */} +
+

Risikoklassen & Akzeptanz

+
+ + + {canWrite && + + {sorted.map((c) => ( + + + + + {canWrite && ( + + )} + + ))} + +
KlasseBis RisikowertAkzeptanzinstanz} +
{c.name}≤ {c.maxScore}{c.acceptance} +
+ +
+ + + + +
+
+
+
+
+ + {/* Eintrittswahrscheinlichkeit */} +
+

Eintrittswahrscheinlichkeit

+
    + {ew.sort((a, b) => a.level - b.level).map((l) => ( +
  • {l.level} · {l.label} — {l.definition}
  • + ))} +
+
+ + {/* Schadenskategorien */} +
+

Schadenskategorien

+
+ + + + + + {damage.map((d) => { + const lv = d.levels as Record; + return ( + + + + + + + + ); + })} + +
Dimension1 Niedrig2 Normal3 Hoch4 Sehr hoch
{d.name}{lv["1"]}{lv["2"]}{lv["3"]}{lv["4"]}
+
+
+
+ ); +} + +/* ─────────────── Anwender-Handbuch ─────────────── */ + +function Handbook({ topics, variables }: { topics: HandbookTopic[]; variables: PolicyVariable[] }) { + const ctx = buildContext(variables); + const cats = [...new Set(topics.map((t) => t.category))]; + return ( +
+

+ Kompakte Regeln für Mitarbeitende. Werte (z. B. Passwortlänge) stammen aus der zentralen Baseline und bleiben automatisch synchron. +

+ {cats.map((cat) => ( +
+

{cat}

+
+ {topics.filter((t) => t.category === cat).map((topic) => ( +
+

{topic.title}

+
+ {topic.sourceRefs.length > 0 && ( +
+ Details: + {topic.sourceRefs.map((ref) => { + const { href, label } = resolveLink(ref); + return ( + {label} → + ); + })} +
+ )} +
+ ))} +
+
+ ))} +
+ ); +} diff --git a/src/server/actions/policies.ts b/src/server/actions/policies.ts new file mode 100644 index 0000000..94d013b --- /dev/null +++ b/src/server/actions/policies.ts @@ -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"); +} diff --git a/src/server/db.ts b/src/server/db.ts index 12dfe3e..5e903ec 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -52,6 +52,14 @@ const TENANT_MODELS = new Set([ "PolicyVariable", "PolicyBaselineParam", "PolicyEvidence", + "CryptoEntry", + "ClassificationClass", + "HandlingAspect", + "HandlingRule", + "RiskMatrixClass", + "RiskEwLevel", + "RiskDamageDimension", + "HandbookTopic", ]); /**