Richtlinien-Fixes: zentrale Variablen, Schutzbedarf zentral, Coverage nach Level

- Zentrale Variablen (Organisation + Rollen + Schutzbedarf-Flags, lib/policy-variables.ts)
  sind im Richtlinien-Editor ausgeblendet und werden nur noch in den Einstellungen
  gepflegt; updatePolicyVariable/updateScopedVariables blocken zentrale Keys zusätzlich
  serverseitig.
- Schutzbedarf/TISAX wird ausschließlich zentral gesteuert (Superadmin): Override je
  Richtlinie entfernt (setProtectionOverride + UI weg), globaler TISAX-Schalter im
  Policy-Modul entfernt (setGlobalTisaxLevel weg) → nur noch Read-only-Anzeige im
  Editor und in der Bibliothek. Rendering nutzt stets den globalen Level.
- Coverage-/Referenzmatrix zeigt nur Anforderungen des aktiven Assessment-Levels
  (Bedingung erfüllt): bei AL2 keine „sehr hoch"-Controls (browser-verifiziert:
  297 statt 316 Anforderungen, 0 SEHR HOCH); AL3 zeigt alle.

tsc + lint + build grün; Edit-Seite: Schutzbedarf read-only, zentrale Variablen gesperrt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 11:17:09 +02:00
co-authored by Claude Opus 4.8
parent d38b265e09
commit 959d8162d6
5 changed files with 49 additions and 64 deletions
+14 -18
View File
@@ -9,9 +9,10 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Pill } from "@/components/mockup-ui";
import { applyProtection, buildContext, effectiveLevel, renderPolicyHtml, splitPolicyDoc } from "@/lib/policy-render";
import { isCentralVariable } from "@/lib/policy-variables";
import { POLICY_STATUS_LABEL, POLICY_STATUS_TONE, POLICY_TYPE_LABEL } from "@/components/policy-modals";
import { PolicyExpertEditor } from "@/components/policy-expert-editor";
import { approvePolicy, rejectPolicy, setProtectionOverride, submitForApproval, updatePolicyTemplate, updateScopedVariables } from "@/server/actions/policies";
import { approvePolicy, rejectPolicy, submitForApproval, updatePolicyTemplate, updateScopedVariables } from "@/server/actions/policies";
const TEMPLATE_TYPES = new Set(["LEITLINIE", "RICHTLINIE", "VERFAHREN"]);
@@ -59,12 +60,14 @@ export default async function PolicyEditPage({
const userName = (id: string | null) => users.find((u) => u.id === id)?.name ?? id ?? "—";
const usedKeys = docVariableKeys(doc.rawMarkdown, baseline);
const docVars = variables.filter((v) => usedKeys.has(v.key) && v.kind !== "boolean");
// Zentrale Variablen (Organisation/Rollen) sind nur in den Einstellungen pflegbar → hier ausblenden.
const docVars = variables.filter((v) => usedKeys.has(v.key) && v.kind !== "boolean" && !isCentralVariable(v));
const keysCsv = docVars.map((v) => v.key).join(",");
const ctx = applyProtection(buildContext(variables), doc.protectionOverride);
// Schutzbedarf wird zentral gesteuert (Superadmin) — kein Override je Richtlinie mehr.
const ctx = applyProtection(buildContext(variables));
const globalVeryHigh = variables.find((v) => v.key === "FLAG_VERY_HIGH_PROTECTION")?.value === "true";
const effLevel = effectiveLevel(globalVeryHigh, doc.protectionOverride);
const effLevel = effectiveLevel(globalVeryHigh);
const { infoMd, bodyMd } = splitPolicyDoc(doc.rawMarkdown);
const stripBaseline = doc.code !== "BASELINE";
const bodyHtml = renderPolicyHtml(bodyMd, ctx, { readMode: true, stripBaseline });
@@ -182,25 +185,18 @@ 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">
{/* Schutzbedarf / TISAX-Level — zentral gesteuert (nur Anzeige) */}
<div 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>
<p className="mt-1 text-[12.5px]">{t("effectiveLevel")}: <b>{effLevel}</b></p>
<p className="mt-2 text-[11px] text-muted-foreground">Der Schutzbedarf wird zentral vom Plattform-Betreiber gesteuert und ist hier nicht änderbar.</p>
</div>
{/* Dokument-Variablen (§8 Variablen-Scoping) */}
{/* Dokument-Variablen (§8 Variablen-Scoping) — zentrale Variablen ausgenommen */}
<form action={updateScopedVariables.bind(null, decoded)} className="shadow-card rounded-2xl border bg-card p-4">
<input type="hidden" name="keys" value={keysCsv} />
<p className="font-heading text-sm font-semibold">{t("docVariables")}</p>
<p className="mt-1 mb-3 text-[11.5px] text-muted-foreground">{t("docVariablesHint")}</p>
<p className="mt-1 mb-3 text-[11.5px] text-muted-foreground">Zentrale Variablen (Unternehmensdaten, Rollen) werden ausschließlich in den Einstellungen gepflegt.</p>
<div className="space-y-2.5">
{docVars.length === 0 && <p className="text-[12.5px] text-muted-foreground">{tc("none")}</p>}
{docVars.map((v) => (
+12 -19
View File
@@ -8,7 +8,6 @@ import { FilterTabs } from "@/components/filter-tabs";
import { Button } from "@/components/ui/button";
import { controlTitle, compareControl } from "@/lib/control-titles";
import { applyProtection, buildContext, renderPolicyMarkdown } from "@/lib/policy-render";
import { setGlobalTisaxLevel } from "@/server/actions/policies";
import type { PolicyRequirement } from "@prisma/client";
import {
Table,
@@ -55,22 +54,13 @@ function ObligationBadge({ type }: { type: string }) {
);
}
/** Zentrale Schutzbedarf-/TISAX-Level-Einstellung (AL2/AL3) — mandantenweit. */
/** Schutzbedarf-/TISAX-Level — zentral vom Betreiber gesteuert (nur Anzeige). */
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>
<p className="mt-1.5 font-heading text-lg font-bold">{level}</p>
<p className="mt-1 text-[11px] text-muted-foreground">{level === "AL3" ? "MUSS · SOLL · HOCH · SEHR HOCH" : "MUSS · SOLL · HOCH"} · zentral gesteuert</p>
</div>
);
}
@@ -133,6 +123,9 @@ export default async function PoliciesPage({
]);
const titleOf = (code: string) => docs.find((d) => d.code === code)?.title ?? code;
const ctx = applyProtection(buildContext(variables));
// Coverage nach Assessment-Level: nur Anforderungen, deren Bedingung im effektiven
// Level erfüllt ist (AL2 blendet „sehr hoch" = FLAG_VERY_HIGH_PROTECTION aus).
const activeRequirements = requirements.filter((r) => !r.condition || ctx[r.condition] === true);
const clean = (s: string, n = 160) =>
renderPolicyMarkdown(s, ctx, { readMode: true, stripBaseline: true })
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
@@ -170,14 +163,14 @@ export default async function PoliciesPage({
/* ── Referenz-/Coverage-Matrix (§9.3, nach Mockup) ── */
if (isCoverage) {
const dir = params.dir === "doc" ? "doc" : "ctrl";
const controlCount = new Set(requirements.map((r) => r.control)).size;
const controlCount = new Set(activeRequirements.map((r) => r.control)).size;
const vasOf = (ids: string[]) => [...new Set(ids)].sort();
return (
<main className="flex-1 p-6">
{head}
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
<p className="text-[12.5px] text-muted-foreground">{t("coverageHint", { controls: controlCount, reqs: requirements.length })}</p>
<p className="text-[12.5px] text-muted-foreground">{t("coverageHint", { controls: controlCount, reqs: activeRequirements.length })}</p>
<div className="flex items-center gap-3">
<div className="inline-flex rounded-lg border bg-card p-1 text-[12px] font-semibold">
<Link href="/policies?view=coverage" className={dir === "ctrl" ? "rounded-md bg-[var(--sidebar-accent)] px-3 py-1.5 text-[var(--primary)]" : "px-3 py-1.5 text-muted-foreground hover:text-foreground"}>{t("byControl")}</Link>
@@ -199,8 +192,8 @@ export default async function PoliciesPage({
</tr>
</thead>
<tbody>
{[...new Set(requirements.map((r) => r.control))].sort(compareControl).map((control) => {
const list = requirements.filter((r) => r.control === control);
{[...new Set(activeRequirements.map((r) => r.control))].sort(compareControl).map((control) => {
const list = activeRequirements.filter((r) => r.control === control);
const vas = vasOf(list.flatMap((r) => r.vaCodes));
return (
<tr key={control} className="border-b align-top last:border-0">
@@ -241,8 +234,8 @@ export default async function PoliciesPage({
</tr>
</thead>
<tbody>
{[...new Set(requirements.map((r) => r.policyCode))].map((policy) => (
<PolicyGroup key={policy} policy={policy} title={titleOf(policy)} rows={requirements.filter((r) => r.policyCode === policy)} clean={clean} />
{[...new Set(activeRequirements.map((r) => r.policyCode))].map((policy) => (
<PolicyGroup key={policy} policy={policy} title={titleOf(policy)} rows={activeRequirements.filter((r) => r.policyCode === policy)} clean={clean} />
))}
</tbody>
</table>
+1 -1
View File
@@ -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 = applyProtection(buildContext(variables), doc.protectionOverride);
const ctx = applyProtection(buildContext(variables));
const { infoMd, bodyMd } = splitPolicyDoc(doc.rawMarkdown);
const stripBaseline = doc.code !== "BASELINE";
+15
View File
@@ -0,0 +1,15 @@
/**
* Abgrenzung zentraler" Richtlinien-Variablen (nur in den Einstellungen pflegbar,
* im Richtlinien-Editor gesperrt): Organisation (Unternehmensname/Scope, ),
* verantwortliche Rollen (ROLE_*) und die Schutzbedarf-Flags. Alles andere
* (dokument-/paketspezifische Variablen, eigene) bleibt im Editor pflegbar.
*
* Reine Funktion ohne Server-/Render-Abhängigkeiten (auch in Actions nutzbar).
*/
export const CENTRAL_VARIABLE_GROUPS = new Set(["Organisation", "Rollen"]);
export const CENTRAL_VARIABLE_KEYS = new Set(["FLAG_HIGH_PROTECTION", "FLAG_VERY_HIGH_PROTECTION"]);
export function isCentralVariable(v: { key: string; groupName?: string | null }): boolean {
if (v.groupName && CENTRAL_VARIABLE_GROUPS.has(v.groupName)) return true;
return CENTRAL_VARIABLE_KEYS.has(v.key);
}
+7 -26
View File
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
import { z } from "zod";
import { moduleGuard } from "@/server/action-guard";
import { type Permission } from "@/server/rbac";
import { isCentralVariable } from "@/lib/policy-variables";
import { writeAuditLog } from "@/server/audit";
const optDate = (v: FormDataEntryValue | null) => (v && String(v) ? new Date(String(v)) : null);
@@ -179,6 +180,8 @@ export async function updatePolicyTemplate(code: string, formData: FormData) {
/** Eine dokument-bezogene Variable ändern (eine Pflegestelle, §7/§8) — propagiert in alle Dokumente. */
export async function updatePolicyVariable(key: string, formData: FormData) {
const { session, db } = await guard();
const variable = await db.policyVariable.findFirst({ where: { key }, select: { key: true, groupName: true } });
if (variable && isCentralVariable(variable)) throw new Error("Zentrale Variablen werden ausschließlich in den Einstellungen gepflegt.");
const value = String(formData.get("value") ?? "");
await db.policyVariable.updateMany({ where: { key }, data: { value } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_variable", after: { key, value } });
@@ -193,8 +196,11 @@ export async function updatePolicyVariable(key: string, formData: FormData) {
export async function updateScopedVariables(code: string, formData: FormData) {
const { session, db } = await guard();
const keys = String(formData.get("keys") ?? "").split(",").map((k) => k.trim()).filter(Boolean);
// Zentrale Variablen (Organisation/Rollen) niemals aus dem Richtlinien-Editor überschreiben.
const rows = keys.length ? await db.policyVariable.findMany({ where: { key: { in: keys } }, select: { key: true, groupName: true } }) : [];
const centralKeys = new Set(rows.filter(isCentralVariable).map((r) => r.key));
for (const key of keys) {
if (!formData.has(`var_${key}`)) continue;
if (centralKeys.has(key) || !formData.has(`var_${key}`)) continue;
const value = String(formData.get(`var_${key}`) ?? "");
await db.policyVariable.updateMany({ where: { key }, data: { value } });
}
@@ -203,31 +209,6 @@ 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) {