diff --git a/messages/de.json b/messages/de.json index 150615c..1899f0e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -564,6 +564,13 @@ "saveVariables": "Variablen speichern", "modeStandard": "Standard (Variablen)", "modeExpert": "Experten-Modus", - "expertHint": "Voller Vorlagen-Editor: Text, Formatierung, Variablen, Deep-Links & Referenzen. Neue Variablen werden beim Speichern angelegt und sind danach im Standard-Modus pflegbar." + "expertHint": "Voller Vorlagen-Editor: Text, Formatierung, Variablen, Deep-Links & Referenzen. Neue Variablen werden beim Speichern angelegt und sind danach im Standard-Modus pflegbar.", + "byControl": "nach Control", + "byDocument": "nach Dokument", + "assessmentExport": "Assessment-Export", + "requirements": "Anforderung(en)", + "requirement": "Anforderung", + "implementation": "Umsetzung", + "comingSoon": "Bald verfügbar" } } \ No newline at end of file diff --git a/messages/en.json b/messages/en.json index a651060..37f675f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -564,6 +564,13 @@ "saveVariables": "Save variables", "modeStandard": "Standard (variables)", "modeExpert": "Expert mode", - "expertHint": "Full template editor: text, formatting, variables, deep links & references. New variables are created on save and can then be maintained in standard mode." + "expertHint": "Full template editor: text, formatting, variables, deep links & references. New variables are created on save and can then be maintained in standard mode.", + "byControl": "by control", + "byDocument": "by document", + "assessmentExport": "Assessment export", + "requirements": "Requirement(s)", + "requirement": "Requirement", + "implementation": "Implementation", + "comingSoon": "Coming soon" } } \ No newline at end of file diff --git a/src/app/(app)/policies/page.tsx b/src/app/(app)/policies/page.tsx index 2d1cb5a..983666d 100644 --- a/src/app/(app)/policies/page.tsx +++ b/src/app/(app)/policies/page.tsx @@ -5,6 +5,10 @@ import { dbForTenant } from "@/server/db"; import { requirePermission } from "@/server/rbac"; import { PageHead, Pill, Tag, KpiCard } from "@/components/mockup-ui"; import { FilterTabs } from "@/components/filter-tabs"; +import { Button } from "@/components/ui/button"; +import { controlTitle, compareControl } from "@/lib/control-titles"; +import { buildContext, renderPolicyMarkdown } from "@/lib/policy-render"; +import type { PolicyRequirement } from "@prisma/client"; import { Table, TableBody, @@ -36,10 +40,57 @@ const STATUS_LABEL: Record = { ARCHIVIERT: "Archiviert", }; +function ObligationBadge({ type }: { type: string }) { + const muss = type === "MUSS"; + return ( + + {type} + + ); +} + +/** Gruppenblock „nach Dokument": Kopfzeile je Richtlinie + Anforderungszeilen. */ +function PolicyGroup({ + policy, + title, + rows, + clean, +}: { + policy: string; + title: string; + rows: PolicyRequirement[]; + clean: (s: string, n?: number) => string; +}) { + return ( + <> + + + {title}{" "} + {policy} + + + {rows.map((r) => ( + + {r.control} + {clean(r.requirement, 110)}… + {clean(r.implementation, 130)}… + + + {r.vaCodes.length === 0 ? : [...r.vaCodes].sort().map((va) => ( + {va} + ))} + + + + ))} + + ); +} + export default async function PoliciesPage({ searchParams, }: { - searchParams: Promise<{ type?: string; q?: string; doc?: string; view?: string }>; + searchParams: Promise<{ type?: string; q?: string; doc?: string; view?: string; dir?: string }>; }) { const session = await requireSession(); requirePermission(session, "policy:read"); @@ -47,10 +98,19 @@ export default async function PoliciesPage({ const db = dbForTenant(session.user.tenantId); const params = await searchParams; - const [docs, requirements] = await Promise.all([ + const [docs, requirements, variables] = await Promise.all([ db.policyDocument.findMany({ orderBy: { orderIdx: "asc" } }), db.policyRequirement.findMany({ orderBy: [{ control: "asc" }, { reqId: "asc" }] }), + db.policyVariable.findMany(), ]); + const titleOf = (code: string) => docs.find((d) => d.code === code)?.title ?? code; + const ctx = buildContext(variables); + const clean = (s: string, n = 160) => + renderPolicyMarkdown(s, ctx, { readMode: true, stripBaseline: true }) + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/\s+/g, " ") + .trim() + .slice(0, n); // Ableitung: je Richtlinie ihre Controls + operationalisierende Verfahren const byPolicy = new Map; vas: Set }>(); @@ -79,61 +139,86 @@ export default async function PoliciesPage({ /> ); - /* ── Coverage-Matrix (Control → Richtlinie → Verfahren) ── */ + /* ── Referenz-/Coverage-Matrix (§9.3, nach Mockup) ── */ if (isCoverage) { - const byControl = new Map }>(); - for (const r of requirements) { - if (!byControl.has(r.control)) byControl.set(r.control, { policy: r.policyCode, must: 0, should: 0, ids: [], vas: new Set() }); - const e = byControl.get(r.control)!; - if (r.obligation === "MUSS") e.must++; - else e.should++; - e.ids.push(r.reqId.split("-").slice(1).join("-")); - r.vaCodes.forEach((v) => e.vas.add(v)); - } - const rows = [...byControl.entries()]; + const dir = params.dir === "doc" ? "doc" : "ctrl"; + const controlCount = new Set(requirements.map((r) => r.control)).size; + const vasOf = (ids: string[]) => [...new Set(ids)].sort(); + return (
{head} -

{t("coverageHint", { controls: rows.length, reqs: requirements.length })}

-
- - - - {t("control")} - {t("policy")} - MUSS - SOLL - {t("procedures")} - {t("reqIds")} - - - - {rows.map(([control, e]) => ( - - {control} - - {e.policy} - - {e.must} - {e.should || "–"} - - - {e.vas.size === 0 ? ( - - ) : ( - [...e.vas].sort().map((va) => ( - - {va} - - )) - )} - - - {e.ids.join(", ")} - - ))} - -
+
+

{t("coverageHint", { controls: controlCount, reqs: requirements.length })}

+
+
+ {t("byControl")} + {t("byDocument")} +
+ +
+
+ +
+ {dir === "ctrl" ? ( + + + + + + + + + + + {[...new Set(requirements.map((r) => r.control))].sort(compareControl).map((control) => { + const list = requirements.filter((r) => r.control === control); + const vas = vasOf(list.flatMap((r) => r.vaCodes)); + return ( + + + + + + + ); + })} + +
{t("control")}{t("requirements")}{t("policy")}{t("procedures")}
+ {control} +
{controlTitle(control)}
+
+
    + {list.map((r) => ( +
  • + {clean(r.requirement, 120)}… +
  • + ))} +
+
{list[0].policyCode} + + {vas.length === 0 ? : vas.map((va) => ( + {va} + ))} + +
+ ) : ( + + + + + + + + + + + {[...new Set(requirements.map((r) => r.policyCode))].map((policy) => ( + r.policyCode === policy)} clean={clean} /> + ))} + +
{t("control")}{t("requirement")}{t("implementation")}{t("procedures")}
+ )}
); diff --git a/src/components/policy-registers.tsx b/src/components/policy-registers.tsx index 9b69f19..8b6880d 100644 --- a/src/components/policy-registers.tsx +++ b/src/components/policy-registers.tsx @@ -24,6 +24,8 @@ import { addHandlingAspect, deleteCryptoEntry, updateCryptoEntry, + updateDamageDimension, + updateEwLevel, updateHandlingRule, updateRiskClass, } from "@/server/actions/policies"; @@ -98,7 +100,7 @@ async function CryptoRegister({ entries, canWrite }: { entries: CryptoEntry[]; c )} -
+
@@ -276,73 +278,94 @@ function RiskMatrix({ - {/* Risikoklassen + Akzeptanzinstanz */} + {/* Risikoklassen + Akzeptanzinstanz — inline editierbar */}
-

Risikoklassen & Akzeptanz

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

Risikoklassen & Akzeptanz {canWrite && · zum Bearbeiten auf eine Zeile klicken}

+
+
+ KlasseBis RisikowertAkzeptanzinstanz +
+ {sorted.map((c) => ( + canWrite ? ( +
+ + {c.name} + ≤ {c.maxScore} + {c.acceptance} + +
+ + + + +
+
+ ) : ( +
+ {c.name} + ≤ {c.maxScore} + {c.acceptance} +
+ ) + ))}
- {/* Eintrittswahrscheinlichkeit */} + {/* Eintrittswahrscheinlichkeit — inline editierbar */}

Eintrittswahrscheinlichkeit

-
    - {ew.sort((a, b) => a.level - b.level).map((l) => ( -
  • {l.level} · {l.label} — {l.definition}
  • +
    + {[...ew].sort((a, b) => a.level - b.level).map((l) => ( + canWrite ? ( +
    + + {l.level} · {l.label} — {l.definition} + + +
    + + + +
    +
    + ) : ( +
    {l.level} · {l.label} — {l.definition}
    + ) ))} -
+
- {/* Schadenskategorien */} + {/* Schadenskategorien — inline editierbar */}

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"]}
+
+
+ Dimension1 Niedrig2 Normal3 Hoch4 Sehr hoch +
+ {damage.map((d) => { + const lv = d.levels as Record; + return canWrite ? ( +
+ + {d.name} + {lv["1"]}{lv["2"]}{lv["3"]}{lv["4"]} + +
+ + + + + + +
+
+ ) : ( +
+ {d.name} + {lv["1"]}{lv["2"]}{lv["3"]}{lv["4"]} +
+ ); + })}
diff --git a/src/lib/control-titles.ts b/src/lib/control-titles.ts new file mode 100644 index 0000000..6991069 --- /dev/null +++ b/src/lib/control-titles.ts @@ -0,0 +1,63 @@ +/** + * VDA-ISA-2027 Control-Titel (aus dem GEFIM-Mockup übernommen) für die + * Referenz-/Coverage-Matrix. Fallback: „ISA “ wenn nicht enthalten. + */ +export const CONTROL_TITLES: Record = { + "1.2.1": "Steuerung der Informationssicherheit", + "1.2.2": "Organisation der Verantwortlichkeiten", + "1.2.3": "Informationssicherheit in Projekten", + "1.3.1": "Identifikation von Assets", + "1.3.2": "Klassifizierung", + "1.3.3": "Zugelassene Hardware", + "1.3.4": "Zugelassene Software", + "1.4.1": "Risikomanagement", + "1.5.1": "Compliance-Prüfung im IS-Betrieb", + "1.5.2": "Unabhängige Überprüfung", + "1.6.1": "Meldung von Ereignissen", + "1.6.2": "Behandlung von Sicherheitsereignissen", + "1.6.3": "Krisenmanagement", + "2.1.1": "Qualifikation für sensible Tätigkeiten", + "2.1.2": "Vertragliche Verpflichtung", + "2.1.3": "Sensibilisierung und Schulung", + "2.1.4": "Mobiles Arbeiten", + "3.1.1": "Sicherheitszonen und Zutritt", + "3.1.3": "Umgang mit unterstützenden Betriebsmitteln", + "3.1.4": "Mobile Geräte und Datenträger", + "4.1.1": "Identifikationsmittel", + "4.1.2": "Sichere Anmeldung", + "4.1.3": "Konten und Anmeldeinformationen", + "4.2.1": "Zugriffsrechte", + "5.1.1": "Einsatz kryptografischer Verfahren", + "5.1.2": "Schutz bei Uebertragung", + "5.2.1": "Change-Management", + "5.2.2": "Trennung der Umgebungen", + "5.2.3": "Schutz vor Malware", + "5.2.4": "Protokollierung und Auswertung", + "5.2.5": "Schwachstellen- und Patch-Management", + "5.2.6": "Technische Überprüfung", + "5.2.7": "Netzwerksicherheit", + "5.2.8": "Kontinuitätsplanung IT", + "5.2.9": "Backup und Wiederherstellung", + "5.3.1": "Sicherheit bei Beschaffung/Entwicklung", + "5.3.2": "Anforderungen an Netzdienste", + "5.3.3": "Rückgabe und sichere Löschung", + "5.3.4": "Externe/geteilte IT-Dienste und Cloud", + "6.1.1": "Informationssicherheit bei Lieferanten", + "6.1.2": "Vertraulichkeit", + "6.1.3": "Abgrenzung der Verantwortlichkeiten", + "7.1.1": "Regulatorische und vertragliche Compliance", + "7.1.2": "Schutz personenbezogener Daten", +}; + +export function controlTitle(ref: string): string { + return CONTROL_TITLES[ref] ?? `ISA ${ref}`; +} + +/** Numerischer Vergleich gepunkteter Control-Refs (1.2.10 nach 1.2.2). */ +export function compareControl(a: string, b: string): number { + const pa = a.split('.').map(Number), pb = b.split('.').map(Number); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0); + } + return 0; +} \ No newline at end of file diff --git a/src/server/actions/policies.ts b/src/server/actions/policies.ts index 3c18888..b68012d 100644 --- a/src/server/actions/policies.ts +++ b/src/server/actions/policies.ts @@ -124,6 +124,29 @@ export async function updateRiskClass(id: string, formData: FormData) { revalidatePath("/policies", "layout"); } +export async function updateEwLevel(id: string, formData: FormData) { + const { session, db } = await guard(); + await db.riskEwLevel.update({ + where: { id }, + data: { label: z.string().trim().min(1).parse(formData.get("label")), definition: str(formData.get("definition")) }, + }); + await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "risk_ew_level", entityId: id }); + revalidatePath("/policies", "layout"); +} + +export async function updateDamageDimension(id: string, formData: FormData) { + const { session, db } = await guard(); + await db.riskDamageDimension.update({ + where: { id }, + data: { + name: z.string().trim().min(1).parse(formData.get("name")), + levels: { "1": str(formData.get("l1")), "2": str(formData.get("l2")), "3": str(formData.get("l3")), "4": str(formData.get("l4")) }, + }, + }); + await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "risk_damage_dimension", entityId: id }); + revalidatePath("/policies", "layout"); +} + /* ── Bearbeitungsmodus Richtlinien/Verfahren ── */ /** Vorlagen-Markdown eines Dokuments speichern (Experten-Modus). */