Richtlinien-Update: 316 Anforderungen/45 Controls + Schutzbedarf-/TISAX-Schalter

Delta-Update des VDA-ISA-2027-Vorlagenpakets eingepflegt:

- Aktualisiertes Seed-Paket (ersetzt bisherigen Stand): 316 Anforderungen
  (122 MUSS · 132 SOLL · 43 HOCH · 19 SEHR HOCH) über 45 Controls; VA-01/VA-05
  jetzt enthalten (13 Verfahren vollständig). Beschädigte RACI-Tokens (VA-05/08/09/
  10/12/13) repariert. Importer: Umsetzungstext aus den .md-IMPL-Ankern extrahiert
  (mapping.json führt ihn nicht mehr), neue Obligation-Typen HOCH/SEHR HOCH.
- Neue Schutzbedarf-Flags (variables.schema): FLAG_HIGH_PROTECTION,
  FLAG_VERY_HIGH_PROTECTION, FLAG_ELEVATED_PROTECTION (abgeleitet). Render-Helper
  applyProtection: HIGH stets an, VERY_HIGH aus Global/Override, ELEVATED = HIGH||VH
  (nie manuell) — angewandt in Lese-, Bearbeiten-, Handbuch- und Coverage-Rendering.
- TISAX-Level-Schalter (AL2/AL3) zentral auf der Bibliothek (setGlobalTisaxLevel);
  AL2 = MUSS/SOLL/HOCH, AL3 = zusätzlich SEHR HOCH. Override je Richtlinie im
  Bearbeitungsmodus (setProtectionOverride, Feld protection_override); effektiver
  Wert = Dokument-Override sonst global.
- KPIs zeigen 316 Anforderungen mit Aufschlüsselung; Coverage/Badges für HOCH/SEHR
  HOCH; Control-Titel-Fallback.

Verifiziert: Import 316/45; Rendering rückstandsfrei über AL2/AL3 × Flag-Kombis;
Override R04→AL3 zeigt SEHR-HOCH-Inhalt, R02 (global AL2) nicht; global bleibt AL2.

Architektur-Hinweis: applyProtection kapselt das Level→Flags-Mapping, sodass die
globale Ebene später ohne Umbau zur TISAX-AL2/AL3-Auswahl wird (bereits so gebaut).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 11:23:07 +02:00
co-authored by Claude Opus 4.8
parent 222046a362
commit eab16c861d
35 changed files with 6169 additions and 1404 deletions
+19 -3
View File
@@ -8,10 +8,10 @@ import { hasPermission, requirePermission } from "@/server/rbac";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Pill } from "@/components/mockup-ui";
import { buildContext, renderPolicyHtml, splitPolicyDoc } from "@/lib/policy-render";
import { applyProtection, buildContext, effectiveLevel, renderPolicyHtml, splitPolicyDoc } from "@/lib/policy-render";
import { POLICY_STATUS_LABEL, POLICY_STATUS_TONE, POLICY_TYPE_LABEL } from "@/components/policy-modals";
import { PolicyExpertEditor } from "@/components/policy-expert-editor";
import { approvePolicy, rejectPolicy, submitForApproval, updatePolicyTemplate, updateScopedVariables } from "@/server/actions/policies";
import { approvePolicy, rejectPolicy, setProtectionOverride, submitForApproval, updatePolicyTemplate, updateScopedVariables } from "@/server/actions/policies";
const TEMPLATE_TYPES = new Set(["LEITLINIE", "RICHTLINIE", "VERFAHREN"]);
@@ -62,7 +62,9 @@ export default async function PolicyEditPage({
const docVars = variables.filter((v) => usedKeys.has(v.key) && v.kind !== "boolean");
const keysCsv = docVars.map((v) => v.key).join(",");
const ctx = buildContext(variables);
const ctx = applyProtection(buildContext(variables), doc.protectionOverride);
const globalVeryHigh = variables.find((v) => v.key === "FLAG_VERY_HIGH_PROTECTION")?.value === "true";
const effLevel = effectiveLevel(globalVeryHigh, doc.protectionOverride);
const { infoMd, bodyMd } = splitPolicyDoc(doc.rawMarkdown);
const stripBaseline = doc.code !== "BASELINE";
const bodyHtml = renderPolicyHtml(bodyMd, ctx, { readMode: true, stripBaseline });
@@ -180,6 +182,20 @@ export default async function PolicyEditPage({
<p className="mt-3 text-[11px] text-muted-foreground">{t("approvalNote")}</p>
</div>
{/* Schutzbedarf / TISAX-Level-Override je Richtlinie (Delta-Update) */}
<form action={setProtectionOverride.bind(null, decoded)} className="shadow-card rounded-2xl border bg-card p-4">
<p className="font-heading text-sm font-semibold">{t("protectionLevel")}</p>
<p className="mt-1 mb-2 text-[11.5px] text-muted-foreground">
{t("effectiveLevel")}: <b>{effLevel}</b> · {t("globalLevel")}: {globalVeryHigh ? "AL3" : "AL2"}
</p>
<select name="override" defaultValue={doc.protectionOverride ?? ""} className="h-8 w-full rounded-md border border-input bg-transparent px-2 text-[12.5px]">
<option value="">{t("levelGlobal")} ({globalVeryHigh ? "AL3" : "AL2"})</option>
<option value="AL2">AL2 {t("levelAl2")}</option>
<option value="AL3">AL3 {t("levelAl3")}</option>
</select>
<Button type="submit" size="sm" className="mt-3 w-full justify-center">{tc("save")}</Button>
</form>
{/* Dokument-Variablen (§8 Variablen-Scoping) */}
<form action={updateScopedVariables.bind(null, decoded)} className="shadow-card rounded-2xl border bg-card p-4">
<input type="hidden" name="keys" value={keysCsv} />
+35 -8
View File
@@ -7,7 +7,8 @@ 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 { applyProtection, buildContext, renderPolicyMarkdown } from "@/lib/policy-render";
import { setGlobalTisaxLevel } from "@/server/actions/policies";
import type { PolicyRequirement } from "@prisma/client";
import {
Table,
@@ -40,15 +41,40 @@ const STATUS_LABEL: Record<string, string> = {
ARCHIVIERT: "Archiviert",
};
const OBLIGATION_STYLE: Record<string, string> = {
MUSS: "bg-[rgba(214,60,94,0.18)] text-[#e88aa0]",
SOLL: "bg-[rgba(90,169,230,0.18)] text-[var(--info)]",
HOCH: "bg-[rgba(226,128,46,0.2)] text-[#e2a06e]",
"SEHR HOCH": "bg-[rgba(125,111,214,0.22)] text-[#c3bdec]",
};
function ObligationBadge({ type }: { type: string }) {
const muss = type === "MUSS";
return (
<span className={`inline-block rounded px-1.5 py-0.5 text-[10px] font-bold ${muss ? "bg-[rgba(214,60,94,0.18)] text-[#e88aa0]" : "bg-[rgba(90,169,230,0.18)] text-[var(--info)]"}`}>
<span className={`inline-block whitespace-nowrap rounded px-1.5 py-0.5 text-[10px] font-bold ${OBLIGATION_STYLE[type] ?? "bg-muted text-muted-foreground"}`}>
{type}
</span>
);
}
/** Zentrale Schutzbedarf-/TISAX-Level-Einstellung (AL2/AL3) — mandantenweit. */
function TisaxLevelCard({ level }: { level: "AL2" | "AL3" }) {
const opt = (lvl: "AL2" | "AL3", label: string) => (
<form action={setGlobalTisaxLevel}>
<input type="hidden" name="level" value={lvl} />
<button type="submit" className={`rounded-md px-2.5 py-1 text-[12px] font-bold ${level === lvl ? "bg-[var(--sidebar-accent)] text-[var(--primary)]" : "text-muted-foreground hover:text-foreground"}`}>{label}</button>
</form>
);
return (
<div className="shadow-card rounded-xl border bg-card p-4">
<p className="text-[12px] text-muted-foreground">Schutzbedarf / TISAX-Level</p>
<div className="mt-1.5 inline-flex rounded-lg border p-1">
{opt("AL2", "AL2")}
{opt("AL3", "AL3")}
</div>
<p className="mt-2 text-[11px] text-muted-foreground">{level === "AL3" ? "MUSS · SOLL · HOCH · SEHR HOCH" : "MUSS · SOLL · HOCH"}</p>
</div>
);
}
/** Gruppenblock „nach Dokument": Kopfzeile je Richtlinie + Anforderungszeilen. */
function PolicyGroup({
policy,
@@ -104,7 +130,7 @@ export default async function PoliciesPage({
db.policyVariable.findMany(),
]);
const titleOf = (code: string) => docs.find((d) => d.code === code)?.title ?? code;
const ctx = buildContext(variables);
const ctx = applyProtection(buildContext(variables));
const clean = (s: string, n = 160) =>
renderPolicyMarkdown(s, ctx, { readMode: true, stripBaseline: true })
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
@@ -234,8 +260,9 @@ export default async function PoliciesPage({
);
const kpiControls = new Set(requirements.map((r) => r.control)).size;
const kpiMust = requirements.filter((r) => r.obligation === "MUSS").length;
const kpiShould = requirements.length - kpiMust;
const count = (typ: string) => requirements.filter((r) => r.obligation === typ).length;
const globalVeryHigh = variables.find((v) => v.key === "FLAG_VERY_HIGH_PROTECTION")?.value === "true";
const globalLevel = globalVeryHigh ? "AL3" : "AL2";
const typeHref = (v: string | null) => `/policies${v ? `?type=${v}` : ""}`;
@@ -246,8 +273,8 @@ export default async function PoliciesPage({
<div className="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<KpiCard label={t("kpiDocs")} value={docs.length} trend={t("kpiDocsTrend")} />
<KpiCard label={t("kpiControls")} value={kpiControls} trend={t("kpiControlsTrend")} />
<KpiCard label={t("kpiMust")} value={kpiMust} trend="MUSS" />
<KpiCard label={t("kpiShould")} value={kpiShould} trend="SOLL" />
<KpiCard label={t("kpiRequirements")} value={requirements.length} trend={`${count("MUSS")} MUSS · ${count("SOLL")} SOLL · ${count("HOCH")} HOCH · ${count("SEHR HOCH")} SEHR HOCH`} />
<TisaxLevelCard level={globalLevel} />
</div>
<div className="mt-4">
+2 -2
View File
@@ -2,7 +2,7 @@ import Link from "next/link";
import { getTranslations } from "next-intl/server";
import type { PolicyDocument, PolicyVariable } from "@prisma/client";
import { Pill } from "@/components/mockup-ui";
import { buildContext, renderPolicyHtml, splitPolicyDoc } from "@/lib/policy-render";
import { applyProtection, buildContext, renderPolicyHtml, splitPolicyDoc } from "@/lib/policy-render";
export const POLICY_TYPE_LABEL: Record<string, string> = {
LEITLINIE: "Leitlinie",
@@ -48,7 +48,7 @@ export interface PolicyReadData {
export async function PolicyReadView({ data }: { data: PolicyReadData }) {
const t = await getTranslations("policies");
const { doc, variables, controls, relatedVas } = data;
const ctx = buildContext(variables);
const ctx = applyProtection(buildContext(variables), doc.protectionOverride);
const { infoMd, bodyMd } = splitPolicyDoc(doc.rawMarkdown);
const stripBaseline = doc.code !== "BASELINE";
+2 -2
View File
@@ -17,7 +17,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Pill } from "@/components/mockup-ui";
import { isExpired, isExpiring } from "@/lib/supplier";
import { buildContext, renderPolicyHtml, resolveLink } from "@/lib/policy-render";
import { applyProtection, buildContext, renderPolicyHtml, resolveLink } from "@/lib/policy-render";
import {
addClassificationClass,
addCryptoEntry,
@@ -375,7 +375,7 @@ function RiskMatrix({
/* ─────────────── Anwender-Handbuch ─────────────── */
function Handbook({ topics, variables }: { topics: HandbookTopic[]; variables: PolicyVariable[] }) {
const ctx = buildContext(variables);
const ctx = applyProtection(buildContext(variables));
const cats = [...new Set(topics.map((t) => t.category))];
return (
<div className="space-y-5">
+21
View File
@@ -22,6 +22,27 @@ export function buildContext(vars: { key: string; kind: string; value: string }[
return ctx;
}
/**
* Setzt die effektiven Schutzbedarf-/TISAX-Flags (Delta-Update):
* - HIGH ist im TISAX-Modell stets aktiv (AL2-Baseline schließt HOHER ein).
* - VERY_HIGH = Dokument-Override (AL2/AL3) sonst globaler Wert (AL3-Schalter).
* - ELEVATED = HIGH || VERY_HIGH (hier stets true) — wird abgeleitet, nie manuell.
*/
export function applyProtection(ctx: RenderContext, override?: string | null): RenderContext {
const global = ctx.FLAG_VERY_HIGH_PROTECTION === true;
const veryHigh = override === "AL3" ? true : override === "AL2" ? false : global;
ctx.FLAG_HIGH_PROTECTION = true;
ctx.FLAG_VERY_HIGH_PROTECTION = veryHigh;
ctx.FLAG_ELEVATED_PROTECTION = true;
return ctx;
}
/** Effektiver TISAX-Level für Anzeige: AL3 wenn VERY_HIGH aktiv, sonst AL2. */
export function effectiveLevel(globalVeryHigh: boolean, override?: string | null): "AL2" | "AL3" {
const veryHigh = override === "AL3" ? true : override === "AL2" ? false : globalVeryHigh;
return veryHigh ? "AL3" : "AL2";
}
// Abschnitte, die das Tool zentral verwaltet und daher im Lese-/Exportmodus ausblendet (§7a.3)
const SUPPRESS_SECTIONS = [
"Verwandte Dokumente",
+25
View File
@@ -204,6 +204,31 @@ export async function updateScopedVariables(code: string, formData: FormData) {
redirect(`/policies/${encodeURIComponent(code)}/edit`);
}
/* ── Schutzbedarf / TISAX-Level (Delta-Update) ── */
/** Globaler TISAX-Level: AL2 (very-high aus) / AL3 (very-high an); HIGH bleibt stets an. */
export async function setGlobalTisaxLevel(formData: FormData) {
const { session, db } = await guard();
const level = String(formData.get("level")) === "AL3" ? "AL3" : "AL2";
await db.policyVariable.updateMany({ where: { key: "FLAG_VERY_HIGH_PROTECTION" }, data: { value: level === "AL3" ? "true" : "false" } });
await db.policyVariable.updateMany({ where: { key: "FLAG_HIGH_PROTECTION" }, data: { value: "true" } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "tisax_level", after: { level } });
revalidatePath("/policies", "layout");
}
/** Schutzbedarf-Override je Richtlinie: "" = global, "AL2", "AL3". */
export async function setProtectionOverride(code: string, formData: FormData) {
const { session, db } = await guard();
const raw = String(formData.get("override") ?? "");
const override = raw === "AL2" || raw === "AL3" ? raw : null;
const doc = await db.policyDocument.findFirst({ where: { code } });
if (!doc) throw new Error("Dokument nicht gefunden");
await db.policyDocument.update({ where: { id: doc.id }, data: { protectionOverride: override } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document", entityId: doc.id, after: { protectionOverride: override } });
revalidatePath("/policies", "layout");
redirect(`/policies/${encodeURIComponent(code)}/edit`);
}
/* ── Freigabe-Workflow (Vier-Augen, §8) ── */
export async function submitForApproval(code: string) {