Basis: Certvia dev@a48c5fb als Fundament für Craftvia
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:
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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." };
|
||||
}
|
||||
}
|
||||
@@ -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) } });
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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: [] };
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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." };
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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" } });
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user