Basis: Certvia dev@a48c5fb als Fundament für Craftvia
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s

Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation
und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 11:05:39 +02:00
co-authored by Claude Opus 5
commit c8e6f30a27
720 changed files with 140143 additions and 0 deletions
+160
View File
@@ -0,0 +1,160 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { requireSession } from "@/server/auth";
import { dbForTenant, prisma } from "@/server/db";
import { hashPassword, verifyPassword } from "@/server/password";
import { resolvePasswordPolicy, validatePassword } from "@/lib/password-policy";
import { verifyTotp, generateRecoveryCodes } from "@/server/mfa";
import { writeAuditLog } from "@/server/audit";
import { resolveMfaRequired } from "@/lib/mfa-policy";
/**
* Self-Service des angemeldeten Mandanten-Nutzers (EXEMPT vom Modul-Gating —
* keine Fachfunktion, eigene Auth über requireSession).
*/
/**
* Persönliche UI-Sprache der angemeldeten Person setzen (Option C: `Identity.uiLocale`).
* Folgt der Identity über alle Mandanten; getrennt von der Vorlagen-Import-Sprache
* (`TenantSettings.locale`). EXEMPT vom Modul-Gating (reine Selbstpräferenz).
*/
export async function setUiLocale(locale: "de" | "en") {
const session = await requireSession();
const identityId = session.user.identityId;
if (!identityId) return;
const value = locale === "en" ? "en" : "de";
await prisma.identity.update({ where: { id: identityId }, data: { uiLocale: value } });
// Die UI-Sprache wirkt auf jede Seite → das komplette Layout neu rendern.
revalidatePath("/", "layout");
}
export type ChangePwState = { status: "idle" } | { status: "error"; message: string };
/**
* Erzwungener Passwortwechsel beim ersten Login (mustChangePassword). Prüft die
* Passwort-Policy des Mandanten, setzt neuen Argon2id-Hash und löscht das Flag.
*/
export async function changeOwnPassword(_prev: ChangePwState, formData: FormData): Promise<ChangePwState> {
const session = await requireSession();
const db = dbForTenant(session.user.tenantId);
const identityId = session.user.identityId;
if (!identityId) return { status: "error", message: "Kein Identity-Kontext in der Session." };
// Option C (WS4): Passwort/MFA/mustChangePassword gehören der GLOBALEN Identity.
// Autoritativ aus der DB laden (F-08): mustChangePassword steuert die Re-Auth-
// Ausnahme, der aktuelle Hash wird serverseitig geprüft.
const identity = await prisma.identity.findUnique({ where: { id: identityId } });
if (!identity) return { status: "error", message: "Konto nicht gefunden." };
const pw = String(formData.get("password") ?? "");
const confirm = String(formData.get("confirm") ?? "");
if (pw !== confirm) return { status: "error", message: "Die beiden Passwörter stimmen nicht überein." };
// Re-Authentifizierung bei kritischer Kontoänderung (F-08): außerhalb des
// erzwungenen Erstwechsels ist das aktuelle Passwort Pflicht; bei aktiver MFA
// zusätzlich ein gültiger TOTP-Code. Ausnahme NUR beim erzwungenen Erstwechsel.
let totpStep: number | null = null;
if (!identity.mustChangePassword) {
const currentPw = String(formData.get("currentPassword") ?? "");
if (!currentPw || !(await verifyPassword(identity.passwordHash, currentPw))) {
return { status: "error", message: "Das aktuelle Passwort ist nicht korrekt." };
}
if (identity.mfaEnrolledAt && identity.mfaSecret) {
const code = String(formData.get("token") ?? "");
// Replay-Schutz (F-17): bereits verwendeter/älterer Zeitschritt wird abgelehnt.
const totp = verifyTotp(code, identity.mfaSecret, identity.lastTotpStep);
if (!totp.ok) {
return { status: "error", message: "Der MFA-Code ist ungültig. Bitte den aktuellen 6-stelligen Code eingeben." };
}
totpStep = totp.step;
}
}
const settings = await db.tenantSettings.findUnique({ where: { tenantId: session.user.tenantId } });
const policy = resolvePasswordPolicy(settings?.securityPolicy);
const missing = validatePassword(pw, policy);
if (missing.length) return { status: "error", message: `Das Passwort benötigt ${missing.join(", ")}.` };
await prisma.identity.update({
where: { id: identityId },
data: { passwordHash: await hashPassword(pw), mustChangePassword: false, ...(totpStep != null ? { lastTotpStep: BigInt(totpStep) } : {}) },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "user_password", entityId: session.user.id });
redirect("/dashboard");
}
/* ── Optionale MFA je Mandanten-Nutzer (Paket C) ── */
export type MfaEnrollState =
| { status: "idle" }
| { status: "error"; message: string }
| { status: "done"; recoveryCodes: string[] };
/** Bestätigt die eigene MFA-Einrichtung (TOTP-Code gegen das gespeicherte Secret). */
export async function confirmOwnMfaEnrollment(_prev: MfaEnrollState, formData: FormData): Promise<MfaEnrollState> {
const session = await requireSession();
const identityId = session.user.identityId;
if (!identityId) return { status: "error", message: "Kein Identity-Kontext in der Session." };
// Option C (WS4): MFA gehört der GLOBALEN Identity.
const identity = await prisma.identity.findUnique({ where: { id: identityId } });
if (!identity) return { status: "error", message: "Konto nicht gefunden." };
if (identity.mfaEnrolledAt) return { status: "error", message: "MFA ist bereits aktiv." };
if (!identity.mfaSecret) return { status: "error", message: "Kein Einrichtungs-Secret vorhanden. Bitte Seite neu laden." };
const code = String(formData.get("token") ?? "");
const totp = verifyTotp(code, identity.mfaSecret);
if (!totp.ok) return { status: "error", message: "Code ungültig. Bitte den aktuellen 6-stelligen Code eingeben." };
// F-17: den bei der Einrichtung akzeptierten Zeitschritt gleich als lastTotpStep
// festhalten, damit der Enrollment-Code nicht anschließend beim Login erneut gilt.
const { plain, hashed } = await generateRecoveryCodes(10);
await prisma.identity.update({ where: { id: identityId }, data: { mfaEnrolledAt: new Date(), recoveryCodes: hashed, lastTotpStep: BigInt(totp.step) } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "user_mfa", entityId: session.user.id, after: { mfaEnrolled: true } });
return { status: "done", recoveryCodes: plain };
}
/** Eigene MFA deaktivieren (nicht möglich, solange der Mandant MFA verlangt). */
export async function disableOwnMfa(formData: FormData) {
const session = await requireSession();
const db = dbForTenant(session.user.tenantId);
const settings = await db.tenantSettings.findUnique({ where: { tenantId: session.user.tenantId } });
if (resolveMfaRequired(settings?.securityPolicy)) throw new Error("MFA ist in diesem Mandanten verpflichtend und kann nicht deaktiviert werden.");
// Re-Authentifizierung (F-08): das Abschalten des zweiten Faktors darf nur mit
// gültigem TOTP-Code möglich sein. Prüfung gegen das Secret der GLOBALEN Identity.
const identityId = session.user.identityId;
if (!identityId) throw new Error("Kein Identity-Kontext in der Session.");
const identity = await prisma.identity.findUnique({ where: { id: identityId } });
if (!identity?.mfaEnrolledAt || !identity.mfaSecret) throw new Error("MFA ist nicht aktiv.");
const code = String(formData.get("token") ?? "");
// Replay-Schutz (F-17): ein bereits verwendeter Code kann MFA nicht deaktivieren.
if (!verifyTotp(code, identity.mfaSecret, identity.lastTotpStep).ok) throw new Error("Der MFA-Code ist ungültig. MFA wurde nicht deaktiviert.");
await prisma.identity.update({ where: { id: identityId }, data: { mfaSecret: null, mfaEnrolledAt: null, recoveryCodes: [], lastTotpStep: null } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "user_mfa", entityId: session.user.id, after: { mfaDisabled: true } });
revalidatePath("/account");
}
/**
* SEC3-c: Recovery-Codes neu erzeugen (Step-up per aktuellem TOTP-Code, F-08). Die alten
* Codes werden dabei entwertet; die neuen werden — wie bei der Ersteinrichtung — nur
* einmal im Klartext zurückgegeben und gehasht gespeichert.
*/
export async function regenerateOwnRecoveryCodes(_prev: MfaEnrollState, formData: FormData): Promise<MfaEnrollState> {
const session = await requireSession();
const identityId = session.user.identityId;
if (!identityId) return { status: "error", message: "Kein Identity-Kontext in der Session." };
// Option C (WS4): Recovery-Codes gehören der GLOBALEN Identity.
const identity = await prisma.identity.findUnique({ where: { id: identityId } });
if (!identity?.mfaEnrolledAt || !identity.mfaSecret) return { status: "error", message: "MFA ist nicht aktiv." };
const code = String(formData.get("token") ?? "");
if (!verifyTotp(code, identity.mfaSecret, identity.lastTotpStep).ok) return { status: "error", message: "Der MFA-Code ist ungültig. Recovery-Codes wurden nicht erneuert." };
const { plain, hashed } = await generateRecoveryCodes();
await prisma.identity.update({ where: { id: identityId }, data: { recoveryCodes: hashed } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "user_mfa", entityId: session.user.id, after: { recoveryCodesRegenerated: true } });
revalidatePath("/account");
return { status: "done", recoveryCodes: plain };
}
+272
View File
@@ -0,0 +1,272 @@
"use server";
import { join } from "node:path";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import type { Framework } from "@prisma/client";
import { requirePlatformFullAdmin } from "@/server/platform-auth";
import { prisma, dbForTenant } from "@/server/db";
import { provisionTenant } from "@/server/provision";
import { reconcilePackage, stampPackageVersion } from "../../../prisma/import-policies";
import { resolvePackageForTenant, getTenantFrameworks } from "../../../prisma/template-store";
import { deriveDomainsCore } from "@/server/policies/derive-domains";
import { protectionFlags } from "@/server/assessment-level";
import { DEFAULT_PASSWORD_POLICY, validatePassword } from "@/lib/password-policy";
const POLICY_SEED_DIR = join(process.cwd(), "seed", "isms-vorlagenpaket-v2");
/**
* Nur Plattform-**Voll**-Admins (SEC4: Read-only-Admins dürfen Mandanten nicht verändern).
* Getrennte Plattform-Session (kein Mandantenkontext).
*/
async function requirePlatformAdmin() {
const { session } = await requirePlatformFullAdmin();
return session;
}
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
const slugify = (s: string) =>
s.toLowerCase().normalize("NFKD").replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "kunde";
export async function createTenant(formData: FormData) {
const session = await requirePlatformAdmin();
const name = z.string().trim().min(2).parse(formData.get("name"));
const slug = str(formData.get("slug")) || slugify(name);
const adminEmail = z.string().trim().email().parse(formData.get("adminEmail"));
const adminName = str(formData.get("adminName")) || "Administrator";
// F-20: Initialpasswort gegen die vorhandene Policy prüfen (konsistent zu
// tenant-users.ts/platform-users.ts) statt nur min(8). Der Mandant existiert
// noch nicht, daher der Plattform-Default DEFAULT_PASSWORD_POLICY.
const adminPassword = z.string().parse(formData.get("adminPassword"));
const pwMissing = validatePassword(adminPassword, DEFAULT_PASSWORD_POLICY);
if (pwMissing.length) throw new Error(`Das Initialpasswort benötigt ${pwMissing.join(", ")}.`);
const tisaxLevel = str(formData.get("tisaxLevel")) === "AL3" ? "AL3" : "AL2";
const seedPolicies = formData.get("seedPolicies") === "on";
// AP2: geführte Rahmenwerke aus den Checkboxen (Reihenfolge = Primär zuerst; TISAX
// vor ISO). Nichts gewählt → TISAX (rückwärtskompatibler Default).
const frameworks: Framework[] = [];
if (formData.get("fw_tisax") === "on") frameworks.push("TISAX");
if (formData.get("fw_iso") === "on") frameworks.push("ISO_27001");
if (frameworks.length === 0) frameworks.push("TISAX");
const existing = await prisma.tenant.findUnique({ where: { slug } });
if (existing) throw new Error(`Kürzel „${slug}" ist bereits vergeben.`);
const tenant = await provisionTenant(prisma, {
name,
slug,
short: str(formData.get("short")) || undefined,
sector: str(formData.get("sector")) || undefined,
admin: { email: adminEmail, name: adminName, password: adminPassword },
tisaxLevel,
frameworks,
seedPoliciesDir: seedPolicies ? join(process.cwd(), "seed", "isms-vorlagenpaket-v2") : undefined,
actorId: session.user.id,
});
revalidatePath("/admin");
redirect(`/admin?created=${tenant.id}`);
}
export async function setTenantStatus(tenantId: string, status: "ACTIVE" | "SUSPENDED" | "ARCHIVED") {
const session = await requirePlatformAdmin();
await prisma.tenant.update({ where: { id: tenantId }, data: { status } });
await prisma.auditLog.create({
data: { tenantId, scope: "platform", actorId: session.user.id, action: "update", entity: "tenant", entityId: tenantId, after: { status } },
});
revalidatePath("/admin");
revalidatePath(`/admin/${tenantId}`);
}
/**
* Kern-Einstellung (nur Superadmin): Assessment-Level (AL2/AL3) des Mandanten —
* einzige Quelle des Schutzbedarfs (Story A2-1). Spiegelt die daraus abgeleiteten
* zentralen Schutzbedarf-Flags ins Richtlinienmodul (`protectionFlags`).
*/
export async function setTenantTisaxLevel(tenantId: string, level: "AL2" | "AL3") {
const session = await requirePlatformAdmin();
const db = dbForTenant(tenantId);
await db.tenantSettings.update({ where: { tenantId }, data: { tisaxLevel: level } });
const flags = protectionFlags(level); // einzige AL→Flag-Ableitung
await db.policyVariable.updateMany({ where: { key: "FLAG_HIGH_PROTECTION" }, data: { value: String(flags.FLAG_HIGH_PROTECTION) } });
await db.policyVariable.updateMany({ where: { key: "FLAG_VERY_HIGH_PROTECTION" }, data: { value: String(flags.FLAG_VERY_HIGH_PROTECTION) } });
await prisma.auditLog.create({
data: { tenantId, scope: "platform", actorId: session.user.id, action: "update", entity: "assessment_level", entityId: tenantId, after: { level } },
});
revalidatePath(`/admin/${tenantId}`);
}
/**
* Sprachwahl je Mandant (de|en). Steuert, in welcher Sprache das Vorlagenpaket importiert
* wird (resolvePackageForTenant). Wirkt auf künftige Importe/Updates; bereits importierte
* Richtlinien bleiben unverändert, bis der Mandant unter /policies/updates übernimmt.
*/
export async function setTenantLocale(tenantId: string, locale: string) {
const session = await requirePlatformAdmin();
const loc = locale === "en" ? "en" : "de";
await dbForTenant(tenantId).tenantSettings.update({ where: { tenantId }, data: { locale: loc } });
await prisma.auditLog.create({
data: { tenantId, scope: "platform", actorId: session.user.id, action: "update", entity: "tenant_locale", entityId: tenantId, after: { locale: loc } },
});
revalidatePath(`/admin/${tenantId}`);
}
/**
* SEC3-a: MFA-Pflicht je Mandant setzen (Superadmin). Wird in `securityPolicy.mfaRequired`
* abgelegt; das (app)-Layout erzwingt daraufhin die MFA-Einrichtung beim Login. Bestehende
* securityPolicy-Felder (z. B. Passwort-Policy) bleiben erhalten.
*/
export async function setTenantMfaRequired(tenantId: string, required: boolean) {
const session = await requirePlatformAdmin();
const db = dbForTenant(tenantId);
const current = await db.tenantSettings.findFirst({ select: { securityPolicy: true } });
const sp = (current?.securityPolicy && typeof current.securityPolicy === "object" ? current.securityPolicy : {}) as Record<string, unknown>;
await db.tenantSettings.update({ where: { tenantId }, data: { securityPolicy: { ...sp, mfaRequired: required } } });
await prisma.auditLog.create({
data: { tenantId, scope: "platform", actorId: session.user.id, action: "update", entity: "tenant_mfa_policy", entityId: tenantId, after: { mfaRequired: required } },
});
revalidatePath(`/admin/${tenantId}`);
}
export async function toggleTenantModule(tenantId: string, moduleKey: string, enabled: boolean) {
const session = await requirePlatformAdmin();
await prisma.tenantModule.upsert({
where: { tenantId_moduleKey: { tenantId, moduleKey } },
update: { enabled },
create: { tenantId, moduleKey, enabled },
});
await prisma.auditLog.create({
data: { tenantId, scope: "platform", actorId: session.user.id, action: "update", entity: "tenant_module", entityId: moduleKey, after: { enabled } },
});
// Story B4-1: Aktivierung des Richtlinien-Moduls importiert das Vorlagenpaket
// mandantenweit nicht-destruktiv (idempotent; erhält Status/Freigabe/Overrides/Variablen).
if (moduleKey === "policies" && enabled) {
// AP1: je Framework des Mandanten importieren (Anforderungen framework-scoped,
// geteilte Inhalte nur beim ersten). Einzel-Framework = unverändert.
const frameworks = await getTenantFrameworks(prisma, tenantId);
for (const [i, framework] of frameworks.entries()) {
const { pkg } = await resolvePackageForTenant(prisma, tenantId, POLICY_SEED_DIR, framework);
await reconcilePackage(prisma, tenantId, pkg, { actorId: session.user.id, framework, reconcileShared: i === 0 });
await stampPackageVersion(prisma, tenantId, pkg.version, framework); // Story B6
}
revalidatePath("/policies", "layout");
}
revalidatePath(`/admin/${tenantId}`);
// Die Mandanten-Navigation (src/app/(app)/layout.tsx) rendert die Modul-Links pro
// Seitenaufruf server-seitig aus `tenantModule`. Ohne Revalidierung der (app)-Routen
// bliebe der Server-Cache stehen → ein deaktiviertes Modul verschwände erst verzögert
// aus der Sidebar. `layout` invalidiert die gesamte Mandanten-Shell.
revalidatePath("/", "layout");
}
/**
* Frameworks eines bestehenden Mandanten setzen (ISO 27001 und/oder TISAX).
*
* Bisher wurde die Framework-Zugehörigkeit ausschließlich beim Anlegen vergeben
* (`provisionTenant`); ein Bestandsmandant liess sich weder auf ISO umstellen noch ein
* Framework wieder abschalten. Diese Aktion schliesst beides.
*
* Ablauf:
* 1. Zugehörigkeit setzen — erstes Element der Liste ist das Primär-Framework.
* 2. Neu hinzugekommenes Framework: Vorlagenpaket importieren. Der geteilte
* Dokumentensatz wird dabei einmal mit abgeglichen, damit Anforderungen des neuen
* Frameworks nicht auf Dokumente zeigen, die der Mandant noch nicht hat (z. B. VA-21/22).
* Der Abgleich ist nicht-destruktiv: Status, Freigabe, Overrides und gepflegte
* Variablenwerte bleiben erhalten.
* 3. Entferntes Framework: die Zugehörigkeit fällt weg, die **Bewertungen bleiben**.
* Anforderungen werden über `archivedAt` stillgelegt statt gelöscht — der Nachweis,
* dass in der Vergangenheit geprüft wurde, gehört zum Audit-Trail. Ein späteres
* Wiedereinschalten reaktiviert sie über den Re-Import.
* `SoaEntry` bleibt unangetastet: die SoA-Sicht blendet sich ohne aktives
* ISO-Framework ohnehin aus, und so überlebt jede Begründung eine Pause unverändert.
* 4. Sichtbarkeits-Flags nachziehen — sonst führte der Mandant die Norm im Datenmodell,
* sähe in den Dokumenten aber weiter die Anforderungssicht des anderen Frameworks.
*
* Mindestens ein Framework muss aktiv bleiben; ohne Rahmenwerk hätte der Mandant kein
* Regelwerk mehr.
*/
export async function setTenantFrameworks(tenantId: string, frameworks: Framework[]) {
const session = await requirePlatformAdmin();
const wanted = [...new Set(frameworks)];
if (wanted.length === 0) throw new Error("Mindestens ein Framework muss aktiv bleiben.");
const current = await getTenantFrameworks(prisma, tenantId);
const added = wanted.filter((f) => !current.includes(f));
const removed = current.filter((f) => !wanted.includes(f));
const db = dbForTenant(tenantId);
// 1. Zugehörigkeit (erstes = primär)
for (const [i, framework] of wanted.entries()) {
await prisma.tenantFramework.upsert({
where: { tenantId_framework: { tenantId, framework } },
update: { isPrimary: i === 0 },
create: { tenantId, framework, isPrimary: i === 0 },
});
}
// 2. Hinzugekommene Frameworks bestücken
for (const [i, framework] of added.entries()) {
const { pkg } = await resolvePackageForTenant(prisma, tenantId, POLICY_SEED_DIR, framework);
await reconcilePackage(prisma, tenantId, pkg, {
actorId: session.user.id,
framework,
// Geteilte Inhalte einmal mitnehmen: der Dokumentensatz kann älter sein als das Paket.
reconcileShared: i === 0,
});
await stampPackageVersion(prisma, tenantId, pkg.version, framework);
}
// 3. Entfernte Frameworks abmelden — stilllegen, nicht löschen
if (removed.length > 0) {
await prisma.tenantFramework.deleteMany({ where: { tenantId, framework: { in: removed } } });
await db.policyRequirement.updateMany({
where: { framework: { in: removed }, archivedAt: null },
data: { archivedAt: new Date() },
});
}
// 4. Sichtbarkeits-Flags im Richtlinienmodul nachziehen
await db.policyVariable.updateMany({
where: { key: "FLAG_FW_TISAX" },
data: { value: String(wanted.includes("TISAX")) },
});
await db.policyVariable.updateMany({
where: { key: "FLAG_FW_ISO27001" },
data: { value: String(wanted.includes("ISO_27001")) },
});
if (added.length > 0) await deriveDomainsCore(db, tenantId);
await prisma.auditLog.create({
data: {
tenantId, scope: "platform", actorId: session.user.id, action: "update",
entity: "tenant_framework", entityId: tenantId,
before: { frameworks: current }, after: { frameworks: wanted, added, removed },
},
});
revalidatePath(`/admin/${tenantId}`);
revalidatePath("/policies", "layout");
revalidatePath("/", "layout");
}
/**
* Story B4-1: Plattform-Admin stößt den Vorlagen-Import/-Aktualisierung für einen
* Mandanten manuell an (Button im Admin). Nicht-destruktiv (siehe importPolicies).
*/
export async function importPolicyPackageForTenant(tenantId: string) {
const session = await requirePlatformAdmin();
// AP1: je Framework des Mandanten (framework-scoped; geteilte Inhalte nur beim ersten).
const frameworks = await getTenantFrameworks(prisma, tenantId);
for (const [i, framework] of frameworks.entries()) {
const { pkg } = await resolvePackageForTenant(prisma, tenantId, POLICY_SEED_DIR, framework);
await reconcilePackage(prisma, tenantId, pkg, { actorId: session.user.id, framework, reconcileShared: i === 0 });
await stampPackageVersion(prisma, tenantId, pkg.version, framework); // Story B6
}
await deriveDomainsCore(dbForTenant(tenantId), tenantId); // Fachbereiche ableiten
await prisma.auditLog.create({
data: { tenantId, scope: "platform", actorId: session.user.id, action: "update", entity: "policy_package", after: { manualImport: true } },
});
revalidatePath(`/admin/${tenantId}`);
revalidatePath("/policies", "layout");
}
+163
View File
@@ -0,0 +1,163 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
const guard = moduleGuard("assets");
const level = z.coerce.number().int().min(1).max(4);
const assetSchema = z.object({
name: z.string().trim().min(1).max(200),
description: z.string().trim().max(5000).optional(),
type: z.enum(["INFORMATION", "SYSTEM", "APPLICATION", "LOCATION", "SUPPLIER", "IT_SERVICE", "PERSON", "DATA"]),
status: z.enum(["ACTIVE", "PLANNED", "RETIRED"]),
ownerId: z.string().optional(),
location: z.string().trim().max(200).optional(),
tags: z.string().optional(),
confidentiality: level,
integrity: level,
availability: level,
});
function parseAssetForm(formData: FormData) {
const parsed = assetSchema.parse({
name: formData.get("name"),
description: formData.get("description") || undefined,
type: formData.get("type"),
status: formData.get("status"),
ownerId: formData.get("ownerId") || undefined,
location: formData.get("location") || undefined,
tags: formData.get("tags") || undefined,
confidentiality: formData.get("confidentiality"),
integrity: formData.get("integrity"),
availability: formData.get("availability"),
});
return {
...parsed,
ownerId: parsed.ownerId || null,
description: parsed.description || null,
location: parsed.location || null,
tags: parsed.tags
? parsed.tags.split(",").map((t) => t.trim()).filter(Boolean)
: [],
};
}
export async function createAsset(formData: FormData) {
const { session, db } = await guard("asset:write");
const data = parseAssetForm(formData);
// tenantId doppelt gemoppelt: explizit für die Typen, der Guard injiziert ohnehin
const asset = await db.asset.create({
data: { ...data, tenantId: session.user.tenantId, createdBy: session.user.id },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "asset",
entityId: asset.id,
after: data,
});
revalidatePath("/assets");
redirect(`/assets?detail=${asset.id}`);
}
export async function updateAsset(assetId: string, formData: FormData) {
const { session, db } = await guard("asset:write");
const before = await db.asset.findUnique({ where: { id: assetId } });
if (!before) throw new Error("Asset nicht gefunden");
const data = parseAssetForm(formData);
await db.asset.update({ where: { id: assetId }, data });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "asset",
entityId: assetId,
before,
after: data,
});
revalidatePath("/assets");
redirect(`/assets?detail=${assetId}`);
}
export async function deleteAsset(assetId: string) {
const { session, db } = await guard("asset:write");
const before = await db.asset.findUnique({ where: { id: assetId } });
if (!before) throw new Error("Asset nicht gefunden");
await db.asset.delete({ where: { id: assetId } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "asset",
entityId: assetId,
before,
});
revalidatePath("/assets");
redirect("/assets");
}
export async function addAssetRelation(assetId: string, formData: FormData) {
const { session, db } = await guard("asset:write");
const relatedAssetId = z.string().min(1).parse(formData.get("relatedAssetId"));
if (relatedAssetId === assetId) return;
// Beide Assets müssen zum Mandanten gehören (Guard filtert, count prüft)
const count = await db.asset.count({ where: { id: { in: [assetId, relatedAssetId] } } });
if (count !== 2) throw new Error("Asset nicht gefunden");
await db.assetRelation.upsert({
where: {
assetId_relatedAssetId_type: { assetId, relatedAssetId, type: "depends_on" },
},
update: {},
create: {
assetId,
relatedAssetId,
type: "depends_on",
tenantId: session.user.tenantId,
},
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "asset_relation",
entityId: assetId,
after: { assetId, relatedAssetId, type: "depends_on" },
});
revalidatePath("/assets");
}
export async function removeAssetRelation(assetId: string, relationId: string) {
const { session, db } = await guard("asset:write");
const before = await db.assetRelation.findUnique({ where: { id: relationId } });
if (!before) return;
await db.assetRelation.delete({ where: { id: relationId } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "asset_relation",
entityId: relationId,
before,
});
revalidatePath("/assets");
}
+314
View File
@@ -0,0 +1,314 @@
"use server";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import type { TenantDb } from "@/server/db";
import { writeAuditLog } from "@/server/audit";
import { isAuditReadinessEnabled } from "@/lib/audit-readiness/activation";
import { loadScopeInput, controlsInScope } from "@/server/soa-context";
import { resolveControlDomain } from "@/server/control-domain";
import { createTask } from "@/server/actions/tasks";
import { storage } from "@/server/storage/adapter";
import { controlTitle } from "@/lib/control-titles";
/**
* Nachweise-Tab der Audit-Vorbereitung (V5B, M4). Erzeugt je In-Scope-Control einen
* bereitzustellenden Nachweis (`AuditEvidenceItem`), ordnet automatisch den fachlich
* zuständigen Ansprechpartner zu, erlaubt das Umhängen, erzeugt Nachweis-Aufgaben
* (`evidence_provide`) und nimmt den direkten Datei-Upload je Item entgegen.
*
* `moduleGuard("audit")` liefert die F-06-Autoritätsprüfungen (Kontostatus/Token/
* Rechte) und setzt die Modul-Aktivierung `audit` voraus. Der Wrapper `guard`
* erzwingt zusätzlich `isAuditReadinessEnabled` (Opt-in, Default = aus), analog zu
* `src/server/actions/audit-readiness.ts`.
*/
const rawGuard = moduleGuard("audit");
/** Autoritätsprüfung + Scharfschaltung des Audit-Wizards; wirft, wenn nicht aktiviert. */
async function guard(...permissions: Parameters<typeof rawGuard>) {
const { session, db } = await rawGuard(...permissions);
if (!(await isAuditReadinessEnabled(db))) throw new Error("Audit-Wizard ist nicht aktiviert.");
return { session, db };
}
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
const NACHWEISE_PATH = "/audit-readiness/[auditId]/nachweise";
/** Generische Nachweisart, wenn zum Control kein Umsetzungshinweis existiert. */
const GENERIC_EVIDENCE = "Richtlinie/Nachweis der Anwendung";
/** Audit laden + Existenz sichern (db ist mandantengebunden → Fremd-Tenant scheidet aus). */
async function requireAudit(db: TenantDb, auditId: string) {
const audit = await db.audit.findUnique({ where: { id: auditId } });
if (!audit) throw new Error("Audit nicht gefunden.");
return audit;
}
/**
* Ansprechpartner für ein Control ermitteln: Control → Bereich/Funktion
* (`resolveControlDomain`, RESPONSIBLE) → `ProjectFunctionAssignment.userId`.
* Unbesetzte Funktion → userId null (Item bleibt offen).
*/
async function resolveOwnerForControl(
db: TenantDb,
tenantId: string,
control: string,
usersByFunction: Map<string, string>,
): Promise<{ functionKey: string | null; userId: string | null }> {
const { rules } = await resolveControlDomain(db, tenantId, control);
const primary = rules.find((r) => r.raci === "RESPONSIBLE") ?? rules[0];
const functionKey = primary?.functionKey ?? null;
const userId = functionKey ? (usersByFunction.get(functionKey) ?? null) : null;
return { functionKey, userId };
}
/**
* Nachweisliste generieren (idempotent). Für jedes In-Scope-Control wird — falls noch
* nicht vorhanden — ein `AuditEvidenceItem` angelegt. Die Nachweisart stammt aus dem
* Umsetzungshinweis (`ImplementationHint.evidence`, global je Control); fehlt einer,
* greift die generische Nachweisart. Der Ansprechpartner wird automatisch über
* `resolveControlDomain` → Funktion → `ProjectFunctionAssignment` zugeordnet.
*/
export async function generateEvidenceList(auditId: string) {
const { session, db } = await guard("onboarding:use");
const tenantId = session.user.tenantId;
const audit = await requireAudit(db, auditId);
const { input } = await loadScopeInput(db, tenantId);
const controls = controlsInScope(input);
// Bereits vorhandene Items (Idempotenz je Control).
const existing = await db.auditEvidenceItem.findMany({ where: { auditId }, select: { control: true } });
const known = new Set(existing.map((e) => e.control));
// Nachweisart je Control aus den Umsetzungshinweisen (global). Erster Hinweis je
// Control liefert Nachweisart + reqId; mehrere Nachweise werden knapp zusammengeführt.
const hints = await db.implementationHint.findMany({ orderBy: { reqId: "asc" }, select: { control: true, reqId: true, evidence: true } });
const hintByControl = new Map<string, { reqId: string; evidence: string }>();
for (const h of hints) {
if (!hintByControl.has(h.control) && h.evidence.trim()) {
hintByControl.set(h.control, { reqId: h.reqId, evidence: h.evidence.trim() });
}
}
// Funktion → User einmalig laden (db ist tenant-gebunden).
const assignments = await db.projectFunctionAssignment.findMany({ where: { userId: { not: null } }, select: { functionKey: true, userId: true } });
const usersByFunction = new Map<string, string>();
for (const a of assignments) {
if (a.userId && !usersByFunction.has(a.functionKey)) usersByFunction.set(a.functionKey, a.userId);
}
let created = 0;
for (const control of controls) {
if (known.has(control)) continue;
const hint = hintByControl.get(control);
const title = (hint?.evidence ?? GENERIC_EVIDENCE).slice(0, 200);
const { functionKey, userId } = await resolveOwnerForControl(db, tenantId, control, usersByFunction);
await db.auditEvidenceItem.create({
data: {
tenantId,
auditId,
control,
title,
reqId: hint?.reqId ?? null,
assignedFunctionKey: functionKey,
assignedUserId: userId,
status: "offen",
},
});
created++;
}
await writeAuditLog({
tenantId, actorId: session.user.id,
action: "create", entity: "audit_evidence_list", entityId: auditId,
after: { created, total: controls.length, auditTitle: audit.title },
});
revalidatePath(NACHWEISE_PATH, "page");
}
/** Item laden + zum Audit gehörig sichern. */
async function requireItem(db: TenantDb, itemId: string) {
const item = await db.auditEvidenceItem.findUnique({ where: { id: itemId } });
if (!item) throw new Error("Nachweis-Position nicht gefunden.");
return item;
}
/**
* Ansprechpartner eines Nachweis-Items umhängen (Dropdown). Leerer Wert → offen lassen.
* Der neue Ansprechpartner muss ein aktiver Nutzer desselben Mandanten sein.
*/
export async function reassignEvidenceItem(itemId: string, formData: FormData) {
const { session, db } = await guard("onboarding:use");
await requireItem(db, itemId); // Existenz/Mandantenbindung sichern
const userIdRaw = str(formData.get("userId"));
let assignedUserId: string | null = null;
if (userIdRaw) {
const user = await db.user.findFirst({ where: { id: userIdRaw, status: "ACTIVE" }, select: { id: true } });
if (!user) throw new Error("Ausgewählte Person ist kein aktiver Nutzer dieses Mandanten.");
assignedUserId = user.id;
}
await db.auditEvidenceItem.update({ where: { id: itemId }, data: { assignedUserId } });
await writeAuditLog({
tenantId: session.user.tenantId, actorId: session.user.id,
action: "update", entity: "audit_evidence_item", entityId: itemId,
after: { assignedUserId },
});
revalidatePath(NACHWEISE_PATH, "page");
}
/**
* Für ein offenes Nachweis-Item eine `evidence_provide`-Aufgabe beim Ansprechpartner
* anlegen und verlinken (`AuditEvidenceItem.taskId`). Frist = `Audit.preparationDeadline`.
* `createTask` setzt Bereich (`domain`) aus dem Control und die RACI-Mitwirkenden.
* Idempotent: existiert bereits eine Aufgabe für das Item, passiert nichts.
*/
export async function createEvidenceTask(itemId: string) {
const { db } = await guard("onboarding:use");
const item = await requireItem(db, itemId);
if (item.taskId) return; // bereits verknüpft
const audit = await requireAudit(db, item.auditId);
const task = await createTask({
type: "evidence_provide",
title: `Nachweis bereitstellen: ${item.control} — ${item.title}`.slice(0, 200),
description: `Für das Audit „${audit.title}" ist ein Nachweis zu Control ${item.control} (${controlTitle(item.control)}) bereitzustellen.`.slice(0, 5000),
origin: `audit:evidence:${item.auditId}`,
owner: item.assignedUserId ?? undefined,
dueDate: audit.preparationDeadline ?? undefined,
priority: "hoch",
links: { control: item.control },
});
await db.auditEvidenceItem.update({ where: { id: itemId }, data: { taskId: task.id } });
revalidatePath(NACHWEISE_PATH, "page");
}
/**
* Für alle offenen Items eines Audits ohne verknüpfte Aufgabe je eine
* `evidence_provide`-Aufgabe erzeugen. Idempotent (überspringt bereits verknüpfte).
*/
export async function createEvidenceTasksForAudit(auditId: string) {
const { db } = await guard("onboarding:use");
await requireAudit(db, auditId);
const items = await db.auditEvidenceItem.findMany({
where: { auditId, status: "offen", taskId: null },
select: { id: true },
});
for (const { id } of items) {
await createEvidenceTask(id);
}
revalidatePath(NACHWEISE_PATH, "page");
}
// ── Datei-Upload je Item (Validierung analog policy-upload.ts, F-15) ──────────
//
// Phase 1: Ablage über den gekapselten Storage-Adapter (Stub — speichert nur
// Metadaten/Key, keine Bytes; echte MinIO-Byte-Speicherung ist Folge-Epic). Der
// Upload-Flow inkl. Evidence-Verknüpfung und Statuswechsel ist voll funktionsfähig.
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024; // 20 MB
type UploadKind = "pdf" | "docx" | "odt";
const ALLOWED_EXT: Record<string, UploadKind> = { pdf: "pdf", docx: "docx", odt: "odt" };
const CANONICAL_MIME: Record<UploadKind, string> = {
pdf: "application/pdf",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
odt: "application/vnd.oasis.opendocument.text",
};
const ALLOWED_CLIENT_MIME: Record<UploadKind, string[]> = {
pdf: ["application/pdf"],
docx: ["application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/octet-stream", ""],
odt: ["application/vnd.oasis.opendocument.text", "application/octet-stream", ""],
};
const startsWith = (bytes: Uint8Array, sig: number[]) => sig.every((b, i) => bytes[i] === b);
const PDF_MAGIC = [0x25, 0x50, 0x44, 0x46]; // "%PDF"
const ZIP_MAGIC = [0x50, 0x4b, 0x03, 0x04]; // "PK\x03\x04" (DOCX/ODT = ZIP-Container)
function magicMatches(kind: UploadKind, bytes: Uint8Array): boolean {
if (kind === "pdf") return startsWith(bytes, PDF_MAGIC);
if (!startsWith(bytes, ZIP_MAGIC)) return false;
if (kind === "odt") {
const head = Buffer.from(bytes.slice(0, 80)).toString("latin1");
return head.includes("opendocument.text");
}
return true; // docx: ZIP-Signatur genügt (OOXML), Endung + Client-MIME grenzen ein.
}
function extOf(filename: string): string {
const dot = filename.lastIndexOf(".");
return dot >= 0 ? filename.slice(dot + 1).toLowerCase() : "";
}
/**
* Datei-Upload je Nachweis-Item. Legt die Datei über den Storage-Adapter (Stub) ab,
* erzeugt eine `Evidence`-Zeile (mit `control`), verknüpft `AuditEvidenceItem.evidenceId`,
* setzt den Status auf `bereitgestellt` und schließt eine ggf. verknüpfte Aufgabe (DONE).
* Vollständige Eingangsvalidierung (Größe/Endung/MIME/Magic-Bytes), da `f.type` nicht
* vertrauenswürdig ist.
*/
export async function uploadEvidenceFile(itemId: string, formData: FormData) {
const { session, db } = await guard("onboarding:use");
const tenantId = session.user.tenantId;
const item = await requireItem(db, itemId);
const file = formData.get("file");
if (!file || typeof file !== "object" || !("arrayBuffer" in file) || (file as File).size === 0) {
throw new Error("Bitte eine Datei auswählen.");
}
const f = file as File;
// (1) Größenlimit VOR dem Einlesen (DoS-Schutz).
if (f.size > MAX_UPLOAD_BYTES) {
throw new Error(`Datei zu groß (max. ${Math.floor(MAX_UPLOAD_BYTES / (1024 * 1024))} MB).`);
}
// (2) Endungs-Allowlist.
const ext = extOf(f.name);
const kind = ALLOWED_EXT[ext];
if (!kind) throw new Error("Dateityp nicht erlaubt. Zulässig: PDF, DOCX, ODT.");
// (3) Client-MIME nur als Zusatzsignal.
const clientMime = (f.type || "").toLowerCase();
if (!ALLOWED_CLIENT_MIME[kind].includes(clientMime)) throw new Error("MIME-Typ passt nicht zur Dateiendung.");
// (4) Inhalt einlesen + Magic-Bytes prüfen (maßgeblich).
const bytes = new Uint8Array(await f.arrayBuffer());
if (!magicMatches(kind, bytes)) throw new Error("Dateiinhalt passt nicht zum angegebenen Typ.");
const stored = await storage.put({ tenantId, filename: f.name, contentType: CANONICAL_MIME[kind], bytes });
// Evidence-Zeile (mit control). Bei bereits verknüpfter Aufgabe daran hängen.
const evidence = await db.evidence.create({
data: {
tenantId,
title: `${item.control} — ${stored.filename}`.slice(0, 200),
kind: "record",
fileRef: stored.storageKey,
control: item.control,
taskId: item.taskId,
createdById: session.user.id,
},
select: { id: true },
});
await db.auditEvidenceItem.update({
where: { id: itemId },
data: { evidenceId: evidence.id, status: "bereitgestellt" },
});
// Verknüpfte Aufgabe erledigt sich.
if (item.taskId) {
await db.task.update({
where: { id: item.taskId },
data: { status: "DONE", resolvedById: session.user.id, resolvedAt: new Date() },
});
}
await writeAuditLog({
tenantId, actorId: session.user.id,
action: "create", entity: "audit_evidence_upload", entityId: itemId,
after: { evidenceId: evidence.id, storageKey: stored.storageKey, size: stored.size, taskDone: Boolean(item.taskId) },
});
revalidatePath(NACHWEISE_PATH, "page");
revalidatePath("/tasks");
}
+157
View File
@@ -0,0 +1,157 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import type { AuditStatus, AuditType } from "@prisma/client";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { isAuditReadinessEnabled } from "@/lib/audit-readiness/activation";
/**
* Audit-Verwaltung (V5A) — Planung/Terminierung von internen und externen Audits.
*
* `moduleGuard("audit")` liefert die F-06-Autoritätsprüfungen (Kontostatus/Token/
* Rechte) und setzt voraus, dass das Modul `audit` für den Mandanten aktiviert ist.
* Der lokale Wrapper `guard` erzwingt zusätzlich `isAuditReadinessEnabled` — analog
* zu `src/server/actions/audit-readiness.ts`. Als Schreibrecht dient `onboarding:use`
* (dieselbe Rolle, die den Audit-Wizard fährt: Mandanten-Admin/ISB), konsistent mit
* den bestehenden Actions des Audit-Moduls.
*
* Der Vollständigkeitscheck (`scripts/check-module-guards.ts`) verlässt sich darauf,
* dass jede exportierte Action über `await guard(...)` läuft (Datei ↔ Modul `audit`).
*/
const rawGuard = moduleGuard("audit");
/** Autoritätsprüfung + Scharfschaltung des Audit-Moduls; wirft, wenn nicht aktiviert. */
async function guard(...permissions: Parameters<typeof rawGuard>) {
const { session, db } = await rawGuard(...permissions);
if (!(await isAuditReadinessEnabled(db))) throw new Error("Audit-Modul ist nicht aktiviert.");
return { session, db };
}
const AUDIT_TYPES = ["INTERNAL", "EXTERNAL"] as const;
const AUDIT_STATUSES = ["PLANNED", "IN_PREPARATION", "DONE"] as const;
const baseSchema = z.object({
type: z.enum(AUDIT_TYPES),
title: z.string().trim().min(1, "Titel erforderlich").max(200),
plannedDate: z.string().trim().max(120).optional(),
scope: z.string().trim().max(2000).optional(),
// extern
assessmentLevel: z.string().trim().max(20).optional(),
provider: z.string().trim().max(200).optional(),
// intern
auditorUserId: z.string().trim().max(60).optional(),
interval: z.string().trim().max(60).optional(), // Turnus (in `result` als Merker abgelegt)
});
function parse(formData: FormData) {
const p = baseSchema.parse({
type: formData.get("type"),
title: formData.get("title"),
plannedDate: formData.get("plannedDate") || undefined,
scope: formData.get("scope") || undefined,
assessmentLevel: formData.get("assessmentLevel") || undefined,
provider: formData.get("provider") || undefined,
auditorUserId: formData.get("auditorUserId") || undefined,
interval: formData.get("interval") || undefined,
});
const isExternal = p.type === "EXTERNAL";
return {
type: p.type as AuditType,
title: p.title,
plannedDate: p.plannedDate || null,
scope: p.scope || null,
// Externe Felder nur für externe Audits, interne nur für interne — sauber trennen.
assessmentLevel: isExternal ? p.assessmentLevel || null : null,
provider: isExternal ? p.provider || null : null,
auditorUserId: isExternal ? null : p.auditorUserId || null,
interval: isExternal ? null : p.interval || null,
};
}
/** Neues Audit anlegen (Status PLANNED). Intern → nur terminieren, Extern → Wizard-fähig. */
export async function createAudit(formData: FormData) {
const { session, db } = await guard("onboarding:use");
const data = parse(formData);
// Turnus des internen Audits als Freitext-Merker im `result`-Feld (kein Schema-Change).
const result = data.interval ? `Turnus: ${data.interval}` : null;
const created = await db.audit.create({
data: {
tenantId: session.user.tenantId,
type: data.type,
title: data.title,
plannedDate: data.plannedDate,
scope: data.scope,
assessmentLevel: data.assessmentLevel,
provider: data.provider,
auditorUserId: data.auditorUserId,
status: "PLANNED",
result,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "audit", entityId: created.id, after: { type: data.type, title: data.title } });
revalidatePath("/audit-readiness");
// Externe Audits: direkt in die Vorbereitung springen; interne bleiben in der Übersicht.
if (created.type === "EXTERNAL") redirect(`/audit-readiness/${created.id}`);
redirect("/audit-readiness");
}
/** Audit aktualisieren (Stammdaten/Terminierung). */
export async function updateAudit(auditId: string, formData: FormData) {
const { session, db } = await guard("onboarding:use");
const before = await db.audit.findUnique({ where: { id: auditId } });
if (!before) throw new Error("Audit nicht gefunden.");
const data = parse(formData);
const result = data.interval ? `Turnus: ${data.interval}` : before.result;
await db.audit.update({
where: { id: auditId },
data: {
type: data.type,
title: data.title,
plannedDate: data.plannedDate,
scope: data.scope,
assessmentLevel: data.assessmentLevel,
provider: data.provider,
auditorUserId: data.auditorUserId,
result,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "audit", entityId: auditId, before, after: { type: data.type, title: data.title } });
revalidatePath("/audit-readiness");
revalidatePath(`/audit-readiness/${auditId}`);
redirect("/audit-readiness");
}
/** Audit löschen (inkl. hängender Nachweispositionen via onDelete: Cascade). */
export async function deleteAudit(auditId: string) {
const { session, db } = await guard("onboarding:use");
const before = await db.audit.findUnique({ where: { id: auditId } });
if (!before) throw new Error("Audit nicht gefunden.");
await db.audit.delete({ where: { id: auditId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "audit", entityId: auditId, before });
revalidatePath("/audit-readiness");
redirect("/audit-readiness");
}
const statusSchema = z.enum(AUDIT_STATUSES);
/** Audit-Status setzen (PLANNED → IN_PREPARATION → DONE). Bei DONE optional Ergebnis. */
export async function setAuditStatus(auditId: string, status: string, formData?: FormData) {
const { session, db } = await guard("onboarding:use");
const next = statusSchema.parse(status) as AuditStatus;
const before = await db.audit.findUnique({ where: { id: auditId } });
if (!before) throw new Error("Audit nicht gefunden.");
const result = formData?.get("result");
await db.audit.update({
where: { id: auditId },
data: {
status: next,
...(next === "DONE" && typeof result === "string" && result.trim() ? { result: result.trim() } : {}),
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "audit", entityId: auditId, before, after: { status: next } });
revalidatePath("/audit-readiness");
revalidatePath(`/audit-readiness/${auditId}`);
}
+457
View File
@@ -0,0 +1,457 @@
"use server";
import { requireSession } from "@/server/auth";
import { requirePlatformSession } from "@/server/platform-auth";
import { prisma } from "@/server/db";
import { hashPassword, verifyPassword } from "@/server/password";
import { resolvePasswordPolicy, validatePassword } from "@/lib/password-policy";
import { writeAuditLog, writePlatformAudit } from "@/server/audit";
import { checkRateLimit } from "@/server/rate-limit";
import { consumeToken, issueToken, peekToken } from "@/server/auth-token";
import { invalidateOtherSessions, invalidateSessions } from "@/server/sessions";
import {
clientIp,
findAccountByEmail,
findAccountById,
isEmailAvailable,
loginPathOf,
principalTypeOf,
readPasswordHash,
sendEmailChangedNotice,
sendEmailChangeVerifyMail,
sendPasswordChangedMail,
sendPasswordResetMail,
writeEmail,
writePasswordHash,
type Domain,
} from "@/server/auth-selfservice";
/**
* SEC2 — Passwort-Self-Service für Mandanten-Nutzer und Plattform-Admins.
*
* EXEMPT vom Modul-Gating: Auth-Funktionen, kein per TenantModule gegatetes
* Fachmodul. Die Reset-Abläufe laufen bewusst **ohne** Session (der Nutzer ist
* ausgesperrt); ihre Absicherung sind Rate-Limit, Enumeration-Neutralität und
* single-use-Tokens. Die angemeldeten Abläufe nutzen requireSession bzw.
* requirePlatformSession.
*
* Durchgehende Prinzipien:
* - **Enumeration-Schutz:** Reset-Anfrage und E-Mail-Änderung antworten immer
* gleich, unabhängig davon, ob das Konto existiert.
* - **Kein Auto-Login nach Reset** — der Nutzer meldet sich neu an.
* - **Keine Tokens in Logs oder Audit-Einträgen.**
*/
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
function domainOf(v: FormDataEntryValue | null): Domain {
return String(v ?? "") === "platform" ? "platform" : "tenant";
}
/** Einheitliche Antwort der Reset-Anfrage (SEC2 §2a). */
const NEUTRAL_ANSWER =
"Falls ein Konto mit dieser Adresse existiert, wurde eine E-Mail mit weiteren Schritten gesendet.";
export type RequestResetState =
| { status: "idle" }
| { status: "done"; message: string }
| { status: "error"; message: string };
/**
* „Passwort vergessen" — Schritt 1.
*
* Antwortet **immer** identisch. Ein Reset wird nur ausgelöst, wenn das Konto
* existiert und aktiv ist; deaktivierte oder gesperrte Konten bekommen weder
* Mail noch abweichende Meldung.
*/
export async function requestPasswordReset(
_prev: RequestResetState,
formData: FormData,
): Promise<RequestResetState> {
const email = str(formData.get("email")).toLowerCase();
const domain = domainOf(formData.get("domain"));
const ip = await clientIp();
const limit = checkRateLimit("passwordResetRequest", { ip, account: email });
if (!limit.allowed) {
return {
status: "error",
message: `Zu viele Anfragen. Bitte in ${Math.ceil(limit.retryAfterSeconds / 60)} Minute(n) erneut versuchen.`,
};
}
if (!email) return { status: "done", message: NEUTRAL_ANSWER };
const account = await findAccountByEmail(domain, email);
if (account && account.active) {
const { raw, expiresAt } = await issueToken({
principalType: principalTypeOf(domain),
principalId: account.id,
tenantId: account.tenantId,
type: "password_reset",
requestIp: ip,
});
await sendPasswordResetMail(account, raw, expiresAt, domain);
// Audit ohne Token und ohne Rückschluss auf den Klartext.
if (domain === "platform") {
await writePlatformAudit({
actorId: account.id,
action: "update",
entity: "password_reset_requested",
entityId: account.id,
});
} else if (account.tenantId) {
await writeAuditLog({
tenantId: account.tenantId,
actorId: account.id,
action: "update",
entity: "password_reset_requested",
entityId: account.id,
});
}
}
return { status: "done", message: NEUTRAL_ANSWER };
}
export type RedeemResetState =
| { status: "idle" }
| { status: "error"; message: string }
| { status: "done" };
const GENERIC_TOKEN_ERROR =
"Der Link ist ungültig oder abgelaufen. Bitte fordern Sie einen neuen an.";
/** Prüft einen Reset-Link, ohne ihn zu verbrauchen (für die Formularanzeige). */
export async function checkResetToken(token: string, domain: Domain): Promise<boolean> {
const resolved = await peekToken(token, "password_reset");
return resolved != null && resolved.principalType === principalTypeOf(domain);
}
/** Prüft einen Einladungs-Link (Option C, WS3), ohne ihn zu verbrauchen. */
export async function checkInvitationToken(token: string): Promise<boolean> {
const resolved = await peekToken(token, "invitation");
// Einladungen adressieren immer die (Mandanten-)Identity.
return resolved != null && resolved.principalType === "identity";
}
/**
* Einladung einlösen (Option C, WS3): der Eingeladene setzt sein Erst-Passwort an
* der GLOBALEN Identity. Anlage erfolgt ausschließlich per Einladung (goldene Regel 4).
*/
export async function redeemInvitation(
_prev: RedeemResetState,
formData: FormData,
): Promise<RedeemResetState> {
const token = str(formData.get("token"));
const password = String(formData.get("password") ?? "");
const confirm = String(formData.get("confirm") ?? "");
const ip = await clientIp();
const limit = checkRateLimit("passwordResetRedeem", { ip });
if (!limit.allowed) return { status: "error", message: "Zu viele Versuche. Bitte später erneut versuchen." };
if (password !== confirm) return { status: "error", message: "Die Passwörter stimmen nicht überein." };
// Erst prüfen (ohne Verbrauch), damit ein Policy-Fehler den Link nicht entwertet.
const preview = await peekToken(token, "invitation");
if (!preview || preview.principalType !== "identity") {
return { status: "error", message: GENERIC_TOKEN_ERROR };
}
const account = await findAccountById("tenant", preview.principalId);
if (!account) return { status: "error", message: GENERIC_TOKEN_ERROR };
const policy = await resolvePolicyFor("tenant", preview.tenantId);
const problems = validatePassword(password, policy);
if (problems.length > 0) return { status: "error", message: problems.join(" ") };
const consumed = await consumeToken(token, "invitation");
if (!consumed) return { status: "error", message: GENERIC_TOKEN_ERROR };
// Passwort an der Identity setzen (writePasswordHash räumt mustChangePassword + Lockout).
await writePasswordHash("tenant", account.id, await hashPassword(password));
if (account.tenantId) {
await writeAuditLog({ tenantId: account.tenantId, actorId: account.id, action: "update", entity: "invitation_redeemed", entityId: account.id });
}
return { status: "done" };
}
/**
* „Passwort vergessen" — Schritt 2: neues Passwort setzen.
*
* Der Token wird **vor** dem Schreiben verbraucht (`consumeToken`), damit ein
* paralleler zweiter Versuch mit demselben Link ins Leere läuft.
*/
export async function redeemPasswordReset(
_prev: RedeemResetState,
formData: FormData,
): Promise<RedeemResetState> {
const token = str(formData.get("token"));
const domain = domainOf(formData.get("domain"));
const password = String(formData.get("password") ?? "");
const confirm = String(formData.get("confirm") ?? "");
const ip = await clientIp();
const limit = checkRateLimit("passwordResetRedeem", { ip });
if (!limit.allowed) {
return { status: "error", message: "Zu viele Versuche. Bitte später erneut versuchen." };
}
if (password !== confirm) {
return { status: "error", message: "Die Passwörter stimmen nicht überein." };
}
// Erst prüfen (ohne Verbrauch), damit ein Policy-Fehler den Link nicht entwertet.
const preview = await peekToken(token, "password_reset");
if (!preview || preview.principalType !== principalTypeOf(domain)) {
return { status: "error", message: GENERIC_TOKEN_ERROR };
}
const account = await findAccountById(domain, preview.principalId);
if (!account || !account.active) {
return { status: "error", message: GENERIC_TOKEN_ERROR };
}
const policy = await resolvePolicyFor(domain, account.tenantId);
const problems = validatePassword(password, policy);
if (problems.length > 0) return { status: "error", message: problems.join(" ") };
// Jetzt verbrauchen — ab hier ist der Link verbraucht, auch bei Fehlern danach.
const consumed = await consumeToken(token, "password_reset");
if (!consumed) return { status: "error", message: GENERIC_TOKEN_ERROR };
await writePasswordHash(domain, account.id, await hashPassword(password));
// Alle Sessions entwerten: nach einem Reset darf keine alte Sitzung weiterlaufen.
await invalidateSessions({ type: principalTypeOf(domain), id: account.id });
await sendPasswordChangedMail(account, ip);
if (domain === "platform") {
await writePlatformAudit({
actorId: account.id,
action: "update",
entity: "password_reset",
entityId: account.id,
});
} else if (account.tenantId) {
await writeAuditLog({
tenantId: account.tenantId,
actorId: account.id,
action: "update",
entity: "password_reset",
entityId: account.id,
});
}
return { status: "done" };
}
/** Passwort-Policy der Domäne (Plattform nutzt die Voreinstellung). */
async function resolvePolicyFor(domain: Domain, tenantId: string | null) {
if (domain === "platform" || !tenantId) return resolvePasswordPolicy(undefined);
const settings = await prisma.tenantSettings.findUnique({
where: { tenantId },
select: { securityPolicy: true },
});
return resolvePasswordPolicy(settings?.securityPolicy);
}
export type SelfChangeState =
| { status: "idle" }
| { status: "ok"; message: string }
| { status: "error"; message: string };
/**
* Passwort selbst ändern (angemeldet, SEC2 §3).
*
* Alt-Passwort wird verifiziert; danach werden **andere** Sitzungen abgemeldet,
* die aktuelle bleibt bestehen (siehe invalidateOtherSessions).
*/
export async function changePasswordSelf(
_prev: SelfChangeState,
formData: FormData,
): Promise<SelfChangeState> {
const domain = domainOf(formData.get("domain"));
const { id, tenantId } = await currentPrincipal(domain);
const ip = await clientIp();
const limit = checkRateLimit("passwordVerify", { ip, account: id });
if (!limit.allowed) {
return { status: "error", message: "Zu viele Versuche. Bitte später erneut versuchen." };
}
const current = String(formData.get("current") ?? "");
const password = String(formData.get("password") ?? "");
const confirm = String(formData.get("confirm") ?? "");
if (password !== confirm) {
return { status: "error", message: "Die Passwörter stimmen nicht überein." };
}
const hash = await readPasswordHash(domain, id);
if (!hash) return { status: "error", message: "Konto nicht gefunden." };
// Generische Meldung: kein Rückschluss darauf, welcher Teil falsch war.
if (!(await verifyPassword(hash, current))) {
return { status: "error", message: "Die Eingaben konnten nicht bestätigt werden." };
}
const policy = await resolvePolicyFor(domain, tenantId);
const problems = validatePassword(password, policy);
if (problems.length > 0) return { status: "error", message: problems.join(" ") };
await writePasswordHash(domain, id, await hashPassword(password));
await invalidateOtherSessions({ type: principalTypeOf(domain), id });
const account = await findAccountById(domain, id);
if (account) await sendPasswordChangedMail(account, ip);
if (domain === "platform") {
await writePlatformAudit({ actorId: id, action: "update", entity: "password_self_change", entityId: id });
} else if (tenantId) {
await writeAuditLog({ tenantId, actorId: id, action: "update", entity: "password_self_change", entityId: id });
}
return {
status: "ok",
message: "Passwort geändert. Andere Sitzungen wurden abgemeldet.",
};
}
export type EmailChangeState =
| { status: "idle" }
| { status: "ok"; message: string }
| { status: "error"; message: string };
const EMAIL_NEUTRAL =
"Falls die Adresse verwendbar ist, wurde ein Bestätigungslink an sie gesendet.";
/**
* E-Mail-Änderung anfordern (SEC2 §4, Double-Opt-in).
*
* Der Verifizierungslink geht an die **neue** Adresse. Ob sie bereits vergeben
* ist, wird nicht nach außen gemeldet — sonst ließe sich darüber prüfen, welche
* Adressen im System existieren.
*/
export async function requestEmailChange(
_prev: EmailChangeState,
formData: FormData,
): Promise<EmailChangeState> {
const domain = domainOf(formData.get("domain"));
// Option C: E-Mail-Änderung als Identity-Operation ist Phase 2 (FEINDESIGN §13).
if (domain === "tenant") {
return { status: "error", message: "Die Änderung der E-Mail-Adresse ist für Mandanten-Konten derzeit nicht verfügbar." };
}
const { id, tenantId } = await currentPrincipal(domain);
const ip = await clientIp();
const limit = checkRateLimit("emailChangeRequest", { ip, account: id });
if (!limit.allowed) {
return { status: "error", message: "Zu viele Anfragen. Bitte später erneut versuchen." };
}
const newEmail = str(formData.get("newEmail")).toLowerCase();
const current = String(formData.get("current") ?? "");
if (!newEmail.includes("@")) {
return { status: "error", message: "Bitte eine gültige E-Mail-Adresse angeben." };
}
// Alt-Passwort bestätigen (Step-up folgt in SEC3).
const hash = await readPasswordHash(domain, id);
if (!hash || !(await verifyPassword(hash, current))) {
return { status: "error", message: "Die Eingaben konnten nicht bestätigt werden." };
}
const account = await findAccountById(domain, id);
if (!account) return { status: "error", message: "Konto nicht gefunden." };
if (newEmail === account.email) {
return { status: "error", message: "Das ist bereits Ihre aktuelle Adresse." };
}
if (await isEmailAvailable(domain, newEmail, tenantId, id)) {
const { raw, expiresAt } = await issueToken({
principalType: principalTypeOf(domain),
principalId: id,
tenantId,
type: "email_change",
newEmail,
requestIp: ip,
});
await sendEmailChangeVerifyMail(account, newEmail, raw, expiresAt, domain);
}
return { status: "ok", message: EMAIL_NEUTRAL };
}
/**
* E-Mail-Änderung bestätigen (Klick auf den Link in der neuen Adresse).
*
* Die Kollisionsprüfung wird **erneut** ausgeführt: zwischen Anforderung und
* Bestätigung kann die Adresse anderweitig vergeben worden sein.
*/
export async function confirmEmailChange(
token: string,
domain: Domain,
): Promise<{ ok: boolean; message: string }> {
// Option C: E-Mail-Änderung als Identity-Operation ist Phase 2 (FEINDESIGN §13).
if (domain === "tenant") {
return { ok: false, message: "Die Änderung der E-Mail-Adresse ist für Mandanten-Konten derzeit nicht verfügbar." };
}
const preview = await peekToken(token, "email_change");
if (!preview || preview.principalType !== principalTypeOf(domain) || !preview.newEmail) {
return { ok: false, message: GENERIC_TOKEN_ERROR };
}
const account = await findAccountById(domain, preview.principalId);
if (!account || !account.active) return { ok: false, message: GENERIC_TOKEN_ERROR };
if (!(await isEmailAvailable(domain, preview.newEmail, preview.tenantId, account.id))) {
return {
ok: false,
message: "Diese Adresse ist inzwischen vergeben. Bitte fordern Sie die Änderung erneut an.",
};
}
const consumed = await consumeToken(token, "email_change");
if (!consumed || !consumed.newEmail) return { ok: false, message: GENERIC_TOKEN_ERROR };
const oldEmail = account.email;
await writeEmail(domain, account.id, consumed.newEmail);
// Die E-Mail ist die Login-Identität — bestehende Sessions werden entwertet.
await invalidateSessions({ type: principalTypeOf(domain), id: account.id });
await sendEmailChangedNotice(account, oldEmail, consumed.newEmail);
if (domain === "platform") {
await writePlatformAudit({
actorId: account.id,
action: "update",
entity: "email_change",
entityId: account.id,
before: { email: oldEmail },
after: { email: consumed.newEmail },
});
} else if (account.tenantId) {
await writeAuditLog({
tenantId: account.tenantId,
actorId: account.id,
action: "update",
entity: "email_change",
entityId: account.id,
before: { email: oldEmail },
after: { email: consumed.newEmail },
});
}
return {
ok: true,
message: `Ihre E-Mail-Adresse wurde auf ${consumed.newEmail} geändert. Bitte melden Sie sich neu an (${loginPathOf(domain)}).`,
};
}
/** Angemeldetes Konto der jeweiligen Domäne. */
async function currentPrincipal(domain: Domain): Promise<{ id: string; tenantId: string | null }> {
if (domain === "platform") {
const session = await requirePlatformSession();
return { id: session.user.id, tenantId: null };
}
const session = await requireSession();
// Option C (WS4): Passwort/Session gehören der Identity → id = Identity.id.
const identityId = session.user.identityId;
if (!identityId) throw new Error("Kein Identity-Kontext in der Session.");
return { id: identityId, tenantId: session.user.tenantId };
}
+300
View File
@@ -0,0 +1,300 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/server/db";
import { requirePlatformFullAdmin } from "@/server/platform-auth";
import { verifyTotp } from "@/server/mfa";
import { writePlatformAudit } from "@/server/audit";
import { getBackupStore } from "@/server/storage/backup-store";
import { listSnapshots } from "@/server/backup/export";
import type { BackupManifest } from "@/server/backup/serialization";
import { getBackupQueue, isBackupQueueEnabled } from "@/server/backup/queue";
import type { BackupOpsJob } from "@/server/backup/job";
import { matchesRestoreConfirmation, restoreConfirmationFor } from "@/server/backup/restore-confirm";
/**
* Betreiber-Portal: Enqueue-Actions für den destruktiven Portal-Restore,
* „Export jetzt" und die DSGVO-Zustellung (KONZEPT §4/§5/§10).
*
* EXEMPT vom Modul-Gating: reine Plattform-/Betreiber-Fähigkeit (eigene Auth über
* `requirePlatformFullAdmin`), kein per TenantModule gegatetes Fachmodul.
*
* Sicherheitskontrollen des Portal-Restore (alle PFLICHT, in dieser Reihenfolge):
* 1. `requirePlatformFullAdmin` — nur aktive Voll-Admins (Read-only darf nicht).
* 2. MFA-Step-up: ein JETZT gültiger, frischer TOTP-Code des Admins; Replay-
* Schutz über `lastTotpStep` (bereits verwendeter/älterer Zeitschritt wird
* abgelehnt, der akzeptierte Schritt wird sofort verbraucht/persistiert).
* 3. Getippte Bestätigung „RESTORE <tenant-slug>" muss EXAKT matchen.
* 4. Erst dann wird enqueued (Ausführung im Worker, nie inline).
* Jede Aktion wird im Plattform-Audit vermerkt (ohne Secrets/PII).
*/
export type BackupActionState =
| { status: "idle" }
| { status: "error"; message: string }
| { status: "done"; message: string; jobId?: string };
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
type StepUpAdmin = {
id: string;
mfaEnrolledAt: Date | null;
mfaSecret: string | null;
lastTotpStep: bigint | null;
};
/**
* MFA-Step-up mit Replay-Schutz: verifiziert einen frischen TOTP-Code und
* VERBRAUCHT den akzeptierten Zeitschritt sofort (persistiert `lastTotpStep`),
* damit derselbe Code nicht ein zweites Mal für eine kritische Aktion taugt.
* Ohne eingerichtete MFA ist kein TOTP-Step-up möglich → übersprungen.
* Wirft bei ungültigem/wiederverwendetem Code.
*/
async function assertStepUpAndConsume(admin: StepUpAdmin, code: string): Promise<void> {
if (!admin.mfaEnrolledAt || !admin.mfaSecret) return;
const res = verifyTotp(code, admin.mfaSecret, admin.lastTotpStep);
if (!res.ok) {
throw new Error("Bitte die Aktion mit einem aktuellen MFA-Code bestätigen (Step-up).");
}
// Replay-Schutz: Zeitschritt sofort verbrauchen.
await prisma.platformAdmin.update({
where: { id: admin.id },
data: { lastTotpStep: BigInt(res.step) },
});
}
async function loadManifest(tenantId: string, snapshotId: string): Promise<BackupManifest | null> {
const raw = await (await getBackupStore()).get(`${tenantId}/backups/${snapshotId}/manifest.json`);
if (!raw) return null;
try {
return JSON.parse(raw.toString("utf8")) as BackupManifest;
} catch {
return null;
}
}
export interface RestorePreview {
snapshotId: string;
snapshotAt: string;
schemaMigration: string | null;
totalRows: number;
artifactTenantSlug: string;
/** Zielmandant (aktuell in der DB) — zum Abgleich in der Vorschau. */
targetSlug: string;
targetName: string;
/** Mismatch-Flag: Artefakt gehört einem anderen Mandanten (würde abgewiesen). */
tenantMismatch: boolean;
tables: { model: string; rows: number }[];
}
/**
* Read-only Dry-run/Vorschau VOR der Bestätigung (KONZEPT §4): welches Artefakt,
* Zeilenzahlen je Tabelle, Snapshot-Zeit, Zielmandant. Verändert NICHTS.
*/
export async function previewRestore(tenantId: string, snapshotId: string): Promise<RestorePreview> {
await requirePlatformFullAdmin();
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { slug: true, name: true },
});
if (!tenant) throw new Error("Mandant nicht gefunden.");
const manifest = await loadManifest(tenantId, snapshotId);
if (!manifest) throw new Error("Sicherungspunkt/Manifest nicht gefunden.");
return {
snapshotId,
snapshotAt: manifest.snapshotAt,
schemaMigration: manifest.schemaMigration,
totalRows: manifest.totalRows,
artifactTenantSlug: manifest.tenantSlug,
targetSlug: tenant.slug,
targetName: tenant.name,
tenantMismatch: manifest.tenantId !== tenantId,
tables: manifest.tables
.filter((t) => t.rowCount > 0)
.map((t) => ({ model: t.model, rows: t.rowCount }))
.sort((a, b) => b.rows - a.rows),
};
}
/** Sicherungspunkte eines Mandanten + Kurz-Metadaten (für die Auswahl im Popup). */
export async function listRestoreSnapshots(
tenantId: string,
): Promise<{ snapshotId: string; snapshotAt: string | null; totalRows: number | null }[]> {
await requirePlatformFullAdmin();
const ids = await listSnapshots(tenantId);
const out: { snapshotId: string; snapshotAt: string | null; totalRows: number | null }[] = [];
for (const snapshotId of ids) {
const manifest = await loadManifest(tenantId, snapshotId);
out.push({
snapshotId,
snapshotAt: manifest?.snapshotAt ?? null,
totalRows: manifest?.totalRows ?? null,
});
}
return out.reverse(); // neueste zuerst
}
async function enqueue(job: BackupOpsJob): Promise<void> {
const queue = getBackupQueue();
if (!queue) {
throw new Error(
"Backup-Worker/Redis ist nicht verfügbar (REDIS_URL). Portal-Restore/Export/DSGVO werden " +
"nie inline ausgeführt — bitte Redis + `npm run worker:backup` bereitstellen.",
);
}
await queue.add(job.kind, job);
}
/**
* Portal-Restore enqueuen. Volle Kontrollkette (s. Kopf). Enqueued nur — der
* destruktive Wipe+Restore läuft im Worker (Mandant-Sperre, Pre-Restore-Snapshot,
* Audit sind in der Engine eingebaut).
*/
export async function enqueueRestore(
_prev: BackupActionState,
formData: FormData,
): Promise<BackupActionState> {
try {
const { admin } = await requirePlatformFullAdmin();
const tenantId = str(formData.get("tenantId"));
const snapshotId = str(formData.get("snapshotId"));
const token = str(formData.get("token"));
const confirm = str(formData.get("confirm"));
if (!tenantId || !snapshotId) return { status: "error", message: "Mandant und Sicherungspunkt sind erforderlich." };
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { slug: true } });
if (!tenant) return { status: "error", message: "Mandant nicht gefunden." };
// 2. MFA-Step-up (frisch + Replay-Schutz).
await assertStepUpAndConsume(admin, token);
// 3. Getippte Bestätigung exakt „RESTORE <slug>".
if (!matchesRestoreConfirmation(tenant.slug, confirm)) {
return { status: "error", message: `Bitte exakt „${restoreConfirmationFor(tenant.slug)}" zur Bestätigung eingeben.` };
}
// Sicherungspunkt muss existieren (Artefakt im Store).
const artifact = await (await getBackupStore()).get(`${tenantId}/backups/${snapshotId}/artifact.cvb`);
if (!artifact) return { status: "error", message: "Der gewählte Sicherungspunkt existiert nicht mehr." };
if (!isBackupQueueEnabled()) {
return { status: "error", message: "Redis/Worker nicht konfiguriert (REDIS_URL) — Restore kann nicht eingestellt werden." };
}
const job = await prisma.backupJob.create({
data: {
kind: "tenant_restore",
status: "queued",
tenantId,
tenantSlug: tenant.slug,
actorId: admin.id,
snapshotId,
params: { restoreFiles: false },
},
});
// 4. Erst jetzt enqueuen. Scheitert das Einstellen, Job als failed markieren.
try {
await enqueue({ kind: "tenant_restore", jobId: job.id, tenantId, snapshotId, actorId: admin.id });
} catch (e) {
const message = e instanceof Error ? e.message : "Einstellen fehlgeschlagen.";
await prisma.backupJob.update({ where: { id: job.id }, data: { status: "failed", error: message } });
return { status: "error", message };
}
await writePlatformAudit({
actorId: admin.id,
action: "update",
entity: "backup_job",
entityId: job.id,
after: { kind: "tenant_restore", tenantId, tenantSlug: tenant.slug, snapshotId, status: "queued" },
});
revalidatePath(`/admin/${tenantId}`);
return { status: "done", message: "Restore eingestellt — die Ausführung übernimmt der Worker (Mandant wird gesperrt).", jobId: job.id };
} catch (err) {
return { status: "error", message: err instanceof Error ? err.message : "Restore konnte nicht eingestellt werden." };
}
}
/** „Export jetzt" enqueuen (On-demand-Sicherung). Voll-Admin + Step-up + Audit. */
export async function enqueueExport(
_prev: BackupActionState,
formData: FormData,
): Promise<BackupActionState> {
try {
const { admin } = await requirePlatformFullAdmin();
const tenantId = str(formData.get("tenantId"));
const token = str(formData.get("token"));
if (!tenantId) return { status: "error", message: "Mandant ist erforderlich." };
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { slug: true } });
if (!tenant) return { status: "error", message: "Mandant nicht gefunden." };
await assertStepUpAndConsume(admin, token);
if (!isBackupQueueEnabled()) {
return { status: "error", message: "Redis/Worker nicht konfiguriert (REDIS_URL) — Export kann nicht eingestellt werden." };
}
const job = await prisma.backupJob.create({
data: { kind: "tenant_export", status: "queued", tenantId, tenantSlug: tenant.slug, actorId: admin.id, params: { reason: "operator-ondemand" } },
});
try {
await enqueue({ kind: "tenant_export", jobId: job.id, tenantId, reason: "operator-ondemand", actorId: admin.id });
} catch (e) {
const message = e instanceof Error ? e.message : "Einstellen fehlgeschlagen.";
await prisma.backupJob.update({ where: { id: job.id }, data: { status: "failed", error: message } });
return { status: "error", message };
}
await writePlatformAudit({ actorId: admin.id, action: "create", entity: "backup_job", entityId: job.id, after: { kind: "tenant_export", tenantId, status: "queued" } });
revalidatePath(`/admin/${tenantId}`);
return { status: "done", message: "Export eingestellt — der Worker legt einen neuen Sicherungspunkt an.", jobId: job.id };
} catch (err) {
return { status: "error", message: err instanceof Error ? err.message : "Export konnte nicht eingestellt werden." };
}
}
/**
* DSGVO-Zustellung enqueuen. `subjectIdentityId` gesetzt → Per-Person-Auskunft,
* sonst Per-Mandant-Paket. Voll-Admin + Step-up + Audit; der Worker baut das ZIP,
* legt es ab und erzeugt einen signierten, ablaufenden Download-Link.
*/
export async function enqueueDsgvoExport(
_prev: BackupActionState,
formData: FormData,
): Promise<BackupActionState> {
try {
const { admin } = await requirePlatformFullAdmin();
const tenantId = str(formData.get("tenantId"));
const token = str(formData.get("token"));
const subjectIdentityId = str(formData.get("subjectIdentityId")) || null;
if (!tenantId) return { status: "error", message: "Mandant ist erforderlich." };
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { slug: true } });
if (!tenant) return { status: "error", message: "Mandant nicht gefunden." };
await assertStepUpAndConsume(admin, token);
if (!isBackupQueueEnabled()) {
return { status: "error", message: "Redis/Worker nicht konfiguriert (REDIS_URL) — DSGVO-Export kann nicht eingestellt werden." };
}
const job = await prisma.backupJob.create({
data: {
kind: "dsgvo_export",
status: "queued",
tenantId,
tenantSlug: tenant.slug,
subjectIdentityId,
actorId: admin.id,
params: { scope: subjectIdentityId ? "person" : "tenant" },
},
});
try {
await enqueue({ kind: "dsgvo_export", jobId: job.id, tenantId, subjectIdentityId, actorId: admin.id });
} catch (e) {
const message = e instanceof Error ? e.message : "Einstellen fehlgeschlagen.";
await prisma.backupJob.update({ where: { id: job.id }, data: { status: "failed", error: message } });
return { status: "error", message };
}
await writePlatformAudit({ actorId: admin.id, action: "export", entity: "backup_job", entityId: job.id, after: { kind: "dsgvo_export", tenantId, scope: subjectIdentityId ? "person" : "tenant", status: "queued" } });
revalidatePath(`/admin/${tenantId}`);
return { status: "done", message: "DSGVO-Export eingestellt — nach Fertigstellung erscheint ein zeitlich begrenzter Download-Link.", jobId: job.id };
} catch (err) {
return { status: "error", message: err instanceof Error ? err.message : "DSGVO-Export konnte nicht eingestellt werden." };
}
}
+278
View File
@@ -0,0 +1,278 @@
"use server";
import { randomBytes } from "node:crypto";
import { revalidatePath } from "next/cache";
import { prisma } from "@/server/db";
import { requirePlatformFullAdmin } from "@/server/platform-auth";
import { verifyTotp } from "@/server/mfa";
import { encryptSecret, decryptSecret } from "@/server/secret-crypto";
import { writePlatformAudit } from "@/server/audit";
import {
resolveBackupStore,
invalidateBackupStore,
type BackupTargetConfig,
} from "@/server/storage/backup-store";
/**
* Betreiber-Portal: Konfiguration des Backup-Zielspeichers (Lane „Konfigurierbarer
* Backup-Zielspeicher"). Ziel wählen (Lokal/S3), Config pflegen, „Verbindung testen",
* speichern. EXEMPT vom Modul-Gating — reine Plattform-/Betreiber-Fähigkeit.
*
* Sicherheitskontrollen (PFLICHT):
* 1. `requirePlatformFullAdmin` — nur aktive Voll-Admins.
* 2. MFA-Step-up: ein JETZT gültiger, frischer TOTP-Code des Admins. Beim
* Speichern wird der Zeitschritt verbraucht (Replay-Schutz), beim reinen
* „Verbindung testen" (nicht-destruktiv) nur verifiziert.
* Das S3-Secret wird VOR dem Schreiben verschlüsselt (secret-crypto) — nie Klartext
* in die DB. Jede Änderung wird im Plattform-Audit vermerkt (ohne Secrets).
*/
export type BackupSettingsState =
| { status: "idle" }
| { status: "error"; message: string }
| { status: "ok"; message: string };
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
/** Display-sichere Sicht der aktuellen Config (Secret NIE im Klartext). */
export interface BackupTargetView {
backupTarget: "local" | "s3";
backupLocalDir: string;
backupS3Endpoint: string;
backupS3Bucket: string;
backupS3Region: string;
backupS3AccessKey: string;
/** true, wenn ein (verschlüsseltes) S3-Secret hinterlegt ist. */
hasS3Secret: boolean;
}
/** Lädt die aktuelle Zielspeicher-Config für die Betreiber-UI (ohne Secret-Klartext). */
export async function getBackupTargetView(): Promise<BackupTargetView> {
await requirePlatformFullAdmin();
const row = await prisma.platformSetting.findUnique({
where: { id: "singleton" },
select: {
backupTarget: true,
backupLocalDir: true,
backupS3Endpoint: true,
backupS3Bucket: true,
backupS3Region: true,
backupS3AccessKey: true,
backupS3SecretKeyEnc: true,
},
});
return {
backupTarget: row?.backupTarget === "s3" ? "s3" : "local",
backupLocalDir: row?.backupLocalDir ?? "",
backupS3Endpoint: row?.backupS3Endpoint ?? "",
backupS3Bucket: row?.backupS3Bucket ?? "",
backupS3Region: row?.backupS3Region ?? "",
backupS3AccessKey: row?.backupS3AccessKey ?? "",
hasS3Secret: !!row?.backupS3SecretKeyEnc,
};
}
/**
* Baut aus dem eingereichten Formular eine (unverschlüsselte, nur-im-Speicher)
* Zielkonfiguration. Beim S3-Secret gilt: leeres Feld → bestehendes Secret behalten
* (der Aufrufer reicht dazu das bereits entschlüsselte Alt-Secret als Fallback herein).
*/
function configFromForm(formData: FormData, fallbackSecret: string | null): BackupTargetConfig {
const target = str(formData.get("backupTarget")) === "s3" ? "s3" : "local";
const secretInput = str(formData.get("backupS3SecretKey"));
return {
backupTarget: target,
backupLocalDir: str(formData.get("backupLocalDir")) || null,
backupS3Endpoint: str(formData.get("backupS3Endpoint")) || null,
backupS3Bucket: str(formData.get("backupS3Bucket")) || null,
backupS3Region: str(formData.get("backupS3Region")) || null,
backupS3AccessKey: str(formData.get("backupS3AccessKey")) || null,
backupS3SecretKey: secretInput || fallbackSecret,
};
}
/** Aktuell hinterlegtes (verschlüsseltes) S3-Secret entschlüsseln (für „Feld leer = behalten"). */
async function currentDecryptedSecret(): Promise<string | null> {
const row = await prisma.platformSetting.findUnique({
where: { id: "singleton" },
select: { backupS3SecretKeyEnc: true },
});
if (!row?.backupS3SecretKeyEnc) return null;
return decryptSecret(row.backupS3SecretKeyEnc);
}
/**
* „Verbindung testen": baut mit der EINGEREICHTEN (noch nicht gespeicherten) Config
* einen Store und probiert put→get→remove eines winzigen Test-Keys. Non-destruktiv
* (eigener `__connectivity-test__/`-Prefix, wird sofort wieder entfernt).
*/
export async function testBackupConnection(
_prev: BackupSettingsState,
formData: FormData,
): Promise<BackupSettingsState> {
try {
const { admin } = await requirePlatformFullAdmin();
// Step-up nur verifizieren (kein Verbrauch — der Test ist nicht-destruktiv).
assertFreshTotp(admin, str(formData.get("token")));
const cfg = configFromForm(formData, await currentDecryptedSecret());
let store;
try {
store = resolveBackupStore(cfg);
} catch (e) {
return { status: "error", message: e instanceof Error ? e.message : "Konfiguration ungültig." };
}
const key = `__connectivity-test__/${randomBytes(8).toString("hex")}.txt`;
const payload = Buffer.from(`certvia backup connectivity ${new Date().toISOString()}`, "utf8");
try {
await store.put(key, payload);
const back = await store.get(key);
if (!back || !back.equals(payload)) {
return { status: "error", message: "Test-Objekt konnte nicht identisch zurückgelesen werden (get≠put)." };
}
await store.remove(key);
} catch (e) {
// best effort: falls put gelang, den Test-Key noch aufräumen.
try { await store.remove(key); } catch { /* ignore */ }
return {
status: "error",
message: `Verbindung fehlgeschlagen: ${e instanceof Error ? e.message : String(e)}`,
};
}
await writePlatformAudit({
actorId: admin.id,
action: "update",
entity: "platform_setting",
entityId: "singleton",
after: { backupConnectionTest: cfg.backupTarget, result: "ok" },
});
return {
status: "ok",
message: `Verbindung erfolgreich (${cfg.backupTarget === "s3" ? "S3/MinIO" : "Lokal"}): put/get/remove ok.`,
};
} catch (err) {
return { status: "error", message: err instanceof Error ? err.message : "Verbindungstest fehlgeschlagen." };
}
}
/**
* Zielspeicher-Konfiguration speichern. Voll-Admin + Step-up (verbraucht). Das
* S3-Secret wird vor dem Schreiben verschlüsselt; ein leeres Secret-Feld behält das
* bestehende Secret. Bei `backupTarget=s3` wird auf Vollständigkeit geprüft
* (fail-secure) — eine unvollständige S3-Config wird abgelehnt, nicht gespeichert.
*/
export async function saveBackupTarget(
_prev: BackupSettingsState,
formData: FormData,
): Promise<BackupSettingsState> {
try {
const { admin } = await requirePlatformFullAdmin();
await assertFreshTotpAndConsume(admin, str(formData.get("token")));
const target = str(formData.get("backupTarget")) === "s3" ? "s3" : "local";
const localDir = str(formData.get("backupLocalDir"));
const endpoint = str(formData.get("backupS3Endpoint"));
const bucket = str(formData.get("backupS3Bucket"));
const region = str(formData.get("backupS3Region"));
const accessKey = str(formData.get("backupS3AccessKey"));
const secretInput = str(formData.get("backupS3SecretKey"));
// Bestehendes Secret behalten, wenn das Feld leer bleibt.
const existing = await prisma.platformSetting.findUnique({
where: { id: "singleton" },
select: { backupS3SecretKeyEnc: true },
});
const keepSecretEnc = existing?.backupS3SecretKeyEnc ?? null;
if (target === "s3") {
// Fail-secure-Validierung VOR dem Schreiben (das Secret kann aus dem Bestand kommen).
const hasSecret = !!secretInput || !!keepSecretEnc;
const missing = [
!endpoint && "Endpoint",
!bucket && "Bucket",
!accessKey && "Access-Key",
!hasSecret && "Secret-Key",
].filter(Boolean);
if (missing.length) {
return {
status: "error",
message: `S3-Konfiguration unvollständig (fehlt: ${missing.join(", ")}). Nicht gespeichert.`,
};
}
} else if (!localDir) {
return {
status: "error",
message: "Für „Lokal“ bitte einen Ablagepfad angeben (muss auf ein gemountetes Volume zeigen, z. B. /app/.backups).",
};
}
// S3-Secret vor dem Schreiben verschlüsseln (nur wenn neu eingegeben).
const secretEnc = secretInput ? encryptSecret(secretInput) : keepSecretEnc;
await prisma.platformSetting.upsert({
where: { id: "singleton" },
update: {
backupTarget: target,
backupLocalDir: localDir || null,
backupS3Endpoint: endpoint || null,
backupS3Bucket: bucket || null,
backupS3Region: region || null,
backupS3AccessKey: accessKey || null,
backupS3SecretKeyEnc: secretEnc,
},
create: {
id: "singleton",
backupTarget: target,
backupLocalDir: localDir || null,
backupS3Endpoint: endpoint || null,
backupS3Bucket: bucket || null,
backupS3Region: region || null,
backupS3AccessKey: accessKey || null,
backupS3SecretKeyEnc: secretEnc,
},
});
// Cache invalidieren, damit der nächste getBackupStore() das neue Ziel nutzt.
invalidateBackupStore();
await writePlatformAudit({
actorId: admin.id,
action: "update",
entity: "platform_setting",
entityId: "singleton",
// KEIN Secret/kein Klartext ins Audit — nur Metadaten.
after: {
backupTarget: target,
backupLocalDir: localDir || null,
backupS3Endpoint: endpoint || null,
backupS3Bucket: bucket || null,
backupS3Region: region || null,
s3SecretSet: !!secretEnc,
},
});
revalidatePath("/admin/backup");
return { status: "ok", message: "Backup-Zielspeicher gespeichert." };
} catch (err) {
return { status: "error", message: err instanceof Error ? err.message : "Speichern fehlgeschlagen." };
}
}
type StepUpAdmin = { id: string; mfaEnrolledAt: Date | null; mfaSecret: string | null; lastTotpStep: bigint | null };
/** Verifiziert einen frischen TOTP-Code (ohne Verbrauch). Ohne MFA übersprungen. */
function assertFreshTotp(admin: StepUpAdmin, code: string): void {
if (!admin.mfaEnrolledAt || !admin.mfaSecret) return;
if (!verifyTotp(code, admin.mfaSecret, admin.lastTotpStep).ok) {
throw new Error("Bitte die Aktion mit einem aktuellen MFA-Code bestätigen (Step-up).");
}
}
/** Wie assertFreshTotp, aber verbraucht den akzeptierten Zeitschritt (Replay-Schutz). */
async function assertFreshTotpAndConsume(admin: StepUpAdmin, code: string): Promise<void> {
if (!admin.mfaEnrolledAt || !admin.mfaSecret) return;
const res = verifyTotp(code, admin.mfaSecret, admin.lastTotpStep);
if (!res.ok) throw new Error("Bitte die Aktion mit einem aktuellen MFA-Code bestätigen (Step-up).");
await prisma.platformAdmin.update({ where: { id: admin.id }, data: { lastTotpStep: BigInt(res.step) } });
}
+124
View File
@@ -0,0 +1,124 @@
"use server";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { isAuditReadinessEnabled } from "@/lib/audit-readiness/activation";
import { buildDraftInput } from "@/server/control-descriptions-context";
import { draftControlDescription as draftAI } from "@/server/ai/draft-control-description";
/**
* Server-Actions der Control-Beschreibungen (VDA-ISA-Spalte 4) im Audit-Wizard.
*
* `moduleGuard("audit")` liefert die F-06-Autoritätsprüfungen (Kontostatus/Token/
* Rechte); der Wrapper `guard` erzwingt zusätzlich die Scharfschaltung des
* Audit-Wizards (`isAuditReadinessEnabled`, Default = aus). Der KI-Entwurf läuft
* über `src/server/ai/*` und degradiert bei fehlendem API-Key sauber (kein
* Schreibvorgang, Status bleibt „manuell zu erfassen").
*/
const rawGuard = moduleGuard("audit");
/** Autoritätsprüfung + Scharfschaltung; wirft, wenn der Wizard nicht aktiviert ist. */
async function guard(...permissions: Parameters<typeof rawGuard>) {
const { session, db } = await rawGuard(...permissions);
if (!(await isAuditReadinessEnabled(db))) throw new Error("Audit-Wizard ist nicht aktiviert.");
return { session, db };
}
const revalidate = () => revalidatePath("/audit-readiness", "layout");
/**
* KI-Entwurf für eine einzelne Anforderung erzeugen und als Entwurf speichern.
* Ohne konfigurierten API-Key (oder bei API-Fehler) passiert nichts — der Status
* bleibt „open" (manuell zu erfassen).
*/
export async function draftControlDescription(reqId: string) {
const { session, db } = await guard("onboarding:use");
const input = await buildDraftInput(db, reqId);
if (!input) throw new Error("Anforderung nicht gefunden.");
const result = await draftAI(input);
if (!result) return; // graceful degradation: kein Entwurf, kein Schreibvorgang.
const tenantId = session.user.tenantId;
await db.controlDescription.upsert({
where: { tenantId_reqId: { tenantId, reqId } },
update: { draftText: result.draftText, sourceRef: result.sourceRef, confidence: result.confidence, status: "draft", updatedById: session.user.id },
create: { tenantId, control: input.control, reqId, draftText: result.draftText, sourceRef: result.sourceRef, confidence: result.confidence, status: "draft", updatedById: session.user.id },
});
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "control_description", entityId: reqId, after: { status: "draft", confidence: result.confidence } });
revalidate();
}
/** KI-Entwurf für alle Anforderungen eines Controls erzeugen (sequentiell). */
export async function draftControl(control: string) {
const { session, db } = await guard("onboarding:use");
const tenantId = session.user.tenantId;
const reqs = await db.policyRequirement.findMany({ where: { control, archivedAt: null }, select: { reqId: true } });
let drafted = 0;
for (const r of reqs) {
const input = await buildDraftInput(db, r.reqId);
if (!input) continue;
const result = await draftAI(input);
if (!result) continue;
await db.controlDescription.upsert({
where: { tenantId_reqId: { tenantId, reqId: r.reqId } },
update: { draftText: result.draftText, sourceRef: result.sourceRef, confidence: result.confidence, status: "draft", updatedById: session.user.id },
create: { tenantId, control, reqId: r.reqId, draftText: result.draftText, sourceRef: result.sourceRef, confidence: result.confidence, status: "draft", updatedById: session.user.id },
});
drafted++;
}
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "control_description", entityId: control, after: { drafted } });
revalidate();
}
/** Entwurf übernehmen (Status → „confirmed"); der Nutzer bestätigt den KI-Vorschlag. */
export async function confirmDescription(reqId: string) {
const { session, db } = await guard("onboarding:use");
const tenantId = session.user.tenantId;
const row = await db.controlDescription.findUnique({ where: { tenantId_reqId: { tenantId, reqId } } });
if (!row?.draftText) throw new Error("Kein Entwurf zum Übernehmen vorhanden.");
await db.controlDescription.update({ where: { tenantId_reqId: { tenantId, reqId } }, data: { status: "confirmed", updatedById: session.user.id } });
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "control_description", entityId: reqId, after: { status: "confirmed" } });
revalidate();
}
/** Beschreibung manuell anpassen/erfassen (Status → „draft"). */
export async function saveDescription(reqId: string, formData: FormData) {
const { session, db } = await guard("onboarding:use");
const tenantId = session.user.tenantId;
const draftText = (formData.get("draftText") as string | null)?.trim() || null;
const sourceRef = (formData.get("sourceRef") as string | null)?.trim() || null;
const confidenceRaw = (formData.get("confidence") as string | null)?.trim();
const confidence = confidenceRaw === "high" || confidenceRaw === "medium" || confidenceRaw === "low" ? confidenceRaw : null;
const req = await db.policyRequirement.findFirst({ where: { reqId, archivedAt: null }, select: { control: true } });
if (!req) throw new Error("Anforderung nicht gefunden.");
await db.controlDescription.upsert({
where: { tenantId_reqId: { tenantId, reqId } },
update: { draftText, sourceRef, confidence, status: "draft", updatedById: session.user.id },
create: { tenantId, control: req.control, reqId, draftText, sourceRef, confidence, status: "draft", updatedById: session.user.id },
});
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "control_description", entityId: reqId, after: { status: "draft", manual: true } });
revalidate();
}
/** Gezielte Rückfrage zum fehlenden Beleg beantworten/speichern (`openAnswer`). */
export async function saveOpenAnswer(reqId: string, formData: FormData) {
const { session, db } = await guard("onboarding:use");
const tenantId = session.user.tenantId;
const openAnswer = (formData.get("openAnswer") as string | null)?.trim() || null;
const req = await db.policyRequirement.findFirst({ where: { reqId, archivedAt: null }, select: { control: true } });
if (!req) throw new Error("Anforderung nicht gefunden.");
await db.controlDescription.upsert({
where: { tenantId_reqId: { tenantId, reqId } },
update: { openAnswer, updatedById: session.user.id },
create: { tenantId, control: req.control, reqId, openAnswer, updatedById: session.user.id },
});
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "control_description", entityId: reqId, after: { openAnswer: Boolean(openAnswer) } });
revalidate();
}
+73
View File
@@ -0,0 +1,73 @@
"use server";
import { revalidatePath } from "next/cache";
import type { ObjectReviewStatus } from "@prisma/client";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { buildGapItems } from "@/server/gap-context";
import { domainForControl, assignTaskParticipants } from "@/server/control-domain";
/**
* Gap-Konsolidierung (Story A8) — Server-Aktion für den GAP-Schritt des Audit-Wizards
* (M4, Step-Key „audit_gap"). Legt für konsolidierte offene Punkte ohne bestehende
* Aufgabe idempotente Aufgaben an (dedup-bewusst: ein Punkt = eine Aufgabe, C8 §2), mit
* Priorität aus C8 §1 und allen Control-/Risiko-Bezügen. Bereits verknüpfte Punkte
* werden nicht doppelt angelegt.
*/
const guard = moduleGuard("onboarding");
type Db = Awaited<ReturnType<typeof guard>>["db"];
// Fortschritts-Key des GAP-Schritts im Audit-Wizard (s. AUDIT_STEP_KEYS).
const GAP_STEP_KEY = "audit_gap";
async function nudgeStep(db: Db, tenantId: string) {
const prog = await db.onboardingProgress.findUnique({ where: { tenantId_stepKey: { tenantId, stepKey: GAP_STEP_KEY } } });
if (!prog || prog.status === "offen") {
const status: ObjectReviewStatus = "in_bearbeitung";
await db.onboardingProgress.upsert({
where: { tenantId_stepKey: { tenantId, stepKey: GAP_STEP_KEY } },
update: { status },
create: { tenantId, stepKey: GAP_STEP_KEY, status },
});
}
}
/** Aufgaben für alle konsolidierten offenen Punkte ohne bestehende Aufgabe anlegen. */
export async function createConsolidatedGapTasks() {
const { session, db } = await guard("onboarding:use");
const { items } = await buildGapItems(db, session.user.tenantId);
let created = 0;
for (const item of items) {
if (item.taskId) continue; // bereits verknüpfte Aufgabe → keine neue (C8 §2)
const exists = await db.task.findFirst({ where: { origin: item.taskOrigin, status: { in: ["PROPOSED", "OPEN"] } }, select: { id: true } });
if (exists) continue;
const links: Record<string, string> = {};
if (item.controls[0]) links.control = item.controls[0];
if (item.source === "risk") links.risk = item.id.replace(/^risk:/, "");
// Cockpit (M3, 1.4): Bereich aus dem führenden Control ableiten (falls vorhanden).
const domain = await domainForControl(db, session.user.tenantId, item.controls[0] ?? null);
const task = await db.task.create({
data: {
tenantId: session.user.tenantId,
type: "organizational",
title: item.action,
origin: item.taskOrigin,
status: "PROPOSED",
priority: item.priority,
createdById: session.user.id,
domain,
links,
},
});
// Cockpit (M3, 2.3): RACI-Mitwirkende automatisch aus Control/Domain befüllen.
await assignTaskParticipants(db, session.user.tenantId, { taskId: task.id, control: item.controls[0] ?? null, domain });
created++;
}
await nudgeStep(db, session.user.tenantId);
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "gap_consolidation", after: { points: items.length, created } });
revalidatePath("/audit-readiness");
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
+34
View File
@@ -0,0 +1,34 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { createTask } from "@/server/actions/tasks";
/**
* Aus einem C6-Umsetzungshinweis (Story B5-2) eine Aufgabe erzeugen — v. a. bei
* Beschaffungs-/Umsetzungsbedarf (`procurement`). Nutzt createTask (F1). Idempotent
* über `origin = "hint:<reqId>"`: existiert bereits ein offener Task, passiert nichts.
*/
const guard = moduleGuard("policies");
export async function createHintTask(reqId: string) {
const { db } = await guard("policy:write");
const hint = await db.implementationHint.findUnique({ where: { reqId } });
if (!hint) throw new Error("Kein Umsetzungshinweis gefunden.");
const origin = `hint:${reqId}`;
const existing = await db.task.findFirst({ where: { origin, status: { in: ["PROPOSED", "OPEN"] } }, select: { id: true } });
if (!existing) {
await createTask({
type: "technical",
title: `Umsetzung ${hint.control}: ${hint.requirement.slice(0, 90)}`,
origin,
priority: "mittel",
links: { control: hint.control },
});
}
revalidatePath("/tasks");
revalidatePath("/policies/hints");
redirect("/tasks");
}
+105
View File
@@ -0,0 +1,105 @@
"use server";
import { revalidatePath } from "next/cache";
import { requirePlatformFullAdmin } from "@/server/platform-auth";
import { prisma } from "@/server/db";
import { writePlatformAudit } from "@/server/audit";
import { generateIntakeToken } from "@/server/incident-inbound/route";
/**
* IM-D — Betreiber-Provisionierung der E-Mail-Intake-Konfiguration.
*
* Der Betreiber legt beim Onboarding (oder später) je Kunde die Intake-Konfiguration
* an: ein global eindeutiger Token wird erzeugt, daraus die Catch-all-Adresse
* `vorfall-<token>@in.certvia.de` abgeleitet. Der Kunde richtet dann bei sich eine
* Weiterleitung ein; sobald die erste Test-Mail als Ticket ankommt, springt der
* Status automatisch auf `verifiziert` (process.ts). Der Betreiber kann den Status
* hier zusätzlich manuell setzen/zurücksetzen.
*
* Plattform-Fähigkeit (eigene Auth über requirePlatformFullAdmin), kein Fachmodul-Guard.
* Alle Änderungen landen im Plattform-Audit.
*/
function parseDomains(raw: FormDataEntryValue | null): string[] {
if (!raw) return [];
return [
...new Set(
String(raw)
.split(/[\s,;]+/)
.map((d) => d.trim().toLowerCase().replace(/^@/, ""))
.filter((d) => d.length > 0 && d.includes(".")),
),
];
}
/** Intake-Konfiguration anlegen/aktualisieren (Onboarding + spätere Pflege). */
export async function provisionIncidentIntake(tenantId: string, formData: FormData) {
const { admin } = await requirePlatformFullAdmin();
if (!tenantId) throw new Error("Kein Mandant angegeben.");
const allowlistDomains = parseDomains(formData.get("allowlistDomains"));
const sourceRaw = String(formData.get("sourceAddress") ?? "").trim().toLowerCase();
const sourceAddress = sourceRaw && sourceRaw.includes("@") ? sourceRaw : null;
const existing = await prisma.incidentIntakeConfig.findUnique({ where: { tenantId }, select: { id: true } });
const row = existing
? await prisma.incidentIntakeConfig.update({
where: { tenantId },
data: { allowlistDomains, sourceAddress },
select: { id: true, token: true },
})
: await prisma.incidentIntakeConfig.create({
data: { tenantId, token: generateIntakeToken(), allowlistDomains, sourceAddress },
select: { id: true, token: true },
});
await writePlatformAudit({
actorId: admin.id,
action: existing ? "update" : "create",
entity: "incident_intake_config",
entityId: row.id,
after: { tenantId, allowlistDomains, sourceAddress },
});
revalidatePath(`/admin/${tenantId}`);
revalidatePath("/admin");
}
/** Provisionierungs-Status manuell setzen (verifiziert ⇄ weiterleitung_ausstehend). */
export async function setIncidentIntakeStatus(tenantId: string, verified: boolean) {
const { admin } = await requirePlatformFullAdmin();
const row = await prisma.incidentIntakeConfig.update({
where: { tenantId },
data: {
status: verified ? "verifiziert" : "weiterleitung_ausstehend",
verifiedAt: verified ? new Date() : null,
},
select: { id: true },
});
await writePlatformAudit({
actorId: admin.id,
action: "update",
entity: "incident_intake_config",
entityId: row.id,
after: { tenantId, status: verified ? "verifiziert" : "weiterleitung_ausstehend" },
});
revalidatePath(`/admin/${tenantId}`);
revalidatePath("/admin");
}
/** Betreiber-Review erledigen (Inbound-Mail ohne/mit unbekanntem Token gesichtet). */
export async function resolveInboundReview(reviewId: string, status: "erledigt" | "zugeordnet") {
const { admin } = await requirePlatformFullAdmin();
const row = await prisma.incidentInboundReview.update({
where: { id: reviewId },
data: { status },
select: { id: true },
});
await writePlatformAudit({
actorId: admin.id,
action: "update",
entity: "incident_inbound_review",
entityId: row.id,
after: { status },
});
revalidatePath("/admin");
}
+70
View File
@@ -0,0 +1,70 @@
"use server";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { generateIntakeToken } from "@/server/incident-inbound/route";
/**
* IM-D — Mandantenseitige Pflege der E-Mail-Intake-Konfiguration (Modul „Vorfälle").
*
* Der Kunden-Admin pflegt hier die Allowlist-Domänen, die optionale Quelladresse und
* den Benachrichtigungsempfänger. Die Intake-Adresse selbst (`vorfall-<token>@…`) wird
* NICHT hier gesetzt, sondern aus dem global eindeutigen Token abgeleitet und nur
* read-only angezeigt. Existiert noch keine Konfiguration (Betreiber hat nicht
* provisioniert), wird sie beim ersten Speichern mit frischem Token angelegt —
* Status bleibt `weiterleitung_ausstehend`, bis die erste Test-Mail ankommt.
*
* moduleGuard("incidents") stellt sicher, dass das Modul aktiv ist; die Bearbeitung
* verlangt zusätzlich `tenant:manage`.
*/
const guard = moduleGuard("incidents");
/** Domänenliste aus Textarea/Feld normalisieren (eine je Zeile oder komma-/leerzeichengetrennt). */
function parseDomains(raw: FormDataEntryValue | null): string[] {
if (!raw) return [];
return [
...new Set(
String(raw)
.split(/[\s,;]+/)
.map((d) => d.trim().toLowerCase().replace(/^@/, ""))
.filter((d) => d.length > 0 && d.includes(".")),
),
];
}
export async function updateIncidentIntake(formData: FormData) {
// Guard prüft aktives Modul „incidents" + tenant:manage (autoritativ aus DB, F-06).
const { session, db } = await guard("tenant:manage");
const tenantId = session.user.tenantId;
const allowlistDomains = parseDomains(formData.get("allowlistDomains"));
const sourceRaw = String(formData.get("sourceAddress") ?? "").trim().toLowerCase();
const sourceAddress = sourceRaw && sourceRaw.includes("@") ? sourceRaw : null;
const notifyRaw = String(formData.get("notifyEmail") ?? "").trim().toLowerCase();
const notifyEmail = notifyRaw && notifyRaw.includes("@") ? notifyRaw : null;
const existing = await db.incidentIntakeConfig.findUnique({ where: { tenantId }, select: { id: true } });
if (existing) {
await db.incidentIntakeConfig.update({
where: { tenantId },
data: { allowlistDomains, sourceAddress, notifyEmail },
});
} else {
// Erste Pflege durch den Kunden: Token erzeugen (global eindeutig; bei extrem
// unwahrscheinlicher Kollision greift der UNIQUE-Constraint → seltener Retry).
await db.incidentIntakeConfig.create({
data: { tenantId, token: generateIntakeToken(), allowlistDomains, sourceAddress, notifyEmail },
});
}
await writeAuditLog({
tenantId,
actorId: session.user.id,
action: "update",
entity: "incident_intake_config",
after: { allowlistDomains, sourceAddress, notifyEmail },
});
revalidatePath("/settings/incident-intake");
}
+872
View File
@@ -0,0 +1,872 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { prisma } from "@/server/db";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { severityFromIncident, INCIDENT_SEVERITIES } from "@/lib/incident-severity";
import { nextIncidentRefNo } from "@/server/incident-refno";
import {
INCIDENT_CATEGORIES,
INCIDENT_PRIORITIES,
INCIDENT_SOURCES,
INCIDENT_STATUSES,
canSeeRestricted,
canTransition,
missingRequiredFields,
type IncidentStatus,
} from "@/lib/incident";
import {
computeDeadlines,
isReportable,
knowledgeTime,
nextReportStatus,
REPORT_STATUS_ORDER,
type ReportStatus,
} from "@/lib/incident-deadlines";
import {
incidentManagerIds,
notifyIncidentEvent,
} from "@/server/mail/incident-notifications";
const guard = moduleGuard("incidents");
type GuardDb = Awaited<ReturnType<typeof guard>>["db"];
/**
* Meldepflicht-Felder (Fristen + Meldung-Track-Status) aus den Flags und dem
* Kenntniszeitpunkt berechnen (§6). NIS2-Timer greifen nur bei
* nis2Category ∈ {wichtig, wesentlich} UND nis2Relevant; DSGVO bei Personenbezug.
* Wird ein noch nicht gemeldeter Vorfall meldepflichtig, springt reportStatus
* `none → pruefung`; entfällt die Meldepflicht wieder, zurück auf `none`.
*/
async function reportingFieldsFor(
db: GuardDb,
tenantId: string,
flags: { nis2Relevant: boolean; dsgvoRelevant: boolean },
times: { detectedAt: Date | null; reportedAt: Date | null; occurredAt: Date | null },
currentReportStatus: string,
) {
const settings = await db.tenantSettings.findUnique({
where: { tenantId },
select: { nis2Category: true },
});
const nis2Category = settings?.nis2Category ?? "keine";
const knownAt = knowledgeTime({ ...times, createdAt: new Date() });
const deadlines = computeDeadlines({
nis2Category,
nis2Relevant: flags.nis2Relevant,
dsgvoRelevant: flags.dsgvoRelevant,
knownAt,
});
const reportable = isReportable({
nis2Category,
nis2Relevant: flags.nis2Relevant,
dsgvoRelevant: flags.dsgvoRelevant,
});
let reportStatus = currentReportStatus;
if (reportable) {
if (currentReportStatus === "none") reportStatus = "pruefung";
} else if (currentReportStatus === "none" || currentReportStatus === "pruefung") {
reportStatus = "none";
}
return { ...deadlines, reportStatus };
}
const impactScale = z.coerce.number().int().min(0).max(4);
const optDate = z
.string()
.trim()
.optional()
.transform((v) => (v ? new Date(v) : null));
const incidentBaseSchema = z.object({
title: z.string().trim().min(1).max(200),
description: z.string().trim().max(10000).optional(),
source: z.enum(INCIDENT_SOURCES).default("manual"),
reporterName: z.string().trim().max(200).optional(),
reporterContact: z.string().trim().max(200).optional(),
occurredAt: optDate,
detectedAt: optDate,
reportedAt: optDate,
category: z.enum(INCIDENT_CATEGORIES).default("other"),
impactC: impactScale,
impactI: impactScale,
impactA: impactScale,
urgency: impactScale,
affectedDataCategories: z.string().trim().max(2000).optional(),
personalData: z.coerce.boolean().default(false),
prototypeData: z.coerce.boolean().default(false),
priority: z.enum(INCIDENT_PRIORITIES).default("mittel"),
nis2Relevant: z.coerce.boolean().default(false),
});
function parseIncidentForm(formData: FormData) {
const p = incidentBaseSchema.parse({
title: formData.get("title"),
description: formData.get("description") || undefined,
source: formData.get("source") || undefined,
reporterName: formData.get("reporterName") || undefined,
reporterContact: formData.get("reporterContact") || undefined,
occurredAt: formData.get("occurredAt") || undefined,
detectedAt: formData.get("detectedAt") || undefined,
reportedAt: formData.get("reportedAt") || undefined,
category: formData.get("category") || undefined,
impactC: formData.get("impactC") ?? 0,
impactI: formData.get("impactI") ?? 0,
impactA: formData.get("impactA") ?? 0,
urgency: formData.get("urgency") ?? 2,
affectedDataCategories: formData.get("affectedDataCategories") || undefined,
personalData: formData.get("personalData") === "on" || formData.get("personalData") === "true",
prototypeData:
formData.get("prototypeData") === "on" || formData.get("prototypeData") === "true",
priority: formData.get("priority") || undefined,
nis2Relevant: formData.get("nis2Relevant") === "on" || formData.get("nis2Relevant") === "true",
});
const dataCategories = (p.affectedDataCategories ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
// Severity aus der Default-Matrix (§5); dsgvoRelevant folgt dem Personenbezug (§4).
const severity = severityFromIncident(p.impactC, p.impactI, p.impactA, p.urgency);
return {
title: p.title,
description: p.description || null,
source: p.source,
reporterName: p.reporterName || null,
reporterContact: p.reporterContact || null,
occurredAt: p.occurredAt,
detectedAt: p.detectedAt,
reportedAt: p.reportedAt,
category: p.category,
impactC: p.impactC,
impactI: p.impactI,
impactA: p.impactA,
urgency: p.urgency,
affectedDataCategories: dataCategories,
personalData: p.personalData,
prototypeData: p.prototypeData,
priority: p.priority,
severity,
nis2Relevant: p.nis2Relevant,
dsgvoRelevant: p.personalData,
};
}
export async function createIncident(formData: FormData) {
const { session, db } = await guard("incident:report");
const data = parseIncidentForm(formData);
const refNo = await nextIncidentRefNo(prisma, session.user.tenantId);
// Meldefristen (§6) aus Kenntniszeitpunkt setzen, falls bereits meldepflichtig.
const reporting = await reportingFieldsFor(
db,
session.user.tenantId,
{ nis2Relevant: data.nis2Relevant, dsgvoRelevant: data.dsgvoRelevant },
{ detectedAt: data.detectedAt, reportedAt: data.reportedAt, occurredAt: data.occurredAt },
"none",
);
const incident = await db.incident.create({
data: {
...data,
...reporting,
refNo,
tenantId: session.user.tenantId,
createdBy: session.user.id,
// Melder wird, sofern nicht gesetzt, mit dem anlegenden Nutzer initialisiert.
ownerId: null,
},
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "incident",
entityId: incident.id,
after: { refNo, title: data.title, severity: data.severity, status: "neu" },
});
// §7 — neuer Vorfall → ISB/Incident-Manager.
await notifyIncidentEvent({
tenantId: session.user.tenantId,
event: "incident_created",
incidentId: incident.id,
refNo,
title: data.title,
recipientIds: await incidentManagerIds(session.user.tenantId),
actorId: session.user.id,
});
revalidatePath("/incidents");
redirect(`/incidents?detail=${incident.id}`);
}
export async function updateIncident(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const before = await db.incident.findFirst({
where: { id: incidentId, tenantId: session.user.tenantId },
});
if (!before) throw new Error("Vorfall nicht gefunden");
const data = parseIncidentForm(formData);
// Meldepflicht/Fristen bei geänderten Flags/Zeiten neu berechnen (§6).
const reporting = await reportingFieldsFor(
db,
session.user.tenantId,
{ nis2Relevant: data.nis2Relevant, dsgvoRelevant: data.dsgvoRelevant },
{ detectedAt: data.detectedAt, reportedAt: data.reportedAt, occurredAt: data.occurredAt },
before.reportStatus,
);
await db.incident.update({ where: { id: incidentId }, data: { ...data, ...reporting } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "incident",
entityId: incidentId,
before: { title: before.title, severity: before.severity, category: before.category },
after: { title: data.title, severity: data.severity, category: data.category },
});
revalidatePath("/incidents");
redirect(`/incidents?detail=${incidentId}`);
}
/**
* Statuswechsel (§3) mit Pflichtfeld-Prüfung je Übergang (§8) und Audit-Eintrag.
* Übergang nach „abgeschlossen" verlangt das Recht incident:close, sonst manage.
*/
export async function transitionIncident(incidentId: string, formData: FormData) {
const target = z.enum(INCIDENT_STATUSES).parse(formData.get("status"));
const permission = target === "abgeschlossen" ? "incident:close" : "incident:manage";
const { session, db } = await guard(permission);
const before = await db.incident.findFirst({
where: { id: incidentId, tenantId: session.user.tenantId },
});
if (!before) throw new Error("Vorfall nicht gefunden");
const from = before.status as IncidentStatus;
if (from === target) throw new Error("Kein Statuswechsel (gleicher Status).");
if (!canTransition(from, target)) {
throw new Error(`Übergang ${from} → ${target} ist nicht erlaubt.`);
}
// Pflichtfelder je Zielstatus (§8): entweder bereits gesetzt oder in diesem Formular mitgeliefert.
const patch: Record<string, string | null> = {};
const textField = (key: string): string | null => {
const raw = formData.get(key);
return raw !== null && String(raw).trim() ? String(raw).trim() : null;
};
const effective: Record<string, unknown> = {
rootCause: before.rootCause,
resolution: before.resolution,
closingNote: before.closingNote,
lessonsLearned: before.lessonsLearned,
};
for (const field of ["rootCause", "resolution", "closingNote", "lessonsLearned"] as const) {
const provided = textField(field);
if (provided) {
patch[field] = provided;
effective[field] = provided;
}
}
// Optionale Abschluss-Zusatzfelder (§8 IM-C): Wirksamkeit der Maßnahmen und
// Post-Incident-Review — beim Abschluss mitschreibbar, nicht erzwungen.
for (const field of ["measuresEffectiveness", "postIncidentReview"] as const) {
const provided = textField(field);
if (provided) patch[field] = provided;
}
const missing = missingRequiredFields(target, effective);
if (missing.length > 0) {
throw new Error(`Pflichtfeld(er) für Status „${target}" fehlt/fehlen: ${missing.join(", ")}`);
}
// interne Meldung (reportedAt) beim ersten Verlassen von „neu" festhalten
const reportedAt = from === "neu" && !before.reportedAt ? new Date() : before.reportedAt;
// Fristen ggf. neu ableiten (Kenntniszeitpunkt kann sich durch reportedAt ändern).
const reporting = await reportingFieldsFor(
db,
session.user.tenantId,
{ nis2Relevant: before.nis2Relevant, dsgvoRelevant: before.dsgvoRelevant },
{ detectedAt: before.detectedAt, reportedAt, occurredAt: before.occurredAt },
before.reportStatus,
);
await db.incident.update({
where: { id: incidentId },
data: {
status: target,
...patch,
...reporting,
reportedAt,
},
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "incident",
entityId: incidentId,
before: { status: from },
after: { status: target },
});
// §7 — Statuswechsel an Beteiligte; Abschluss zusätzlich an Melder + Manager.
const beteiligte = [before.ownerId, before.assigneeId, before.createdBy];
if (target === "abgeschlossen") {
await notifyIncidentEvent({
tenantId: session.user.tenantId,
event: "incident_closed",
incidentId,
refNo: before.refNo,
title: before.title,
recipientIds: [...beteiligte, ...(await incidentManagerIds(session.user.tenantId))],
actorId: session.user.id,
});
} else {
await notifyIncidentEvent({
tenantId: session.user.tenantId,
event: "incident_status_changed",
incidentId,
refNo: before.refNo,
title: before.title,
recipientIds: beteiligte,
actorId: session.user.id,
detail: target,
dedupeSuffix: target,
});
}
revalidatePath("/incidents");
redirect(`/incidents?detail=${incidentId}`);
}
/**
* Meldepflicht setzen/prüfen (§3/§6): NIS2-Relevanz und Personenbezug markieren,
* Fristen aus dem Kenntniszeitpunkt (neu) berechnen und den Meldung-Track ggf.
* `none → pruefung` schalten. Recht incident:manage, mit Audit.
*/
export async function setIncidentReportability(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const before = await db.incident.findFirst({
where: { id: incidentId, tenantId: session.user.tenantId },
});
if (!before) throw new Error("Vorfall nicht gefunden");
const nis2Relevant =
formData.get("nis2Relevant") === "on" || formData.get("nis2Relevant") === "true";
const personalData =
formData.get("personalData") === "on" || formData.get("personalData") === "true";
const reporting = await reportingFieldsFor(
db,
session.user.tenantId,
{ nis2Relevant, dsgvoRelevant: personalData },
{ detectedAt: before.detectedAt, reportedAt: before.reportedAt, occurredAt: before.occurredAt },
before.reportStatus,
);
await db.incident.update({
where: { id: incidentId },
data: { nis2Relevant, personalData, dsgvoRelevant: personalData, ...reporting },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "incident",
entityId: incidentId,
before: {
nis2Relevant: before.nis2Relevant,
dsgvoRelevant: before.dsgvoRelevant,
reportStatus: before.reportStatus,
},
after: { nis2Relevant, dsgvoRelevant: personalData, reportStatus: reporting.reportStatus },
});
revalidatePath("/incidents");
redirect(`/incidents?detail=${incidentId}`);
}
/**
* Meldung-Track vorwärts schalten (§3 Parallel-Track): pruefung → Erstmeldung →
* Folgemeldung → Abschlussbericht. Übermittlung an die Behörde MANUELL — hier nur
* Status + Timer. Abschluss verlangt incident:close, sonst incident:manage. Audit.
*/
export async function advanceReportStatus(incidentId: string, formData: FormData) {
const target = z
.enum(["erstmeldung", "folgemeldung", "abschluss"])
.parse(formData.get("reportStatus"));
const permission = target === "abschluss" ? "incident:close" : "incident:manage";
const { session, db } = await guard(permission);
const before = await db.incident.findFirst({
where: { id: incidentId, tenantId: session.user.tenantId },
});
if (!before) throw new Error("Vorfall nicht gefunden");
const current = before.reportStatus as ReportStatus;
if (REPORT_STATUS_ORDER[current] === undefined || current === "none") {
throw new Error("Für diesen Vorfall besteht keine Meldepflicht (Track nicht aktiv).");
}
const allowed = nextReportStatus(current);
if (allowed !== target) {
throw new Error(`Meldung-Übergang ${current} → ${target} ist nicht erlaubt.`);
}
await db.incident.update({ where: { id: incidentId }, data: { reportStatus: target } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "incident",
entityId: incidentId,
before: { reportStatus: current },
after: { reportStatus: target },
});
await notifyIncidentEvent({
tenantId: session.user.tenantId,
event: "incident_status_changed",
incidentId,
refNo: before.refNo,
title: before.title,
recipientIds: [before.ownerId, before.assigneeId, before.createdBy],
actorId: session.user.id,
detail: `Meldung: ${target}`,
dedupeSuffix: `report_${target}`,
});
revalidatePath("/incidents");
redirect(`/incidents?detail=${incidentId}`);
}
export async function addIncidentComment(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:report");
const body = z.string().trim().min(1).max(5000).parse(formData.get("body"));
const internal = formData.get("internal") === "on" || formData.get("internal") === "true";
const incident = await db.incident.findFirst({
where: { id: incidentId, tenantId: session.user.tenantId },
select: { id: true },
});
if (!incident) throw new Error("Vorfall nicht gefunden");
await db.incidentComment.create({
data: { tenantId: session.user.tenantId, incidentId, authorId: session.user.id, body, internal },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "incident_comment",
entityId: incidentId,
});
revalidatePath("/incidents");
redirect(`/incidents?detail=${incidentId}`);
}
export async function editIncidentComment(commentId: string, formData: FormData) {
const { session, db } = await guard("incident:report");
const body = z.string().trim().min(1).max(5000).parse(formData.get("body"));
const before = await db.incidentComment.findFirst({
where: { id: commentId, tenantId: session.user.tenantId },
select: { id: true, authorId: true, incidentId: true },
});
if (!before) throw new Error("Kommentar nicht gefunden");
// Nur der Autor darf seinen Kommentar bearbeiten (sofern nicht manage/close).
if (before.authorId !== session.user.id && !canSeeRestricted(session)) {
throw new Error("Nur der Autor darf den Kommentar bearbeiten.");
}
await db.incidentComment.update({ where: { id: commentId }, data: { body } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "incident_comment",
entityId: before.incidentId,
});
revalidatePath("/incidents");
redirect(`/incidents?detail=${before.incidentId}`);
}
async function requireIncident(
db: Awaited<ReturnType<typeof guard>>["db"],
tenantId: string,
incidentId: string,
) {
const inc = await db.incident.findFirst({
where: { id: incidentId, tenantId },
select: { id: true },
});
if (!inc) throw new Error("Vorfall nicht gefunden");
}
export async function linkIncidentAsset(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const assetId = z.string().min(1).parse(formData.get("assetId"));
await requireIncident(db, session.user.tenantId, incidentId);
if ((await db.asset.count({ where: { id: assetId } })) !== 1) throw new Error("Asset nicht gefunden");
await db.incidentAsset.upsert({
where: { incidentId_assetId: { incidentId, assetId } },
update: {},
create: { tenantId: session.user.tenantId, incidentId, assetId },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "incident_asset", entityId: incidentId, after: { assetId } });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
export async function unlinkIncidentAsset(incidentId: string, linkId: string) {
const { session, db } = await guard("incident:manage");
const row = await db.incidentAsset.findFirst({ where: { id: linkId, tenantId: session.user.tenantId } });
if (row) await db.incidentAsset.delete({ where: { id: linkId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "incident_asset", entityId: incidentId });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
export async function linkIncidentProcess(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const processId = z.string().min(1).parse(formData.get("processId"));
await requireIncident(db, session.user.tenantId, incidentId);
if ((await db.process.count({ where: { id: processId } })) !== 1) throw new Error("Prozess nicht gefunden");
await db.incidentProcess.upsert({
where: { incidentId_processId: { incidentId, processId } },
update: {},
create: { tenantId: session.user.tenantId, incidentId, processId },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "incident_process", entityId: incidentId, after: { processId } });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
export async function unlinkIncidentProcess(incidentId: string, linkId: string) {
const { session, db } = await guard("incident:manage");
const row = await db.incidentProcess.findFirst({ where: { id: linkId, tenantId: session.user.tenantId } });
if (row) await db.incidentProcess.delete({ where: { id: linkId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "incident_process", entityId: incidentId });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
export async function linkIncidentRisk(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const riskId = z.string().min(1).parse(formData.get("riskId"));
await requireIncident(db, session.user.tenantId, incidentId);
if ((await db.risk.count({ where: { id: riskId } })) !== 1) throw new Error("Risiko nicht gefunden");
await db.incidentRisk.upsert({
where: { incidentId_riskId: { incidentId, riskId } },
update: {},
create: { tenantId: session.user.tenantId, incidentId, riskId },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "incident_risk", entityId: incidentId, after: { riskId } });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
export async function unlinkIncidentRisk(incidentId: string, linkId: string) {
const { session, db } = await guard("incident:manage");
const row = await db.incidentRisk.findFirst({ where: { id: linkId, tenantId: session.user.tenantId } });
if (row) await db.incidentRisk.delete({ where: { id: linkId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "incident_risk", entityId: incidentId });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
export async function linkIncidentControl(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const controlRef = z.string().trim().min(1).max(50).parse(formData.get("controlRef"));
await requireIncident(db, session.user.tenantId, incidentId);
await db.incidentControl.upsert({
where: { incidentId_controlRef: { incidentId, controlRef } },
update: {},
create: { tenantId: session.user.tenantId, incidentId, controlRef },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "incident_control", entityId: incidentId, after: { controlRef } });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
export async function unlinkIncidentControl(incidentId: string, linkId: string) {
const { session, db } = await guard("incident:manage");
const row = await db.incidentControl.findFirst({ where: { id: linkId, tenantId: session.user.tenantId } });
if (row) await db.incidentControl.delete({ where: { id: linkId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "incident_control", entityId: incidentId });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
/** Verknüpfung zum zentralen Maßnahmen-Modul (§9) — nur verknüpfen, Detailausbau IM-C. */
export async function linkIncidentMeasure(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const measureId = z.string().min(1).parse(formData.get("measureId"));
await requireIncident(db, session.user.tenantId, incidentId);
if ((await db.measure.count({ where: { id: measureId } })) !== 1) throw new Error("Maßnahme nicht gefunden");
await db.incidentMeasure.upsert({
where: { incidentId_measureId: { incidentId, measureId } },
update: {},
create: { tenantId: session.user.tenantId, incidentId, measureId },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "incident_measure", entityId: incidentId, after: { measureId } });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
export async function unlinkIncidentMeasure(incidentId: string, linkId: string) {
const { session, db } = await guard("incident:manage");
const row = await db.incidentMeasure.findFirst({ where: { id: linkId, tenantId: session.user.tenantId } });
if (row) await db.incidentMeasure.delete({ where: { id: linkId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "incident_measure", entityId: incidentId });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
/** Gültige aktive Nutzer-ID des eigenen Mandanten oder null (für FK-Sicherheit). */
async function validOwnerOrNull(db: GuardDb, ownerId: string | null): Promise<string | null> {
if (!ownerId) return null;
return (await db.user.count({ where: { id: ownerId, status: "ACTIVE" } })) === 1 ? ownerId : null;
}
/**
* §9 — „Maßnahme direkt aus dem Vorfall anlegen": legt eine Maßnahme im ZENTRALEN
* Maßnahmen-Modul an (nicht doppelt) und verknüpft sie mit dem Vorfall
* (IncidentMeasure). Verlangt incident:manage UND measure:write.
*/
export async function createMeasureForIncident(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage", "measure:write");
await requireIncident(db, session.user.tenantId, incidentId);
const title = z.string().trim().min(1).max(200).parse(formData.get("title"));
const description = (formData.get("description") as string | null)?.trim() || null;
const priority = z.enum(["LOW", "MEDIUM", "HIGH"]).catch("MEDIUM").parse(formData.get("priority") ?? "MEDIUM");
const ownerId = await validOwnerOrNull(db, (formData.get("ownerId") as string | null)?.trim() || null);
const dueRaw = (formData.get("dueDate") as string | null)?.trim() || null;
const last = await prisma.measure.aggregate({
where: { tenantId: session.user.tenantId },
_max: { refNo: true },
});
const measure = await db.measure.create({
data: {
title,
description,
priority,
ownerId,
dueDate: dueRaw ? new Date(dueRaw) : null,
refNo: (last._max.refNo ?? 0) + 1,
tenantId: session.user.tenantId,
createdBy: session.user.id,
},
});
await db.incidentMeasure.create({
data: { tenantId: session.user.tenantId, incidentId, measureId: measure.id },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "measure", entityId: measure.id, after: { title, fromIncident: incidentId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "incident_measure", entityId: incidentId, after: { measureId: measure.id, created: true } });
revalidatePath("/incidents");
revalidatePath("/measures");
redirect(`/incidents?edit=${incidentId}`);
}
/**
* §9 — Vorfall erzeugt ein NEUES Risiko: legt es im Risiko-Modul an (Register)
* und verknüpft es (IncidentRisk). Verlangt incident:manage UND risk:write.
* (Ein BESTEHENDES Risiko bestätigt der Vorfall über linkIncidentRisk.)
*/
export async function createRiskForIncident(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage", "risk:write");
await requireIncident(db, session.user.tenantId, incidentId);
const title = z.string().trim().min(1).max(200).parse(formData.get("title"));
const description = (formData.get("description") as string | null)?.trim() || null;
const likelihood = z.coerce.number().int().min(1).max(5).catch(3).parse(formData.get("likelihood") ?? 3);
const impact = z.coerce.number().int().min(1).max(5).catch(3).parse(formData.get("impact") ?? 3);
const last = await prisma.risk.aggregate({
where: { tenantId: session.user.tenantId },
_max: { refNo: true },
});
const risk = await db.risk.create({
data: {
title,
description,
likelihood,
impact,
score: likelihood * impact,
refNo: (last._max.refNo ?? 0) + 1,
tenantId: session.user.tenantId,
createdBy: session.user.id,
},
});
await db.incidentRisk.create({
data: { tenantId: session.user.tenantId, incidentId, riskId: risk.id },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "risk", entityId: risk.id, after: { title, fromIncident: incidentId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "incident_risk", entityId: incidentId, after: { riskId: risk.id, created: true } });
revalidatePath("/incidents");
revalidatePath("/risks");
redirect(`/incidents?edit=${incidentId}`);
}
/** Bestehenden Nachweis (zentrales Evidence-Modell) mit dem Vorfall verknüpfen. */
export async function linkIncidentEvidence(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const evidenceId = z.string().min(1).parse(formData.get("evidenceId"));
const note = (formData.get("note") as string | null)?.trim() || null;
await requireIncident(db, session.user.tenantId, incidentId);
if ((await db.evidence.count({ where: { id: evidenceId } })) !== 1) throw new Error("Nachweis nicht gefunden");
await db.incidentEvidence.upsert({
where: { incidentId_evidenceId: { incidentId, evidenceId } },
update: { note },
create: { tenantId: session.user.tenantId, incidentId, evidenceId, note },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "incident_evidence", entityId: incidentId, after: { evidenceId } });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
/**
* Neuen Nachweis (Referenz/Text) direkt am Vorfall anlegen und verknüpfen — als
* Beweissicherung (ISO A.5.28); Datei-Persistenz folgt mit dem Storage-Paket.
* Verlangt incident:manage UND evidence:write.
*/
export async function createEvidenceForIncident(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage", "evidence:write");
await requireIncident(db, session.user.tenantId, incidentId);
const title = z.string().trim().min(1).max(200).parse(formData.get("title"));
const kind = z.enum(["record", "protocol", "screenshot", "export"]).catch("record").parse(formData.get("kind") ?? "record");
const fileRef = (formData.get("fileRef") as string | null)?.trim() || null;
const note = (formData.get("note") as string | null)?.trim() || null;
const ev = await db.evidence.create({
data: { tenantId: session.user.tenantId, title, kind, fileRef, createdById: session.user.id },
});
await db.incidentEvidence.create({
data: { tenantId: session.user.tenantId, incidentId, evidenceId: ev.id, note },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "incident_evidence", entityId: incidentId, after: { evidenceId: ev.id, created: true } });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
export async function unlinkIncidentEvidence(incidentId: string, linkId: string) {
const { session, db } = await guard("incident:manage");
const row = await db.incidentEvidence.findFirst({ where: { id: linkId, tenantId: session.user.tenantId } });
if (row) await db.incidentEvidence.delete({ where: { id: linkId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "incident_evidence", entityId: incidentId });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
/**
* §8 — Post-Incident-Review (Kurzbericht) + Wirksamkeit der Maßnahmen setzen.
* Der Review-Text speist das Management-Review (als Feld/Notiz, da kein eigenes
* Review-Modul). Recht incident:manage.
*/
export async function setIncidentReview(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
await requireIncident(db, session.user.tenantId, incidentId);
const measuresEffectiveness = (formData.get("measuresEffectiveness") as string | null)?.trim() || null;
const postIncidentReview = (formData.get("postIncidentReview") as string | null)?.trim() || null;
await db.incident.update({ where: { id: incidentId }, data: { measuresEffectiveness, postIncidentReview } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "incident", entityId: incidentId, after: { postIncidentReview: Boolean(postIncidentReview), measuresEffectiveness: Boolean(measuresEffectiveness) } });
revalidatePath("/incidents");
redirect(`/incidents?detail=${incidentId}`);
}
/** Severity/Kategorie manuell setzen (überschreibt/bestätigt die Default-Matrix). */
export async function setIncidentSeverity(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const severity = z.enum(INCIDENT_SEVERITIES).parse(formData.get("severity"));
await requireIncident(db, session.user.tenantId, incidentId);
await db.incident.update({ where: { id: incidentId }, data: { severity } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "incident", entityId: incidentId, after: { severity } });
revalidatePath("/incidents");
redirect(`/incidents?detail=${incidentId}`);
}
export async function setIncidentCategory(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const category = z.enum(INCIDENT_CATEGORIES).parse(formData.get("category"));
await requireIncident(db, session.user.tenantId, incidentId);
await db.incident.update({ where: { id: incidentId }, data: { category } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "incident", entityId: incidentId, after: { category } });
revalidatePath("/incidents");
redirect(`/incidents?detail=${incidentId}`);
}
/** Vertraulichkeit umschalten (§11). Nur manage/close. */
export async function setIncidentRestricted(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const restricted = formData.get("restricted") === "on" || formData.get("restricted") === "true";
await requireIncident(db, session.user.tenantId, incidentId);
await db.incident.update({ where: { id: incidentId }, data: { restricted } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "incident", entityId: incidentId, after: { restricted } });
revalidatePath("/incidents");
redirect(`/incidents?detail=${incidentId}`);
}
/** Owner (Incident-Manager) und Bearbeiter (assignee) setzen (§3 Steuerung). */
export async function setIncidentOwners(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
await requireIncident(db, session.user.tenantId, incidentId);
const ownerId = (formData.get("ownerId") as string | null)?.trim() || null;
const assigneeId = (formData.get("assigneeId") as string | null)?.trim() || null;
// Zuweisungen müssen aktive Nutzer des eigenen Mandanten sein.
for (const uid of [ownerId, assigneeId]) {
if (uid && (await db.user.count({ where: { id: uid, status: "ACTIVE" } })) !== 1) {
throw new Error("Zugewiesener Nutzer ist ungültig.");
}
}
const inc = await db.incident.findFirst({
where: { id: incidentId, tenantId: session.user.tenantId },
select: { refNo: true, title: true, assigneeId: true },
});
await db.incident.update({ where: { id: incidentId }, data: { ownerId, assigneeId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "incident", entityId: incidentId, after: { ownerId, assigneeId } });
// §7 — Zuweisung → Bearbeiter (nur bei echter Änderung des Bearbeiters).
if (assigneeId && assigneeId !== inc?.assigneeId && inc) {
await notifyIncidentEvent({
tenantId: session.user.tenantId,
event: "incident_assigned",
incidentId,
refNo: inc.refNo,
title: inc.title,
recipientIds: [assigneeId],
actorId: session.user.id,
});
}
revalidatePath("/incidents");
redirect(`/incidents?detail=${incidentId}`);
}
+116
View File
@@ -0,0 +1,116 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/server/db";
import { requirePlatformSession } from "@/server/platform-auth";
import { writePlatformAudit } from "@/server/audit";
import { getMailConfig } from "@/server/mail/config";
import { enqueueMail } from "@/server/mail/service";
import { isQueueEnabled } from "@/server/mail/queue";
import { formatWhen, normalizeLocale } from "@/server/mail/templates";
/**
* SEC1 §9 — Admin-Testversand.
*
* EXEMPT vom Modul-Gating: Betriebsfunktion der Plattform-Administration, kein
* mandantengebundenes Fachmodul. Autorisierung läuft über
* `requirePlatformSession()` (getrennte Auth-Domäne, prüft Session positiv und
* den Kontostatus autoritativ aus der DB), nicht über `moduleGuard`.
*
* Der Versand geht bewusst nur an die **eigene** Adresse des angemeldeten
* Plattform-Admins — die Aktion soll die Zustellstrecke prüfen, nicht als
* Mail-Relay für beliebige Empfänger dienen.
*/
export type TestMailState =
| { status: "idle" }
| { status: "ok"; message: string }
| { status: "error"; message: string };
export async function sendTestMail(): Promise<TestMailState> {
try {
const session = await requirePlatformSession();
const admin = await prisma.platformAdmin.findUnique({
where: { id: session.user.id },
select: { id: true, email: true, name: true },
});
if (!admin) return { status: "error", message: "Konto nicht gefunden." };
const { config, reason } = getMailConfig();
if (!config) return { status: "error", message: reason ?? "SMTP ist nicht konfiguriert." };
const locale = normalizeLocale("de");
const result = await enqueueMail({
template: "test",
to: admin.email,
// Plattform-Mail ohne Mandantenbezug → scope=platform, tenantId=null.
tenantId: null,
locale,
vars: { name: admin.name, when: formatWhen(new Date(), locale) },
});
await writePlatformAudit({
actorId: admin.id,
action: "create",
entity: "mail_test",
entityId: "mailLogId" in result ? result.mailLogId : undefined,
after: { to: admin.email, status: result.status },
});
revalidatePath("/admin");
switch (result.status) {
case "sent":
return { status: "ok", message: `Test-Mail an ${admin.email} versendet.` };
case "queued":
return {
status: "ok",
message: `Test-Mail an ${admin.email} eingestellt — die Zustellung übernimmt der Worker.`,
};
case "duplicate":
return { status: "ok", message: "Es liegt bereits eine identische Mail in der Warteschlange." };
case "not_configured":
return { status: "error", message: result.reason };
case "error":
return { status: "error", message: `Versand fehlgeschlagen: ${result.error}` };
}
} catch (err) {
// Kein harter Wurf → globale Error-Boundary: fehlende Plattform-Session o. Ä.
// wird als anzeigbare Meldung zurückgegeben.
return { status: "error", message: err instanceof Error ? err.message : "Test-Versand fehlgeschlagen." };
}
}
/** Betriebszustand der Mail-Strecke für die Anzeige im Adminportal. */
export async function getMailStatus(): Promise<{
configured: boolean;
reason?: string;
host?: string;
from?: string;
queue: "bullmq" | "inline";
recent: { id: string; to: string; template: string; status: string; error: string | null; createdAt: Date }[];
}> {
try {
await requirePlatformSession();
const { config, reason } = getMailConfig();
const recent = await prisma.mailLog.findMany({
orderBy: { createdAt: "desc" },
take: 10,
select: { id: true, to: true, template: true, status: true, error: true, createdAt: true },
});
return {
configured: config != null,
reason,
host: config ? `${config.host}:${config.port}` : undefined,
from: config?.from,
queue: isQueueEnabled() ? "bullmq" : "inline",
recent,
};
} catch (err) {
// Defensive: nie die (platform)/admin-Seite mit einer Ausnahme zerlegen.
return { configured: false, reason: err instanceof Error ? err.message : "Mailstatus nicht verfügbar.", queue: isQueueEnabled() ? "bullmq" : "inline", recent: [] };
}
}
+253
View File
@@ -0,0 +1,253 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { prisma } from "@/server/db";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
const guard = moduleGuard("measures");
import { recomputeResidualRisk } from "@/server/risk-calc";
const measureSchema = z.object({
title: z.string().trim().min(1).max(200),
description: z.string().trim().max(5000).optional(),
status: z.enum(["OPEN", "IN_PROGRESS", "DONE"]),
priority: z.enum(["LOW", "MEDIUM", "HIGH"]),
ownerId: z.string().optional(),
dueDate: z.string().optional(),
});
// Minderung dezimal (0,00–4,00) — Komma-Eingaben werden akzeptiert
const reduction = z
.preprocess(
(v) => (typeof v === "string" ? v.replace(",", ".") : v),
z.coerce.number().min(0).max(4)
)
.transform((v) => Math.round(v * 100) / 100);
function parseMeasureForm(formData: FormData) {
const parsed = measureSchema.parse({
title: formData.get("title"),
description: formData.get("description") || undefined,
status: formData.get("status") ?? "OPEN",
priority: formData.get("priority") ?? "MEDIUM",
ownerId: formData.get("ownerId") || undefined,
dueDate: formData.get("dueDate") || undefined,
});
return {
title: parsed.title,
description: parsed.description || null,
status: parsed.status,
priority: parsed.priority,
ownerId: parsed.ownerId || null,
dueDate: parsed.dueDate ? new Date(parsed.dueDate) : null,
};
}
async function nextRefNo(tenantId: string) {
const last = await prisma.measure.aggregate({
where: { tenantId },
_max: { refNo: true },
});
return (last._max.refNo ?? 0) + 1;
}
export async function createMeasure(formData: FormData) {
const { session, db } = await guard("measure:write");
const data = parseMeasureForm(formData);
const measure = await db.measure.create({
data: {
...data,
refNo: await nextRefNo(session.user.tenantId),
tenantId: session.user.tenantId,
createdBy: session.user.id,
},
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "measure",
entityId: measure.id,
after: data,
});
revalidatePath("/measures");
redirect(`/measures?detail=${measure.id}`);
}
export async function updateMeasure(measureId: string, formData: FormData) {
const { session, db } = await guard("measure:write");
const before = await db.measure.findUnique({ where: { id: measureId } });
if (!before) throw new Error("Maßnahme nicht gefunden");
const data = parseMeasureForm(formData);
await db.measure.update({ where: { id: measureId }, data });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "measure",
entityId: measureId,
before,
after: data,
});
revalidatePath("/measures");
revalidatePath("/risks");
redirect(`/measures?detail=${measureId}`);
}
/** Status-Wechsel per Drag-and-Drop auf dem Kanban-Board. */
export async function updateMeasureStatus(measureId: string, status: string) {
const { session, db } = await guard("measure:write");
const parsed = z.enum(["OPEN", "IN_PROGRESS", "DONE"]).parse(status);
const before = await db.measure.findUnique({ where: { id: measureId } });
if (!before || before.status === parsed) return;
await db.measure.update({ where: { id: measureId }, data: { status: parsed } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "measure",
entityId: measureId,
before: { status: before.status },
after: { status: parsed },
});
revalidatePath("/measures");
revalidatePath("/risks");
}
export async function deleteMeasure(measureId: string) {
const { session, db } = await guard("measure:write");
const before = await db.measure.findUnique({
where: { id: measureId },
include: { riskMeasures: { select: { riskId: true } } },
});
if (!before) throw new Error("Maßnahme nicht gefunden");
await db.measure.delete({ where: { id: measureId } });
// Rest-Risiken der betroffenen Risiken neu berechnen (Links kaskadiert gelöscht)
for (const rm of before.riskMeasures) {
await recomputeResidualRisk(db, rm.riskId);
}
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "measure",
entityId: measureId,
before,
});
revalidatePath("/measures");
revalidatePath("/risks");
redirect("/measures");
}
/** Bestehende Maßnahme mit einem Risiko verknüpfen (inkl. Minderung). */
export async function linkMeasureToRisk(riskId: string, formData: FormData) {
const { session, db } = await guard("risk:write");
const measureId = z.string().min(1).parse(formData.get("measureId"));
const reductionLikelihood = reduction.parse(formData.get("reductionLikelihood") ?? 0);
const reductionImpact = reduction.parse(formData.get("reductionImpact") ?? 0);
const riskCount = await db.risk.count({ where: { id: riskId } });
const measureCount = await db.measure.count({ where: { id: measureId } });
if (riskCount !== 1 || measureCount !== 1) throw new Error("Nicht gefunden");
await db.riskMeasure.upsert({
where: { riskId_measureId: { riskId, measureId } },
update: { reductionLikelihood, reductionImpact },
create: {
riskId,
measureId,
reductionLikelihood,
reductionImpact,
tenantId: session.user.tenantId,
},
});
await recomputeResidualRisk(db, riskId);
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "risk_measure",
entityId: riskId,
after: { riskId, measureId, reductionLikelihood, reductionImpact },
});
revalidatePath("/risks");
revalidatePath("/measures");
}
/** Neue Maßnahme direkt aus dem Risiko heraus anlegen und verknüpfen (SPEC §4.2). */
export async function createMeasureForRisk(riskId: string, formData: FormData) {
const { session, db } = await guard("risk:write", "measure:write");
const riskCount = await db.risk.count({ where: { id: riskId } });
if (riskCount !== 1) throw new Error("Risiko nicht gefunden");
const title = z.string().trim().min(1).max(200).parse(formData.get("title"));
const reductionLikelihood = reduction.parse(formData.get("reductionLikelihood") ?? 0);
const reductionImpact = reduction.parse(formData.get("reductionImpact") ?? 0);
const measure = await db.measure.create({
data: {
title,
refNo: await nextRefNo(session.user.tenantId),
tenantId: session.user.tenantId,
createdBy: session.user.id,
},
});
await db.riskMeasure.create({
data: {
riskId,
measureId: measure.id,
reductionLikelihood,
reductionImpact,
tenantId: session.user.tenantId,
},
});
await recomputeResidualRisk(db, riskId);
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "measure",
entityId: measure.id,
after: { title, linkedRisk: riskId, reductionLikelihood, reductionImpact },
});
revalidatePath("/risks");
revalidatePath("/measures");
}
export async function unlinkMeasureFromRisk(riskId: string, riskMeasureId: string) {
const { session, db } = await guard("risk:write");
const before = await db.riskMeasure.findUnique({ where: { id: riskMeasureId } });
if (!before) return;
await db.riskMeasure.delete({ where: { id: riskMeasureId } });
await recomputeResidualRisk(db, riskId);
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "risk_measure",
entityId: riskMeasureId,
before,
});
revalidatePath("/risks");
revalidatePath("/measures");
}
+62
View File
@@ -0,0 +1,62 @@
"use server";
import { Prisma } from "@prisma/client";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { WIZARD_QUESTIONS } from "@/lib/onboarding/questions";
import { deriveResult } from "@/lib/onboarding/facts";
import { getAssessmentLevel, protectionFlags } from "@/server/assessment-level";
import { proposeTasksFromTriggers } from "@/server/actions/tasks";
/**
* Fragebogen (Story B3, Schritt „context"): Antworten als WizardFacts speichern und
* die Wirkung propagieren — Flags via B2-Regel-Engine ableiten, Aufgaben-Vorschläge
* via B1 (proposeTasksFromTriggers) erzeugen. Schreibt NUR Fakten/Flags, nie die
* gesperrten Zentralvariablen. Nur sichtbare (übermittelte) Fragen werden geschrieben.
*/
const guard = moduleGuard("onboarding");
export async function saveFacts(formData: FormData) {
const { session, db } = await guard("onboarding:use");
let saved = 0;
for (const q of WIZARD_QUESTIONS) {
const raw = formData.get(q.id);
if (raw === null) continue; // nicht sichtbar/nicht übermittelt → Bestand bleibt
const value: Prisma.InputJsonValue = q.type === "boolean" ? String(raw) === "true" : String(raw);
await db.wizardFact.upsert({
where: { tenantId_key: { tenantId: session.user.tenantId, key: q.id } },
update: { value, section: q.section },
create: { tenantId: session.user.tenantId, key: q.id, section: q.section, value },
});
saved++;
}
// Propagation via B2: alle Fakten → Flags + Aufgaben-Trigger. Schutzbedarf-Flags
// zentral aus dem Assessment-Level (A2-1), Prüfziele zentral aus WizardScope (A2-2).
const facts = await db.wizardFact.findMany({ select: { key: true, value: true } });
const level = await getAssessmentLevel(db);
const scope = await db.wizardScope.findFirst({ select: { pruefziele: true } });
const result = deriveResult(facts, protectionFlags(level), scope?.pruefziele ?? ["informationssicherheit"]);
const proposed = result.tasks.length > 0 ? await proposeTasksFromTriggers(result.tasks) : { created: 0, skipped: 0 };
// State-Machine: Schritt „context" von offen → in_bearbeitung anschieben.
const prog = await db.onboardingProgress.findUnique({ where: { tenantId_stepKey: { tenantId: session.user.tenantId, stepKey: "context" } } });
if (!prog || prog.status === "offen") {
await db.onboardingProgress.upsert({
where: { tenantId_stepKey: { tenantId: session.user.tenantId, stepKey: "context" } },
update: { status: "in_bearbeitung" },
create: { tenantId: session.user.tenantId, stepKey: "context", status: "in_bearbeitung" },
});
}
await writeAuditLog({
tenantId: session.user.tenantId, actorId: session.user.id,
action: "update", entity: "wizard_facts",
after: { saved, activeFlags: Object.keys(result.flags).filter((k) => result.flags[k]), proposedTasks: proposed.created },
});
revalidatePath("/onboarding");
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
+63
View File
@@ -0,0 +1,63 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { createTask } from "@/server/actions/tasks";
import type { TaskType } from "@/lib/tasks";
import type { TenantDb } from "@/server/db";
/**
* Task-Erzeugung für die Wizard-Schritte 4 (policies) und 6 (risks). Setzen auf den
* Bestandsmodulen /policies und /risks auf (keine Doppel-Datenhaltung); die Aktionen
* erzeugen für erkannte Lücken Aufgaben (F1), idempotent je `origin`.
*/
const guard = moduleGuard("onboarding");
interface Gap { origin: string; title: string; type: TaskType; }
async function createGapTasks(db: TenantDb, gaps: Gap[]): Promise<number> {
let created = 0;
for (const g of gaps) {
const existing = await db.task.findFirst({ where: { origin: g.origin, status: { in: ["PROPOSED", "OPEN"] } }, select: { id: true } });
if (existing) continue;
await createTask({ type: g.type, title: g.title, origin: g.origin, priority: "mittel" });
created++;
}
return created;
}
/** Schritt 4 (policies): Lücken im Richtlinienmodul → Aufgaben. */
export async function createPolicyGapTasks() {
const { db } = await guard("onboarding:use");
const [state, entwurf, openApprovals] = await Promise.all([
db.policyPackageState.findFirst({ select: { importedVersion: true } }),
db.policyDocument.count({ where: { archivedAt: null, status: "ENTWURF" } }),
db.task.count({ where: { type: "policy_approval", status: "OPEN" } }),
]);
const gaps: Gap[] = [];
if (!state?.importedVersion) gaps.push({ origin: "wizard-policies:import", title: "Richtlinien-Vorlagenpaket übernehmen (Paket-Updates)", type: "document_create" });
if (entwurf > 0) gaps.push({ origin: "wizard-policies:entwurf", title: `${entwurf} Richtlinie(n) im Entwurf zur Freigabe bringen`, type: "organizational" });
if (openApprovals === 0 && entwurf > 0) gaps.push({ origin: "wizard-policies:freigabe", title: "Freigabe-Workflow für offene Richtlinien starten", type: "organizational" });
await createGapTasks(db, gaps);
revalidatePath("/tasks");
redirect("/onboarding?step=policy");
}
/** Schritt 6 (risks): Lücken im Risikomodul → Aufgaben. */
export async function createRiskGapTasks() {
const { db } = await guard("onboarding:use");
const [risks, highUntreated, catalogTotal, adopted] = await Promise.all([
db.risk.count(),
db.risk.count({ where: { score: { gte: 10 }, treatment: "MITIGATE", riskMeasures: { none: {} } } }),
db.riskCatalogEntry.count(),
db.risk.count({ where: { catalogCode: { not: null } } }),
]);
const gaps: Gap[] = [];
if (risks === 0) gaps.push({ origin: "wizard-risks:empty", title: "Risikoregister aufbauen — Standard-Risikokatalog sichten und relevante Risiken übernehmen", type: "organizational" });
else if (adopted === 0 && catalogTotal > 0) gaps.push({ origin: "wizard-risks:catalog", title: "Standard-Risikokatalog (C4) auf Relevanz prüfen und Risiken übernehmen", type: "organizational" });
if (highUntreated > 0) gaps.push({ origin: "wizard-risks:high-untreated", title: `${highUntreated} hohe(s) Risiko/Risiken ohne verknüpfte Maßnahme behandeln`, type: "technical" });
await createGapTasks(db, gaps);
revalidatePath("/tasks");
redirect("/onboarding?step=risks");
}
+210
View File
@@ -0,0 +1,210 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import type { Domain, ObjectReviewStatus } from "@prisma/client";
import { moduleGuard } from "@/server/action-guard";
import { prisma } from "@/server/db";
import { writeAuditLog } from "@/server/audit";
import { getFunctionDef } from "@/lib/onboarding/functions";
import { hashPassword, generateCompliantPassword } from "@/server/password";
import { resolvePasswordPolicy } from "@/lib/password-policy";
import { issueToken } from "@/server/auth-token";
import { sendUserInvitationMail } from "@/server/auth-selfservice";
/**
* Team / Funktionszuordnung — Ebene 1 „Fundament" (M1).
*
* Bildet die echte Zuweisung ISMS-Funktion → User(n) ab (`ProjectFunctionAssignment`):
* - `assignFunction` : bestehenden Account einer Funktion zuweisen
* - `unassignFunction` : eine Zuweisung wieder entfernen
* - `markFunctionUnfilled` : Funktion bewusst unbesetzt lassen → Task „Funktion besetzen"
* - `inviteFunctionHolder` : neuen Account per E-Mail einladen (SEC2: Set-Passwort-Link)
*
* Fachregel (im Code erzwungen): der/die unabhängige interne Auditor:in (AUDITOR_INT)
* darf nicht dieselbe Person wie der/die ISB sein (keine Selbstprüfung).
*/
const guard = moduleGuard("onboarding");
type Db = Awaited<ReturnType<typeof guard>>["db"];
const emailSchema = z.string().trim().email();
/** Schiebt den Schritt „roles" von offen → in_bearbeitung an. */
async function nudgeRolesStep(db: Db, tenantId: string) {
const prog = await db.onboardingProgress.findUnique({
where: { tenantId_stepKey: { tenantId, stepKey: "roles" } },
});
if (!prog || prog.status === "offen") {
const status: ObjectReviewStatus = "in_bearbeitung";
await db.onboardingProgress.upsert({
where: { tenantId_stepKey: { tenantId, stepKey: "roles" } },
update: { status },
create: { tenantId, stepKey: "roles", status },
});
}
}
/** Prüft die Funktionstrennung ISB ≠ AUDITOR_INT (keine Selbstprüfung). */
async function assertAuditorIndependence(db: Db, functionKey: string, userId: string) {
if (functionKey === "AUDITOR_INT") {
const isb = await db.projectFunctionAssignment.findFirst({ where: { functionKey: "ISB", userId } });
if (isb) throw new Error("Der/die unabhängige interne Auditor:in darf nicht der/die ISB sein (Funktionstrennung).");
}
if (functionKey === "ISB") {
const auditor = await db.projectFunctionAssignment.findFirst({ where: { functionKey: "AUDITOR_INT", userId } });
if (auditor) throw new Error("Diese Person ist bereits als unabhängige:r interne:r Auditor:in geführt — ISB und Auditor:in müssen getrennt sein.");
}
}
/** Funktion → bestehenden Account zuweisen. */
export async function assignFunction(functionKey: string, formData: FormData) {
const { session, db } = await guard("onboarding:use");
const tenantId = session.user.tenantId;
const def = getFunctionDef(functionKey);
if (!def) throw new Error("Unbekannte Funktion.");
const userId = String(formData.get("userId") ?? "").trim();
if (!userId) throw new Error("Bitte einen Account auswählen.");
const user = await db.user.findFirst({ where: { id: userId, status: "ACTIVE" }, select: { id: true } });
if (!user) throw new Error("Account nicht gefunden oder nicht aktiv.");
await assertAuditorIndependence(db, functionKey, userId);
// Nicht-Mehrfach-Funktionen: bestehende Halter ersetzen. Mehrfach-Funktionen
// (Asset-/Risk-Owner): denselben Account nicht doppelt eintragen.
if (!def.multi) {
await db.projectFunctionAssignment.deleteMany({ where: { functionKey } });
} else if (await db.projectFunctionAssignment.findFirst({ where: { functionKey, userId } })) {
return; // bereits zugewiesen
}
await db.projectFunctionAssignment.create({
data: { tenantId, functionKey, userId, domain: def.domain ?? undefined },
});
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "project_function_assignment", entityId: functionKey, after: { userId } });
await nudgeRolesStep(db, tenantId);
revalidatePath("/onboarding");
revalidatePath("/dashboard");
}
/** Eine Funktionszuweisung entfernen. */
export async function unassignFunction(assignmentId: string) {
const { session, db } = await guard("onboarding:use");
const tenantId = session.user.tenantId;
const row = await db.projectFunctionAssignment.findFirst({ where: { id: assignmentId } });
if (!row) return;
await db.projectFunctionAssignment.delete({ where: { id: assignmentId } });
await writeAuditLog({ tenantId, actorId: session.user.id, action: "delete", entity: "project_function_assignment", entityId: row.functionKey, after: { removed: assignmentId } });
revalidatePath("/onboarding");
revalidatePath("/dashboard");
}
/** Funktion bewusst unbesetzt lassen → Task „Funktion besetzen" (idempotent). */
export async function markFunctionUnfilled(functionKey: string) {
const { session, db } = await guard("onboarding:use");
const tenantId = session.user.tenantId;
const def = getFunctionDef(functionKey);
if (!def) throw new Error("Unbekannte Funktion.");
const origin = `wizard:fill-function:${functionKey}`;
const exists = await db.task.findFirst({ where: { origin, status: { in: ["PROPOSED", "OPEN"] } }, select: { id: true } });
if (!exists) {
await db.task.create({
data: {
tenantId,
type: "organizational",
title: `Funktion besetzen: ${def.label}`,
description: def.desc,
origin,
status: "OPEN",
priority: "hoch",
createdById: session.user.id,
...(def.domain ? { domain: def.domain as Domain } : {}),
},
});
}
await writeAuditLog({ tenantId, actorId: session.user.id, action: "create", entity: "task", entityId: origin, after: { functionKey } });
await nudgeRolesStep(db, tenantId);
revalidatePath("/onboarding");
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
/**
* Account per E-Mail einladen. Existiert bereits ein aktiver Account mit der Adresse,
* wird dieser der Funktion zugewiesen. Andernfalls wird ein neuer Account angelegt
* (Basisrolle „user", Passwortwechsel erzwungen) und über die bestehende SEC2-
* Infrastruktur ein Set-Passwort-Link (password_reset) versendet — das Klartext-
* Passwort verlässt den Server nie.
*/
export async function inviteFunctionHolder(functionKey: string, formData: FormData) {
// Kontenanlage erfordert zusätzlich user:manage.
const { session, db } = await guard("onboarding:use", "user:manage");
const tenantId = session.user.tenantId;
const def = getFunctionDef(functionKey);
if (!def) throw new Error("Unbekannte Funktion.");
const parsed = emailSchema.safeParse(String(formData.get("email") ?? "").toLowerCase());
if (!parsed.success) throw new Error("Bitte eine gültige E-Mail-Adresse angeben.");
const email = parsed.data;
const name = String(formData.get("name") ?? "").trim() || email.split("@")[0]!;
const existing = await db.user.findFirst({ where: { email }, select: { id: true, status: true } });
let userId: string;
let invited = false;
if (existing) {
if (existing.status !== "ACTIVE") throw new Error("Ein Account mit dieser Adresse existiert, ist aber nicht aktiv.");
userId = existing.id;
} else {
const settings = await db.tenantSettings.findUnique({ where: { tenantId } });
const policy = resolvePasswordPolicy(settings?.securityPolicy);
const initialPassword = generateCompliantPassword(policy);
const baseRole = await db.role.findFirst({ where: { key: "user" }, select: { id: true } });
// Option C (WS3): Anlage NUR per Einladung. Bekannte Identity → nur Mitgliedschaft
// (kein Passwort-Reset); unbekannt → Identity anlegen + Einladung (/invite).
// Membership.passwordHash ist Legacy (Login nutzt Identity).
const throwaway = await hashPassword(initialPassword);
const prior = await prisma.identity.findUnique({ where: { email } });
const identity = prior ?? (await prisma.identity.create({
data: { email, passwordHash: throwaway, mustChangePassword: true, status: "ACTIVE" },
}));
const created = await db.user.create({
data: {
tenantId,
identityId: identity.id,
email,
name,
status: "ACTIVE",
...(baseRole ? { userRoles: { create: [{ roleId: baseRole.id }] } } : {}),
},
});
userId = created.id;
invited = true;
if (!prior) {
const tenant = await db.tenant.findUnique({ where: { id: tenantId }, select: { name: true } });
const { raw, expiresAt } = await issueToken({ principalType: "identity", principalId: identity.id, tenantId, type: "invitation" });
await sendUserInvitationMail({ to: email, name, tenantId, tenantName: tenant?.name ?? "", rawToken: raw, expiresAt, locale: settings?.locale });
}
}
await assertAuditorIndependence(db, functionKey, userId);
if (!def.multi) {
await db.projectFunctionAssignment.deleteMany({ where: { functionKey } });
} else if (await db.projectFunctionAssignment.findFirst({ where: { functionKey, userId } })) {
return;
}
await db.projectFunctionAssignment.create({
data: { tenantId, functionKey, userId, invitedEmail: invited ? email : undefined, domain: def.domain ?? undefined },
});
await writeAuditLog({ tenantId, actorId: session.user.id, action: invited ? "create" : "update", entity: "project_function_assignment", entityId: functionKey, after: { userId, invited } });
await nudgeRolesStep(db, tenantId);
revalidatePath("/onboarding");
revalidatePath("/settings/users");
revalidatePath("/dashboard");
}
+211
View File
@@ -0,0 +1,211 @@
"use server";
import { revalidatePath } from "next/cache";
import type { ObjectReviewStatus } from "@prisma/client";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { MODULE_KEYS } from "@/lib/modules";
import { getVisibleSteps, STEP_KEYS, type StepCtx, type StepKey } from "@/lib/onboarding/registry";
import { advanceTarget, isAwaitingValidation } from "@/lib/onboarding/state";
import { loadRoleContext } from "@/server/roles";
import { evaluateFt } from "@/lib/ft-rules";
import { proposeTasksFromTriggers } from "@/server/actions/tasks";
/**
* Onboarding-Wizard — Fortschritts-/Review-State-Machine (Story A1-1/F2).
* Bearbeiter (`onboarding:use`): `advanceStep` (offen→in_bearbeitung→zur_validierung,
* zurueckgewiesen→in_bearbeitung), `resetStep`. Validator (`validate_objects`):
* `validateStep` (→validiert) und `rejectStep` (→zurueckgewiesen, mit Kommentar).
* Bearbeitung und Validierung sind entkoppelt: jeder sichtbare Schritt ist jederzeit
* bearbeitbar (Parallelisierung), die Freigaben erfolgen unabhängig.
*/
const guard = moduleGuard("onboarding");
type Db = Awaited<ReturnType<typeof guard>>["db"];
function isStepKey(v: unknown): v is StepKey {
return typeof v === "string" && (STEP_KEYS as readonly string[]).includes(v);
}
/** Sichtbarkeits-Kontext: alle Modul-Keys außer den ausdrücklich deaktivierten. */
async function loadCtx(db: Db): Promise<StepCtx> {
const rows = await db.tenantModule.findMany();
const disabled = new Set(rows.filter((m) => !m.enabled).map((m) => m.moduleKey));
return { enabledModules: new Set(MODULE_KEYS.filter((k) => !disabled.has(k))) };
}
async function statusMap(db: Db): Promise<Map<string, ObjectReviewStatus>> {
const rows = await db.onboardingProgress.findMany();
return new Map(rows.map((r) => [r.stepKey, r.status]));
}
/**
* Gemeinsame Vorprüfung: Schritt existiert und ist im aktuellen Scope sichtbar.
* Bearbeitung ist von der Validierung entkoppelt (Parallelisierung) — es gibt keinen
* Vorgänger-Validierungs-Zwang mehr; jeder sichtbare Schritt ist bearbeitbar.
*/
async function requireVisibleStep(db: Db, stepKey: string): Promise<{ steps: ReturnType<typeof getVisibleSteps>; current: ObjectReviewStatus }> {
if (!isStepKey(stepKey)) throw new Error("Unbekannter Wizard-Schritt.");
const steps = getVisibleSteps(await loadCtx(db));
const idx = steps.findIndex((s) => s.key === stepKey);
if (idx === -1) throw new Error("Schritt ist im aktuellen Scope nicht sichtbar.");
const map = await statusMap(db);
const statusOf = (k: StepKey) => map.get(k) ?? "offen";
return { steps, current: statusOf(stepKey as StepKey) };
}
async function setStatus(db: Db, tenantId: string, stepKey: string, status: ObjectReviewStatus, review?: { reviewerId: string | null; reviewComment: string | null }) {
await db.onboardingProgress.upsert({
where: { tenantId_stepKey: { tenantId, stepKey } },
update: { status, ...(review ?? {}) },
create: { tenantId, stepKey, status, ...(review ?? {}) },
});
}
/** Bearbeiter-Aktion: Schritt eine Stufe weiterschieben bzw. nach Rückweisung nacharbeiten. */
export async function advanceStep(stepKey: string) {
const { session, db } = await guard("onboarding:use");
const { current } = await requireVisibleStep(db, stepKey);
const next = advanceTarget(current);
if (!next) return; // zur_validierung/validiert: keine Bearbeiter-Aktion
await setStatus(db, session.user.tenantId, stepKey, next);
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "onboarding_step", entityId: stepKey, after: { status: next } });
revalidatePath("/onboarding");
revalidatePath("/dashboard");
}
/** Validator-Aktion: Schritt bestätigen (nur aus `zur_validierung`). */
export async function validateStep(stepKey: string) {
const { session, db } = await guard("validate_objects");
const { current } = await requireVisibleStep(db, stepKey);
if (!isAwaitingValidation(current)) throw new Error("Der Schritt steht nicht zur Validierung an.");
await setStatus(db, session.user.tenantId, stepKey, "validiert", { reviewerId: session.user.id, reviewComment: null });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "onboarding_step", entityId: stepKey, after: { status: "validiert" } });
revalidatePath("/onboarding");
revalidatePath("/dashboard");
}
/** Validator-Aktion: Schritt zurückweisen (nur aus `zur_validierung`), mit Begründung. */
export async function rejectStep(stepKey: string, formData: FormData) {
const { session, db } = await guard("validate_objects");
const { current } = await requireVisibleStep(db, stepKey);
if (!isAwaitingValidation(current)) throw new Error("Der Schritt steht nicht zur Validierung an.");
const comment = (formData.get("comment") as string | null)?.trim() || null;
await setStatus(db, session.user.tenantId, stepKey, "zurueckgewiesen", { reviewerId: session.user.id, reviewComment: comment });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "onboarding_step", entityId: stepKey, after: { status: "zurueckgewiesen" } });
revalidatePath("/onboarding");
revalidatePath("/dashboard");
}
/** Bearbeiter-Aktion: Schritt wieder öffnen. */
export async function resetStep(stepKey: string) {
const { session, db } = await guard("onboarding:use");
if (!isStepKey(stepKey)) throw new Error("Unbekannter Wizard-Schritt.");
await setStatus(db, session.user.tenantId, stepKey, "offen", { reviewerId: null, reviewComment: null });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "onboarding_step", entityId: stepKey, after: { status: "offen" } });
revalidatePath("/onboarding");
revalidatePath("/dashboard");
}
/**
* Scope (Ebene 1 „Fundament", Story A2-2): Prüfziele, Geltungsbereich, Standorte und
* Ausschlüsse als `WizardScope` speichern. Informationssicherheit ist stets aktiv;
* Prototypenschutz/Datenschutz optional. Schiebt den Schritt „scope" von offen →
* in_bearbeitung an. Das Assessment-Level (Schutzbedarf) ist read-only (A2-1) und
* wird hier nicht geschrieben.
*/
export async function saveScope(formData: FormData) {
const { session, db } = await guard("onboarding:use");
const pruefziele = ["informationssicherheit"];
if (formData.get("pz_prototypenschutz")) pruefziele.push("prototypenschutz");
if (formData.get("pz_datenschutz")) pruefziele.push("datenschutz");
const geltungsbereich = (formData.get("geltungsbereich") as string | null)?.trim() || null;
const ausschluesse = (formData.get("ausschluesse") as string | null)?.trim() || null;
const standorte = ((formData.get("standorte") as string | null) ?? "")
.split("\n").map((s) => s.trim()).filter(Boolean);
await db.wizardScope.upsert({
where: { tenantId: session.user.tenantId },
update: { pruefziele, geltungsbereich, standorte, ausschluesse },
create: { tenantId: session.user.tenantId, pruefziele, geltungsbereich, standorte, ausschluesse },
});
// State-Machine: „scope" von offen → in_bearbeitung anschieben.
const prog = await db.onboardingProgress.findUnique({ where: { tenantId_stepKey: { tenantId: session.user.tenantId, stepKey: "scope" } } });
if (!prog || prog.status === "offen") {
await setStatus(db, session.user.tenantId, "scope", "in_bearbeitung");
}
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "wizard_scope", after: { pruefziele, standorte: standorte.length } });
revalidatePath("/onboarding");
revalidatePath("/dashboard");
}
/**
* Rollen/Funktionstrennung (Schritt 3, Story A4-2): FT-01…06 auswerten und für die
* task-würdigen Findings Aufgaben anlegen. FT-01/FT-04 laufen über die bestehenden
* Fragebogen-Trigger (`isb_equals_it`/`isb_not_named`, idempotent), FT-03/FT-05 als
* neue Vorschläge (idempotent über `origin`). Schiebt den Schritt „roles" an.
*/
export async function createFtTasks() {
const { session, db } = await guard("onboarding:use");
const findings = evaluateFt(await loadRoleContext(db));
const reuse = findings.filter((f) => f.task?.reuse).map((f) => f.task!.origin);
if (reuse.length) await proposeTasksFromTriggers(reuse);
for (const f of findings.filter((x) => x.task && !x.task.reuse)) {
const origin = `wizard:${f.task!.origin}`;
const exists = await db.task.findFirst({ where: { origin, status: { in: ["PROPOSED", "OPEN"] } }, select: { id: true } });
if (exists) continue;
await db.task.create({
data: {
tenantId: session.user.tenantId, type: "organizational", title: f.task!.title, origin,
status: "PROPOSED", priority: "hoch", createdById: session.user.id,
links: f.task!.control ? { control: f.task!.control } : undefined,
},
});
}
const prog = await db.onboardingProgress.findUnique({ where: { tenantId_stepKey: { tenantId: session.user.tenantId, stepKey: "roles" } } });
if (!prog || prog.status === "offen") await setStatus(db, session.user.tenantId, "roles", "in_bearbeitung");
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "ft_check", after: { findings: findings.map((f) => f.code) } });
revalidatePath("/onboarding");
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
/**
* Assets (Schritt 5, Story A5-1): nutzt das bestehende Assetinventar/BIA (keine
* Doppel-Datenhaltung) und erzeugt Aufgaben für Lücken (Assets ohne Eigentümer bzw.
* ohne Schutzbedarfsbewertung). Idempotent über `origin`. Schiebt den Schritt „assets" an.
*/
export async function createAssetGapTasks() {
const { session, db } = await guard("onboarding:use");
const [withoutOwner, unrated] = await Promise.all([
db.asset.count({ where: { ownerId: null } }),
db.asset.count({ where: { confidentiality: 1, integrity: 1, availability: 1 } }),
]);
const gaps: { origin: string; title: string; control: string }[] = [];
if (withoutOwner > 0) gaps.push({ origin: "asset-no-owner", title: `Eigentümer für ${withoutOwner} Asset(s) zuweisen`, control: "1.3.1" });
if (unrated > 0) gaps.push({ origin: "asset-unrated", title: `Schutzbedarf (C/I/A) für ${unrated} Asset(s) bewerten`, control: "1.3.2" });
for (const g of gaps) {
const origin = `wizard:${g.origin}`;
const exists = await db.task.findFirst({ where: { origin, status: { in: ["PROPOSED", "OPEN"] } }, select: { id: true } });
if (exists) continue;
await db.task.create({
data: {
tenantId: session.user.tenantId, type: "organizational", title: g.title, origin,
status: "PROPOSED", priority: "mittel", createdById: session.user.id, links: { control: g.control },
},
});
}
const prog = await db.onboardingProgress.findUnique({ where: { tenantId_stepKey: { tenantId: session.user.tenantId, stepKey: "assets" } } });
if (!prog || prog.status === "offen") await setStatus(db, session.user.tenantId, "assets", "in_bearbeitung");
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "asset_gap_check", after: { withoutOwner, unrated } });
revalidatePath("/onboarding");
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
+104
View File
@@ -0,0 +1,104 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/server/db";
import { hashPassword } from "@/server/password";
import { writePlatformAudit } from "@/server/audit";
import { invalidateSessions } from "@/server/sessions";
import { requirePlatformFullAdmin, assertPlatformStepUp, countActiveFullAdmins } from "@/server/platform-auth";
import { DEFAULT_PASSWORD_POLICY, validatePassword } from "@/lib/password-policy";
/**
* SEC4: Verwaltung weiterer Plattform-Administratoren. Nur Voll-Admins (Read-only-Admins
* dürfen nichts verändern), kritische Aktionen mit Step-up-Re-Auth (TOTP), Selbst-Aussperr-
* Schutz (letzter aktiver Voll-Admin), vollständiges Plattform-Audit. EXEMPT vom Modul-Gating
* (eigene Auth über requirePlatformFullAdmin).
*/
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
const toRole = (v: string): "full" | "readonly" => (v === "full" ? "full" : "readonly");
const isStatus = (v: string): v is "ACTIVE" | "LOCKED" | "DISABLED" => v === "ACTIVE" || v === "LOCKED" || v === "DISABLED";
export type AdminActionState = { status: "idle" } | { status: "error"; message: string } | { status: "done"; message: string };
/** Verhindert, dass der letzte aktive Voll-Admin herabgestuft/gesperrt/deaktiviert wird. */
async function assertNotLastFullAdmin(target: { role: string; status: string }): Promise<void> {
if (target.role === "full" && target.status === "ACTIVE" && (await countActiveFullAdmins()) <= 1) {
throw new Error("Der letzte aktive Voll-Administrator kann nicht herabgestuft, gesperrt oder deaktiviert werden.");
}
}
/** Weiteren Plattform-Admin anlegen (Initial-Passwort). Voll-Admin + Step-up. */
export async function createPlatformAdmin(_prev: AdminActionState, formData: FormData): Promise<AdminActionState> {
const { admin } = await requirePlatformFullAdmin();
try {
assertPlatformStepUp(admin, str(formData.get("token")));
} catch (e) {
return { status: "error", message: e instanceof Error ? e.message : "Step-up erforderlich." };
}
const email = str(formData.get("email")).toLowerCase();
const name = str(formData.get("name"));
const role = toRole(str(formData.get("role")));
const password = String(formData.get("password") ?? "");
if (!email.includes("@") || !name) return { status: "error", message: "Gültige E-Mail und Name sind erforderlich." };
const pwMissing = validatePassword(password, DEFAULT_PASSWORD_POLICY);
if (pwMissing.length) return { status: "error", message: `Das Passwort benötigt ${pwMissing.join(", ")}.` };
if (await prisma.platformAdmin.findUnique({ where: { email }, select: { id: true } })) {
return { status: "error", message: "Diese E-Mail ist bereits vergeben." };
}
const created = await prisma.platformAdmin.create({
data: { email, name, role, status: "ACTIVE", passwordHash: await hashPassword(password) },
});
await writePlatformAudit({ actorId: admin.id, action: "create", entity: "platform_admin", entityId: created.id, after: { email, role } });
revalidatePath("/admins");
return { status: "done", message: `Plattform-Admin ${email} (${role === "full" ? "Voll-Admin" : "Read-only"}) angelegt.` };
}
/** Rolle ändern (full/readonly). Voll-Admin + Step-up + Last-Admin-Schutz. */
export async function setPlatformAdminRole(adminId: string, role: string, formData: FormData) {
const { admin } = await requirePlatformFullAdmin();
assertPlatformStepUp(admin, str(formData.get("token")));
const target = await prisma.platformAdmin.findUnique({ where: { id: adminId }, select: { id: true, role: true, status: true } });
if (!target) throw new Error("Plattform-Admin nicht gefunden.");
const newRole = toRole(role);
if (newRole !== "full") await assertNotLastFullAdmin(target);
await prisma.platformAdmin.update({ where: { id: adminId }, data: { role: newRole } });
await writePlatformAudit({ actorId: admin.id, action: "update", entity: "platform_admin", entityId: adminId, after: { role: newRole } });
revalidatePath("/admins");
}
/** Sperren/Reaktivieren/Deaktivieren. Voll-Admin + Step-up + Last-Admin-Schutz. */
export async function setPlatformAdminStatus(adminId: string, status: string, formData: FormData) {
const { admin } = await requirePlatformFullAdmin();
assertPlatformStepUp(admin, str(formData.get("token")));
if (!isStatus(status)) throw new Error("Ungültiger Status.");
const target = await prisma.platformAdmin.findUnique({ where: { id: adminId }, select: { id: true, role: true, status: true } });
if (!target) throw new Error("Plattform-Admin nicht gefunden.");
if (status !== "ACTIVE") await assertNotLastFullAdmin(target);
await prisma.platformAdmin.update({ where: { id: adminId }, data: { status } });
if (status !== "ACTIVE") await invalidateSessions({ type: "platform_admin", id: adminId });
await writePlatformAudit({ actorId: admin.id, action: "update", entity: "platform_admin", entityId: adminId, after: { status } });
revalidatePath("/admins");
}
/** Passwort eines Admins zurücksetzen. Voll-Admin + Step-up; meldet dessen Sitzungen ab. */
export async function resetPlatformAdminPassword(adminId: string, _prev: AdminActionState, formData: FormData): Promise<AdminActionState> {
const { admin } = await requirePlatformFullAdmin();
try {
assertPlatformStepUp(admin, str(formData.get("token")));
} catch (e) {
return { status: "error", message: e instanceof Error ? e.message : "Step-up erforderlich." };
}
const password = String(formData.get("password") ?? "");
const pwMissing = validatePassword(password, DEFAULT_PASSWORD_POLICY);
if (pwMissing.length) return { status: "error", message: `Das Passwort benötigt ${pwMissing.join(", ")}.` };
const target = await prisma.platformAdmin.findUnique({ where: { id: adminId }, select: { id: true } });
if (!target) return { status: "error", message: "Plattform-Admin nicht gefunden." };
await prisma.platformAdmin.update({ where: { id: adminId }, data: { passwordHash: await hashPassword(password) } });
await invalidateSessions({ type: "platform_admin", id: adminId });
await writePlatformAudit({ actorId: admin.id, action: "update", entity: "platform_admin", entityId: adminId, after: { passwordReset: true } });
revalidatePath("/admins");
return { status: "done", message: "Passwort zurückgesetzt; andere Sitzungen des Kontos abgemeldet." };
}
+169
View File
@@ -0,0 +1,169 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { requirePlatformSession } from "@/server/platform-auth";
import { prisma, dbForTenant, type TenantDb } from "@/server/db";
import { hashPassword, generateCompliantPassword } from "@/server/password";
import { resolvePasswordPolicy } from "@/lib/password-policy";
import { issueToken } from "@/server/auth-token";
import { sendUserInvitationMail } from "@/server/auth-selfservice";
import { writePlatformAudit } from "@/server/audit";
/**
* Benutzerverwaltung durch den Plattform-Admin je Mandant (Paket A).
* EXEMPT vom Modul-Gating; Autorisierung über die Plattform-Session. Alle
* Schreibzugriffe laufen über dbForTenant(zielTenant) → strikt mandantengebunden.
* Audit-Scope: platform (Betreiber handelt mandantenübergreifend).
*
* Zukunftssicher: `createTenantUser` kapselt die Aktivierung über ein Initial-/
* Einmal-Passwort (mustChangePassword). Der spätere Einladungs-Flow (Paket 4) kann
* hier als alternative Aktivierung (Token statt Passwort) eingehängt werden.
*/
export type CreateUserState =
| { status: "idle" }
| { status: "error"; message: string }
// invited = Einladungsmail verschickt (Nutzer setzt Passwort selbst);
// generatedPassword gesetzt = Fallback-Anzeige, falls die Mail nicht ging.
| { status: "done"; email: string; generatedPassword: string | null; invited: boolean };
export type EditUserState =
| { status: "idle" }
| { status: "error"; message: string }
| { status: "ok" };
/** Zählt aktive Nutzer mit Mandanten-Admin-Rolle (Lockout-Schutz). */
async function countActiveTenantAdmins(db: TenantDb): Promise<number> {
return db.user.count({
where: { status: "ACTIVE", userRoles: { some: { role: { key: "tenant-admin" } } } },
});
}
async function tenantPolicy(db: TenantDb, tenantId: string) {
const s = await db.tenantSettings.findUnique({ where: { tenantId } });
return resolvePasswordPolicy(s?.securityPolicy);
}
const emailSchema = z.string().trim().email();
/** Benutzer anlegen (Initial-/Einmal-Passwort, erzwungener Wechsel). */
export async function createTenantUser(tenantId: string, _prev: CreateUserState, formData: FormData): Promise<CreateUserState> {
const session = await requirePlatformSession();
const db = dbForTenant(tenantId);
const name = String(formData.get("name") ?? "").trim();
const emailRaw = String(formData.get("email") ?? "").trim().toLowerCase();
const roleIds = formData.getAll("roles").map(String).filter(Boolean);
if (!name) return { status: "error", message: "Bitte einen Namen angeben." };
const email = emailSchema.safeParse(emailRaw);
if (!email.success) return { status: "error", message: "Bitte eine gültige E-Mail-Adresse angeben." };
if (roleIds.length === 0) return { status: "error", message: "Bitte mindestens eine Rolle zuweisen." };
const existing = await db.user.findFirst({ where: { email: email.data } });
if (existing) return { status: "error", message: "In diesem Mandanten existiert bereits ein Nutzer mit dieser E-Mail." };
// Rollen müssen zum Mandanten gehören (dbForTenant filtert; count verifiziert).
const validRoles = await db.role.count({ where: { id: { in: roleIds } } });
if (validRoles !== roleIds.length) return { status: "error", message: "Ungültige Rollenauswahl." };
// Option C (WS3): Anlage NUR per Einladung (goldene Regel 4) — kein „Passwort direkt
// setzen" mehr. Bekannte Identity → nur Mitgliedschaft ergänzen (Betreiber darf direkt
// verknüpfen, KEIN Passwort-Reset). Unbekannt → Identity anlegen + Einladung zum
// Passwort-Setzen (/invite). Membership.passwordHash ist Legacy (Login nutzt Identity).
const policy = await tenantPolicy(db, tenantId);
const throwaway = await hashPassword(generateCompliantPassword(policy));
const prior = await prisma.identity.findUnique({ where: { email: email.data } });
const identity = prior ?? (await prisma.identity.create({
data: { email: email.data, passwordHash: throwaway, mustChangePassword: true, status: "ACTIVE" },
}));
const user = await db.user.create({
data: {
tenantId, identityId: identity.id, email: email.data, name, status: "ACTIVE",
userRoles: { create: roleIds.map((roleId) => ({ roleId })) },
},
});
await writePlatformAudit({ actorId: session.user.id, action: "create", entity: "user", entityId: user.id, after: { tenantId, email: email.data, roleIds, linkedExisting: !!prior } });
revalidatePath(`/admin/${tenantId}`);
// Nur für NEUE Identitäten einen Einladungslink zum Passwort-Setzen verschicken;
// eine bestehende Person hat bereits Zugangsdaten und sieht den Mandanten künftig
// in ihrer Auswahl. Antwort ist in beiden Fällen identisch (neutral).
if (!prior) {
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { name: true } });
const { raw, expiresAt } = await issueToken({ principalType: "identity", principalId: identity.id, tenantId, type: "invitation" });
const invite = await sendUserInvitationMail({ to: email.data, name, tenantId, tenantName: tenant?.name ?? "", rawToken: raw, expiresAt });
if (invite.status !== "queued" && invite.status !== "sent") {
await writePlatformAudit({ actorId: session.user.id, action: "update", entity: "user_invite_failed", entityId: user.id, after: { tenantId, email: email.data, mail: invite.status } });
}
}
return { status: "done", email: email.data, generatedPassword: null, invited: true };
}
/** Stammdaten (Name, E-Mail) eines Nutzers ändern; E-Mail bleibt je Mandant eindeutig. */
export async function updateTenantUser(tenantId: string, userId: string, _prev: EditUserState, formData: FormData): Promise<EditUserState> {
const session = await requirePlatformSession();
const db = dbForTenant(tenantId);
const user = await db.user.findUnique({ where: { id: userId } });
if (!user) return { status: "error", message: "Nutzer nicht gefunden." };
const name = String(formData.get("name") ?? "").trim();
const email = emailSchema.safeParse(String(formData.get("email") ?? "").trim().toLowerCase());
if (!name) return { status: "error", message: "Bitte einen Namen angeben." };
if (!email.success) return { status: "error", message: "Bitte eine gültige E-Mail-Adresse angeben." };
if (email.data !== user.email && (await db.user.findFirst({ where: { email: email.data, id: { not: userId } } }))) {
return { status: "error", message: "Diese E-Mail ist in diesem Mandanten bereits vergeben." };
}
await db.user.update({ where: { id: userId }, data: { name, email: email.data } });
await writePlatformAudit({ actorId: session.user.id, action: "update", entity: "user", entityId: userId, after: { tenantId, name, email: email.data } });
revalidatePath(`/admin/${tenantId}`);
return { status: "ok" };
}
/** Deaktivieren/Reaktivieren mit Lockout-Schutz (letzter aktiver Mandanten-Admin). */
export async function setTenantUserStatus(tenantId: string, userId: string, status: "ACTIVE" | "DEACTIVATED") {
const session = await requirePlatformSession();
const db = dbForTenant(tenantId);
const user = await db.user.findUnique({ where: { id: userId }, include: { userRoles: { include: { role: true } } } });
if (!user) throw new Error("Nutzer nicht gefunden");
if (status === "DEACTIVATED") {
const isAdmin = user.userRoles.some((ur) => ur.role.key === "tenant-admin");
if (isAdmin && user.status === "ACTIVE" && (await countActiveTenantAdmins(db)) <= 1) {
throw new Error("Der letzte aktive Mandanten-Admin kann nicht deaktiviert werden.");
}
}
await db.user.update({ where: { id: userId }, data: { status } });
await writePlatformAudit({ actorId: session.user.id, action: "update", entity: "user", entityId: userId, after: { tenantId, status } });
revalidatePath(`/admin/${tenantId}`);
}
/** Rollen eines Nutzers setzen (Mehrfachauswahl), mit Lockout-Schutz. */
export async function setTenantUserRoles(tenantId: string, userId: string, formData: FormData) {
const session = await requirePlatformSession();
const db = dbForTenant(tenantId);
const roleIds = formData.getAll("roles").map(String).filter(Boolean);
const user = await db.user.findUnique({ where: { id: userId }, include: { userRoles: { include: { role: true } } } });
if (!user) throw new Error("Nutzer nicht gefunden");
const validRoles = roleIds.length ? await db.role.count({ where: { id: { in: roleIds } } }) : 0;
if (validRoles !== roleIds.length) throw new Error("Ungültige Rollenauswahl");
// Lockout-Schutz: dem letzten aktiven Mandanten-Admin nicht die Admin-Rolle entziehen.
const adminRole = await db.role.findFirst({ where: { key: "tenant-admin" } });
const hadAdmin = user.userRoles.some((ur) => ur.role.key === "tenant-admin");
const keepsAdmin = adminRole ? roleIds.includes(adminRole.id) : false;
if (hadAdmin && !keepsAdmin && user.status === "ACTIVE" && (await countActiveTenantAdmins(db)) <= 1) {
throw new Error("Dem letzten aktiven Mandanten-Admin kann die Admin-Rolle nicht entzogen werden.");
}
await db.$transaction([
db.userRole.deleteMany({ where: { userId } }),
db.userRole.createMany({ data: roleIds.map((roleId) => ({ userId, roleId })) }),
]);
await writePlatformAudit({ actorId: session.user.id, action: "update", entity: "user_roles", entityId: userId, after: { tenantId, roleIds } });
revalidatePath(`/admin/${tenantId}`);
}
+88
View File
@@ -0,0 +1,88 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/server/db";
import { requirePlatformSession, platformSignOut } from "@/server/platform-auth";
import { getPlatformSettings } from "@/server/platform-settings";
import { verifyTotp, generateRecoveryCodes } from "@/server/mfa";
import { writePlatformAudit } from "@/server/audit";
/**
* Plattform-Actions (Phase-1-Härtung Paket 2): MFA-Enrollment und Logout.
* EXEMPT vom Modul-Gating (keine per TenantModule gegatete Fachfunktion) — eigene
* Autorisierung über requirePlatformSession.
*/
export type EnrollState =
| { status: "idle" }
| { status: "error"; message: string }
| { status: "done"; recoveryCodes: string[] };
/**
* Bestätigt die MFA-Einrichtung: prüft den TOTP-Code gegen das (beim Seitenaufruf
* gespeicherte) Einrichtungs-Secret, aktiviert MFA und liefert einmalig die
* Recovery-Codes im Klartext zurück (nur Hashes werden gespeichert).
*/
export async function confirmMfaEnrollment(_prev: EnrollState, formData: FormData): Promise<EnrollState> {
const session = await requirePlatformSession();
const admin = await prisma.platformAdmin.findUnique({ where: { id: session.user.id } });
if (!admin) return { status: "error", message: "Konto nicht gefunden." };
if (admin.mfaEnrolledAt) return { status: "error", message: "MFA ist bereits eingerichtet." };
if (!admin.mfaSecret) return { status: "error", message: "Kein Einrichtungs-Secret vorhanden. Bitte Seite neu laden." };
const code = String(formData.get("token") ?? "");
const totp = verifyTotp(code, admin.mfaSecret);
if (!totp.ok) {
return { status: "error", message: "Code ungültig. Bitte den aktuellen 6-stelligen Code aus der App eingeben." };
}
// F-17: den bei der Einrichtung akzeptierten Zeitschritt gleich als lastTotpStep
// festhalten, damit der Enrollment-Code nicht anschließend beim Login erneut gilt.
const { plain, hashed } = await generateRecoveryCodes(10);
await prisma.platformAdmin.update({
where: { id: admin.id },
data: { mfaEnrolledAt: new Date(), recoveryCodes: hashed, failedLogins: 0, lockedUntil: null, lastTotpStep: BigInt(totp.step) },
});
await writePlatformAudit({ actorId: admin.id, action: "update", entity: "platform_admin", entityId: admin.id, after: { mfaEnrolled: true } });
return { status: "done", recoveryCodes: plain };
}
/** MFA freiwillig deaktivieren (nur zulässig, solange keine MFA-Pflicht gilt). */
export async function disablePlatformMfa(formData: FormData) {
const session = await requirePlatformSession();
const settings = await getPlatformSettings();
if (settings.mfaRequired) throw new Error("MFA ist per Plattform-Policy verpflichtend und kann nicht deaktiviert werden.");
// Re-Authentifizierung (F-08): das Abschalten des zweiten Faktors darf nur mit
// gültigem TOTP-Code möglich sein, sonst entfernt ein Angreifer mit gekaperter
// Session die MFA einfach. Prüfung gegen das gespeicherte Secret aus der DB.
const admin = await prisma.platformAdmin.findUnique({ where: { id: session.user.id } });
if (!admin?.mfaEnrolledAt || !admin.mfaSecret) throw new Error("MFA ist nicht aktiv.");
const code = String(formData.get("token") ?? "");
// Replay-Schutz (F-17): ein bereits verwendeter Code kann MFA nicht deaktivieren.
if (!verifyTotp(code, admin.mfaSecret, admin.lastTotpStep).ok) throw new Error("Der MFA-Code ist ungültig. MFA wurde nicht deaktiviert.");
await prisma.platformAdmin.update({
where: { id: session.user.id },
data: { mfaSecret: null, mfaEnrolledAt: null, recoveryCodes: [], lastTotpStep: null },
});
await writePlatformAudit({ actorId: session.user.id, action: "update", entity: "platform_admin", entityId: session.user.id, after: { mfaDisabled: true } });
revalidatePath("/profile");
}
/** Plattformweite MFA-Pflicht schalten (stellt die Erzwingung wieder her — nur Flag). */
export async function setPlatformMfaRequired(required: boolean) {
const session = await requirePlatformSession();
await prisma.platformSetting.upsert({
where: { id: "singleton" },
update: { mfaRequired: required },
create: { id: "singleton", mfaRequired: required },
});
await writePlatformAudit({ actorId: session.user.id, action: "update", entity: "platform_setting", entityId: "singleton", after: { mfaRequired: required } });
revalidatePath("/profile");
}
/** Plattform-Logout (eigene Session-Domäne). */
export async function platformSignOutAction() {
await platformSignOut({ redirectTo: "/platform/login" });
}
+320
View File
@@ -0,0 +1,320 @@
"use server";
import { revalidatePath } from "next/cache";
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";
import { notifyTaskEvent } from "@/server/mail/notifications";
import { deriveDomainsCore } from "@/server/policies/derive-domains";
import { DOMAIN_LABELS } from "@/lib/control-domain";
import type { Domain } from "@prisma/client";
const optDate = (v: FormDataEntryValue | null) => (v && String(v) ? new Date(String(v)) : null);
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
/**
* Optionales Redirect-Ziel aus dem Formular („im Wizard bleiben"). Lässt NUR interne,
* absolute Pfade zu (kein Protokoll/Host, kein `//`, kein Backslash) — verhindert
* Open-Redirects. Ohne gültiges Ziel greift der jeweilige Default.
*/
const safeInternalPath = (v: FormDataEntryValue | null): string | null => {
const s = typeof v === "string" ? v.trim() : "";
if (!s.startsWith("/") || s.startsWith("//") || s.includes("\\") || s.includes("://")) return null;
return s;
};
const guardModule = moduleGuard("policies");
/** Modul-Gating „policies" + RBAC. Default-Recht policy:write; Freigabe nutzt policy:approve. */
async function guard(permission: Permission = "policy:write") {
return guardModule(permission);
}
/* ── 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", "layout");
}
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", "layout");
}
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", "layout");
}
/* ── 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", "layout");
}
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", "layout");
}
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", "layout");
}
/* ── 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", "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). */
export async function updatePolicyTemplate(code: string, formData: FormData) {
const { session, db } = await guard();
const doc = await db.policyDocument.findFirst({ where: { code } });
if (!doc) throw new Error("Dokument nicht gefunden");
const rawMarkdown = String(formData.get("rawMarkdown") ?? "");
await db.policyDocument.update({ where: { id: doc.id }, data: { rawMarkdown } });
// Fehlende {{VARIABLEN}} automatisch anlegen (Experten-Modus: neue Variablen)
const referenced = new Set([...rawMarkdown.matchAll(/\{\{\s*([A-Z][A-Z0-9_]*)\s*\}\}/g)].map((m) => m[1]));
if (referenced.size) {
const existing = new Set((await db.policyVariable.findMany({ select: { key: true } })).map((v) => v.key));
const last = await db.policyVariable.aggregate({ _max: { orderIdx: true } });
let idx = (last._max.orderIdx ?? 0) + 1;
for (const key of referenced) {
if (existing.has(key)) continue;
const isFlag = key.startsWith("FLAG_");
await db.policyVariable.create({
data: { tenantId: session.user.tenantId, key, title: key, kind: isFlag ? "boolean" : "string", groupName: "Eigene", value: isFlag ? "false" : "", orderIdx: idx++ },
});
}
}
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document", entityId: doc.id });
revalidatePath("/policies", "layout");
redirect(`/policies/${encodeURIComponent(code)}/edit?expert=1`);
}
/** 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 } });
revalidatePath("/policies", "layout");
}
/**
* Dokument-bezogene Variablen gebündelt speichern (Bearbeitungsmodus). Nur die
* übergebenen Schlüssel (Feld `keys` = kommagetrennt) werden aus dem Formular
* gelesen; Werte gelten zentral für alle Dokumente (eine Pflegestelle, §8).
*/
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 (centralKeys.has(key) || !formData.has(`var_${key}`)) continue;
const value = String(formData.get(`var_${key}`) ?? "");
await db.policyVariable.updateMany({ where: { key }, data: { value } });
}
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_variables", entityId: code, after: { keys } });
revalidatePath("/policies", "layout");
redirect(`/policies/${encodeURIComponent(code)}/edit`);
}
/* ── Freigabe-Workflow (Vier-Augen, §8) — Einreichen erzeugt eine Aufgabe ── */
/**
* Richtlinie zur Freigabe einreichen: der Einreicher wählt einen konkreten
* Freigeber (aktiver Nutzer mit policy:approve, ≠ Einreicher). Das Dokument geht
* auf IN_FREIGABE und es entsteht eine Aufgabe (policy_approval) für den Freigeber.
* Freigabe/Ablehnung erfolgen im Aufgaben-Modul (actions/tasks.ts).
*/
export async function submitForApproval(code: string, formData: FormData) {
const { session, db } = await guard();
const approverId = String(formData.get("approverId") ?? "").trim();
if (!approverId) throw new Error("Bitte einen Freigeber auswählen.");
if (approverId === session.user.id) throw new Error("Vier-Augen-Prinzip: Der Freigeber muss eine andere Person sein.");
const doc = await db.policyDocument.findFirst({ where: { code } });
if (!doc) throw new Error("Dokument nicht gefunden");
const approver = await db.user.findFirst({
where: { id: approverId, status: "ACTIVE", userRoles: { some: { role: { rolePermissions: { some: { permission: { key: "policy:approve" } } } } } } },
select: { id: true },
});
if (!approver) throw new Error("Ungültiger Freigeber (kein aktiver Nutzer mit Freigaberecht).");
// Vorherige offene Freigabe-Aufgaben dieses Dokuments abschließen (Resubmit).
await db.task.updateMany({ where: { entityType: "policy_document", entityId: doc.id, status: "OPEN" }, data: { status: "CANCELLED" } });
await db.policyDocument.update({ where: { id: doc.id }, data: { status: "IN_FREIGABE", submittedBy: session.user.id, approvedBy: null, approvedAt: null } });
const note = str(formData.get("note"));
const task = await db.task.create({
data: {
tenantId: session.user.tenantId, type: "policy_approval",
title: `Freigabe: ${doc.code} — ${doc.title}`,
entityType: "policy_document", entityId: doc.id, entityRef: doc.code,
assigneeId: approver.id, createdById: session.user.id, status: "OPEN",
comments: note ? { create: { tenantId: session.user.tenantId, authorId: session.user.id, kind: "submit", body: note } } : undefined,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document", entityId: doc.id, after: { status: "IN_FREIGABE", approverId: approver.id, taskId: task.id } });
// SEC1: der ausgewählte Freigeber wird über die Anfrage informiert.
await notifyTaskEvent({
tenantId: session.user.tenantId,
event: "task_approval_requested",
taskId: task.id,
taskTitle: task.title,
taskType: task.type,
recipientId: approver.id,
actorId: session.user.id,
});
revalidatePath("/policies", "layout");
revalidatePath("/tasks");
revalidatePath("/onboarding", "layout");
const back = safeInternalPath(formData.get("returnTo"));
redirect(back ?? `/policies/${encodeURIComponent(code)}/edit`);
}
/* ── Fachbereich (Domain) — Ableitung & manuelles Überschreiben ── */
/** Fachbereiche idempotent ableiten (programmatisch wiederverwendbar, z. B. Import). */
export async function derivePolicyDomains(): Promise<number> {
const { session, db } = await guard();
const filled = await deriveDomainsCore(db, session.user.tenantId);
if (filled) {
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document", after: { deriveDomains: filled } });
revalidatePath("/policies", "layout");
}
return filled;
}
/** Formular-Variante des Ableitens (Button in der Ansicht): leitet mit Ergebnis-Banner zurück. */
export async function submitDeriveDomains(formData: FormData) {
const { session, db } = await guard();
const filled = await deriveDomainsCore(db, session.user.tenantId);
if (filled) {
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document", after: { deriveDomains: filled } });
revalidatePath("/policies", "layout");
revalidatePath("/onboarding", "layout");
}
const back = safeInternalPath(formData.get("returnTo"));
redirect(back ?? `/policies?view=domains&derived=${filled}`);
}
/** Manuelles Setzen/Zurücksetzen des Fachbereichs (Dropdown in der Ansicht). Leerwert = zurücksetzen. */
export async function setPolicyDomain(code: string, formData: FormData) {
const { session, db } = await guard();
const raw = str(formData.get("domain"));
const domain: Domain | null = raw && raw in DOMAIN_LABELS ? (raw as Domain) : 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: { domain } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document", entityId: doc.id, after: { domain } });
revalidatePath("/policies", "layout");
revalidatePath("/onboarding", "layout");
}
+75
View File
@@ -0,0 +1,75 @@
"use server";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { REVIEW_CYCLES, computeNextReview, bumpMinorVersion } from "@/lib/review-cycle";
/**
* AP5 — Dokumentenlenkung (Modul „policies"): Prüfzyklus/-termin (A.5.1), dokumentierte
* Neuversion mit Historie und Lesebestätigung je Version (SPEC §4.6, Klausel 7.3, A.6.3).
* moduleGuard("policies"); Verwaltung verlangt `policy:write`, Bestätigen `policy:read`.
*/
const guard = moduleGuard("policies");
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
/** Prüfzyklus setzen und daraus den nächsten Prüftermin berechnen. */
export async function setDocReviewCycle(docId: string, formData: FormData) {
const { session, db } = await guard("policy:write");
const cycleRaw = str(formData.get("reviewCycle"));
const cycle = cycleRaw in REVIEW_CYCLES ? cycleRaw : null;
await db.policyDocument.update({
where: { id: docId },
data: { reviewCycle: cycle, nextReviewAt: computeNextReview(new Date(), cycle) },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document_review", entityId: docId, after: { reviewCycle: cycle } });
revalidatePath("/policies/control");
}
/**
* Dokument als geprüft markieren: aktuelle Version in die Historie schreiben, Minor-
* Version erhöhen (neue Version → Lesebestätigungen müssen erneuert werden, da
* versions-scoped) und den nächsten Prüftermin aus dem Zyklus neu setzen.
*/
export async function markDocReviewed(docId: string, formData: FormData) {
const { session, db } = await guard("policy:write");
const doc = await db.policyDocument.findUnique({ where: { id: docId }, select: { version: true, title: true, reviewCycle: true } });
if (!doc) throw new Error("Dokument nicht gefunden.");
// Momentaufnahme der bisherigen Version.
await db.policyDocumentVersion.create({
data: {
tenantId: session.user.tenantId,
policyDocumentId: docId,
version: doc.version,
title: doc.title,
changeNote: str(formData.get("changeNote")) || "Turnusmäßige Überprüfung",
createdBy: session.user.id,
},
});
const newVersion = bumpMinorVersion(doc.version);
await db.policyDocument.update({
where: { id: docId },
data: { version: newVersion, nextReviewAt: computeNextReview(new Date(), doc.reviewCycle) },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document_review", entityId: docId, after: { from: doc.version, to: newVersion } });
revalidatePath("/policies/control");
}
/** Aktuelle Version des Dokuments als gelesen bestätigen (versions-scoped, idempotent). */
export async function acknowledgePolicy(docId: string) {
const { session, db } = await guard("policy:read");
const doc = await db.policyDocument.findUnique({ where: { id: docId }, select: { version: true } });
if (!doc) throw new Error("Dokument nicht gefunden.");
await db.policyAcknowledgement.upsert({
where: {
tenantId_policyDocumentId_userId_version: {
tenantId: session.user.tenantId, policyDocumentId: docId, userId: session.user.id, version: doc.version,
},
},
update: {},
create: { tenantId: session.user.tenantId, policyDocumentId: docId, userId: session.user.id, version: doc.version },
});
revalidatePath("/policies/control");
}
+54
View File
@@ -0,0 +1,54 @@
"use server";
import { join } from "node:path";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { prisma, dbForTenant } from "@/server/db";
import { moduleGuard } from "@/server/action-guard";
import { reconcilePackage, stampPackageVersion } from "../../../prisma/import-policies";
import { resolvePackageForTenant, getTenantFrameworks } from "../../../prisma/template-store";
import { deriveDomainsCore } from "@/server/policies/derive-domains";
/**
* Richtlinien-Vorlagen (Story B4-1): das Vorlagenpaket mandantenweit nicht-destruktiv
* importieren/aktualisieren (Diff/Upsert, archivedAt; Status/Freigabe/Overrides und
* nutzergepflegte Variablenwerte bleiben erhalten). Kapselt prisma/import-policies.ts
* als serverseitig aufrufbare Action. Einstieg: Button unter /policies (Self-Service)
* und im Admin (siehe importPolicyPackageForTenant). Import bei Modul-Aktivierung:
* siehe toggleTenantModule.
*/
const guard = moduleGuard("policies");
const SEED_DIR = join(process.cwd(), "seed", "isms-vorlagenpaket-v2");
export async function importPolicyPackage() {
const { session } = await guard("policy:write");
const tenantId = session.user.tenantId;
// AP1: je Framework des Mandanten importieren. Anforderungen sind framework-scoped,
// die geteilten Inhalte (Dokumente/Variablen/…) werden nur beim ersten Framework
// abgeglichen (reconcileShared). Einzel-Framework-Mandant = unverändertes Verhalten.
const frameworks = await getTenantFrameworks(prisma, tenantId);
const agg = { dA: 0, dU: 0, dAr: 0, dR: 0, rA: 0, rU: 0, rAr: 0 };
for (const [i, framework] of frameworks.entries()) {
// Quelle: veröffentlichte DB-Vorlage in der Sprache des Mandanten (DE-/Datei-Fallback).
const { pkg } = await resolvePackageForTenant(prisma, tenantId, SEED_DIR, framework);
const result = await reconcilePackage(prisma, tenantId, pkg, {
actorId: session.user.id,
framework,
reconcileShared: i === 0,
});
// Story B6: übernommene Paket-Version je Framework stempeln (kontrollierte Übernahme).
await stampPackageVersion(prisma, tenantId, pkg.version, framework);
const d = result.report.documents;
const r = result.report.requirements;
agg.dA += d.added; agg.dU += d.updated; agg.dAr += d.archived; agg.dR += d.reactivated;
agg.rA += r.added; agg.rU += r.updated; agg.rAr += r.archived;
}
// Fachbereiche neuer Richtlinien direkt ableiten (idempotent, tenant-scoped).
await deriveDomainsCore(dbForTenant(tenantId), tenantId);
revalidatePath("/policies", "layout");
revalidatePath("/policies/updates");
revalidatePath("/dashboard");
const q = `d${agg.dA}-${agg.dU}-${agg.dAr}-${agg.dR}_r${agg.rA}-${agg.rU}-${agg.rAr}`;
redirect(`/policies?import=${q}`);
}
+313
View File
@@ -0,0 +1,313 @@
"use server";
import { revalidatePath } from "next/cache";
import type { Framework } from "@prisma/client";
import { prisma } from "@/server/db";
import { requirePlatformFullAdmin } from "@/server/platform-auth";
import { writePlatformAudit } from "@/server/audit";
/**
* Plattform-Bearbeitung der globalen Richtlinien-VORLAGEN (Phase 2). Getrennte
* Plattform-Session (requirePlatformFullAdmin, kein Mandantenkontext). Bearbeitet wird
* ausschließlich der aktuelle ENTWURF (DRAFT) — veröffentlichte Versionen sind
* unveränderlich. Inhalte je Sprache (locale de|en). Die Veröffentlichung (Phase 3)
* wandelt den Entwurf in eine neue veröffentlichte Version.
*
* EXEMPT vom Modul-Guard (eigene Plattform-Auth, kein per TenantModule gegatetes Fachmodul).
*/
const LOCALES = new Set(["de", "en"]);
const str = (fd: FormData, k: string) => String(fd.get(k) ?? "").trim();
// Der Plattform-Vorlagen-Editor ist framework-fähig (TISAX ODER ISO 27001). Alle
// Draft-/Publish-Operationen sind auf das jeweilige Framework gescoped, damit ein
// Publish nicht die Version des anderen Frameworks archiviert (Falle 1.2). Form-Actions
// erhalten das Framework als verstecktes Feld, gebundene Actions per .bind().
function frameworkFrom(fd: FormData): Framework {
return String(fd.get("framework")) === "ISO_27001" ? "ISO_27001" : "TISAX";
}
function bumpVersion(v?: string | null): string {
if (!v) return "1.0";
const m = v.match(/^(\d+)\.(\d+)/);
return m ? `${m[1]}.${Number(m[2]) + 1}` : `${v}.1`;
}
async function getDraft(framework: Framework) {
return prisma.policyTemplateVersion.findFirst({ where: { status: "DRAFT", framework }, orderBy: { createdAt: "desc" } });
}
/** Sicherstellen, dass die Ziel-Version ein Entwurf ist (nur Entwürfe sind bearbeitbar). */
async function assertDraftVersion(versionId: string) {
const v = await prisma.policyTemplateVersion.findUnique({ where: { id: versionId }, select: { status: true } });
if (!v || v.status !== "DRAFT") {
throw new Error("Nur der Entwurf ist bearbeitbar. Bitte zuerst einen Entwurf anlegen.");
}
}
/** Kinder einer Version (alle Sprachen) in eine andere Version kopieren. */
async function cloneChildren(fromId: string, toId: string) {
const [docs, reqs, vars, baseline, evidence] = await Promise.all([
prisma.policyTemplateDoc.findMany({ where: { versionId: fromId } }),
prisma.policyTemplateRequirement.findMany({ where: { versionId: fromId } }),
prisma.policyTemplateVariable.findMany({ where: { versionId: fromId } }),
prisma.policyTemplateBaselineParam.findMany({ where: { versionId: fromId } }),
prisma.policyTemplateEvidence.findMany({ where: { versionId: fromId } }),
]);
await prisma.$transaction([
prisma.policyTemplateDoc.createMany({
data: docs.map((d) => ({
versionId: toId, locale: d.locale, code: d.code, type: d.type, title: d.title,
docVersion: d.docVersion, policyCode: d.policyCode, fulfills: d.fulfills, domain: d.domain,
rawMarkdown: d.rawMarkdown, orderIdx: d.orderIdx,
})),
}),
prisma.policyTemplateRequirement.createMany({
data: reqs.map((r) => ({
versionId: toId, locale: r.locale, reqId: r.reqId, policyCode: r.policyCode, control: r.control,
obligation: r.obligation, condition: r.condition, requirement: r.requirement,
implementation: r.implementation, vaCodes: r.vaCodes, nachweisLink: r.nachweisLink, orderIdx: r.orderIdx,
})),
}),
prisma.policyTemplateVariable.createMany({
data: vars.map((v) => ({
versionId: toId, locale: v.locale, key: v.key, title: v.title, kind: v.kind,
groupName: v.groupName, value: v.value, required: v.required, orderIdx: v.orderIdx,
})),
}),
prisma.policyTemplateBaselineParam.createMany({
data: baseline.map((b) => ({
versionId: toId, locale: b.locale, blId: b.blId, section: b.section, name: b.name, vorgabe: b.vorgabe, orderIdx: b.orderIdx,
})),
}),
prisma.policyTemplateEvidence.createMany({
data: evidence.map((e) => ({
versionId: toId, locale: e.locale, nr: e.nr, policyCode: e.policyCode, nachweis: e.nachweis,
quelle: e.quelle, verantwortlich: e.verantwortlich, turnus: e.turnus,
})),
}),
]);
}
/**
* Aktuellen Entwurf liefern oder neu anlegen. Ein neuer Entwurf wird als Kopie der
* zuletzt veröffentlichten Version erstellt (Bearbeitung startet vom aktuellen Stand,
* inkl. beider Sprachen). Version des Entwurfs = nächste freie Minor-Version.
*/
export async function ensureDraft(framework: Framework): Promise<string> {
const { admin } = await requirePlatformFullAdmin();
const existing = await getDraft(framework);
if (existing) return existing.id;
const published = await prisma.policyTemplateVersion.findFirst({
where: { status: "PUBLISHED", framework },
orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }],
});
// Freie Versionsnummer finden (darf mit keiner bestehenden Version dieses Frameworks kollidieren).
let version = bumpVersion(published?.version);
while (await prisma.policyTemplateVersion.findUnique({ where: { framework_version: { framework, version } }, select: { id: true } })) {
version = bumpVersion(version);
}
const draft = await prisma.policyTemplateVersion.create({ data: { framework, version, status: "DRAFT" } });
if (published) await cloneChildren(published.id, draft.id);
await writePlatformAudit({ actorId: admin.id, action: "create", entity: "policy_template_version", entityId: draft.id, after: { framework, version, clonedFrom: published?.version ?? null } });
revalidatePath("/templates");
return draft.id;
}
/** Form-tauglicher Void-Wrapper (Button „Entwurf anlegen/bearbeiten"), per .bind(framework). */
export async function startDraftAction(framework: Framework): Promise<void> {
await ensureDraft(framework);
}
/** Entwurf verwerfen (löscht die Entwurfs-Version samt Inhalten; Cascade). */
export async function discardDraft(framework: Framework): Promise<void> {
const { admin } = await requirePlatformFullAdmin();
const draft = await getDraft(framework);
if (!draft) return;
await prisma.policyTemplateVersion.delete({ where: { id: draft.id } });
await writePlatformAudit({ actorId: admin.id, action: "delete", entity: "policy_template_version", entityId: draft.id, after: { discarded: draft.version } });
revalidatePath("/templates");
}
/**
* Entwurf veröffentlichen: wird zur neuen veröffentlichten Version. Bisher veröffentlichte
* Versionen werden archiviert (nur die neue bleibt PUBLISHED). Neue Mandanten erhalten sie
* automatisch bei der Provisionierung; bestehende sehen unter /policies/updates „Update
* verfügbar" und übernehmen selbst (nicht-destruktiv). Optionaler Änderungshinweis (notes).
*/
export async function publishDraft(formData: FormData): Promise<void> {
const { admin } = await requirePlatformFullAdmin();
const framework = frameworkFrom(formData);
const draft = await getDraft(framework);
if (!draft) throw new Error("Kein Entwurf zum Veröffentlichen vorhanden.");
const docCount = await prisma.policyTemplateDoc.count({ where: { versionId: draft.id } });
if (docCount === 0) throw new Error("Der Entwurf enthält keine Dokumente.");
const version = (str(formData, "version") || draft.version).trim();
if (!/^\d+(\.\d+)*$/.test(version)) throw new Error("Ungültige Versionsnummer (z. B. 2.2).");
const notes = str(formData, "notes") || null;
const clash = await prisma.policyTemplateVersion.findFirst({ where: { version, framework, id: { not: draft.id } }, select: { id: true } });
if (clash) throw new Error(`Version ${version} existiert bereits. Bitte eine andere Versionsnummer wählen.`);
await prisma.$transaction([
// Bisher veröffentlichte Versionen DIESES Frameworks archivieren — nur die neue bleibt PUBLISHED.
prisma.policyTemplateVersion.updateMany({ where: { status: "PUBLISHED", framework }, data: { status: "ARCHIVED" } }),
prisma.policyTemplateVersion.update({
where: { id: draft.id },
data: { status: "PUBLISHED", version, notes, publishedAt: new Date(), publishedBy: admin.id },
}),
]);
await writePlatformAudit({ actorId: admin.id, action: "update", entity: "policy_template_version", entityId: draft.id, after: { published: version } });
revalidatePath("/templates");
revalidatePath("/policies/updates");
revalidatePath("/policies", "layout");
}
// ── Dokumente ───────────────────────────────────────────────────────────────
/** Fehlende {{VARIABLEN}} aus dem Rohtext als String-Variablen im Entwurf anlegen (Parität zum Mandanten-Editor). */
async function ensureVariablesForMarkdown(versionId: string, locale: string, markdown: string) {
const found = new Set<string>();
for (const m of markdown.matchAll(/\{\{([A-Z0-9_]+)\}\}/g)) found.add(m[1]);
if (found.size === 0) return;
const existing = await prisma.policyTemplateVariable.findMany({ where: { versionId, locale }, select: { key: true } });
const have = new Set(existing.map((v) => v.key));
const missing = [...found].filter((k) => !have.has(k));
if (missing.length === 0) return;
const maxOrder = existing.length;
await prisma.policyTemplateVariable.createMany({
data: missing.map((key, i) => ({ versionId, locale, key, title: key, kind: "string", groupName: "Eigene", value: "", required: false, orderIdx: maxOrder + i })),
skipDuplicates: true,
});
}
export async function updateTemplateDoc(docId: string, formData: FormData): Promise<void> {
const { admin } = await requirePlatformFullAdmin();
const doc = await prisma.policyTemplateDoc.findUnique({ where: { id: docId } });
if (!doc) throw new Error("Dokument nicht gefunden.");
await assertDraftVersion(doc.versionId);
const rawMarkdown = String(formData.get("rawMarkdown") ?? "");
const title = str(formData, "title") || doc.title;
await prisma.policyTemplateDoc.update({ where: { id: docId }, data: { rawMarkdown, title } });
await ensureVariablesForMarkdown(doc.versionId, doc.locale, rawMarkdown);
await writePlatformAudit({ actorId: admin.id, action: "update", entity: "policy_template_doc", entityId: docId, after: { code: doc.code, locale: doc.locale } });
revalidatePath(`/templates/${doc.locale}/${doc.code}`);
revalidatePath("/templates");
}
export async function createTemplateDoc(formData: FormData): Promise<void> {
const { admin } = await requirePlatformFullAdmin();
const draft = await getDraft(frameworkFrom(formData));
if (!draft) throw new Error("Kein Entwurf vorhanden. Bitte zuerst einen Entwurf anlegen.");
const locale = str(formData, "locale");
const code = str(formData, "code").toUpperCase();
const type = str(formData, "type") || "RICHTLINIE";
const title = str(formData, "title") || code;
if (!LOCALES.has(locale)) throw new Error("Ungültige Sprache.");
if (!/^[A-Z0-9][A-Z0-9-]*$/.test(code)) throw new Error("Ungültiger Code (z. B. R15, VA-21, EIG-1).");
const dup = await prisma.policyTemplateDoc.findUnique({ where: { versionId_locale_code: { versionId: draft.id, locale, code } }, select: { id: true } });
if (dup) throw new Error(`Dokument ${code} (${locale}) existiert bereits im Entwurf.`);
const maxOrder = await prisma.policyTemplateDoc.aggregate({ where: { versionId: draft.id, locale }, _max: { orderIdx: true } });
await prisma.policyTemplateDoc.create({
data: {
versionId: draft.id, locale, code, type: type as never, title,
rawMarkdown: `# ${title}\n\n`, orderIdx: (maxOrder._max.orderIdx ?? 0) + 1,
},
});
await writePlatformAudit({ actorId: admin.id, action: "create", entity: "policy_template_doc", entityId: `${code}`, after: { code, locale, type } });
revalidatePath("/templates");
}
// ── Anforderungen (Control-Mapping) ───────────────────────────────────────────
export async function upsertTemplateRequirement(formData: FormData): Promise<void> {
const { admin } = await requirePlatformFullAdmin();
const id = str(formData, "id");
const data = {
reqId: str(formData, "reqId"),
policyCode: str(formData, "policyCode"),
control: str(formData, "control"),
obligation: str(formData, "obligation") || "MUSS",
condition: str(formData, "condition") || null,
requirement: str(formData, "requirement"),
implementation: str(formData, "implementation"),
vaCodes: str(formData, "vaCodes").split(",").map((s) => s.trim()).filter(Boolean),
nachweisLink: str(formData, "nachweisLink") || null,
};
if (id) {
const ex = await prisma.policyTemplateRequirement.findUnique({ where: { id } });
if (!ex) throw new Error("Anforderung nicht gefunden.");
await assertDraftVersion(ex.versionId);
await prisma.policyTemplateRequirement.update({ where: { id }, data });
await writePlatformAudit({ actorId: admin.id, action: "update", entity: "policy_template_requirement", entityId: ex.reqId, after: { locale: ex.locale } });
revalidatePath(`/templates/${ex.locale}/requirements`);
return;
}
const draft = await getDraft(frameworkFrom(formData));
if (!draft) throw new Error("Kein Entwurf vorhanden.");
const locale = str(formData, "locale");
if (!LOCALES.has(locale)) throw new Error("Ungültige Sprache.");
if (!data.reqId) throw new Error("Anforderungs-ID fehlt.");
const dup = await prisma.policyTemplateRequirement.findUnique({ where: { versionId_locale_reqId: { versionId: draft.id, locale, reqId: data.reqId } }, select: { id: true } });
if (dup) throw new Error(`Anforderung ${data.reqId} (${locale}) existiert bereits.`);
const maxOrder = await prisma.policyTemplateRequirement.aggregate({ where: { versionId: draft.id, locale }, _max: { orderIdx: true } });
await prisma.policyTemplateRequirement.create({ data: { versionId: draft.id, locale, ...data, orderIdx: (maxOrder._max.orderIdx ?? 0) + 1 } });
await writePlatformAudit({ actorId: admin.id, action: "create", entity: "policy_template_requirement", entityId: data.reqId, after: { locale } });
revalidatePath(`/templates/${locale}/requirements`);
}
export async function deleteTemplateRequirement(id: string): Promise<void> {
const { admin } = await requirePlatformFullAdmin();
const ex = await prisma.policyTemplateRequirement.findUnique({ where: { id } });
if (!ex) return;
await assertDraftVersion(ex.versionId);
await prisma.policyTemplateRequirement.delete({ where: { id } });
await writePlatformAudit({ actorId: admin.id, action: "delete", entity: "policy_template_requirement", entityId: ex.reqId, after: { locale: ex.locale } });
revalidatePath(`/templates/${ex.locale}/requirements`);
}
// ── Variablen ─────────────────────────────────────────────────────────────────
export async function upsertTemplateVariable(formData: FormData): Promise<void> {
const { admin } = await requirePlatformFullAdmin();
const id = str(formData, "id");
const data = {
key: str(formData, "key").toUpperCase().replace(/[^A-Z0-9_]/g, "_"),
title: str(formData, "title"),
kind: str(formData, "kind") === "boolean" ? "boolean" : "string",
groupName: str(formData, "groupName") || null,
value: str(formData, "value"),
required: formData.get("required") === "on" || formData.get("required") === "true",
};
if (id) {
const ex = await prisma.policyTemplateVariable.findUnique({ where: { id } });
if (!ex) throw new Error("Variable nicht gefunden.");
await assertDraftVersion(ex.versionId);
await prisma.policyTemplateVariable.update({ where: { id }, data: { title: data.title || ex.title, kind: data.kind, groupName: data.groupName, value: data.value, required: data.required } });
await writePlatformAudit({ actorId: admin.id, action: "update", entity: "policy_template_variable", entityId: ex.key, after: { locale: ex.locale } });
revalidatePath(`/templates/${ex.locale}/variables`);
return;
}
const draft = await getDraft(frameworkFrom(formData));
if (!draft) throw new Error("Kein Entwurf vorhanden.");
const locale = str(formData, "locale");
if (!LOCALES.has(locale)) throw new Error("Ungültige Sprache.");
if (!data.key) throw new Error("Variablen-Key fehlt.");
const dup = await prisma.policyTemplateVariable.findUnique({ where: { versionId_locale_key: { versionId: draft.id, locale, key: data.key } }, select: { id: true } });
if (dup) throw new Error(`Variable ${data.key} (${locale}) existiert bereits.`);
const maxOrder = await prisma.policyTemplateVariable.aggregate({ where: { versionId: draft.id, locale }, _max: { orderIdx: true } });
await prisma.policyTemplateVariable.create({ data: { versionId: draft.id, locale, ...data, title: data.title || data.key, orderIdx: (maxOrder._max.orderIdx ?? 0) + 1 } });
await writePlatformAudit({ actorId: admin.id, action: "create", entity: "policy_template_variable", entityId: data.key, after: { locale } });
revalidatePath(`/templates/${locale}/variables`);
}
export async function deleteTemplateVariable(id: string): Promise<void> {
const { admin } = await requirePlatformFullAdmin();
const ex = await prisma.policyTemplateVariable.findUnique({ where: { id } });
if (!ex) return;
await assertDraftVersion(ex.versionId);
await prisma.policyTemplateVariable.delete({ where: { id } });
await writePlatformAudit({ actorId: admin.id, action: "delete", entity: "policy_template_variable", entityId: ex.key, after: { locale: ex.locale } });
revalidatePath(`/templates/${ex.locale}/variables`);
}
+186
View File
@@ -0,0 +1,186 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { storage } from "@/server/storage/adapter";
import { escapeHtml } from "@/lib/policy-render";
/**
* Eigene Richtlinie hochladen (Story B4-2). Legt ein PolicyDocument (Typ EIGENES)
* mit Pflicht-Control-Zuordnung an; die Zuordnung erzeugt PolicyRequirement-Zeilen
* (Namensraum EIG-*, außerhalb des Paket-Namensraums → vom Re-Import unberührt) und
* fließt zusätzlich als <!-- REQ <control> -->-Anker in den Rohtext (Nachweislage,
* Schritt 7). Die Datei wird über den gekapselten Storage-Adapter (Stub, S1 folgt)
* abgelegt — im Stub nur Metadaten/Key, keine Bytes.
*/
const guard = moduleGuard("policies");
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
/**
* Upload-Vertrag (F-15) — verbindliche Anforderungen an JEDES Storage-Backend,
* insbesondere die echte Implementierung aus Epic S1 (S3/MinIO). Der aktuelle
* Storage-Adapter ist ein Stub, der keine Bytes persistiert; die untenstehenden
* Kontrollen dürfen daher NICHT als „durch den Stub abgesichert" missverstanden
* werden, sondern sind Pflicht auf Anwendungsebene:
* 1. Größenlimit VOR dem Einlesen in den RAM prüfen (kein DoS über Riesendatei).
* 2. Allowlist für Endungen UND MIME-Typen (PDF, DOCX, ODT).
* 3. Inhaltsprüfung über Magic Bytes — dem clientseitig gesetzten `f.type`
* wird NICHT vertraut; der abgeleitete, geprüfte Typ wird gespeichert.
* Beim echten Backend zusätzlich: Ablage außerhalb des Web-Roots, Auslieferung
* mit `Content-Disposition: attachment` und `X-Content-Type-Options: nosniff`
* (F-07), sowie AV-Scan vor Freigabe.
*/
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024; // 20 MB Obergrenze
type UploadKind = "pdf" | "docx" | "odt";
// Endung → erlaubter Typ.
const ALLOWED_EXT: Record<string, UploadKind> = { pdf: "pdf", docx: "docx", odt: "odt" };
// Kanonischer, serverseitig gesetzter MIME-Typ je geprüftem Inhalt.
const CANONICAL_MIME: Record<UploadKind, string> = {
pdf: "application/pdf",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
odt: "application/vnd.oasis.opendocument.text",
};
// Vom Client zulässigerweise gesetzte MIME-Typen (nur als Zusatzsignal geprüft,
// nicht als alleinige Wahrheit — die Magic-Byte-Prüfung ist maßgeblich).
const ALLOWED_CLIENT_MIME: Record<UploadKind, string[]> = {
pdf: ["application/pdf"],
docx: [
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/octet-stream",
"",
],
odt: ["application/vnd.oasis.opendocument.text", "application/octet-stream", ""],
};
const startsWith = (bytes: Uint8Array, sig: number[]) =>
sig.every((b, i) => bytes[i] === b);
const PDF_MAGIC = [0x25, 0x50, 0x44, 0x46]; // "%PDF"
const ZIP_MAGIC = [0x50, 0x4b, 0x03, 0x04]; // "PK\x03\x04" (DOCX/ODT sind ZIP-Container)
/** Prüft, ob der Inhalt (Magic Bytes) zum per Endung erwarteten Typ passt. */
function magicMatches(kind: UploadKind, bytes: Uint8Array): boolean {
if (kind === "pdf") return startsWith(bytes, PDF_MAGIC);
// DOCX und ODT sind beide ZIP-Container.
if (!startsWith(bytes, ZIP_MAGIC)) return false;
if (kind === "odt") {
// ODT legt als erstes, unkomprimiertes Element die "mimetype"-Datei ab; ihr
// Inhalt "…opendocument.text" liegt in den ersten ~80 Bytes im Klartext.
const head = Buffer.from(bytes.slice(0, 80)).toString("latin1");
return head.includes("opendocument.text");
}
return true; // docx: ZIP-Signatur genügt (OOXML), Endung + Client-MIME grenzen ein.
}
/** Endung aus dem (bereits an anderer Stelle bereinigten) Dateinamen ziehen. */
function extOf(filename: string): string {
const dot = filename.lastIndexOf(".");
return dot >= 0 ? filename.slice(dot + 1).toLowerCase() : "";
}
export async function uploadOwnPolicy(formData: FormData) {
const { session, db } = await guard("policy:write");
const title = str(formData.get("title"));
if (!title) throw new Error("Bitte einen Titel angeben.");
const controls = formData.getAll("controls").map((c) => String(c).trim()).filter(Boolean);
if (controls.length === 0) throw new Error("Bitte mindestens ein Control zuordnen (Pflicht).");
const reqIds = str(formData.get("reqIds")).split(",").map((s) => s.trim()).filter(Boolean);
// Datei über den gekapselten Storage-Adapter (Stub) ablegen — mit vollständiger
// Eingangsvalidierung (F-15), da der clientseitige `f.type` nicht vertrauenswürdig ist.
const file = formData.get("file");
let stored: { storageKey: string; filename: string; size: number } | null = null;
if (file && typeof file === "object" && "arrayBuffer" in file && (file as File).size > 0) {
const f = file as File;
// (1) Größenlimit VOR dem Einlesen in den RAM (DoS-Schutz).
if (f.size > MAX_UPLOAD_BYTES) {
throw new Error(`Datei zu groß (max. ${Math.floor(MAX_UPLOAD_BYTES / (1024 * 1024))} MB).`);
}
// (2) Endungs-Allowlist.
const ext = extOf(f.name);
const kind = ALLOWED_EXT[ext];
if (!kind) {
throw new Error("Dateityp nicht erlaubt. Zulässig: PDF, DOCX, ODT.");
}
// (3) Vom Client gemeldeten MIME-Typ nur als Zusatzsignal prüfen.
const clientMime = (f.type || "").toLowerCase();
if (!ALLOWED_CLIENT_MIME[kind].includes(clientMime)) {
throw new Error("MIME-Typ passt nicht zur Dateiendung.");
}
// (4) Inhalt einlesen und über Magic Bytes verifizieren (maßgeblich).
const bytes = new Uint8Array(await f.arrayBuffer());
if (!magicMatches(kind, bytes)) {
throw new Error("Dateiinhalt passt nicht zum angegebenen Typ.");
}
// Nicht dem clientseitigen `f.type` vertrauen: kanonischen, geprüften MIME speichern.
stored = await storage.put({
tenantId: session.user.tenantId,
filename: f.name,
contentType: CANONICAL_MIME[kind],
bytes,
});
}
// Nächsten freien EIG-Code vergeben.
const count = await db.policyDocument.count({ where: { type: "EIGENES" } });
const code = `EIG-${count + 1}`;
// Titel für die Markdown-Einbettung HTML-kodieren (F-03/F-15): der Titel fließt
// roh in den gespeicherten Rohtext und damit in die Renderpipeline; anders als
// der Dateiname war er bislang unbereinigt. Das DB-Feld `title` bleibt der
// Klartext (React kodiert bei der Anzeige selbst).
const titleMd = escapeHtml(title);
const anchors = controls.map((c) => `<!-- REQ ${c} -->`).join("\n");
const meta = [
`# ${titleMd}`,
"",
`> Eigenes Dokument (Upload).${stored ? ` Datei: ${stored.filename} (${stored.size} Bytes). Storage: ${stored.storageKey}` : " Ohne Datei."}`,
reqIds.length ? `> Zugeordnete Anforderungs-IDs: ${reqIds.join(", ")}` : "",
"",
`Zugeordnete Controls: ${controls.join(", ")}.`,
"",
anchors,
"",
].filter((l) => l !== "").join("\n");
await db.policyDocument.create({
data: {
tenantId: session.user.tenantId,
code, type: "EIGENES", title, status: "ENTWURF",
owner: session.user.id, rawMarkdown: meta, orderIdx: 1000 + count,
},
});
// Control-Mapping → PolicyRequirement-Zeilen (erscheinen in Coverage/Nachweislage).
for (const control of controls) {
await db.policyRequirement.create({
data: {
tenantId: session.user.tenantId,
reqId: `${code}-${control}`, policyCode: code, control,
obligation: "MUSS", condition: null,
requirement: title, implementation: "Eigenes hochgeladenes Dokument.",
vaCodes: [], nachweisLink: stored?.storageKey ?? null,
},
});
}
await writeAuditLog({
tenantId: session.user.tenantId, actorId: session.user.id,
action: "create", entity: "policy_document", entityId: code,
after: { type: "EIGENES", controls, hasFile: Boolean(stored) },
});
revalidatePath("/policies", "layout");
redirect(`/policies/${code}`);
}
+553
View File
@@ -0,0 +1,553 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import type { AssetType, InfoLabel, ProcessCategory } from "@prisma/client";
import { moduleGuard } from "@/server/action-guard";
import type { TenantDb } from "@/server/db";
import { writeAuditLog } from "@/server/audit";
const guard = moduleGuard("bia");
/** Katalog-Vorschläge zu einem übernommenen Prozess (M2, prozessgeführter Flow). */
export interface ProcessCatalogSuggestion {
processId: string;
processName: string;
/** Code des zugrunde liegenden ProcessCatalogEntry (Match über den Namen). */
catalogCode: string;
/** Vorgeschlagene Träger-Asset-Typen (SECONDARY) — Assets-Step. */
suggestedAssetTypes: AssetType[];
/** Vorgeschlagene Klassifizierungslabels primärer Werte — Information-Step. */
suggestedInfoLabels: InfoLabel[];
/** Vorgeschlagene Standard-Risiken (→ RiskCatalogEntry.code) — Risks-Step. */
suggestedRiskCodes: string[];
}
/**
* M2 Strukturanalyse: Lädt die geseedeten Katalog-Vorschläge
* (`ProcessCatalogEntry.suggestedAssetTypes/suggestedInfoLabels/suggestedRiskCodes`)
* zu den bereits ÜBERNOMMENEN Prozessen des Mandanten. Zuordnung Prozess → Katalog
* erfolgt über den Namen (adoptCatalogProcess legt den Prozess unter `entry.name` an).
* Genutzt von den Wizard-Schritten Information/Assets/Risiken, um die Vorschläge als
* Chips/Defaults bzw. Ein-Klick-Übernahme auszuspielen.
*/
export async function getProcessCatalogSuggestions(): Promise<ProcessCatalogSuggestion[]> {
const { db } = await guard("bia:read");
const [processes, catalog] = await Promise.all([
db.process.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true, catalogCode: true } }),
db.processCatalogEntry.findMany({
select: {
code: true,
name: true,
suggestedAssetTypes: true,
suggestedInfoLabels: true,
suggestedRiskCodes: true,
},
}),
]);
// v3A: bevorzugt über catalogCode zuordnen (robust), Name nur als Fallback für Altbestand.
const byCode = new Map(catalog.map((c) => [c.code, c]));
const byName = new Map(catalog.map((c) => [c.name, c]));
return processes.flatMap((p) => {
const entry = (p.catalogCode ? byCode.get(p.catalogCode) : undefined) ?? byName.get(p.name);
if (!entry) return [];
return [
{
processId: p.id,
processName: p.name,
catalogCode: entry.code,
suggestedAssetTypes: entry.suggestedAssetTypes,
suggestedInfoLabels: entry.suggestedInfoLabels,
suggestedRiskCodes: entry.suggestedRiskCodes,
},
];
});
}
const processSchema = z.object({
name: z.string().trim().min(1).max(200),
description: z.string().trim().max(5000).optional(),
category: z.enum(["CORE", "MANAGEMENT", "SUPPORT"]),
ownerId: z.string().optional(),
// TISAX v3A — mehr fachliche Prozess-Informationen (additiv).
purpose: z.string().trim().max(5000).optional(),
parentId: z.string().optional(),
deputyOwnerId: z.string().optional(),
legalBasis: z.string().trim().max(2000).optional(),
interfaces: z.string().trim().max(2000).optional(),
catalogCode: z.string().trim().max(100).optional(),
});
/** Checkbox („on"/fehlt) → Boolean. */
const bool = (v: FormDataEntryValue | null) => v === "on" || v === "true" || v === "1";
function parseProcessForm(formData: FormData) {
const parsed = processSchema.parse({
name: formData.get("name"),
description: formData.get("description") || undefined,
category: formData.get("category"),
ownerId: formData.get("ownerId") || undefined,
purpose: formData.get("purpose") || undefined,
parentId: formData.get("parentId") || undefined,
deputyOwnerId: formData.get("deputyOwnerId") || undefined,
legalBasis: formData.get("legalBasis") || undefined,
interfaces: formData.get("interfaces") || undefined,
catalogCode: formData.get("catalogCode") || undefined,
});
return {
...parsed,
description: parsed.description || null,
ownerId: parsed.ownerId || null,
purpose: parsed.purpose || null,
parentId: parsed.parentId || null,
deputyOwnerId: parsed.deputyOwnerId || null,
legalBasis: parsed.legalBasis || null,
interfaces: parsed.interfaces || null,
catalogCode: parsed.catalogCode || null,
dataProtectionRelevant: bool(formData.get("dataProtectionRelevant")),
prototypeRelevant: bool(formData.get("prototypeRelevant")),
};
}
/** Ein Katalog-Eintrag, wie ihn `adoptEntry` zum Anlegen/Verknüpfen benötigt. */
type AdoptableEntry = { code: string; name: string; category: ProcessCategory };
/**
* Übernimmt EINEN Katalog-Eintrag ins mandanteneigene Register (idempotent über
* catalogCode/Name) und setzt optional `parentId` (Katalog-`parentCode` → Instanz).
* Liefert die Instanz-Id. Interner Helfer für {@link adoptCatalogProcess} — keine
* Server-Action (nicht exportiert).
*/
async function adoptEntry(
db: TenantDb,
actor: { id: string; tenantId: string },
entry: AdoptableEntry,
ownerId: string | null,
parentId: string | null,
): Promise<string> {
// Zuordnung Prozess↔Katalog über catalogCode (Name nur Fallback für Altbestand).
const already = await db.process.findFirst({
where: { OR: [{ catalogCode: entry.code }, { name: entry.name }] },
select: { id: true },
});
if (already) {
await db.process.update({
where: { id: already.id },
data: {
catalogCode: entry.code,
...(ownerId ? { ownerId } : {}),
...(parentId ? { parentId } : {}),
},
});
return already.id;
}
const process = await db.process.create({
data: {
tenantId: actor.tenantId,
name: entry.name,
category: entry.category,
catalogCode: entry.code,
parentId,
ownerId,
createdBy: actor.id,
},
select: { id: true },
});
await writeAuditLog({
tenantId: actor.tenantId,
actorId: actor.id,
action: "create",
entity: "process",
entityId: process.id,
after: { name: entry.name, category: entry.category, fromCatalog: entry.code, parentId },
});
return process.id;
}
/**
* M2 Strukturanalyse: Übernimmt einen Prozess aus dem globalen `ProcessCatalogEntry`
* ins mandanteneigene Register (idempotent je catalogCode). Owner optional. Wird im
* prozessgeführten Wizard-Schritt „Prozesse" genutzt.
*
* TISAX v4B: Beim Übernehmen eines Katalog-Prozesses werden dessen **Teilprozesse**
* (`ProcessCatalogEntry.parentCode == code`) mit angelegt und über `Process.parentId`
* an die übernommene Instanz gehängt (Katalog-`parentCode` → Instanz-`parentId`). Ist
* der übernommene Eintrag selbst ein Teilprozess, wird — sofern der Hauptprozess
* bereits übernommen wurde — dessen Instanz als `parentId` gesetzt.
*/
export async function adoptCatalogProcess(formData: FormData) {
const { session, db } = await guard("bia:write");
const code = z.string().min(1).parse(formData.get("code"));
const ownerId = (formData.get("ownerId") as string) || null;
const actor = { id: session.user.id, tenantId: session.user.tenantId };
const entry = await db.processCatalogEntry.findUnique({ where: { code } });
if (!entry) throw new Error("Katalog-Eintrag nicht gefunden");
// Ist dieser Eintrag ein Teilprozess: bereits übernommene Hauptprozess-Instanz suchen.
const parentInstanceId = entry.parentCode
? (
await db.process.findFirst({
where: { catalogCode: entry.parentCode },
select: { id: true },
})
)?.id ?? null
: null;
const processId = await adoptEntry(db, actor, entry, ownerId, parentInstanceId);
// Teilprozesse des übernommenen Katalog-Prozesses mit anlegen (parentId = Instanz).
const children = await db.processCatalogEntry.findMany({
where: { parentCode: code },
orderBy: { orderIdx: "asc" },
});
for (const child of children) {
await adoptEntry(db, actor, child, null, processId);
}
revalidatePath("/onboarding");
revalidatePath("/processes");
}
export async function createProcess(formData: FormData) {
const { session, db } = await guard("bia:write");
const data = parseProcessForm(formData);
// tenantId explizit für die Typen, der Guard injiziert ohnehin
const process = await db.process.create({
data: { ...data, tenantId: session.user.tenantId, createdBy: session.user.id },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "process",
entityId: process.id,
after: data,
});
revalidatePath("/processes");
revalidatePath("/onboarding");
// Aus dem Prozesshaus (Onboarding) heraus angelegt → direkt ins BIA-Popup springen;
// sonst (Modul) in den Bearbeiten-Dialog. Steuerung über verstecktes `returnTo`.
const returnTo = (formData.get("returnTo") as string | null)?.trim();
if (returnTo === "house") {
redirect(`/onboarding?step=processes&bia=${process.id}&biaStep=1`);
}
redirect(`/processes?edit=${process.id}`);
}
export async function updateProcess(processId: string, formData: FormData) {
const { session, db } = await guard("bia:write");
const before = await db.process.findUnique({ where: { id: processId } });
if (!before) throw new Error("Prozess nicht gefunden");
const data = parseProcessForm(formData);
await db.process.update({ where: { id: processId }, data });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "process",
entityId: processId,
before,
after: data,
});
revalidatePath("/processes");
redirect(`/processes?detail=${processId}`);
}
/**
* Prozess endgültig entfernen (nicht nur `inScope` deaktivieren). Rechte-/Tenant-gegated
* über den Guard; Audit-Log. Verknüpfungen werden sauber behandelt:
* - **Risiken** (`Risk.processId` optional): Prozessbezug wird gelöst (SetNull), die
* Risiken selbst bleiben erhalten.
* - **Teilprozesse** (`parentId`): werden auf die oberste Ebene gehoben statt
* mitgelöscht (kein stiller Datenverlust).
* - **Träger-Zuordnungen** (`ProcessAsset`) und **BIA-Eintrag** (`BiaEntry`): entfernt
* (DB-seitig ohnehin `onDelete: Cascade`, hier zusätzlich explizit).
*
* `returnTo=house` kehrt ins Prozesshaus (Onboarding) zurück, sonst ins Prozess-Modul.
*/
export async function deleteProcess(processId: string, formData?: FormData) {
const { session, db } = await guard("bia:write");
const before = await db.process.findUnique({ where: { id: processId }, include: { bia: true } });
if (!before) throw new Error("Prozess nicht gefunden");
await db.risk.updateMany({ where: { processId }, data: { processId: null } });
await db.process.updateMany({ where: { parentId: processId }, data: { parentId: null } });
await db.processAsset.deleteMany({ where: { processId } });
await db.biaEntry.deleteMany({ where: { processId } });
// Prozess-Abhängigkeiten in beide Richtungen lösen (DB-seitig ohnehin Cascade).
await db.processDependency.deleteMany({
where: { OR: [{ sourceProcessId: processId }, { targetProcessId: processId }] },
});
await db.process.delete({ where: { id: processId } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "process",
entityId: processId,
before,
});
revalidatePath("/processes");
revalidatePath("/onboarding");
const returnTo = (formData?.get("returnTo") as string | null)?.trim();
if (returnTo === "house") redirect("/onboarding?step=processes");
redirect("/processes");
}
export async function assignAsset(processId: string, formData: FormData) {
const { session, db } = await guard("bia:write");
const assetId = z.string().min(1).parse(formData.get("assetId"));
const role = z.enum(["PRIMARY", "SECONDARY"]).parse(formData.get("role"));
const assetCount = await db.asset.count({ where: { id: assetId } });
const processCount = await db.process.count({ where: { id: processId } });
if (assetCount !== 1 || processCount !== 1) throw new Error("Nicht gefunden");
await db.processAsset.upsert({
where: { processId_assetId: { processId, assetId } },
update: { role },
create: { processId, assetId, role, tenantId: session.user.tenantId },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "process_asset",
entityId: processId,
after: { processId, assetId, role },
});
revalidatePath("/processes");
revalidatePath("/onboarding");
}
export async function unassignAsset(processId: string, processAssetId: string) {
const { session, db } = await guard("bia:write");
const before = await db.processAsset.findUnique({ where: { id: processAssetId } });
if (!before) return;
await db.processAsset.delete({ where: { id: processAssetId } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "process_asset",
entityId: processAssetId,
before,
});
revalidatePath("/processes");
revalidatePath("/onboarding");
}
/**
* Strukturierte Prozess-Abhängigkeit anlegen/aktualisieren: `source` benötigt `target`
* (Auswahl innerhalb des Mandanten). Idempotent über das Unique-Paar; Selbstbezug
* ausgeschlossen. Beide Prozesse müssen im Mandanten existieren (RLS + Zählung).
*/
export async function addProcessDependency(sourceProcessId: string, formData: FormData) {
const { session, db } = await guard("bia:write");
const targetProcessId = z.string().min(1).parse(formData.get("targetProcessId"));
const note = (formData.get("note") as string)?.trim() || null;
if (targetProcessId === sourceProcessId) {
throw new Error("Ein Prozess kann nicht von sich selbst abhängen.");
}
const count = await db.process.count({ where: { id: { in: [sourceProcessId, targetProcessId] } } });
if (count !== 2) throw new Error("Prozess nicht gefunden");
await db.processDependency.upsert({
where: { sourceProcessId_targetProcessId: { sourceProcessId, targetProcessId } },
update: { note },
create: { tenantId: session.user.tenantId, sourceProcessId, targetProcessId, note },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "process_dependency",
entityId: sourceProcessId,
after: { sourceProcessId, targetProcessId, note },
});
revalidatePath("/processes");
revalidatePath("/onboarding");
}
/** Prozess-Abhängigkeit wieder entfernen (per Kanten-Id). */
export async function removeProcessDependency(dependencyId: string) {
const { session, db } = await guard("bia:write");
const before = await db.processDependency.findUnique({ where: { id: dependencyId } });
if (!before) return;
await db.processDependency.delete({ where: { id: dependencyId } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "process_dependency",
entityId: dependencyId,
before,
});
revalidatePath("/processes");
revalidatePath("/onboarding");
}
const hours = z
.union([z.literal(""), z.coerce.number().int().min(0).max(100000)])
.transform((v) => (v === "" ? null : v));
export async function saveBia(processId: string, formData: FormData) {
const { session, db } = await guard("bia:write");
const processCount = await db.process.count({ where: { id: processId } });
if (processCount !== 1) throw new Error("Prozess nicht gefunden");
const level = z.coerce.number().int().min(1).max(4);
const data = {
rtoHours: hours.parse(formData.get("rtoHours") ?? ""),
rpoHours: hours.parse(formData.get("rpoHours") ?? ""),
mtdHours: hours.parse(formData.get("mtdHours") ?? ""),
impactC: level.parse(formData.get("impactC")),
impactI: level.parse(formData.get("impactI")),
impactA: level.parse(formData.get("impactA")),
notes: (formData.get("notes") as string)?.trim() || null,
};
// Kritikalität nach Max-Prinzip aus den Schadenshöhen (SPEC §4.1.2)
const criticality = Math.max(data.impactC, data.impactI, data.impactA);
const before = await db.biaEntry.findUnique({ where: { processId } });
await db.biaEntry.upsert({
where: { processId },
update: { ...data, criticality },
create: { ...data, criticality, processId, tenantId: session.user.tenantId },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: before ? "update" : "create",
entity: "bia_entry",
entityId: processId,
before: before ?? undefined,
after: { ...data, criticality },
});
revalidatePath("/processes");
}
/**
* Kombiniertes Speichern für den aufgeräumten Bearbeiten-Dialog: Stammdaten
* und BIA in einem Vorgang (ein Speichern-Button). Asset-Zuordnung läuft
* weiter über die inkrementellen Aktionen assignAsset/unassignAsset.
*/
export async function saveProcessAll(processId: string, formData: FormData) {
const { session, db } = await guard("bia:write");
const before = await db.process.findUnique({
where: { id: processId },
include: { bia: true },
});
if (!before) throw new Error("Prozess nicht gefunden");
const master = parseProcessForm(formData);
await db.process.update({ where: { id: processId }, data: master });
const level = z.coerce.number().int().min(1).max(4);
const biaData = {
rtoHours: hours.parse(formData.get("rtoHours") ?? ""),
rpoHours: hours.parse(formData.get("rpoHours") ?? ""),
mtdHours: hours.parse(formData.get("mtdHours") ?? ""),
impactC: level.parse(formData.get("impactC")),
impactI: level.parse(formData.get("impactI")),
impactA: level.parse(formData.get("impactA")),
notes: (formData.get("notes") as string)?.trim() || null,
};
const criticality = Math.max(biaData.impactC, biaData.impactI, biaData.impactA);
await db.biaEntry.upsert({
where: { processId },
update: { ...biaData, criticality },
create: { ...biaData, criticality, processId, tenantId: session.user.tenantId },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "process",
entityId: processId,
before,
after: { ...master, bia: { ...biaData, criticality } },
});
revalidatePath("/processes");
redirect(`/processes?detail=${processId}`);
}
// ── TISAX v3A: Prozesshaus & BIA-Popup ───────────────────────────────────────
/**
* Prozesshaus-Aktivierung: `inScope` je Prozess umschalten (Kachel-Schalter). Kein
* Redirect — der Nutzer bleibt im Prozesshaus.
*/
export async function toggleProcessScope(processId: string, formData: FormData) {
const { session, db } = await guard("bia:write");
const inScope = bool(formData.get("inScope"));
const before = await db.process.findFirst({ where: { id: processId }, select: { id: true, inScope: true } });
if (!before) throw new Error("Prozess nicht gefunden");
await db.process.update({ where: { id: processId }, data: { inScope } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "process",
entityId: processId,
before: { inScope: before.inScope },
after: { inScope },
});
revalidatePath("/onboarding");
revalidatePath("/processes");
}
/**
* Abschluss des BIA-Popups (Schritt 5): setzt `process.biaStatus` (offen | teilweise |
* komplett) → steuert die Farbkennzeichnung der Prozesshaus-Kachel. Kehrt ins
* Prozesshaus zurück (schließt das Popup).
*/
export async function setBiaStatus(processId: string, formData: FormData) {
const { session, db } = await guard("bia:write");
const biaStatus = z.enum(["offen", "teilweise", "komplett"]).parse(formData.get("biaStatus"));
const before = await db.process.findFirst({ where: { id: processId }, select: { id: true, biaStatus: true } });
if (!before) throw new Error("Prozess nicht gefunden");
await db.process.update({ where: { id: processId }, data: { biaStatus } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "process",
entityId: processId,
before: { biaStatus: before.biaStatus },
after: { biaStatus },
});
revalidatePath("/onboarding");
revalidatePath("/processes");
redirect("/onboarding?step=processes");
}
+92
View File
@@ -0,0 +1,92 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { prisma } from "@/server/db";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { withQuery } from "@/lib/supplier";
const guard = moduleGuard("assets");
const level = z.coerce.number().int().min(1).max(4);
const projectSchema = z.object({
name: z.string().trim().min(1).max(200),
confidentiality: level,
integrity: level,
availability: level,
ownerId: z.string().optional(),
classification: z.string().trim().max(200).optional(),
status: z.enum(["GEPLANT", "LAUFEND", "ABGESCHLOSSEN", "ABGEBROCHEN"]),
isbInvolved: z.string().optional(),
notes: z.string().trim().max(5000).optional(),
});
function parse(formData: FormData) {
const p = projectSchema.parse({
name: formData.get("name"),
confidentiality: formData.get("confidentiality"),
integrity: formData.get("integrity"),
availability: formData.get("availability"),
ownerId: formData.get("ownerId") || undefined,
classification: formData.get("classification") || undefined,
status: formData.get("status") || "GEPLANT",
isbInvolved: formData.get("isbInvolved") || undefined,
notes: formData.get("notes") || undefined,
});
return {
asset: {
name: p.name,
confidentiality: p.confidentiality,
integrity: p.integrity,
availability: p.availability,
ownerId: p.ownerId || null,
},
profile: {
classification: p.classification || null,
status: p.status,
isbInvolved: p.isbInvolved === "on",
notes: p.notes || null,
},
};
}
export async function createProject(formData: FormData) {
const { session, db } = await guard("asset:write");
const { asset, profile } = parse(formData);
const created = await db.asset.create({
data: { ...asset, type: "PROJECT", tenantId: session.user.tenantId, createdBy: session.user.id },
});
const last = await prisma.projectProfile.aggregate({ where: { tenantId: session.user.tenantId }, _max: { refNo: true } });
await db.projectProfile.create({
data: { ...profile, assetId: created.id, refNo: (last._max.refNo ?? 0) + 1, tenantId: session.user.tenantId, createdBy: session.user.id },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "project", entityId: created.id, after: { ...asset, ...profile } });
revalidatePath("/assets");
redirect(`/assets?type=PROJECT&edit=${created.id}`);
}
export async function updateProject(assetId: string, formData: FormData) {
const { session, db } = await guard("asset:write");
const before = await db.asset.findUnique({ where: { id: assetId }, include: { projectProfile: true } });
if (!before) throw new Error("Projekt nicht gefunden");
const { asset, profile } = parse(formData);
await db.asset.update({ where: { id: assetId }, data: asset });
await db.projectProfile.update({ where: { assetId }, data: profile });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "project", entityId: assetId, before, after: { ...asset, ...profile } });
revalidatePath("/assets");
const returnTo = (formData.get("returnTo") as string) || "/assets?type=PROJECT";
redirect(withQuery(returnTo, "detail", assetId));
}
export async function deleteProject(assetId: string, returnTo: string = "/assets?type=PROJECT") {
const { session, db } = await guard("asset:write");
const before = await db.asset.findUnique({ where: { id: assetId } });
if (!before) throw new Error("Projekt nicht gefunden");
await db.asset.delete({ where: { id: assetId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "project", entityId: assetId, before });
revalidatePath("/assets");
redirect(returnTo);
}
+69
View File
@@ -0,0 +1,69 @@
"use server";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
/**
* Editierbare verwaltete Register (WP3.0). Zeilen werden gegen die Pflichtspalten
* der ManagedRegister-Definition gepflegt; optionale Cross-Links an Lieferant/Asset.
* Modul „policies", Recht policy:write.
*/
const guard = moduleGuard("policies");
interface Column { key: string; label: string }
function readValues(columns: Column[], formData: FormData): Record<string, string> {
const values: Record<string, string> = {};
for (const c of columns) values[c.key] = String(formData.get(`col_${c.key}`) ?? "").trim();
return values;
}
const optRef = (v: FormDataEntryValue | null) => {
const s = v ? String(v).trim() : "";
return s || null;
};
export async function addRegisterRow(code: string, formData: FormData) {
const { session, db } = await guard("policy:write");
const reg = await db.managedRegister.findFirst({ where: { code } });
if (!reg) throw new Error("Register nicht gefunden");
const columns = reg.columns as unknown as Column[];
const last = await db.registerRow.aggregate({ where: { registerId: reg.id }, _max: { orderIdx: true } });
await db.registerRow.create({
data: {
tenantId: session.user.tenantId, registerId: reg.id,
values: readValues(columns, formData),
supplierRef: reg.supplierLink ? optRef(formData.get("supplierRef")) : null,
assetRef: reg.assetLink ? optRef(formData.get("assetRef")) : null,
orderIdx: (last._max.orderIdx ?? 0) + 1,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "register_row", entityId: reg.code });
revalidatePath(`/policies/${code}`);
}
export async function updateRegisterRow(rowId: string, formData: FormData) {
const { session, db } = await guard("policy:write");
const row = await db.registerRow.findUnique({ where: { id: rowId }, include: { register: true } });
if (!row) throw new Error("Zeile nicht gefunden");
const columns = row.register.columns as unknown as Column[];
await db.registerRow.update({
where: { id: rowId },
data: {
values: readValues(columns, formData),
supplierRef: row.register.supplierLink ? optRef(formData.get("supplierRef")) : row.supplierRef,
assetRef: row.register.assetLink ? optRef(formData.get("assetRef")) : row.assetRef,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "register_row", entityId: rowId });
revalidatePath(`/policies/${row.register.code}`);
}
export async function deleteRegisterRow(rowId: string) {
const { session, db } = await guard("policy:write");
const row = await db.registerRow.findUnique({ where: { id: rowId }, include: { register: { select: { code: true } } } });
if (!row) return;
await db.registerRow.delete({ where: { id: rowId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "register_row", entityId: rowId });
revalidatePath(`/policies/${row.register.code}`);
}
+234
View File
@@ -0,0 +1,234 @@
"use server";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import type { TenantDb } from "@/server/db";
/**
* AP4 — Managementklauseln (Modul „review"): Kennzahlen (9.1), Managementbewertung
* (9.3) und Nichtkonformität/Korrekturmaßnahme (10.2). moduleGuard("review") stellt das
* aktive Modul sicher; Bearbeitung verlangt `review:manage`.
*/
const guard = moduleGuard("review");
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
const dateOrNull = (v: FormDataEntryValue | null) => {
const s = str(v);
if (!s) return null;
const d = new Date(s);
return isNaN(d.getTime()) ? null : d;
};
async function audit(tenantId: string, actorId: string, action: "create" | "update" | "delete", entity: string, entityId: string, after?: unknown) {
await writeAuditLog({ tenantId, actorId, action, entity, entityId, after: after as object | undefined });
}
// ── 9.1 Kennzahlen ────────────────────────────────────────────────────────────
export async function createKpi(formData: FormData) {
const { session, db } = await guard("review:manage");
const name = str(formData.get("name"));
if (!name) throw new Error("Name der Kennzahl fehlt.");
const kpi = await db.kpi.create({
data: {
tenantId: session.user.tenantId,
name,
description: str(formData.get("description")) || null,
dataSource: str(formData.get("dataSource")) || null,
unit: str(formData.get("unit")) || null,
target: str(formData.get("target")) || null,
cadence: str(formData.get("cadence")) || "monatlich",
ownerId: str(formData.get("ownerId")) || null,
},
select: { id: true },
});
await audit(session.user.tenantId, session.user.id, "create", "kpi", kpi.id, { name });
revalidatePath("/review");
}
export async function updateKpi(kpiId: string, formData: FormData) {
const { session, db } = await guard("review:manage");
await db.kpi.update({
where: { id: kpiId },
data: {
name: str(formData.get("name")),
description: str(formData.get("description")) || null,
dataSource: str(formData.get("dataSource")) || null,
unit: str(formData.get("unit")) || null,
target: str(formData.get("target")) || null,
cadence: str(formData.get("cadence")) || "monatlich",
ownerId: str(formData.get("ownerId")) || null,
active: formData.get("active") !== "false",
},
});
await audit(session.user.tenantId, session.user.id, "update", "kpi", kpiId);
revalidatePath("/review");
}
/** Messwert je Periode erfassen (Upsert über (kpi, period)). */
export async function recordKpiValue(kpiId: string, formData: FormData) {
const { session, db } = await guard("review:manage");
const period = str(formData.get("period"));
const value = str(formData.get("value"));
if (!period || !value) throw new Error("Periode und Wert sind erforderlich.");
await db.kpiValue.upsert({
where: { tenantId_kpiId_period: { tenantId: session.user.tenantId, kpiId, period } },
update: { value, note: str(formData.get("note")) || null, recordedAt: new Date() },
create: { tenantId: session.user.tenantId, kpiId, period, value, note: str(formData.get("note")) || null },
});
await audit(session.user.tenantId, session.user.id, "update", "kpi_value", kpiId, { period, value });
revalidatePath("/review");
}
// ── 9.3 Managementbewertung ───────────────────────────────────────────────────
export async function createManagementReview(formData: FormData) {
const { session, db } = await guard("review:manage");
const review = await db.managementReview.create({
data: {
tenantId: session.user.tenantId,
reviewDate: dateOrNull(formData.get("reviewDate")) ?? new Date(),
inputs: str(formData.get("inputs")) || null,
results: str(formData.get("results")) || null,
ownerId: str(formData.get("ownerId")) || null,
},
select: { id: true },
});
await audit(session.user.tenantId, session.user.id, "create", "management_review", review.id);
revalidatePath("/review");
}
export async function updateManagementReview(reviewId: string, formData: FormData) {
const { session, db } = await guard("review:manage");
const statusRaw = str(formData.get("status"));
await db.managementReview.update({
where: { id: reviewId },
data: {
reviewDate: dateOrNull(formData.get("reviewDate")) ?? undefined,
inputs: str(formData.get("inputs")) || null,
results: str(formData.get("results")) || null,
ownerId: str(formData.get("ownerId")) || null,
status: statusRaw === "abgeschlossen" ? "abgeschlossen" : "entwurf",
},
});
await audit(session.user.tenantId, session.user.id, "update", "management_review", reviewId);
revalidatePath("/review");
}
export async function addReviewDecision(reviewId: string, formData: FormData) {
const { session, db } = await guard("review:manage");
const decision = str(formData.get("decision"));
if (!decision) throw new Error("Beschlusstext fehlt.");
await db.managementReviewDecision.create({
data: {
tenantId: session.user.tenantId,
reviewId,
decision,
ownerId: str(formData.get("ownerId")) || null,
dueDate: dateOrNull(formData.get("dueDate")),
},
});
await audit(session.user.tenantId, session.user.id, "create", "management_review_decision", reviewId);
revalidatePath("/review");
}
export async function setReviewDecisionStatus(decisionId: string, done: boolean) {
const { session, db } = await guard("review:manage");
await db.managementReviewDecision.update({ where: { id: decisionId }, data: { status: done ? "erledigt" : "offen" } });
await audit(session.user.tenantId, session.user.id, "update", "management_review_decision", decisionId, { status: done ? "erledigt" : "offen" });
revalidatePath("/review");
}
// ── 10.2 Nichtkonformität & Korrekturmaßnahme ─────────────────────────────────
/** Nächste Nichtkonformitäts-Kennung „NC-<Jahr>-<lfd>" (mandantengebunden). */
async function nextNcRefNo(db: TenantDb, tenantId: string): Promise<string> {
const year = new Date().getFullYear();
const prefix = `NC-${year}-`;
const rows = await db.nonconformity.findMany({ where: { tenantId, refNo: { startsWith: prefix } }, select: { refNo: true } });
let max = 0;
for (const r of rows) {
const n = parseInt(r.refNo.slice(prefix.length), 10);
if (!isNaN(n) && n > max) max = n;
}
return `${prefix}${String(max + 1).padStart(4, "0")}`;
}
export async function createNonconformity(formData: FormData) {
const { session, db } = await guard("review:manage");
const description = str(formData.get("description"));
const source = str(formData.get("source"));
if (!description || !source) throw new Error("Herkunft und Beschreibung sind erforderlich.");
const refNo = await nextNcRefNo(db, session.user.tenantId);
const nc = await db.nonconformity.create({
data: {
tenantId: session.user.tenantId,
refNo,
source,
description,
immediateCorrection: str(formData.get("immediateCorrection")) || null,
ownerId: str(formData.get("ownerId")) || null,
},
select: { id: true },
});
await audit(session.user.tenantId, session.user.id, "create", "nonconformity", nc.id, { refNo });
revalidatePath("/review");
}
export async function updateNonconformity(ncId: string, formData: FormData) {
const { session, db } = await guard("review:manage");
const statusRaw = str(formData.get("status"));
const status = ["offen", "in_bearbeitung", "abgeschlossen"].includes(statusRaw) ? statusRaw : "offen";
await db.nonconformity.update({
where: { id: ncId },
data: {
source: str(formData.get("source")),
description: str(formData.get("description")),
immediateCorrection: str(formData.get("immediateCorrection")) || null,
ownerId: str(formData.get("ownerId")) || null,
status,
closedAt: status === "abgeschlossen" ? new Date() : null,
},
});
await audit(session.user.tenantId, session.user.id, "update", "nonconformity", ncId, { status });
revalidatePath("/review");
}
export async function addCorrectiveAction(ncId: string, formData: FormData) {
const { session, db } = await guard("review:manage");
const action = str(formData.get("action"));
if (!action) throw new Error("Maßnahmentext fehlt.");
await db.correctiveAction.create({
data: {
tenantId: session.user.tenantId,
nonconformityId: ncId,
action,
rootCause: str(formData.get("rootCause")) || null,
ownerId: str(formData.get("ownerId")) || null,
dueDate: dateOrNull(formData.get("dueDate")),
},
});
await audit(session.user.tenantId, session.user.id, "create", "corrective_action", ncId);
revalidatePath("/review");
}
/** Maßnahme pflegen inkl. dokumentierter Wirksamkeitsprüfung (10.2 d/e). */
export async function updateCorrectiveAction(actionId: string, formData: FormData) {
const { session, db } = await guard("review:manage");
const statusRaw = str(formData.get("status"));
const status = statusRaw === "umgesetzt" ? "umgesetzt" : "geplant";
const effectivenessCheck = str(formData.get("effectivenessCheck")) || null;
const confirmed = formData.get("effectivenessConfirmed") === "on" || formData.get("effectivenessConfirmed") === "true";
await db.correctiveAction.update({
where: { id: actionId },
data: {
action: str(formData.get("action")),
rootCause: str(formData.get("rootCause")) || null,
ownerId: str(formData.get("ownerId")) || null,
dueDate: dateOrNull(formData.get("dueDate")),
status,
effectivenessCheck,
// Wirksamkeit gilt erst als bestätigt, wenn Haken UND eine Bewertung vorliegen.
effectivenessConfirmedAt: confirmed && effectivenessCheck ? new Date() : null,
},
});
await audit(session.user.tenantId, session.user.id, "update", "corrective_action", actionId, { status });
revalidatePath("/review");
}
+242
View File
@@ -0,0 +1,242 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import type { TenantDb } from "@/server/db";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { recomputeResidualRisk } from "@/server/risk-calc";
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
/** Risikowert > 9 (hoch/sehr hoch) erfordert dokumentierte Akzeptanz (VA-09). */
const ACCEPT_THRESHOLD = 9;
/**
* Standard-Risikokatalog (Story A6-1). Übernimmt ein Katalog-Risiko (C4) mit seiner
* Default-Bewertung (E×S) ins mandanteneigene Risk-Register — dort frei anpassbar
* (Bewertung, Behandlung, Maßnahmen → A6-2). Idempotent je `catalogCode`.
*/
const guard = moduleGuard("risk");
/**
* Gemeinsame, idempotente Übernahme eines Katalog-Risikos ins Register. Gibt `true`
* zurück, wenn ein neues Risiko angelegt wurde, `false`, wenn es bereits existierte.
* Kapselt die Logik für den Katalog-Pfad (`adoptCatalogRisk`, mit Redirect) und den
* prozessgeführten Wizard-Pfad (`adoptSuggestedRisk`, ohne Redirect).
*/
async function createRiskFromCatalog(
db: TenantDb,
tenantId: string,
userId: string,
code: string,
processId?: string,
): Promise<boolean> {
const entry = await db.riskCatalogEntry.findUnique({ where: { code } });
if (!entry) throw new Error("Katalog-Risiko nicht gefunden.");
// Im prozessgeführten BIA-Popup wird das Katalog-Risiko JE PROZESS übernommen: schon
// vorhandenes Risiko am selben Prozess = idempotent; an anderem Prozess = eigener
// Eintrag. Ohne Prozess (globaler Katalog-Pfad) bleibt es idempotent je Code.
const existing = await db.risk.findFirst({
where: processId ? { catalogCode: code, processId } : { catalogCode: code },
select: { id: true },
});
if (existing) return false;
const last = await db.risk.aggregate({ _max: { refNo: true } });
const likelihood = entry.defaultLikelihood;
const impact = entry.defaultImpact;
await db.risk.create({
data: {
tenantId,
refNo: (last._max.refNo ?? 0) + 1,
title: entry.title,
description: entry.description,
threat: `Standard-Risikokatalog ${entry.code} (${entry.category})`,
catalogCode: entry.code,
processId: processId ?? null,
likelihood, impact, score: likelihood * impact,
treatment: "MITIGATE",
createdBy: userId,
},
});
await writeAuditLog({ tenantId, actorId: userId, action: "create", entity: "risk", after: { fromCatalog: code, likelihood, impact, processId } });
return true;
}
export async function adoptCatalogRisk(code: string) {
const { session, db } = await guard("risk:write");
const created = await createRiskFromCatalog(db, session.user.tenantId, session.user.id, code);
if (!created) redirect("/risks?catalog=exists");
revalidatePath("/risks");
revalidatePath("/risks/catalog");
redirect("/risks?catalog=adopted");
}
/** Ein Katalog-Vorschlag im Risks-Step, mit Übernahme-Status je Prozess. */
export interface SuggestedRisk {
code: string;
title: string;
category: string;
adopted: boolean;
}
export interface ProcessRiskSuggestion {
processId: string;
processName: string;
risks: SuggestedRisk[];
}
/**
* M2 Risks-Step: löst die `suggestedRiskCodes` der übernommenen Katalog-Prozesse
* (Match über den Prozessnamen) zu den zugehörigen `RiskCatalogEntry` auf und markiert,
* welche bereits ins Register übernommen wurden. Grundlage für die Ein-Klick-Übernahme.
*/
export async function getSuggestedRisksForProcesses(): Promise<ProcessRiskSuggestion[]> {
const { db } = await guard("risk:read");
const [processes, catalog] = await Promise.all([
db.process.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true, catalogCode: true } }),
db.processCatalogEntry.findMany({ select: { code: true, name: true, suggestedRiskCodes: true } }),
]);
// v3A: Zuordnung Prozess↔Katalog bevorzugt über catalogCode (Name nur als Fallback).
const byCatCode = new Map(catalog.map((c) => [c.code, c.suggestedRiskCodes]));
const byCatName = new Map(catalog.map((c) => [c.name, c.suggestedRiskCodes]));
const allCodes = Array.from(new Set(catalog.flatMap((c) => c.suggestedRiskCodes)));
if (allCodes.length === 0) return [];
const [entries, adoptedRows] = await Promise.all([
db.riskCatalogEntry.findMany({
where: { code: { in: allCodes } },
select: { code: true, title: true, category: true },
}),
// Übernahme JE PROZESS bewerten (processId), damit derselbe Katalog-Code an mehreren
// Prozessen unabhängig als „übernommen" markiert werden kann.
db.risk.findMany({ where: { catalogCode: { in: allCodes } }, select: { catalogCode: true, processId: true } }),
]);
const entryByCode = new Map(entries.map((e) => [e.code, e]));
const adoptedByProcess = new Set(
adoptedRows.filter((r) => r.processId).map((r) => `${r.processId}:${r.catalogCode}`),
);
return processes.flatMap((p) => {
const codes = (p.catalogCode ? byCatCode.get(p.catalogCode) : undefined) ?? byCatName.get(p.name);
if (!codes || codes.length === 0) return [];
const risks = codes.flatMap((code) => {
const e = entryByCode.get(code);
if (!e) return [];
return [{ code: e.code, title: e.title, category: e.category, adopted: adoptedByProcess.has(`${p.id}:${code}`) }];
});
if (risks.length === 0) return [];
return [{ processId: p.id, processName: p.name, risks }];
});
}
/**
* Prozessgeführte Ein-Klick-Übernahme eines vorgeschlagenen Katalog-Risikos aus dem
* Wizard (Risks-Step). Wie `adoptCatalogRisk`, aber ohne Redirect — der Nutzer bleibt
* im Wizard. Idempotent je `catalogCode`.
*/
export async function adoptSuggestedRisk(code: string) {
const { session, db } = await guard("risk:write");
await createRiskFromCatalog(db, session.user.tenantId, session.user.id, code);
revalidatePath("/onboarding");
revalidatePath("/risks");
revalidatePath("/risks/catalog");
}
/**
* BIA-Popup Schritt 4: übernimmt ein vorgeschlagenes Katalog-Risiko und verknüpft es
* direkt mit DIESEM Prozess (processId gesetzt). So erscheint es im selben Popup-Schritt
* zur Bewertung. Idempotent je (catalogCode, processId). Kein Redirect (bleibt im Popup).
*/
export async function adoptProcessRisk(processId: string, code: string) {
const { session, db } = await guard("risk:write");
if ((await db.process.count({ where: { id: processId } })) !== 1) {
throw new Error("Prozess nicht gefunden");
}
await createRiskFromCatalog(db, session.user.tenantId, session.user.id, code, processId);
revalidatePath("/onboarding");
revalidatePath("/risks");
}
/**
* Standardmaßnahme eines Katalog-Risikos als echte, mit dem Risiko verknüpfte Maßnahme
* übernehmen (nicht als separate Aufgabe). So erscheint sie im Risiko unter „Maßnahmen"
* und mindert nach Pflege der Minderung das Restrisiko. Idempotent je Risiko/Maßnahmentitel.
*/
export async function adoptStandardMeasure(riskId: string) {
const { session, db } = await guard("risk:write");
// Defense in Depth: tenantId explizit filtern (F-02). Nichtssagende
// Fehlermeldung für den Objekt-Lookup (F-16, Score-/Existenz-Oracle schließen).
const risk = await db.risk.findFirst({
where: { id: riskId, tenantId: session.user.tenantId },
select: { id: true, catalogCode: true, score: true },
});
if (!risk) throw new Error("Vorgang nicht möglich.");
if (!risk.catalogCode) throw new Error("Standardmaßnahme nur für Katalog-Risiken verfügbar.");
const entry = await db.riskCatalogEntry.findUnique({ where: { code: risk.catalogCode } });
if (!entry) throw new Error("Katalog-Eintrag nicht gefunden.");
const title = entry.standardMeasure.slice(0, 200);
const existing = await db.riskMeasure.findFirst({ where: { riskId, measure: { title } }, select: { id: true } });
if (!existing) {
// Transaktionsklammer (F-02): Maßnahme UND Verknüpfung entstehen atomar —
// kein Teilzustand, falls ein Schritt scheitert.
const measure = await db.$transaction(async (tx) => {
const last = await tx.measure.aggregate({ _max: { refNo: true } });
const m = await tx.measure.create({
data: {
tenantId: session.user.tenantId,
refNo: (last._max.refNo ?? 0) + 1,
title,
description: entry.rationale || null,
priority: risk.score >= 10 ? "HIGH" : "MEDIUM",
createdBy: session.user.id,
},
});
await tx.riskMeasure.create({ data: { tenantId: session.user.tenantId, riskId, measureId: m.id, reductionLikelihood: 0, reductionImpact: 0 } });
return m;
});
// Restrisiko-Neuberechnung NACH dem Commit: der Tenant-Guard schreibt
// `findUnique` auf ein `findFirst` des rohen Clients um (F-02, Verhindern statt
// Erkennen); dieser sähe die in der Transaktion frisch angelegte Verknüpfung
// nicht. Erst nach dem Commit liefert der Lesezugriff den konsistenten Stand.
await recomputeResidualRisk(db, riskId);
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "measure", entityId: measure.id, after: { title, linkedRisk: riskId, fromCatalog: entry.code } });
}
revalidatePath("/risks");
revalidatePath("/measures");
redirect(`/risks?detail=${riskId}&measure=1`);
}
/**
* Story A6-2: Risiko akzeptieren (Behandlung ACCEPT). Liegt das (Rest-)Risiko über
* der Akzeptanzschwelle, ist eine dokumentierte Akzeptanz (VA-09) Pflicht.
*/
export async function acceptRisk(riskId: string, formData: FormData) {
const { session, db } = await guard("risk:write");
// Defense in Depth: tenantId explizit filtern (F-02). Der Objekt-Lookup gibt eine
// nichtssagende Meldung zurück — sonst ließe der Unterschied zwischen „nicht
// gefunden" und „über der Akzeptanzschwelle" ein Existenz-/Score-Oracle zu (F-16).
const risk = await db.risk.findFirst({
where: { id: riskId, tenantId: session.user.tenantId },
select: { id: true, score: true, residualScore: true },
});
if (!risk) throw new Error("Vorgang nicht möglich.");
const rationale = str(formData.get("rationale"));
const effScore = risk.residualScore ?? risk.score;
if (effScore > ACCEPT_THRESHOLD && !rationale) {
throw new Error("Restrisiko über der Akzeptanzschwelle — bitte die Akzeptanz dokumentiert begründen (VA-09).");
}
await db.risk.update({
where: { id: riskId },
data: { treatment: "ACCEPT", acceptanceRationale: rationale || null, acceptedById: session.user.id, acceptedAt: new Date() },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "risk", entityId: riskId, after: { treatment: "ACCEPT", effScore, documented: Boolean(rationale) } });
revalidatePath("/risks");
redirect(`/risks?detail=${riskId}&accepted=1`);
}
+368
View File
@@ -0,0 +1,368 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { prisma } from "@/server/db";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { createObjectReviewTask } from "@/server/object-review";
import { syncTaskFromObject } from "@/server/task-sync";
import { riskRef } from "@/lib/risk";
const guard = moduleGuard("risk");
import { recomputeResidualRisk } from "@/server/risk-calc";
const scale = z.coerce.number().int().min(1).max(5);
const riskSchema = z.object({
title: z.string().trim().min(1).max(200),
description: z.string().trim().max(5000).optional(),
threat: z.string().trim().max(500).optional(),
vulnerability: z.string().trim().max(500).optional(),
likelihood: scale,
impact: scale,
treatment: z.enum(["AVOID", "MITIGATE", "TRANSFER", "ACCEPT"]),
status: z.enum(["OPEN", "IN_TREATMENT", "ACCEPTED", "CLOSED"]),
ownerId: z.string().optional(),
processId: z.string().optional(),
});
function parseRiskForm(formData: FormData) {
const parsed = riskSchema.parse({
title: formData.get("title"),
description: formData.get("description") || undefined,
threat: formData.get("threat") || undefined,
vulnerability: formData.get("vulnerability") || undefined,
likelihood: formData.get("likelihood"),
impact: formData.get("impact"),
treatment: formData.get("treatment"),
status: formData.get("status"),
ownerId: formData.get("ownerId") || undefined,
processId: formData.get("processId") || undefined,
});
return {
title: parsed.title,
description: parsed.description || null,
threat: parsed.threat || null,
vulnerability: parsed.vulnerability || null,
likelihood: parsed.likelihood,
impact: parsed.impact,
score: parsed.likelihood * parsed.impact,
treatment: parsed.treatment,
status: parsed.status,
ownerId: parsed.ownerId || null,
processId: parsed.processId || null,
};
}
export async function createRisk(formData: FormData) {
const { session, db } = await guard("risk:write");
const data = parseRiskForm(formData);
// Laufende Nummer je Mandant (MVP: max+1; bei Kollision greift der Unique-Index)
const last = await prisma.risk.aggregate({
where: { tenantId: session.user.tenantId },
_max: { refNo: true },
});
const risk = await db.risk.create({
data: {
...data,
refNo: (last._max.refNo ?? 0) + 1,
tenantId: session.user.tenantId,
createdBy: session.user.id,
},
});
// Optional: direkt aus einem Asset heraus angelegt → Asset sofort verknüpfen
const assetId = formData.get("assetId") as string | null;
if (assetId) {
const assetCount = await db.asset.count({ where: { id: assetId } });
if (assetCount === 1) {
await db.riskAsset.create({
data: { riskId: risk.id, assetId, tenantId: session.user.tenantId },
});
}
}
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "risk",
entityId: risk.id,
after: data,
});
revalidatePath("/risks");
revalidatePath("/assets");
redirect(`/risks?edit=${risk.id}`);
}
export async function updateRisk(riskId: string, formData: FormData) {
const { session, db } = await guard("risk:write");
const before = await db.risk.findUnique({ where: { id: riskId } });
if (!before) throw new Error("Risiko nicht gefunden");
const data = parseRiskForm(formData);
await db.risk.update({ where: { id: riskId }, data });
// Brutto-Bewertung geändert → Rest-Risiko aus den Maßnahmen neu ableiten
await recomputeResidualRisk(db, riskId);
// Cockpit (M3, 2.2): Auto-Completion GEDECKELT — Risiko ACCEPTED/CLOSED schließt die
// verknüpften Aufgaben bis DONE (Restrisiko dokumentiert). „audit-ready" bleibt separat.
if (data.status === "ACCEPTED" || data.status === "CLOSED") {
await syncTaskFromObject(
{ db, tenantId: session.user.tenantId, actorId: session.user.id },
"risk",
{ matchKey: [riskId, riskRef(before.refNo), String(before.refNo)], reached: true },
);
}
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "risk",
entityId: riskId,
before,
after: data,
});
revalidatePath("/risks");
redirect(`/risks?detail=${riskId}`);
}
/**
* BIA-Popup Schritt 4: Inline-Bewertung eines prozessverknüpften Risikos (Eintritts-
* wahrscheinlichkeit × Auswirkung = Wert, Behandlung) — dieselbe Bewertungslogik wie
* `updateRisk`, aber schlank (nur Bewertungsfelder) und mit Rücksprung ins BIA-Popup.
*/
export async function rateProcessRisk(processId: string, riskId: string, formData: FormData) {
const { session, db } = await guard("risk:write");
const before = await db.risk.findFirst({
where: { id: riskId, tenantId: session.user.tenantId },
select: { id: true, likelihood: true, impact: true, treatment: true, status: true },
});
if (!before) throw new Error("Vorgang nicht möglich.");
const data = z
.object({
likelihood: scale,
impact: scale,
treatment: z.enum(["AVOID", "MITIGATE", "TRANSFER", "ACCEPT"]),
status: z.enum(["OPEN", "IN_TREATMENT", "ACCEPTED", "CLOSED"]),
})
.parse({
likelihood: formData.get("likelihood"),
impact: formData.get("impact"),
treatment: formData.get("treatment"),
status: formData.get("status"),
});
await db.risk.update({
where: { id: riskId },
data: { ...data, score: data.likelihood * data.impact, processId },
});
await recomputeResidualRisk(db, riskId);
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "risk",
entityId: riskId,
before,
after: { ...data, score: data.likelihood * data.impact, viaBiaPopup: processId },
});
revalidatePath("/onboarding");
revalidatePath("/risks");
redirect(`/onboarding?step=processes&bia=${processId}&biaStep=4`);
}
/**
* BIA-Popup Schritt 4 (bestehendes Risiko zuordnen): ordnet ein bereits vorhandenes
* Risiko diesem Prozess zu (setzt `processId`). Rücksprung ins Popup. Idempotent.
*/
export async function linkProcessRisk(processId: string, formData: FormData) {
const { session, db } = await guard("risk:write");
const riskId = z.string().min(1).parse(formData.get("riskId"));
const [risk, processCount] = await Promise.all([
db.risk.findFirst({ where: { id: riskId, tenantId: session.user.tenantId }, select: { id: true, processId: true } }),
db.process.count({ where: { id: processId } }),
]);
if (!risk || processCount !== 1) throw new Error("Vorgang nicht möglich.");
await db.risk.update({ where: { id: riskId }, data: { processId } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "risk",
entityId: riskId,
before: { processId: risk.processId },
after: { processId, viaBiaPopup: true },
});
revalidatePath("/onboarding");
revalidatePath("/risks");
redirect(`/onboarding?step=processes&bia=${processId}&biaStep=4`);
}
/**
* BIA-Popup Schritt 4 (neues Risiko anlegen): legt ein neues Risiko an und verknüpft es
* direkt mit DIESEM Prozess — inkl. der vorhandenen Bewertung (Eintritt × Auswirkung =
* Wert, Behandlung, Status). Schlanke Variante von `createRisk`, mit Rücksprung ins Popup.
*/
export async function createProcessRisk(processId: string, formData: FormData) {
const { session, db } = await guard("risk:write");
if ((await db.process.count({ where: { id: processId } })) !== 1) {
throw new Error("Prozess nicht gefunden");
}
const parsed = z
.object({
title: z.string().trim().min(1).max(200),
description: z.string().trim().max(5000).optional(),
likelihood: scale,
impact: scale,
treatment: z.enum(["AVOID", "MITIGATE", "TRANSFER", "ACCEPT"]).default("MITIGATE"),
status: z.enum(["OPEN", "IN_TREATMENT", "ACCEPTED", "CLOSED"]).default("OPEN"),
})
.parse({
title: formData.get("title"),
description: formData.get("description") || undefined,
likelihood: formData.get("likelihood"),
impact: formData.get("impact"),
treatment: formData.get("treatment") || undefined,
status: formData.get("status") || undefined,
});
const last = await prisma.risk.aggregate({
where: { tenantId: session.user.tenantId },
_max: { refNo: true },
});
const risk = await db.risk.create({
data: {
tenantId: session.user.tenantId,
refNo: (last._max.refNo ?? 0) + 1,
title: parsed.title,
description: parsed.description || null,
likelihood: parsed.likelihood,
impact: parsed.impact,
score: parsed.likelihood * parsed.impact,
treatment: parsed.treatment,
status: parsed.status,
processId,
createdBy: session.user.id,
},
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "risk",
entityId: risk.id,
after: { title: parsed.title, score: parsed.likelihood * parsed.impact, processId, viaBiaPopup: true },
});
revalidatePath("/onboarding");
revalidatePath("/risks");
redirect(`/onboarding?step=processes&bia=${processId}&biaStep=4`);
}
export async function deleteRisk(riskId: string) {
const { session, db } = await guard("risk:write");
const before = await db.risk.findUnique({ where: { id: riskId } });
if (!before) throw new Error("Risiko nicht gefunden");
await db.risk.delete({ where: { id: riskId } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "risk",
entityId: riskId,
before,
});
revalidatePath("/risks");
redirect("/risks");
}
export async function addRiskAsset(riskId: string, formData: FormData) {
const { session, db } = await guard("risk:write");
const assetId = z.string().min(1).parse(formData.get("assetId"));
const assetCount = await db.asset.count({ where: { id: assetId } });
const riskCount = await db.risk.count({ where: { id: riskId } });
if (assetCount !== 1 || riskCount !== 1) throw new Error("Nicht gefunden");
await db.riskAsset.upsert({
where: { riskId_assetId: { riskId, assetId } },
update: {},
create: { riskId, assetId, tenantId: session.user.tenantId },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "risk_asset",
entityId: riskId,
after: { riskId, assetId },
});
revalidatePath("/risks");
revalidatePath("/assets");
}
export async function removeRiskAsset(riskId: string, riskAssetId: string) {
const { session, db } = await guard("risk:write");
const before = await db.riskAsset.findUnique({ where: { id: riskAssetId } });
if (!before) return;
await db.riskAsset.delete({ where: { id: riskAssetId } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "risk_asset",
entityId: riskAssetId,
before,
});
revalidatePath("/risks");
revalidatePath("/assets");
}
/**
* Risiko zur Validierung einreichen (Story A3-1, generisches Objekt-Review). Erzeugt
* eine `validation`-Aufgabe an einen berechtigten Validator (Vier-Augen). Der
* Review-Status wird aus der Aufgabe abgeleitet — kein Statusfeld am Risiko.
*/
export async function submitRiskForReview(riskId: string, formData: FormData) {
const { session, db } = await guard("risk:write");
// Defense in Depth: tenantId explizit in die Abfrage, damit die Isolation
// nicht allein am zentralen Guard hängt (F-02). findFirst statt findUnique,
// damit der tenantId-Filter direkt greift. Nichtssagende Fehlermeldung (F-16).
const risk = await db.risk.findFirst({
where: { id: riskId, tenantId: session.user.tenantId },
select: { refNo: true, title: true },
});
if (!risk) throw new Error("Vorgang nicht möglich.");
await createObjectReviewTask(db, {
tenantId: session.user.tenantId,
entityType: "risk",
entityId: riskId,
entityRef: riskRef(risk.refNo),
title: `Validierung: ${riskRef(risk.refNo)} — ${risk.title}`,
approverId: String(formData.get("approverId") ?? "").trim(),
submitterId: session.user.id,
note: (formData.get("note") as string | null)?.trim() || null,
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "risk", entityId: riskId, after: { review: "submitted" } });
revalidatePath("/risks", "layout");
revalidatePath("/tasks");
}
+126
View File
@@ -0,0 +1,126 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { prisma } from "@/server/db";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
const guard = moduleGuard("suppliers");
import { withQuery } from "@/lib/supplier";
const level = z.coerce.number().int().min(1).max(4);
const serviceSchema = z.object({
name: z.string().trim().min(1).max(200),
confidentiality: level,
integrity: level,
availability: level,
criticality: level,
providerAssetId: z.string().optional(),
internal: z.string().optional(),
notes: z.string().trim().max(5000).optional(),
});
function parse(formData: FormData) {
const p = serviceSchema.parse({
name: formData.get("name"),
confidentiality: formData.get("confidentiality"),
integrity: formData.get("integrity"),
availability: formData.get("availability"),
criticality: formData.get("criticality"),
providerAssetId: formData.get("providerAssetId") || undefined,
internal: formData.get("internal") || undefined,
notes: formData.get("notes") || undefined,
});
return {
asset: { name: p.name, confidentiality: p.confidentiality, integrity: p.integrity, availability: p.availability },
profile: {
criticality: p.criticality,
providerAssetId: p.providerAssetId || null,
internal: p.internal === "on",
notes: p.notes || null,
},
};
}
export async function createService(formData: FormData) {
const { session, db } = await guard("supplier:write");
const { asset, profile } = parse(formData);
const created = await db.asset.create({
data: { ...asset, type: "IT_SERVICE", tenantId: session.user.tenantId, createdBy: session.user.id },
});
const last = await prisma.iTServiceProfile.aggregate({ where: { tenantId: session.user.tenantId }, _max: { refNo: true } });
await db.iTServiceProfile.create({
data: { ...profile, assetId: created.id, refNo: (last._max.refNo ?? 0) + 1, tenantId: session.user.tenantId, createdBy: session.user.id },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "it_service", entityId: created.id, after: { ...asset, ...profile } });
revalidatePath("/suppliers");
revalidatePath("/assets");
redirect(`/suppliers?tab=services&edit=${created.id}`);
}
export async function updateService(assetId: string, formData: FormData) {
const { session, db } = await guard("supplier:write");
const before = await db.asset.findUnique({ where: { id: assetId }, include: { serviceProfile: true } });
if (!before) throw new Error("Service nicht gefunden");
const { asset, profile } = parse(formData);
await db.asset.update({ where: { id: assetId }, data: asset });
await db.iTServiceProfile.update({ where: { assetId }, data: profile });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "it_service", entityId: assetId, before, after: { ...asset, ...profile } });
revalidatePath("/suppliers");
revalidatePath("/assets");
const returnTo = (formData.get("returnTo") as string) || "/suppliers?tab=services";
redirect(withQuery(returnTo, "detail", assetId));
}
export async function deleteService(assetId: string, returnTo: string = "/suppliers?tab=services") {
const { session, db } = await guard("supplier:write");
const before = await db.asset.findUnique({ where: { id: assetId } });
if (!before) throw new Error("Service nicht gefunden");
await db.asset.delete({ where: { id: assetId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "it_service", entityId: assetId, before });
revalidatePath("/suppliers");
revalidatePath("/assets");
redirect(returnTo);
}
export async function addRaci(assetId: string, formData: FormData) {
const { session, db } = await guard("supplier:write");
if ((await db.asset.count({ where: { id: assetId } })) !== 1) throw new Error("Nicht gefunden");
await db.serviceControlResponsibility.create({
data: {
assetId,
tenantId: session.user.tenantId,
controlRef: z.string().trim().min(1).parse(formData.get("controlRef")),
title: (formData.get("title") as string)?.trim() || null,
applicable: formData.get("applicable") !== "off",
responsibility: z.enum(["PROVIDER", "US", "SHARED"]).parse(formData.get("responsibility") ?? "SHARED"),
evidenceRef: (formData.get("evidenceRef") as string)?.trim() || null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "service_raci", entityId: assetId });
revalidatePath("/suppliers");
revalidatePath("/assets");
}
/** Verantwortung einer RACI-Zeile per Klick ändern (PROVIDER/US/SHARED durchschalten). */
export async function cycleRaci(assetId: string, raciId: string) {
const { session, db } = await guard("supplier:write");
const row = await db.serviceControlResponsibility.findUnique({ where: { id: raciId } });
if (!row) return;
const next = { PROVIDER: "US", US: "SHARED", SHARED: "PROVIDER" } as const;
await db.serviceControlResponsibility.update({ where: { id: raciId }, data: { responsibility: next[row.responsibility] } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "service_raci", entityId: assetId, after: { responsibility: next[row.responsibility] } });
revalidatePath("/suppliers");
revalidatePath("/assets");
}
export async function deleteRaci(assetId: string, raciId: string) {
const { session, db } = await guard("supplier:write");
await db.serviceControlResponsibility.delete({ where: { id: raciId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "service_raci", entityId: assetId });
revalidatePath("/suppliers");
revalidatePath("/assets");
}
+52
View File
@@ -0,0 +1,52 @@
"use server";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { SOA_STATUS } from "@/lib/soa";
/**
* AP3 — Pflege der Anwendbarkeitserklärung (SoA). Der ISB bearbeitet je Control die
* normativen Pflichtangaben (Anwendbarkeit + Begründung/Ausschluss + Umsetzungsstatus)
* sowie Herkunft, Verantwortlichen, Richtlinie und Nachweis. moduleGuard("soa") stellt
* das aktive Modul sicher; die Bearbeitung verlangt `soa:write`. Die Vorbefüllung der
* 93 Controls erledigt der Seiten-Load idempotent (ensureSoaEntries).
*/
const guard = moduleGuard("soa");
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
export async function updateSoaEntry(entryId: string, formData: FormData) {
const { session, db } = await guard("soa:write");
// RLS stellt die Mandantenbindung sicher; existierende Zeile laden (für Audit-Kontext).
const entry = await db.soaEntry.findUnique({ where: { id: entryId }, select: { id: true, control: true } });
if (!entry) throw new Error("SoA-Eintrag nicht gefunden.");
const applicable = formData.get("applicable") === "on" || formData.get("applicable") === "true";
const statusRaw = str(formData.get("implementationStatus"));
const implementationStatus = (SOA_STATUS as readonly string[]).includes(statusRaw) ? statusRaw : "geplant";
await db.soaEntry.update({
where: { id: entryId },
data: {
applicable,
justification: str(formData.get("justification")),
source: str(formData.get("source")) || null,
implementationStatus,
ownerId: str(formData.get("ownerId")) || null,
policyCode: str(formData.get("policyCode")) || null,
evidenceId: str(formData.get("evidenceId")) || null,
},
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "soa_entry",
entityId: entryId,
after: { control: entry.control, applicable, implementationStatus },
});
revalidatePath("/soa");
}
+185
View File
@@ -0,0 +1,185 @@
"use server";
import { revalidatePath } from "next/cache";
import type { ObjectReviewStatus } from "@prisma/client";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { buildControlRows } from "@/server/soa-context";
import { domainForControl, assignTaskParticipants } from "@/server/control-domain";
import { syncTaskFromObject } from "@/server/task-sync";
/**
* Control-Assessment (Story A7) — Server-Aktionen für Schritt 7 „controls".
* Bearbeiter (`onboarding:use`): Reifegrad je Control bestätigen/überschreiben
* (Pflichtbegründung bei Absenkung unter den Vorschlag, C5 §4.6), alle offenen
* Vorschläge übernehmen und Gap-Aufgaben (C5 §4) anlegen. Der Reifegrad-Vorschlag
* selbst ist reine Logik (src/lib/maturity.ts) und wird hier nur persistiert.
*/
const guard = moduleGuard("onboarding");
type Db = Awaited<ReturnType<typeof guard>>["db"];
async function nudgeStep(db: Db, tenantId: string) {
const prog = await db.onboardingProgress.findUnique({ where: { tenantId_stepKey: { tenantId, stepKey: "controls" } } });
if (!prog || prog.status === "offen") {
const status: ObjectReviewStatus = "in_bearbeitung";
await db.onboardingProgress.upsert({
where: { tenantId_stepKey: { tenantId, stepKey: "controls" } },
update: { status },
create: { tenantId, stepKey: "controls", status },
});
}
}
/** Einen Control-Reifegrad bestätigen oder überschreiben (Pflichtbestätigung, C5 §5-Vier-Augen). */
export async function confirmControlMaturity(formData: FormData) {
const { session, db } = await guard("onboarding:use");
const control = String(formData.get("control") ?? "");
const value = Number(formData.get("value"));
const justification = String(formData.get("justification") ?? "").trim() || null;
if (!Number.isInteger(value) || value < 0 || value > 3) throw new Error("Ungültiger Reifegrad (0–3).");
const { rows } = await buildControlRows(db, session.user.tenantId);
const row = rows.find((r) => r.control === control);
if (!row) throw new Error("Control liegt nicht im aktuellen Scope.");
// Absenkung unter den Vorschlag erfordert eine Begründung (C5 §4.6).
if (value < row.suggestion.value && !justification) {
throw new Error("Absenkung unter den Vorschlag erfordert eine Begründung.");
}
await db.controlAssessment.upsert({
where: { tenantId_control: { tenantId: session.user.tenantId, control } },
update: { suggested: row.suggestion.value, confirmedValue: value, target: row.target, justification, confirmedById: session.user.id },
create: { tenantId: session.user.tenantId, control, suggested: row.suggestion.value, confirmedValue: value, target: row.target, justification, confirmedById: session.user.id },
});
await nudgeStep(db, session.user.tenantId);
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "control_assessment", entityId: control, after: { confirmedValue: value, suggested: row.suggestion.value, target: row.target } });
revalidatePath("/onboarding");
revalidatePath("/dashboard");
}
/** Alle noch nicht bestätigten Controls mit ihrem Reifegrad-Vorschlag bestätigen. */
export async function confirmAllSuggestions() {
const { session, db } = await guard("onboarding:use");
const { rows } = await buildControlRows(db, session.user.tenantId);
let count = 0;
for (const row of rows) {
if (row.confirmed !== null) continue;
await db.controlAssessment.upsert({
where: { tenantId_control: { tenantId: session.user.tenantId, control: row.control } },
update: { suggested: row.suggestion.value, confirmedValue: row.suggestion.value, target: row.target, confirmedById: session.user.id },
create: { tenantId: session.user.tenantId, control: row.control, suggested: row.suggestion.value, confirmedValue: row.suggestion.value, target: row.target, confirmedById: session.user.id },
});
count++;
}
await nudgeStep(db, session.user.tenantId);
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "control_assessment", after: { bulkConfirmed: count } });
revalidatePath("/onboarding");
revalidatePath("/dashboard");
}
/** Aus den offenen Punkten (C5 §4) idempotente Aufgaben je Control/Belegklasse anlegen. */
export async function createControlGapTasks() {
const { session, db } = await guard("onboarding:use");
const { rows } = await buildControlRows(db, session.user.tenantId);
const gaps = rows.flatMap((r) => r.gaps);
let created = 0;
for (const g of gaps) {
const origin = `wizard:control-gap:${g.control}:${g.kind}`;
const exists = await db.task.findFirst({ where: { origin, status: { in: ["PROPOSED", "OPEN"] } }, select: { id: true } });
if (exists) continue;
// Cockpit (M3, 1.4): Bereich aus Kapitel→Domain-Mapping ableiten.
const domain = await domainForControl(db, session.user.tenantId, g.control);
const task = await db.task.create({
data: {
tenantId: session.user.tenantId,
type: "organizational",
title: g.action,
origin,
status: "PROPOSED",
priority: g.kind === "widerspruch" ? "hoch" : "mittel",
createdById: session.user.id,
domain,
links: { control: g.control },
},
});
// Cockpit (M3, 2.3): RACI-Mitwirkende automatisch aus Control/Domain befüllen.
await assignTaskParticipants(db, session.user.tenantId, { taskId: task.id, control: g.control, domain });
created++;
}
await nudgeStep(db, session.user.tenantId);
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "control_gap_check", after: { gaps: gaps.length, created } });
revalidatePath("/onboarding");
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
const IMPL_STATUS = new Set(["offen", "in_umsetzung", "erledigt"]);
/**
* Umsetzungsstatus eines Umsetzungshinweises (Spiegelstrich) dokumentieren (#10):
* offen / in Umsetzung / erledigt (+ Notiz). „erledigt" aller relevanten Hinweise eines
* Controls zählt als operativer Nachweis und hebt den Reifegrad-Vorschlag.
*/
export async function setImplementationStatus(reqId: string, formData: FormData) {
const { session, db } = await guard("onboarding:use");
const hint = await db.implementationHint.findUnique({ where: { reqId }, select: { control: true } });
if (!hint) throw new Error("Umsetzungshinweis nicht gefunden.");
const statusRaw = String(formData.get("status") ?? "");
const status = IMPL_STATUS.has(statusRaw) ? statusRaw : "offen";
const note = String(formData.get("note") ?? "").trim() || null;
await db.controlImplementation.upsert({
where: { tenantId_reqId: { tenantId: session.user.tenantId, reqId } },
update: { status, note, control: hint.control, updatedById: session.user.id },
create: { tenantId: session.user.tenantId, reqId, control: hint.control, status, note, updatedById: session.user.id },
});
// Cockpit (M3, 2.2): Auto-Completion GEDECKELT — „erledigt" schließt die dem
// Control zugeordneten Aufgaben bis DONE (umgesetzt). Reifegrad-Anhebung bleibt
// ein separater, manuell zu bestätigender Schritt („Existenz ≠ Wirksamkeit").
if (status === "erledigt") {
await syncTaskFromObject(
{ db, tenantId: session.user.tenantId, actorId: session.user.id },
"control",
{ matchKey: hint.control, reached: true },
);
}
await nudgeStep(db, session.user.tenantId);
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "control_implementation", entityId: reqId, after: { status } });
revalidatePath("/onboarding");
revalidatePath("/dashboard");
}
/** Aus einem offenen Umsetzungshinweis eine Aufgabe erzeugen (idempotent je reqId). */
export async function createImplementationTask(reqId: string) {
const { session, db } = await guard("onboarding:use");
const hint = await db.implementationHint.findUnique({ where: { reqId } });
if (!hint) throw new Error("Umsetzungshinweis nicht gefunden.");
const origin = `hint:${reqId}`;
const exists = await db.task.findFirst({ where: { origin, status: { in: ["PROPOSED", "OPEN"] } }, select: { id: true } });
if (!exists) {
const domain = await domainForControl(db, session.user.tenantId, hint.control);
const task = await db.task.create({
data: {
tenantId: session.user.tenantId,
type: "technical",
title: `Umsetzung ${hint.control}: ${hint.requirement.slice(0, 90)}`,
description: `Organisatorisch: ${hint.organisational} | Technisch: ${hint.technical} | Nachweise: ${hint.evidence}`,
origin,
status: "PROPOSED",
priority: "mittel",
createdById: session.user.id,
domain,
links: { control: hint.control },
},
});
// Cockpit (M3, 2.3): RACI-Mitwirkende automatisch aus Control/Domain befüllen.
await assignTaskParticipants(db, session.user.tenantId, { taskId: task.id, control: hint.control, domain });
}
await nudgeStep(db, session.user.tenantId);
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "control_impl_task", entityId: reqId, after: { origin } });
revalidatePath("/onboarding");
revalidatePath("/tasks");
}
+96
View File
@@ -0,0 +1,96 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { prisma } from "@/server/db";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { withQuery } from "@/lib/supplier";
const guard = moduleGuard("suppliers");
const level = z.coerce.number().int().min(1).max(4);
const softwareSchema = z.object({
name: z.string().trim().min(1).max(200),
confidentiality: level,
integrity: level,
availability: level,
criticality: level,
providerAssetId: z.string().optional(),
version: z.string().trim().max(100).optional(),
approvalStatus: z.enum(["BEANTRAGT", "FREIGEGEBEN", "GESPERRT"]),
approvedBy: z.string().trim().max(200).optional(),
nextReview: z.string().optional(),
notes: z.string().trim().max(5000).optional(),
});
function parse(formData: FormData) {
const p = softwareSchema.parse({
name: formData.get("name"),
confidentiality: formData.get("confidentiality"),
integrity: formData.get("integrity"),
availability: formData.get("availability"),
criticality: formData.get("criticality"),
providerAssetId: formData.get("providerAssetId") || undefined,
version: formData.get("version") || undefined,
approvalStatus: formData.get("approvalStatus") || "BEANTRAGT",
approvedBy: formData.get("approvedBy") || undefined,
nextReview: formData.get("nextReview") || undefined,
notes: formData.get("notes") || undefined,
});
return {
asset: { name: p.name, confidentiality: p.confidentiality, integrity: p.integrity, availability: p.availability },
profile: {
criticality: p.criticality,
providerAssetId: p.providerAssetId || null,
version: p.version || null,
approvalStatus: p.approvalStatus,
approvedBy: p.approvedBy || null,
nextReview: p.nextReview ? new Date(p.nextReview) : null,
notes: p.notes || null,
},
};
}
export async function createSoftware(formData: FormData) {
const { session, db } = await guard("supplier:write");
const { asset, profile } = parse(formData);
const created = await db.asset.create({
data: { ...asset, type: "SOFTWARE", tenantId: session.user.tenantId, createdBy: session.user.id },
});
const last = await prisma.softwareProfile.aggregate({ where: { tenantId: session.user.tenantId }, _max: { refNo: true } });
await db.softwareProfile.create({
data: { ...profile, assetId: created.id, refNo: (last._max.refNo ?? 0) + 1, tenantId: session.user.tenantId, createdBy: session.user.id },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "software", entityId: created.id, after: { ...asset, ...profile } });
revalidatePath("/suppliers");
revalidatePath("/assets");
redirect(`/suppliers?tab=software&edit=${created.id}`);
}
export async function updateSoftware(assetId: string, formData: FormData) {
const { session, db } = await guard("supplier:write");
const before = await db.asset.findUnique({ where: { id: assetId }, include: { softwareProfile: true } });
if (!before) throw new Error("Software nicht gefunden");
const { asset, profile } = parse(formData);
await db.asset.update({ where: { id: assetId }, data: asset });
await db.softwareProfile.update({ where: { assetId }, data: profile });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "software", entityId: assetId, before, after: { ...asset, ...profile } });
revalidatePath("/suppliers");
revalidatePath("/assets");
const returnTo = (formData.get("returnTo") as string) || "/suppliers?tab=software";
redirect(withQuery(returnTo, "detail", assetId));
}
export async function deleteSoftware(assetId: string, returnTo: string = "/suppliers?tab=software") {
const { session, db } = await guard("supplier:write");
const before = await db.asset.findUnique({ where: { id: assetId } });
if (!before) throw new Error("Software nicht gefunden");
await db.asset.delete({ where: { id: assetId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "software", entityId: assetId, before });
revalidatePath("/suppliers");
revalidatePath("/assets");
redirect(returnTo);
}
+446
View File
@@ -0,0 +1,446 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { Prisma } from "@prisma/client";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { normalizeAssetName } from "@/lib/normalize-asset";
/**
* M2 Strukturanalyse (Ebene 2) — prozessgeführte Erhebung.
*
* Kernprinzip (nicht verletzen): Ein Informationswert ist KEIN neues Objekt, sondern
* ein `Asset` mit `type = INFORMATION|DATA` (primärer Wert). Diese Actions erfassen
* primäre Informations-Assets über den Prozess — mit Dedup + Autovervollständigung
* (§2.1) — und übernehmen Prozesse aus dem globalen `ProcessCatalogEntry`.
*
* Träger-Assets (SECONDARY) laufen weiter über `assignAsset` in
* `src/server/actions/processes.ts`; Schutzbedarf C/I/A bleibt am Asset.
*/
const guard = moduleGuard("assets");
const INFO_TYPES = ["INFORMATION", "DATA"] as const;
const INFO_LABELS = ["NONE", "INFO_HIGH", "INFO_VERY_HIGH", "PROTOTYPE", "PERSONAL_DATA"] as const;
export interface AssetSuggestion {
id: string;
name: string;
type: (typeof INFO_TYPES)[number];
label: string;
normalizedName: string | null;
/** Prozessnamen, in denen dieses Asset bereits erfasst ist ("bereits erfasst in Prozess X"). */
processes: string[];
}
/**
* As-you-type-Suche für die Combobox (§2.1). Liefert bestehende primäre
* Informations-Assets (type INFORMATION/DATA) des Mandanten, deren normalisierter
* Name den (ebenfalls normalisierten) Query enthält, inkl. der Prozesse, in denen
* sie bereits verknüpft sind. Der Client rechnet darauf die weiche Fuzzy-Warnung
* (Levenshtein) — die harte Exakt-Dedup erzwingt die DB (@@unique).
*/
export async function searchAssets(query: string): Promise<AssetSuggestion[]> {
const { db } = await guard("asset:read");
const norm = normalizeAssetName(query ?? "");
if (norm.length < 2) return [];
const rows = await db.asset.findMany({
where: {
type: { in: [...INFO_TYPES] },
OR: [
{ normalizedName: { contains: norm } },
// Fallback für Altbestand ohne gesetzten normalizedName.
{ name: { contains: query.trim(), mode: "insensitive" } },
],
},
select: {
id: true,
name: true,
type: true,
label: true,
normalizedName: true,
processAssets: { select: { process: { select: { name: true } } } },
},
orderBy: { name: "asc" },
take: 12,
});
return rows.map((r) => ({
id: r.id,
name: r.name,
type: r.type as (typeof INFO_TYPES)[number],
label: r.label,
normalizedName: r.normalizedName,
processes: r.processAssets.map((pa) => pa.process.name),
}));
}
const newInfoSchema = z.object({
name: z.string().trim().min(1).max(200),
type: z.enum(INFO_TYPES).default("INFORMATION"),
label: z.enum(INFO_LABELS).default("NONE"),
});
/**
* Verknüpft ein bestehendes Asset als PRIMÄREN Informationswert mit dem Prozess
* (ProcessAsset role=PRIMARY) — der „ein Klick verknüpft"-Pfad der Autocomplete.
*/
export async function linkPrimaryInformation(processId: string, formData: FormData) {
const { session, db } = await guard("asset:write");
const assetId = z.string().min(1).parse(formData.get("assetId"));
const [assetCount, processCount] = await Promise.all([
db.asset.count({ where: { id: assetId, type: { in: [...INFO_TYPES] } } }),
db.process.count({ where: { id: processId } }),
]);
if (assetCount !== 1 || processCount !== 1) throw new Error("Nicht gefunden");
await db.processAsset.upsert({
where: { processId_assetId: { processId, assetId } },
update: { role: "PRIMARY" },
create: { processId, assetId, role: "PRIMARY", tenantId: session.user.tenantId },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "process_asset",
entityId: processId,
after: { processId, assetId, role: "PRIMARY", via: "m2-link-primary" },
});
revalidatePath("/onboarding");
revalidatePath("/processes");
}
/**
* Erfasst einen NEUEN primären Informationswert am Prozess — MIT Exakt-Dedup:
* Der normalisierte Name (§2.1) ist über `@@unique([tenantId, normalizedName])`
* eindeutig. Existiert bereits ein Asset mit gleichem Schlüssel, wird es VERKNÜPFT
* statt neu angelegt (auch bei Groß-/Kleinschreibung, Umlaut-Faltung, Satzzeichen).
* Der Unique-Constraint fängt zusätzlich Wettläufe (P2002) ab.
*/
export async function addPrimaryInformation(processId: string, formData: FormData) {
const { session, db } = await guard("asset:write");
const tenantId = session.user.tenantId;
const data = newInfoSchema.parse({
name: formData.get("name"),
type: formData.get("type") || undefined,
label: formData.get("label") || undefined,
});
const normalizedName = normalizeAssetName(data.name);
if (!normalizedName) throw new Error("Name ergibt keinen gültigen Wert.");
if ((await db.process.count({ where: { id: processId } })) !== 1) {
throw new Error("Prozess nicht gefunden");
}
// 1) Exakt-Dedup: bestehendes (primäres) Asset mit gleichem Schlüssel verknüpfen.
const existing = await db.asset.findFirst({
where: { normalizedName, type: { in: [...INFO_TYPES] } },
select: { id: true },
});
let assetId = existing?.id;
let created = false;
if (!assetId) {
try {
const asset = await db.asset.create({
data: {
tenantId,
name: data.name,
type: data.type,
label: data.label,
normalizedName,
createdBy: session.user.id,
},
select: { id: true },
});
assetId = asset.id;
created = true;
} catch (e) {
// Wettlauf: paralleler Insert hat den Schlüssel belegt → bestehendes verknüpfen.
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
const race = await db.asset.findFirst({
where: { normalizedName, type: { in: [...INFO_TYPES] } },
select: { id: true },
});
if (!race) throw e;
assetId = race.id;
} else {
throw e;
}
}
}
await db.processAsset.upsert({
where: { processId_assetId: { processId, assetId: assetId! } },
update: { role: "PRIMARY" },
create: { processId, assetId: assetId!, role: "PRIMARY", tenantId },
});
await writeAuditLog({
tenantId,
actorId: session.user.id,
action: created ? "create" : "update",
entity: created ? "asset" : "process_asset",
entityId: assetId!,
after: { name: data.name, type: data.type, label: data.label, normalizedName, processId, deduped: !created },
});
revalidatePath("/onboarding");
revalidatePath("/processes");
revalidatePath("/assets");
}
const level = z.coerce.number().int().min(1).max(4);
// Träger-/Sekundär-Assets sind alle Asset-Typen AUSSER den primären Informationswerten
// (INFORMATION/DATA). Default SYSTEM (häufigster Träger).
const CARRIER_TYPES = ["SYSTEM", "APPLICATION", "LOCATION", "SUPPLIER", "IT_SERVICE", "SOFTWARE", "PROJECT", "PERSON"] as const;
/**
* BIA-Popup Schritt 2 (Träger neu anlegen): erfasst ein NEUES Träger-Asset über die
* volle Asset-Inventar-Maske (AssetForm) und verknüpft es als SECONDARY mit dem Prozess.
* Analog zu `createPrimaryInformationAsset`, aber für Träger-Typen (kein INFORMATION/DATA).
* Exakt-Dedup über den normalisierten Namen: gleicher Schlüssel → verknüpfen statt doppeln.
*/
export async function createSecondaryCarrierAsset(processId: string, formData: FormData) {
const { session, db } = await guard("asset:write");
const tenantId = session.user.tenantId;
const schema = z.object({
name: z.string().trim().min(1).max(200),
description: z.string().trim().max(5000).optional(),
type: z.enum(CARRIER_TYPES).default("SYSTEM"),
status: z.enum(["ACTIVE", "PLANNED", "RETIRED"]).default("ACTIVE"),
ownerId: z.string().optional(),
location: z.string().trim().max(200).optional(),
tags: z.string().optional(),
confidentiality: level,
integrity: level,
availability: level,
});
const data = schema.parse({
name: formData.get("name"),
description: formData.get("description") || undefined,
type: formData.get("type") || undefined,
status: formData.get("status") || undefined,
ownerId: formData.get("ownerId") || undefined,
location: formData.get("location") || undefined,
tags: formData.get("tags") || undefined,
confidentiality: formData.get("confidentiality"),
integrity: formData.get("integrity"),
availability: formData.get("availability"),
});
const normalizedName = normalizeAssetName(data.name);
if (!normalizedName) throw new Error("Name ergibt keinen gültigen Wert.");
if ((await db.process.count({ where: { id: processId } })) !== 1) {
throw new Error("Prozess nicht gefunden");
}
// Exakt-Dedup über den Mandanten-Schlüssel (@@unique tenantId,normalizedName): ein
// bestehendes Asset gleichen Namens (beliebigen Typs) wird als Träger verknüpft.
const existing = await db.asset.findFirst({
where: { normalizedName },
select: { id: true },
});
let assetId = existing?.id;
let created = false;
if (!assetId) {
try {
const asset = await db.asset.create({
data: {
tenantId,
name: data.name,
description: data.description || null,
type: data.type,
status: data.status,
ownerId: data.ownerId || null,
location: data.location || null,
tags: data.tags ? data.tags.split(",").map((t) => t.trim()).filter(Boolean) : [],
confidentiality: data.confidentiality,
integrity: data.integrity,
availability: data.availability,
normalizedName,
createdBy: session.user.id,
},
select: { id: true },
});
assetId = asset.id;
created = true;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
const race = await db.asset.findFirst({ where: { normalizedName }, select: { id: true } });
if (!race) throw e;
assetId = race.id;
} else {
throw e;
}
}
}
await db.processAsset.upsert({
where: { processId_assetId: { processId, assetId: assetId! } },
update: { role: "SECONDARY" },
create: { processId, assetId: assetId!, role: "SECONDARY", tenantId },
});
await writeAuditLog({
tenantId,
actorId: session.user.id,
action: created ? "create" : "update",
entity: created ? "asset" : "process_asset",
entityId: assetId!,
after: { name: data.name, type: data.type, carrierFor: processId, deduped: !created },
});
revalidatePath("/onboarding");
revalidatePath("/processes");
revalidatePath("/assets");
}
/**
* BIA-Popup Schritt 1 (manueller Pfad): erfasst einen primären Informationswert über
* die VOLLE Asset-Inventar-Maske (AssetForm) und verknüpft ihn als PRIMARY mit dem
* Prozess. Der Werttyp ist auf INFORMATION/DATA beschränkt (primärer Wert). Exakt-Dedup
* wie bei `addPrimaryInformation`: gleicher normalisierter Name → verknüpfen statt doppeln.
*/
export async function createPrimaryInformationAsset(processId: string, formData: FormData) {
const { session, db } = await guard("asset:write");
const tenantId = session.user.tenantId;
const schema = z.object({
name: z.string().trim().min(1).max(200),
description: z.string().trim().max(5000).optional(),
type: z.enum(INFO_TYPES).default("INFORMATION"),
label: z.enum(INFO_LABELS).default("NONE"),
status: z.enum(["ACTIVE", "PLANNED", "RETIRED"]).default("ACTIVE"),
ownerId: z.string().optional(),
location: z.string().trim().max(200).optional(),
tags: z.string().optional(),
confidentiality: level,
integrity: level,
availability: level,
});
const data = schema.parse({
name: formData.get("name"),
description: formData.get("description") || undefined,
type: formData.get("type") || undefined,
label: formData.get("label") || undefined,
status: formData.get("status") || undefined,
ownerId: formData.get("ownerId") || undefined,
location: formData.get("location") || undefined,
tags: formData.get("tags") || undefined,
confidentiality: formData.get("confidentiality"),
integrity: formData.get("integrity"),
availability: formData.get("availability"),
});
const normalizedName = normalizeAssetName(data.name);
if (!normalizedName) throw new Error("Name ergibt keinen gültigen Wert.");
if ((await db.process.count({ where: { id: processId } })) !== 1) {
throw new Error("Prozess nicht gefunden");
}
const existing = await db.asset.findFirst({
where: { normalizedName, type: { in: [...INFO_TYPES] } },
select: { id: true },
});
let assetId = existing?.id;
let created = false;
if (!assetId) {
try {
const asset = await db.asset.create({
data: {
tenantId,
name: data.name,
description: data.description || null,
type: data.type,
status: data.status,
label: data.label,
ownerId: data.ownerId || null,
location: data.location || null,
tags: data.tags ? data.tags.split(",").map((t) => t.trim()).filter(Boolean) : [],
confidentiality: data.confidentiality,
integrity: data.integrity,
availability: data.availability,
normalizedName,
createdBy: session.user.id,
},
select: { id: true },
});
assetId = asset.id;
created = true;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
const race = await db.asset.findFirst({
where: { normalizedName, type: { in: [...INFO_TYPES] } },
select: { id: true },
});
if (!race) throw e;
assetId = race.id;
} else {
throw e;
}
}
}
await db.processAsset.upsert({
where: { processId_assetId: { processId, assetId: assetId! } },
update: { role: "PRIMARY" },
create: { processId, assetId: assetId!, role: "PRIMARY", tenantId },
});
await writeAuditLog({
tenantId,
actorId: session.user.id,
action: created ? "create" : "update",
entity: created ? "asset" : "process_asset",
entityId: assetId!,
after: { name: data.name, type: data.type, primaryFor: processId, deduped: !created },
});
revalidatePath("/onboarding");
revalidatePath("/processes");
revalidatePath("/assets");
}
/**
* BIA-Popup Schritt 3: Schutzbedarf C/I/A direkt am (primären) Informations-Asset
* setzen. Träger erben per Maximum-Prinzip (Anzeige/Analyse), der echte Wert liegt am
* primären Wert. Kein Redirect — der Nutzer bleibt im Popup-Schritt.
*/
export async function saveInfoProtection(assetId: string, formData: FormData) {
const { session, db } = await guard("asset:write");
const before = await db.asset.findFirst({
where: { id: assetId },
select: { id: true, confidentiality: true, integrity: true, availability: true },
});
if (!before) throw new Error("Asset nicht gefunden");
const data = {
confidentiality: level.parse(formData.get("confidentiality")),
integrity: level.parse(formData.get("integrity")),
availability: level.parse(formData.get("availability")),
};
await db.asset.update({ where: { id: assetId }, data });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "asset",
entityId: assetId,
before,
after: data,
});
revalidatePath("/onboarding");
revalidatePath("/processes");
revalidatePath("/assets");
}
+299
View File
@@ -0,0 +1,299 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { prisma, type TenantDb } from "@/server/db";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { withQuery } from "@/lib/supplier";
const guard = moduleGuard("suppliers");
const level = z.coerce.number().int().min(1).max(4);
const supplierSchema = z.object({
name: z.string().trim().min(1).max(200),
sector: z.string().trim().max(200).optional(),
serviceDesc: z.string().trim().max(2000).optional(),
criticality: level,
dataCategories: z.string().optional(),
confidentiality: level,
integrity: level,
availability: level,
nis2Relevant: z.string().optional(),
lifecycle: z.enum(["ACTIVE", "ONBOARDING", "UNDER_REVIEW", "OFFBOARDED"]),
contact: z.string().trim().max(200).optional(),
nextReview: z.string().optional(),
notes: z.string().trim().max(5000).optional(),
});
function parse(formData: FormData) {
const p = supplierSchema.parse({
name: formData.get("name"),
sector: formData.get("sector") || undefined,
serviceDesc: formData.get("serviceDesc") || undefined,
criticality: formData.get("criticality"),
dataCategories: formData.get("dataCategories") || undefined,
confidentiality: formData.get("confidentiality"),
integrity: formData.get("integrity"),
availability: formData.get("availability"),
nis2Relevant: formData.get("nis2Relevant") || undefined,
lifecycle: formData.get("lifecycle"),
contact: formData.get("contact") || undefined,
nextReview: formData.get("nextReview") || undefined,
notes: formData.get("notes") || undefined,
});
return {
asset: {
name: p.name,
confidentiality: p.confidentiality,
integrity: p.integrity,
availability: p.availability,
},
profile: {
sector: p.sector || null,
serviceDesc: p.serviceDesc || null,
criticality: p.criticality,
dataCategories: p.dataCategories
? p.dataCategories.split(",").map((s) => s.trim()).filter(Boolean)
: [],
nis2Relevant: p.nis2Relevant === "on",
lifecycle: p.lifecycle,
contact: p.contact || null,
nextReview: p.nextReview ? new Date(p.nextReview) : null,
notes: p.notes || null,
},
};
}
export async function createSupplier(formData: FormData) {
const { session, db } = await guard("supplier:write");
const { asset, profile } = parse(formData);
const created = await db.asset.create({
data: {
...asset,
type: "SUPPLIER",
tenantId: session.user.tenantId,
createdBy: session.user.id,
},
});
const last = await prisma.supplierProfile.aggregate({
where: { tenantId: session.user.tenantId },
_max: { refNo: true },
});
await db.supplierProfile.create({
data: {
...profile,
assetId: created.id,
refNo: (last._max.refNo ?? 0) + 1,
tenantId: session.user.tenantId,
createdBy: session.user.id,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "supplier", entityId: created.id, after: { ...asset, ...profile } });
revalidatePath("/suppliers");
revalidatePath("/assets");
redirect(`/suppliers?edit=${created.id}`);
}
export async function updateSupplier(assetId: string, formData: FormData) {
const { session, db } = await guard("supplier:write");
const before = await db.asset.findUnique({ where: { id: assetId }, include: { supplierProfile: true } });
if (!before) throw new Error("Lieferant nicht gefunden");
const { asset, profile } = parse(formData);
await db.asset.update({ where: { id: assetId }, data: asset });
await db.supplierProfile.update({ where: { assetId }, data: profile });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "supplier", entityId: assetId, before, after: { ...asset, ...profile } });
revalidatePath("/suppliers");
revalidatePath("/assets");
const returnTo = (formData.get("returnTo") as string) || "/suppliers";
redirect(withQuery(returnTo, "detail", assetId));
}
export async function deleteSupplier(assetId: string, returnTo: string = "/suppliers") {
const { session, db } = await guard("supplier:write");
const before = await db.asset.findUnique({ where: { id: assetId } });
if (!before) throw new Error("Lieferant nicht gefunden");
await db.asset.delete({ where: { id: assetId } }); // Profil + Kinder kaskadieren
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "supplier", entityId: assetId, before });
revalidatePath("/suppliers");
revalidatePath("/assets");
redirect(returnTo);
}
/** Stellt sicher, dass das Asset zum Mandanten gehört (Guard hat Modul/Recht bereits geprüft). */
async function ensure(db: TenantDb, assetId: string) {
if ((await db.asset.count({ where: { id: assetId } })) !== 1) throw new Error("Nicht gefunden");
return db;
}
const optDate = (v: FormDataEntryValue | null) => (v && String(v) ? new Date(String(v)) : null);
export async function addContract(assetId: string, formData: FormData) {
const { session, db } = await guard("supplier:write");
await ensure(db, assetId);
const bool = (n: string) => formData.get(n) === "on";
await db.contract.create({
data: {
assetId,
tenantId: session.user.tenantId,
type: (formData.get("type") as string) || "service",
avDpa: bool("avDpa"),
securityClauses: bool("securityClauses"),
flowdown: bool("flowdown"),
customerRequirementsPassed: bool("customerRequirementsPassed"),
validFrom: optDate(formData.get("validFrom")),
validTo: optDate(formData.get("validTo")),
reference: (formData.get("reference") as string)?.trim() || null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "contract", entityId: assetId });
revalidatePath("/suppliers");
revalidatePath("/assets");
}
export async function addNda(assetId: string, formData: FormData) {
const { session, db } = await guard("supplier:write");
await ensure(db, assetId);
await db.nda.create({
data: {
assetId,
tenantId: session.user.tenantId,
parties: (formData.get("parties") as string)?.trim() || null,
infoScope: (formData.get("infoScope") as string)?.trim() || null,
subject: (formData.get("subject") as string)?.trim() || null,
validFrom: optDate(formData.get("validFrom")),
validTo: optDate(formData.get("validTo")),
extensionStatus: (formData.get("extensionStatus") as string)?.trim() || null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "nda", entityId: assetId });
revalidatePath("/suppliers");
revalidatePath("/assets");
}
export async function addEvidence(assetId: string, formData: FormData) {
const { session, db } = await guard("supplier:write");
await ensure(db, assetId);
await db.supplierEvidence.create({
data: {
assetId,
tenantId: session.user.tenantId,
kind: z.enum(["CERTIFICATE", "TISAX_LABEL", "ATTESTATION", "AUDIT_REPORT", "SELF_ASSESSMENT"]).parse(formData.get("kind")),
name: (formData.get("name") as string)?.trim() || null,
protectsCia: (formData.get("protectsCia") as string)?.trim() || null,
validTo: optDate(formData.get("validTo")),
adequacyChecked: formData.get("adequacyChecked") === "on",
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "supplier_evidence", entityId: assetId });
revalidatePath("/suppliers");
revalidatePath("/assets");
}
export async function addAssessment(assetId: string, formData: FormData) {
const { session, db } = await guard("supplier:write");
await ensure(db, assetId);
await db.supplierAssessment.create({
data: {
assetId,
tenantId: session.user.tenantId,
type: z.enum(["QUESTIONNAIRE", "SELF_ASSESSMENT", "AUDIT"]).parse(formData.get("type")),
score: formData.get("score") ? Number(formData.get("score")) : null,
date: optDate(formData.get("date")),
nextReview: optDate(formData.get("nextReview")),
result: (formData.get("result") as string)?.trim() || null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "supplier_assessment", entityId: assetId });
revalidatePath("/suppliers");
revalidatePath("/assets");
}
export async function addDecision(assetId: string, formData: FormData) {
const { session, db } = await guard("supplier:write");
await ensure(db, assetId);
await db.managementDecision.create({
data: {
assetId,
tenantId: session.user.tenantId,
reasonNoAudit: z.string().trim().min(1).parse(formData.get("reasonNoAudit")),
decision: z.string().trim().min(1).parse(formData.get("decision")),
decidedBy: (formData.get("decidedBy") as string)?.trim() || session.user.name || null,
recordRef: (formData.get("recordRef") as string)?.trim() || null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "management_decision", entityId: assetId });
revalidatePath("/suppliers");
revalidatePath("/assets");
}
/** Gate-Kompensation: echtes Risiko im Risikomodul anlegen und mit dem Lieferanten verknüpfen. */
export async function createGateRisk(assetId: string) {
const { session, db } = await guard("supplier:write", "risk:write");
const asset = await db.asset.findUnique({ where: { id: assetId } });
if (!asset) throw new Error("Lieferant nicht gefunden");
const last = await prisma.risk.aggregate({ where: { tenantId: session.user.tenantId }, _max: { refNo: true } });
const risk = await db.risk.create({
data: {
tenantId: session.user.tenantId,
refNo: (last._max.refNo ?? 0) + 1,
title: `Third-Party-Risiko: ${asset.name}`,
description: "Sehr hoher Schutzbedarf ohne gültiges Third-Party-Audit/TISAX-Label — Kompensationsrisiko (VDA-ISA 6.1.1).",
threat: "Ausfall/Kompromittierung eines kritischen Lieferanten",
vulnerability: "Fehlender unabhängiger Sicherheitsnachweis",
likelihood: 3,
impact: 4,
score: 12,
treatment: "MITIGATE",
status: "IN_TREATMENT",
createdBy: session.user.id,
},
});
await db.riskAsset.create({ data: { tenantId: session.user.tenantId, riskId: risk.id, assetId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "risk", entityId: risk.id, after: { gateRiskFor: assetId } });
revalidatePath("/suppliers");
revalidatePath("/risks");
redirect(`/risks?detail=${risk.id}`);
}
/** ISB-Freigabe des Reifegrads (Abweichung nur mit Begründung → Audit-Log). */
export async function approveMaturity(assetId: string, computed: number, formData: FormData) {
const { session, db } = await guard("supplier:write");
await ensure(db, assetId);
const isbValue = z.coerce.number().min(0).max(3).parse(formData.get("isbValue"));
const justification = (formData.get("justification") as string)?.trim() || null;
if (Math.abs(isbValue - computed) > 0.01 && !justification) {
throw new Error("Abweichung vom berechneten Wert erfordert eine Begründung.");
}
await db.maturityAssessment.upsert({
where: { assetId },
update: { computedValue: computed, isbValue, isbJustification: justification, approvedBy: session.user.name, approvedAt: new Date() },
create: { assetId, tenantId: session.user.tenantId, computedValue: computed, isbValue, isbJustification: justification, approvedBy: session.user.name, approvedAt: new Date() },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "maturity_assessment", entityId: assetId, after: { computed, isbValue, justification } });
revalidatePath("/suppliers");
revalidatePath("/assets");
}
export async function deleteChild(
kind: "contract" | "nda" | "evidence" | "assessment" | "decision" | "subcontractor",
id: string
) {
const { session, db } = await guard("supplier:write");
const map = {
contract: db.contract,
nda: db.nda,
evidence: db.supplierEvidence,
assessment: db.supplierAssessment,
decision: db.managementDecision,
subcontractor: db.subcontractor,
} as const;
// @ts-expect-error dynamischer Delegate
await map[kind].delete({ where: { id } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: `supplier_${kind}`, entityId: id });
revalidatePath("/suppliers");
revalidatePath("/assets");
}
+643
View File
@@ -0,0 +1,643 @@
"use server";
import { Prisma, type Task } from "@prisma/client";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { moduleGuard } from "@/server/action-guard";
import type { TenantDb } from "@/server/db";
import { requirePermission } from "@/server/rbac";
import { writeAuditLog } from "@/server/audit";
import { pathForEntity } from "@/server/object-review";
import { notifyTaskEvent } from "@/server/mail/notifications";
import { TASK_PRIORITIES, TASK_TYPES, EVIDENCE_KINDS, type CreateTaskInput, isTaskPriority, isTaskType } from "@/lib/tasks";
import { originForTrigger, triggerById } from "@/lib/task-triggers";
import { domainForControl, assignTaskParticipants } from "@/server/control-domain";
import { spawnRecurrence, syncTaskFromObject } from "@/server/task-sync";
/**
* Aufgaben-Modul (generisch). Erster Typ: policy_approval — Freigabe/Ablehnung
* einer Richtlinie durch den zugewiesenen Freigeber. Vier-Augen: der Freigeber
* ist eine andere Person als der Einreicher. Kommentare werden historisiert.
*/
const guard = moduleGuard("tasks");
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
/**
* F-10: Schema-Validierung für `createTask`. Bislang wurde ein beliebiges JS-Objekt
* mit freien JSON-Feldern (`resources`, `links`) ohne Schema und ohne Größenlimit
* entgegengenommen. Zod erzwingt jetzt Typ, Längen- und Feldbegrenzung, damit keine
* beliebig großen JSON-Strukturen in die DB geschrieben werden können.
*/
const shortText = z.string().trim().max(200);
const longText = z.string().trim().max(5000);
const resourceValue = z.string().trim().max(500);
const createTaskSchema = z.object({
type: z.enum(TASK_TYPES),
title: shortText.min(1, "Aufgabe braucht einen Titel."),
description: longText.nullish(),
origin: shortText.min(1, "Aufgabe braucht eine Herkunft (origin)."),
owner: z.string().trim().max(64).nullish(),
dueDate: z.union([z.string().trim().max(64), z.date()]).nullish(),
priority: z.enum(TASK_PRIORITIES).optional(),
resources: z
.object({
tool: resourceValue.optional(),
budget: resourceValue.optional(),
personnel: resourceValue.optional(),
time: resourceValue.optional(),
})
.strict()
.optional(),
links: z
.object({
control: shortText.optional(),
risk: shortText.optional(),
document: shortText.optional(),
asset: shortText.optional(),
})
.strict()
.optional(),
});
/**
* F-10: `assigneeId`/`owner` muss auf einen AKTIVEN Nutzer DESSELBEN Mandanten zeigen.
* `db` ist bereits mandantengebunden (`dbForTenant`), daher genügt id+status. Ein
* leerer/`null`-Wert ist zulässig (keine Zuweisung). Gibt die geprüfte owner-Id oder
* `null` zurück.
*/
async function assertValidOwner(db: TenantDb, owner: string | null | undefined): Promise<string | null> {
const id = (owner ?? "").trim();
if (!id) return null;
const user = await db.user.findFirst({ where: { id, status: "ACTIVE" }, select: { id: true } });
if (!user) throw new Error("Zugewiesene Person ist kein aktiver Nutzer dieses Mandanten.");
return user.id;
}
/** Erforderliches Recht je Review-Typ: generische Objekt-Validierung vs. Richtlinien-Freigabe (A3-1). */
function reviewPermission(type: string): "validate_objects" | "policy:approve" {
return type === "validation" ? "validate_objects" : "policy:approve";
}
/** Review-Aufgabe bestätigen (nur zugewiesener Validator/Freigeber, ≠ Einreicher). */
export async function approveTask(taskId: string, formData: FormData) {
const { session, db } = await guard();
const task = await db.task.findUnique({ where: { id: taskId } });
if (!task || task.status !== "OPEN") throw new Error("Aufgabe ist nicht (mehr) offen.");
if (task.assigneeId !== session.user.id) throw new Error("Nur der zugewiesene Validator kann diese Aufgabe bearbeiten.");
if (task.createdById === session.user.id) throw new Error("Vier-Augen-Prinzip: nicht selbst bestätigen.");
requirePermission(session, reviewPermission(task.type));
// Richtlinien-Freigabe hat einen Modell-Seiteneffekt; generische Objekt-Reviews
// (validation) leiten ihren Status aus der Aufgabe ab (keine Statusspalte).
let approvedPolicyCode: string | null = null;
if (task.type === "policy_approval" && task.entityId) {
const doc = await db.policyDocument.update({ where: { id: task.entityId }, data: { status: "FREIGEGEBEN", approvedBy: session.user.id, approvedAt: new Date() }, select: { code: true } });
approvedPolicyCode = doc.code;
}
const note = str(formData.get("note"));
await db.task.update({
where: { id: taskId },
data: {
status: "DONE", resolvedById: session.user.id, resolvedAt: new Date(),
comments: { create: { tenantId: session.user.tenantId, authorId: session.user.id, kind: "approve", body: note || "Bestätigt." } },
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "task", entityId: taskId, after: { status: "DONE", approved: true } });
// Cockpit (M3, 2.2): Wiedervorlage-Serie fortschreiben + Auto-Completion (gedeckelt)
// für weitere Aufgaben, die auf dasselbe (jetzt freigegebene) Dokument verweisen.
await spawnRecurrence({ db, tenantId: session.user.tenantId, actorId: session.user.id }, task);
if (task.type === "policy_approval" && task.entityId) {
await syncTaskFromObject(
{ db, tenantId: session.user.tenantId, actorId: session.user.id },
"policy_document",
{ entityId: task.entityId, matchKey: approvedPolicyCode ?? undefined, reached: true },
);
}
// SEC1: der Einreicher erfährt die Entscheidung per Mail.
await notifyTaskEvent({
tenantId: session.user.tenantId,
event: "task_decided",
taskId,
taskTitle: task.title,
taskType: task.type,
recipientId: task.createdById,
actorId: session.user.id,
detail: "freigegeben",
});
revalidatePath("/tasks");
revalidatePath("/dashboard");
const p = pathForEntity(task.entityType);
if (p) revalidatePath(p, "layout");
}
/** Review-Aufgabe zurückweisen (mit Grund). Bei Richtlinien zurück auf ENTWURF. */
export async function rejectTask(taskId: string, formData: FormData) {
const { session, db } = await guard();
const task = await db.task.findUnique({ where: { id: taskId } });
if (!task || task.status !== "OPEN") throw new Error("Aufgabe ist nicht (mehr) offen.");
if (task.assigneeId !== session.user.id) throw new Error("Nur der zugewiesene Validator kann diese Aufgabe bearbeiten.");
requirePermission(session, reviewPermission(task.type));
const reason = str(formData.get("note"));
if (!reason) throw new Error("Bitte einen Ablehnungs-/Rückweisungsgrund angeben.");
if (task.type === "policy_approval" && task.entityId) {
await db.policyDocument.update({ where: { id: task.entityId }, data: { status: "ENTWURF", submittedBy: null } });
}
await db.task.update({
where: { id: taskId },
data: {
status: "REJECTED", resolvedById: session.user.id, resolvedAt: new Date(),
comments: { create: { tenantId: session.user.tenantId, authorId: session.user.id, kind: "reject", body: reason } },
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "task", entityId: taskId, after: { status: "REJECTED", reason } });
// SEC1: der Einreicher erfährt die Entscheidung. Der Begründungstext bleibt
// bewusst in der Aufgabe — die Mail verlinkt nur darauf.
await notifyTaskEvent({
tenantId: session.user.tenantId,
event: "task_decided",
taskId,
taskTitle: task.title,
taskType: task.type,
recipientId: task.createdById,
actorId: session.user.id,
detail: "zurückgewiesen",
});
revalidatePath("/tasks");
revalidatePath("/dashboard");
const p = pathForEntity(task.entityType);
if (p) revalidatePath(p, "layout");
}
/** Kommentar zu einer Aufgabe (nur Beteiligte: Freigeber oder Einreicher). */
export async function commentTask(taskId: string, formData: FormData) {
const { session, db } = await guard();
const task = await db.task.findUnique({ where: { id: taskId } });
if (!task) throw new Error("Aufgabe nicht gefunden.");
if (task.assigneeId !== session.user.id && task.createdById !== session.user.id) {
throw new Error("Nur Beteiligte können kommentieren.");
}
const body = str(formData.get("body"));
if (!body) return;
await db.taskComment.create({ data: { tenantId: session.user.tenantId, taskId, authorId: session.user.id, kind: "comment", body } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "task_comment", entityId: taskId });
revalidatePath("/tasks");
}
/**
* Generische Aufgabe erzeugen (Contracts §1). Einheitlicher Einstieg für Dev A's
* Wizard-Gates/Trigger und spätere Auto-Generierung (B1). `owner` → `assigneeId`;
* `status` wird serverseitig gesetzt (Default "OPEN"). Der bestehende
* policy_approval-Flow (submitForApproval) bleibt unberührt.
*/
export async function createTask(input: CreateTaskInput): Promise<Task> {
const { session, db } = await guard("task:write");
// F-10: Eingabe strikt validieren (Typ, Längen, JSON-Felder) statt beliebiges Objekt.
const parsed = createTaskSchema.safeParse(input);
if (!parsed.success) {
throw new Error(parsed.error.issues[0]?.message ?? "Ungültige Aufgabendaten.");
}
const data = parsed.data;
const priority = data.priority ?? "mittel";
const dueDate = data.dueDate ? new Date(data.dueDate) : null;
if (dueDate && Number.isNaN(dueDate.getTime())) throw new Error("Ungültiges Fälligkeitsdatum.");
// F-10: Owner muss aktiver Nutzer desselben Mandanten sein.
const assigneeId = await assertValidOwner(db, data.owner);
// Cockpit (M3, 1.4): Bereich aus dem verknüpften Control ableiten.
const domain = await domainForControl(db, session.user.tenantId, data.links?.control ?? null);
const task = await db.task.create({
data: {
tenantId: session.user.tenantId,
type: data.type,
title: data.title,
description: data.description?.trim() || null,
origin: data.origin,
priority,
assigneeId,
createdById: session.user.id,
dueDate,
domain,
resources: data.resources ? (data.resources as Prisma.InputJsonValue) : undefined,
links: data.links ? (data.links as Prisma.InputJsonValue) : undefined,
},
});
// Cockpit (M3, 2.3): RACI-Mitwirkende automatisch aus Control/Domain befüllen,
// sobald Funktion→User (Ebene 1) gepflegt ist. Der assignee bleibt primär RESPONSIBLE.
await assignTaskParticipants(db, session.user.tenantId, {
taskId: task.id,
control: data.links?.control ?? null,
domain,
assigneeId,
});
await writeAuditLog({
tenantId: session.user.tenantId, actorId: session.user.id,
action: "create", entity: "task", entityId: task.id,
after: { type: data.type, origin: data.origin, priority },
});
// SEC1: Zuweisung benachrichtigen (nicht bei Vorschlägen ohne Owner und nicht,
// wenn sich jemand die Aufgabe selbst anlegt).
await notifyTaskEvent({
tenantId: session.user.tenantId,
event: "task_assigned",
taskId: task.id,
taskTitle: task.title,
taskType: task.type,
recipientId: assigneeId,
actorId: session.user.id,
});
revalidatePath("/tasks");
revalidatePath("/dashboard");
return task;
}
/**
* Aus einem Trigger-Set (C2 §8) Aufgaben-*Vorschläge* erzeugen (Story B1). Wird von
* Dev A's Wizard-Gates/Zurückweisungen aufgerufen. Idempotent über `origin`: existiert
* je Trigger bereits ein offener Vorschlag oder eine offene Aufgabe, wird übersprungen.
*/
export async function proposeTasksFromTriggers(triggerIds: string[]): Promise<{ created: number; skipped: number }> {
const { session, db } = await guard("task:write");
let created = 0;
let skipped = 0;
for (const id of triggerIds) {
const trg = triggerById(id);
if (!trg) { skipped++; continue; }
const origin = originForTrigger(id);
const existing = await db.task.findFirst({ where: { origin, status: { in: ["PROPOSED", "OPEN"] } }, select: { id: true } });
if (existing) { skipped++; continue; }
// Cockpit (M3): Kontext-Verteiler (F2) — expliziter Trigger-Bereich (Fakt→Domain)
// hat Vorrang; sonst aus dem Control-Bezug ableiten (inkl. Tenant-Override).
const domain = trg.domain ?? (await domainForControl(db, session.user.tenantId, trg.links.control ?? null));
// `task` wird für die RACI-Auto-Befüllung (F1) unten benötigt.
const task = await db.task.create({
data: {
tenantId: session.user.tenantId,
type: trg.taskType,
title: trg.title,
origin,
status: "PROPOSED",
priority: trg.priority,
createdById: session.user.id,
domain,
links: trg.links as Prisma.InputJsonValue,
resources: trg.resourcesHint ? (trg.resourcesHint as Prisma.InputJsonValue) : undefined,
},
});
// Cockpit (M3, 2.3): RACI-Mitwirkende automatisch befüllen (Vorschlag ohne Owner).
await assignTaskParticipants(db, session.user.tenantId, {
taskId: task.id,
control: trg.links.control ?? null,
domain,
});
created++;
}
if (created > 0) {
await writeAuditLog({
tenantId: session.user.tenantId, actorId: session.user.id,
action: "create", entity: "task_proposal", after: { created, skipped, triggers: triggerIds },
});
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
return { created, skipped };
}
/**
* Aufgaben-Vorschlag bestätigen (Story B1): PROPOSED → OPEN. Der Bearbeiter kann
* Owner, Fälligkeit, Priorität und Ressourcenfelder anpassen. Nur Beteiligte
* (Ersteller oder zugewiesener Owner) dürfen bestätigen.
*/
export async function confirmProposal(taskId: string, formData: FormData) {
const { session, db } = await guard();
const task = await db.task.findUnique({ where: { id: taskId } });
if (!task || task.status !== "PROPOSED") throw new Error("Kein offener Vorschlag.");
if (task.createdById !== session.user.id && task.assigneeId !== session.user.id) {
throw new Error("Nur Beteiligte können den Vorschlag bestätigen.");
}
const resources = {
tool: str(formData.get("res_tool")),
budget: str(formData.get("res_budget")),
personnel: str(formData.get("res_personnel")),
time: str(formData.get("res_time")),
};
const owner = str(formData.get("owner"));
const priorityRaw = str(formData.get("priority"));
const dueRaw = str(formData.get("dueDate"));
const due = dueRaw ? new Date(dueRaw) : null;
if (due && Number.isNaN(due.getTime())) throw new Error("Ungültiges Fälligkeitsdatum.");
const hasResources = Object.values(resources).some(Boolean);
// F-10: Owner (falls gesetzt) muss aktiver Nutzer desselben Mandanten sein.
const assigneeId = (await assertValidOwner(db, owner)) ?? task.assigneeId;
await db.task.update({
where: { id: taskId },
data: {
status: "OPEN",
assigneeId,
dueDate: due ?? task.dueDate,
priority: isTaskPriority(priorityRaw) ? priorityRaw : task.priority,
resources: (hasResources ? resources : (task.resources ?? undefined)) as Prisma.InputJsonValue,
comments: { create: { tenantId: session.user.tenantId, authorId: session.user.id, kind: "submit", body: "Vorschlag bestätigt und übernommen." } },
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "task", entityId: taskId, after: { status: "OPEN", confirmed: true } });
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
/**
* Nicht zugewiesene Aufgabe aus dem Pool übernehmen: setzt sich selbst als Owner.
* Ein Vorschlag (PROPOSED) wird dabei zur offenen Aufgabe. Nur möglich, solange
* niemand sonst zugewiesen ist (kein „Wegnehmen").
*/
export async function claimTask(taskId: string) {
const { session, db } = await guard("task:write");
const task = await db.task.findUnique({ where: { id: taskId } });
if (!task) throw new Error("Aufgabe nicht gefunden.");
if (task.assigneeId) throw new Error("Aufgabe ist bereits zugewiesen.");
if (!["PROPOSED", "OPEN"].includes(task.status)) throw new Error("Aufgabe ist nicht mehr offen.");
await db.task.update({
where: { id: taskId },
data: {
assigneeId: session.user.id,
status: "OPEN",
comments: { create: { tenantId: session.user.tenantId, authorId: session.user.id, kind: "submit", body: "Aufgabe aus dem Pool übernommen." } },
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "task", entityId: taskId, after: { assigneeId: session.user.id, claimed: true } });
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
/** Aufgaben-Vorschlag verwerfen (Story B1): PROPOSED → DISCARDED, Grund pflicht. */
export async function discardProposal(taskId: string, formData: FormData) {
const { session, db } = await guard();
const task = await db.task.findUnique({ where: { id: taskId } });
if (!task || task.status !== "PROPOSED") throw new Error("Kein offener Vorschlag.");
if (task.createdById !== session.user.id && task.assigneeId !== session.user.id) {
throw new Error("Nur Beteiligte können den Vorschlag verwerfen.");
}
const reason = str(formData.get("note"));
if (!reason) throw new Error("Bitte einen Grund fürs Verwerfen angeben.");
await db.task.update({
where: { id: taskId },
data: {
status: "DISCARDED", resolvedById: session.user.id, resolvedAt: new Date(),
comments: { create: { tenantId: session.user.tenantId, authorId: session.user.id, kind: "reject", body: reason } },
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "task", entityId: taskId, after: { status: "DISCARDED", reason } });
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
/** Editier-/Verwaltungsrecht: Beteiligte(r) oder Aufgaben-Oversight (task:read_all, z. B. PM/Admin). */
function canManageTask(
session: { user: { id: string; permissions?: string[] } },
task: { assigneeId: string | null; createdById: string | null },
): boolean {
return task.assigneeId === session.user.id || task.createdById === session.user.id || Boolean(session.user.permissions?.includes("task:read_all"));
}
const EDITABLE_STATUS = new Set(["OPEN", "IN_PROGRESS", "DONE", "CANCELLED"]);
const BOARD_STATUS = new Set(["OPEN", "IN_PROGRESS", "DONE"]);
/** Statuswechsel per Kanban-Drag (OPEN/IN_PROGRESS/DONE). */
export async function updateTaskStatus(taskId: string, status: string) {
const { session, db } = await guard();
if (!BOARD_STATUS.has(status)) throw new Error("Ungültiger Status.");
const task = await db.task.findUnique({ where: { id: taskId } });
if (!task) throw new Error("Aufgabe nicht gefunden.");
if (!canManageTask(session, task)) throw new Error("Keine Berechtigung, diese Aufgabe zu verschieben.");
if (task.status === status) return;
await db.task.update({
where: { id: taskId },
data: {
status,
resolvedById: status === "DONE" ? session.user.id : null,
resolvedAt: status === "DONE" ? new Date() : null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "task", entityId: taskId, after: { status } });
// Cockpit (M3, 2.2): Wiedervorlage — bei DONE eine Folge-Aufgabe der Serie erzeugen.
if (status === "DONE" && task.status !== "DONE") {
await spawnRecurrence({ db, tenantId: session.user.tenantId, actorId: session.user.id }, task);
}
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
/**
* Cockpit (M3, 2.3): Reihenfolge einer Kanban-Spalte per DnD persistieren. Der Client
* schickt die neue Reihenfolge der Aufgaben-IDs einer Spalte; `orderIdx` wird
* sequenziell (0..n) geschrieben. Nur verwaltbare Aufgaben werden angefasst.
*/
export async function reorderTasks(orderedIds: string[]) {
const { session, db } = await guard();
if (!Array.isArray(orderedIds) || orderedIds.length === 0) return;
const tasks = await db.task.findMany({
where: { id: { in: orderedIds } },
select: { id: true, assigneeId: true, createdById: true, orderIdx: true },
});
const byId = new Map(tasks.map((t) => [t.id, t]));
await db.$transaction(
orderedIds.flatMap((id, idx) => {
const t = byId.get(id);
if (!t || t.orderIdx === idx || !canManageTask(session, t)) return [];
return [db.task.update({ where: { id }, data: { orderIdx: idx } })];
}),
);
revalidatePath("/tasks");
}
/**
* Aufgabe bearbeiten (analog Maßnahme): Titel, Beschreibung, Status, Owner, Priorität,
* Fälligkeit. Für Beteiligte oder Aufgaben-Oversight. Kein Zugriff auf Review-Zustände
* (PROPOSED/REJECTED/DISCARDED laufen über die eigenen Aktionen).
*/
export async function updateTask(taskId: string, formData: FormData) {
const { session, db } = await guard("task:write");
const task = await db.task.findUnique({ where: { id: taskId } });
if (!task) throw new Error("Aufgabe nicht gefunden.");
if (!canManageTask(session, task)) throw new Error("Keine Berechtigung, diese Aufgabe zu bearbeiten.");
const title = str(formData.get("title")).slice(0, 200) || task.title;
const description = str(formData.get("description")).slice(0, 5000) || null;
const owner = str(formData.get("owner"));
const priorityRaw = str(formData.get("priority"));
const statusRaw = str(formData.get("status"));
const dueRaw = str(formData.get("dueDate"));
const due = dueRaw ? new Date(dueRaw) : null;
if (due && Number.isNaN(due.getTime())) throw new Error("Ungültiges Fälligkeitsdatum.");
const status = EDITABLE_STATUS.has(statusRaw) ? statusRaw : task.status;
// F-10: Owner (falls gesetzt) muss aktiver Nutzer desselben Mandanten sein.
const assigneeId = await assertValidOwner(db, owner);
await db.task.update({
where: { id: taskId },
data: {
title,
description,
status,
assigneeId,
priority: isTaskPriority(priorityRaw) ? priorityRaw : task.priority,
dueDate: dueRaw ? due : null,
resolvedById: status === "DONE" ? session.user.id : null,
resolvedAt: status === "DONE" ? new Date() : null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "task", entityId: taskId, after: { status, title } });
// Cockpit (M3, 2.2): Wiedervorlage — bei DONE eine Folge-Aufgabe der Serie erzeugen.
if (status === "DONE" && task.status !== "DONE") {
await spawnRecurrence({ db, tenantId: session.user.tenantId, actorId: session.user.id }, task);
}
// SEC1: nur bei einem *Wechsel* des Zuständigen benachrichtigen — sonst würde
// jede Bearbeitung (Titel, Fälligkeit) eine Mail auslösen.
if (assigneeId && assigneeId !== task.assigneeId) {
await notifyTaskEvent({
tenantId: session.user.tenantId,
event: "task_assigned",
taskId,
taskTitle: title,
taskType: task.type,
recipientId: assigneeId,
actorId: session.user.id,
});
}
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
/** Aufgabe löschen (nur Beteiligte oder Aufgaben-Oversight). */
export async function deleteTask(taskId: string) {
const { session, db } = await guard();
const task = await db.task.findUnique({ where: { id: taskId } });
if (!task) throw new Error("Aufgabe nicht gefunden.");
if (!canManageTask(session, task)) throw new Error("Keine Berechtigung, diese Aufgabe zu löschen.");
await db.task.delete({ where: { id: taskId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "task", entityId: taskId });
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
/**
* Eigene Aufgabe über die UI anlegen (z. B. Projektdurchlauf) — direkt als offene Aufgabe.
* Für Maßnahmen (z. B. zu Risiken) gibt es den eigenen Maßnahmen-Weg.
*/
export async function createOwnTask(formData: FormData) {
await guard(); // Modul-/Rechtezugriff (createTask guardet zusätzlich)
const type = str(formData.get("type")) || "organizational";
const prio = str(formData.get("priority"));
const owner = str(formData.get("owner"));
const dueRaw = str(formData.get("dueDate"));
await createTask({
type: isTaskType(type) ? type : "organizational",
title: str(formData.get("title")),
description: str(formData.get("description")) || null,
origin: "manual",
priority: isTaskPriority(prio) ? prio : "mittel",
owner: owner || undefined,
dueDate: dueRaw || undefined,
});
redirect("/tasks");
}
// ── Cockpit (M3, 1.6): Nachweis-/Evidence-Register je Aufgabe ─────────────────
//
// Ein Nachweis (Evidence) ist die Voraussetzung für „audit-ready" (Wirksamkeit):
// die Auto-Completion schließt eine Aufgabe nur bis DONE (dokumentiert/umgesetzt),
// Reifegrad 3 verlangt zusätzlich einen verknüpften Nachweis + Vier-Augen (2.2).
// validUntil koppelt an Task.effectiveUntil (Wiedervorlage) und markiert Ablauf.
const evidenceKindSchema = z.enum(EVIDENCE_KINDS);
/** Optionales Datum aus dem Formular (YYYY-MM-DD) parsen; leer → null. */
function parseDate(raw: string, label: string): Date | null {
if (!raw) return null;
const d = new Date(raw);
if (Number.isNaN(d.getTime())) throw new Error(`Ungültiges ${label}.`);
return d;
}
/** Zugriff auf die Nachweise einer Aufgabe: Beteiligte(r) oder Aufgaben-Oversight. */
async function requireManageableTask(db: TenantDb, session: { user: { id: string; permissions?: string[] } }, taskId: string) {
const task = await db.task.findUnique({ where: { id: taskId }, select: { id: true, assigneeId: true, createdById: true } });
if (!task) throw new Error("Aufgabe nicht gefunden.");
if (!canManageTask(session, task)) throw new Error("Keine Berechtigung für die Nachweise dieser Aufgabe.");
return task;
}
/**
* Nachweis anlegen und direkt mit der Aufgabe verknüpfen (kein Datei-Upload —
* `fileRef` ist ein Text-/URL-Verweis auf die Ablage). Setzt optional das
* Wirksamkeitsintervall (validFrom/validUntil).
*/
export async function createEvidence(taskId: string, formData: FormData) {
const { session, db } = await guard("task:write");
await requireManageableTask(db, session, taskId);
const title = str(formData.get("title")).slice(0, 200);
if (!title) throw new Error("Der Nachweis braucht einen Titel.");
const kindParsed = evidenceKindSchema.safeParse(str(formData.get("kind")));
if (!kindParsed.success) throw new Error("Ungültige Nachweisart.");
const fileRef = str(formData.get("fileRef")).slice(0, 500) || null;
const control = str(formData.get("control")).slice(0, 200) || null;
const validFrom = parseDate(str(formData.get("validFrom")), "Gültig-ab-Datum");
const validUntil = parseDate(str(formData.get("validUntil")), "Gültig-bis-Datum");
const evidence = await db.evidence.create({
data: {
tenantId: session.user.tenantId,
title,
kind: kindParsed.data,
fileRef,
control,
validFrom,
validUntil,
taskId,
createdById: session.user.id,
},
select: { id: true },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "evidence", entityId: evidence.id, after: { taskId, kind: kindParsed.data } });
revalidatePath("/tasks");
}
/** Bestehenden (unverknüpften) Nachweis mit der Aufgabe verknüpfen. */
export async function linkEvidence(taskId: string, formData: FormData) {
const { session, db } = await guard("task:write");
await requireManageableTask(db, session, taskId);
const evidenceId = str(formData.get("evidenceId"));
if (!evidenceId) throw new Error("Kein Nachweis ausgewählt.");
// db ist mandantengebunden → das update trifft nur Nachweise dieses Mandanten.
const evidence = await db.evidence.findUnique({ where: { id: evidenceId }, select: { id: true } });
if (!evidence) throw new Error("Nachweis nicht gefunden.");
await db.evidence.update({ where: { id: evidenceId }, data: { taskId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "evidence", entityId: evidenceId, after: { taskId, linked: true } });
revalidatePath("/tasks");
}
/** Nachweis von der Aufgabe entfernen (Verknüpfung lösen — der Nachweis bleibt erhalten). */
export async function unlinkEvidence(evidenceId: string) {
const { session, db } = await guard("task:write");
const evidence = await db.evidence.findUnique({ where: { id: evidenceId }, select: { id: true, taskId: true } });
if (!evidence) throw new Error("Nachweis nicht gefunden.");
if (evidence.taskId) await requireManageableTask(db, session, evidence.taskId);
await db.evidence.update({ where: { id: evidenceId }, data: { taskId: null } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "evidence", entityId: evidenceId, after: { unlinked: true } });
revalidatePath("/tasks");
}
/** Nachweise einer Aufgabe auflisten (für Server-Komponenten/Detailansicht). */
export async function listTaskEvidence(taskId: string) {
const { db } = await guard();
return db.evidence.findMany({ where: { taskId }, orderBy: { createdAt: "desc" } });
}
+57
View File
@@ -0,0 +1,57 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { requireSession } from "@/server/auth";
import { dbForTenant, prisma } from "@/server/db";
import { requirePermission } from "@/server/rbac";
import { writeAuditLog } from "@/server/audit";
import { syncPolicyVariablesFromSettings } from "@/server/provision";
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
/** Kunden-Admin pflegt Stammdaten/Branding/Policy; Stammdaten speisen die ISMS-Variablen. */
export async function updateTenantSettings(formData: FormData) {
const session = await requireSession();
requirePermission(session, "tenant:manage");
const tenantId = session.user.tenantId;
const db = dbForTenant(tenantId);
const orgName = z.string().trim().min(1).parse(formData.get("orgName"));
// TISAX-/Schutzbedarf-Tiefe ist eine Kern-Einstellung und wird ausschließlich vom
// Superadmin (Admin-Konsole) gesteuert — hier bewusst nicht anfassen.
const data = {
orgName,
orgShort: str(formData.get("orgShort")) || null,
address: str(formData.get("address")) || null,
sector: str(formData.get("sector")) || null,
duns: str(formData.get("duns")) || null,
ismsScope: str(formData.get("ismsScope")) || null,
ismsScopeDescription: str(formData.get("ismsScopeDescription")) || null,
roleManagement: str(formData.get("roleManagement")) || null,
roleIsb: str(formData.get("roleIsb")) || null,
roleItLead: str(formData.get("roleItLead")) || null,
roleDpo: str(formData.get("roleDpo")) || null,
accent: str(formData.get("accent")) || null,
locale: str(formData.get("locale")) || "de",
timezone: str(formData.get("timezone")) || "Europe/Berlin",
// NIS2-Betroffenheit des Mandanten (steuert später die Meldefristen-Timer, IM-B).
nis2Category: (["keine", "wichtig", "wesentlich"].includes(str(formData.get("nis2Category")))
? str(formData.get("nis2Category"))
: "keine"),
};
await db.tenantSettings.upsert({
where: { tenantId },
update: data,
create: { tenantId, ...data },
});
// Stammdaten → ISMS-Template-Variablen (eine Pflegestelle)
await syncPolicyVariablesFromSettings(prisma, tenantId, data);
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "tenant_settings", after: { orgName } });
revalidatePath("/settings");
revalidatePath("/policies");
}
+47
View File
@@ -0,0 +1,47 @@
"use server";
import { redirect } from "next/navigation";
import { auth, unstable_update } from "@/server/auth";
import { prisma } from "@/server/db";
import { writeAuditLog } from "@/server/audit";
/**
* WS2 (Option C) — aktiven Mandanten wählen/wechseln.
*
* EXEMPT vom Modul-Gating: kein per TenantModule gegatetes Fachmodul, sondern der
* Session-/Mandantenkontext selbst. Sicherheitskontrollen (Pflicht, §6 KONZEPT):
* - Der aktive Mandant ist SERVER-autoritativ: die gewählte Mitgliedschaft muss
* zur Session-Identity gehören und Mitgliedschaft + Mandant ACTIVE sein.
* - Beim Wechsel werden Rechte NEU aufgelöst (im jwt-Callback via unstable_update,
* nicht token-eingefroren) → Entzug/Sperre wirkt sofort.
* - Das MFA-Netz beim Betreten eines Pflicht-Mandanten greift über das bestehende
* Enrollment-Gate in (app)/layout.tsx (prüft die securityPolicy des AKTIVEN
* Mandanten und leitet ggf. auf /enroll-mfa).
* - Der Wechsel wird auditiert ("Identity → Mandant").
*/
export async function setActiveTenant(membershipId: string) {
const session = await auth();
const identityId = session?.user?.identityId;
if (!identityId) redirect("/login");
// Gehört die Mitgliedschaft zur Session-Identity und ist alles ACTIVE?
const membership = await prisma.user.findFirst({
where: { id: membershipId, identityId, status: "ACTIVE", tenant: { status: "ACTIVE" } },
select: { id: true, tenantId: true },
});
if (!membership) throw new Error("Ungültige Mandantenauswahl.");
// Token neu prägen: activeTenantId/activeMembershipId/permissions/tenantSlug (jwt-Callback).
await unstable_update({ user: { activeMembershipId: membershipId } });
await writeAuditLog({
tenantId: membership.tenantId,
actorId: membership.id,
action: "login",
entity: "tenant_switch",
entityId: membership.tenantId,
after: { identityId },
});
redirect("/dashboard");
}
+313
View File
@@ -0,0 +1,313 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { requireSession } from "@/server/auth";
import { prisma, dbForTenant, type TenantDb } from "@/server/db";
import { requirePermission } from "@/server/rbac";
import { PERMISSIONS, ROLE_DEFS } from "@/server/rbac";
import { hashPassword, generateCompliantPassword } from "@/server/password";
import { resolvePasswordPolicy } from "@/lib/password-policy";
import { issueToken } from "@/server/auth-token";
import { sendUserInvitationMail } from "@/server/auth-selfservice";
import { writeAuditLog } from "@/server/audit";
/**
* Benutzer- & Rollenverwaltung durch den Mandanten-Admin (Paket B). EXEMPT vom
* Modul-Gating; Autorisierung über RBAC (user:manage / role:manage). Alle Zugriffe
* laufen strikt über die eigene Session (dbForTenant(session.tenantId)) — Cross-Tenant
* ist ausgeschlossen. Audit-Scope: tenant.
*/
const STANDARD_ROLE_KEYS = new Set(Object.keys(ROLE_DEFS));
const PERMISSION_SET = new Set<string>(PERMISSIONS);
export type CreateUserValues = { name: string; email: string; roleIds: string[] };
export type CreateUserState =
| { status: "idle" }
| { status: "error"; message: string; values?: CreateUserValues }
// invited = Einladungsmail verschickt (Nutzer setzt Passwort selbst);
// generatedPassword gesetzt = Fallback-Anzeige, falls die Mail nicht ging.
| { status: "done"; email: string; generatedPassword: string | null; invited: boolean };
export type EditUserState =
| { status: "idle" }
| { status: "error"; message: string }
| { status: "ok" };
async function ctx(permission: "user:manage" | "role:manage") {
const session = await requireSession();
requirePermission(session, permission);
return { session, tenantId: session.user.tenantId, db: dbForTenant(session.user.tenantId) };
}
/**
* Wandelt eine geworfene Ausnahme (fehlende Berechtigung/Session aus ctx(),
* DB-Fehler etc.) in eine anzeigbare Meldung — damit die Action einen
* Fehlerzustand ZURÜCKGIBT statt die globale Error-Boundary auszulösen
* ("Aktion konnte nicht abgeschlossen werden" + Formularverlust).
*/
function actionError(err: unknown): string {
const msg = err instanceof Error ? err.message : String(err);
return msg || "Die Aktion konnte nicht ausgeführt werden.";
}
/**
* F-13: Effektive Rechtemenge des Handelnden AUTORITATIV aus der DB (nicht aus dem
* JWT), konsistent zum moduleGuard-Muster aus F-06 (src/server/action-guard.ts).
* Grundlage für die Begrenzung der vergebbaren Rechte („kein Grant über das eigene
* Niveau hinaus"). Wirft, wenn das Konto nicht (mehr) aktiv ist.
*/
async function actorEffectivePermissions(db: TenantDb, userId: string): Promise<Set<string>> {
const account = await db.user.findFirst({
where: { id: userId, status: "ACTIVE" },
select: {
userRoles: {
select: {
role: { select: { rolePermissions: { select: { permission: { select: { key: true } } } } } },
},
},
},
});
if (!account) throw new Error("Konto ist nicht aktiv.");
return new Set(account.userRoles.flatMap((ur) => ur.role.rolePermissions.map((rp) => rp.permission.key)));
}
/** Vereinigte Rechtemenge mehrerer Rollen (für die Selbstzuweisungs-Prüfung, F-13). */
async function permissionsOfRoles(db: TenantDb, roleIds: string[]): Promise<Set<string>> {
if (roleIds.length === 0) return new Set();
const rows = await db.rolePermission.findMany({
where: { roleId: { in: roleIds } },
select: { permission: { select: { key: true } } },
});
return new Set(rows.map((r) => r.permission.key));
}
async function countActiveTenantAdmins(db: TenantDb): Promise<number> {
return db.user.count({ where: { status: "ACTIVE", userRoles: { some: { role: { key: "tenant-admin" } } } } });
}
async function tenantPolicy(db: TenantDb, tenantId: string) {
const s = await db.tenantSettings.findUnique({ where: { tenantId } });
return resolvePasswordPolicy(s?.securityPolicy);
}
const emailSchema = z.string().trim().email();
/* ── Benutzer (user:manage) ── */
export async function createUser(_prev: CreateUserState, formData: FormData): Promise<CreateUserState> {
const name = String(formData.get("name") ?? "").trim();
const emailRaw = String(formData.get("email") ?? "").trim().toLowerCase();
const roleIds = formData.getAll("roles").map(String).filter(Boolean);
// Eingaben im Fehlerfall zurückgeben, damit das Formular nicht geleert wird.
const values: CreateUserValues = { name, email: emailRaw, roleIds };
const fail = (message: string): CreateUserState => ({ status: "error", message, values });
try {
const { session, tenantId, db } = await ctx("user:manage");
if (!name) return fail("Bitte einen Namen angeben.");
const email = emailSchema.safeParse(emailRaw);
if (!email.success) return fail("Bitte eine gültige E-Mail-Adresse angeben.");
if (roleIds.length === 0) return fail("Bitte mindestens eine Rolle zuweisen.");
if (await db.user.findFirst({ where: { email: email.data } })) return fail("Es existiert bereits ein Nutzer mit dieser E-Mail.");
if ((await db.role.count({ where: { id: { in: roleIds } } })) !== roleIds.length) return fail("Ungültige Rollenauswahl.");
// Option C (WS3): Anlage NUR per Einladung (goldene Regel 4). Neutralität — der
// Mandanten-Admin sieht IMMER dieselbe Rückmeldung, unabhängig davon, ob die
// Identity global schon existierte (kein Cross-Tenant-Leak). Bekannte Identity →
// nur Mitgliedschaft ergänzen (KEIN Passwort-Reset), sie bestätigt den Beitritt
// implizit beim nächsten Login über die Mandantenauswahl. Unbekannt → Identity +
// Einladung zum Passwort-Setzen (/invite). Membership.passwordHash = Legacy.
const policy = await tenantPolicy(db, tenantId);
const throwaway = await hashPassword(generateCompliantPassword(policy));
const prior = await prisma.identity.findUnique({ where: { email: email.data } });
const identity = prior ?? (await prisma.identity.create({
data: { email: email.data, passwordHash: throwaway, mustChangePassword: true, status: "ACTIVE" },
}));
const user = await db.user.create({
data: {
tenantId, identityId: identity.id, email: email.data, name, status: "ACTIVE",
userRoles: { create: roleIds.map((roleId) => ({ roleId })) },
},
});
await writeAuditLog({ tenantId, actorId: session.user.id, action: "create", entity: "user", entityId: user.id, after: { email: email.data, roleIds, linkedExisting: !!prior } });
revalidatePath("/settings/users");
if (!prior) {
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { name: true } });
const { raw, expiresAt } = await issueToken({ principalType: "identity", principalId: identity.id, tenantId, type: "invitation" });
const invite = await sendUserInvitationMail({ to: email.data, name, tenantId, tenantName: tenant?.name ?? "", rawToken: raw, expiresAt });
if (invite.status !== "queued" && invite.status !== "sent") {
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "user_invite_failed", entityId: user.id, after: { email: email.data, mail: invite.status } });
}
}
return { status: "done", email: email.data, generatedPassword: null, invited: true };
} catch (err) {
return fail(actionError(err));
}
}
/** Stammdaten (Name, E-Mail) eines Nutzers ändern; E-Mail bleibt je Mandant eindeutig. */
export async function updateUser(userId: string, _prev: EditUserState, formData: FormData): Promise<EditUserState> {
try {
const { session, tenantId, db } = await ctx("user:manage");
const user = await db.user.findUnique({ where: { id: userId } });
if (!user) return { status: "error", message: "Nutzer nicht gefunden." };
const name = String(formData.get("name") ?? "").trim();
const email = emailSchema.safeParse(String(formData.get("email") ?? "").trim().toLowerCase());
if (!name) return { status: "error", message: "Bitte einen Namen angeben." };
if (!email.success) return { status: "error", message: "Bitte eine gültige E-Mail-Adresse angeben." };
if (email.data !== user.email && (await db.user.findFirst({ where: { email: email.data, id: { not: userId } } }))) {
return { status: "error", message: "Diese E-Mail ist bereits vergeben." };
}
await db.user.update({ where: { id: userId }, data: { name, email: email.data } });
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "user", entityId: userId, after: { name, email: email.data } });
revalidatePath("/settings/users");
return { status: "ok" };
} catch (err) {
return { status: "error", message: actionError(err) };
}
}
export async function setUserStatus(userId: string, status: "ACTIVE" | "DEACTIVATED") {
const { session, tenantId, db } = await ctx("user:manage");
const user = await db.user.findUnique({ where: { id: userId }, include: { userRoles: { include: { role: true } } } });
if (!user) throw new Error("Nutzer nicht gefunden");
if (status === "DEACTIVATED" && user.userRoles.some((ur) => ur.role.key === "tenant-admin") && user.status === "ACTIVE" && (await countActiveTenantAdmins(db)) <= 1) {
throw new Error("Der letzte aktive Mandanten-Admin kann nicht deaktiviert werden.");
}
await db.user.update({ where: { id: userId }, data: { status } });
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "user", entityId: userId, after: { status } });
revalidatePath("/settings/users");
}
export async function setUserRoles(userId: string, formData: FormData) {
const { session, tenantId, db } = await ctx("user:manage");
const roleIds = formData.getAll("roles").map(String).filter(Boolean);
const user = await db.user.findUnique({ where: { id: userId }, include: { userRoles: { include: { role: true } } } });
if (!user) throw new Error("Nutzer nicht gefunden");
if (roleIds.length && (await db.role.count({ where: { id: { in: roleIds } } })) !== roleIds.length) throw new Error("Ungültige Rollenauswahl");
// F-13: Selbstzuweisung darf keine Rechte oberhalb des eigenen Niveaus einbringen.
// Sonst könnte ein tenant-admin (role:manage + user:manage) sich z. B. die ISB-Rolle
// mit policy:approve/risk:accept geben und so die Aufgabentrennung (ISO 27001 A.5.3)
// aushebeln. Die eigenen bereits gehaltenen Rollen sind per Definition ⊆ effektiv,
// bleiben also zulässig; nur das Hinzufügen höher privilegierter Rollen wird geblockt.
if (userId === session.user.id) {
const effective = await actorEffectivePermissions(db, session.user.id);
const targetPerms = await permissionsOfRoles(db, roleIds);
const excess = [...targetPerms].filter((p) => !effective.has(p));
if (excess.length) {
throw new Error(`Selbstzuweisung von Rechten oberhalb des eigenen Niveaus ist nicht zulässig: ${excess.join(", ")}.`);
}
}
const adminRole = await db.role.findFirst({ where: { key: "tenant-admin" } });
const hadAdmin = user.userRoles.some((ur) => ur.role.key === "tenant-admin");
const keepsAdmin = adminRole ? roleIds.includes(adminRole.id) : false;
if (hadAdmin && !keepsAdmin && user.status === "ACTIVE" && (await countActiveTenantAdmins(db)) <= 1) {
throw new Error("Dem letzten aktiven Mandanten-Admin kann die Admin-Rolle nicht entzogen werden.");
}
await db.$transaction([
db.userRole.deleteMany({ where: { userId } }),
db.userRole.createMany({ data: roleIds.map((roleId) => ({ userId, roleId })) }),
]);
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "user_roles", entityId: userId, after: { roleIds } });
revalidatePath("/settings/users");
}
/* ── Rollen (role:manage) ── */
const slugify = (s: string) =>
s.toLowerCase().normalize("NFKD").replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "rolle";
function validPermissionKeys(formData: FormData): string[] {
return formData.getAll("perms").map(String).filter((k) => PERMISSION_SET.has(k));
}
export type RoleFormState = { status: "idle" } | { status: "error"; message: string } | { status: "done" };
/** Eigene Rolle anlegen (Standardrollen-Keys sind reserviert). */
export async function createRole(_prev: RoleFormState, formData: FormData): Promise<RoleFormState> {
const { session, tenantId, db } = await ctx("role:manage");
const name = String(formData.get("name") ?? "").trim();
if (name.length < 2) return { status: "error", message: "Bitte einen Rollennamen (min. 2 Zeichen) angeben." };
const perms = validPermissionKeys(formData);
// F-13 (pragmatische Variante): Delegation ist zulässig — ein Admin darf für ANDERE
// Rollen mit Rechten oberhalb seines eigenen Niveaus anlegen (sonst könnte er keine
// fachlichen Rollen einrichten und keine Standardrollen wie ISB klonen+bearbeiten).
// Die eigentliche Privilege-Escalation (sich selbst hochstufen) wird in setUserRoles
// durch die Selbstzuweisungs-Sperre verhindert. Die Vergabe wird vollständig auditiert.
let key = slugify(name);
if (STANDARD_ROLE_KEYS.has(key)) key = `${key}-eigen`;
// Eindeutigkeit je Mandant sicherstellen.
let suffix = 1;
const base = key;
while (await db.role.findFirst({ where: { key } })) key = `${base}-${suffix++}`;
const role = await db.role.create({ data: { tenantId, key, name } });
if (perms.length) {
const permRows = await db.permission.findMany({ where: { key: { in: perms } } });
await db.rolePermission.createMany({ data: permRows.map((p) => ({ roleId: role.id, permissionId: p.id })) });
}
await writeAuditLog({ tenantId, actorId: session.user.id, action: "create", entity: "role", entityId: role.id, after: { key, name, perms } });
revalidatePath("/settings/users");
return { status: "done" };
}
/** Permissions einer eigenen Rolle setzen (Standardrollen sind schreibgeschützt). */
export async function updateRolePermissions(roleId: string, formData: FormData) {
const { session, tenantId, db } = await ctx("role:manage");
const role = await db.role.findUnique({ where: { id: roleId } });
if (!role) throw new Error("Rolle nicht gefunden");
if (STANDARD_ROLE_KEYS.has(role.key)) throw new Error("Standardrollen sind schreibgeschützt. Bitte klonen und die Kopie anpassen.");
const perms = validPermissionKeys(formData);
// F-13 (pragmatische Variante): Delegation zulässig (s. createRole); Selbst-
// Eskalation ist über die Selbstzuweisungs-Sperre in setUserRoles ausgeschlossen.
const permRows = await db.permission.findMany({ where: { key: { in: perms } } });
await db.$transaction([
db.rolePermission.deleteMany({ where: { roleId } }),
db.rolePermission.createMany({ data: permRows.map((p) => ({ roleId, permissionId: p.id })) }),
]);
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "role", entityId: roleId, after: { perms } });
revalidatePath("/settings/users");
}
/** Rolle klonen (auch Standardrollen) → neue eigene, editierbare Rolle. */
export async function cloneRole(roleId: string, formData: FormData) {
const { session, tenantId, db } = await ctx("role:manage");
const source = await db.role.findUnique({ where: { id: roleId }, include: { rolePermissions: true } });
if (!source) throw new Error("Rolle nicht gefunden");
const name = String(formData.get("name") ?? "").trim() || `${source.name} (Kopie)`;
let key = slugify(name);
if (STANDARD_ROLE_KEYS.has(key)) key = `${key}-eigen`;
let suffix = 1;
const base = key;
while (await db.role.findFirst({ where: { key } })) key = `${base}-${suffix++}`;
const clone = await db.role.create({ data: { tenantId, key, name } });
if (source.rolePermissions.length) {
await db.rolePermission.createMany({ data: source.rolePermissions.map((rp) => ({ roleId: clone.id, permissionId: rp.permissionId })) });
}
await writeAuditLog({ tenantId, actorId: session.user.id, action: "create", entity: "role", entityId: clone.id, after: { clonedFrom: source.key, key, name } });
revalidatePath("/settings/users");
}
/** Rolle löschen: nur eigene Rollen, nur ohne zugewiesene Nutzer (Umzug-Hinweis). */
export async function deleteRole(roleId: string) {
const { session, tenantId, db } = await ctx("role:manage");
const role = await db.role.findUnique({ where: { id: roleId }, include: { _count: { select: { userRoles: true } } } });
if (!role) throw new Error("Rolle nicht gefunden");
if (STANDARD_ROLE_KEYS.has(role.key)) throw new Error("Standardrollen können nicht gelöscht werden.");
if (role._count.userRoles > 0) throw new Error(`Rolle ist ${role._count.userRoles} Nutzer(n) zugewiesen. Bitte zuerst umziehen/entfernen.`);
await db.role.delete({ where: { id: roleId } });
await writeAuditLog({ tenantId, actorId: session.user.id, action: "delete", entity: "role", entityId: roleId, after: { key: role.key } });
revalidatePath("/settings/users");
}
+96
View File
@@ -0,0 +1,96 @@
"use server";
import { cookies } from "next/headers";
import { revalidatePath } from "next/cache";
import { requireSession } from "@/server/auth";
import { prisma } from "@/server/db";
import { writeAuditLog } from "@/server/audit";
import { buildRegistrationOptions, buildAuthenticationOptions, verifyReg, toB64Url, LOGIN_CHALLENGE_COOKIE } from "@/server/webauthn";
import type { RegistrationResponseJSON } from "@simplewebauthn/types";
/**
* SEC3-b: Passkey-Login vorbereiten (öffentlich, pre-session). Erzeugt Authentifizierungs-
* Optionen für discoverable Credentials (leere allowCredentials → der Browser wählt einen
* resident Passkey) und legt die Challenge in einem kurzlebigen httpOnly-Cookie ab.
*/
export async function beginPasskeyLogin() {
const options = await buildAuthenticationOptions([]);
const jar = await cookies();
jar.set(LOGIN_CHALLENGE_COOKIE, options.challenge, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 120,
});
return options;
}
/**
* SEC3-b: Passkey-Registrierung & -Verwaltung des angemeldeten Mandanten-Nutzers
* (EXEMPT vom Modul-Gating — eigene Auth über requireSession). Die Challenge wird
* zwischen Options- und Verify-Schritt in einem kurzlebigen httpOnly-Cookie gehalten.
*/
const REG_COOKIE = "wa_reg_challenge";
export async function beginPasskeyRegistration() {
const session = await requireSession();
// WS4b: Passkeys gehören der GLOBALEN Identity (stabile userID über Mandanten hinweg).
const identityId = session.user.identityId;
if (!identityId) throw new Error("Kein Identity-Kontext in der Session.");
const creds = await prisma.webAuthnCredential.findMany({ where: { identityId }, select: { credentialId: true, transports: true } });
const options = await buildRegistrationOptions({
userId: identityId,
userName: session.user.email ?? identityId,
userDisplayName: session.user.name ?? session.user.email ?? "Nutzer",
existing: creds,
});
const jar = await cookies();
jar.set(REG_COOKIE, options.challenge, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 300,
});
return options;
}
export async function finishPasskeyRegistration(response: RegistrationResponseJSON, deviceName: string) {
const session = await requireSession();
const jar = await cookies();
const expectedChallenge = jar.get(REG_COOKIE)?.value;
if (!expectedChallenge) throw new Error("Registrierung abgelaufen — bitte erneut starten.");
const verification = await verifyReg(response, expectedChallenge);
if (!verification.verified || !verification.registrationInfo) throw new Error("Passkey konnte nicht verifiziert werden.");
const { credentialID, credentialPublicKey, counter } = verification.registrationInfo;
const identityId = session.user.identityId;
if (!identityId) throw new Error("Kein Identity-Kontext in der Session.");
await prisma.webAuthnCredential.create({
data: {
identityId,
credentialId: toB64Url(credentialID),
publicKey: toB64Url(credentialPublicKey),
counter: BigInt(counter),
transports: response.response.transports ?? [],
deviceName: deviceName.trim().slice(0, 60) || null,
},
});
jar.delete(REG_COOKIE);
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "webauthn_credential", entityId: session.user.id, after: { deviceName: deviceName.trim() || null } });
revalidatePath("/account");
}
export async function removePasskey(credentialDbId: string) {
const session = await requireSession();
const identityId = session.user.identityId;
if (!identityId) throw new Error("Kein Identity-Kontext in der Session.");
const cred = await prisma.webAuthnCredential.findFirst({ where: { id: credentialDbId, identityId }, select: { id: true } });
if (!cred) throw new Error("Passkey nicht gefunden.");
await prisma.webAuthnCredential.delete({ where: { id: cred.id } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "webauthn_credential", entityId: cred.id });
revalidatePath("/account");
}