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,122 @@
|
||||
import { ForbiddenError } from "@/server/rbac";
|
||||
import { ModuleDisabledError } from "@/server/modules";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
|
||||
/**
|
||||
* F-16 · Error-Boundary für Server Actions.
|
||||
*
|
||||
* Zweck (zwei Dinge auf einmal):
|
||||
* 1. **Nach außen** nur eine generische, nichtssagende Meldung
|
||||
* (`Vorgang nicht möglich.`). Interne Details — Modellnamen, die
|
||||
* Isolationslogik (`Tenant isolation violation: Risk …`), fehlende
|
||||
* Berechtigungen — dürfen den Client nicht erreichen (CWE-209). In der
|
||||
* Produktion maskiert Next.js Server-Action-Fehler zwar ohnehin, in
|
||||
* Entwicklung/Test aber nicht — und wir wollen die Meldung an EINER Stelle
|
||||
* bewusst kappen statt uns auf das Framework zu verlassen.
|
||||
* 2. **Nach innen** vollständige Protokollierung: technischer Fehler ins Log
|
||||
* (`console.error` mit Kontext) und — bei einer Zugriffsverweigerung — ein
|
||||
* strukturierter Audit-Eintrag (`action: "denied"`), damit ein
|
||||
* Verweigerungsereignis nicht als anonymer Stacktrace verschwindet.
|
||||
*
|
||||
* Verhältnis zu den bereits vorhandenen `denied`-Audits:
|
||||
* - **Tenant-Isolationsverletzungen** werden bereits an der Quelle auditiert
|
||||
* (`reportIsolationViolation` in `db.ts`, ebenfalls F-16).
|
||||
* - **Deaktivierte Module** werden bereits in `assertModuleEnabled`
|
||||
* (`modules.ts`) auditiert.
|
||||
* Um doppelte Audit-Zeilen für dasselbe Ereignis zu vermeiden, schreibt diese
|
||||
* Boundary den `denied`-Eintrag NUR für `ForbiddenError` — die RBAC-Verweigerung
|
||||
* aus `requirePermission`, die sonst NIRGENDS protokolliert wird (genau die
|
||||
* Lücke aus F-16). Für `ModuleDisabledError` und Isolationsverletzungen wird die
|
||||
* Meldung weiterhin generisch gekappt, aber nicht erneut auditiert.
|
||||
*
|
||||
* Adoption (bewusst schrittweise — NICHT flächendeckend in diesem Change):
|
||||
* Action-Dateien können ihren Rumpf nach der Guard-/Kontextermittlung wrappen,
|
||||
* z. B.:
|
||||
*
|
||||
* ```ts
|
||||
* export async function submitRiskForReview(fd: FormData) {
|
||||
* const { session, db } = await moduleGuard("risks")("risk:write");
|
||||
* return withActionErrors(
|
||||
* async () => {
|
||||
* // … eigentliche Mutation …
|
||||
* },
|
||||
* { tenantId: session.user.tenantId, actorId: session.user.id, entity: "risk" },
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Der Wrap gehört INNERHALB der Action (nach dem Guard, damit `tenantId`/`actorId`
|
||||
* für den Audit-Kontext bekannt sind). Die flächendeckende Anwendung erfolgt
|
||||
* durch die jeweiligen Fach-Lanes, nicht hier.
|
||||
*/
|
||||
|
||||
/** Einheitliche, nichtssagende Fehlermeldung für den Client (schließt Oracles). */
|
||||
export const GENERIC_ACTION_ERROR = "Vorgang nicht möglich.";
|
||||
|
||||
/** Audit-Kontext, den der Aufrufer nach der Guard-Auswertung bereitstellt. */
|
||||
export type ActionAuditCtx = {
|
||||
/** Mandant, dem der Verweigerungsversuch zugeschrieben wird. */
|
||||
tenantId: string;
|
||||
/** Handelnde Person, falls bekannt (aus der Session). */
|
||||
actorId?: string;
|
||||
/** Fachliche Entität, z. B. "risk", "measure", "policy". */
|
||||
entity: string;
|
||||
/** Optionale ID des betroffenen Objekts. */
|
||||
entityId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Erkennt eine Tenant-Isolationsverletzung (der Guard wirft einen einfachen
|
||||
* `Error` mit stabiler Meldung). Bewusst nachrichtenbasiert, da kein eigener
|
||||
* Fehlertyp existiert.
|
||||
*/
|
||||
export function isTenantIsolationViolation(err: unknown): boolean {
|
||||
return err instanceof Error && /Tenant isolation violation/.test(err.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt `fn` aus, kappt Fehler nach außen auf `GENERIC_ACTION_ERROR` und
|
||||
* protokolliert intern. Bei `ForbiddenError` wird zusätzlich ein
|
||||
* `denied`-Audit geschrieben (best effort — ein fehlgeschlagenes Audit darf die
|
||||
* Fehlerbehandlung nicht sprengen). Der Rückgabewert von `fn` wird im Erfolgsfall
|
||||
* unverändert durchgereicht.
|
||||
*/
|
||||
export async function withActionErrors<T>(
|
||||
fn: () => Promise<T>,
|
||||
ctx: ActionAuditCtx,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
// Nur die RBAC-Verweigerung ist hier NICHT bereits an der Quelle auditiert
|
||||
// → hier nachziehen. Isolations-/Modul-Denials sind bereits protokolliert.
|
||||
if (err instanceof ForbiddenError) {
|
||||
try {
|
||||
await writeAuditLog({
|
||||
tenantId: ctx.tenantId,
|
||||
actorId: ctx.actorId,
|
||||
action: "denied",
|
||||
entity: ctx.entity,
|
||||
entityId: ctx.entityId,
|
||||
after: { reason: err.message },
|
||||
});
|
||||
} catch (auditErr) {
|
||||
console.error("[action] audit-write-failed", ctx.entity, auditErr);
|
||||
}
|
||||
}
|
||||
|
||||
// Vollständige interne Protokollierung (bleibt im Server-Log/Container).
|
||||
const kind =
|
||||
err instanceof ForbiddenError
|
||||
? "forbidden"
|
||||
: err instanceof ModuleDisabledError
|
||||
? "module-disabled"
|
||||
: isTenantIsolationViolation(err)
|
||||
? "tenant-isolation"
|
||||
: "error";
|
||||
console.error(`[action] ${ctx.entity} (${kind})`, err);
|
||||
|
||||
// Nach außen: generisch, ohne interne Details.
|
||||
throw new Error(GENERIC_ACTION_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant, prisma } from "@/server/db";
|
||||
import { ForbiddenError, type Permission } from "@/server/rbac";
|
||||
import { assertModuleEnabled } from "@/server/modules";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
import { isTokenStillValid } from "@/server/sessions";
|
||||
|
||||
/**
|
||||
* Einheitlicher Einstieg für mutierende Server-Actions eines gegateten Moduls (§3.4).
|
||||
* Reihenfolge: Session → Kontostatus/Rechte (autoritativ aus der DB) →
|
||||
* Modul-Aktivierung (wirft bei Deaktivierung + Audit). Gibt Session und den
|
||||
* mandantengebundenen Prisma-Client zurück.
|
||||
*
|
||||
* `moduleGuard("assets")` erzeugt den Guard eines Moduls; jede Action ruft ihn mit
|
||||
* den benötigten Rechten auf, z. B. `const { session, db } = await guard("asset:write")`.
|
||||
* Mehrere Rechte werden alle geprüft (z. B. `guard("risk:write", "measure:write")`).
|
||||
*
|
||||
* F-06: Kontostatus, Passwortzwang und effektive Rechte werden AUTORITATIV aus der
|
||||
* Datenbank geprüft, NICHT aus dem JWT. Sonst blieben ein deaktiviertes Konto und
|
||||
* ein entzogenes Recht bei schreibenden Aktionen bis zum Token-Ablauf wirksam,
|
||||
* obwohl die UI/Layout-Guards (Lesepfade) bereits sperren. Die Layout-Guards
|
||||
* (`(app)/layout.tsx`) decken die Seitenaufrufe ab, dieser Guard die Mutationen.
|
||||
* Kostet eine zusätzliche Query pro Mutation — vertretbar, da Mutationen ohnehin
|
||||
* mehrere Queries ausführen.
|
||||
*
|
||||
* Der Vollständigkeitscheck (`scripts/check-module-guards.ts`) verlässt sich darauf,
|
||||
* dass jede Action eines gegateten Moduls über einen so erzeugten `guard(...)` läuft.
|
||||
*/
|
||||
export function moduleGuard(moduleKey: string) {
|
||||
return async function guard(...permissions: Permission[]) {
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
// Mitgliedschaft (Status ACTIVE) + effektive Rechte des AKTIVEN Mandanten
|
||||
// autoritativ aus der DB (F-06).
|
||||
const account = await db.user.findFirst({
|
||||
where: { id: session.user.id, status: "ACTIVE" },
|
||||
select: {
|
||||
userRoles: {
|
||||
select: {
|
||||
role: {
|
||||
select: {
|
||||
rolePermissions: { select: { permission: { select: { key: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
// Option C (WS4): Passwortzwang, Kill-Switch und globaler Sperrstatus gehören der
|
||||
// GLOBALEN Identity — separat und autoritativ aus der DB.
|
||||
const identity = session.user.identityId
|
||||
? await prisma.identity.findUnique({
|
||||
where: { id: session.user.identityId },
|
||||
select: { status: true, mustChangePassword: true, sessionsValidAfter: true },
|
||||
})
|
||||
: null;
|
||||
if (!account || !identity || identity.status !== "ACTIVE") {
|
||||
// Deaktiviertes/entferntes Konto (Membership ODER Identity) versucht eine
|
||||
// Mutation → als Sicherheitsereignis protokollieren (nicht nur ablehnen).
|
||||
await writeAuditLog({
|
||||
tenantId: session.user.tenantId,
|
||||
actorId: session.user.id,
|
||||
action: "denied",
|
||||
entity: "account_inactive",
|
||||
entityId: session.user.id,
|
||||
});
|
||||
throw new Error("Konto ist nicht aktiv.");
|
||||
}
|
||||
// SEC2: entwertete Session (Kill-Switch an der Identity) darf keine Mutation mehr.
|
||||
if (!isTokenStillValid(session.user.tokenIssuedAt, identity.sessionsValidAfter)) {
|
||||
throw new Error("Sitzung ist nicht mehr gueltig. Bitte neu anmelden.");
|
||||
}
|
||||
if (identity.mustChangePassword) throw new Error("Passwortwechsel erforderlich.");
|
||||
|
||||
if (permissions.length > 0) {
|
||||
const effective = new Set(
|
||||
account.userRoles.flatMap((ur) =>
|
||||
ur.role.rolePermissions.map((rp) => rp.permission.key),
|
||||
),
|
||||
);
|
||||
for (const permission of permissions) {
|
||||
if (!effective.has(permission)) throw new ForbiddenError(permission);
|
||||
}
|
||||
}
|
||||
|
||||
await assertModuleEnabled(session, moduleKey);
|
||||
return { session, db };
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
|
||||
/**
|
||||
* Zentrale, gekapselte LLM-Anbindung (Anthropic). Erste und derzeit einzige
|
||||
* Nutzung: KI-Entwurf der Umsetzungsbeschreibungen (VDA-ISA-ABGABE) im
|
||||
* Audit-Wizard. Bewusst als Singleton mit lazy-Init, damit ein fehlender
|
||||
* API-Key den restlichen Serverstart nicht beeinträchtigt.
|
||||
*
|
||||
* Konfiguration ausschließlich über Umgebungsvariablen:
|
||||
* - ANTHROPIC_API_KEY Pflicht für den KI-Betrieb. Fehlt er, ist die
|
||||
* KI-Anbindung deaktiviert (graceful degradation):
|
||||
* `getAnthropic()` liefert `null`, Aufrufer fallen auf
|
||||
* „manuell zu erfassen" zurück.
|
||||
* - ANTHROPIC_MODEL Optionale Modell-ID; Default `claude-opus-5`.
|
||||
*
|
||||
* Das offizielle SDK liest `ANTHROPIC_API_KEY` selbst aus der Umgebung; wir
|
||||
* prüfen die Existenz dennoch explizit, um den Client nur bei vorhandenem Key
|
||||
* zu instanziieren und die Deaktivierung eindeutig zu machen.
|
||||
*/
|
||||
|
||||
const DEFAULT_MODEL = "claude-opus-5";
|
||||
|
||||
/** Modell-ID für KI-Aufrufe (ENV-Override, sonst Default). */
|
||||
export const AI_MODEL = process.env.ANTHROPIC_MODEL?.trim() || DEFAULT_MODEL;
|
||||
|
||||
let client: Anthropic | null | undefined;
|
||||
|
||||
/** True, wenn ein API-Key gesetzt ist und die KI-Anbindung genutzt werden kann. */
|
||||
export function isAiConfigured(): boolean {
|
||||
return Boolean(process.env.ANTHROPIC_API_KEY?.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert den (gecachten) Anthropic-Client oder `null`, wenn kein API-Key
|
||||
* konfiguriert ist. Aufrufer müssen den `null`-Fall behandeln (kein Entwurf).
|
||||
*/
|
||||
export function getAnthropic(): Anthropic | null {
|
||||
if (client !== undefined) return client;
|
||||
client = isAiConfigured() ? new Anthropic() : null;
|
||||
return client;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { getAnthropic, AI_MODEL } from "./client";
|
||||
|
||||
/**
|
||||
* KI-Entwurf einer „Beschreibung der Umsetzung" je ISA-Teilanforderung im
|
||||
* VDA-ISA-ABGABE-Stil. Aus dem Anforderungstext (MUSS/SOLL) sowie den
|
||||
* verknüpften Dokumenten/Nachweisen wird EIN konkreter „wie-umgesetzt"-Satz mit
|
||||
* Dokumentverweis formuliert. Die Konfidenz wird deterministisch aus der
|
||||
* Quellenlage abgeleitet (nicht vom Modell), damit sie nachvollziehbar bleibt.
|
||||
*
|
||||
* Graceful degradation: Ohne konfigurierten API-Key (oder bei einem Fehler des
|
||||
* Aufrufs) liefert `draftControlDescription` `null` — der Aufrufer belässt den
|
||||
* Status dann bei „manuell zu erfassen".
|
||||
*/
|
||||
|
||||
export interface DraftDocument {
|
||||
code: string;
|
||||
title: string;
|
||||
version: string;
|
||||
/** PolicyStatus (FREIGEGEBEN = validiert). */
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface DraftInput {
|
||||
control: string;
|
||||
reqId: string;
|
||||
/** MUSS | SOLL */
|
||||
obligation: string;
|
||||
/** Anforderungstext (PolicyRequirement.requirement). */
|
||||
requirement: string;
|
||||
/** Umsetzungshinweis (PolicyRequirement.implementation). */
|
||||
implementation: string;
|
||||
/** Verknüpfte Richtlinien/Verfahren. */
|
||||
documents: DraftDocument[];
|
||||
/** Titel vorhandener Nachweise (Evidence) zum Control. */
|
||||
evidence: string[];
|
||||
}
|
||||
|
||||
export interface DraftResult {
|
||||
/** Umsetzungssatz inkl. Dokumentverweis (ABGABE-Stil). */
|
||||
draftText: string;
|
||||
/** Kompakter Quellenverweis (Dokumentliste), semikolon-getrennt. */
|
||||
sourceRef: string | null;
|
||||
confidence: "high" | "medium" | "low";
|
||||
}
|
||||
|
||||
/**
|
||||
* Konfidenz aus der Quellenlage: ein freigegebenes (validiertes) Dokument UND
|
||||
* ein Nachweis ⇒ high; irgendein verknüpftes Dokument ⇒ medium; sonst low.
|
||||
*/
|
||||
function deriveConfidence(input: DraftInput): DraftResult["confidence"] {
|
||||
const hasApprovedDoc = input.documents.some((d) => d.status === "FREIGEGEBEN");
|
||||
const hasEvidence = input.evidence.length > 0;
|
||||
if (hasApprovedDoc && hasEvidence) return "high";
|
||||
if (input.documents.length > 0) return "medium";
|
||||
return "low";
|
||||
}
|
||||
|
||||
/** Semikolon-getrennter Dokumentverweis („R08 – Titel, Version 1.0"). */
|
||||
function buildSourceRef(docs: DraftDocument[]): string | null {
|
||||
if (docs.length === 0) return null;
|
||||
return docs.map((d) => `${d.code} – ${d.title}, Version ${d.version}`).join("; ");
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = [
|
||||
"Du unterstützt bei der Erstellung einer VDA-ISA-Prüfungsdokumentation (TISAX).",
|
||||
"Formuliere für eine einzelne Anforderung EINEN präzisen deutschen Satz im ABGABE-Stil,",
|
||||
"der beschreibt, WIE die Anforderung umgesetzt ist. Der Satz nennt konkrete Spezifika",
|
||||
"aus dem Umsetzungshinweis und schließt mit einem Dokumentverweis in Klammern ab,",
|
||||
"Form: (<Dokument-Code> – <Titel>, Version <X>, Abschnitt <passend>).",
|
||||
"Erfinde keine Fakten, keine Zahlen und keine Abschnitte, die sich nicht aus den",
|
||||
"Eingaben ableiten lassen. Wenn kein Dokument verknüpft ist, lasse den Klammerzusatz weg.",
|
||||
"Antworte ausschließlich mit dem Satz — ohne Aufzählungszeichen, ohne Anführungszeichen,",
|
||||
"ohne Vor- oder Nachbemerkung und ohne interne XML-Tags.",
|
||||
].join(" ");
|
||||
|
||||
function buildUserPrompt(input: DraftInput): string {
|
||||
const docs =
|
||||
input.documents.length > 0
|
||||
? input.documents
|
||||
.map((d) => `- ${d.code}: ${d.title} (Version ${d.version}, Status ${d.status})`)
|
||||
.join("\n")
|
||||
: "- (keine verknüpften Dokumente)";
|
||||
const evidence =
|
||||
input.evidence.length > 0 ? input.evidence.map((e) => `- ${e}`).join("\n") : "- (keine Nachweise vorhanden)";
|
||||
return [
|
||||
`Control: ${input.control} (${input.reqId}), Verbindlichkeit: ${input.obligation}`,
|
||||
`Anforderung: ${input.requirement}`,
|
||||
`Umsetzungshinweis: ${input.implementation || "(keiner)"}`,
|
||||
`Verknüpfte Dokumente:\n${docs}`,
|
||||
`Vorhandene Nachweise:\n${evidence}`,
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt den KI-Entwurf. Liefert `null`, wenn keine KI konfiguriert ist oder
|
||||
* der Aufruf fehlschlägt (kein Fehler-Spam; der Aufrufer degradiert sauber).
|
||||
*/
|
||||
export async function draftControlDescription(input: DraftInput): Promise<DraftResult | null> {
|
||||
const client = getAnthropic();
|
||||
if (!client) return null;
|
||||
|
||||
try {
|
||||
const response = await client.messages.create({
|
||||
model: AI_MODEL,
|
||||
max_tokens: 1024,
|
||||
system: SYSTEM_PROMPT,
|
||||
messages: [{ role: "user", content: buildUserPrompt(input) }],
|
||||
});
|
||||
|
||||
if (response.stop_reason === "refusal") return null;
|
||||
const text = response.content
|
||||
.filter((b): b is Extract<typeof b, { type: "text" }> => b.type === "text")
|
||||
.map((b) => b.text)
|
||||
.join("")
|
||||
.trim();
|
||||
if (!text) return null;
|
||||
|
||||
return {
|
||||
draftText: text,
|
||||
sourceRef: buildSourceRef(input.documents),
|
||||
confidence: deriveConfidence(input),
|
||||
};
|
||||
} catch {
|
||||
// Netzwerk-/API-Fehler dürfen die UI nicht blockieren.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { dbForTenant } from "@/server/db";
|
||||
|
||||
/**
|
||||
* Assessment-Level (AL2/AL3) — **einzige Quelle der Wahrheit** für den Schutzbedarf
|
||||
* (Story A2-1). Wird ausschließlich im Admin-/Superadmin-Portal je Mandant gesetzt
|
||||
* (`setTenantTisaxLevel`, Feld `TenantSettings.tisaxLevel`). Im Tenant und im Wizard
|
||||
* nur read-only. Von hier leiten sich die zentralen Schutzbedarf-Flags ab — dieselbe
|
||||
* Ableitung nutzen Admin-Persistierung (policyVariable) **und** der Wizard-Seed.
|
||||
*/
|
||||
type TenantDb = ReturnType<typeof dbForTenant>;
|
||||
|
||||
export type AssessmentLevel = "AL2" | "AL3";
|
||||
|
||||
/** Aktuelles Assessment-Level des Mandanten (Default AL2). */
|
||||
export async function getAssessmentLevel(db: TenantDb): Promise<AssessmentLevel> {
|
||||
const s = await db.tenantSettings.findFirst({ select: { tisaxLevel: true } });
|
||||
return s?.tisaxLevel === "AL3" ? "AL3" : "AL2";
|
||||
}
|
||||
|
||||
/**
|
||||
* AL → zentrale Schutzbedarf-Flags. HIGH ist im TISAX-Modell stets aktiv (AL2-Baseline),
|
||||
* VERY_HIGH nur bei AL3; ELEVATED ist abgeleitet (HIGH || VERY_HIGH).
|
||||
*/
|
||||
export function protectionFlags(level: AssessmentLevel): Record<string, boolean> {
|
||||
return {
|
||||
FLAG_HIGH_PROTECTION: true,
|
||||
FLAG_VERY_HIGH_PROTECTION: level === "AL3",
|
||||
FLAG_ELEVATED_PROTECTION: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// B1 — buildAssessment(db, tenantId, framework): der framework-parametrisierte
|
||||
// Einstieg in die Bewertung. Delegiert an die Strategie (Feindesign §3). Die
|
||||
// Belegauflösung (loadEvidenceResolver, Schicht 1) ist GETEILT und kennt kein Framework.
|
||||
//
|
||||
// TISAX bleibt bewusst über buildControlRows (unveränderte, snapshot-gesicherte Ausgabe,
|
||||
// §7a Regressionsschutz) — buildAssessment("TISAX") adaptiert sie in AssessmentRow.
|
||||
// ISO rechnet über die SoA-Anwendbarkeit + den geteilten Belegstatus.
|
||||
|
||||
import type { TenantDb } from "@/server/db";
|
||||
import { buildControlRows, loadEvidenceResolver } from "@/server/soa-context";
|
||||
import { controlSpecIso, ISO_SPEC_CONTROLS } from "@/lib/control-specs-iso";
|
||||
import { interpretationBand, averageReifegrad, type ControlAssessment } from "@/lib/readiness";
|
||||
import {
|
||||
isoSuggestStatus, isoGaps, isoBand,
|
||||
type FrameworkKey, type ImplStatus, type AssessmentRow, type ReadinessSummary,
|
||||
} from "@/lib/assessment";
|
||||
|
||||
export interface Assessment {
|
||||
framework: FrameworkKey;
|
||||
rows: AssessmentRow[];
|
||||
summary: ReadinessSummary;
|
||||
}
|
||||
|
||||
const nf1 = (n: number) => n.toLocaleString("de-DE", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
|
||||
|
||||
/** TISAX-Bewertung: verhaltensgleich zu buildControlRows, in AssessmentRow gegossen. */
|
||||
async function buildTisaxAssessment(db: TenantDb, tenantId: string): Promise<Assessment> {
|
||||
const { rows } = await buildControlRows(db, tenantId);
|
||||
const assessmentRows: AssessmentRow[] = rows.map((r) => ({
|
||||
control: r.control,
|
||||
title: r.spec.title,
|
||||
spec: r.spec,
|
||||
evidence: r.evidence,
|
||||
verdict: { kind: "maturity", suggestion: r.suggestion, confirmed: r.confirmed, target: r.target },
|
||||
gaps: r.gaps,
|
||||
}));
|
||||
|
||||
// Ø-Reifegrad wie in der Readiness-Sicht (unbestätigt zählt mit 0, C9 §1).
|
||||
const ca: ControlAssessment[] = rows.map((r) => ({
|
||||
control: r.control,
|
||||
chapter: r.control.split(".")[0],
|
||||
reifegrad: r.confirmed ?? 0,
|
||||
bestaetigt: r.confirmed != null,
|
||||
}));
|
||||
const avg = averageReifegrad(ca);
|
||||
const band = interpretationBand(avg ?? 0);
|
||||
const fulfilled = rows.filter((r) => r.confirmed != null && r.confirmed >= r.target).length;
|
||||
|
||||
return {
|
||||
framework: "TISAX",
|
||||
rows: assessmentRows,
|
||||
summary: {
|
||||
framework: "TISAX",
|
||||
band: band.band,
|
||||
tone: band.tone,
|
||||
text: band.text,
|
||||
metricLabel: "Ø-Reifegrad",
|
||||
metricValue: avg == null ? "—" : nf1(avg),
|
||||
total: rows.length,
|
||||
fulfilled,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const isAnnex = (control: string) => control.startsWith("A.");
|
||||
|
||||
/** ISO-Bewertung: Scope aus der SoA-Anwendbarkeit + Klauseln 4–10 (immer), Status statt Reifegrad. */
|
||||
async function buildIsoAssessment(db: TenantDb): Promise<Assessment> {
|
||||
const soa = await db.soaEntry.findMany({
|
||||
where: { framework: "ISO_27001" },
|
||||
select: { control: true, applicable: true, justification: true, implementationStatus: true },
|
||||
});
|
||||
const soaByControl = new Map(soa.map((s) => [s.control, s]));
|
||||
// Belegauflösung ist geteilt und framework-blind; ISO nutzt keinen Hinweis-Nachweis
|
||||
// (operationalProof) → leeres completeControls.
|
||||
const resolve = await loadEvidenceResolver(db, new Set());
|
||||
|
||||
const inScope = ISO_SPEC_CONTROLS.filter((c) => !isAnnex(c) || (soaByControl.get(c)?.applicable ?? false));
|
||||
const rows: AssessmentRow[] = inScope.map((control) => {
|
||||
const spec = controlSpecIso(control);
|
||||
const ev = resolve(spec);
|
||||
const entry = soaByControl.get(control);
|
||||
const applicable = isAnnex(control) ? (entry?.applicable ?? false) : true;
|
||||
const confirmed = (entry?.implementationStatus as ImplStatus | undefined) ?? null;
|
||||
const suggested = isoSuggestStatus(spec, ev);
|
||||
// Klauseln 4–10 sind nicht Gegenstand der SoA-Anwendbarkeit → keine Begründungslücke.
|
||||
const justification = isAnnex(control) ? (entry?.justification ?? "") : "(Klausel — immer anwendbar)";
|
||||
return {
|
||||
control,
|
||||
title: spec.title,
|
||||
spec,
|
||||
evidence: ev,
|
||||
verdict: { kind: "status", applicable, suggested, confirmed },
|
||||
gaps: isoGaps(spec, ev, applicable, justification, confirmed),
|
||||
};
|
||||
});
|
||||
|
||||
// Leere SoA → keine Anwendbarkeitsaussage (DoD: nicht „0 %", sondern Hinweis).
|
||||
if (soa.length === 0) {
|
||||
return {
|
||||
framework: "ISO_27001",
|
||||
rows,
|
||||
summary: {
|
||||
framework: "ISO_27001", band: "Anwendbarkeit offen", tone: "mut",
|
||||
text: "Die Anwendbarkeit der Controls ist noch nicht erklärt. Bitte zuerst die SoA ausfüllen (/soa) — danach rechnet die Readiness.",
|
||||
metricLabel: "Umsetzungsgrad", metricValue: "—", total: rows.length, fulfilled: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Effektiver Status: bestätigter SoA-Status, sonst der evidenzbasierte Vorschlag
|
||||
// (Klauseln haben keine SoA-Bestätigung → Vorschlag).
|
||||
const effective = (r: AssessmentRow): ImplStatus =>
|
||||
r.verdict.kind === "status" ? (r.verdict.confirmed ?? r.verdict.suggested) : "geplant";
|
||||
const fulfilled = rows.filter((r) => effective(r) === "umgesetzt").length;
|
||||
const ratio = rows.length ? fulfilled / rows.length : 0;
|
||||
const band = isoBand(ratio);
|
||||
|
||||
return {
|
||||
framework: "ISO_27001",
|
||||
rows,
|
||||
summary: {
|
||||
framework: "ISO_27001",
|
||||
band: band.band, tone: band.tone, text: band.text,
|
||||
metricLabel: "Umsetzungsgrad",
|
||||
metricValue: `${Math.round(ratio * 100)} %`,
|
||||
total: rows.length,
|
||||
fulfilled,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Framework-parametrisierte Bewertung. TISAX bleibt snapshot-identisch, ISO rechnet über die SoA. */
|
||||
export async function buildAssessment(db: TenantDb, tenantId: string, framework: FrameworkKey): Promise<Assessment> {
|
||||
return framework === "ISO_27001" ? buildIsoAssessment(db) : buildTisaxAssessment(db, tenantId);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { prisma } from "./db";
|
||||
|
||||
/**
|
||||
* Audit trail (SPEC §5 AuditLog, §10): every writing action creates an entry.
|
||||
* Uses the raw client on purpose — audit writes must never be silently
|
||||
* filtered, and tenantId is passed explicitly by the caller.
|
||||
*/
|
||||
export async function writeAuditLog(entry: {
|
||||
tenantId: string;
|
||||
actorId?: string;
|
||||
action: "create" | "update" | "delete" | "login" | "logout" | "export" | "import" | "denied";
|
||||
scope?: "tenant" | "platform";
|
||||
entity: string;
|
||||
entityId?: string;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
}) {
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
tenantId: entry.tenantId,
|
||||
scope: entry.scope ?? "tenant",
|
||||
actorId: entry.actorId,
|
||||
action: entry.action,
|
||||
entity: entry.entity,
|
||||
entityId: entry.entityId,
|
||||
before: entry.before as object | undefined,
|
||||
after: entry.after as object | undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit-Eintrag der Plattform-Ebene (kein Mandantenbezug, scope="platform").
|
||||
* Für Superadmin-Anmeldungen und -Aktionen (Phase-1-Härtung Paket 2).
|
||||
*/
|
||||
export async function writePlatformAudit(entry: {
|
||||
actorId?: string;
|
||||
action: "create" | "update" | "delete" | "login" | "logout" | "export" | "import" | "denied";
|
||||
entity: string;
|
||||
entityId?: string;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
}) {
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
tenantId: null,
|
||||
scope: "platform",
|
||||
actorId: entry.actorId,
|
||||
action: entry.action,
|
||||
entity: entry.entity,
|
||||
entityId: entry.entityId,
|
||||
before: entry.before as object | undefined,
|
||||
after: entry.after as object | undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import { headers } from "next/headers";
|
||||
import { prisma } from "@/server/db";
|
||||
import { absoluteUrl } from "@/server/mail/config";
|
||||
import { enqueueMail, type EnqueueResult } from "@/server/mail/service";
|
||||
import { formatWhen, normalizeLocale, type Locale } from "@/server/mail/templates";
|
||||
|
||||
/**
|
||||
* SEC2 — gemeinsame Bausteine für Mandanten-Nutzer **und** Plattform-Admins.
|
||||
*
|
||||
* Die beiden Auth-Domänen haben getrennte Stores (`User` / `PlatformAdmin`) und
|
||||
* getrennte Logins. Damit die Self-Service-Abläufe nicht doppelt existieren,
|
||||
* abstrahiert dieses Modul das Konto auf das, was Reset und E-Mail-Änderung
|
||||
* brauchen: Kennung, Adresse, Name, Aktivstatus, Sprache.
|
||||
*/
|
||||
|
||||
export type Domain = "tenant" | "platform";
|
||||
|
||||
export function principalTypeOf(domain: Domain): "identity" | "platform_admin" {
|
||||
// Option C (WS4): Mandanten-Prinzipal = globale Identity.
|
||||
return domain === "platform" ? "platform_admin" : "identity";
|
||||
}
|
||||
|
||||
/** Login-Pfad der jeweiligen Domäne (Ziel nach Reset). */
|
||||
export function loginPathOf(domain: Domain): string {
|
||||
return domain === "platform" ? "/platform/login" : "/login";
|
||||
}
|
||||
|
||||
export type Account = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
tenantId: string | null;
|
||||
locale: Locale;
|
||||
/** Nur aktive Konten dürfen zurücksetzen (SEC2 §6). */
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sucht ein Konto anhand der E-Mail-Adresse.
|
||||
*
|
||||
* **Mandantenseite:** die Adresse ist nur *je Mandant* eindeutig. Existiert sie
|
||||
* in mehreren Mandanten, ist ohne weitere Angabe nicht entscheidbar, welches
|
||||
* Konto gemeint ist — dann wird bewusst **kein** Reset ausgelöst. Nach außen ist
|
||||
* das nicht unterscheidbar, weil die Antwort ohnehin immer gleich lautet.
|
||||
*/
|
||||
export async function findAccountByEmail(
|
||||
domain: Domain,
|
||||
email: string,
|
||||
): Promise<Account | null> {
|
||||
const normalized = email.trim().toLowerCase();
|
||||
if (!normalized) return null;
|
||||
|
||||
if (domain === "platform") {
|
||||
const admin = await prisma.platformAdmin.findUnique({ where: { email: normalized } });
|
||||
if (!admin) return null;
|
||||
return {
|
||||
id: admin.id,
|
||||
email: admin.email,
|
||||
name: admin.name,
|
||||
tenantId: null,
|
||||
locale: "de",
|
||||
active: admin.status === "ACTIVE",
|
||||
};
|
||||
}
|
||||
|
||||
// Option C (WS4): Konto = globale Identity (E-Mail global eindeutig → keine
|
||||
// Mehrdeutigkeit mehr). Name/Sprache stammen aus einer aktiven Mitgliedschaft
|
||||
// (nur für Mail-Anrede/Locale).
|
||||
const identity = await prisma.identity.findUnique({ where: { email: normalized } });
|
||||
if (!identity) return null;
|
||||
const membership = await prisma.user.findFirst({
|
||||
where: { identityId: identity.id, status: "ACTIVE", tenant: { status: "ACTIVE" } },
|
||||
include: { tenant: { select: { id: true, settings: { select: { locale: true } } } } },
|
||||
});
|
||||
return {
|
||||
id: identity.id,
|
||||
email: identity.email,
|
||||
name: membership?.name ?? identity.email.split("@")[0]!,
|
||||
tenantId: membership?.tenantId ?? null,
|
||||
locale: normalizeLocale(membership?.tenant.settings?.locale),
|
||||
active: identity.status === "ACTIVE" && (!identity.lockedUntil || identity.lockedUntil <= new Date()),
|
||||
};
|
||||
}
|
||||
|
||||
/** Konto über die Kennung laden (nach Token-Einlösung). */
|
||||
export async function findAccountById(domain: Domain, id: string): Promise<Account | null> {
|
||||
if (domain === "platform") {
|
||||
const admin = await prisma.platformAdmin.findUnique({ where: { id } });
|
||||
if (!admin) return null;
|
||||
return {
|
||||
id: admin.id,
|
||||
email: admin.email,
|
||||
name: admin.name,
|
||||
tenantId: null,
|
||||
locale: "de",
|
||||
active: admin.status === "ACTIVE",
|
||||
};
|
||||
}
|
||||
const identity = await prisma.identity.findUnique({ where: { id } });
|
||||
if (!identity) return null;
|
||||
const membership = await prisma.user.findFirst({
|
||||
where: { identityId: identity.id, status: "ACTIVE" },
|
||||
include: { tenant: { select: { id: true, settings: { select: { locale: true } } } } },
|
||||
});
|
||||
return {
|
||||
id: identity.id,
|
||||
email: identity.email,
|
||||
name: membership?.name ?? identity.email.split("@")[0]!,
|
||||
tenantId: membership?.tenantId ?? null,
|
||||
locale: normalizeLocale(membership?.tenant.settings?.locale),
|
||||
active: identity.status === "ACTIVE",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt einen neuen Passwort-Hash und räumt die Nebenbedingungen auf:
|
||||
* Force-Change-Flag, Fehlversuchszähler und Sperre.
|
||||
*/
|
||||
export async function writePasswordHash(
|
||||
domain: Domain,
|
||||
id: string,
|
||||
passwordHash: string,
|
||||
): Promise<void> {
|
||||
if (domain === "platform") {
|
||||
await prisma.platformAdmin.update({
|
||||
where: { id },
|
||||
data: { passwordHash, failedLogins: 0, lockedUntil: null },
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Option C (WS4): Passwort gehört der Identity (id = Identity.id).
|
||||
await prisma.identity.update({
|
||||
where: { id },
|
||||
data: { passwordHash, mustChangePassword: false, failedLogins: 0, lockedUntil: null },
|
||||
});
|
||||
}
|
||||
|
||||
/** Aktuellen Passwort-Hash lesen (Alt-Passwort-Prüfung). */
|
||||
export async function readPasswordHash(domain: Domain, id: string): Promise<string | null> {
|
||||
if (domain === "platform") {
|
||||
const row = await prisma.platformAdmin.findUnique({
|
||||
where: { id },
|
||||
select: { passwordHash: true },
|
||||
});
|
||||
return row?.passwordHash ?? null;
|
||||
}
|
||||
const row = await prisma.identity.findUnique({ where: { id }, select: { passwordHash: true } });
|
||||
return row?.passwordHash ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist die Zieladresse frei?
|
||||
*
|
||||
* Plattformweit für Admins, **mandantenweit** für Nutzer (`@@unique([tenantId,
|
||||
* email])`). Das Ergebnis wird nie direkt nach außen gemeldet — der Aufrufer
|
||||
* antwortet generisch (Enumeration-Schutz).
|
||||
*/
|
||||
export async function isEmailAvailable(
|
||||
domain: Domain,
|
||||
email: string,
|
||||
tenantId: string | null,
|
||||
selfId: string,
|
||||
): Promise<boolean> {
|
||||
const normalized = email.trim().toLowerCase();
|
||||
if (domain === "platform") {
|
||||
const existing = await prisma.platformAdmin.findUnique({ where: { email: normalized } });
|
||||
return !existing || existing.id === selfId;
|
||||
}
|
||||
// Option C: E-Mail-Änderung für Mandanten-Konten (Identity) ist bewusst Phase 2
|
||||
// (FEINDESIGN §13) — der Tenant-Flow ist deaktiviert (siehe auth-recovery.ts),
|
||||
// daher hier fail-closed.
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Adresse setzen (nach bestätigter Änderung). */
|
||||
export async function writeEmail(domain: Domain, id: string, email: string): Promise<void> {
|
||||
const normalized = email.trim().toLowerCase();
|
||||
if (domain === "platform") {
|
||||
await prisma.platformAdmin.update({ where: { id }, data: { email: normalized } });
|
||||
return;
|
||||
}
|
||||
// Option C: E-Mail-Änderung als Identity-Operation ist Phase 2 (nicht mitbauen).
|
||||
throw new Error("E-Mail-Änderung für Mandanten-Konten ist derzeit nicht verfügbar (Phase 2).");
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-IP für Rate-Limit und Audit. Hinter einem Reverse Proxy steht sie in
|
||||
* `x-forwarded-for` (erster Eintrag). Bewusst nur als Hinweis verwendet — der
|
||||
* Header ist fälschbar und trägt keine Sicherheitsentscheidung allein.
|
||||
*/
|
||||
export async function clientIp(): Promise<string | null> {
|
||||
try {
|
||||
const h = await headers();
|
||||
const forwarded = h.get("x-forwarded-for");
|
||||
if (forwarded) return forwarded.split(",")[0]!.trim();
|
||||
return h.get("x-real-ip");
|
||||
} catch {
|
||||
// `headers()` wirft außerhalb eines Requests (Skript, Worker, Test). Die IP
|
||||
// ist nur ein Zusatzsignal für Rate-Limit und Audit — ohne sie greift der
|
||||
// kontobezogene Zähler weiterhin.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Bestätigungsmail „Passwort geändert" (nicht abbestellbar). */
|
||||
export async function sendPasswordChangedMail(
|
||||
account: Account,
|
||||
ip: string | null,
|
||||
): Promise<void> {
|
||||
await enqueueMail({
|
||||
template: "password_changed",
|
||||
to: account.email,
|
||||
tenantId: account.tenantId,
|
||||
locale: account.locale,
|
||||
vars: {
|
||||
name: account.name,
|
||||
when: formatWhen(new Date(), account.locale),
|
||||
ip: ip ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Reset-Link versenden. Das Rohtoken existiert nur in dieser URL. */
|
||||
export async function sendPasswordResetMail(
|
||||
account: Account,
|
||||
rawToken: string,
|
||||
expiresAt: Date,
|
||||
domain: Domain,
|
||||
): Promise<void> {
|
||||
const url = absoluteUrl(
|
||||
`/reset?token=${encodeURIComponent(rawToken)}${domain === "platform" ? "&domain=platform" : ""}`,
|
||||
);
|
||||
await enqueueMail({
|
||||
template: "password_reset",
|
||||
to: account.email,
|
||||
tenantId: account.tenantId,
|
||||
locale: account.locale,
|
||||
vars: {
|
||||
name: account.name,
|
||||
actionUrl: url,
|
||||
expires: formatWhen(expiresAt, account.locale),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* SEC-Invite: Einladungsmail an einen neu angelegten Mandanten-Nutzer. Der Link
|
||||
* führt auf dieselbe /reset-Seite (password_reset-Token) — der Eingeladene setzt
|
||||
* dort sein eigenes Passwort. Gibt das Enqueue-Ergebnis zurück, damit der Aufrufer
|
||||
* bei nicht möglichem Versand auf ein angezeigtes Passwort zurückfallen kann.
|
||||
*/
|
||||
export async function sendUserInvitationMail(input: {
|
||||
to: string;
|
||||
name: string;
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
rawToken: string;
|
||||
expiresAt: Date;
|
||||
locale?: string | null;
|
||||
}): Promise<EnqueueResult> {
|
||||
const locale = normalizeLocale(input.locale);
|
||||
// Option C (WS3): dedizierte Einladungsseite statt Zweckentfremdung von /reset.
|
||||
const url = absoluteUrl(`/invite?token=${encodeURIComponent(input.rawToken)}`);
|
||||
return enqueueMail({
|
||||
template: "invitation",
|
||||
to: input.to,
|
||||
tenantId: input.tenantId,
|
||||
locale,
|
||||
vars: {
|
||||
name: input.name,
|
||||
tenantName: input.tenantName,
|
||||
actionUrl: url,
|
||||
expires: formatWhen(input.expiresAt, locale),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Verifizierungslink an die NEUE Adresse. */
|
||||
export async function sendEmailChangeVerifyMail(
|
||||
account: Account,
|
||||
newEmail: string,
|
||||
rawToken: string,
|
||||
expiresAt: Date,
|
||||
domain: Domain,
|
||||
): Promise<void> {
|
||||
const url = absoluteUrl(
|
||||
`/verify-email?token=${encodeURIComponent(rawToken)}${domain === "platform" ? "&domain=platform" : ""}`,
|
||||
);
|
||||
await enqueueMail({
|
||||
template: "email_change_verify",
|
||||
// Geht bewusst an die NEUE Adresse — sie ist der zu bestätigende Kanal.
|
||||
to: newEmail,
|
||||
tenantId: account.tenantId,
|
||||
locale: account.locale,
|
||||
vars: {
|
||||
name: account.name,
|
||||
actionUrl: url,
|
||||
expires: formatWhen(expiresAt, account.locale),
|
||||
newEmail,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Hinweis an die ALTE Adresse, nachdem die Änderung wirksam wurde. */
|
||||
export async function sendEmailChangedNotice(
|
||||
account: Account,
|
||||
oldEmail: string,
|
||||
newEmail: string,
|
||||
): Promise<void> {
|
||||
await enqueueMail({
|
||||
template: "email_changed_notice",
|
||||
to: oldEmail,
|
||||
tenantId: account.tenantId,
|
||||
locale: account.locale,
|
||||
vars: {
|
||||
name: account.name,
|
||||
newEmail,
|
||||
when: formatWhen(new Date(), account.locale),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { prisma } from "@/server/db";
|
||||
|
||||
/**
|
||||
* SEC2 — Einmal-Tokens für Passwort-Reset und E-Mail-Änderung.
|
||||
*
|
||||
* Regeln (verbindlich, SEC2 §1/§6):
|
||||
* - Rohtoken = 32 Byte CSPRNG, base64url. Es existiert **nur** im Link und in
|
||||
* der Rückgabe von `issueToken()`; gespeichert wird ausschließlich der
|
||||
* SHA-256-Hash. Nie loggen, nie ins MailLog.
|
||||
* - Lookup über den Hash (unique). Der anschließende Vergleich läuft in
|
||||
* konstanter Zeit — auch wenn der Index-Lookup bereits eindeutig ist, hält
|
||||
* das die Prüfung frei von Timing-Unterschieden.
|
||||
* - **Single-use**: beim Einlösen wird `usedAt` gesetzt, und zwar über ein
|
||||
* bedingtes `updateMany` (nur wenn `usedAt` noch NULL ist). Zwei parallele
|
||||
* Einlösungen desselben Tokens können so nicht beide gewinnen.
|
||||
* - Beim Neuanfordern werden offene Tokens desselben Typs und Prinzipals
|
||||
* entwertet.
|
||||
*
|
||||
* Alle Zugriffe laufen über den **rohen** `prisma`-Client: Einlösung passiert
|
||||
* ohne Session (der Nutzer ist gerade nicht angemeldet) und damit ohne
|
||||
* Mandantenkontext. Die RLS-Policy auf `auth_tokens` bleibt als zweite
|
||||
* Verteidigungslinie bestehen.
|
||||
*/
|
||||
|
||||
// Option C (WS4): der Mandanten-Prinzipal ist jetzt die GLOBALE Identity
|
||||
// (Passwort/Reset gehören der Identity, nicht mehr der per-Mandant-Mitgliedschaft).
|
||||
// "tenant_user" bleibt für Bestands-/Kompatibilität im Typ, wird aber nicht mehr
|
||||
// ausgestellt (principalTypeOf(tenant) → "identity").
|
||||
export type PrincipalType = "identity" | "tenant_user" | "platform_admin";
|
||||
export type TokenType = "password_reset" | "email_change" | "invitation";
|
||||
|
||||
/** Gültigkeitsdauer je Typ (SEC2 §1: Reset 30–60 Min, Verify 60 Min; Einladung 7 Tage). */
|
||||
const TTL_MINUTES: Record<TokenType, number> = {
|
||||
password_reset: 60,
|
||||
email_change: 60,
|
||||
invitation: 7 * 24 * 60,
|
||||
};
|
||||
|
||||
function hashToken(raw: string): string {
|
||||
return createHash("sha256").update(raw).digest("hex");
|
||||
}
|
||||
|
||||
/** Konstante-Zeit-Vergleich zweier Hex-Hashes gleicher Länge. */
|
||||
function hashesEqual(a: string, b: string): boolean {
|
||||
const bufA = Buffer.from(a, "hex");
|
||||
const bufB = Buffer.from(b, "hex");
|
||||
if (bufA.length !== bufB.length) return false;
|
||||
return timingSafeEqual(bufA, bufB);
|
||||
}
|
||||
|
||||
export type IssuedToken = { raw: string; expiresAt: Date };
|
||||
|
||||
/**
|
||||
* Erzeugt ein neues Token und entwertet offene Tokens desselben Typs.
|
||||
* Gibt das **Rohtoken** zurück — der einzige Moment, in dem es existiert.
|
||||
*/
|
||||
export async function issueToken(input: {
|
||||
principalType: PrincipalType;
|
||||
principalId: string;
|
||||
tenantId?: string | null;
|
||||
type: TokenType;
|
||||
newEmail?: string;
|
||||
requestIp?: string | null;
|
||||
/** Überschreibt die Default-TTL des Typs (z. B. längerer Einladungslink). */
|
||||
ttlMinutes?: number;
|
||||
}): Promise<IssuedToken> {
|
||||
// Offene Tokens desselben Typs entwerten: eine neue Anforderung macht die
|
||||
// vorherige ungültig (verhindert mehrere gleichzeitig gültige Links).
|
||||
await prisma.authToken.updateMany({
|
||||
where: {
|
||||
principalType: input.principalType,
|
||||
principalId: input.principalId,
|
||||
type: input.type,
|
||||
usedAt: null,
|
||||
},
|
||||
data: { usedAt: new Date() },
|
||||
});
|
||||
|
||||
const raw = randomBytes(32).toString("base64url");
|
||||
const expiresAt = new Date(Date.now() + (input.ttlMinutes ?? TTL_MINUTES[input.type]) * 60_000);
|
||||
|
||||
await prisma.authToken.create({
|
||||
data: {
|
||||
principalType: input.principalType,
|
||||
principalId: input.principalId,
|
||||
tenantId: input.tenantId ?? null,
|
||||
type: input.type,
|
||||
tokenHash: hashToken(raw),
|
||||
newEmail: input.newEmail,
|
||||
expiresAt,
|
||||
requestIp: input.requestIp ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return { raw, expiresAt };
|
||||
}
|
||||
|
||||
export type ResolvedToken = {
|
||||
id: string;
|
||||
principalType: PrincipalType;
|
||||
principalId: string;
|
||||
tenantId: string | null;
|
||||
type: TokenType;
|
||||
newEmail: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prüft ein Rohtoken, **ohne** es zu verbrauchen (für die Anzeige des
|
||||
* Reset-Formulars). Liefert `null` bei jedem Fehlerfall — der Aufrufer gibt
|
||||
* daraufhin eine generische Meldung aus, nie den konkreten Grund.
|
||||
*/
|
||||
export async function peekToken(raw: string, type: TokenType): Promise<ResolvedToken | null> {
|
||||
if (!raw) return null;
|
||||
const hash = hashToken(raw);
|
||||
const row = await prisma.authToken.findUnique({ where: { tokenHash: hash } });
|
||||
if (!row) return null;
|
||||
if (!hashesEqual(row.tokenHash, hash)) return null;
|
||||
if (row.type !== type) return null;
|
||||
if (row.usedAt) return null;
|
||||
if (row.expiresAt <= new Date()) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
principalType: row.principalType as PrincipalType,
|
||||
principalId: row.principalId,
|
||||
tenantId: row.tenantId,
|
||||
type: row.type as TokenType,
|
||||
newEmail: row.newEmail,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Löst ein Token ein und markiert es als verbraucht.
|
||||
*
|
||||
* Das `updateMany` mit `usedAt: null` in der Bedingung ist die eigentliche
|
||||
* Sperre: gewinnt ein paralleler Aufruf, ändert dieser Aufruf 0 Zeilen und
|
||||
* bekommt `null` — der Token wird garantiert nur einmal wirksam.
|
||||
*/
|
||||
export async function consumeToken(raw: string, type: TokenType): Promise<ResolvedToken | null> {
|
||||
const token = await peekToken(raw, type);
|
||||
if (!token) return null;
|
||||
|
||||
const result = await prisma.authToken.updateMany({
|
||||
where: { id: token.id, usedAt: null },
|
||||
data: { usedAt: new Date() },
|
||||
});
|
||||
if (result.count !== 1) return null;
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Aufräumen abgelaufener/verbrauchter Tokens (für einen späteren Wartungsjob). */
|
||||
export async function purgeExpiredTokens(before: Date = new Date()): Promise<number> {
|
||||
const { count } = await prisma.authToken.deleteMany({
|
||||
where: { OR: [{ expiresAt: { lt: before } }, { usedAt: { not: null } }] },
|
||||
});
|
||||
return count;
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import { cookies } from "next/headers";
|
||||
import { z } from "zod";
|
||||
import { Prisma, type Identity } from "@prisma/client";
|
||||
import { prisma } from "./db";
|
||||
import { verifyTotp, matchRecovery } from "./mfa";
|
||||
import { verifyAuth, LOGIN_CHALLENGE_COOKIE } from "./webauthn";
|
||||
import { hashPassword, verifyPassword } from "./password";
|
||||
import { writeAuditLog } from "./audit";
|
||||
import { assertSecureEnv } from "./env";
|
||||
import { verifyLoginTicket } from "./login-ticket";
|
||||
import type { AuthenticationResponseJSON } from "@simplewebauthn/types";
|
||||
import type { SessionMembership } from "@/types/next-auth";
|
||||
|
||||
/**
|
||||
* Auth.js (NextAuth v5), Mandanten-Login. Umbau Option C (WS1): authentifiziert
|
||||
* gegen die GLOBALE `Identity` (E-Mail + ein Passwort + eine MFA); die per-Mandant-
|
||||
* `User`-Zeile ist die "Mitgliedschaft". Die Session trägt die Mitgliedschaftsliste
|
||||
* und den AKTIVEN Mandanten (`tenantId` = aktiver Mandant → ~356 dbForTenant-Call-
|
||||
* Sites unverändert). JWT-Strategie: Rollen/Rechte des AKTIVEN Mandanten werden beim
|
||||
* Login aufgelöst (Mandantenwechsel via /select-tenant re-auflösen = WS2).
|
||||
*
|
||||
* Two-Step-UI (MFA als getrennter 2. Schritt) + /select-tenant-Auswahl bei mehreren
|
||||
* Mitgliedschaften ohne Slug sind WS2/WS5. Hier: MFA wird an der Identity geprüft;
|
||||
* mehrere Mitgliedschaften ohne Organisations-Slug ⇒ (noch) kein Login (bis WS2).
|
||||
*/
|
||||
|
||||
// Brute-Force-Schutz analog zur Plattform-Domäne (platform-auth.ts): nach
|
||||
// LOCK_THRESHOLD Fehlversuchen wird die Identity für LOCK_MINUTES gesperrt (F-05).
|
||||
const LOCK_THRESHOLD = 5;
|
||||
const LOCK_MINUTES = 15;
|
||||
|
||||
// Dummy-Hash EINMAL bei ERSTER Nutzung erzeugen (memoisiert), NICHT auf Modulebene:
|
||||
// Nutzer-Enumeration über Laufzeitunterschiede wird so weiterhin verhindert (F-05,
|
||||
// Nebenbefund), aber der Aufruf erfolgt erst zur Laufzeit. Grund: hashPassword() liest
|
||||
// den PASSWORD_PEPPER (src/server/password.ts), der zur Docker-Build-Zeit fehlt (analog
|
||||
// zu assertSecureEnv in env.ts). Ein hashPassword()-Aufruf auf Modulebene würde `next
|
||||
// build` (Page-Data-Collection der Auth-Route) mit einem Import-Zeit-Throw zerlegen.
|
||||
let dummyHashMemo: Promise<string> | undefined;
|
||||
function dummyHash(): Promise<string> {
|
||||
return (dummyHashMemo ??= hashPassword("dummy-password-for-constant-time-verify"));
|
||||
}
|
||||
|
||||
const credentialsSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
// Optional: wählt bei mehreren Mitgliedschaften den Mandanten (Organisation).
|
||||
tenant: z.string().optional(),
|
||||
// Optional TOTP-/Recovery-Code (nur nötig, wenn die Identity MFA eingerichtet hat).
|
||||
token: z.string().optional(),
|
||||
});
|
||||
|
||||
export class LoginError extends Error {}
|
||||
|
||||
// Aktive Mitgliedschaft inkl. Rollen/Rechte (für Session-Aufbau).
|
||||
const membershipInclude = {
|
||||
tenant: true,
|
||||
userRoles: { include: { role: { include: { rolePermissions: { include: { permission: true } } } } } },
|
||||
} satisfies Prisma.UserInclude;
|
||||
type MembershipWithRoles = Prisma.UserGetPayload<{ include: typeof membershipInclude }>;
|
||||
|
||||
/** Aktive Mitgliedschaften (Mandant + Mitgliedschaft ACTIVE) einer Identity laden. */
|
||||
function loadMemberships(identityId: string): Promise<MembershipWithRoles[]> {
|
||||
return prisma.user.findMany({
|
||||
where: { identityId, status: "ACTIVE", tenant: { status: "ACTIVE" } },
|
||||
include: membershipInclude,
|
||||
});
|
||||
}
|
||||
|
||||
function membershipList(memberships: MembershipWithRoles[]): SessionMembership[] {
|
||||
return memberships.map((m) => ({
|
||||
membershipId: m.id,
|
||||
tenantId: m.tenantId,
|
||||
tenantSlug: m.tenant.slug,
|
||||
tenantName: m.tenant.name,
|
||||
}));
|
||||
}
|
||||
|
||||
function permissionsOf(m: MembershipWithRoles): string[] {
|
||||
return [...new Set(m.userRoles.flatMap((ur) => ur.role.rolePermissions.map((rp) => rp.permission.key)))];
|
||||
}
|
||||
|
||||
/** Baut das an NextAuth zurückgegebene User-Objekt aus Identity + aktiver Mitgliedschaft. */
|
||||
function buildSessionUser(identity: Identity, active: MembershipWithRoles, memberships: MembershipWithRoles[]) {
|
||||
return {
|
||||
id: active.id, // = aktive Mitgliedschaft, STABIL
|
||||
identityId: identity.id,
|
||||
email: identity.email,
|
||||
name: active.name,
|
||||
tenantId: active.tenantId, // = activeTenantId
|
||||
tenantSlug: active.tenant.slug,
|
||||
activeMembershipId: active.id,
|
||||
memberships: membershipList(memberships),
|
||||
roles: active.userRoles.map((ur) => ur.role.key),
|
||||
permissions: permissionsOf(active),
|
||||
isPlatformAdmin: false, // Plattform-Admins sind ein getrennter Store/Session (E)
|
||||
mfaEnrolled: !!identity.mfaEnrolledAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* WS2: Session OHNE aktiven Mandanten (mehrere Mitgliedschaften, kein Slug gewählt).
|
||||
* `tenantId` bleibt leer → requireSession wirft, Guards/Layout leiten auf /select-tenant.
|
||||
*/
|
||||
function buildNoTenantUser(identity: Identity, memberships: MembershipWithRoles[]) {
|
||||
return {
|
||||
id: identity.id, // Platzhalter; ohne aktiven Mandanten wird keine Fachfunktion erreicht
|
||||
identityId: identity.id,
|
||||
email: identity.email,
|
||||
name: identity.email,
|
||||
tenantId: "",
|
||||
tenantSlug: "",
|
||||
activeMembershipId: "",
|
||||
memberships: membershipList(memberships),
|
||||
roles: [] as string[],
|
||||
permissions: [] as string[],
|
||||
isPlatformAdmin: false,
|
||||
mfaEnrolled: !!identity.mfaEnrolledAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* WS2: löst eine Mitgliedschaft für den Mandantenwechsel auf — server-autoritativ.
|
||||
* Prüft, dass die Mitgliedschaft zur Session-Identity gehört und Mitgliedschaft +
|
||||
* Mandant ACTIVE sind. Gibt die neu zu prägenden Token-Felder zurück (oder null).
|
||||
*/
|
||||
export async function resolveActiveMembership(identityId: string, membershipId: string) {
|
||||
const m = await prisma.user.findFirst({
|
||||
where: { id: membershipId, identityId, status: "ACTIVE", tenant: { status: "ACTIVE" } },
|
||||
include: membershipInclude,
|
||||
});
|
||||
if (!m) return null;
|
||||
return {
|
||||
userId: m.id,
|
||||
tenantId: m.tenantId,
|
||||
tenantSlug: m.tenant.slug,
|
||||
activeMembershipId: m.id,
|
||||
roles: m.userRoles.map((ur) => ur.role.key),
|
||||
permissions: permissionsOf(m),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fehlversuch an der IDENTITY zählen, ab Schwelle sperren, als Audit ("denied")
|
||||
* protokollieren; gibt immer null zurück. Vorbild: registerFailure() in platform-auth.ts.
|
||||
*
|
||||
* Ausnahme letzter aktiver Mandanten-Admin: ist die Identity in irgendeinem ihrer
|
||||
* Mandanten der EINZIGE aktive tenant-admin, wird KEINE harte Sperre gesetzt (sonst
|
||||
* DoS der Mandanten-Administration). Fehlversuche werden trotzdem gezählt/auditiert.
|
||||
*/
|
||||
async function registerIdentityFailure(
|
||||
identity: Identity,
|
||||
auditTenantId: string | null,
|
||||
isLastActiveAdmin: boolean,
|
||||
): Promise<null> {
|
||||
const updated = await prisma.identity.update({
|
||||
where: { id: identity.id },
|
||||
data: { failedLogins: { increment: 1 } },
|
||||
});
|
||||
if (updated.failedLogins >= LOCK_THRESHOLD && !isLastActiveAdmin) {
|
||||
await prisma.identity.update({
|
||||
where: { id: identity.id },
|
||||
data: { lockedUntil: new Date(Date.now() + LOCK_MINUTES * 60_000), failedLogins: 0 },
|
||||
});
|
||||
if (auditTenantId) await writeAuditLog({ tenantId: auditTenantId, actorId: identity.id, action: "denied", entity: "login", entityId: identity.id, after: { locked: true } });
|
||||
} else if (auditTenantId) {
|
||||
await writeAuditLog({ tenantId: auditTenantId, actorId: identity.id, action: "denied", entity: "login", entityId: identity.id });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kern der Mandanten-Anmeldung (Option C, WS1) — als eigene Funktion exportiert,
|
||||
* damit sie direkt testbar ist (scripts/test-identity-login.ts). Prüft Credentials
|
||||
* gegen die GLOBALE Identity, lädt die Mitgliedschaften und wählt den aktiven
|
||||
* Mandanten (Slug oder Single-Membership). Rückgabe = an NextAuth übergebenes
|
||||
* User-Objekt oder null.
|
||||
*/
|
||||
// Letzter aktiver Admin (DoS-Ausnahme der Sperre) über alle Mandanten der Identity.
|
||||
async function isLastActiveAdminAcross(memberships: MembershipWithRoles[]): Promise<boolean> {
|
||||
const adminTenantIds = memberships
|
||||
.filter((m) => m.userRoles.some((ur) => ur.role.key === "tenant-admin"))
|
||||
.map((m) => m.tenantId);
|
||||
for (const tid of adminTenantIds) {
|
||||
const active = await prisma.user.count({
|
||||
where: { tenantId: tid, status: "ACTIVE", userRoles: { some: { role: { key: "tenant-admin" } } } },
|
||||
});
|
||||
if (active <= 1) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* WS5-Baustein: prüft E-Mail + Passwort gegen die Identity (mit Lockout, konstanter
|
||||
* Laufzeit gegen Enumeration). Erstellt KEINE Session. Rückgabe sagt, ob ein zweiter
|
||||
* MFA-Schritt nötig ist. Kein Passwort-Orakel: Fehlversuche zählen/sperren wie beim
|
||||
* vollen Login; Rückgabe ist bei falschem Passwort UND unbekannter E-Mail identisch null.
|
||||
*/
|
||||
export async function verifyIdentityPassword(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{ identityId: string; mfaRequired: boolean } | null> {
|
||||
assertSecureEnv();
|
||||
const identity = await prisma.identity.findUnique({ where: { email: email.toLowerCase() } });
|
||||
if (!identity || identity.status !== "ACTIVE") {
|
||||
await verifyPassword(await dummyHash(), password); // konstante Laufzeit (F-05)
|
||||
return null;
|
||||
}
|
||||
const memberships = await loadMemberships(identity.id);
|
||||
const auditTenantId = memberships[0]?.tenantId ?? null;
|
||||
if (identity.lockedUntil && identity.lockedUntil > new Date()) {
|
||||
if (auditTenantId) await writeAuditLog({ tenantId: auditTenantId, actorId: identity.id, action: "denied", entity: "login", entityId: identity.id, after: { reason: "locked" } });
|
||||
return null;
|
||||
}
|
||||
if (!(await verifyPassword(identity.passwordHash, password))) {
|
||||
await registerIdentityFailure(identity, auditTenantId, await isLastActiveAdminAcross(memberships));
|
||||
return null;
|
||||
}
|
||||
return { identityId: identity.id, mfaRequired: !!(identity.mfaEnrolledAt && identity.mfaSecret) };
|
||||
}
|
||||
|
||||
/**
|
||||
* WS5-Baustein: prüft den MFA-Code (TOTP oder Recovery) gegen die Identity, mit Replay-
|
||||
* Schutz (F-17) und Lockout bei Fehlversuch. Bei Erfolg wird der Zeitschritt/Recovery-
|
||||
* Verbrauch persistiert. Erstellt KEINE Session.
|
||||
*/
|
||||
export async function verifyIdentityMfa(identityId: string, code: string): Promise<boolean> {
|
||||
const identity = await prisma.identity.findUnique({ where: { id: identityId } });
|
||||
if (!identity || identity.status !== "ACTIVE" || !identity.mfaEnrolledAt || !identity.mfaSecret) return false;
|
||||
const memberships = await loadMemberships(identity.id);
|
||||
const auditTenantId = memberships[0]?.tenantId ?? null;
|
||||
const trimmed = (code ?? "").trim();
|
||||
if (!trimmed) {
|
||||
await registerIdentityFailure(identity, auditTenantId, await isLastActiveAdminAcross(memberships));
|
||||
return false;
|
||||
}
|
||||
const totp = verifyTotp(trimmed, identity.mfaSecret, identity.lastTotpStep);
|
||||
if (totp.ok) {
|
||||
await prisma.identity.update({ where: { id: identity.id }, data: { lastTotpStep: BigInt(totp.step) } });
|
||||
return true;
|
||||
}
|
||||
const codes = Array.isArray(identity.recoveryCodes) ? (identity.recoveryCodes as string[]) : [];
|
||||
const idx = await matchRecovery(trimmed, codes);
|
||||
if (idx >= 0) {
|
||||
await prisma.identity.update({ where: { id: identity.id }, data: { recoveryCodes: codes.filter((_, i) => i !== idx) } });
|
||||
return true;
|
||||
}
|
||||
await registerIdentityFailure(identity, auditTenantId, await isLastActiveAdminAcross(memberships));
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* WS5-Baustein: baut die Session, NACHDEM Passwort (+ ggf. MFA) verifiziert wurden.
|
||||
* Setzt Lockout/Fehlversuche zurück, wählt den aktiven Mandanten per Slug oder Single-
|
||||
* Membership (mehrere ohne Slug ⇒ No-Tenant-State → /select-tenant).
|
||||
*/
|
||||
export async function finalizeIdentityLogin(identityId: string, tenant?: string) {
|
||||
const identity = await prisma.identity.findUnique({ where: { id: identityId } });
|
||||
if (!identity || identity.status !== "ACTIVE") return null;
|
||||
await prisma.identity.update({ where: { id: identity.id }, data: { failedLogins: 0, lockedUntil: null } });
|
||||
const memberships = await loadMemberships(identity.id);
|
||||
if (memberships.length === 0) return null;
|
||||
let active: MembershipWithRoles | undefined;
|
||||
if (tenant) {
|
||||
active = memberships.find((m) => m.tenant.slug === tenant.toLowerCase());
|
||||
if (!active) return null;
|
||||
} else if (memberships.length === 1) {
|
||||
active = memberships[0];
|
||||
}
|
||||
return active ? buildSessionUser(identity, active, memberships) : buildNoTenantUser(identity, memberships);
|
||||
}
|
||||
|
||||
/**
|
||||
* Einstufiger Mandanten-Login (Bestandsflow + testbarer Kern). Nutzt dieselben
|
||||
* Bausteine wie der Two-Step (WS5): Passwort → ggf. MFA → Session.
|
||||
*/
|
||||
export async function authorizeTenantCredentials(raw: unknown) {
|
||||
const parsed = credentialsSchema.safeParse(raw);
|
||||
if (!parsed.success) return null;
|
||||
const { email, password, tenant, token } = parsed.data;
|
||||
|
||||
const pw = await verifyIdentityPassword(email, password);
|
||||
if (!pw) return null;
|
||||
if (pw.mfaRequired && !(await verifyIdentityMfa(pw.identityId, token ?? ""))) return null;
|
||||
return finalizeIdentityLogin(pw.identityId, tenant);
|
||||
}
|
||||
|
||||
export const { handlers, auth, signIn, signOut, unstable_update } = NextAuth({
|
||||
// F-09: Session-Lebensdauer begrenzen (Default wären 30 Tage). Mandanten-Session
|
||||
// 8 Stunden, mit rollierender Erneuerung bei Aktivität (updateAge 30 min).
|
||||
session: { strategy: "jwt", maxAge: 8 * 60 * 60, updateAge: 30 * 60 },
|
||||
pages: { signIn: "/login" },
|
||||
providers: [
|
||||
Credentials({
|
||||
credentials: { email: {}, password: {}, tenant: {}, token: {} },
|
||||
authorize: (raw) => authorizeTenantCredentials(raw),
|
||||
}),
|
||||
// SEC3-b: Passkey/WebAuthn als alternativer Login. WebAuthn ist in WS0 noch
|
||||
// mitgliedschafts-/mandantengebunden (Umzug auf die Identity = WS4); ein Passkey
|
||||
// meldet daher direkt in seinen Mandanten an. Challenge stammt aus httpOnly-Cookie.
|
||||
Credentials({
|
||||
id: "passkey",
|
||||
name: "Passkey",
|
||||
credentials: { response: {} },
|
||||
authorize: async (raw) => {
|
||||
assertSecureEnv();
|
||||
const responseStr = typeof raw?.response === "string" ? raw.response : "";
|
||||
if (!responseStr) return null;
|
||||
let assertion: AuthenticationResponseJSON;
|
||||
try {
|
||||
assertion = JSON.parse(responseStr) as AuthenticationResponseJSON;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!assertion?.id) return null;
|
||||
|
||||
const jar = await cookies();
|
||||
const expectedChallenge = jar.get(LOGIN_CHALLENGE_COOKIE)?.value;
|
||||
if (!expectedChallenge) return null;
|
||||
|
||||
// WS4b: Passkey gehört der GLOBALEN Identity (nicht mehr mandantengebunden).
|
||||
const cred = await prisma.webAuthnCredential.findUnique({
|
||||
where: { credentialId: assertion.id },
|
||||
include: { identity: true },
|
||||
});
|
||||
if (!cred || cred.identity.status !== "ACTIVE") return null;
|
||||
|
||||
const memberships = await loadMemberships(cred.identityId);
|
||||
const auditTenantId = memberships[0]?.tenantId ?? null;
|
||||
|
||||
if (cred.identity.lockedUntil && cred.identity.lockedUntil > new Date()) {
|
||||
if (auditTenantId) await writeAuditLog({ tenantId: auditTenantId, actorId: cred.identityId, action: "denied", entity: "login", entityId: cred.identityId, after: { reason: "locked", method: "passkey" } });
|
||||
return null;
|
||||
}
|
||||
|
||||
const verification = await verifyAuth({
|
||||
response: assertion,
|
||||
expectedChallenge,
|
||||
credential: { credentialId: cred.credentialId, publicKey: cred.publicKey, counter: cred.counter },
|
||||
});
|
||||
if (!verification.verified) {
|
||||
await prisma.identity.update({ where: { id: cred.identityId }, data: { failedLogins: { increment: 1 } } }).catch(() => {});
|
||||
return null;
|
||||
}
|
||||
|
||||
await prisma.webAuthnCredential.update({
|
||||
where: { id: cred.id },
|
||||
data: { counter: BigInt(verification.authenticationInfo.newCounter), lastUsedAt: new Date() },
|
||||
});
|
||||
await prisma.identity.update({ where: { id: cred.identityId }, data: { failedLogins: 0, lockedUntil: null } });
|
||||
|
||||
if (memberships.length === 0) return null;
|
||||
// Passkey trägt keinen Mandanten-Slug → Single-Membership direkt, sonst /select-tenant.
|
||||
const active = memberships.length === 1 ? memberships[0] : undefined;
|
||||
return active ? buildSessionUser(cred.identity, active, memberships) : buildNoTenantUser(cred.identity, memberships);
|
||||
},
|
||||
}),
|
||||
// WS5: Two-Step-Login-Abschluss. Der Provider prägt die Session aus einem signierten,
|
||||
// kurzlebigen `login_ticket` (Beweis, dass Passwort + ggf. MFA verifiziert wurden) —
|
||||
// er prüft selbst KEINE Credentials. Ausgestellt wird das Ticket erst nach dem MFA-
|
||||
// Schritt (actions/login.ts), sodass vor MFA KEINE volle Session entsteht.
|
||||
Credentials({
|
||||
id: "login-ticket",
|
||||
name: "Login Ticket",
|
||||
credentials: { ticket: {} },
|
||||
authorize: async (raw) => {
|
||||
assertSecureEnv();
|
||||
const ticket = typeof raw?.ticket === "string" ? raw.ticket : "";
|
||||
const payload = verifyLoginTicket(ticket);
|
||||
if (!payload) return null;
|
||||
return finalizeIdentityLogin(payload.identityId, payload.tenant);
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, user, trigger, session }) {
|
||||
if (user) {
|
||||
token.userId = user.id!;
|
||||
token.identityId = user.identityId;
|
||||
token.tenantId = user.tenantId;
|
||||
token.tenantSlug = user.tenantSlug;
|
||||
token.activeMembershipId = user.activeMembershipId;
|
||||
token.memberships = user.memberships;
|
||||
token.roles = user.roles;
|
||||
token.permissions = user.permissions;
|
||||
token.isPlatformAdmin = user.isPlatformAdmin;
|
||||
token.mfaEnrolled = user.mfaEnrolled;
|
||||
} else if (trigger === "update") {
|
||||
// WS2: Mandantenwechsel via setActiveTenant → unstable_update({ user: { activeMembershipId } }).
|
||||
// Rechte/Mandant werden HIER neu aufgelöst (nicht token-eingefroren), server-autoritativ.
|
||||
const membershipId = (session as { user?: { activeMembershipId?: string } } | undefined)?.user?.activeMembershipId;
|
||||
if (membershipId && token.identityId) {
|
||||
const resolved = await resolveActiveMembership(token.identityId, membershipId);
|
||||
if (resolved) {
|
||||
token.userId = resolved.userId;
|
||||
token.tenantId = resolved.tenantId;
|
||||
token.tenantSlug = resolved.tenantSlug;
|
||||
token.activeMembershipId = resolved.activeMembershipId;
|
||||
token.roles = resolved.roles;
|
||||
token.permissions = resolved.permissions;
|
||||
}
|
||||
}
|
||||
}
|
||||
return token;
|
||||
},
|
||||
session({ session, token }) {
|
||||
session.user.id = token.userId;
|
||||
session.user.identityId = token.identityId;
|
||||
session.user.tenantId = token.tenantId;
|
||||
session.user.tenantSlug = token.tenantSlug;
|
||||
session.user.activeMembershipId = token.activeMembershipId;
|
||||
session.user.memberships = token.memberships ?? [];
|
||||
session.user.roles = token.roles;
|
||||
session.user.permissions = token.permissions;
|
||||
session.user.isPlatformAdmin = token.isPlatformAdmin;
|
||||
session.user.mfaEnrolled = token.mfaEnrolled;
|
||||
// SEC2: `iat` durchreichen (Prüfung gegen sessionsValidAfter in Guards).
|
||||
session.user.tokenIssuedAt = typeof token.iat === "number" ? token.iat : undefined;
|
||||
return session;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** Session guard for server components / route handlers. */
|
||||
export async function requireSession() {
|
||||
// Fail-Secure: bei fehlendem/zu kurzem AUTH_SECRET hart abbrechen (F-01).
|
||||
assertSecureEnv();
|
||||
const session = await auth();
|
||||
// F-01 (Defense in Depth): positiv statt existenzbasiert prüfen.
|
||||
if (
|
||||
!session ||
|
||||
"error" in session ||
|
||||
!session.user ||
|
||||
!session.user.id ||
|
||||
!session.user.tenantId ||
|
||||
!Array.isArray(session.user.permissions)
|
||||
) {
|
||||
throw new Error("Nicht angemeldet");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// ── §9-Verschlüsselung der Tenant-Artefakte (client-seitig, vor dem Upload) ───
|
||||
//
|
||||
// Entscheidung (KONZEPT §9): client-seitige AES-256-Verschlüsselung mit EINEM
|
||||
// Schlüssel pro Umgebung — das Artefakt ist verschlüsselt, BEVOR es MinIO/S3
|
||||
// erreicht (zero-knowledge vom Speicher, at-rest + in-transit). Konsistent zur
|
||||
// bestehenden TOTP-Verschlüsselung (AES-256-GCM, src/server/secret-crypto.ts).
|
||||
//
|
||||
// Schlüssel: `BACKUP_ENC_KEY` (dediziert, pro Umgebung), Fallback `AUTH_SECRET`.
|
||||
// Der Schlüssel liegt NIE im Artefakt (Restore-Kohärenz-Regel, KONZEPT §9):
|
||||
// Pepper/MFA_ENC_KEY/BACKUP_ENC_KEY sind Umgebungs-Secrets, kein Artefakt-Inhalt.
|
||||
//
|
||||
// Blob-Layout (nach der Kompression):
|
||||
// magic "CVB1" | iv(12) | authTag(16) | ciphertext(gzip(plaintext))
|
||||
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
||||
import { gzipSync, gunzipSync } from "node:zlib";
|
||||
|
||||
const MAGIC = Buffer.from("CVB1", "ascii");
|
||||
|
||||
function backupKey(): Buffer {
|
||||
const material = process.env.BACKUP_ENC_KEY || process.env.AUTH_SECRET;
|
||||
if (!material) {
|
||||
throw new Error(
|
||||
"BACKUP_ENC_KEY/AUTH_SECRET fehlt — Tenant-Artefakte können nicht verschlüsselt werden. " +
|
||||
"Pro Umgebung eindeutig setzen; NIEMALS im selben Bucket wie die Artefakte ablegen (KONZEPT §9).",
|
||||
);
|
||||
}
|
||||
return createHash("sha256").update(`${material}:tenant-backup`).digest(); // 32 Byte
|
||||
}
|
||||
|
||||
/** Komprimiert (gzip) und verschlüsselt (AES-256-GCM) den Klartext-Body. */
|
||||
export function sealArtifact(plaintext: string): Buffer {
|
||||
const gz = gzipSync(Buffer.from(plaintext, "utf8"));
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv("aes-256-gcm", backupKey(), iv);
|
||||
const ct = Buffer.concat([cipher.update(gz), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return Buffer.concat([MAGIC, iv, tag, ct]);
|
||||
}
|
||||
|
||||
/** Entschlüsselt und dekomprimiert ein von `sealArtifact` erzeugtes Blob. */
|
||||
export function openArtifact(blob: Buffer): string {
|
||||
if (blob.length < MAGIC.length + 12 + 16 || !blob.subarray(0, 4).equals(MAGIC)) {
|
||||
throw new Error("Ungültiges/beschädigtes Backup-Artefakt (Magic-Byte-Prüfung fehlgeschlagen).");
|
||||
}
|
||||
let off = MAGIC.length;
|
||||
const iv = blob.subarray(off, off + 12);
|
||||
off += 12;
|
||||
const tag = blob.subarray(off, off + 16);
|
||||
off += 16;
|
||||
const ct = blob.subarray(off);
|
||||
const decipher = createDecipheriv("aes-256-gcm", backupKey(), iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const gz = Buffer.concat([decipher.update(ct), decipher.final()]);
|
||||
return gunzipSync(gz).toString("utf8");
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// ── DSGVO-Zustellpaket als ZIP (Per-Mandant / Per-Person) ────────────────────
|
||||
//
|
||||
// Wrappt die vorhandene DSGVO-Export-Engine (src/server/dsgvo/export.ts) und
|
||||
// verpackt deren Ergebnis in ein maschinenlesbares ZIP (JSON) — für die
|
||||
// Zustellung über einen zeitlich begrenzten, signierten Link (KONZEPT §5).
|
||||
// Die Engine selbst wird NICHT verändert, nur aufgerufen. Secrets (passwordHash,
|
||||
// mfaSecret, recoveryCodes) sind bereits engine-seitig ausgeschlossen.
|
||||
|
||||
import { prisma } from "../db";
|
||||
import { exportTenantAsJson, exportSubject } from "../dsgvo/export";
|
||||
import { buildZip, type ZipEntry } from "./zip";
|
||||
|
||||
export interface DsgvoPackage {
|
||||
zip: Buffer;
|
||||
/** Kurzbeschreibung fürs Audit/Job-Ergebnis (ohne PII). */
|
||||
summary: {
|
||||
scope: "tenant" | "person";
|
||||
tenantId: string;
|
||||
subjectIdentityId?: string;
|
||||
entries: number;
|
||||
bytes: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** JSON-Serialisierung mit BigInt-/Date-Sicherheit (Prisma liefert beides). */
|
||||
function toJson(value: unknown): string {
|
||||
return JSON.stringify(
|
||||
value,
|
||||
(_k, v) => (typeof v === "bigint" ? v.toString() : v),
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut das DSGVO-Paket:
|
||||
* - `subjectIdentityId` gesetzt → Per-Person-Auskunft (Art. 15/20) dieser
|
||||
* Person in DIESEM Mandanten.
|
||||
* - sonst → Per-Mandant-Paket (Art. 20 Portabilität / Offboarding-Kopie).
|
||||
*/
|
||||
export async function buildDsgvoPackage(
|
||||
tenantId: string,
|
||||
subjectIdentityId?: string | null,
|
||||
): Promise<DsgvoPackage> {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { id: true, slug: true, name: true },
|
||||
});
|
||||
if (!tenant) throw new Error(`buildDsgvoPackage: Mandant ${tenantId} existiert nicht.`);
|
||||
|
||||
const generatedAt = new Date().toISOString();
|
||||
const entries: ZipEntry[] = [];
|
||||
|
||||
if (subjectIdentityId) {
|
||||
const subject = await exportSubject(tenantId, subjectIdentityId);
|
||||
entries.push({
|
||||
name: "README.txt",
|
||||
data:
|
||||
`certvia — DSGVO-Auskunft (Art. 15/20), Einzelperson\n` +
|
||||
`Mandant: ${tenant.name} (${tenant.slug})\n` +
|
||||
`Betroffene Identity: ${subjectIdentityId}\n` +
|
||||
`Erstellt: ${generatedAt}\n\n` +
|
||||
`Inhalt:\n` +
|
||||
` identity.json — Identitäts-Metadaten (OHNE Secrets)\n` +
|
||||
` memberships.json — Mitgliedschaft(en) in diesem Mandanten\n` +
|
||||
` references.json — referenzierte Objekte (Eigentümer/Ersteller/Akteur)\n`,
|
||||
});
|
||||
entries.push({ name: "identity.json", data: toJson(subject.identity) });
|
||||
entries.push({ name: "memberships.json", data: toJson(subject.memberships) });
|
||||
entries.push({ name: "references.json", data: toJson(subject.references) });
|
||||
} else {
|
||||
const { manifest, tables } = await exportTenantAsJson(tenantId);
|
||||
entries.push({
|
||||
name: "README.txt",
|
||||
data:
|
||||
`certvia — DSGVO-Datenpaket (Art. 20 Portabilität), gesamter Mandant\n` +
|
||||
`Mandant: ${tenant.name} (${tenant.slug})\n` +
|
||||
`Erstellt: ${generatedAt}\n` +
|
||||
`Zeilen gesamt: ${manifest.totalRows}\n\n` +
|
||||
`Inhalt:\n` +
|
||||
` manifest.json — Schema-/Migrationsversion, Zeilenzahlen, Prüfsummen\n` +
|
||||
` data/<Modell>.json — je Tabelle die Zeilen dieses Mandanten\n`,
|
||||
});
|
||||
entries.push({ name: "manifest.json", data: toJson(manifest) });
|
||||
for (const table of tables) {
|
||||
entries.push({ name: `data/${table.model}.json`, data: toJson(table.rows) });
|
||||
}
|
||||
}
|
||||
|
||||
const zip = buildZip(entries);
|
||||
return {
|
||||
zip,
|
||||
summary: {
|
||||
scope: subjectIdentityId ? "person" : "tenant",
|
||||
tenantId,
|
||||
...(subjectIdentityId ? { subjectIdentityId } : {}),
|
||||
entries: entries.length,
|
||||
bytes: zip.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
// ── exportTenant: konsistentes, mandanten-scoptes Backup-Artefakt ────────────
|
||||
//
|
||||
// Läuft AUSSCHLIESSLICH über den Owner-`prisma`-Client (BYPASSRLS) — NIE über
|
||||
// dbForTenant/appBase. Ein konsistenter Snapshot entsteht in einer REPEATABLE-
|
||||
// READ-Transaktion: je Tabelle `WHERE tenant_id = A` (Join-Tabellen über die
|
||||
// id-Menge der Tenant-User/-Rollen), in FK-Reihenfolge parent→child.
|
||||
//
|
||||
// Ergebnis: gzip + AES-256-verschlüsseltes Blob (KONZEPT §9) + Manifest
|
||||
// (Schema-/Migrationsversion, Zeilenzahlen, Prüfsummen, Identity-Referenzen).
|
||||
// Umgebungs-Secrets (Pepper/MFA_ENC_KEY/BACKUP_ENC_KEY) landen NICHT im Artefakt.
|
||||
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "../db";
|
||||
import { buildTenantTopology, assertTopologyMatchesDatabase } from "./topology";
|
||||
import {
|
||||
serializeTable,
|
||||
ARTIFACT_FORMAT_VERSION,
|
||||
type BackupManifest,
|
||||
type TableManifest,
|
||||
type TableRows,
|
||||
} from "./serialization";
|
||||
import { sealArtifact } from "./crypto";
|
||||
import { getBackupStore } from "../storage/backup-store";
|
||||
|
||||
export interface ExportOptions {
|
||||
/** Artefakt+Manifest in den Objektspeicher hochladen (Default true). */
|
||||
persist?: boolean;
|
||||
/** Grund/Anlass (Audit/Manifest-Kontext), z. B. "pre-restore" | "nightly". */
|
||||
reason?: string;
|
||||
/** Mandanten-Dateien (MinIO `${tenantId}/uploads/`) mitsichern (Default false). */
|
||||
includeFiles?: boolean;
|
||||
}
|
||||
|
||||
export interface ExportResult {
|
||||
tenantId: string;
|
||||
snapshotId: string;
|
||||
manifest: BackupManifest;
|
||||
/** Verschlüsseltes Blob (in-memory; für Restore ohne Storage-Roundtrip). */
|
||||
artifact: Buffer;
|
||||
/** Storage-Key des Artefakts (falls persistiert), sonst null. */
|
||||
artifactKey: string | null;
|
||||
}
|
||||
|
||||
/** Storage-Prefix eines Mandanten (mandantenpräfixiert, KONZEPT §3). */
|
||||
export function tenantBackupPrefix(tenantId: string): string {
|
||||
return `${tenantId}/backups/`;
|
||||
}
|
||||
|
||||
function snapshotPrefix(tenantId: string, snapshotId: string): string {
|
||||
return `${tenantId}/backups/${snapshotId}/`;
|
||||
}
|
||||
|
||||
/** Neuen, sortierbaren Snapshot-Bezeichner erzeugen (Zeit + Zufall). */
|
||||
function newSnapshotId(): string {
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const rnd = Math.random().toString(36).slice(2, 8);
|
||||
return `${ts}_${rnd}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exportiert genau EINEN Mandanten. Verlässt nie `tenant_id = A` → beweisbar
|
||||
* keine Fremdmandanten berührt (Isolationstest: scripts/test-backup-isolation.ts).
|
||||
*/
|
||||
export async function exportTenant(
|
||||
tenantId: string,
|
||||
opts: ExportOptions = {},
|
||||
): Promise<ExportResult> {
|
||||
if (!tenantId) throw new Error("exportTenant: tenantId ist erforderlich.");
|
||||
const persist = opts.persist ?? true;
|
||||
|
||||
// Sicherheitsnachweis: abgeleitete Topologie == reale DB-FK-Constraints.
|
||||
await assertTopologyMatchesDatabase(prisma);
|
||||
|
||||
const topo = buildTenantTopology();
|
||||
|
||||
const result = await prisma.$transaction(
|
||||
async (tx) => {
|
||||
// Mandant existiert?
|
||||
const tenant = await tx.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { id: true, slug: true },
|
||||
});
|
||||
if (!tenant) throw new Error(`exportTenant: Mandant ${tenantId} existiert nicht.`);
|
||||
|
||||
// Zuletzt angewandte Migration (Kohärenzprüfung beim Restore).
|
||||
const migRows = await tx.$queryRawUnsafe<{ migration_name: string }[]>(
|
||||
`SELECT migration_name FROM _prisma_migrations WHERE finished_at IS NOT NULL ORDER BY finished_at DESC LIMIT 1`,
|
||||
);
|
||||
const schemaMigration = migRows[0]?.migration_name ?? null;
|
||||
|
||||
const lines: string[] = [];
|
||||
const tableManifests: TableManifest[] = [];
|
||||
// pk-id-Mengen je Modell (für idSet-Scope der Join-Tabellen).
|
||||
const idSets = new Map<string, Set<string>>();
|
||||
const identityRefs = new Set<string>();
|
||||
let totalRows = 0;
|
||||
|
||||
for (const node of topo.insertOrder) {
|
||||
const delegate = (tx as unknown as Record<string, {
|
||||
findMany: (a: unknown) => Promise<Record<string, unknown>[]>;
|
||||
}>)[node.delegate];
|
||||
|
||||
let rows: TableRows;
|
||||
if (node.scope.by === "tenantColumn") {
|
||||
rows = await delegate.findMany({ where: { tenantId } });
|
||||
} else {
|
||||
// Join-Tabelle: über die id-Menge des mandanten-gescopten Elternteils.
|
||||
const via = node.scope.via;
|
||||
const parentIds = idSets.get(via.parent) ?? new Set<string>();
|
||||
const fromField = via.fromFields[0];
|
||||
rows = parentIds.size
|
||||
? await delegate.findMany({ where: { [fromField]: { in: [...parentIds] } } })
|
||||
: [];
|
||||
}
|
||||
|
||||
// pk-id-Menge dieses Modells festhalten (nur Single-Column-PK relevant
|
||||
// als Scoping-Elternteil — User.id/Role.id).
|
||||
if (node.pk.length === 1) {
|
||||
const pkField = node.pk[0];
|
||||
const s = new Set<string>();
|
||||
for (const r of rows) {
|
||||
const v = r[pkField];
|
||||
if (typeof v === "string") s.add(v);
|
||||
}
|
||||
idSets.set(node.model, s);
|
||||
}
|
||||
|
||||
// Identity-Referenzen aus User-Zeilen (Identity-Stub-Guard beim Restore).
|
||||
if (node.model === "User") {
|
||||
for (const r of rows) {
|
||||
const idn = r["identityId"];
|
||||
if (typeof idn === "string") identityRefs.add(idn);
|
||||
}
|
||||
}
|
||||
|
||||
const { line, manifest } = serializeTable(node.model, node.table, rows);
|
||||
lines.push(line);
|
||||
tableManifests.push(manifest);
|
||||
totalRows += rows.length;
|
||||
}
|
||||
|
||||
const manifest: BackupManifest = {
|
||||
formatVersion: ARTIFACT_FORMAT_VERSION,
|
||||
tenantId,
|
||||
tenantSlug: tenant.slug,
|
||||
snapshotAt: new Date().toISOString(),
|
||||
schemaMigration,
|
||||
tables: tableManifests,
|
||||
identityRefs: [...identityRefs],
|
||||
totalRows,
|
||||
};
|
||||
|
||||
return { manifest, body: lines.join("\n") };
|
||||
},
|
||||
{ isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead, timeout: 120_000 },
|
||||
);
|
||||
|
||||
const artifact = sealArtifact(result.body);
|
||||
const snapshotId = newSnapshotId();
|
||||
let artifactKey: string | null = null;
|
||||
|
||||
if (persist) {
|
||||
const store = await getBackupStore();
|
||||
const prefix = snapshotPrefix(tenantId, snapshotId);
|
||||
artifactKey = `${prefix}artifact.cvb`;
|
||||
await store.put(artifactKey, artifact);
|
||||
// Manifest zusätzlich im Klartext (Dry-run/Preview/Listing; enthält keine PII).
|
||||
await store.put(
|
||||
`${prefix}manifest.json`,
|
||||
Buffer.from(JSON.stringify(result.manifest, null, 2), "utf8"),
|
||||
);
|
||||
|
||||
// Optional: Mandanten-Dateien (MinIO uploads-Prefix) mitsichern (best effort).
|
||||
if (opts.includeFiles) {
|
||||
try {
|
||||
const fileKeys = await store.list(`${tenantId}/uploads/`);
|
||||
for (const key of fileKeys) {
|
||||
const bytes = await store.get(key);
|
||||
if (bytes) await store.put(`${prefix}files/${key}`, bytes);
|
||||
}
|
||||
} catch {
|
||||
// Datei-Snapshot ist best effort; der DB-Snapshot bleibt gültig.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { tenantId, snapshotId, manifest: result.manifest, artifact, artifactKey };
|
||||
}
|
||||
|
||||
/** Verschlüsseltes Artefakt eines Snapshots aus dem Objektspeicher laden. */
|
||||
export async function loadArtifact(tenantId: string, snapshotId: string): Promise<Buffer> {
|
||||
const key = `${snapshotPrefix(tenantId, snapshotId)}artifact.cvb`;
|
||||
const blob = await (await getBackupStore()).get(key);
|
||||
if (!blob) throw new Error(`Artefakt ${key} nicht gefunden.`);
|
||||
return blob;
|
||||
}
|
||||
|
||||
/** Snapshot-Ids eines Mandanten (neueste zuletzt), aus dem Objektspeicher. */
|
||||
export async function listSnapshots(tenantId: string): Promise<string[]> {
|
||||
const keys = await (await getBackupStore()).list(tenantBackupPrefix(tenantId));
|
||||
const ids = new Set<string>();
|
||||
for (const k of keys) {
|
||||
const rest = k.slice(tenantBackupPrefix(tenantId).length);
|
||||
const slash = rest.indexOf("/");
|
||||
if (slash > 0) ids.add(rest.slice(0, slash));
|
||||
}
|
||||
return [...ids].sort();
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Öffentliche API der Backup-/Restore-Engine (Schicht B, mandanten-scoped).
|
||||
export {
|
||||
buildTenantTopology,
|
||||
assertTopologyMatchesDatabase,
|
||||
TENANT_MODELS,
|
||||
JOIN_MODELS,
|
||||
type TenantTopology,
|
||||
type TableNode,
|
||||
} from "./topology";
|
||||
export {
|
||||
exportTenant,
|
||||
loadArtifact,
|
||||
listSnapshots,
|
||||
tenantBackupPrefix,
|
||||
type ExportOptions,
|
||||
type ExportResult,
|
||||
} from "./export";
|
||||
export {
|
||||
restoreTenant,
|
||||
type RestoreSource,
|
||||
type RestoreOptions,
|
||||
type RestoreResult,
|
||||
type StubIdentity,
|
||||
} from "./restore";
|
||||
export { sealArtifact, openArtifact } from "./crypto";
|
||||
export type { BackupManifest } from "./serialization";
|
||||
@@ -0,0 +1,44 @@
|
||||
// ── Job-Nutzlast der Backup-/DSGVO-Ops-Queue (Betreiber-Portal) ──────────────
|
||||
//
|
||||
// Analog zur Mail-Queue (SEC1). Die Nutzlast trägt NUR IDs/Metadaten — KEINE
|
||||
// Secrets, KEINE PII. Der Worker lädt Artefakte/Personendaten anhand der IDs
|
||||
// serverseitig; die zugehörige `BackupJob`-Zeile (Status/Ergebnis) wird über
|
||||
// `jobId` fortgeschrieben (KONZEPT §4: die Action enqueued nur).
|
||||
|
||||
export const BACKUP_QUEUE = "backup-ops";
|
||||
export const BACKUP_DLQ = "backup-ops-dead-letter";
|
||||
|
||||
/** Portal-Restore: destruktiver Wipe+Restore genau EINES Mandanten. */
|
||||
export interface TenantRestoreJob {
|
||||
kind: "tenant_restore";
|
||||
jobId: string;
|
||||
tenantId: string;
|
||||
snapshotId: string;
|
||||
actorId: string;
|
||||
restoreFiles?: boolean;
|
||||
}
|
||||
|
||||
/** „Export jetzt" — On-demand-Sicherung eines Mandanten. */
|
||||
export interface TenantExportJob {
|
||||
kind: "tenant_export";
|
||||
jobId: string;
|
||||
tenantId: string;
|
||||
reason: string;
|
||||
actorId: string;
|
||||
includeFiles?: boolean;
|
||||
}
|
||||
|
||||
/** DSGVO-Zustellung: ZIP-Paket (Per-Mandant oder Per-Person) + signierter Link. */
|
||||
export interface DsgvoExportJob {
|
||||
kind: "dsgvo_export";
|
||||
jobId: string;
|
||||
tenantId: string;
|
||||
/** Gesetzt → Per-Person-Auskunft; leer → Per-Mandant-Paket. */
|
||||
subjectIdentityId?: string | null;
|
||||
actorId: string;
|
||||
}
|
||||
|
||||
export type BackupOpsJob = TenantRestoreJob | TenantExportJob | DsgvoExportJob;
|
||||
|
||||
/** Gültigkeitsdauer des DSGVO-Download-Links (kurze TTL, KONZEPT §5). */
|
||||
export const DSGVO_DOWNLOAD_TTL_MS = 60 * 60 * 1000; // 1 Stunde
|
||||
@@ -0,0 +1,128 @@
|
||||
// ── Ausführung der Backup-/DSGVO-Ops-Jobs (Worker-Seite) ─────────────────────
|
||||
//
|
||||
// `processBackupJob` ist die reine, DB-gebundene Ausführung EINES Jobs — bewusst
|
||||
// getrennt vom BullMQ-Worker, damit die Enqueue-/Kontroll- und Ausführungslogik
|
||||
// auch OHNE laufendes Redis testbar ist (der Test ruft processBackupJob direkt).
|
||||
// Der Worker (worker.ts) reicht die Job-Nutzlast nur hierher durch.
|
||||
//
|
||||
// Alle Fach-Engines (restoreTenant/exportTenant/DSGVO-Export) werden NUR
|
||||
// aufgerufen, nicht verändert. Status/Ergebnis wandern in die `BackupJob`-Zeile;
|
||||
// dort landen KEINE Secrets und KEINE PII (nur Zeilenzahlen/Metadaten).
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { prisma } from "../db";
|
||||
import { restoreTenant } from "./restore";
|
||||
import { exportTenant } from "./export";
|
||||
import { buildDsgvoPackage } from "./dsgvo-zip";
|
||||
import { getBackupStore } from "../storage/backup-store";
|
||||
import { writePlatformAudit } from "../audit";
|
||||
import { DSGVO_DOWNLOAD_TTL_MS, type BackupOpsJob } from "./job";
|
||||
|
||||
/** Storage-Key des DSGVO-ZIP eines Jobs (getrennter Prefix, mandantenpräfixiert). */
|
||||
export function dsgvoPackageKey(tenantId: string, jobId: string): string {
|
||||
return `${tenantId}/dsgvo-exports/${jobId}.zip`;
|
||||
}
|
||||
|
||||
async function markRunning(jobId: string): Promise<void> {
|
||||
await prisma.backupJob.update({ where: { id: jobId }, data: { status: "running" } });
|
||||
}
|
||||
|
||||
/** Führt EINEN Backup-/DSGVO-Job aus und schreibt Status/Ergebnis zurück. */
|
||||
export async function processBackupJob(job: BackupOpsJob): Promise<void> {
|
||||
await markRunning(job.jobId);
|
||||
try {
|
||||
switch (job.kind) {
|
||||
case "tenant_restore": {
|
||||
const res = await restoreTenant(
|
||||
job.tenantId,
|
||||
{ snapshotId: job.snapshotId },
|
||||
{ actorId: job.actorId, restoreFiles: job.restoreFiles },
|
||||
);
|
||||
await prisma.backupJob.update({
|
||||
where: { id: job.jobId },
|
||||
data: {
|
||||
status: "done",
|
||||
result: {
|
||||
restoredRows: res.restoredRows,
|
||||
deletedRows: res.deletedRows,
|
||||
preRestoreSnapshotId: res.preRestoreSnapshotId,
|
||||
stubIdentities: res.stubIdentities.length,
|
||||
relinkedIdentities: res.relinkedIdentities.length,
|
||||
tombstonesReapplied: res.tombstonesReapplied,
|
||||
},
|
||||
},
|
||||
});
|
||||
await writePlatformAudit({
|
||||
actorId: job.actorId,
|
||||
action: "update",
|
||||
entity: "backup_job",
|
||||
entityId: job.jobId,
|
||||
after: { kind: job.kind, tenantId: job.tenantId, snapshotId: job.snapshotId, status: "done" },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "tenant_export": {
|
||||
const res = await exportTenant(job.tenantId, {
|
||||
reason: job.reason,
|
||||
persist: true,
|
||||
includeFiles: job.includeFiles,
|
||||
});
|
||||
await prisma.backupJob.update({
|
||||
where: { id: job.jobId },
|
||||
data: {
|
||||
status: "done",
|
||||
snapshotId: res.snapshotId,
|
||||
result: { snapshotId: res.snapshotId, totalRows: res.manifest.totalRows },
|
||||
},
|
||||
});
|
||||
await writePlatformAudit({
|
||||
actorId: job.actorId,
|
||||
action: "create",
|
||||
entity: "backup_job",
|
||||
entityId: job.jobId,
|
||||
after: { kind: job.kind, tenantId: job.tenantId, snapshotId: res.snapshotId, status: "done" },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "dsgvo_export": {
|
||||
const pkg = await buildDsgvoPackage(job.tenantId, job.subjectIdentityId);
|
||||
const key = dsgvoPackageKey(job.tenantId, job.jobId);
|
||||
await (await getBackupStore()).put(key, pkg.zip);
|
||||
// Signierter, ablaufender Download: opakes Zufalls-Token + kurze TTL.
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const expiresAt = new Date(Date.now() + DSGVO_DOWNLOAD_TTL_MS);
|
||||
await prisma.backupJob.update({
|
||||
where: { id: job.jobId },
|
||||
data: {
|
||||
status: "done",
|
||||
downloadToken: token,
|
||||
downloadExpiresAt: expiresAt,
|
||||
result: { ...pkg.summary, storageKey: key, expiresAt: expiresAt.toISOString() },
|
||||
},
|
||||
});
|
||||
await writePlatformAudit({
|
||||
actorId: job.actorId,
|
||||
action: "export",
|
||||
entity: "backup_job",
|
||||
entityId: job.jobId,
|
||||
// KEIN Token/keine PII ins Audit — nur Metadaten.
|
||||
after: { kind: job.kind, tenantId: job.tenantId, scope: pkg.summary.scope, status: "done" },
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await prisma.backupJob
|
||||
.update({ where: { id: job.jobId }, data: { status: "failed", error: message } })
|
||||
.catch(() => {});
|
||||
await writePlatformAudit({
|
||||
actorId: job.actorId,
|
||||
action: "denied",
|
||||
entity: "backup_job",
|
||||
entityId: job.jobId,
|
||||
after: { kind: job.kind, tenantId: job.tenantId, status: "failed", error: message },
|
||||
}).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// ── BullMQ-Queue der Backup-/DSGVO-Ops (Betreiber-Portal) ────────────────────
|
||||
//
|
||||
// Eigenständig aufgebaut, analog zur Mail-Queue (SEC1, src/server/mail/queue.ts),
|
||||
// aber mit EIGENER Queue (`backup-ops`) und eigenen Redis-Verbindungen — ein
|
||||
// BullMQ-Worker konsumiert ALLE Jobs seiner Queue, also darf der Backup-Worker
|
||||
// nicht auf `mail` liegen.
|
||||
//
|
||||
// Betriebsmodus:
|
||||
// - `REDIS_URL` gesetzt → Jobs laufen asynchron über den Worker
|
||||
// (`npm run worker:backup`, eigener Container).
|
||||
// - ohne `REDIS_URL` → KEIN Queue-Betrieb. Portal-Restore/Export/DSGVO sind
|
||||
// destruktiv bzw. langlaufend und werden NIE inline in der Server-Action
|
||||
// ausgeführt (KONZEPT §4). Die Action meldet dann, dass Redis/Worker fehlt.
|
||||
|
||||
import { Queue } from "bullmq";
|
||||
import IORedis, { type Redis } from "ioredis";
|
||||
import { BACKUP_DLQ, BACKUP_QUEUE, type BackupOpsJob } from "./job";
|
||||
|
||||
let queue: Queue<BackupOpsJob> | null = null;
|
||||
let deadLetter: Queue<{ job: BackupOpsJob; error: string }> | null = null;
|
||||
let producerConnection: Redis | null = null;
|
||||
let workerConnection: Redis | null = null;
|
||||
let logged = false;
|
||||
|
||||
export function backupRedisUrl(): string | undefined {
|
||||
const v = process.env.REDIS_URL?.trim();
|
||||
return v ? v : undefined;
|
||||
}
|
||||
|
||||
export function isBackupQueueEnabled(): boolean {
|
||||
return backupRedisUrl() != null;
|
||||
}
|
||||
|
||||
/** Producer-Verbindung (Server-Action): fail-fast, kein stilles Puffern. */
|
||||
function getProducerConnection(): Redis | null {
|
||||
const url = backupRedisUrl();
|
||||
if (!url) return null;
|
||||
if (!producerConnection) {
|
||||
producerConnection = new IORedis(url, {
|
||||
maxRetriesPerRequest: 1,
|
||||
enableReadyCheck: false,
|
||||
enableOfflineQueue: false,
|
||||
connectTimeout: 3_000,
|
||||
retryStrategy: (times) => Math.min(times * 500, 5_000),
|
||||
lazyConnect: false,
|
||||
});
|
||||
producerConnection.on("error", (err) => {
|
||||
console.error("[backup] Redis (Producer) nicht erreichbar:", err.message);
|
||||
});
|
||||
}
|
||||
return producerConnection;
|
||||
}
|
||||
|
||||
/** Ist die Producer-Verbindung gerade wirklich benutzbar? */
|
||||
export function isBackupQueueReady(): boolean {
|
||||
return getProducerConnection()?.status === "ready";
|
||||
}
|
||||
|
||||
/** Worker-Verbindung: robust (blockierende Reads brauchen maxRetriesPerRequest=null). */
|
||||
export function getBackupConnection(): Redis | null {
|
||||
const url = backupRedisUrl();
|
||||
if (!url) return null;
|
||||
if (!workerConnection) {
|
||||
workerConnection = new IORedis(url, {
|
||||
maxRetriesPerRequest: null,
|
||||
enableReadyCheck: false,
|
||||
});
|
||||
workerConnection.on("error", (err) => {
|
||||
console.error("[backup] Redis (Worker) Verbindungsfehler:", err.message);
|
||||
});
|
||||
}
|
||||
return workerConnection;
|
||||
}
|
||||
|
||||
export function getBackupQueue(): Queue<BackupOpsJob> | null {
|
||||
const conn = getProducerConnection();
|
||||
if (!conn) {
|
||||
if (!logged) {
|
||||
console.warn(
|
||||
"[backup] REDIS_URL nicht gesetzt — Portal-Restore/Export/DSGVO benötigen den Worker (kein Inline-Betrieb).",
|
||||
);
|
||||
logged = true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!queue) {
|
||||
queue = new Queue<BackupOpsJob>(BACKUP_QUEUE, {
|
||||
connection: conn,
|
||||
defaultJobOptions: {
|
||||
// Restore ist destruktiv → NUR EIN Versuch, kein automatisches Retry
|
||||
// (ein halb gelaufener Restore darf nicht blind wiederholt werden; die
|
||||
// Engine ist zwar idempotent, aber die Wiederholung ist eine bewusste
|
||||
// Betreiber-Entscheidung, kein Automatismus).
|
||||
attempts: 1,
|
||||
removeOnComplete: { age: 30 * 24 * 3600, count: 500 },
|
||||
removeOnFail: { age: 30 * 24 * 3600 },
|
||||
},
|
||||
});
|
||||
if (!logged) {
|
||||
console.info("[backup] Queue aktiv (BullMQ) — Backup-/DSGVO-Jobs laufen über den Worker.");
|
||||
logged = true;
|
||||
}
|
||||
}
|
||||
return queue;
|
||||
}
|
||||
|
||||
/** Dead-Letter-Queue: Jobs nach dem endgültigen Fehlversuch (nur Worker). */
|
||||
export function getBackupDeadLetterQueue(): Queue<{ job: BackupOpsJob; error: string }> | null {
|
||||
const conn = getBackupConnection();
|
||||
if (!conn) return null;
|
||||
if (!deadLetter) {
|
||||
deadLetter = new Queue<{ job: BackupOpsJob; error: string }>(BACKUP_DLQ, {
|
||||
connection: conn,
|
||||
defaultJobOptions: { removeOnComplete: false, removeOnFail: false },
|
||||
});
|
||||
}
|
||||
return deadLetter;
|
||||
}
|
||||
|
||||
/** Verbindungen schließen (Worker-Shutdown, Tests). */
|
||||
export async function closeBackupQueues(): Promise<void> {
|
||||
await queue?.close();
|
||||
await deadLetter?.close();
|
||||
queue = null;
|
||||
deadLetter = null;
|
||||
producerConnection?.disconnect();
|
||||
producerConnection = null;
|
||||
workerConnection?.disconnect();
|
||||
workerConnection = null;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// ── Reine Validierung der getippten Restore-Bestätigung ──────────────────────
|
||||
//
|
||||
// Bewusst getrennt von der Server-Action (backup-admin.ts): eine "use server"-
|
||||
// Datei darf NUR async Funktionen exportieren. Diese synchronen, seiteneffekt-
|
||||
// freien Helfer sind so auch direkt (unit-)testbar.
|
||||
|
||||
/** Erwartete getippte Bestätigung für einen Restore (exakt, case-sensitive). */
|
||||
export function restoreConfirmationFor(slug: string): string {
|
||||
return `RESTORE ${slug}`;
|
||||
}
|
||||
|
||||
/** Prüft die getippte Bestätigung exakt gegen „RESTORE <slug>". */
|
||||
export function matchesRestoreConfirmation(slug: string, typed: string): boolean {
|
||||
return typed === restoreConfirmationFor(slug);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
// ── restoreTenant: gezielter, isolierter Wiederherstellungs-Lauf ─────────────
|
||||
//
|
||||
// Owner-`prisma` (BYPASSRLS), EINE Transaktion für den destruktiven Replace.
|
||||
// Ablauf (KONZEPT §3):
|
||||
// 1. Mandant sperren (status = SUSPENDED) — verhindert parallele Schreibzugriffe.
|
||||
// 2. Pre-Restore-Sicherheitsschnappschuss (Restore ist damit reversibel).
|
||||
// 3. Identity-Stub-Guard: fehlende globale Identities re-provisionieren/neu verknüpfen.
|
||||
// 4. Replace in einer Owner-Transaktion: DELETE child→parent + Reinsert parent→child
|
||||
// mit ORIGINALEN cuid-PKs. FK-Trigger via `session_replication_role = replica`
|
||||
// (SET LOCAL, superuser) deaktiviert → Selbstreferenzen (processes.parent_id)
|
||||
// und Rest-Reihenfolge sind unkritisch; der Snapshot ist in sich konsistent.
|
||||
// 5. Tombstones erneut anwenden (ein alter Snapshot darf gelöschte PII NICHT
|
||||
// zurückbringen — DSGVO, KONZEPT §6).
|
||||
// 6. Mandant reaktivieren + Audit.
|
||||
//
|
||||
// Beweisbar isoliert: jede Operation ist auf `tenant_id = A` (bzw. die id-Menge
|
||||
// der Tenant-User/-Rollen) begrenzt (Isolationstest: test-backup-isolation.ts).
|
||||
|
||||
import { prisma } from "../db";
|
||||
import { writeAuditLog } from "../audit";
|
||||
import { buildTenantTopology, assertTopologyMatchesDatabase } from "./topology";
|
||||
import { parseArtifactBody, verifyTableChecksum, type BackupManifest, type TableRows } from "./serialization";
|
||||
import { openArtifact } from "./crypto";
|
||||
import { exportTenant, loadArtifact } from "./export";
|
||||
import { getBackupStore } from "../storage/backup-store";
|
||||
import { applyTombstones } from "../dsgvo/tombstone";
|
||||
|
||||
/** passwordHash-Sentinel für Stub-Identities: kein gültiger Argon2-PHC → Login fällt fail-closed. */
|
||||
const STUB_PASSWORD_SENTINEL = "!stub-no-login!";
|
||||
|
||||
export interface RestoreSource {
|
||||
/** In-Memory-Artefakt (z. B. Isolationstest) ODER ... */
|
||||
artifact?: Buffer;
|
||||
/** ... Snapshot-Id im Objektspeicher. */
|
||||
snapshotId?: string;
|
||||
/** Optionales Manifest zur Checksummen-/Kohärenzprüfung (bei snapshotId geladen). */
|
||||
manifest?: BackupManifest;
|
||||
}
|
||||
|
||||
export interface RestoreOptions {
|
||||
actorId?: string;
|
||||
/** Pre-Restore-Schnappschuss überspringen (nur Tests). Default false. */
|
||||
skipPreRestore?: boolean;
|
||||
/** Schema-Migrations-Abweichung tolerieren (Default false → Abweisung). */
|
||||
allowSchemaMismatch?: boolean;
|
||||
/** Mandanten-Dateien aus dem Snapshot mit wiederherstellen (Default false). */
|
||||
restoreFiles?: boolean;
|
||||
}
|
||||
|
||||
export interface StubIdentity {
|
||||
identityId: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface RestoreResult {
|
||||
tenantId: string;
|
||||
restoredRows: number;
|
||||
deletedRows: number;
|
||||
/** Neu re-provisionierte Identity-Stubs (Einladungs-Flow anstoßen). */
|
||||
stubIdentities: StubIdentity[];
|
||||
/** Auf existierende Identities per E-Mail neu verknüpfte Referenzen. */
|
||||
relinkedIdentities: { from: string; to: string }[];
|
||||
preRestoreSnapshotId: string | null;
|
||||
tombstonesReapplied: number;
|
||||
}
|
||||
|
||||
/** Aktuell angewandte Migration (für die Kohärenzprüfung). */
|
||||
async function currentMigration(): Promise<string | null> {
|
||||
const rows = await prisma.$queryRawUnsafe<{ migration_name: string }[]>(
|
||||
`SELECT migration_name FROM _prisma_migrations WHERE finished_at IS NOT NULL ORDER BY finished_at DESC LIMIT 1`,
|
||||
);
|
||||
return rows[0]?.migration_name ?? null;
|
||||
}
|
||||
|
||||
export async function restoreTenant(
|
||||
tenantId: string,
|
||||
source: RestoreSource,
|
||||
opts: RestoreOptions = {},
|
||||
): Promise<RestoreResult> {
|
||||
if (!tenantId) throw new Error("restoreTenant: tenantId ist erforderlich.");
|
||||
|
||||
await assertTopologyMatchesDatabase(prisma);
|
||||
const topo = buildTenantTopology();
|
||||
|
||||
// Artefakt beschaffen.
|
||||
const blob =
|
||||
source.artifact ??
|
||||
(source.snapshotId ? await loadArtifact(tenantId, source.snapshotId) : null);
|
||||
if (!blob) throw new Error("restoreTenant: weder artifact noch snapshotId angegeben.");
|
||||
|
||||
// Manifest beschaffen (für Kohärenz-/Checksummenprüfung).
|
||||
let manifest = source.manifest ?? null;
|
||||
if (!manifest && source.snapshotId) {
|
||||
const mj = await (await getBackupStore()).get(`${tenantId}/backups/${source.snapshotId}/manifest.json`);
|
||||
if (mj) manifest = JSON.parse(mj.toString("utf8")) as BackupManifest;
|
||||
}
|
||||
|
||||
// Blob → Tabellen (geordnet parent→child).
|
||||
const body = openArtifact(blob);
|
||||
const tables = parseArtifactBody(body);
|
||||
const rowsByModel = new Map<string, TableRows>();
|
||||
for (const t of tables) rowsByModel.set(t.model, t.rows);
|
||||
|
||||
// Manifest-Kohärenz: Zielmandant + Schema-Migration.
|
||||
if (manifest) {
|
||||
if (manifest.tenantId !== tenantId) {
|
||||
throw new Error(
|
||||
`restoreTenant: Artefakt gehört Mandant ${manifest.tenantId}, Ziel ist ${tenantId} — abgewiesen.`,
|
||||
);
|
||||
}
|
||||
const cur = await currentMigration();
|
||||
if (!opts.allowSchemaMismatch && manifest.schemaMigration && cur && manifest.schemaMigration !== cur) {
|
||||
throw new Error(
|
||||
`restoreTenant: Schema-Migration im Artefakt (${manifest.schemaMigration}) ≠ DB (${cur}). ` +
|
||||
`Artefakt vor dem Reinsert migrieren oder allowSchemaMismatch setzen.`,
|
||||
);
|
||||
}
|
||||
// Integritätsnachweis je Tabelle.
|
||||
for (const tm of manifest.tables) {
|
||||
const rows = rowsByModel.get(tm.model) ?? [];
|
||||
if (!verifyTableChecksum(tm.model, rows, tm.checksum)) {
|
||||
throw new Error(`restoreTenant: Checksummen-Mismatch bei ${tm.model} — Artefakt beschädigt.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Zielmandant prüfen + vorherigen Status merken.
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (!tenant) throw new Error(`restoreTenant: Mandant ${tenantId} existiert nicht.`);
|
||||
const previousStatus = tenant.status;
|
||||
|
||||
// 1. Sperren (eigener Commit, damit die Sperre unabhängig vom Replace sichtbar ist).
|
||||
await prisma.tenant.update({ where: { id: tenantId }, data: { status: "SUSPENDED" } });
|
||||
|
||||
// 2. Pre-Restore-Sicherheitsschnappschuss.
|
||||
let preRestoreSnapshotId: string | null = null;
|
||||
if (!opts.skipPreRestore) {
|
||||
const snap = await exportTenant(tenantId, { reason: "pre-restore", persist: true });
|
||||
preRestoreSnapshotId = snap.snapshotId;
|
||||
}
|
||||
|
||||
// 3. Identity-Stub-Guard (globale Schicht A; NICHT im Tenant-Artefakt).
|
||||
const userRows = rowsByModel.get("User") ?? [];
|
||||
const stubIdentities: StubIdentity[] = [];
|
||||
const relinked: { from: string; to: string }[] = [];
|
||||
const remap = new Map<string, string>();
|
||||
{
|
||||
const refs = new Map<string, { email: string; name: string }>();
|
||||
for (const u of userRows) {
|
||||
const idn = u["identityId"];
|
||||
if (typeof idn === "string") {
|
||||
refs.set(idn, {
|
||||
email: String(u["email"] ?? ""),
|
||||
name: String(u["name"] ?? ""),
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [identityId, info] of refs) {
|
||||
const byId = await prisma.identity.findUnique({ where: { id: identityId }, select: { id: true } });
|
||||
if (byId) continue;
|
||||
const byEmail = info.email
|
||||
? await prisma.identity.findUnique({ where: { email: info.email }, select: { id: true } })
|
||||
: null;
|
||||
if (byEmail) {
|
||||
// Neu verknüpfen (KONZEPT §8): User zeigt künftig auf die existierende Identity.
|
||||
remap.set(identityId, byEmail.id);
|
||||
relinked.push({ from: identityId, to: byEmail.id });
|
||||
} else {
|
||||
// Stub re-provisionieren: neutraler Status, KEINE Secrets, Einladungs-Flow.
|
||||
// Identity trägt KEINEN Namen (der lebt denormalisiert auf User); der
|
||||
// Stub braucht nur E-Mail + neutralen Status, KEINE Secrets.
|
||||
await prisma.identity.create({
|
||||
data: {
|
||||
id: identityId,
|
||||
email: info.email || `stub+${identityId}@invalid.local`,
|
||||
passwordHash: STUB_PASSWORD_SENTINEL,
|
||||
mustChangePassword: true,
|
||||
status: "DISABLED",
|
||||
},
|
||||
});
|
||||
stubIdentities.push({ identityId, email: info.email, name: info.name });
|
||||
}
|
||||
}
|
||||
// Remap auf die zu reinsertenden User-Zeilen anwenden.
|
||||
if (remap.size) {
|
||||
for (const u of userRows) {
|
||||
const idn = u["identityId"];
|
||||
if (typeof idn === "string" && remap.has(idn)) u["identityId"] = remap.get(idn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. + 5. Destruktiver Replace + Tombstones in EINER Owner-Transaktion.
|
||||
let deletedRows = 0;
|
||||
let restoredRows = 0;
|
||||
let tombstonesReapplied = 0;
|
||||
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
// FK-Trigger deaktivieren (superuser) → Selbstreferenzen/Reihenfolge unkritisch.
|
||||
// SET LOCAL: gilt nur in dieser Transaktion, wird bei Commit/Rollback verworfen.
|
||||
await tx.$executeRawUnsafe(`SET LOCAL session_replication_role = replica`);
|
||||
await tx.$executeRawUnsafe(`SET CONSTRAINTS ALL DEFERRED`);
|
||||
|
||||
// DELETE child→parent, streng tenant-gescopt.
|
||||
for (const node of topo.deleteOrder) {
|
||||
const del = (tx as unknown as Record<string, {
|
||||
deleteMany: (a: unknown) => Promise<{ count: number }>;
|
||||
findMany: (a: unknown) => Promise<Record<string, unknown>[]>;
|
||||
}>)[node.delegate];
|
||||
|
||||
if (node.scope.by === "tenantColumn") {
|
||||
const r = await del.deleteMany({ where: { tenantId } });
|
||||
deletedRows += r.count;
|
||||
} else {
|
||||
const via = node.scope.via;
|
||||
const parentNode = topo.nodes.get(via.parent)!;
|
||||
const parentPk = parentNode.pk[0];
|
||||
const parentDelegate = (tx as unknown as Record<string, {
|
||||
findMany: (a: unknown) => Promise<Record<string, unknown>[]>;
|
||||
}>)[parentNode.delegate];
|
||||
const parents = await parentDelegate.findMany({
|
||||
where: { tenantId },
|
||||
select: { [parentPk]: true },
|
||||
});
|
||||
const ids = parents.map((p) => p[parentPk]).filter((v) => typeof v === "string");
|
||||
if (ids.length) {
|
||||
const r = await del.deleteMany({ where: { [via.fromFields[0]]: { in: ids } } });
|
||||
deletedRows += r.count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reinsert parent→child mit ORIGINALEN PKs.
|
||||
for (const node of topo.insertOrder) {
|
||||
const rows = rowsByModel.get(node.model) ?? [];
|
||||
if (!rows.length) continue;
|
||||
const create = (tx as unknown as Record<string, {
|
||||
createMany: (a: unknown) => Promise<{ count: number }>;
|
||||
}>)[node.delegate];
|
||||
const r = await create.createMany({ data: rows });
|
||||
restoredRows += r.count;
|
||||
}
|
||||
|
||||
// Tombstones erneut anwenden (gelöschte PII bleibt gelöscht).
|
||||
tombstonesReapplied = await applyTombstones(tx as never, tenantId);
|
||||
},
|
||||
{ timeout: 300_000 },
|
||||
);
|
||||
|
||||
// 6. Reaktivieren (Vorstatus, aber nie ARCHIVED reaktivieren).
|
||||
await prisma.tenant.update({
|
||||
where: { id: tenantId },
|
||||
data: { status: previousStatus === "ARCHIVED" ? "ACTIVE" : previousStatus },
|
||||
});
|
||||
|
||||
// Mandanten-Dateien (best effort).
|
||||
if (opts.restoreFiles && source.snapshotId) {
|
||||
try {
|
||||
const store = await getBackupStore();
|
||||
const prefix = `${tenantId}/backups/${source.snapshotId}/files/`;
|
||||
const keys = await store.list(prefix);
|
||||
for (const k of keys) {
|
||||
const bytes = await store.get(k);
|
||||
if (bytes) await store.put(k.slice(prefix.length), bytes);
|
||||
}
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId,
|
||||
actorId: opts.actorId,
|
||||
action: "import",
|
||||
entity: "tenant_restore",
|
||||
entityId: source.snapshotId ?? "in-memory",
|
||||
after: {
|
||||
restoredRows,
|
||||
deletedRows,
|
||||
preRestoreSnapshotId,
|
||||
stubIdentities: stubIdentities.length,
|
||||
relinkedIdentities: relinked.length,
|
||||
tombstonesReapplied,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
tenantId,
|
||||
restoredRows,
|
||||
deletedRows,
|
||||
stubIdentities,
|
||||
relinkedIdentities: relinked,
|
||||
preRestoreSnapshotId,
|
||||
tombstonesReapplied,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// ── NDJSON-Serialisierung + Manifest für Tenant-Artefakte ────────────────────
|
||||
//
|
||||
// Ein Artefakt ist ein einzelner gzip+AES-verschlüsselter Blob. Vor der
|
||||
// Kompression liegt es als Text vor:
|
||||
//
|
||||
// Zeile 1 .. N-1: je eine Tabelle als JSON { "model": "...", "rows": [...] }
|
||||
// (die Tabellen in Insert-Reihenfolge parent→child)
|
||||
//
|
||||
// Die Zeilenzahlen/Prüfsummen je Tabelle stehen im separaten Manifest (nicht
|
||||
// verschlüsselt zwingend, aber Teil des Blobs). Werte werden mit einem
|
||||
// typerhaltenden Replacer serialisiert: `Date`→{__t:"date"}, `BigInt`→{__t:"bigint"}
|
||||
// (BigInt kommt in Tenant-Modellen aktuell nicht vor, wird aber defensiv gestützt).
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
/** Schema-/Format-Version des Artefakt-Layouts (nicht die Prisma-Migration). */
|
||||
export const ARTIFACT_FORMAT_VERSION = 1;
|
||||
|
||||
export interface TableManifest {
|
||||
model: string;
|
||||
table: string;
|
||||
rowCount: number;
|
||||
/** SHA-256 über die serialisierten Zeilen (Integritätsnachweis). */
|
||||
checksum: string;
|
||||
}
|
||||
|
||||
export interface BackupManifest {
|
||||
formatVersion: number;
|
||||
tenantId: string;
|
||||
tenantSlug: string;
|
||||
/** ISO-Zeitpunkt des konsistenten Snapshots. */
|
||||
snapshotAt: string;
|
||||
/** Zuletzt angewandte Prisma-Migration (Kohärenzprüfung beim Restore). */
|
||||
schemaMigration: string | null;
|
||||
tables: TableManifest[];
|
||||
/** Distinkte identityId-Referenzen der User-Zeilen (für Identity-Stub-Guard). */
|
||||
identityRefs: string[];
|
||||
totalRows: number;
|
||||
}
|
||||
|
||||
interface TypedDate {
|
||||
__t: "date";
|
||||
v: string;
|
||||
}
|
||||
interface TypedBigInt {
|
||||
__t: "bigint";
|
||||
v: string;
|
||||
}
|
||||
|
||||
function replacer(_key: string, value: unknown): unknown {
|
||||
if (value instanceof Date) return { __t: "date", v: value.toISOString() } satisfies TypedDate;
|
||||
if (typeof value === "bigint") return { __t: "bigint", v: value.toString() } satisfies TypedBigInt;
|
||||
return value;
|
||||
}
|
||||
|
||||
function reviveValue(value: unknown): unknown {
|
||||
if (value && typeof value === "object") {
|
||||
const t = (value as { __t?: string }).__t;
|
||||
if (t === "date") return new Date((value as TypedDate).v);
|
||||
if (t === "bigint") return BigInt((value as TypedBigInt).v);
|
||||
if (Array.isArray(value)) return value.map(reviveValue);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value)) out[k] = reviveValue(v);
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export type TableRows = Record<string, unknown>[];
|
||||
|
||||
/** Serialisiert die Zeilen einer Tabelle zu einer NDJSON-Zeile + Manifest-Eintrag. */
|
||||
export function serializeTable(
|
||||
model: string,
|
||||
table: string,
|
||||
rows: TableRows,
|
||||
): { line: string; manifest: TableManifest } {
|
||||
const payload = JSON.stringify({ model, rows }, replacer);
|
||||
const checksum = createHash("sha256").update(payload).digest("hex");
|
||||
return {
|
||||
line: payload,
|
||||
manifest: { model, table, rowCount: rows.length, checksum },
|
||||
};
|
||||
}
|
||||
|
||||
/** Wandelt das gesamte NDJSON (ohne Manifest-Zeile) in geordnete Tabellen zurück. */
|
||||
export function parseArtifactBody(body: string): { model: string; rows: TableRows }[] {
|
||||
const out: { model: string; rows: TableRows }[] = [];
|
||||
for (const line of body.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
const parsed = JSON.parse(line) as { model: string; rows: TableRows };
|
||||
const rows = (reviveValue(parsed.rows) as TableRows) ?? [];
|
||||
out.push({ model: parsed.model, rows });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Prüft eine Tabellen-Zeile gegen den Manifest-Checksum (Integritätsnachweis). */
|
||||
export function verifyTableChecksum(
|
||||
model: string,
|
||||
rows: TableRows,
|
||||
expected: string,
|
||||
): boolean {
|
||||
const payload = JSON.stringify({ model, rows }, replacer);
|
||||
return createHash("sha256").update(payload).digest("hex") === expected;
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
// ── Traversierungs-Engine: FK-sichere Reihenfolge der mandanten-scoped Tabellen ──
|
||||
//
|
||||
// Phase 1 der Backup-/Restore-/DSGVO-Lane. Leitet EINE deterministische
|
||||
// Tabellen-Reihenfolge ab, die von Export (parent→child), Restore (Reinsert
|
||||
// parent→child, DELETE child→parent) und DSGVO-Löschung geteilt wird.
|
||||
//
|
||||
// Quelle der Wahrheit:
|
||||
// • Modell-Menge = `TENANT_MODELS` (aus src/server/db.ts) ∪ {UserRole, RolePermission}.
|
||||
// Die beiden Join-Tabellen tragen KEIN tenant_id (kein RLS) und sind bewusst
|
||||
// NICHT in TENANT_MODELS — ohne sie gingen beim Restore die Rollenzuweisungen
|
||||
// verloren. RolePermission.permissionId zeigt auf den GLOBALEN Permission-
|
||||
// Katalog (Schicht A) → nur die (roleId, permissionId)-Zuordnung wird
|
||||
// mit-exportiert, der Katalog selbst NICHT.
|
||||
// • Tabellennamen/Spalten = Prisma-DMMF (`Prisma.dmmf`), das die @@map-/@map-
|
||||
// Namen autoritativ trägt.
|
||||
// • FK-Kanten = die `@relation(fields: […])`-Attribute der kanonischen
|
||||
// `prisma/schema.prisma`. Grund: Prisma 7 entfernt `relationFromFields` aus
|
||||
// dem Laufzeit-DMMF (sowohl `Prisma.dmmf` als auch `_runtimeDataModel`), sodass
|
||||
// die Kantenrichtung dort nicht mehr ableitbar ist. Die Relation-Attribute im
|
||||
// Schema sind die IDENTISCHE Quelle, aus der Prisma das DMMF erzeugt.
|
||||
//
|
||||
// Sicherheitsnachweis (destruktive Lane): `assertTopologyMatchesDatabase()`
|
||||
// vergleicht die abgeleiteten Kanten gegen die realen FK-Constraints in Postgres
|
||||
// (`information_schema`). Jede Abweichung ist ein harter Fehler (fail-closed) —
|
||||
// so kann eine Schemaänderung die Traversierung nicht still invalidieren.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Mandanten-scoped Modelle mit `tenant_id`-Spalte (Spiegel von `TENANT_MODELS`
|
||||
* in src/server/db.ts — dort ist die Menge NICHT exportiert, hier bewusst
|
||||
* dupliziert und per `assertTenantModelsInSync()` im Test gegen die DB geprüft).
|
||||
*/
|
||||
export const TENANT_MODELS: readonly string[] = [
|
||||
"User",
|
||||
"Role",
|
||||
"AuditLog",
|
||||
"MailLog",
|
||||
"NotificationPreference",
|
||||
"AuthToken",
|
||||
"Task",
|
||||
"TaskComment",
|
||||
"TaskParticipant",
|
||||
"Evidence",
|
||||
"Audit",
|
||||
"AuditEvidenceItem",
|
||||
"ControlDescription",
|
||||
"ManagedRegister",
|
||||
"RegisterRow",
|
||||
"OnboardingProgress",
|
||||
"ProjectFunctionAssignment",
|
||||
"WizardFact",
|
||||
"WizardScope",
|
||||
"PolicyPackageState",
|
||||
"TenantSettings",
|
||||
"TenantModule",
|
||||
"Asset",
|
||||
"AssetRelation",
|
||||
"Process",
|
||||
"ProcessAsset",
|
||||
"BiaEntry",
|
||||
"Risk",
|
||||
"RiskAsset",
|
||||
"Measure",
|
||||
"RiskMeasure",
|
||||
"SupplierProfile",
|
||||
"ITServiceProfile",
|
||||
"SoftwareProfile",
|
||||
"ProjectProfile",
|
||||
"SupplierAssessment",
|
||||
"Contract",
|
||||
"Nda",
|
||||
"SupplierEvidence",
|
||||
"ServiceControlResponsibility",
|
||||
"Subcontractor",
|
||||
"ManagementDecision",
|
||||
"MaturityAssessment",
|
||||
"ControlAssessment",
|
||||
"ControlImplementation",
|
||||
"PolicyDocument",
|
||||
"PolicyRequirement",
|
||||
"PolicyVariable",
|
||||
"PolicyBaselineParam",
|
||||
"PolicyEvidence",
|
||||
"CryptoEntry",
|
||||
"ClassificationClass",
|
||||
"HandlingAspect",
|
||||
"HandlingRule",
|
||||
"RiskMatrixClass",
|
||||
"RiskEwLevel",
|
||||
"RiskDamageDimension",
|
||||
"HandbookTopic",
|
||||
];
|
||||
|
||||
/**
|
||||
* Join-Tabellen ohne eigenes tenant_id, aber tenant-relevant. Werden über die
|
||||
* id-Menge der Tenant-User/-Rollen ein-/ausgeschlossen (siehe `TableNode.scope`).
|
||||
*/
|
||||
export const JOIN_MODELS: readonly string[] = ["UserRole", "RolePermission"];
|
||||
|
||||
export interface FkEdge {
|
||||
/** Kind-Modell (hält die FK-Spalte). */
|
||||
child: string;
|
||||
/** Eltern-Modell (Ziel der FK). */
|
||||
parent: string;
|
||||
/** Prisma-Feldnamen der FK-Skalare im Kind. */
|
||||
fromFields: string[];
|
||||
/** DB-Spaltennamen der FK-Skalare im Kind. */
|
||||
fromColumns: string[];
|
||||
}
|
||||
|
||||
export type ScopeKind =
|
||||
| { by: "tenantColumn"; column: string }
|
||||
| { by: "idSet"; via: FkEdge };
|
||||
|
||||
export interface TableNode {
|
||||
/** Prisma-Modellname (z. B. "ITServiceProfile"). */
|
||||
model: string;
|
||||
/** Prisma-Client-Delegate-Name (z. B. "iTServiceProfile"). */
|
||||
delegate: string;
|
||||
/** DB-Tabellenname (@@map). */
|
||||
table: string;
|
||||
/** Primärschlüssel-Skalarfelder (Prisma-Namen). ["id"] bzw. Join-Composite. */
|
||||
pk: string[];
|
||||
/** `true`, wenn das Modell eine tenant_id-Spalte trägt. */
|
||||
hasTenantColumn: boolean;
|
||||
/**
|
||||
* Wie die Zeilen dieses Modells auf einen Mandanten begrenzt werden:
|
||||
* - tenantColumn: direktes WHERE tenant_id = A.
|
||||
* - idSet: über die FK auf ein bereits mandanten-gescoptes Eltern-Modell
|
||||
* (Join-Tabellen: WHERE <fk> IN (ids der Tenant-Zeilen des Elternteils)).
|
||||
*/
|
||||
scope: ScopeKind;
|
||||
}
|
||||
|
||||
export interface TenantTopology {
|
||||
/** Knoten nach Modellname. */
|
||||
nodes: Map<string, TableNode>;
|
||||
/** Reihenfolge parent→child (Export-Reihenfolge, Restore-Reinsert). */
|
||||
insertOrder: TableNode[];
|
||||
/** Reihenfolge child→parent (Restore-/Löschungs-DELETE). */
|
||||
deleteOrder: TableNode[];
|
||||
/** Alle FK-Kanten innerhalb der Modell-Menge. */
|
||||
edges: FkEdge[];
|
||||
}
|
||||
|
||||
/** Prisma-Client-Delegate-Name (nur erster Buchstabe klein — Prisma-Konvention). */
|
||||
function delegateName(model: string): string {
|
||||
return model.charAt(0).toLowerCase() + model.slice(1);
|
||||
}
|
||||
|
||||
// ── Schema-Parsing (FK-Kanten + PK) ─────────────────────────────────────────
|
||||
|
||||
interface ParsedModel {
|
||||
fields: {
|
||||
name: string;
|
||||
/** Zielmodell, falls Relationsfeld; sonst undefined. */
|
||||
relationTarget?: string;
|
||||
/** FK-Feldnamen aus @relation(fields: […]) (nur auf der FK-Haltenden Seite). */
|
||||
relationFromFields?: string[];
|
||||
isId?: boolean;
|
||||
}[];
|
||||
/** Composite-PK aus @@id([...]). */
|
||||
compositeId?: string[];
|
||||
}
|
||||
|
||||
let schemaCache: Map<string, ParsedModel> | null = null;
|
||||
|
||||
function locateSchema(): string {
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
// src/server/backup → Repo-Wurzel/prisma/schema.prisma
|
||||
return join(here, "..", "..", "..", "prisma", "schema.prisma");
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimaler, robuster Parser für die `@relation(fields: […])`-Topologie und die
|
||||
* Primärschlüssel. Bewusst kein voller PSL-Parser: es genügt, je Modell die
|
||||
* Relationsfelder mit `fields:[…]` und die `@id`/`@@id`-Angaben zu erkennen.
|
||||
*/
|
||||
function parseSchema(): Map<string, ParsedModel> {
|
||||
if (schemaCache) return schemaCache;
|
||||
const src = readFileSync(locateSchema(), "utf8");
|
||||
const models = new Map<string, ParsedModel>();
|
||||
|
||||
// Zuerst alle Modellnamen sammeln (zur Relations-Ziel-Erkennung).
|
||||
const modelNames = new Set<string>();
|
||||
for (const m of src.matchAll(/^\s*model\s+(\w+)\s*\{/gm)) modelNames.add(m[1]);
|
||||
|
||||
const blockRe = /^\s*model\s+(\w+)\s*\{([\s\S]*?)^\s*\}/gm;
|
||||
for (const block of src.matchAll(blockRe)) {
|
||||
const name = block[1];
|
||||
const body = block[2];
|
||||
const parsed: ParsedModel = { fields: [] };
|
||||
|
||||
for (const rawLine of body.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("//")) continue;
|
||||
|
||||
// Composite-PK: @@id([a, b])
|
||||
const compId = line.match(/^@@id\(\[([^\]]+)\]\)/);
|
||||
if (compId) {
|
||||
parsed.compositeId = compId[1].split(",").map((s) => s.trim());
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("@@")) continue;
|
||||
|
||||
// Feldzeile: "<name> <Type> ...".
|
||||
const fieldMatch = line.match(/^(\w+)\s+([A-Za-z0-9_]+)(\[\])?\??/);
|
||||
if (!fieldMatch) continue;
|
||||
const fieldName = fieldMatch[1];
|
||||
const typeName = fieldMatch[2];
|
||||
|
||||
const isRelation = modelNames.has(typeName);
|
||||
const relAttr = line.match(/@relation\(([^)]*)\)/);
|
||||
let relationFromFields: string[] | undefined;
|
||||
if (relAttr) {
|
||||
const fieldsAttr = relAttr[1].match(/fields:\s*\[([^\]]*)\]/);
|
||||
if (fieldsAttr) {
|
||||
relationFromFields = fieldsAttr[1]
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
parsed.fields.push({
|
||||
name: fieldName,
|
||||
relationTarget: isRelation ? typeName : undefined,
|
||||
relationFromFields,
|
||||
isId: /@id\b/.test(line),
|
||||
});
|
||||
}
|
||||
|
||||
models.set(name, parsed);
|
||||
}
|
||||
|
||||
schemaCache = models;
|
||||
return models;
|
||||
}
|
||||
|
||||
// ── DMMF-Zugriff (Tabellen-/Spaltennamen) ───────────────────────────────────
|
||||
|
||||
interface DmmfField {
|
||||
name: string;
|
||||
kind: string;
|
||||
dbName?: string | null;
|
||||
}
|
||||
interface DmmfModel {
|
||||
name: string;
|
||||
dbName?: string | null;
|
||||
fields: DmmfField[];
|
||||
}
|
||||
|
||||
function dmmfModels(): Map<string, DmmfModel> {
|
||||
const map = new Map<string, DmmfModel>();
|
||||
for (const m of Prisma.dmmf.datamodel.models as unknown as DmmfModel[]) {
|
||||
map.set(m.name, m);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** DB-Spaltenname eines Skalarfelds (aus dem DMMF, @map-aufgelöst). */
|
||||
function columnOf(dmmf: DmmfModel, fieldName: string): string {
|
||||
const f = dmmf.fields.find((x) => x.name === fieldName);
|
||||
return f?.dbName ?? fieldName;
|
||||
}
|
||||
|
||||
// ── Topologie-Aufbau ─────────────────────────────────────────────────────────
|
||||
|
||||
let topologyCache: TenantTopology | null = null;
|
||||
|
||||
/**
|
||||
* Baut die deterministische Traversierungs-Topologie. Idempotent/gecacht.
|
||||
*/
|
||||
export function buildTenantTopology(): TenantTopology {
|
||||
if (topologyCache) return topologyCache;
|
||||
|
||||
const set = new Set<string>([...TENANT_MODELS, ...JOIN_MODELS]);
|
||||
const schema = parseSchema();
|
||||
const dmmf = dmmfModels();
|
||||
|
||||
// Kanten sammeln (nur innerhalb der Menge; Selbstreferenzen ignoriert — sie
|
||||
// begrenzen die Zwischen-Modell-Ordnung nicht, werden beim Reinsert über
|
||||
// deaktivierte Trigger abgedeckt).
|
||||
const edges: FkEdge[] = [];
|
||||
for (const model of set) {
|
||||
const pm = schema.get(model);
|
||||
const dm = dmmf.get(model);
|
||||
if (!pm || !dm) {
|
||||
throw new Error(`Topologie: Modell ${model} nicht in Schema/DMMF gefunden.`);
|
||||
}
|
||||
for (const f of pm.fields) {
|
||||
if (!f.relationTarget || !f.relationFromFields?.length) continue;
|
||||
const parent = f.relationTarget;
|
||||
if (parent === model) continue; // Selbstreferenz
|
||||
if (!set.has(parent)) {
|
||||
// Kante auf globale Schicht-A-Tabelle (Tenant/Identity/Permission …):
|
||||
// für die Ordnung irrelevant (Eltern immer vorhanden).
|
||||
continue;
|
||||
}
|
||||
edges.push({
|
||||
child: model,
|
||||
parent,
|
||||
fromFields: f.relationFromFields,
|
||||
fromColumns: f.relationFromFields.map((ff) => columnOf(dm, ff)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Knoten bauen.
|
||||
const nodes = new Map<string, TableNode>();
|
||||
for (const model of set) {
|
||||
const dm = dmmf.get(model)!;
|
||||
const pm = schema.get(model)!;
|
||||
const hasTenantColumn = dm.fields.some((f) => f.name === "tenantId");
|
||||
|
||||
// PK bestimmen.
|
||||
let pk: string[];
|
||||
if (pm.compositeId) pk = pm.compositeId;
|
||||
else {
|
||||
const idField = pm.fields.find((f) => f.isId)?.name;
|
||||
pk = idField ? [idField] : ["id"];
|
||||
}
|
||||
|
||||
// Scope bestimmen.
|
||||
let scope: ScopeKind;
|
||||
if (hasTenantColumn) {
|
||||
scope = { by: "tenantColumn", column: columnOf(dm, "tenantId") };
|
||||
} else {
|
||||
// Join-Tabelle: über die FK auf ein in-set, mandanten-gescoptes Elternteil.
|
||||
// Bevorzugt eine Kante, deren Elternteil selbst eine tenant_id-Spalte hat
|
||||
// (User/Role) → deterministisch die erste solche in Schema-Reihenfolge.
|
||||
const scopingEdge = edges.find(
|
||||
(e) => e.child === model && dmmf.get(e.parent)?.fields.some((f) => f.name === "tenantId"),
|
||||
);
|
||||
if (!scopingEdge) {
|
||||
throw new Error(
|
||||
`Topologie: ${model} hat kein tenant_id und keine FK auf ein mandanten-gescoptes Elternteil — Scope unbestimmbar.`,
|
||||
);
|
||||
}
|
||||
scope = { by: "idSet", via: scopingEdge };
|
||||
}
|
||||
|
||||
nodes.set(model, {
|
||||
model,
|
||||
delegate: delegateName(model),
|
||||
table: dm.dbName ?? model,
|
||||
pk,
|
||||
hasTenantColumn,
|
||||
scope,
|
||||
});
|
||||
}
|
||||
|
||||
// Deterministischer topologischer Sort (Kahn, alphabetische Tiebreaks).
|
||||
const indeg = new Map<string, number>();
|
||||
const adj = new Map<string, string[]>();
|
||||
for (const model of set) {
|
||||
indeg.set(model, 0);
|
||||
adj.set(model, []);
|
||||
}
|
||||
const seenEdge = new Set<string>();
|
||||
for (const e of edges) {
|
||||
const key = `${e.child}|${e.parent}`;
|
||||
if (seenEdge.has(key)) continue;
|
||||
seenEdge.add(key);
|
||||
adj.get(e.parent)!.push(e.child);
|
||||
indeg.set(e.child, indeg.get(e.child)! + 1);
|
||||
}
|
||||
const queue = [...set].filter((m) => indeg.get(m) === 0).sort();
|
||||
const insertOrderNames: string[] = [];
|
||||
while (queue.length) {
|
||||
const n = queue.shift()!;
|
||||
insertOrderNames.push(n);
|
||||
for (const c of adj.get(n)!.slice().sort()) {
|
||||
indeg.set(c, indeg.get(c)! - 1);
|
||||
if (indeg.get(c) === 0) {
|
||||
// stabil einsortieren
|
||||
queue.push(c);
|
||||
queue.sort();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (insertOrderNames.length !== set.size) {
|
||||
const remaining = [...set].filter((m) => !insertOrderNames.includes(m));
|
||||
throw new Error(
|
||||
`Topologie: FK-Zyklus zwischen Tenant-Modellen entdeckt — betroffen: ${remaining.join(", ")}. ` +
|
||||
`Zyklen erfordern DEFERRABLE-Constraints oder eine explizite Bruchstelle.`,
|
||||
);
|
||||
}
|
||||
|
||||
const insertOrder = insertOrderNames.map((m) => nodes.get(m)!);
|
||||
const deleteOrder = [...insertOrder].reverse();
|
||||
|
||||
topologyCache = { nodes, insertOrder, deleteOrder, edges };
|
||||
return topologyCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sicherheitsnachweis: die abgeleitete FK-Topologie MUSS deckungsgleich mit den
|
||||
* realen Postgres-Constraints sein. Prüft beide Richtungen:
|
||||
* (a) jede abgeleitete In-Set-Kante existiert als FK in der DB,
|
||||
* (b) jede reale FK zwischen zwei In-Set-Tabellen ist als Kante abgeleitet.
|
||||
* Wirft bei Abweichung (fail-closed) — vor Export/Restore aufzurufen.
|
||||
*/
|
||||
export async function assertTopologyMatchesDatabase(
|
||||
db: Pick<PrismaClient, "$queryRawUnsafe">,
|
||||
): Promise<void> {
|
||||
const topo = buildTenantTopology();
|
||||
const tableToModel = new Map<string, string>();
|
||||
for (const n of topo.nodes.values()) tableToModel.set(n.table, n.model);
|
||||
|
||||
const rows = await db.$queryRawUnsafe<
|
||||
{ child: string; parent: string; col: string }[]
|
||||
>(`
|
||||
SELECT tc.table_name AS child, ccu.table_name AS parent, kcu.column_name AS col
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = 'public'`);
|
||||
|
||||
const dbEdges = new Set<string>();
|
||||
for (const r of rows) {
|
||||
const cm = tableToModel.get(r.child);
|
||||
const pm = tableToModel.get(r.parent);
|
||||
if (!cm || !pm) continue; // mind. eine Seite global → für Ordnung irrelevant
|
||||
if (cm === pm) continue; // Selbstreferenz
|
||||
dbEdges.add(`${cm}|${pm}`);
|
||||
}
|
||||
const derived = new Set(topo.edges.map((e) => `${e.child}|${e.parent}`));
|
||||
|
||||
const missingInDerived = [...dbEdges].filter((e) => !derived.has(e));
|
||||
const missingInDb = [...derived].filter((e) => !dbEdges.has(e));
|
||||
if (missingInDerived.length || missingInDb.length) {
|
||||
throw new Error(
|
||||
"Topologie stimmt NICHT mit den DB-FK-Constraints überein (fail-closed). " +
|
||||
(missingInDerived.length
|
||||
? `In DB, aber nicht abgeleitet: ${missingInDerived.join(", ")}. `
|
||||
: "") +
|
||||
(missingInDb.length
|
||||
? `Abgeleitet, aber nicht in DB: ${missingInDb.join(", ")}.`
|
||||
: ""),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// ── BullMQ-Worker der Backup-/DSGVO-Ops-Queue ────────────────────────────────
|
||||
//
|
||||
// Analog zum Mail-Worker (SEC1). Nimmt Jobs aus `backup-ops` und führt sie über
|
||||
// `processBackupJob` aus. Concurrency 1: Restore ist destruktiv und langlaufend
|
||||
// (Mandant-Sperre, Pre-Restore-Snapshot) — Jobs laufen bewusst seriell, nicht
|
||||
// parallel. Kein automatisches Retry (attempts=1 in queue.ts): eine Wiederholung
|
||||
// eines destruktiven Restore ist eine bewusste Betreiber-Entscheidung.
|
||||
|
||||
import { Worker, type Job } from "bullmq";
|
||||
import { BACKUP_QUEUE, type BackupOpsJob } from "./job";
|
||||
import { closeBackupQueues, getBackupConnection, getBackupDeadLetterQueue } from "./queue";
|
||||
import { processBackupJob } from "./ops";
|
||||
|
||||
export function startBackupWorker(): Worker<BackupOpsJob> {
|
||||
const connection = getBackupConnection();
|
||||
if (!connection) {
|
||||
throw new Error("REDIS_URL ist nicht gesetzt — ohne Redis gibt es keinen Backup-Worker-Betrieb.");
|
||||
}
|
||||
|
||||
const worker = new Worker<BackupOpsJob>(
|
||||
BACKUP_QUEUE,
|
||||
async (job: Job<BackupOpsJob>) => {
|
||||
await processBackupJob(job.data);
|
||||
},
|
||||
{ connection, concurrency: 1 },
|
||||
);
|
||||
|
||||
worker.on("failed", async (job, err) => {
|
||||
if (!job) return;
|
||||
console.error(`[backup] Job ${job.id} (${job.data.kind}) fehlgeschlagen: ${err.message}`);
|
||||
// attempts=1 → jeder Fehlschlag ist endgültig: Dead-Letter.
|
||||
await getBackupDeadLetterQueue()
|
||||
?.add("dead", { job: job.data, error: err.message })
|
||||
.catch(() => {});
|
||||
console.error(`[backup] ALARM — Job ${job.id} in die Dead-Letter-Queue verschoben.`);
|
||||
});
|
||||
|
||||
worker.on("completed", (job) => {
|
||||
console.info(`[backup] Job ${job.id} (${job.data.kind}) fertig — Mandant ${job.data.tenantId}.`);
|
||||
});
|
||||
|
||||
return worker;
|
||||
}
|
||||
|
||||
/** Sauberes Herunterfahren von Worker und Redis. */
|
||||
export async function shutdownBackupWorker(worker: Worker | null): Promise<void> {
|
||||
await worker?.close();
|
||||
await closeBackupQueues();
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// ── Minimaler, abhängigkeitsfreier ZIP-Writer (DSGVO-Zustellpakete) ──────────
|
||||
//
|
||||
// Baut ein Standard-ZIP (PKZIP/APPNOTE) rein aus node:zlib + einer eigenen
|
||||
// CRC32-Implementierung — KEINE Zusatz-Dependency (jszip/archiver sind nur
|
||||
// transitiv im Baum und keine deklarierten Abhängigkeiten). Verwendet die
|
||||
// Deflate-Methode; Ausgabe ist mit jedem Standard-Entpacker (`unzip`, macOS
|
||||
// Archive Utility, jszip) lesbar. Bewusst klein gehalten: keine ZIP64-,
|
||||
// Streaming- oder Verschlüsselungs-Features (die Vertraulichkeit liegt beim
|
||||
// signierten, ablaufenden Download-Link bzw. der Transport-/Speicher-Ebene).
|
||||
|
||||
import { deflateRawSync } from "node:zlib";
|
||||
|
||||
export interface ZipEntry {
|
||||
/** Pfad im Archiv (mit "/" als Trenner). */
|
||||
name: string;
|
||||
/** Inhalt. Strings werden als UTF-8 kodiert. */
|
||||
data: Buffer | string;
|
||||
}
|
||||
|
||||
// CRC32-Tabelle (IEEE 802.3, Polynom 0xEDB88320) — einmalig vorberechnet.
|
||||
const CRC_TABLE: number[] = (() => {
|
||||
const table: number[] = new Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
table[n] = c >>> 0;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
function crc32(buf: Buffer): number {
|
||||
let crc = 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i++) crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* MS-DOS-Zeitstempel (feste, reproduzierbare Zeit). ZIP kennt nur lokale
|
||||
* DOS-Zeit; wir setzen einen konstanten Wert, damit dieselbe Eingabe dasselbe
|
||||
* Archiv ergibt (testbar). 1980-01-01 00:00:00.
|
||||
*/
|
||||
const DOS_DATE = 0x0021; // Jahr 1980, Monat 1, Tag 1
|
||||
const DOS_TIME = 0x0000;
|
||||
|
||||
/** Baut ein ZIP-Archiv aus den Einträgen und gibt die Bytes zurück. */
|
||||
export function buildZip(entries: ZipEntry[]): Buffer {
|
||||
const chunks: Buffer[] = [];
|
||||
const central: Buffer[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
const nameBuf = Buffer.from(entry.name, "utf8");
|
||||
const raw = typeof entry.data === "string" ? Buffer.from(entry.data, "utf8") : entry.data;
|
||||
const crc = crc32(raw);
|
||||
const compressed = deflateRawSync(raw);
|
||||
// Falls Deflate größer wäre (winzige Dateien), trotzdem Deflate nutzen — der
|
||||
// Overhead ist vernachlässigbar und hält den Writer einfach (eine Methode).
|
||||
const method = 8; // deflate
|
||||
|
||||
// Local file header.
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(0x04034b50, 0); // Signatur
|
||||
local.writeUInt16LE(20, 4); // benötigte Version
|
||||
local.writeUInt16LE(0x0800, 6); // Flags: Bit 11 = UTF-8-Dateinamen
|
||||
local.writeUInt16LE(method, 8);
|
||||
local.writeUInt16LE(DOS_TIME, 10);
|
||||
local.writeUInt16LE(DOS_DATE, 12);
|
||||
local.writeUInt32LE(crc, 14);
|
||||
local.writeUInt32LE(compressed.length, 18);
|
||||
local.writeUInt32LE(raw.length, 22);
|
||||
local.writeUInt16LE(nameBuf.length, 26);
|
||||
local.writeUInt16LE(0, 28); // Extra-Feld-Länge
|
||||
|
||||
chunks.push(local, nameBuf, compressed);
|
||||
|
||||
// Central directory record.
|
||||
const cdir = Buffer.alloc(46);
|
||||
cdir.writeUInt32LE(0x02014b50, 0); // Signatur
|
||||
cdir.writeUInt16LE(20, 4); // erzeugende Version
|
||||
cdir.writeUInt16LE(20, 6); // benötigte Version
|
||||
cdir.writeUInt16LE(0x0800, 8); // Flags
|
||||
cdir.writeUInt16LE(method, 10);
|
||||
cdir.writeUInt16LE(DOS_TIME, 12);
|
||||
cdir.writeUInt16LE(DOS_DATE, 14);
|
||||
cdir.writeUInt32LE(crc, 16);
|
||||
cdir.writeUInt32LE(compressed.length, 20);
|
||||
cdir.writeUInt32LE(raw.length, 24);
|
||||
cdir.writeUInt16LE(nameBuf.length, 28);
|
||||
cdir.writeUInt16LE(0, 30); // Extra
|
||||
cdir.writeUInt16LE(0, 32); // Kommentar
|
||||
cdir.writeUInt16LE(0, 34); // Disk-Nummer
|
||||
cdir.writeUInt16LE(0, 36); // interne Attribute
|
||||
cdir.writeUInt32LE(0, 38); // externe Attribute
|
||||
cdir.writeUInt32LE(offset, 42); // Offset des Local-Headers
|
||||
central.push(cdir, nameBuf);
|
||||
|
||||
offset += local.length + nameBuf.length + compressed.length;
|
||||
}
|
||||
|
||||
const centralBuf = Buffer.concat(central);
|
||||
const centralOffset = offset;
|
||||
|
||||
// End of central directory record.
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(0x06054b50, 0);
|
||||
eocd.writeUInt16LE(0, 4); // Disk
|
||||
eocd.writeUInt16LE(0, 6); // Disk mit Central-Dir-Start
|
||||
eocd.writeUInt16LE(entries.length, 8);
|
||||
eocd.writeUInt16LE(entries.length, 10);
|
||||
eocd.writeUInt32LE(centralBuf.length, 12);
|
||||
eocd.writeUInt32LE(centralOffset, 16);
|
||||
eocd.writeUInt16LE(0, 20); // Kommentarlänge
|
||||
|
||||
return Buffer.concat([...chunks, centralBuf, eocd]);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { TenantDb } from "@/server/db";
|
||||
import type { ControlDescription } from "@prisma/client";
|
||||
import { controlTitle, compareControl } from "@/lib/control-titles";
|
||||
import type { DraftInput } from "@/server/ai/draft-control-description";
|
||||
|
||||
/**
|
||||
* Gemeinsamer Server-Kontext für die Control-Beschreibungen (VDA-ISA-Spalte 4).
|
||||
* Bündelt je Control die Kontrollfrage, die Anforderungs-Bullets aus
|
||||
* `PolicyRequirement`, die verknüpften Richtlinien/Verfahren (`PolicyDocument`)
|
||||
* sowie den vorhandenen Nachweisstand (`Evidence`) und die bereits erfassten
|
||||
* `ControlDescription`-Zeilen. Wird von der Controls-Seite, der ABGABE-Vorschau
|
||||
* und den KI-/Speicher-Actions geteilt (eine Aufbereitungsstelle).
|
||||
*/
|
||||
|
||||
export type DescriptionStatus = "open" | "draft" | "confirmed";
|
||||
|
||||
export interface RequirementBullet {
|
||||
reqId: string;
|
||||
obligation: string; // MUSS | SOLL
|
||||
requirement: string;
|
||||
implementation: string;
|
||||
/** Verknüpfte Dokumente (policyCode + vaCodes → PolicyDocument). */
|
||||
documents: { code: string; title: string; version: string; status: string }[];
|
||||
/** Bereits erfasste Beschreibung (Entwurf/übernommen) oder null. */
|
||||
description: {
|
||||
draftText: string | null;
|
||||
sourceRef: string | null;
|
||||
confidence: string | null;
|
||||
status: DescriptionStatus;
|
||||
openAnswer: string | null;
|
||||
} | null;
|
||||
/** True, wenn zum Control mindestens ein Nachweis existiert. */
|
||||
hasEvidence: boolean;
|
||||
}
|
||||
|
||||
/** Status-Ampel je Control: grün (alle übernommen) / orange (teils) / rot (keine). */
|
||||
export type ControlLamp = "green" | "amber" | "red";
|
||||
|
||||
export interface ControlGroup {
|
||||
control: string;
|
||||
frage: string;
|
||||
bullets: RequirementBullet[];
|
||||
/** Nachweistitel des Controls (Evidence). */
|
||||
evidence: string[];
|
||||
lamp: ControlLamp;
|
||||
}
|
||||
|
||||
function descStatus(row: ControlDescription | undefined): DescriptionStatus {
|
||||
const s = row?.status;
|
||||
return s === "confirmed" || s === "draft" ? s : "open";
|
||||
}
|
||||
|
||||
/** Ampel aus den Beschreibungs-Status der Bullets. */
|
||||
function lampOf(bullets: RequirementBullet[]): ControlLamp {
|
||||
if (bullets.length === 0) return "red";
|
||||
const confirmed = bullets.filter((b) => b.description?.status === "confirmed").length;
|
||||
if (confirmed === bullets.length) return "green";
|
||||
const started = bullets.filter((b) => b.description && b.description.status !== "open").length;
|
||||
return started > 0 ? "amber" : "red";
|
||||
}
|
||||
|
||||
/** Lädt alle Controls (mit Anforderungs-Bullets, Dokumenten, Nachweisen, Beschreibungen). */
|
||||
export async function buildControlGroups(db: TenantDb): Promise<ControlGroup[]> {
|
||||
const [reqs, docs, evidence, descriptions] = await Promise.all([
|
||||
db.policyRequirement.findMany({
|
||||
where: { archivedAt: null },
|
||||
select: { reqId: true, control: true, obligation: true, requirement: true, implementation: true, policyCode: true, vaCodes: true },
|
||||
}),
|
||||
db.policyDocument.findMany({ where: { archivedAt: null }, select: { code: true, title: true, version: true, status: true } }),
|
||||
db.evidence.findMany({ where: { control: { not: null } }, select: { control: true, title: true } }),
|
||||
db.controlDescription.findMany(),
|
||||
]);
|
||||
|
||||
const docByCode = new Map(docs.map((d) => [d.code, d]));
|
||||
const descByReq = new Map(descriptions.map((d) => [d.reqId, d]));
|
||||
const evidenceByControl = new Map<string, string[]>();
|
||||
for (const e of evidence) {
|
||||
if (!e.control) continue;
|
||||
const list = evidenceByControl.get(e.control) ?? [];
|
||||
list.push(e.title);
|
||||
evidenceByControl.set(e.control, list);
|
||||
}
|
||||
|
||||
const byControl = new Map<string, RequirementBullet[]>();
|
||||
for (const r of reqs) {
|
||||
const codes = [r.policyCode, ...r.vaCodes].filter(Boolean);
|
||||
const documents = [...new Set(codes)]
|
||||
.map((c) => docByCode.get(c))
|
||||
.filter((d): d is NonNullable<typeof d> => Boolean(d))
|
||||
.map((d) => ({ code: d.code, title: d.title, version: d.version, status: d.status }));
|
||||
const row = descByReq.get(r.reqId);
|
||||
const bullet: RequirementBullet = {
|
||||
reqId: r.reqId,
|
||||
obligation: r.obligation,
|
||||
requirement: r.requirement,
|
||||
implementation: r.implementation,
|
||||
documents,
|
||||
description: row
|
||||
? { draftText: row.draftText, sourceRef: row.sourceRef, confidence: row.confidence, status: descStatus(row), openAnswer: row.openAnswer }
|
||||
: null,
|
||||
hasEvidence: (evidenceByControl.get(r.control)?.length ?? 0) > 0,
|
||||
};
|
||||
const list = byControl.get(r.control) ?? [];
|
||||
list.push(bullet);
|
||||
byControl.set(r.control, list);
|
||||
}
|
||||
|
||||
return [...byControl.entries()]
|
||||
.sort((a, b) => compareControl(a[0], b[0]))
|
||||
.map(([control, bullets]) => ({
|
||||
control,
|
||||
frage: controlTitle(control),
|
||||
bullets: bullets.sort((a, b) => a.reqId.localeCompare(b.reqId, undefined, { numeric: true })),
|
||||
evidence: evidenceByControl.get(control) ?? [],
|
||||
lamp: lampOf(bullets),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut den KI-Eingabekontext für eine einzelne Anforderung (reqId). Liefert
|
||||
* `null`, wenn die Anforderung nicht existiert. Wird von der Draft-Action genutzt.
|
||||
*/
|
||||
export async function buildDraftInput(db: TenantDb, reqId: string): Promise<DraftInput | null> {
|
||||
const r = await db.policyRequirement.findFirst({
|
||||
where: { reqId, archivedAt: null },
|
||||
select: { reqId: true, control: true, obligation: true, requirement: true, implementation: true, policyCode: true, vaCodes: true },
|
||||
});
|
||||
if (!r) return null;
|
||||
|
||||
const codes = [...new Set([r.policyCode, ...r.vaCodes].filter(Boolean))];
|
||||
const [docs, evidence] = await Promise.all([
|
||||
db.policyDocument.findMany({ where: { code: { in: codes }, archivedAt: null }, select: { code: true, title: true, version: true, status: true } }),
|
||||
db.evidence.findMany({ where: { control: r.control }, select: { title: true } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
control: r.control,
|
||||
reqId: r.reqId,
|
||||
obligation: r.obligation,
|
||||
requirement: r.requirement,
|
||||
implementation: r.implementation,
|
||||
documents: docs.map((d) => ({ code: d.code, title: d.title, version: d.version, status: d.status })),
|
||||
evidence: evidence.map((e) => e.title),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// Cockpit (M3, 1.4): serverseitige Auflösung Control → Bereich + Default-RACI.
|
||||
//
|
||||
// Reihenfolge: Tenant-Override (`ControlDomainMap.tenantId = <tenant>`) vor globalem
|
||||
// Default (`tenantId = null`); je Ebene exakter Control-Match vor Kapitel-Präfix.
|
||||
// Fällt auf die statischen Defaults (`CONTROL_DOMAIN_DEFAULTS`) zurück, falls die
|
||||
// Tabelle (noch) nicht geseedet ist.
|
||||
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { Domain, RaciKind } from "@prisma/client";
|
||||
import type { TenantDb } from "@/server/db";
|
||||
import {
|
||||
CONTROL_DOMAIN_DEFAULTS,
|
||||
matchControlRules,
|
||||
type ControlDomainRule,
|
||||
} from "@/lib/control-domain";
|
||||
|
||||
export interface ResolvedControlDomain {
|
||||
/** Primärer Bereich (RESPONSIBLE) — Default für Task.domain. */
|
||||
domain: Domain | null;
|
||||
/** Alle Regeln (RESPONSIBLE + Mitwirkende) für die beste Genauigkeitsstufe. */
|
||||
rules: ControlDomainRule[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bereich + Default-RACI für ein Control ermitteln. Für die Aufgaben-Erzeugung
|
||||
* (soa/gap/trigger) — setzt `Task.domain` und liefert die Default-Mitwirkenden.
|
||||
*/
|
||||
export async function resolveControlDomain(
|
||||
db: TenantDb,
|
||||
tenantId: string,
|
||||
control: string | null | undefined,
|
||||
): Promise<ResolvedControlDomain> {
|
||||
if (!control) return { domain: null, rules: [] };
|
||||
|
||||
// Tenant-Override zuerst, sonst globaler Default. Nur die potenziell relevanten
|
||||
// Zeilen laden (exaktes Control ODER Kapitel-Präfix).
|
||||
const chapter = String(control).trim().split(".")[0] ?? "";
|
||||
const rows = await db.controlDomainMap.findMany({
|
||||
where: {
|
||||
control: { in: [String(control).trim(), chapter] },
|
||||
OR: [{ tenantId }, { tenantId: null }],
|
||||
},
|
||||
});
|
||||
|
||||
// Tenant-Override gewinnt: gibt es für die beste Genauigkeitsstufe Tenant-Zeilen,
|
||||
// nur diese verwenden.
|
||||
const asRules = (list: typeof rows): ControlDomainRule[] =>
|
||||
list.map((r) => ({ control: r.control, domain: r.domain, raci: r.raci, functionKey: r.functionKey ?? undefined }));
|
||||
|
||||
const tenantRules = matchControlRules(control, asRules(rows.filter((r) => r.tenantId === tenantId)));
|
||||
const globalRules = matchControlRules(control, asRules(rows.filter((r) => r.tenantId === null)));
|
||||
|
||||
let rules = tenantRules.length ? tenantRules : globalRules;
|
||||
// DB nicht geseedet → statischer Fallback.
|
||||
if (!rules.length) rules = matchControlRules(control, CONTROL_DOMAIN_DEFAULTS);
|
||||
|
||||
const primary = rules.find((r) => r.raci === "RESPONSIBLE") ?? rules[0];
|
||||
return { domain: primary?.domain ?? null, rules };
|
||||
}
|
||||
|
||||
/** Nur die Default-Domain (RESPONSIBLE) — Kurzform für Task-Erzeugung. */
|
||||
export async function domainForControl(
|
||||
db: TenantDb,
|
||||
tenantId: string,
|
||||
control: string | null | undefined,
|
||||
): Promise<Domain | null> {
|
||||
const { domain } = await resolveControlDomain(db, tenantId, control);
|
||||
return domain;
|
||||
}
|
||||
|
||||
/** Nachrangige Mitwirkende (nicht RESPONSIBLE) mit Funktions-Default. */
|
||||
export function consultedFunctions(rules: ControlDomainRule[]): { functionKey: string; raci: RaciKind }[] {
|
||||
return rules
|
||||
.filter((r) => r.raci !== "RESPONSIBLE" && r.functionKey)
|
||||
.map((r) => ({ functionKey: r.functionKey as string, raci: r.raci }));
|
||||
}
|
||||
|
||||
// ── Cockpit (M3, 2.3): RACI-Auto-Befüllung (TaskParticipant) ──────────────────
|
||||
//
|
||||
// Auflösung Control/Domain → zuständige Funktionen (RESPONSIBLE + CONSULTED) →
|
||||
// über ProjectFunctionAssignment (functionKey→userId, je Tenant) zu Usern →
|
||||
// TaskParticipant-Zeilen. Zentral aus der Task-Erzeugung aufgerufen; sobald die
|
||||
// Funktion→User-Zuordnung (Ebene 1) gepflegt ist, bekommen neue Aufgaben
|
||||
// automatisch Mitwirkende.
|
||||
|
||||
/** RACI-Rang: RESPONSIBLE gewinnt vor CONSULTED bei Funktions-Dedup. */
|
||||
const RACI_RANK: Record<RaciKind, number> = { RESPONSIBLE: 0, ACCOUNTABLE: 1, CONSULTED: 2, INFORMED: 3 };
|
||||
|
||||
/** Nur die für die Auto-Befüllung relevanten Rollen (Sichtbarkeit + Verantwortung). */
|
||||
const RACI_ASSIGNABLE: RaciKind[] = ["RESPONSIBLE", "CONSULTED"];
|
||||
|
||||
/** Für eine Domain die zuständigen Funktionen aus ControlDomainMap (Tenant-Override vor global, sonst statischer Fallback). */
|
||||
async function domainFunctionRules(
|
||||
db: TenantDb,
|
||||
tenantId: string,
|
||||
domain: Domain,
|
||||
): Promise<ControlDomainRule[]> {
|
||||
// ControlDomainMap ist ein globales Modell (kein Auto-Tenant-Filter) → explizit
|
||||
// Tenant-Override ODER globalen Default laden.
|
||||
const rows = await db.controlDomainMap.findMany({
|
||||
where: { domain, OR: [{ tenantId }, { tenantId: null }] },
|
||||
});
|
||||
const tenantRows = rows.filter((r) => r.tenantId === tenantId);
|
||||
const chosen = tenantRows.length ? tenantRows : rows.filter((r) => r.tenantId === null);
|
||||
if (chosen.length) {
|
||||
return chosen.map((r) => ({ control: r.control, domain: r.domain, raci: r.raci, functionKey: r.functionKey ?? undefined }));
|
||||
}
|
||||
// DB (noch) nicht geseedet → statischer Fallback.
|
||||
return CONTROL_DOMAIN_DEFAULTS.filter((r) => r.domain === domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zuständige Funktionen (RESPONSIBLE + CONSULTED) für Control und/oder Domain.
|
||||
* Dedupliziert je functionKey (RESPONSIBLE gewinnt vor CONSULTED).
|
||||
*/
|
||||
async function responsibleFunctions(
|
||||
db: TenantDb,
|
||||
tenantId: string,
|
||||
control: string | null | undefined,
|
||||
domain: Domain | null | undefined,
|
||||
): Promise<{ functionKey: string; raci: RaciKind }[]> {
|
||||
const best = new Map<string, RaciKind>();
|
||||
const add = (functionKey: string | undefined, raci: RaciKind) => {
|
||||
if (!functionKey || !RACI_ASSIGNABLE.includes(raci)) return;
|
||||
const cur = best.get(functionKey);
|
||||
if (cur == null || RACI_RANK[raci] < RACI_RANK[cur]) best.set(functionKey, raci);
|
||||
};
|
||||
|
||||
if (control) {
|
||||
const { rules } = await resolveControlDomain(db, tenantId, control);
|
||||
for (const r of rules) add(r.functionKey, r.raci);
|
||||
}
|
||||
if (domain) {
|
||||
for (const r of await domainFunctionRules(db, tenantId, domain)) add(r.functionKey, r.raci);
|
||||
}
|
||||
return [...best].map(([functionKey, raci]) => ({ functionKey, raci }));
|
||||
}
|
||||
|
||||
export interface AssignTaskParticipantsInput {
|
||||
taskId: string;
|
||||
control?: string | null;
|
||||
domain?: Domain | null;
|
||||
/** Primär-Verantwortliche(r) (Task.assigneeId) — nicht doppelt als RESPONSIBLE-Participant. */
|
||||
assigneeId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* RACI-Auto-Befüllung: aus ControlDomainMap (Control und/oder Domain) die zuständigen
|
||||
* Funktionen (RESPONSIBLE + CONSULTED, Tenant-Override vor global) auflösen, über
|
||||
* ProjectFunctionAssignment (functionKey→userId je Tenant) zu Usern mappen und
|
||||
* TaskParticipant-Zeilen anlegen. Unbesetzte Funktionen (userId null) werden
|
||||
* übersprungen. Idempotent (Unique tenantId+taskId+userId+raci; P2002 wird
|
||||
* abgefangen). Der assigneeId wird NICHT doppelt als RESPONSIBLE-Participant gesetzt.
|
||||
* Gibt die Anzahl neu angelegter Mitwirkenden-Zeilen zurück.
|
||||
*/
|
||||
export async function assignTaskParticipants(
|
||||
db: TenantDb,
|
||||
tenantId: string,
|
||||
input: AssignTaskParticipantsInput,
|
||||
): Promise<number> {
|
||||
const functions = await responsibleFunctions(db, tenantId, input.control, input.domain);
|
||||
if (!functions.length) return 0;
|
||||
|
||||
const keys = [...new Set(functions.map((f) => f.functionKey))];
|
||||
// db ist mandantengebunden → ProjectFunctionAssignment wird automatisch tenant-gefiltert.
|
||||
const assignments = await db.projectFunctionAssignment.findMany({
|
||||
where: { functionKey: { in: keys } },
|
||||
select: { functionKey: true, userId: true },
|
||||
});
|
||||
const usersByFunction = new Map<string, string[]>();
|
||||
for (const a of assignments) {
|
||||
if (!a.userId) continue; // unbesetzte Funktion überspringen
|
||||
const list = usersByFunction.get(a.functionKey) ?? [];
|
||||
if (!list.includes(a.userId)) list.push(a.userId);
|
||||
usersByFunction.set(a.functionKey, list);
|
||||
}
|
||||
|
||||
// (userId, raci) deduplizieren; assignee nicht doppelt als RESPONSIBLE.
|
||||
const wanted = new Map<string, { userId: string; raci: RaciKind }>();
|
||||
for (const fn of functions) {
|
||||
for (const userId of usersByFunction.get(fn.functionKey) ?? []) {
|
||||
if (fn.raci === "RESPONSIBLE" && userId === input.assigneeId) continue;
|
||||
wanted.set(`${userId}::${fn.raci}`, { userId, raci: fn.raci });
|
||||
}
|
||||
}
|
||||
if (!wanted.size) return 0;
|
||||
|
||||
let created = 0;
|
||||
for (const { userId, raci } of wanted.values()) {
|
||||
try {
|
||||
// tenantId zusätzlich explizit (Prisma-Typ verlangt es; der Tenant-Guard setzt ihn ohnehin).
|
||||
await db.taskParticipant.create({ data: { tenantId, taskId: input.taskId, userId, raci } });
|
||||
created++;
|
||||
} catch (e) {
|
||||
// Idempotenz: Wettlauf/Doppelanlage über den Unique-Constraint abfangen.
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") continue;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
return created;
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
|
||||
/**
|
||||
* Central database access for the ISMS tool.
|
||||
*
|
||||
* Multi-tenant rule (see docs/SPEC.md §2): no query on tenant-scoped models
|
||||
* without a tenant context. Application code MUST use `dbForTenant(tenantId)`
|
||||
* for all tenant-scoped models — the raw client is only for global data
|
||||
* (permissions, control catalogs) and platform administration.
|
||||
*
|
||||
* Postgres Row Level Security is added on top as a second line of defense
|
||||
* (Policy `tenant_isolation` je Tenant-Tabelle, gesetzt in den Migrationen).
|
||||
*
|
||||
* ── F-04: RLS scharfschalten (env-gesteuert) ─────────────────────────────────
|
||||
* Standardmäßig (`RLS_ENFORCED` != "true") bleibt alles wie bisher: die App
|
||||
* verbindet sich als Owner-Rolle (`DATABASE_URL`), RLS greift für den Owner
|
||||
* ohne FORCE nicht, die alleinige Isolation ist der Tenant-Guard unten.
|
||||
*
|
||||
* Ist `RLS_ENFORCED=true`, verbindet sich die App zusätzlich als eingeschränkte
|
||||
* Rolle `isms_app` über `RLS_DATABASE_URL` (NOBYPASSRLS). Für diese Rolle sind
|
||||
* die Policies unter `FORCE ROW LEVEL SECURITY` scharf. `dbForTenant` setzt dann
|
||||
* pro Operation den Mandantenkontext (`app.tenant_id`) TRANSAKTIONSLOKAL und
|
||||
* führt die Operation auf DERSELBEN Transaktions-Connection aus — nur so greift
|
||||
* der GUC-Kontext trotz Connection-Pooling. Der Tenant-Guard (F-02) bleibt als
|
||||
* Defense-in-Depth zusätzlich aktiv.
|
||||
*
|
||||
* Migrationen, Seeds und der mandantenübergreifende Login-Lookup laufen weiter
|
||||
* über die Owner-`prisma`-Instanz (`DATABASE_URL`) — die Owner-Rolle muss in
|
||||
* Prod BYPASSRLS/Superuser sein, sonst sieht der Login keine Nutzer.
|
||||
*/
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma?: PrismaClient;
|
||||
appBase?: PrismaClient;
|
||||
};
|
||||
|
||||
/** Owner-Client (DATABASE_URL) — Migrationen, Seed, Login-Lookup, Plattform-Admin. */
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
|
||||
/**
|
||||
* F-04: Ist die scharfe RLS aktiv? Zentral aus dem Env-Flag gelesen.
|
||||
* Default (Flag fehlt/!= "true") = AUS → heutiges Verhalten unverändert.
|
||||
*/
|
||||
export const RLS_ENFORCED = process.env.RLS_ENFORCED === "true";
|
||||
|
||||
/**
|
||||
* Basis-Client der eingeschränkten App-Rolle `isms_app` (nur bei aktivem Flag).
|
||||
* Uneextendiert — wird in `dbForTenant` gezielt für die transaktionslokale
|
||||
* Kontextsetzung genutzt, damit die Guard-Extension nicht rekursiv greift.
|
||||
*
|
||||
* Fail secure: `RLS_ENFORCED=true` ohne `RLS_DATABASE_URL` → harter Startabbruch,
|
||||
* damit die App nicht versehentlich doch als Owner (ohne RLS) läuft.
|
||||
*/
|
||||
let appBase: PrismaClient | undefined;
|
||||
if (RLS_ENFORCED) {
|
||||
const rlsUrl = process.env.RLS_DATABASE_URL;
|
||||
if (!rlsUrl) {
|
||||
throw new Error(
|
||||
"RLS_ENFORCED=true, aber RLS_DATABASE_URL fehlt. Setze eine Verbindung als " +
|
||||
"Rolle isms_app (LOGIN, NOBYPASSRLS), z. B. " +
|
||||
"postgresql://isms_app:<pw>@<host>:5432/isms?schema=public — sonst würde die " +
|
||||
"App als Owner ohne scharfe RLS laufen (fail secure).",
|
||||
);
|
||||
}
|
||||
appBase =
|
||||
globalForPrisma.appBase ??
|
||||
new PrismaClient({
|
||||
adapter: new PrismaPg({ connectionString: rlsUrl }),
|
||||
});
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.appBase = appBase;
|
||||
}
|
||||
|
||||
/** Models that carry a tenantId column and must never be queried without one. */
|
||||
const TENANT_MODELS = new Set<string>([
|
||||
"User",
|
||||
"Role",
|
||||
"AuditLog",
|
||||
// SEC1: MailLog trägt wie AuditLog eine nullable tenantId — Plattform-Zeilen
|
||||
// (scope=platform) werden bewusst über den rohen `prisma`-Client geschrieben,
|
||||
// der Guard deckt den Mandantenpfad ab.
|
||||
"MailLog",
|
||||
"NotificationPreference",
|
||||
// SEC2: ebenfalls nullable tenantId (Plattform-Admin-Tokens). Der Lookup beim
|
||||
// Einlösen läuft ohne Session über den rohen Client — der Eintrag hier schützt
|
||||
// davor, dass ein Token versehentlich ohne Mandantenfilter über dbForTenant
|
||||
// gelesen wird.
|
||||
"AuthToken",
|
||||
"Task",
|
||||
"TaskComment",
|
||||
"TaskParticipant",
|
||||
"Evidence",
|
||||
"Audit",
|
||||
"AuditEvidenceItem",
|
||||
"ControlDescription",
|
||||
"ManagedRegister",
|
||||
"RegisterRow",
|
||||
"OnboardingProgress",
|
||||
"ProjectFunctionAssignment",
|
||||
"WizardFact",
|
||||
"WizardScope",
|
||||
"PolicyPackageState",
|
||||
"TenantSettings",
|
||||
"TenantModule",
|
||||
"Asset",
|
||||
"AssetRelation",
|
||||
"Process",
|
||||
"ProcessAsset",
|
||||
"ProcessDependency",
|
||||
"BiaEntry",
|
||||
"Risk",
|
||||
"RiskAsset",
|
||||
"Measure",
|
||||
"RiskMeasure",
|
||||
"SupplierProfile",
|
||||
"ITServiceProfile",
|
||||
"SoftwareProfile",
|
||||
"ProjectProfile",
|
||||
"SupplierAssessment",
|
||||
"Contract",
|
||||
"Nda",
|
||||
"SupplierEvidence",
|
||||
"ServiceControlResponsibility",
|
||||
"Subcontractor",
|
||||
"ManagementDecision",
|
||||
"MaturityAssessment",
|
||||
"ControlAssessment",
|
||||
"ControlImplementation",
|
||||
// Incident-Management (IM-A)
|
||||
"Incident",
|
||||
"IncidentComment",
|
||||
"IncidentAsset",
|
||||
"IncidentProcess",
|
||||
"IncidentRisk",
|
||||
"IncidentControl",
|
||||
"IncidentMeasure",
|
||||
"IncidentEvidence",
|
||||
"IncidentAttachment",
|
||||
// IM-D — mandantengebundene Intake-Konfiguration (RLS). Die Review-Queue
|
||||
// (IncidentInboundReview) ist bewusst plattformweit → NICHT hier.
|
||||
"IncidentIntakeConfig",
|
||||
// WS4b: WebAuthnCredential ist jetzt identitäts-global (kein tenant_id) → NICHT hier.
|
||||
"PolicyDocument",
|
||||
"PolicyRequirement",
|
||||
"PolicyVariable",
|
||||
// AP1: Framework-Zugehörigkeit je Mandant (RLS-Policy in der Migration).
|
||||
"TenantFramework",
|
||||
// AP3: Anwendbarkeitserklärung (SoA) je Mandant/Framework (RLS-Policy in der Migration).
|
||||
"SoaEntry",
|
||||
// AP4: Managementklauseln (Kennzahlen 9.1, Managementbewertung 9.3, CAPA 10.2).
|
||||
"Kpi",
|
||||
"KpiValue",
|
||||
"ManagementReview",
|
||||
"ManagementReviewDecision",
|
||||
"Nonconformity",
|
||||
"CorrectiveAction",
|
||||
// AP5: Dokumentenlenkung (Lesebestätigung + Versionshistorie).
|
||||
"PolicyAcknowledgement",
|
||||
"PolicyDocumentVersion",
|
||||
"PolicyBaselineParam",
|
||||
"PolicyEvidence",
|
||||
"CryptoEntry",
|
||||
"ClassificationClass",
|
||||
"HandlingAspect",
|
||||
"HandlingRule",
|
||||
"RiskMatrixClass",
|
||||
"RiskEwLevel",
|
||||
"RiskDamageDimension",
|
||||
"HandbookTopic",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Meldet eine Mandanten-Isolationsverletzung als Sicherheitsereignis (F-16).
|
||||
*
|
||||
* Ein Cross-Tenant-Zugriffsversuch ist das schwerwiegendste denkbare Signal in
|
||||
* diesem Produkt und muss als strukturierter Audit-Eintrag (`action: "denied"`)
|
||||
* erfasst werden — nicht nur als anonymer Stacktrace im Container-Log.
|
||||
*
|
||||
* Import-Zyklus (F-16): `audit.ts` importiert statisch `prisma` aus DIESER Datei.
|
||||
* Ein statischer Gegenimport (`import { writeAuditLog } from "./audit"`) erzeugte
|
||||
* den Zyklus `db.ts → audit.ts → db.ts`. Wir lösen ihn per **Lazy-Import**
|
||||
* (`await import("./audit")`) genau an der Aufrufstelle: Zum Zeitpunkt des
|
||||
* Aufrufs (Laufzeit, nicht Modulauswertung) ist das `prisma`-Binding in `audit.ts`
|
||||
* längst aufgelöst. Das `prisma`-Modul selbst muss dafür nicht ausgelagert werden,
|
||||
* sodass KEIN bestehender `from "@/server/db"`-Import bricht.
|
||||
*
|
||||
* Best effort & fail-safe: Das Audit-Schreiben nutzt den ROHEN Owner-`prisma`
|
||||
* (NICHT `dbForTenant`) → keine Rekursion in die Guard-Extension. Fehler des
|
||||
* Audit-Schreibens werden hier geschluckt (nur geloggt) — die eigentliche
|
||||
* Isolations-Exception wirft der Aufrufer UNABHÄNGIG davon weiter.
|
||||
*
|
||||
* Achtung Kontext: Die Funktion läuft mitten in der laufenden DB-Operation
|
||||
* (im RLS-Pfad ggf. innerhalb der F-04-Transaktion des `isms_app`-Basisclients).
|
||||
* Der Audit-Insert läuft über den separaten Owner-`prisma` auf EIGENER Connection
|
||||
* und ist damit von der Tenant-Transaktion entkoppelt: Er verklemmt sie nicht und
|
||||
* bleibt bestehen, auch wenn die Tenant-Operation durch den anschließenden Throw
|
||||
* zurückgerollt wird — genau das gewünschte Verhalten für ein Sicherheitsereignis.
|
||||
*/
|
||||
async function reportIsolationViolation(details: {
|
||||
model: string;
|
||||
operation: string;
|
||||
expectedTenantId: string;
|
||||
reason: string;
|
||||
}) {
|
||||
console.error("[SECURITY] tenant-isolation-violation", JSON.stringify(details));
|
||||
try {
|
||||
// Lazy-Import bricht den statischen Zyklus (siehe Doku oben).
|
||||
const { writeAuditLog } = await import("./audit");
|
||||
await writeAuditLog({
|
||||
// Der Versuch wird dem anfragenden (eigenen) Mandanten zugeschrieben —
|
||||
// ein actorId ist im Guard nicht verfügbar, wird also nicht erfunden.
|
||||
tenantId: details.expectedTenantId,
|
||||
action: "denied",
|
||||
entity: "tenant_isolation",
|
||||
entityId: details.model,
|
||||
after: {
|
||||
model: details.model,
|
||||
operation: details.operation,
|
||||
reason: details.reason,
|
||||
},
|
||||
});
|
||||
} catch (auditErr) {
|
||||
// Niemals die eigentliche Isolations-Exception verschlucken: hier nur loggen.
|
||||
console.error("[SECURITY] audit-write-failed (tenant-isolation-violation)", auditErr);
|
||||
}
|
||||
}
|
||||
|
||||
/** Delegate-Form, wie sie der Guard für Direktaufrufe (ohne Rekursion) braucht. */
|
||||
type GuardDelegate = {
|
||||
findUnique: (a: unknown) => Promise<{ tenantId?: string } | null>;
|
||||
findFirst: (a: unknown) => Promise<unknown>;
|
||||
findFirstOrThrow: (a: unknown) => Promise<unknown>;
|
||||
};
|
||||
|
||||
/** Roher Client bzw. Transaktions-Client als Quelle uneextendierter Delegates. */
|
||||
type DelegateSource = Record<string, GuardDelegate>;
|
||||
|
||||
/**
|
||||
* F-02-Tenant-Guard, herausgelöst, damit er über zwei Ausführungspfade
|
||||
* (Owner ohne RLS / isms_app mit RLS-Transaktion) identisch wiederverwendet wird.
|
||||
*
|
||||
* `source` liefert die uneextendierten Modell-Delegates für die Direktaufrufe
|
||||
* (findUnique-Hybrid, Ownership-Vorprüfung) — bei RLS-off der Owner-`prisma`,
|
||||
* bei RLS-on der Transaktions-Client `tx`. `runFinal` führt die eigentliche
|
||||
* (ggf. transformierte) Operation aus — bei RLS-off `query(args)`, bei RLS-on
|
||||
* die Operation auf dem Transaktions-Delegate. So bleiben alle Direktaufrufe
|
||||
* garantiert auf derselben Connection wie die Kontextsetzung.
|
||||
*/
|
||||
async function applyTenantGuard(
|
||||
source: DelegateSource,
|
||||
tenantId: string,
|
||||
model: string,
|
||||
operation: string,
|
||||
args: unknown,
|
||||
runFinal: (args: unknown) => Promise<unknown>,
|
||||
): Promise<unknown> {
|
||||
const a = args as Record<string, unknown>;
|
||||
const delegate = source[model.charAt(0).toLowerCase() + model.slice(1)];
|
||||
|
||||
if (
|
||||
operation === "findMany" ||
|
||||
operation === "findFirst" ||
|
||||
operation === "findFirstOrThrow" ||
|
||||
operation === "count" ||
|
||||
operation === "aggregate" ||
|
||||
operation === "groupBy" ||
|
||||
operation === "updateMany" ||
|
||||
operation === "deleteMany"
|
||||
) {
|
||||
a.where = { AND: [{ tenantId }, (a.where as object) ?? {}] };
|
||||
} else if (operation === "create") {
|
||||
a.data = { ...(a.data as object), tenantId };
|
||||
} else if (operation === "createMany") {
|
||||
const data = a.data as Record<string, unknown>[];
|
||||
a.data = data.map((d) => ({ ...d, tenantId }));
|
||||
} else if (operation === "findUnique" || operation === "findUniqueOrThrow") {
|
||||
// F-02: `findUnique` lässt sich nicht per `AND` um `tenantId` erweitern.
|
||||
// Hybrid-Guard:
|
||||
// (a) rein skalare where-Klausel (typisch `where: { id }`) → auf
|
||||
// `findFirst`/`findFirstOrThrow` mit tenantId-Vorfilter umschreiben:
|
||||
// Fremdzugriff wird verhindert, nicht nur erkannt.
|
||||
// (b) Compound-Unique-Wrapper (`tenantId_key: {...}`, `tenantId_control`
|
||||
// …) → `findUnique` bleibt, aber die Ownership-Prüfung wird
|
||||
// fail-closed (tenantId in die Projektion injizieren, hart abbrechen,
|
||||
// wenn das Ergebnis kein tenantId trägt).
|
||||
const whereObj = (a.where as Record<string, unknown>) ?? {};
|
||||
// Ein Compound-Unique-Wrapper trägt Objektwerte; skalare Unique-Filter
|
||||
// (id, slug …) sind Primitive bzw. Date.
|
||||
const scalarOnly = Object.values(whereObj).every(
|
||||
(v) => v === null || typeof v !== "object" || v instanceof Date,
|
||||
);
|
||||
|
||||
if (scalarOnly) {
|
||||
// (a) Fremdzugriff verhindern.
|
||||
const firstArgs = { ...a, where: { AND: [{ tenantId }, whereObj] } };
|
||||
return operation === "findUniqueOrThrow"
|
||||
? delegate.findFirstOrThrow(firstArgs)
|
||||
: delegate.findFirst(firstArgs);
|
||||
}
|
||||
|
||||
// (b) Compound-Unique-Wrapper: fail-closed prüfen.
|
||||
const sel = a.select as Record<string, unknown> | undefined;
|
||||
const injected = Boolean(sel) && !("tenantId" in (sel as object));
|
||||
if (injected) a.select = { ...(sel as object), tenantId: true };
|
||||
|
||||
const result = await runFinal(args);
|
||||
if (result && typeof result === "object") {
|
||||
if (!("tenantId" in result)) {
|
||||
// Nach der Injektion darf das nicht mehr vorkommen — fail-closed
|
||||
// statt fail-open.
|
||||
await reportIsolationViolation({
|
||||
model,
|
||||
operation,
|
||||
expectedTenantId: tenantId,
|
||||
reason: "result-without-tenantId",
|
||||
});
|
||||
throw new Error(
|
||||
`Tenant isolation violation: ${model} belongs to another tenant`,
|
||||
);
|
||||
}
|
||||
if ((result as { tenantId: string }).tenantId !== tenantId) {
|
||||
await reportIsolationViolation({
|
||||
model,
|
||||
operation,
|
||||
expectedTenantId: tenantId,
|
||||
reason: "foreign-tenant",
|
||||
});
|
||||
throw new Error(
|
||||
`Tenant isolation violation: ${model} belongs to another tenant`,
|
||||
);
|
||||
}
|
||||
// Injiziertes tenantId vor der Rückgabe entfernen, damit sich der
|
||||
// Rückgabetyp für die Aufrufer nicht ändert.
|
||||
if (injected) delete (result as Record<string, unknown>).tenantId;
|
||||
}
|
||||
return result;
|
||||
} else if (
|
||||
operation === "update" ||
|
||||
operation === "delete" ||
|
||||
operation === "upsert"
|
||||
) {
|
||||
// Mutations on unique keys: verify ownership BEFORE mutating.
|
||||
const existing = await delegate.findUnique({ where: a.where });
|
||||
if (existing && existing.tenantId !== tenantId) {
|
||||
await reportIsolationViolation({
|
||||
model,
|
||||
operation,
|
||||
expectedTenantId: tenantId,
|
||||
reason: "foreign-tenant",
|
||||
});
|
||||
throw new Error(
|
||||
`Tenant isolation violation: ${model} belongs to another tenant`,
|
||||
);
|
||||
}
|
||||
if (operation === "upsert") {
|
||||
a.create = { ...(a.create as object), tenantId };
|
||||
}
|
||||
}
|
||||
|
||||
return runFinal(args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Prisma client that transparently enforces the tenant scope:
|
||||
* reads are filtered by tenantId, creates get the tenantId injected.
|
||||
*
|
||||
* RLS-off (Default): Owner-`prisma` + Guard-Extension, `query(args)` als Ausführung.
|
||||
* RLS-on (`RLS_ENFORCED=true`): `isms_app`-Basisclient; jede Operation eines
|
||||
* Tenant-Modells läuft in einer eigenen Transaktion des Basisclients, in der
|
||||
* `app.tenant_id` transaktionslokal gesetzt wird (verworfen bei Commit/Rollback
|
||||
* → kein Leak über den Pool). Die Guard-Logik ist in beiden Pfaden identisch.
|
||||
*/
|
||||
export function dbForTenant(tenantId: string) {
|
||||
if (!tenantId) throw new Error("dbForTenant: tenantId is required");
|
||||
|
||||
const base = RLS_ENFORCED ? appBase! : prisma;
|
||||
|
||||
return base.$extends({
|
||||
query: {
|
||||
$allModels: {
|
||||
async $allOperations({ model, operation, args, query }) {
|
||||
// Globale Kataloge: kein Mandantenkontext, keine Transaktion.
|
||||
if (!TENANT_MODELS.has(model)) return query(args);
|
||||
|
||||
if (!RLS_ENFORCED) {
|
||||
// Owner-Pfad: Guard transformiert, Ausführung via `query`.
|
||||
return applyTenantGuard(
|
||||
prisma as unknown as DelegateSource,
|
||||
tenantId,
|
||||
model,
|
||||
operation,
|
||||
args,
|
||||
(a) => query(a as typeof args),
|
||||
);
|
||||
}
|
||||
|
||||
// RLS-Pfad: alles in EINER Transaktion des Basisclients, damit
|
||||
// Kontextsetzung und Ausführung garantiert auf derselben Connection
|
||||
// liegen. `tx` ist uneextendiert → keine Rekursion in die Extension.
|
||||
return appBase!.$transaction(async (tx) => {
|
||||
// transaktionslokal (dritter Parameter true): gilt nur in dieser Tx,
|
||||
// wird beim Commit/Rollback verworfen. tenantId kommt gebunden als
|
||||
// Parameter (CUID aus der Session) → keine SQL-Injektion.
|
||||
await tx.$executeRaw`SELECT set_config('app.tenant_id', ${tenantId}, true)`;
|
||||
const delegate = (tx as unknown as Record<
|
||||
string,
|
||||
Record<string, (a: unknown) => Promise<unknown>>
|
||||
>)[model.charAt(0).toLowerCase() + model.slice(1)];
|
||||
return applyTenantGuard(
|
||||
tx as unknown as DelegateSource,
|
||||
tenantId,
|
||||
model,
|
||||
operation,
|
||||
args,
|
||||
(a) => delegate[operation](a),
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type TenantDb = ReturnType<typeof dbForTenant>;
|
||||
@@ -0,0 +1,284 @@
|
||||
import type { TenantDb } from "./db";
|
||||
|
||||
/**
|
||||
* Ableitung des Abhängigkeitsgraphen (SPEC §4.11) aus Process,
|
||||
* ProcessAsset(role), Asset und AssetRelation. Keine redundante
|
||||
* Datenhaltung — der Graph wird bei jedem Aufruf frisch berechnet.
|
||||
*
|
||||
* Kritikalität je Knoten:
|
||||
* - Prozess: BIA-Kritikalität (1–4), sonst 1
|
||||
* - Asset: max(C, I, A)
|
||||
* Kritische Kante: beide Endknoten-Kritikalität ≥ Schwellwert.
|
||||
* Kritischer Pfad: von kritischen Prozessen entlang kritischer Kanten (DFS).
|
||||
* SPOF: Asset, von dem ≥ spofMin kritische Prozesse (transitiv) abhängen.
|
||||
*/
|
||||
|
||||
export type GraphNodeKind =
|
||||
| "process"
|
||||
| "INFORMATION"
|
||||
| "SYSTEM"
|
||||
| "APPLICATION"
|
||||
| "LOCATION"
|
||||
| "SUPPLIER"
|
||||
| "IT_SERVICE"
|
||||
| "SOFTWARE"
|
||||
| "PROJECT"
|
||||
| "PERSON"
|
||||
| "DATA";
|
||||
|
||||
export type GraphNode = {
|
||||
id: string;
|
||||
entity: "process" | "asset";
|
||||
kind: GraphNodeKind;
|
||||
name: string;
|
||||
sub: string | null;
|
||||
criticality: number;
|
||||
critical: boolean;
|
||||
spof: boolean;
|
||||
dependentCriticalProcesses: number;
|
||||
};
|
||||
|
||||
export type GraphEdge = {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
label: string;
|
||||
critical: boolean;
|
||||
};
|
||||
|
||||
export type DependencyGraph = {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
threshold: number;
|
||||
analysis: {
|
||||
spofs: { id: string; name: string; count: number }[];
|
||||
criticalProcessCount: number;
|
||||
criticalEdgeCount: number;
|
||||
longestCriticalPath: string[]; // Knoten-Namen
|
||||
};
|
||||
};
|
||||
|
||||
export async function buildDependencyGraph(
|
||||
db: TenantDb,
|
||||
opts: { threshold?: number; spofMin?: number } = {}
|
||||
): Promise<DependencyGraph> {
|
||||
const threshold = opts.threshold ?? 3;
|
||||
const spofMin = opts.spofMin ?? 2;
|
||||
|
||||
const [processes, assets, relations, procDeps] = await Promise.all([
|
||||
db.process.findMany({
|
||||
include: {
|
||||
bia: { select: { criticality: true } },
|
||||
processAssets: { select: { assetId: true, role: true } },
|
||||
},
|
||||
}),
|
||||
db.asset.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
confidentiality: true,
|
||||
integrity: true,
|
||||
availability: true,
|
||||
},
|
||||
}),
|
||||
db.assetRelation.findMany({ select: { assetId: true, relatedAssetId: true, type: true } }),
|
||||
db.processDependency.findMany({
|
||||
select: { sourceProcessId: true, targetProcessId: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const pid = (id: string) => `p:${id}`;
|
||||
const aid = (id: string) => `a:${id}`;
|
||||
|
||||
const assetCrit = new Map<string, number>();
|
||||
for (const a of assets) {
|
||||
assetCrit.set(a.id, Math.max(a.confidentiality, a.integrity, a.availability));
|
||||
}
|
||||
|
||||
const nodes: GraphNode[] = [];
|
||||
const critOf = new Map<string, number>();
|
||||
|
||||
for (const p of processes) {
|
||||
const c = p.bia?.criticality ?? 1;
|
||||
critOf.set(pid(p.id), c);
|
||||
nodes.push({
|
||||
id: pid(p.id),
|
||||
entity: "process",
|
||||
kind: "process",
|
||||
name: p.name,
|
||||
sub: null,
|
||||
criticality: c,
|
||||
critical: c >= threshold,
|
||||
spof: false,
|
||||
dependentCriticalProcesses: 0,
|
||||
});
|
||||
}
|
||||
for (const a of assets) {
|
||||
const c = assetCrit.get(a.id) ?? 1;
|
||||
critOf.set(aid(a.id), c);
|
||||
nodes.push({
|
||||
id: aid(a.id),
|
||||
entity: "asset",
|
||||
kind: a.type,
|
||||
name: a.name,
|
||||
sub: null,
|
||||
criticality: c,
|
||||
critical: c >= threshold,
|
||||
spof: false,
|
||||
dependentCriticalProcesses: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Zwei Sichten:
|
||||
// - adjacency (Analyse): Abhängigkeitsrichtung (Abhängiger → Abhängigkeit),
|
||||
// Basis für kritischen Pfad, SPOF, transitive Abhängigkeiten.
|
||||
// - edges (Anzeige): links = unterstützende Abhängigkeit, Mitte = Prozess,
|
||||
// rechts = erzeugtes primäres Asset. Pfeile fließen sauber von links nach
|
||||
// rechts, daher zeigen unterstützende Kanten VON der Abhängigkeit zum
|
||||
// Nutzer/Prozess.
|
||||
const edges: GraphEdge[] = [];
|
||||
const adjacency = new Map<string, string[]>();
|
||||
const addAdj = (s: string, t: string) => {
|
||||
if (!adjacency.has(s)) adjacency.set(s, []);
|
||||
adjacency.get(s)!.push(t);
|
||||
};
|
||||
const isCritEdge = (s: string, t: string) =>
|
||||
(critOf.get(s) ?? 1) >= threshold && (critOf.get(t) ?? 1) >= threshold;
|
||||
|
||||
for (const p of processes) {
|
||||
for (const pa of p.processAssets) {
|
||||
const proc = pid(p.id);
|
||||
const asset = aid(pa.assetId);
|
||||
// Analyse: Prozess ist auf alle zugeordneten Assets angewiesen (SPOF/Pfad)
|
||||
addAdj(proc, asset);
|
||||
if (pa.role === "PRIMARY") {
|
||||
// erzeugtes Asset rechts vom Prozess
|
||||
edges.push({
|
||||
id: `${proc}->${asset}`,
|
||||
source: proc,
|
||||
target: asset,
|
||||
label: "erzeugt",
|
||||
critical: isCritEdge(proc, asset),
|
||||
});
|
||||
} else {
|
||||
// unterstützendes Asset links vom Prozess (Kante zeigt zum Prozess)
|
||||
edges.push({
|
||||
id: `${asset}->${proc}`,
|
||||
source: asset,
|
||||
target: proc,
|
||||
label: "unterstützt",
|
||||
critical: isCritEdge(asset, proc),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const assetIds = new Set(assets.map((a) => a.id));
|
||||
for (const r of relations) {
|
||||
if (!assetIds.has(r.assetId) || !assetIds.has(r.relatedAssetId)) continue;
|
||||
// Analyse: asset hängt von relatedAsset ab
|
||||
addAdj(aid(r.assetId), aid(r.relatedAssetId));
|
||||
// Anzeige: Abhängigkeit (relatedAsset) links, Nutzer (asset) rechts
|
||||
const s = aid(r.relatedAssetId);
|
||||
const t = aid(r.assetId);
|
||||
edges.push({
|
||||
id: `${s}->${t}`,
|
||||
source: s,
|
||||
target: t,
|
||||
label: "unterstützt",
|
||||
critical: isCritEdge(s, t),
|
||||
});
|
||||
}
|
||||
|
||||
// Strukturierte Prozess-zu-Prozess-Abhängigkeiten (ProcessDependency): `source`
|
||||
// benötigt `target`. Analyse: source hängt von target ab. Anzeige: Abhängigkeit
|
||||
// (target) links → Nutzer (source) rechts, konsistent zur Flussrichtung der
|
||||
// übrigen Kanten (Pfeil zeigt zum Nutzer, „wird benötigt von").
|
||||
const procNodeIds = new Set(processes.map((p) => p.id));
|
||||
for (const d of procDeps) {
|
||||
if (!procNodeIds.has(d.sourceProcessId) || !procNodeIds.has(d.targetProcessId)) continue;
|
||||
addAdj(pid(d.sourceProcessId), pid(d.targetProcessId));
|
||||
const s = pid(d.targetProcessId);
|
||||
const t = pid(d.sourceProcessId);
|
||||
edges.push({
|
||||
id: `${s}->${t}`,
|
||||
source: s,
|
||||
target: t,
|
||||
label: "benötigt von",
|
||||
critical: isCritEdge(s, t),
|
||||
});
|
||||
}
|
||||
|
||||
// Transitive Abhängigkeiten je kritischem Prozess → SPOF-Zählung je Asset
|
||||
const criticalProcesses = nodes.filter((n) => n.entity === "process" && n.critical);
|
||||
const dependents = new Map<string, Set<string>>(); // assetNodeId → Set kritischer Prozess-IDs
|
||||
for (const cp of criticalProcesses) {
|
||||
const seen = new Set<string>();
|
||||
const stack = [...(adjacency.get(cp.id) ?? [])];
|
||||
while (stack.length) {
|
||||
const cur = stack.pop()!;
|
||||
if (seen.has(cur)) continue;
|
||||
seen.add(cur);
|
||||
if (!dependents.has(cur)) dependents.set(cur, new Set());
|
||||
dependents.get(cur)!.add(cp.id);
|
||||
for (const next of adjacency.get(cur) ?? []) stack.push(next);
|
||||
}
|
||||
}
|
||||
|
||||
// SPOF-Zählung über Assets UND (seit den strukturierten Prozess-Abhängigkeiten)
|
||||
// Prozesse: ein gemeinsam benötigter Prozess wie „IT-Betrieb", von dem mehrere
|
||||
// kritische Prozesse abhängen, ist ebenfalls ein Single Point of Failure. Ein
|
||||
// Prozess zählt nicht sich selbst (adjacency startet bei den direkten Abhängigkeiten).
|
||||
const spofs: { id: string; name: string; count: number }[] = [];
|
||||
for (const n of nodes) {
|
||||
const count = dependents.get(n.id)?.size ?? 0;
|
||||
n.dependentCriticalProcesses = count;
|
||||
if (count >= spofMin) {
|
||||
n.spof = true;
|
||||
spofs.push({ id: n.id, name: n.name, count });
|
||||
}
|
||||
}
|
||||
spofs.sort((a, b) => b.count - a.count);
|
||||
|
||||
// Längster kritischer Pfad (Abhängigkeitskette): DFS über die Analyse-Adjazenz,
|
||||
// beschränkt auf kritische Knoten, ab kritischen Prozessen.
|
||||
const critAdj = new Map<string, string[]>();
|
||||
for (const [s, targets] of adjacency) {
|
||||
if ((critOf.get(s) ?? 1) < threshold) continue;
|
||||
const critTargets = targets.filter((t) => (critOf.get(t) ?? 1) >= threshold);
|
||||
if (critTargets.length) critAdj.set(s, critTargets);
|
||||
}
|
||||
const nameOf = new Map(nodes.map((n) => [n.id, n.name]));
|
||||
let longest: string[] = [];
|
||||
const dfs = (node: string, path: string[], visited: Set<string>) => {
|
||||
const next = critAdj.get(node) ?? [];
|
||||
if (next.length === 0) {
|
||||
if (path.length > longest.length) longest = [...path];
|
||||
return;
|
||||
}
|
||||
let extended = false;
|
||||
for (const nx of next) {
|
||||
if (visited.has(nx)) continue;
|
||||
extended = true;
|
||||
visited.add(nx);
|
||||
dfs(nx, [...path, nx], visited);
|
||||
visited.delete(nx);
|
||||
}
|
||||
if (!extended && path.length > longest.length) longest = [...path];
|
||||
};
|
||||
for (const cp of criticalProcesses) {
|
||||
dfs(cp.id, [cp.id], new Set([cp.id]));
|
||||
}
|
||||
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
threshold,
|
||||
analysis: {
|
||||
spofs,
|
||||
criticalProcessCount: criticalProcesses.length,
|
||||
criticalEdgeCount: edges.filter((e) => e.critical).length,
|
||||
longestCriticalPath: longest.map((id) => nameOf.get(id) ?? id),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// ── DSGVO-Löschung (Art. 17) — Anonymisieren vs. Hard-Delete ─────────────────
|
||||
//
|
||||
// Zwei Scopes (KONZEPT §6):
|
||||
// • Person (Betroffener) in EINEM Mandanten: PII der Mitgliedschaft wird
|
||||
// ANONYMISIERT (Name/E-Mail → Tombstone), die referenzielle Struktur
|
||||
// (ownerId/actorId/… über die anonymisierte User-Zeile) bleibt erhalten —
|
||||
// Nachweis-/Aufbewahrungspflicht (Audit-Trail/Freigaben). Ein `TombstoneEntry`
|
||||
// stellt sicher, dass ein späterer Restore die PII NICHT zurückbringt.
|
||||
// Globale Identity ohne verbleibende Mitgliedschaft → global anonymisiert.
|
||||
// • Mandant (Offboarding): alle tenant_id-Zeilen hart gelöscht (child→parent,
|
||||
// eine Owner-Transaktion) + MinIO-Prefix; Identities ohne Rest-Mitgliedschaft
|
||||
// werden global gelöscht. Löschnachweis (DeletionCertificate) in beiden Fällen.
|
||||
//
|
||||
// Alles über den Owner-`prisma`-Client, streng tenant-/personen-gescopt.
|
||||
|
||||
import { prisma } from "../db";
|
||||
import { writeAuditLog } from "../audit";
|
||||
import { buildTenantTopology } from "../backup/topology";
|
||||
import { exportTenant } from "../backup/export";
|
||||
import { getBackupStore } from "../storage/backup-store";
|
||||
|
||||
const STUB_PASSWORD_SENTINEL = "!stub-no-login!";
|
||||
|
||||
function tombstoneEmail(id: string): string {
|
||||
return `deleted+${id}@tombstone.local`;
|
||||
}
|
||||
const TOMBSTONE_NAME = "Gelöschte Person";
|
||||
|
||||
export interface DeleteSubjectResult {
|
||||
identityId: string;
|
||||
anonymizedMemberships: number;
|
||||
identityAnonymized: boolean;
|
||||
certificateId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Personen-Löschung innerhalb eines Mandanten: Mitgliedschafts-PII anonymisieren,
|
||||
* Tombstone setzen (restore-fest), ggf. globale Identity anonymisieren, Nachweis.
|
||||
*/
|
||||
export async function deleteSubject(
|
||||
tenantId: string,
|
||||
identityId: string,
|
||||
opts: { actorId?: string; reason?: string } = {},
|
||||
): Promise<DeleteSubjectResult> {
|
||||
const memberships = await prisma.user.findMany({ where: { tenantId, identityId } });
|
||||
if (!memberships.length) {
|
||||
throw new Error(`deleteSubject: keine Mitgliedschaft von ${identityId} in Mandant ${tenantId}.`);
|
||||
}
|
||||
|
||||
const subjectEmail = memberships[0]?.email ?? null;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const m of memberships) {
|
||||
const anonFields = { email: tombstoneEmail(m.id), name: TOMBSTONE_NAME };
|
||||
// Live-Daten anonymisieren.
|
||||
await tx.user.update({ where: { id: m.id }, data: anonFields });
|
||||
// Restore-fester Tombstone: bei Rückkehr der alten Zeile erneut anonymisieren.
|
||||
await tx.tombstoneEntry.upsert({
|
||||
where: { tenantId_model_targetValue: { tenantId, model: "User", targetValue: m.id } },
|
||||
create: {
|
||||
tenantId,
|
||||
model: "User",
|
||||
targetField: "id",
|
||||
targetValue: m.id,
|
||||
action: "anonymize",
|
||||
anonymizedFields: anonFields,
|
||||
reason: opts.reason ?? "dsgvo-subject-deletion",
|
||||
},
|
||||
update: { anonymizedFields: anonFields },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Globale Identity: controller-scoped (KONZEPT §6/§8). Die anonymisierte
|
||||
// Mitgliedschaft in DIESEM Mandanten bleibt (referenzielle Struktur/Nachweis);
|
||||
// die globale Identity wird nur anonymisiert, wenn die Person in KEINEM ANDEREN
|
||||
// Mandanten noch Mitglied ist — sonst bliebe deren Auth-Zugang dort bestehen.
|
||||
const remaining = await prisma.user.count({
|
||||
where: { identityId, tenantId: { not: tenantId } },
|
||||
});
|
||||
let identityAnonymized = false;
|
||||
if (remaining === 0) {
|
||||
await prisma.identity.update({
|
||||
where: { id: identityId },
|
||||
data: {
|
||||
// Identity trägt keinen Namen (nur User); hier E-Mail tilgen + Secrets entfernen.
|
||||
email: tombstoneEmail(identityId),
|
||||
passwordHash: STUB_PASSWORD_SENTINEL,
|
||||
mfaSecret: null,
|
||||
mfaEnrolledAt: null,
|
||||
recoveryCodes: [],
|
||||
status: "DISABLED",
|
||||
mustChangePassword: true,
|
||||
},
|
||||
});
|
||||
identityAnonymized = true;
|
||||
}
|
||||
|
||||
const cert = await prisma.deletionCertificate.create({
|
||||
data: {
|
||||
scope: "person",
|
||||
tenantId,
|
||||
subjectIdentityId: identityId,
|
||||
subjectEmail,
|
||||
actorId: opts.actorId,
|
||||
anonymizedCounts: { User: memberships.length, ...(identityAnonymized ? { Identity: 1 } : {}) },
|
||||
reason: opts.reason,
|
||||
},
|
||||
});
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId,
|
||||
actorId: opts.actorId,
|
||||
action: "delete",
|
||||
entity: "dsgvo_subject",
|
||||
entityId: identityId,
|
||||
after: { anonymizedMemberships: memberships.length, identityAnonymized, certificateId: cert.id },
|
||||
});
|
||||
|
||||
return {
|
||||
identityId,
|
||||
anonymizedMemberships: memberships.length,
|
||||
identityAnonymized,
|
||||
certificateId: cert.id,
|
||||
};
|
||||
}
|
||||
|
||||
export interface OffboardResult {
|
||||
tenantId: string;
|
||||
deletedRows: number;
|
||||
deletedIdentities: number;
|
||||
portabilitySnapshotId: string | null;
|
||||
certificateId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mandanten-Offboarding: Portabilitäts-Snapshot (optional), Hard-Delete aller
|
||||
* tenant_id-Zeilen (child→parent, eine Owner-Transaktion), MinIO-Prefix,
|
||||
* Identity-Aufräumung, Löschnachweis. Der Tenant-Stammsatz wird auf ARCHIVED
|
||||
* gesetzt (nicht hart entfernt), damit Nachweis/Audit referenzierbar bleiben.
|
||||
*/
|
||||
export async function offboardTenant(
|
||||
tenantId: string,
|
||||
opts: { actorId?: string; reason?: string; portabilitySnapshot?: boolean; purgeFiles?: boolean } = {},
|
||||
): Promise<OffboardResult> {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { id: true, slug: true },
|
||||
});
|
||||
if (!tenant) throw new Error(`offboardTenant: Mandant ${tenantId} existiert nicht.`);
|
||||
|
||||
// Optionaler Portabilitäts-/Offboarding-Snapshot vor der Löschung.
|
||||
let portabilitySnapshotId: string | null = null;
|
||||
if (opts.portabilitySnapshot) {
|
||||
const snap = await exportTenant(tenantId, { reason: "offboarding", persist: true, includeFiles: true });
|
||||
portabilitySnapshotId = snap.snapshotId;
|
||||
}
|
||||
|
||||
// Identities der Mitglieder VOR der Löschung merken (für Aufräumung).
|
||||
const memberIdentityIds = (
|
||||
await prisma.user.findMany({ where: { tenantId }, select: { identityId: true } })
|
||||
).map((u) => u.identityId);
|
||||
|
||||
const topo = buildTenantTopology();
|
||||
let deletedRows = 0;
|
||||
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await tx.$executeRawUnsafe(`SET LOCAL session_replication_role = replica`);
|
||||
for (const node of topo.deleteOrder) {
|
||||
const del = (tx as unknown as Record<string, {
|
||||
deleteMany: (a: unknown) => Promise<{ count: number }>;
|
||||
findMany: (a: unknown) => Promise<Record<string, unknown>[]>;
|
||||
}>)[node.delegate];
|
||||
if (node.scope.by === "tenantColumn") {
|
||||
const r = await del.deleteMany({ where: { tenantId } });
|
||||
deletedRows += r.count;
|
||||
} else {
|
||||
const via = node.scope.via;
|
||||
const parentNode = topo.nodes.get(via.parent)!;
|
||||
const parentPk = parentNode.pk[0];
|
||||
const parentDelegate = (tx as unknown as Record<string, {
|
||||
findMany: (a: unknown) => Promise<Record<string, unknown>[]>;
|
||||
}>)[parentNode.delegate];
|
||||
const parents = await parentDelegate.findMany({ where: { tenantId }, select: { [parentPk]: true } });
|
||||
const ids = parents.map((p) => p[parentPk]).filter((v) => typeof v === "string");
|
||||
if (ids.length) {
|
||||
const r = await del.deleteMany({ where: { [via.fromFields[0]]: { in: ids } } });
|
||||
deletedRows += r.count;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ timeout: 300_000 },
|
||||
);
|
||||
|
||||
// Globale Identity-Aufräumung: Identities ohne verbleibende Mitgliedschaft löschen.
|
||||
let deletedIdentities = 0;
|
||||
for (const idn of new Set(memberIdentityIds)) {
|
||||
const remaining = await prisma.user.count({ where: { identityId: idn } });
|
||||
if (remaining === 0) {
|
||||
await prisma.identity.delete({ where: { id: idn } }).then(
|
||||
() => deletedIdentities++,
|
||||
() => {
|
||||
/* bereits weg / referenziert → überspringen */
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// MinIO-Prefix des Mandanten (Uploads) entfernen.
|
||||
if (opts.purgeFiles !== false) {
|
||||
try {
|
||||
await (await getBackupStore()).remove(`${tenantId}/uploads/`);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
|
||||
// Tenant-Stammsatz archivieren (nicht hart entfernen).
|
||||
await prisma.tenant.update({ where: { id: tenantId }, data: { status: "ARCHIVED" } });
|
||||
|
||||
const cert = await prisma.deletionCertificate.create({
|
||||
data: {
|
||||
scope: "tenant",
|
||||
tenantId,
|
||||
tenantSlug: tenant.slug,
|
||||
actorId: opts.actorId,
|
||||
deletedCounts: { rows: deletedRows, identities: deletedIdentities },
|
||||
snapshotId: portabilitySnapshotId,
|
||||
reason: opts.reason,
|
||||
},
|
||||
});
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId,
|
||||
actorId: opts.actorId,
|
||||
action: "delete",
|
||||
entity: "dsgvo_tenant_offboarding",
|
||||
entityId: tenantId,
|
||||
after: { deletedRows, deletedIdentities, portabilitySnapshotId, certificateId: cert.id },
|
||||
});
|
||||
|
||||
return { tenantId, deletedRows, deletedIdentities, portabilitySnapshotId, certificateId: cert.id };
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// ── DSGVO-Export: per-Mandant (Art. 20) und per-Person (Art. 15/20) ──────────
|
||||
//
|
||||
// Läuft über den Owner-`prisma`-Client. Zwei Granularitäten (KONZEPT §5):
|
||||
// 1. Per-Mandant — der gesamte Kundendatensatz (= das Backup-Artefakt, hier
|
||||
// maschinenlesbar als JSON entschlüsselt).
|
||||
// 2. Per-Person — alle personenbezogenen Zeilen einer Identity in EINEM
|
||||
// Mandanten: Identity-Metadaten (OHNE Secrets), Mitgliedschaft(en) und alle
|
||||
// Referenzen über die weichen PII-Felder (ownerId/assigneeId/actorId/…).
|
||||
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "../db";
|
||||
import { exportTenant } from "../backup/export";
|
||||
import { openArtifact } from "../backup/crypto";
|
||||
import { parseArtifactBody } from "../backup/serialization";
|
||||
import { writeAuditLog } from "../audit";
|
||||
import { PII_REFERENCE_FIELDS } from "./pii-fields";
|
||||
|
||||
/** Per-Mandant-Export als entschlüsseltes JSON (Portabilität/Offboarding-Kopie). */
|
||||
export async function exportTenantAsJson(tenantId: string) {
|
||||
const res = await exportTenant(tenantId, { persist: false, reason: "dsgvo-tenant" });
|
||||
const body = openArtifact(res.artifact);
|
||||
const tables = parseArtifactBody(body);
|
||||
await writeAuditLog({
|
||||
tenantId,
|
||||
action: "export",
|
||||
entity: "dsgvo_tenant_export",
|
||||
after: { totalRows: res.manifest.totalRows },
|
||||
});
|
||||
return { manifest: res.manifest, tables };
|
||||
}
|
||||
|
||||
export interface SubjectExport {
|
||||
identity: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
uiLocale: string;
|
||||
status: string;
|
||||
mfaEnrolled: boolean;
|
||||
createdAt: Date;
|
||||
} | null;
|
||||
memberships: Record<string, unknown>[];
|
||||
/** Referenzierte Objekte je (Modell.Feld). */
|
||||
references: Record<string, Record<string, unknown>[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-Person-Auskunft innerhalb EINES Mandanten. Secrets (passwordHash,
|
||||
* mfaSecret, recoveryCodes) werden bewusst NIE ausgegeben.
|
||||
*/
|
||||
export async function exportSubject(tenantId: string, identityId: string): Promise<SubjectExport> {
|
||||
const identity = await prisma.identity.findUnique({
|
||||
where: { id: identityId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
uiLocale: true,
|
||||
status: true,
|
||||
mfaEnrolledAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Mitgliedschaft(en) der Person in DIESEM Mandanten (i. d. R. genau eine).
|
||||
// Der Anzeigename lebt denormalisiert auf User (Identity trägt keinen Namen).
|
||||
const memberships = await prisma.user.findMany({ where: { tenantId, identityId } });
|
||||
const userIds = memberships.map((m) => m.id);
|
||||
|
||||
// Feld-Existenz aus dem DMMF prüfen (robust gegen Schema-Drift der PII-Liste).
|
||||
const fieldsByModel = new Map<string, Set<string>>();
|
||||
for (const m of Prisma.dmmf.datamodel.models) {
|
||||
fieldsByModel.set(m.name, new Set(m.fields.map((f) => f.name)));
|
||||
}
|
||||
|
||||
const references: Record<string, Record<string, unknown>[]> = {};
|
||||
if (userIds.length) {
|
||||
for (const ref of PII_REFERENCE_FIELDS) {
|
||||
if (!fieldsByModel.get(ref.model)?.has(ref.field)) continue;
|
||||
const delegate = (prisma as unknown as Record<string, {
|
||||
findMany: (a: unknown) => Promise<Record<string, unknown>[]>;
|
||||
}>)[ref.model.charAt(0).toLowerCase() + ref.model.slice(1)];
|
||||
const rows = await delegate.findMany({
|
||||
where: { tenantId, [ref.field]: { in: userIds } },
|
||||
});
|
||||
if (rows.length) references[`${ref.model}.${ref.field}`] = rows;
|
||||
}
|
||||
}
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId,
|
||||
action: "export",
|
||||
entity: "dsgvo_subject_export",
|
||||
entityId: identityId,
|
||||
after: { memberships: memberships.length, referenceGroups: Object.keys(references).length },
|
||||
});
|
||||
|
||||
return {
|
||||
identity: identity
|
||||
? {
|
||||
id: identity.id,
|
||||
email: identity.email,
|
||||
name: memberships[0]?.name ?? "",
|
||||
uiLocale: identity.uiLocale,
|
||||
status: identity.status,
|
||||
mfaEnrolled: identity.mfaEnrolledAt != null,
|
||||
createdAt: identity.createdAt,
|
||||
}
|
||||
: null,
|
||||
memberships,
|
||||
references,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// ── Klassifikation der weichen Personen-Referenzfelder (DSGVO) ───────────────
|
||||
//
|
||||
// Diese Felder speichern eine `User.id` (mandanten-lokale Mitgliedschaft) als
|
||||
// lose Referenz auf eine Person. Grundlage für die DSGVO-Auskunft (welche
|
||||
// Objekte referenzieren die Person) und die -Löschung (welche Referenzen
|
||||
// bleiben — pseudonymisiert über die anonymisierte User-Zeile — vs. werden
|
||||
// mitgelöscht). Zentrale, gepflegte Liste (KONZEPT §5/§6, offene DSB-Feinklassifikation).
|
||||
|
||||
export interface PiiReference {
|
||||
/** Prisma-Modellname. */
|
||||
model: string;
|
||||
/** Prisma-Feldname, das eine User.id trägt. */
|
||||
field: string;
|
||||
}
|
||||
|
||||
/** Felder, die eine Person als „Eigentümer/Ersteller/Bearbeiter/Akteur" referenzieren. */
|
||||
export const PII_REFERENCE_FIELDS: readonly PiiReference[] = [
|
||||
{ model: "Asset", field: "ownerId" },
|
||||
{ model: "Asset", field: "createdBy" },
|
||||
{ model: "Process", field: "ownerId" },
|
||||
{ model: "Process", field: "createdBy" },
|
||||
{ model: "Process", field: "deputyOwnerId" },
|
||||
{ model: "Risk", field: "ownerId" },
|
||||
{ model: "Risk", field: "createdBy" },
|
||||
{ model: "Measure", field: "ownerId" },
|
||||
{ model: "Measure", field: "createdBy" },
|
||||
{ model: "SupplierProfile", field: "createdBy" },
|
||||
{ model: "ITServiceProfile", field: "createdBy" },
|
||||
{ model: "SoftwareProfile", field: "createdBy" },
|
||||
{ model: "ProjectProfile", field: "createdBy" },
|
||||
{ model: "ManagementDecision", field: "decidedBy" },
|
||||
{ model: "AuditLog", field: "actorId" },
|
||||
{ model: "Task", field: "assigneeId" },
|
||||
{ model: "Task", field: "createdById" },
|
||||
{ model: "TaskComment", field: "authorId" },
|
||||
{ model: "TaskParticipant", field: "userId" },
|
||||
{ model: "Audit", field: "auditorUserId" },
|
||||
{ model: "AuditEvidenceItem", field: "assignedUserId" },
|
||||
{ model: "OnboardingProgress", field: "reviewerId" },
|
||||
{ model: "ProjectFunctionAssignment", field: "userId" },
|
||||
{ model: "NotificationPreference", field: "userId" },
|
||||
];
|
||||
@@ -0,0 +1,58 @@
|
||||
// ── Tombstone-Wiederanwendung beim Restore (DSGVO, KONZEPT §6) ───────────────
|
||||
//
|
||||
// „Backups vs. Löschung": Eine Personen-/PII-Löschung wirkt auf Live-Daten. Ein
|
||||
// späterer Tenant-Restore aus einem ÄLTEREN Snapshot würde die gelöschte/
|
||||
// anonymisierte PII zurückbringen. Deshalb ist `TombstoneEntry` GLOBAL (kein
|
||||
// tenant_id in TENANT_MODELS → wird beim Restore nicht überschrieben) und wird am
|
||||
// Ende jedes Restore erneut angewandt: betroffene Zeilen werden erneut gelöscht
|
||||
// bzw. anonymisiert.
|
||||
|
||||
/** Minimales Client-/Transaktions-Interface (Owner-Client oder Tx). */
|
||||
type DynClient = Record<
|
||||
string,
|
||||
{
|
||||
findMany: (a: unknown) => Promise<Record<string, unknown>[]>;
|
||||
deleteMany: (a: unknown) => Promise<{ count: number }>;
|
||||
updateMany: (a: unknown) => Promise<{ count: number }>;
|
||||
}
|
||||
>;
|
||||
|
||||
interface TombstoneRow {
|
||||
model: string;
|
||||
targetField: string;
|
||||
targetValue: string;
|
||||
action: string;
|
||||
anonymizedFields: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function delegateName(model: string): string {
|
||||
return model.charAt(0).toLowerCase() + model.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wendet alle Tombstones eines Mandanten erneut an. Läuft innerhalb der Restore-
|
||||
* Transaktion über denselben (Owner-)Client. Gibt die Zahl angewandter Tombstones
|
||||
* zurück (Zeile getroffen oder nicht — der Tombstone gilt als angewandt, sobald
|
||||
* die Regel ausgeführt wurde).
|
||||
*/
|
||||
export async function applyTombstones(tx: DynClient, tenantId: string): Promise<number> {
|
||||
const tombstones = (await tx["tombstoneEntry"].findMany({
|
||||
where: { tenantId },
|
||||
})) as unknown as TombstoneRow[];
|
||||
|
||||
let applied = 0;
|
||||
for (const t of tombstones) {
|
||||
const delegate = tx[delegateName(t.model)];
|
||||
if (!delegate) continue; // unbekanntes Modell (Schema-Drift) → überspringen
|
||||
const where = { [t.targetField]: t.targetValue };
|
||||
if (t.action === "delete") {
|
||||
await delegate.deleteMany({ where });
|
||||
} else if (t.action === "anonymize") {
|
||||
await delegate.updateMany({ where, data: t.anonymizedFields ?? {} });
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
applied++;
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Fail-Secure-Startprüfung der sicherheitskritischen Umgebungsvariablen (F-01, Teil 3).
|
||||
*
|
||||
* WARUM LAZY UND MEMOISIERT — bitte NICHT zu einem Import-Zeit-`throw` "aufräumen":
|
||||
* Zur Docker-Build-Zeit lädt `next build` alle Module, aber es sind KEINE echten
|
||||
* Secrets gesetzt — `DATABASE_URL` ist in der builder-Stage nur ein Platzhalter
|
||||
* (siehe Dockerfile) und `AUTH_SECRET` ist überhaupt nicht gesetzt. Ein `throw` auf
|
||||
* Modulebene würde daher jeden Build zerlegen. Deshalb prüft `assertSecureEnv()`
|
||||
* erst beim ersten TATSÄCHLICHEN Auth-Zugriff (aus `requireSession()`,
|
||||
* `requirePlatformSession()` und den beiden `authorize()`-Callbacks heraus) und
|
||||
* merkt sich das Ergebnis, statt bei jedem Aufruf neu zu prüfen.
|
||||
*
|
||||
* Ziel: Fehlende oder zu kurze Secrets führen zu einem harten Abbruch der
|
||||
* Auth-Verarbeitung (fail secure) statt zu einem stillen Weiterlaufen, bei dem
|
||||
* Auth.js bei Fehlkonfiguration ein Session-Objekt mit Fehlerzustand liefern
|
||||
* könnte (fail open, CWE-636).
|
||||
*/
|
||||
|
||||
// Mindestlänge für Secrets (Auth.js empfiehlt >= 32 zufällige Zeichen; 16 ist die
|
||||
// harte Untergrenze, unter der wir den Start als Fehlkonfiguration werten).
|
||||
const MIN_SECRET_LENGTH = 16;
|
||||
|
||||
let verified = false;
|
||||
|
||||
/**
|
||||
* Prüft einmalig (memoisiert), dass die sicherheitskritischen Umgebungsvariablen
|
||||
* gesetzt und plausibel sind. Wirft bei Fehlkonfiguration, sonst still.
|
||||
*/
|
||||
export function assertSecureEnv(): void {
|
||||
if (verified) return;
|
||||
|
||||
// Auth.js v5 nutzt AUTH_SECRET; NEXTAUTH_SECRET wird als Fallback akzeptiert,
|
||||
// damit bestehende Deployments nicht fälschlich als unsicher gewertet werden.
|
||||
const authSecret = process.env.AUTH_SECRET ?? process.env.NEXTAUTH_SECRET;
|
||||
if (!authSecret || authSecret.length < MIN_SECRET_LENGTH) {
|
||||
throw new Error(
|
||||
`Startabbruch: AUTH_SECRET fehlt oder ist zu kurz (mind. ${MIN_SECRET_LENGTH} Zeichen). ` +
|
||||
"Fail-Secure statt Fail-Open — Auth-Zugriff wird abgebrochen.",
|
||||
);
|
||||
}
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl || databaseUrl.length < MIN_SECRET_LENGTH) {
|
||||
throw new Error("Startabbruch: DATABASE_URL fehlt oder ist ungültig.");
|
||||
}
|
||||
|
||||
// Härtung §1: PASSWORD_PEPPER ist Pflicht (32-Byte hex = 64 Hexzeichen). Er wird
|
||||
// in jeden Argon2-Hash/-Verify gemischt (src/server/password.ts) und ist NICHT aus
|
||||
// AUTH_SECRET ableitbar (eigenständiges, nicht rotierbares Umgebungs-Secret).
|
||||
const pepper = process.env.PASSWORD_PEPPER ?? "";
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(pepper)) {
|
||||
throw new Error(
|
||||
"Startabbruch: PASSWORD_PEPPER fehlt oder ist kein 32-Byte-Hex (64 Hexzeichen). " +
|
||||
"Erzeuge ein pro-Umgebung eindeutiges Secret: openssl rand -hex 32. Fail-Secure.",
|
||||
);
|
||||
}
|
||||
|
||||
verified = true;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Aufbereitung der Export-Zeilen (Story B7-2) aus A7 (Control-Assessment) und A8
|
||||
// (Gap-Liste). Wird vom CSV-Route-Handler und der Management-Zusammenfassung geteilt.
|
||||
|
||||
import type { TenantDb } from "@/server/db";
|
||||
import { buildControlRows } from "@/server/soa-context";
|
||||
import { buildGapItems } from "@/server/gap-context";
|
||||
import { buildControlGroups } from "@/server/control-descriptions-context";
|
||||
import { pruefzielOfControl, composeAbgabeText, type ExportControl, type AbgabeControl } from "@/lib/export/vda-isa";
|
||||
|
||||
export async function buildExportRows(db: TenantDb, tenantId: string): Promise<ExportControl[]> {
|
||||
const [{ rows }, { items }] = await Promise.all([
|
||||
buildControlRows(db, tenantId),
|
||||
buildGapItems(db, tenantId),
|
||||
]);
|
||||
|
||||
const gapByControl = new Map<string, number>();
|
||||
for (const g of items) for (const c of g.controls) gapByControl.set(c, (gapByControl.get(c) ?? 0) + 1);
|
||||
|
||||
return rows.map<ExportControl>((r) => {
|
||||
const ev = r.evidence;
|
||||
const belege = [
|
||||
`Richtlinie ${r.spec.policy.join("/") || "—"} (${ev.policy})`,
|
||||
`Verfahren ${r.spec.verfahren.join("/") || "—"} (${ev.verfahren})`,
|
||||
ev.assetLinked ? "Asset verknüpft" : null,
|
||||
ev.riskLinked ? "Risiko verknüpft" : null,
|
||||
].filter(Boolean).join("; ");
|
||||
return {
|
||||
control: r.control,
|
||||
frage: r.spec.title,
|
||||
reifegrad: r.confirmed,
|
||||
bestaetigt: r.confirmed !== null,
|
||||
umsetzung: r.suggestion.reason,
|
||||
belege,
|
||||
offenePunkte: gapByControl.get(r.control) ?? r.gaps.length,
|
||||
pruefziel: pruefzielOfControl(r.control),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ABGABE-Zeilen (VDA-ISA-Prüfungsdokumentation): je Control der bestätigte
|
||||
* Reifegrad (ControlAssessment), die zusammengesetzte Umsetzungsbeschreibung
|
||||
* (übernommene ControlDescription je Anforderung) und die Referenz-Dokumentation
|
||||
* (Dokumentverweise der übernommenen Beschreibungen + vorhandene Nachweise).
|
||||
*/
|
||||
export async function buildAbgabeRows(db: TenantDb, tenantId: string): Promise<AbgabeControl[]> {
|
||||
const [groups, assessments] = await Promise.all([
|
||||
buildControlGroups(db),
|
||||
db.controlAssessment.findMany({ where: { tenantId }, select: { control: true, confirmedValue: true, target: true } }),
|
||||
]);
|
||||
const asmByControl = new Map(assessments.map((a) => [a.control, a]));
|
||||
|
||||
return groups.map<AbgabeControl>((g) => {
|
||||
const confirmed = g.bullets.filter((b) => b.description?.status === "confirmed" && b.description.draftText);
|
||||
const umsetzung = composeAbgabeText(confirmed.map((b) => ({ requirement: b.requirement, text: b.description!.draftText })));
|
||||
const docRefs = [...new Set(confirmed.map((b) => b.description!.sourceRef).filter((s): s is string => Boolean(s)))];
|
||||
const referenzen = [...docRefs, ...g.evidence].join("; ");
|
||||
const asm = asmByControl.get(g.control);
|
||||
return {
|
||||
control: g.control,
|
||||
frage: g.frage,
|
||||
pruefziel: pruefzielOfControl(g.control),
|
||||
reifegrad: asm?.confirmedValue ?? null,
|
||||
zielReifegrad: asm?.target ?? null,
|
||||
umsetzung,
|
||||
referenzen,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Vorfallregister-Export als echte xlsx (IM-C, §9/§72). Serverseitig, weil
|
||||
// `exceljs` Node-APIs nutzt und nicht client-safe ist. Spalten/Zeilen entstammen
|
||||
// dem client-safen Builder in src/lib/incident-export.ts (gleiche Reihenfolge wie
|
||||
// die CSV-Variante), damit CSV und XLSX deckungsgleich bleiben.
|
||||
|
||||
import ExcelJS from "exceljs";
|
||||
import {
|
||||
INCIDENT_REGISTER_COLUMNS,
|
||||
incidentRegisterRow,
|
||||
type IncidentRegisterInput,
|
||||
} from "@/lib/incident-export";
|
||||
|
||||
// Sinnvolle Spaltenbreiten je Spalte (parallel zu INCIDENT_REGISTER_COLUMNS).
|
||||
const WIDTHS = [14, 40, 24, 12, 10, 16, 16, 18, 18, 18, 20, 20, 12, 13, 12, 20, 22, 20, 22, 11, 9, 8, 9, 18];
|
||||
|
||||
/** Erzeugt das Vorfallregister als xlsx-Workbook-Buffer. */
|
||||
export async function buildIncidentRegisterXlsx(rows: IncidentRegisterInput[]): Promise<Buffer> {
|
||||
const wb = new ExcelJS.Workbook();
|
||||
wb.creator = "Certvia";
|
||||
wb.created = new Date();
|
||||
|
||||
const ws = wb.addWorksheet("Vorfallregister", { views: [{ state: "frozen", ySplit: 1 }] });
|
||||
ws.columns = INCIDENT_REGISTER_COLUMNS.map((header, i) => ({ header, width: WIDTHS[i] ?? 16 }));
|
||||
|
||||
const headerRow = ws.getRow(1);
|
||||
headerRow.font = { bold: true };
|
||||
headerRow.alignment = { vertical: "middle", wrapText: true };
|
||||
headerRow.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFF4F2FA" } };
|
||||
|
||||
for (const r of rows) {
|
||||
const row = ws.addRow(incidentRegisterRow(r));
|
||||
row.alignment = { vertical: "top", wrapText: true };
|
||||
}
|
||||
|
||||
const out = await wb.xlsx.writeBuffer();
|
||||
return Buffer.from(out as ArrayBuffer);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// SoA-Export als echte xlsx (AP3). Serverseitig (exceljs nutzt Node-APIs). Spalten/
|
||||
// Zeilen kommen aus dem client-safen Builder in src/lib/soa.ts, damit CSV und XLSX
|
||||
// deckungsgleich bleiben.
|
||||
|
||||
import ExcelJS from "exceljs";
|
||||
import { SOA_EXPORT_COLUMNS, soaExportRow, type SoaExportInput } from "@/lib/soa";
|
||||
|
||||
const WIDTHS = [12, 40, 10, 50, 24, 16, 12, 22, 22, 11];
|
||||
|
||||
/** Erzeugt die Anwendbarkeitserklärung als xlsx-Workbook-Buffer. */
|
||||
export async function buildSoaXlsx(rows: SoaExportInput[]): Promise<Buffer> {
|
||||
const wb = new ExcelJS.Workbook();
|
||||
wb.creator = "Certvia";
|
||||
wb.created = new Date();
|
||||
|
||||
const ws = wb.addWorksheet("SoA", { views: [{ state: "frozen", ySplit: 1 }] });
|
||||
ws.columns = SOA_EXPORT_COLUMNS.map((header, i) => ({ header, width: WIDTHS[i] ?? 16 }));
|
||||
|
||||
const headerRow = ws.getRow(1);
|
||||
headerRow.font = { bold: true };
|
||||
headerRow.alignment = { vertical: "middle" };
|
||||
|
||||
for (const r of rows) {
|
||||
const row = ws.addRow(soaExportRow(r));
|
||||
row.alignment = { vertical: "top", wrapText: true };
|
||||
}
|
||||
|
||||
const buffer = await wb.xlsx.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// VDA-ISA-ABGABE-Export als echte xlsx. Serverseitig, weil `exceljs` Node-APIs
|
||||
// (Buffer/Streams) nutzt und nicht client-safe ist — daher unter src/server/.
|
||||
// Aufbau je Control: Kontrollnummer, Reifegrad (Ist/Ziel), zusammengesetzte
|
||||
// „Beschreibung der Umsetzung", Referenz Dokumentation, Prüfziel. Sortierung wie
|
||||
// die CSV-Variante (IS → Prototyp → Datenschutz, dann Control numerisch).
|
||||
|
||||
import ExcelJS from "exceljs";
|
||||
import { PRUEFZIEL_LABEL, sortAbgabeRows, type AbgabeControl } from "@/lib/export/vda-isa";
|
||||
|
||||
/**
|
||||
* Erzeugt die ABGABE-Sicht als xlsx-Workbook-Buffer im VDA-ISA-Layout.
|
||||
* Kopfzeile fett + graue Füllung, sinnvolle Spaltenbreiten, mehrzeilige
|
||||
* Text-Zellen (Umsetzung/Referenzen) mit Zeilenumbruch und oberer Ausrichtung.
|
||||
*/
|
||||
export async function buildAbgabeXlsx(rows: AbgabeControl[]): Promise<Buffer> {
|
||||
const wb = new ExcelJS.Workbook();
|
||||
wb.creator = "ISMS-Tool";
|
||||
wb.created = new Date();
|
||||
|
||||
const ws = wb.addWorksheet("VDA-ISA ABGABE", {
|
||||
views: [{ state: "frozen", ySplit: 1 }],
|
||||
});
|
||||
|
||||
ws.columns = [
|
||||
{ header: "Kontrollnummer", key: "control", width: 16 },
|
||||
{ header: "Kontrollfrage/Ziel", key: "frage", width: 40 },
|
||||
{ header: "Reifegrad (Ist)", key: "reifegrad", width: 14 },
|
||||
{ header: "Reifegrad (Ziel)", key: "zielReifegrad", width: 14 },
|
||||
{ header: "Beschreibung der Umsetzung", key: "umsetzung", width: 70 },
|
||||
{ header: "Referenz Dokumentation", key: "referenzen", width: 40 },
|
||||
{ header: "Prüfziel", key: "pruefziel", width: 22 },
|
||||
];
|
||||
|
||||
const headerRow = ws.getRow(1);
|
||||
headerRow.font = { bold: true };
|
||||
headerRow.alignment = { vertical: "middle", wrapText: true };
|
||||
headerRow.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFE7E7E7" } };
|
||||
|
||||
for (const r of sortAbgabeRows(rows)) {
|
||||
const row = ws.addRow({
|
||||
control: r.control,
|
||||
frage: r.frage,
|
||||
reifegrad: r.reifegrad ?? "na",
|
||||
zielReifegrad: r.zielReifegrad ?? "na",
|
||||
umsetzung: r.umsetzung,
|
||||
referenzen: r.referenzen,
|
||||
pruefziel: PRUEFZIEL_LABEL[r.pruefziel],
|
||||
});
|
||||
row.alignment = { vertical: "top", wrapText: true };
|
||||
row.getCell("reifegrad").alignment = { vertical: "top", horizontal: "center" };
|
||||
row.getCell("zielReifegrad").alignment = { vertical: "top", horizontal: "center" };
|
||||
}
|
||||
|
||||
// exceljs liefert ArrayBuffer/Buffer je nach Umgebung — als Node-Buffer normalisieren.
|
||||
const out = await wb.xlsx.writeBuffer();
|
||||
return Buffer.from(out as ArrayBuffer);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { TenantDb } from "@/server/db";
|
||||
import { buildControlRows, loadScopeInput, type ControlRow } from "@/server/soa-context";
|
||||
import { activeRequirements, type C1Row } from "@/lib/scope-filter";
|
||||
import { riskRef } from "@/lib/risk";
|
||||
import { consolidate, summarize, type RawGap, type GapItem, type GapEffort, type GapSummary } from "@/lib/gap-consolidation";
|
||||
import type { OpenPoint } from "@/lib/maturity";
|
||||
|
||||
/**
|
||||
* Gap-Kontext (Story A8). Aggregiert die offenen Punkte aus Schritt 7 (Control-Assessment,
|
||||
* A7) und Schritt 6 (Risiko-Register, A6: Risiken oberhalb der Akzeptanzlinie), reichert
|
||||
* sie um Anforderungstyp/Aufwand/Reifegrad-Wirkung an, gleicht sie mit bestehenden Aufgaben
|
||||
* ab und übergibt sie an die reine Konsolidierungs-Engine (src/lib/gap-consolidation.ts).
|
||||
*/
|
||||
|
||||
/** Risikowert > 9 (hoch/sehr hoch) = oberhalb der Akzeptanzlinie (analog VA-09/A6). */
|
||||
const ACCEPT_THRESHOLD = 9;
|
||||
|
||||
const TYPE_RANK: Record<C1Row["type"], number> = { "SEHR HOCH": 4, HOCH: 3, MUSS: 2, SOLL: 1 };
|
||||
|
||||
/** Höchster in-Scope-Anforderungstyp je Control (bestimmt die Priorität nach C8 §1). */
|
||||
async function requirementTypeByControl(db: TenantDb, tenantId: string): Promise<Map<string, C1Row["type"]>> {
|
||||
const { input } = await loadScopeInput(db, tenantId);
|
||||
const out = new Map<string, C1Row["type"]>();
|
||||
for (const r of activeRequirements(input)) {
|
||||
const cur = out.get(r.control);
|
||||
if (!cur || TYPE_RANK[r.type] > TYPE_RANK[cur]) out.set(r.control, r.type);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Aufwand + Reifegrad-Wirkung eines Control-Gaps aus Art und Belegstatus (für Quick-Wins). */
|
||||
function controlGapEffort(gap: OpenPoint, row: ControlRow): { effort: GapEffort; maturityImpact: number } {
|
||||
const impact = Math.max(1, row.target - row.suggestion.value);
|
||||
switch (gap.kind) {
|
||||
case "nachweis":
|
||||
// Review/Audit/Test durchführen — organisatorisch, kein Tool/Budget.
|
||||
return { effort: "gering", maturityImpact: impact };
|
||||
case "verknuepfung":
|
||||
// Asset-/Risiko-Verknüpfung herstellen — organisatorisch.
|
||||
return { effort: "gering", maturityImpact: impact };
|
||||
case "richtlinie":
|
||||
// Dokument bereits verknüpft (unvalidiert) → Freigabe = gering; fehlt → Erstellung = hoch.
|
||||
return { effort: row.evidence.policy === "verknuepft" ? "gering" : "hoch", maturityImpact: impact };
|
||||
case "verfahren":
|
||||
return { effort: row.evidence.verfahren === "verknuepft" ? "gering" : "hoch", maturityImpact: impact };
|
||||
case "widerspruch":
|
||||
return { effort: "hoch", maturityImpact: impact };
|
||||
default:
|
||||
return { effort: "mittel", maturityImpact: impact };
|
||||
}
|
||||
}
|
||||
|
||||
/** Baut die konsolidierte, priorisierte Gap-Liste inkl. Summary. */
|
||||
export async function buildGapItems(db: TenantDb, tenantId: string): Promise<{ items: GapItem[]; summary: GapSummary }> {
|
||||
const [{ rows }, reqTypes, openTasks, risks, catalog] = await Promise.all([
|
||||
buildControlRows(db, tenantId),
|
||||
requirementTypeByControl(db, tenantId),
|
||||
db.task.findMany({ where: { status: { in: ["PROPOSED", "OPEN"] } }, select: { id: true, origin: true } }),
|
||||
db.risk.findMany({
|
||||
where: { status: { in: ["OPEN", "IN_TREATMENT"] }, treatment: { not: "ACCEPT" } },
|
||||
select: { id: true, refNo: true, title: true, score: true, residualScore: true, catalogCode: true },
|
||||
}),
|
||||
db.riskCatalogEntry.findMany({ select: { code: true, controls: true } }),
|
||||
]);
|
||||
|
||||
const taskByOrigin = new Map<string, string>();
|
||||
for (const t of openTasks) if (t.origin) taskByOrigin.set(t.origin, t.id);
|
||||
const controlsByCatalog = new Map(catalog.map((c) => [c.code, c.controls]));
|
||||
|
||||
const raws: RawGap[] = [];
|
||||
|
||||
// Quelle Schritt 7: Control-Gaps (A7 openPoints).
|
||||
for (const row of rows) {
|
||||
const reqType = reqTypes.get(row.control);
|
||||
for (const gap of row.gaps) {
|
||||
const { effort, maturityImpact } = controlGapEffort(gap, row);
|
||||
const taskOrigin = `wizard:control-gap:${row.control}:${gap.kind}`;
|
||||
raws.push({
|
||||
id: `control:${row.control}:${gap.kind}`,
|
||||
source: "control",
|
||||
title: `${row.control} — ${gap.missing}`,
|
||||
action: gap.action,
|
||||
controls: [row.control],
|
||||
requirementType: reqType,
|
||||
kind: gap.kind,
|
||||
maturity: row.confirmed ?? row.suggestion.value,
|
||||
target: row.target,
|
||||
effort,
|
||||
maturityImpact,
|
||||
hasDependency: false,
|
||||
taskOrigin,
|
||||
taskId: taskByOrigin.get(taskOrigin) ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Quelle Schritt 6: Risiken oberhalb der Akzeptanzlinie (A6).
|
||||
for (const risk of risks) {
|
||||
const eff = risk.residualScore ?? risk.score;
|
||||
if (eff <= ACCEPT_THRESHOLD) continue;
|
||||
const controls = risk.catalogCode ? controlsByCatalog.get(risk.catalogCode) ?? [] : [];
|
||||
const taskOrigin = `risk-measure:${risk.id}`;
|
||||
raws.push({
|
||||
id: `risk:${risk.id}`,
|
||||
source: "risk",
|
||||
title: `${riskRef(risk.refNo)} — ${risk.title}`,
|
||||
action: `Behandlungsmaßnahme für Risiko ${riskRef(risk.refNo)} umsetzen`,
|
||||
controls,
|
||||
riskScore: eff,
|
||||
aboveAcceptance: true,
|
||||
// Risikobehandlung erfordert i. d. R. Umsetzung/Tool → kein Quick-Win-Default.
|
||||
effort: "mittel",
|
||||
maturityImpact: 1,
|
||||
hasDependency: false,
|
||||
taskOrigin,
|
||||
taskId: taskByOrigin.get(taskOrigin) ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const items = consolidate(raws);
|
||||
return { items, summary: summarize(items) };
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// Aufbereitung der Vorfall-Exportdaten (IM-C, §9/§72): Register-Zeilen,
|
||||
// Einzel-Berichtsdaten und Meldevorlagen-Eingaben. Fasst die DB-Zugriffe für den
|
||||
// Export-Route-Handler zusammen; die eigentliche Formatierung liegt in den
|
||||
// client-safen Buildern (src/lib/incident-export.ts, incident-report-html.ts).
|
||||
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import type { TenantDb } from "@/server/db";
|
||||
import { prisma } from "@/server/db";
|
||||
import { measureRef } from "@/lib/measure";
|
||||
import { riskRef } from "@/lib/risk";
|
||||
import { deadlineItems } from "@/lib/incident-deadlines";
|
||||
import type { IncidentRegisterInput, IncidentTemplateInput } from "@/lib/incident-export";
|
||||
import type { IncidentReportData } from "@/lib/incident-report-html";
|
||||
|
||||
const DEADLINE_LABEL_DE: Record<string, string> = {
|
||||
erstmeldung: "NIS2 Erstmeldung (24 h)",
|
||||
folgemeldung: "NIS2 Folgemeldung (72 h)",
|
||||
abschluss: "NIS2 Abschlussbericht (1 Monat)",
|
||||
dsgvo: "DSGVO-Meldung (Art. 33, 72 h)",
|
||||
reaction: "Interne Reaktionsfrist (SLA)",
|
||||
resolution: "Interne Behebungsfrist (SLA)",
|
||||
};
|
||||
|
||||
async function organisationName(tenantId: string): Promise<string | null> {
|
||||
const t = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { name: true } });
|
||||
return t?.name ?? null;
|
||||
}
|
||||
|
||||
/** Registerzeilen (Liste) für CSV/XLSX — respektiert einen optionalen Where-Filter. */
|
||||
export async function buildIncidentRegisterInputs(
|
||||
db: TenantDb,
|
||||
where: Prisma.IncidentWhereInput = {},
|
||||
): Promise<IncidentRegisterInput[]> {
|
||||
const incidents = await db.incident.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
owner: { select: { name: true } },
|
||||
assignee: { select: { name: true } },
|
||||
_count: {
|
||||
select: {
|
||||
incidentMeasures: true,
|
||||
incidentRisks: true,
|
||||
incidentAssets: true,
|
||||
incidentControls: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return incidents.map((i) => ({
|
||||
refNo: i.refNo,
|
||||
title: i.title,
|
||||
category: i.category,
|
||||
severity: i.severity,
|
||||
priority: i.priority,
|
||||
status: i.status,
|
||||
source: i.source,
|
||||
occurredAt: i.occurredAt,
|
||||
detectedAt: i.detectedAt,
|
||||
reportedAt: i.reportedAt,
|
||||
ownerName: i.owner?.name ?? null,
|
||||
assigneeName: i.assignee?.name ?? null,
|
||||
nis2Relevant: i.nis2Relevant,
|
||||
dsgvoRelevant: i.dsgvoRelevant,
|
||||
personalData: i.personalData,
|
||||
prototypeData: i.prototypeData,
|
||||
reportStatus: i.reportStatus,
|
||||
erstmeldungDueAt: i.erstmeldungDueAt,
|
||||
dsgvoDueAt: i.dsgvoDueAt,
|
||||
abschlussDueAt: i.abschlussDueAt,
|
||||
measureCount: i._count.incidentMeasures,
|
||||
riskCount: i._count.incidentRisks,
|
||||
assetCount: i._count.incidentAssets,
|
||||
controlCount: i._count.incidentControls,
|
||||
createdAt: i.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
const REPORT_INCLUDE = {
|
||||
owner: { select: { name: true } },
|
||||
assignee: { select: { name: true } },
|
||||
incidentAssets: { include: { asset: { select: { name: true } } } },
|
||||
incidentProcesses: { include: { process: { select: { name: true } } } },
|
||||
incidentRisks: { include: { risk: { select: { refNo: true, title: true } } } },
|
||||
incidentControls: true,
|
||||
incidentMeasures: {
|
||||
include: { measure: { select: { refNo: true, title: true, status: true, dueDate: true, owner: { select: { name: true } } } } },
|
||||
},
|
||||
incidentEvidence: { include: { evidence: { select: { title: true } } } },
|
||||
} satisfies Prisma.IncidentInclude;
|
||||
|
||||
/** Vollständige Daten für den druckbaren Einzel-Vorfallbericht. */
|
||||
export async function buildIncidentReportData(
|
||||
db: TenantDb,
|
||||
tenantId: string,
|
||||
where: Prisma.IncidentWhereInput,
|
||||
): Promise<IncidentReportData | null> {
|
||||
const inc = await db.incident.findFirst({ where, include: REPORT_INCLUDE });
|
||||
if (!inc) return null;
|
||||
|
||||
const audit = await db.auditLog.findMany({
|
||||
where: { entity: "incident", entityId: inc.id },
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: 300,
|
||||
});
|
||||
const actorIds = [...new Set(audit.map((a) => a.actorId).filter((x): x is string => Boolean(x)))];
|
||||
const actors = actorIds.length
|
||||
? await db.user.findMany({ where: { id: { in: actorIds } }, select: { id: true, name: true } })
|
||||
: [];
|
||||
const actorNames = Object.fromEntries(actors.map((a) => [a.id, a.name]));
|
||||
|
||||
const deadlines = deadlineItems(inc).map((d) => ({
|
||||
label: DEADLINE_LABEL_DE[d.kind] ?? d.kind,
|
||||
dueAt: d.dueAt,
|
||||
done: d.done,
|
||||
overdue: d.overdue,
|
||||
}));
|
||||
|
||||
return {
|
||||
refNo: inc.refNo,
|
||||
title: inc.title,
|
||||
organisation: await organisationName(tenantId),
|
||||
description: inc.description,
|
||||
category: inc.category,
|
||||
source: inc.source,
|
||||
status: inc.status,
|
||||
severity: inc.severity,
|
||||
priority: inc.priority,
|
||||
occurredAt: inc.occurredAt,
|
||||
detectedAt: inc.detectedAt,
|
||||
reportedAt: inc.reportedAt,
|
||||
ownerName: inc.owner?.name ?? null,
|
||||
assigneeName: inc.assignee?.name ?? null,
|
||||
reporterName: inc.reporterName,
|
||||
reporterContact: inc.reporterContact,
|
||||
impactC: inc.impactC,
|
||||
impactI: inc.impactI,
|
||||
impactA: inc.impactA,
|
||||
urgency: inc.urgency,
|
||||
affectedDataCategories: inc.affectedDataCategories,
|
||||
personalData: inc.personalData,
|
||||
prototypeData: inc.prototypeData,
|
||||
nis2Relevant: inc.nis2Relevant,
|
||||
dsgvoRelevant: inc.dsgvoRelevant,
|
||||
reportStatus: inc.reportStatus,
|
||||
rootCause: inc.rootCause,
|
||||
resolution: inc.resolution,
|
||||
closingNote: inc.closingNote,
|
||||
lessonsLearned: inc.lessonsLearned,
|
||||
measuresEffectiveness: inc.measuresEffectiveness,
|
||||
postIncidentReview: inc.postIncidentReview,
|
||||
deadlines,
|
||||
measures: inc.incidentMeasures.map((m) => ({
|
||||
ref: measureRef(m.measure.refNo),
|
||||
title: m.measure.title,
|
||||
status: m.measure.status,
|
||||
owner: m.measure.owner?.name ?? null,
|
||||
dueDate: m.measure.dueDate,
|
||||
})),
|
||||
risks: inc.incidentRisks.map((r) => ({ ref: riskRef(r.risk.refNo), title: r.risk.title })),
|
||||
controls: inc.incidentControls.map((c) => c.controlRef),
|
||||
assets: inc.incidentAssets.map((a) => a.asset.name),
|
||||
processes: inc.incidentProcesses.map((p) => p.process.name),
|
||||
evidence: inc.incidentEvidence.map((e) => ({ title: e.evidence.title, note: e.note })),
|
||||
timeline: audit.map((a) => ({
|
||||
at: a.createdAt,
|
||||
actor: a.actorId ? actorNames[a.actorId] ?? "System" : "System",
|
||||
action: a.action,
|
||||
})),
|
||||
generatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Eingabedaten für die NIS2-/DSGVO-Meldevorlagen. */
|
||||
export async function buildIncidentTemplateInput(
|
||||
db: TenantDb,
|
||||
tenantId: string,
|
||||
where: Prisma.IncidentWhereInput,
|
||||
): Promise<IncidentTemplateInput | null> {
|
||||
const inc = await db.incident.findFirst({ where });
|
||||
if (!inc) return null;
|
||||
return {
|
||||
refNo: inc.refNo,
|
||||
title: inc.title,
|
||||
description: inc.description,
|
||||
category: inc.category,
|
||||
severity: inc.severity,
|
||||
organisation: await organisationName(tenantId),
|
||||
occurredAt: inc.occurredAt,
|
||||
detectedAt: inc.detectedAt,
|
||||
reportedAt: inc.reportedAt,
|
||||
impactC: inc.impactC,
|
||||
impactI: inc.impactI,
|
||||
impactA: inc.impactA,
|
||||
affectedDataCategories: inc.affectedDataCategories,
|
||||
personalData: inc.personalData,
|
||||
nis2Relevant: inc.nis2Relevant,
|
||||
reporterName: inc.reporterName,
|
||||
reporterContact: inc.reporterContact,
|
||||
erstmeldungDueAt: inc.erstmeldungDueAt,
|
||||
folgemeldungDueAt: inc.folgemeldungDueAt,
|
||||
abschlussDueAt: inc.abschlussDueAt,
|
||||
dsgvoDueAt: inc.dsgvoDueAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* IM-D — Inbound-Parsing (REIN, ohne Netz/DB).
|
||||
*
|
||||
* Trennung von IMAP-I/O (KONZEPT §14.3): dieser Baustein bekommt bereits
|
||||
* geparste Header + Body (im Betrieb aus mailparser, im Test aus Fixtures) und
|
||||
* extrahiert die entscheidungsrelevanten Felder:
|
||||
* - Token aus dem EMPFÄNGER-Header (`Delivered-To`/`X-Envelope-To` — NICHT `To`,
|
||||
* da dort bei Weiterleitung die Kundenadresse steht),
|
||||
* - Absender/Betreff/Text,
|
||||
* - Auto-Reply/Bounce-Erkennung (`Auto-Submitted`/`Precedence`),
|
||||
* - DKIM/SPF-Signal aus `Authentication-Results`.
|
||||
*
|
||||
* Bewusst keine Seiteneffekte, keine Prisma-, keine IMAP-Importe → in
|
||||
* scripts/test-incident-inbound.ts mit Fixtures prüfbar.
|
||||
*/
|
||||
|
||||
export type AuthResult = "pass" | "fail" | "none";
|
||||
|
||||
/** Header-Bag: Schlüssel case-insensitiv, Werte einzeln oder mehrfach. */
|
||||
export type RawHeaders = Record<string, string | string[] | undefined>;
|
||||
|
||||
export interface InboundInput {
|
||||
headers: RawHeaders;
|
||||
/** Absenderzeile, ggf. „Name <a@b>". Fällt auf den `from`-Header zurück. */
|
||||
from?: string;
|
||||
subject?: string;
|
||||
/** Klartext-Body (mailparser: `.text`). */
|
||||
text?: string;
|
||||
/** Message-ID aus dem Parser; sonst aus dem Header gezogen. */
|
||||
messageId?: string;
|
||||
}
|
||||
|
||||
export interface ParsedInbound {
|
||||
messageId: string | null;
|
||||
sender: string | null;
|
||||
senderName: string | null;
|
||||
senderDomain: string | null;
|
||||
subject: string;
|
||||
text: string;
|
||||
/** Nur aus Delivered-To/X-Envelope-To (nicht To/Cc). */
|
||||
recipients: string[];
|
||||
/** Erster Token, der auf die Intake-Domain passt. */
|
||||
token: string | null;
|
||||
recipientForToken: string | null;
|
||||
autoSubmitted: boolean;
|
||||
dkim: AuthResult;
|
||||
spf: AuthResult;
|
||||
}
|
||||
|
||||
/** Intake-Domain (Env INCIDENT_INTAKE_DOMAIN, Default in.certvia.de). */
|
||||
export function intakeDomain(): string {
|
||||
const v = process.env.INCIDENT_INTAKE_DOMAIN?.trim();
|
||||
return (v && v.length > 0 ? v : "in.certvia.de").toLowerCase();
|
||||
}
|
||||
|
||||
/** Ableitung der Intake-Adresse aus dem Token: vorfall-<token>@<domain>. */
|
||||
export function intakeAddress(token: string, domain: string = intakeDomain()): string {
|
||||
return `vorfall-${token}@${domain}`;
|
||||
}
|
||||
|
||||
/** Token-Format: URL-sichere Kleinbuchstaben/Ziffern, keine leicht verwechselbaren Zeichen. */
|
||||
const TOKEN_RE = /^[a-z0-9]{8,64}$/;
|
||||
|
||||
/** Alle Werte eines Headers (case-insensitiv) als flache Liste. */
|
||||
function headerValues(headers: RawHeaders, name: string): string[] {
|
||||
const target = name.toLowerCase();
|
||||
const out: string[] = [];
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
if (k.toLowerCase() !== target || v == null) continue;
|
||||
if (Array.isArray(v)) out.push(...v.map((x) => String(x)));
|
||||
else out.push(String(v));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function headerFirst(headers: RawHeaders, name: string): string | null {
|
||||
const vs = headerValues(headers, name);
|
||||
return vs.length ? vs[0] : null;
|
||||
}
|
||||
|
||||
/** Bare-E-Mail aus „Name <a@b>" oder „a@b"; lowercased. */
|
||||
export function extractEmail(input: string | null | undefined): string | null {
|
||||
if (!input) return null;
|
||||
const angle = input.match(/<([^<>@\s]+@[^<>@\s]+)>/);
|
||||
const candidate = angle ? angle[1] : input.trim();
|
||||
const m = candidate.match(/([^\s<>@]+@[^\s<>@]+\.[^\s<>@]+)/);
|
||||
return m ? m[1].toLowerCase() : null;
|
||||
}
|
||||
|
||||
function extractName(input: string | null | undefined): string | null {
|
||||
if (!input) return null;
|
||||
const angle = input.match(/^\s*"?([^"<]+?)"?\s*</);
|
||||
if (angle) {
|
||||
const name = angle[1].trim();
|
||||
return name.length ? name : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Token aus einer einzelnen Adresse ziehen, sofern sie auf die Intake-Domain passt. */
|
||||
export function tokenFromAddress(address: string, domain: string = intakeDomain()): string | null {
|
||||
const email = extractEmail(address);
|
||||
if (!email) return null;
|
||||
const at = email.lastIndexOf("@");
|
||||
if (at < 0) return null;
|
||||
const local = email.slice(0, at);
|
||||
const dom = email.slice(at + 1);
|
||||
if (dom !== domain.toLowerCase()) return null;
|
||||
if (!local.startsWith("vorfall-")) return null;
|
||||
const token = local.slice("vorfall-".length);
|
||||
return TOKEN_RE.test(token) ? token : null;
|
||||
}
|
||||
|
||||
/** Empfänger-Adressen aus den zustellrelevanten Headern (NICHT To/Cc). */
|
||||
export function deliveryRecipients(headers: RawHeaders): string[] {
|
||||
const raw = [
|
||||
...headerValues(headers, "Delivered-To"),
|
||||
...headerValues(headers, "X-Envelope-To"),
|
||||
...headerValues(headers, "X-Original-To"),
|
||||
];
|
||||
const emails: string[] = [];
|
||||
for (const line of raw) {
|
||||
// Ein Header kann mehrere Adressen tragen (Komma-getrennt).
|
||||
for (const part of line.split(",")) {
|
||||
const e = extractEmail(part);
|
||||
if (e && !emails.includes(e)) emails.push(e);
|
||||
}
|
||||
}
|
||||
return emails;
|
||||
}
|
||||
|
||||
/** Auto-Reply/Bounce/Mailingliste erkennen (Schleifenschutz, KONZEPT §2). */
|
||||
export function isAutoSubmitted(headers: RawHeaders): boolean {
|
||||
const autoSub = headerFirst(headers, "Auto-Submitted");
|
||||
if (autoSub && autoSub.trim().toLowerCase() !== "no") return true;
|
||||
|
||||
const precedence = headerFirst(headers, "Precedence");
|
||||
if (precedence) {
|
||||
const p = precedence.trim().toLowerCase();
|
||||
if (p === "bulk" || p === "auto_reply" || p === "junk" || p === "list") return true;
|
||||
}
|
||||
|
||||
// Leerer Envelope-From (<>) ist der klassische Bounce.
|
||||
const returnPath = headerFirst(headers, "Return-Path");
|
||||
if (returnPath && returnPath.trim() === "<>") return true;
|
||||
|
||||
if (headerFirst(headers, "X-Autoreply")) return true;
|
||||
if (headerFirst(headers, "X-Autorespond")) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** DKIM/SPF-Ergebnis aus allen Authentication-Results-Headern ableiten. */
|
||||
export function authResults(headers: RawHeaders): { dkim: AuthResult; spf: AuthResult } {
|
||||
const joined = headerValues(headers, "Authentication-Results").join("; ").toLowerCase();
|
||||
const read = (mech: string): AuthResult => {
|
||||
const m = joined.match(new RegExp(`${mech}=(pass|fail|softfail|neutral|none|permerror|temperror)`));
|
||||
if (!m) return "none";
|
||||
if (m[1] === "pass") return "pass";
|
||||
if (m[1] === "fail" || m[1] === "softfail" || m[1] === "permerror") return "fail";
|
||||
return "none";
|
||||
};
|
||||
return { dkim: read("dkim"), spf: read("spf") };
|
||||
}
|
||||
|
||||
/** Message-ID normalisieren (mit/ohne spitze Klammern). */
|
||||
function normalizeMessageId(input: string | null | undefined): string | null {
|
||||
if (!input) return null;
|
||||
const m = input.match(/<([^<>]+)>/);
|
||||
const id = (m ? m[1] : input).trim();
|
||||
return id.length ? id : null;
|
||||
}
|
||||
|
||||
/** Vollständiges Parsing einer eingehenden Mail (rein). */
|
||||
export function parseInbound(input: InboundInput, domain: string = intakeDomain()): ParsedInbound {
|
||||
const headers = input.headers ?? {};
|
||||
const fromRaw = input.from ?? headerFirst(headers, "From") ?? null;
|
||||
const sender = extractEmail(fromRaw);
|
||||
const senderName = extractName(fromRaw);
|
||||
const senderDomain = sender ? sender.slice(sender.lastIndexOf("@") + 1) : null;
|
||||
|
||||
const recipients = deliveryRecipients(headers);
|
||||
let token: string | null = null;
|
||||
let recipientForToken: string | null = null;
|
||||
for (const r of recipients) {
|
||||
const t = tokenFromAddress(r, domain);
|
||||
if (t) {
|
||||
token = t;
|
||||
recipientForToken = r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const { dkim, spf } = authResults(headers);
|
||||
|
||||
return {
|
||||
messageId: normalizeMessageId(input.messageId ?? headerFirst(headers, "Message-ID")),
|
||||
sender,
|
||||
senderName,
|
||||
senderDomain,
|
||||
subject: (input.subject ?? headerFirst(headers, "Subject") ?? "").trim(),
|
||||
text: (input.text ?? "").trim(),
|
||||
recipients,
|
||||
token,
|
||||
recipientForToken,
|
||||
autoSubmitted: isAutoSubmitted(headers),
|
||||
dkim,
|
||||
spf,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* IM-D — Inbound-Verarbeitung mit DB (vom Worker benutzt; NICHT rein).
|
||||
*
|
||||
* Bindet die reine Entscheidung (route.ts) an die Persistenz:
|
||||
* - Token → Mandant via IncidentIntakeConfig (Owner-Client, mandantenübergreifend),
|
||||
* - Dedupe per Message-ID (Incident.inboundMessageId ODER Review.messageId),
|
||||
* - Anlage des Vorfalls (dbForTenant → RLS/Guard) bzw. eines IncidentInboundReview.
|
||||
*
|
||||
* Der IMAP-I/O bleibt im Worker (scripts/incident-inbound-worker.ts); hier gibt es
|
||||
* keine Netz-/IMAP-Abhängigkeit, nur Prisma.
|
||||
*/
|
||||
import { prisma, dbForTenant } from "@/server/db";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
import { nextIncidentRefNo } from "@/server/incident-refno";
|
||||
import { incidentManagerIds, notifyIncidentEvent } from "@/server/mail/incident-notifications";
|
||||
import type { ParsedInbound } from "./parse";
|
||||
import { decideRoute, type IntakeConfigView, type RouteDecision } from "./route";
|
||||
|
||||
/** Konfiguration zum Token laden (mandantenübergreifend, Owner-Client). */
|
||||
export async function resolveIntakeConfig(token: string): Promise<IntakeConfigView | null> {
|
||||
const cfg = await prisma.incidentIntakeConfig.findUnique({
|
||||
where: { token },
|
||||
select: { tenantId: true, token: true, allowlistDomains: true, sourceAddress: true },
|
||||
});
|
||||
return cfg ?? null;
|
||||
}
|
||||
|
||||
/** Wurde diese Message-ID bereits zu einem Vorfall ODER einem Review verarbeitet? */
|
||||
export async function messageAlreadySeen(messageId: string | null): Promise<boolean> {
|
||||
if (!messageId) return false;
|
||||
const [inc, rev] = await Promise.all([
|
||||
prisma.incident.count({ where: { inboundMessageId: messageId } }),
|
||||
prisma.incidentInboundReview.count({ where: { messageId } }),
|
||||
]);
|
||||
return inc > 0 || rev > 0;
|
||||
}
|
||||
|
||||
export interface ProcessResult {
|
||||
decision: RouteDecision;
|
||||
incidentId?: string;
|
||||
refNo?: string;
|
||||
reviewId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine geparste Mail verarbeiten: Konfiguration auflösen, Dedupe prüfen, entscheiden
|
||||
* und das Ergebnis persistieren. Gibt die Entscheidung + ggf. angelegte IDs zurück.
|
||||
*/
|
||||
export async function processInbound(msg: ParsedInbound): Promise<ProcessResult> {
|
||||
const config = msg.token ? await resolveIntakeConfig(msg.token) : null;
|
||||
const alreadySeen = await messageAlreadySeen(msg.messageId);
|
||||
const decision = decideRoute(msg, { config, alreadySeen });
|
||||
|
||||
if (decision.action === "ignore") {
|
||||
return { decision };
|
||||
}
|
||||
|
||||
if (decision.action === "review") {
|
||||
const row = await prisma.incidentInboundReview.create({
|
||||
data: {
|
||||
messageId: decision.review.messageId,
|
||||
sender: decision.review.sender,
|
||||
subject: decision.review.subject,
|
||||
recipient: decision.review.recipient,
|
||||
token: decision.review.token,
|
||||
reason: decision.review.reason,
|
||||
tenantId: decision.review.tenantId,
|
||||
receivedAt: new Date(),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
// Plattform-Audit (tenant-los): Betreiber muss die Review nachvollziehen können.
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
tenantId: decision.review.tenantId,
|
||||
scope: "platform",
|
||||
action: "create",
|
||||
entity: "incident_inbound_review",
|
||||
entityId: row.id,
|
||||
after: { reason: decision.review.reason, sender: decision.review.sender },
|
||||
},
|
||||
});
|
||||
return { decision, reviewId: row.id };
|
||||
}
|
||||
|
||||
// action === "incident"
|
||||
const draft = decision.incident;
|
||||
const refNo = await nextIncidentRefNo(prisma, draft.tenantId);
|
||||
const db = dbForTenant(draft.tenantId);
|
||||
const incident = await db.incident.create({
|
||||
data: {
|
||||
tenantId: draft.tenantId,
|
||||
refNo,
|
||||
title: draft.title,
|
||||
description: draft.description,
|
||||
source: "email",
|
||||
status: "neu",
|
||||
reporterName: draft.reporterName,
|
||||
reporterContact: draft.reporterContact,
|
||||
inboundMessageId: draft.inboundMessageId,
|
||||
},
|
||||
select: { id: true, refNo: true, title: true },
|
||||
});
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId: draft.tenantId,
|
||||
action: "create",
|
||||
entity: "incident",
|
||||
entityId: incident.id,
|
||||
after: { refNo: incident.refNo, source: "email", inbound: true, spfWarning: decision.spfWarning },
|
||||
});
|
||||
|
||||
// Provisionierung (KONZEPT §12a): Die erste erfolgreich zugestellte Inbound-Mail
|
||||
// belegt, dass die Weiterleitung des Kunden funktioniert → Status automatisch von
|
||||
// `weiterleitung_ausstehend` auf `verifiziert` heben (Owner-Client, updateMany ist
|
||||
// idempotent und trifft nur noch offene Konfigurationen). Der Betreiber kann den
|
||||
// Status im Portal zusätzlich manuell setzen.
|
||||
await prisma.incidentIntakeConfig.updateMany({
|
||||
where: { tenantId: draft.tenantId, status: "weiterleitung_ausstehend" },
|
||||
data: { status: "verifiziert", verifiedAt: new Date() },
|
||||
});
|
||||
|
||||
// §7 — neuer Vorfall → ISB/Incident-Manager (best effort; Mailfehler dürfen die
|
||||
// Verarbeitung nicht scheitern lassen — notifyIncidentEvent fängt intern ab).
|
||||
await notifyIncidentEvent({
|
||||
tenantId: draft.tenantId,
|
||||
event: "incident_created",
|
||||
incidentId: incident.id,
|
||||
refNo: incident.refNo,
|
||||
title: incident.title,
|
||||
recipientIds: await incidentManagerIds(draft.tenantId),
|
||||
});
|
||||
|
||||
return { decision, incidentId: incident.id, refNo: incident.refNo };
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* IM-D — Inbound-Routing (REIN, ohne Netz/DB).
|
||||
*
|
||||
* Entscheidet auf Basis der geparsten Mail (parse.ts) + der aufgelösten
|
||||
* Intake-Konfiguration, was mit einer Mail geschieht:
|
||||
* - `ignore` — Auto-Reply/Bounce oder bereits verarbeitet (Dedupe per Message-ID)
|
||||
* - `incident` — Vorfall im richtigen Mandanten anlegen (Status neu, source=email)
|
||||
* - `review` — Betreiber-Review (kein/unbekannter Token oder Allowlist/DKIM-Fehler)
|
||||
*
|
||||
* Vertrauensmodell (KONZEPT §2): Weiterleitung bricht i. d. R. SPF (DKIM bleibt
|
||||
* gültig) → SPF-Fail wird NICHT hart abgelehnt; Vertrauen entsteht aus
|
||||
* Absender-Allowlist + DKIM. SPF ist nur ein Signal (im Review-Grund dokumentiert,
|
||||
* aber nie allein ausschlaggebend).
|
||||
*
|
||||
* Die DB-/Netz-Anbindung (Token→Mandant, Dublettenprüfung, Persistenz) macht der
|
||||
* Worker; hier ist alles rein und mit Fixtures testbar.
|
||||
*/
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { ParsedInbound } from "./parse";
|
||||
|
||||
/** Sicht auf die Intake-Konfiguration, die der Router braucht (vom Worker geladen). */
|
||||
export interface IntakeConfigView {
|
||||
tenantId: string;
|
||||
token: string;
|
||||
allowlistDomains: string[];
|
||||
sourceAddress: string | null;
|
||||
}
|
||||
|
||||
export interface RouteDeps {
|
||||
/** Vom Token aufgelöste Konfiguration (oder null, wenn Token unbekannt). */
|
||||
config: IntakeConfigView | null;
|
||||
/** Wurde diese Message-ID bereits verarbeitet? (Incident ODER Review vorhanden) */
|
||||
alreadySeen: boolean;
|
||||
}
|
||||
|
||||
export type ReviewReason = "no_token" | "unknown_token" | "allowlist_failed" | "dkim_failed";
|
||||
export type IgnoreReason = "auto_submitted" | "duplicate" | "no_message_id";
|
||||
|
||||
export interface IncidentDraft {
|
||||
tenantId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
reporterName: string | null;
|
||||
reporterContact: string | null;
|
||||
inboundMessageId: string | null;
|
||||
}
|
||||
|
||||
export interface ReviewDraft {
|
||||
messageId: string | null;
|
||||
sender: string;
|
||||
subject: string | null;
|
||||
recipient: string | null;
|
||||
token: string | null;
|
||||
reason: ReviewReason;
|
||||
tenantId: string | null;
|
||||
}
|
||||
|
||||
export type RouteDecision =
|
||||
| { action: "ignore"; reason: IgnoreReason }
|
||||
| { action: "incident"; incident: IncidentDraft; spfWarning: boolean }
|
||||
| { action: "review"; review: ReviewDraft };
|
||||
|
||||
const MAX_TITLE = 200;
|
||||
const MAX_DESC = 10000;
|
||||
|
||||
function domainOf(email: string | null): string | null {
|
||||
if (!email) return null;
|
||||
const at = email.lastIndexOf("@");
|
||||
return at >= 0 ? email.slice(at + 1).toLowerCase() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reine Routing-Entscheidung. Reihenfolge ist bewusst:
|
||||
* 1. Auto-Reply/Bounce → ignorieren (Schleifenschutz).
|
||||
* 2. Dedupe per Message-ID → ignorieren (Idempotenz beim erneuten Abholen).
|
||||
* 3. Kein Token → Review (nicht droppen).
|
||||
* 4. Unbekannter Token → Review.
|
||||
* 5. Allowlist-Fehlschlag → Review (mit aufgelöstem Mandanten).
|
||||
* 6. DKIM=fail → Review (DKIM + Allowlist tragen das Vertrauen).
|
||||
* 7. sonst → Vorfall (SPF-Fail wird toleriert, nur als Warnung markiert).
|
||||
*/
|
||||
export function decideRoute(msg: ParsedInbound, deps: RouteDeps): RouteDecision {
|
||||
if (msg.autoSubmitted) return { action: "ignore", reason: "auto_submitted" };
|
||||
if (deps.alreadySeen) return { action: "ignore", reason: "duplicate" };
|
||||
|
||||
const sender = msg.sender ?? "";
|
||||
const baseReview = {
|
||||
messageId: msg.messageId,
|
||||
sender,
|
||||
subject: msg.subject || null,
|
||||
recipient: msg.recipientForToken ?? msg.recipients[0] ?? null,
|
||||
token: msg.token,
|
||||
};
|
||||
|
||||
if (!msg.token) {
|
||||
return { action: "review", review: { ...baseReview, reason: "no_token", tenantId: null } };
|
||||
}
|
||||
if (!deps.config) {
|
||||
return { action: "review", review: { ...baseReview, reason: "unknown_token", tenantId: null } };
|
||||
}
|
||||
|
||||
const cfg = deps.config;
|
||||
const senderDomain = msg.senderDomain ?? domainOf(msg.sender);
|
||||
const allow = cfg.allowlistDomains.map((d) => d.trim().toLowerCase()).filter(Boolean);
|
||||
// Allowlist ist obligatorisch: ohne gepflegte Domäne wird nichts automatisch zum
|
||||
// Ticket (fail-closed) — die Mail geht in die Betreiber-Review.
|
||||
const allowlisted = senderDomain != null && allow.includes(senderDomain);
|
||||
if (!allowlisted) {
|
||||
return {
|
||||
action: "review",
|
||||
review: { ...baseReview, reason: "allowlist_failed", tenantId: cfg.tenantId },
|
||||
};
|
||||
}
|
||||
if (msg.dkim === "fail") {
|
||||
return {
|
||||
action: "review",
|
||||
review: { ...baseReview, reason: "dkim_failed", tenantId: cfg.tenantId },
|
||||
};
|
||||
}
|
||||
|
||||
const title = (msg.subject || "(ohne Betreff)").slice(0, MAX_TITLE);
|
||||
const origin = `Eingang per E-Mail von ${sender || "unbekannt"}` +
|
||||
(baseReview.recipient ? ` an ${baseReview.recipient}` : "");
|
||||
const description = `[${origin}]\n\n${msg.text}`.slice(0, MAX_DESC);
|
||||
|
||||
return {
|
||||
action: "incident",
|
||||
spfWarning: msg.spf === "fail",
|
||||
incident: {
|
||||
tenantId: cfg.tenantId,
|
||||
title,
|
||||
description,
|
||||
reporterName: msg.senderName ?? (sender || null),
|
||||
reporterContact: sender || null,
|
||||
inboundMessageId: msg.messageId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Globaler, nicht erratbarer Intake-Token (Auto-Token, KONZEPT §12a). */
|
||||
export function generateIntakeToken(): string {
|
||||
// 16 Bytes → 32 Hex-Zeichen (a–f, 0–9) → passt auf TOKEN_RE ([a-z0-9]{8,64}).
|
||||
return randomBytes(16).toString("hex");
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* refNo-Generator für Vorfälle: `INC-<JJJJ>-<lfd>` je Mandant/Jahr (Muster Risiko-refNo).
|
||||
*
|
||||
* Ausgelagert (kein "use server"), damit sowohl die Server-Action als auch der
|
||||
* Akzeptanztest (scripts/test-incidents.ts) die reine Logik verwenden können.
|
||||
* `client` ist ein Prisma-artiger Client mit einem `incident.findMany` — im
|
||||
* Betrieb der rohe Owner-`prisma`, im Test ebenso.
|
||||
*/
|
||||
type IncidentFinder = {
|
||||
incident: {
|
||||
findMany: (args: {
|
||||
where: { tenantId: string; refNo: { startsWith: string } };
|
||||
select: { refNo: true };
|
||||
}) => Promise<{ refNo: string }[]>;
|
||||
};
|
||||
};
|
||||
|
||||
export async function nextIncidentRefNo(
|
||||
client: IncidentFinder,
|
||||
tenantId: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<string> {
|
||||
const year = now.getFullYear();
|
||||
const prefix = `INC-${year}-`;
|
||||
const rows = await client.incident.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 (Number.isFinite(n) && n > max) max = n;
|
||||
}
|
||||
return `${prefix}${String(max + 1).padStart(4, "0")}`;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { TenantDb } from "@/server/db";
|
||||
import { isaControlForIso, ISO_TO_ISA } from "@/lib/iso-isa-crosswalk";
|
||||
|
||||
/**
|
||||
* Umsetzungshinweise (C6) für ISO-27001-Anforderungen (B2).
|
||||
*
|
||||
* Der Hinweiskatalog `ImplementationHint` ist nach VDA-ISA-Controls verschlüsselt und wird
|
||||
* NICHT dupliziert: Weil ISO- und VDA-ISA-Anforderungen in der gemeinsamen Bibliothek auf
|
||||
* denselben Abschnitt zeigen, gelten die dortigen Hinweise für beide Normen. Die Auflösung
|
||||
* läuft über den generierten Crosswalk (`src/lib/iso-isa-crosswalk.ts`).
|
||||
*
|
||||
* Für die 33 Anforderungen in ISO-eigenen Abschnitten (Managementsystem-Klauseln, einzelne
|
||||
* Anhang-A-Controls) gibt es kein ISA-Gegenstück und damit keine Hinweise — der Aufruf
|
||||
* liefert dort eine leere Liste, kein Fehler.
|
||||
*/
|
||||
|
||||
export type IsoHint = {
|
||||
reqId: string;
|
||||
control: string;
|
||||
stufe: string;
|
||||
requirement: string;
|
||||
organisational: string;
|
||||
technical: string;
|
||||
evidence: string;
|
||||
resources: string;
|
||||
};
|
||||
|
||||
/** Hinweise zu einer ISO-Anforderung („A.8.5", „9.1"). Leer, wenn kein ISA-Gegenstück existiert. */
|
||||
export async function hintsForIsoControl(db: TenantDb, isoRef: string): Promise<IsoHint[]> {
|
||||
const isa = isaControlForIso(isoRef);
|
||||
if (!isa) return [];
|
||||
return db.implementationHint.findMany({
|
||||
where: { control: isa },
|
||||
orderBy: { reqId: "asc" },
|
||||
select: {
|
||||
reqId: true, control: true, stufe: true, requirement: true,
|
||||
organisational: true, technical: true, evidence: true, resources: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hinweise für mehrere ISO-Anforderungen in einem Zug — für Listen-/Panelansichten.
|
||||
* Rückgabe: ISO-Referenz → Hinweise (nur Einträge mit mindestens einem Hinweis).
|
||||
*/
|
||||
export async function hintsForIsoControls(
|
||||
db: TenantDb,
|
||||
isoRefs: string[],
|
||||
): Promise<Map<string, IsoHint[]>> {
|
||||
const byIsa = new Map<string, string[]>();
|
||||
for (const ref of isoRefs) {
|
||||
const isa = isaControlForIso(ref);
|
||||
if (!isa) continue;
|
||||
byIsa.set(isa, [...(byIsa.get(isa) ?? []), ref]);
|
||||
}
|
||||
if (byIsa.size === 0) return new Map();
|
||||
|
||||
const hints = await db.implementationHint.findMany({
|
||||
where: { control: { in: [...byIsa.keys()] } },
|
||||
orderBy: { reqId: "asc" },
|
||||
select: {
|
||||
reqId: true, control: true, stufe: true, requirement: true,
|
||||
organisational: true, technical: true, evidence: true, resources: true,
|
||||
},
|
||||
});
|
||||
|
||||
const out = new Map<string, IsoHint[]>();
|
||||
for (const h of hints) {
|
||||
for (const isoRef of byIsa.get(h.control) ?? []) {
|
||||
out.set(isoRef, [...(out.get(isoRef) ?? []), h]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Abdeckungsgrad: wie viele ISO-Anforderungen über den Crosswalk Hinweise erreichen können. */
|
||||
export function isoHintCoverage(): { withIsa: number; isoOnly: number; total: number } {
|
||||
const withIsa = Object.keys(ISO_TO_ISA).length;
|
||||
return { withIsa, isoOnly: 120 - withIsa, total: 120 };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
/**
|
||||
* WS5 (Option C) — kurzlebige, signierte Zustände für den Two-Step-Login:
|
||||
* - `mfa_pending`: „Passwort ok, MFA offen" — erlaubt AUSSCHLIESSLICH den MFA-Schritt,
|
||||
* KEINE App-Session (verhindert „halb angemeldet"-Bypass).
|
||||
* - `login_ticket`: „Passwort (+ ggf. MFA) verifiziert" — der login-ticket-Provider
|
||||
* prägt daraus die volle Session.
|
||||
*
|
||||
* Signatur = HMAC-SHA256 mit AUTH_SECRET (assertSecureEnv garantiert Länge/Existenz).
|
||||
* Beide Zustände sind einzweckig (purpose) und kurzlebig (exp). Sie enthalten KEIN
|
||||
* Geheimnis, nur die identityId + optional den gewählten Organisations-Slug.
|
||||
*/
|
||||
|
||||
type Purpose = "mfa_pending" | "login_ticket";
|
||||
export type TicketPayload = { purpose: Purpose; identityId: string; tenant?: string; exp: number };
|
||||
|
||||
function secret(): string {
|
||||
return process.env.AUTH_SECRET ?? "";
|
||||
}
|
||||
|
||||
function sign(payload: TicketPayload): string {
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
const sig = createHmac("sha256", secret()).update(body).digest("base64url");
|
||||
return `${body}.${sig}`;
|
||||
}
|
||||
|
||||
function verify(token: string | undefined | null, purpose: Purpose): TicketPayload | null {
|
||||
if (!token) return null;
|
||||
const dot = token.indexOf(".");
|
||||
if (dot <= 0) return null;
|
||||
const body = token.slice(0, dot);
|
||||
const sig = token.slice(dot + 1);
|
||||
const expected = createHmac("sha256", secret()).update(body).digest("base64url");
|
||||
const a = Buffer.from(sig);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
|
||||
let p: TicketPayload;
|
||||
try {
|
||||
p = JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as TicketPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (p.purpose !== purpose || typeof p.exp !== "number" || p.exp < Date.now() || !p.identityId) return null;
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Cookie-Name des MFA-pending-Zustands (httpOnly, kurzlebig). */
|
||||
export const MFA_PENDING_COOKIE = "mfa_pending";
|
||||
|
||||
export function signMfaPending(identityId: string, tenant?: string, ttlMs = 5 * 60_000): string {
|
||||
return sign({ purpose: "mfa_pending", identityId, tenant, exp: Date.now() + ttlMs });
|
||||
}
|
||||
export function verifyMfaPending(token: string | undefined | null): TicketPayload | null {
|
||||
return verify(token, "mfa_pending");
|
||||
}
|
||||
export function signLoginTicket(identityId: string, tenant?: string, ttlMs = 2 * 60_000): string {
|
||||
return sign({ purpose: "login_ticket", identityId, tenant, exp: Date.now() + ttlMs });
|
||||
}
|
||||
export function verifyLoginTicket(token: string | undefined | null): TicketPayload | null {
|
||||
return verify(token, "login_ticket");
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { z } from "zod";
|
||||
import { BRAND } from "@/lib/brand";
|
||||
|
||||
/**
|
||||
* SEC1 — Mail-Konfiguration aus Env/Secret-Store.
|
||||
*
|
||||
* Grundsatz aus dem Aufgabenpaket (§3): **kein stiller Fehlversand**. Fehlt die
|
||||
* SMTP-Konfiguration, wird der Versand deaktiviert — Jobs bleiben `pending`, es
|
||||
* gibt eine deutliche Warnung im Log, aber keinen Absturz und keinen scheinbar
|
||||
* erfolgreichen Versand.
|
||||
*
|
||||
* Namensgebung: die Variablen `SMTP_HOST/PORT/USER/PASSWORD/FROM` existieren
|
||||
* bereits im Repo (.env.example, docker-compose.coolify.yml) und bleiben führend.
|
||||
* Die im Aufgabenpaket genannten Aliasse `SMTP_PASS`, `MAIL_FROM`,
|
||||
* `MAIL_FROM_NAME`, `MAIL_REPLY_TO`, `APP_BASE_URL` werden zusätzlich akzeptiert,
|
||||
* damit beide Schreibweisen funktionieren.
|
||||
*/
|
||||
|
||||
const schema = z.object({
|
||||
host: z.string().min(1),
|
||||
port: z.coerce.number().int().min(1).max(65535),
|
||||
/** true = implizites TLS (465), false = STARTTLS (587) */
|
||||
secure: z.boolean(),
|
||||
user: z.string().optional(),
|
||||
pass: z.string().optional(),
|
||||
from: z.string().min(3),
|
||||
fromName: z.string().min(1),
|
||||
replyTo: z.string().optional(),
|
||||
baseUrl: z.string().url(),
|
||||
});
|
||||
|
||||
export type MailConfig = z.infer<typeof schema>;
|
||||
|
||||
const env = (...names: string[]): string | undefined => {
|
||||
for (const n of names) {
|
||||
const v = process.env[n];
|
||||
if (v != null && v.trim() !== "") return v.trim();
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
function parseSecure(port: number): boolean {
|
||||
const raw = env("SMTP_SECURE");
|
||||
if (raw != null) return raw.toLowerCase() === "true" || raw === "1";
|
||||
// Ohne explizite Angabe: 465 = implizites TLS, sonst STARTTLS.
|
||||
return port === 465;
|
||||
}
|
||||
|
||||
let cached: { config: MailConfig | null; reason?: string } | null = null;
|
||||
|
||||
/**
|
||||
* Liefert die Mail-Konfiguration oder `null`, wenn sie unvollständig ist.
|
||||
* Das Ergebnis wird gecacht (Env ändert sich zur Laufzeit nicht).
|
||||
*/
|
||||
export function getMailConfig(): { config: MailConfig | null; reason?: string } {
|
||||
if (cached) return cached;
|
||||
|
||||
const host = env("SMTP_HOST");
|
||||
const portRaw = env("SMTP_PORT");
|
||||
const port = portRaw ? Number(portRaw) : undefined;
|
||||
|
||||
const candidate = {
|
||||
host,
|
||||
port,
|
||||
secure: port != null && Number.isFinite(port) ? parseSecure(port) : false,
|
||||
user: env("SMTP_USER"),
|
||||
pass: env("SMTP_PASSWORD", "SMTP_PASS"),
|
||||
from: env("MAIL_FROM", "SMTP_FROM"),
|
||||
fromName: env("MAIL_FROM_NAME") ?? BRAND.name,
|
||||
replyTo: env("MAIL_REPLY_TO"),
|
||||
baseUrl: env("APP_BASE_URL", "AUTH_URL", "NEXTAUTH_URL"),
|
||||
};
|
||||
|
||||
const parsed = schema.safeParse(candidate);
|
||||
if (!parsed.success) {
|
||||
const missing = parsed.error.issues.map((i) => i.path.join(".")).join(", ");
|
||||
cached = {
|
||||
config: null,
|
||||
reason: `Mail-Versand deaktiviert — unvollständige SMTP-Konfiguration (${missing}). Erwartet: SMTP_HOST, SMTP_PORT, SMTP_FROM/MAIL_FROM, APP_BASE_URL/AUTH_URL.`,
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
cached = { config: parsed.data };
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Nur für Tests: gecachte Konfiguration verwerfen. */
|
||||
export function resetMailConfigCache(): void {
|
||||
cached = null;
|
||||
}
|
||||
|
||||
/** Absenderzeile `Certvia <no-reply@certvia.de>`. */
|
||||
export function mailFrom(config: MailConfig): string {
|
||||
return `${config.fromName} <${config.from}>`;
|
||||
}
|
||||
|
||||
/** Baut eine absolute URL auf Basis von APP_BASE_URL (Mails brauchen absolute Links). */
|
||||
export function absoluteUrl(path: string, config?: MailConfig | null): string {
|
||||
const base = (config ?? getMailConfig().config)?.baseUrl ?? "http://localhost:3000";
|
||||
return `${base.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { prisma } from "@/server/db";
|
||||
import { getMailConfig, mailFrom } from "./config";
|
||||
import { getMailProvider } from "./provider-smtp";
|
||||
import { TransientMailError } from "./provider";
|
||||
import { renderTemplate } from "./templates";
|
||||
import type { MailJob } from "./job";
|
||||
|
||||
/**
|
||||
* SEC1 — die eigentliche Zustellung.
|
||||
*
|
||||
* Bewusst getrennt von Queue und Worker, damit derselbe Pfad sowohl vom
|
||||
* BullMQ-Worker als auch vom Inline-Fallback (Betrieb ohne Redis) verwendet wird
|
||||
* — es gibt genau eine Stelle, an der eine Mail rausgeht.
|
||||
*
|
||||
* MailLog-Schreibzugriffe laufen über den rohen `prisma`-Client: der Worker hat
|
||||
* keinen Mandantenkontext (kein Request, keine Session), und Plattform-Zeilen
|
||||
* haben ohnehin `tenantId = null`. Die Mandantenzuordnung steckt bereits in der
|
||||
* beim Einstellen angelegten Zeile — hier wird nur ihr Status fortgeschrieben.
|
||||
*/
|
||||
|
||||
export class MailNotConfiguredError extends Error {
|
||||
constructor(reason: string) {
|
||||
super(reason);
|
||||
this.name = "MailNotConfiguredError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendert und versendet einen Job und schreibt das Ergebnis ins MailLog.
|
||||
* Wirft bei temporären Fehlern `TransientMailError` (→ Retry durch den Worker).
|
||||
*/
|
||||
export async function deliverMail(job: MailJob): Promise<{ messageId: string }> {
|
||||
const { config, reason } = getMailConfig();
|
||||
const provider = getMailProvider();
|
||||
if (!config || !provider) {
|
||||
// Kein stiller Fehlversand: die Zeile bleibt `pending`, der Grund steht dran.
|
||||
await prisma.mailLog.update({
|
||||
where: { id: job.mailLogId },
|
||||
data: { error: reason ?? "SMTP nicht konfiguriert" },
|
||||
});
|
||||
throw new MailNotConfiguredError(reason ?? "SMTP nicht konfiguriert");
|
||||
}
|
||||
|
||||
const rendered = renderTemplate(job.template, job.locale, job.vars);
|
||||
|
||||
try {
|
||||
const { messageId } = await provider.send({
|
||||
from: mailFrom(config),
|
||||
to: job.to,
|
||||
replyTo: config.replyTo,
|
||||
subject: rendered.subject,
|
||||
html: rendered.html,
|
||||
text: rendered.text,
|
||||
// Auto-Antworten und Abwesenheitsnotizen unterdrücken (RFC 3834).
|
||||
headers: { "Auto-Submitted": "auto-generated", "X-Auto-Response-Suppress": "All" },
|
||||
});
|
||||
|
||||
await prisma.mailLog.update({
|
||||
where: { id: job.mailLogId },
|
||||
data: {
|
||||
status: "sent",
|
||||
providerMessageId: messageId,
|
||||
sentAt: new Date(),
|
||||
error: null,
|
||||
attempts: { increment: 1 },
|
||||
},
|
||||
});
|
||||
return { messageId };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const transient = err instanceof TransientMailError;
|
||||
await prisma.mailLog.update({
|
||||
where: { id: job.mailLogId },
|
||||
data: {
|
||||
// Temporär → bleibt `pending`, der Worker versucht es erneut.
|
||||
status: transient ? "pending" : "failed",
|
||||
error: message.slice(0, 500),
|
||||
attempts: { increment: 1 },
|
||||
},
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Endgültiges Scheitern nach Ausschöpfung aller Versuche (Dead-Letter). */
|
||||
export async function markMailFailed(mailLogId: string, error: string): Promise<void> {
|
||||
await prisma.mailLog.update({
|
||||
where: { id: mailLogId },
|
||||
data: { status: "failed", error: error.slice(0, 500) },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { prisma } from "@/server/db";
|
||||
import { absoluteUrl } from "./config";
|
||||
import { enqueueMail } from "./service";
|
||||
import { normalizeLocale, type Locale } from "./templates";
|
||||
import {
|
||||
openReportDeadlines,
|
||||
type DeadlineKind,
|
||||
} from "@/lib/incident-deadlines";
|
||||
|
||||
/**
|
||||
* Vorfall-Benachrichtigungen (Fachkonzept §7, SEC1-Infrastruktur) — IM-B.
|
||||
*
|
||||
* Modelliert nach `notifications.ts` (Aufgaben), gleiche Zusagen:
|
||||
* - **Opt-in per Default** (fehlt eine `NotificationPreference`, wird versendet).
|
||||
* - **Mandantenisolation** — Empfänger immer innerhalb des auslösenden Mandanten.
|
||||
* - **Sprache je Empfänger:** Präferenz → Mandanten-Locale → `de`.
|
||||
* - **Kein Selbstversand** — der Auslöser bekommt keine Mail über die eigene Aktion.
|
||||
* - **Idempotenz** über `dedupeKey`; der Fristen-Job hängt das Datum an.
|
||||
*
|
||||
* „Fire and forget": Fehler werden geloggt, nie an die Fachaktion durchgereicht.
|
||||
*/
|
||||
|
||||
export type IncidentEvent =
|
||||
| "incident_created"
|
||||
| "incident_assigned"
|
||||
| "incident_status_changed"
|
||||
| "incident_report_due"
|
||||
| "incident_closed";
|
||||
|
||||
type Recipient = { id: string; email: string; name: string; locale: Locale };
|
||||
|
||||
async function resolveRecipient(
|
||||
tenantId: string,
|
||||
userId: string | null | undefined,
|
||||
event: IncidentEvent,
|
||||
): Promise<Recipient | null> {
|
||||
if (!userId) return null;
|
||||
|
||||
const [user, preference, settings] = await Promise.all([
|
||||
prisma.user.findFirst({
|
||||
where: { id: userId, tenantId, status: "ACTIVE" },
|
||||
select: { id: true, email: true, name: true },
|
||||
}),
|
||||
prisma.notificationPreference.findUnique({
|
||||
where: { userId_eventType: { userId, eventType: event } },
|
||||
select: { email: true, locale: true },
|
||||
}),
|
||||
prisma.tenantSettings.findUnique({ where: { tenantId }, select: { locale: true } }),
|
||||
]);
|
||||
|
||||
if (!user) return null;
|
||||
if (preference && !preference.email) return null;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
locale: normalizeLocale(preference?.locale ?? settings?.locale),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* IDs der ISB/Incident-Manager eines Mandanten = aktive Nutzer mit dem Recht
|
||||
* `incident:manage` (§1/§7). Streng mandantengebunden.
|
||||
*/
|
||||
export async function incidentManagerIds(tenantId: string): Promise<string[]> {
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
status: "ACTIVE",
|
||||
userRoles: {
|
||||
some: { role: { rolePermissions: { some: { permission: { key: "incident:manage" } } } } },
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return users.map((u) => u.id);
|
||||
}
|
||||
|
||||
type TextBuilder = (title: string, refNo: string, detail?: string) => { subject: string; body: string };
|
||||
|
||||
const TEXTS: Record<IncidentEvent, Record<Locale, TextBuilder>> = {
|
||||
incident_created: {
|
||||
de: (title, refNo) => ({
|
||||
subject: "Neuer Vorfall gemeldet",
|
||||
body: `Ein neuer Vorfall „${title}" (${refNo}) wurde gemeldet und wartet auf Triage.`,
|
||||
}),
|
||||
en: (title, refNo) => ({
|
||||
subject: "New incident reported",
|
||||
body: `A new incident "${title}" (${refNo}) has been reported and awaits triage.`,
|
||||
}),
|
||||
},
|
||||
incident_assigned: {
|
||||
de: (title, refNo) => ({
|
||||
subject: "Vorfall zugewiesen",
|
||||
body: `Ihnen wurde der Vorfall „${title}" (${refNo}) zur Bearbeitung zugewiesen.`,
|
||||
}),
|
||||
en: (title, refNo) => ({
|
||||
subject: "Incident assigned",
|
||||
body: `The incident "${title}" (${refNo}) has been assigned to you.`,
|
||||
}),
|
||||
},
|
||||
incident_status_changed: {
|
||||
de: (title, refNo, detail) => ({
|
||||
subject: "Statusänderung am Vorfall",
|
||||
body: `Der Vorfall „${title}" (${refNo}) hat einen neuen Stand: ${detail ?? "aktualisiert"}.`,
|
||||
}),
|
||||
en: (title, refNo, detail) => ({
|
||||
subject: "Incident status changed",
|
||||
body: `The incident "${title}" (${refNo}) has a new state: ${detail ?? "updated"}.`,
|
||||
}),
|
||||
},
|
||||
incident_report_due: {
|
||||
de: (title, refNo, detail) => ({
|
||||
subject: "Meldefrist für Vorfall",
|
||||
body: `Für den Vorfall „${title}" (${refNo}) ${detail ?? "steht eine Meldefrist an"}.`,
|
||||
}),
|
||||
en: (title, refNo, detail) => ({
|
||||
subject: "Reporting deadline for incident",
|
||||
body: `For the incident "${title}" (${refNo}) ${detail ?? "a reporting deadline is due"}.`,
|
||||
}),
|
||||
},
|
||||
incident_closed: {
|
||||
de: (title, refNo) => ({
|
||||
subject: "Vorfall abgeschlossen",
|
||||
body: `Der Vorfall „${title}" (${refNo}) wurde abgeschlossen.`,
|
||||
}),
|
||||
en: (title, refNo) => ({
|
||||
subject: "Incident closed",
|
||||
body: `The incident "${title}" (${refNo}) has been closed.`,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Versendet eine Vorfall-Benachrichtigung an eine Menge von Empfängern. Der
|
||||
* Auslöser (`actorId`) wird ausgenommen, Empfänger werden dedupliziert und je
|
||||
* Sprache/Präferenz aufgelöst.
|
||||
*/
|
||||
export async function notifyIncidentEvent(input: {
|
||||
tenantId: string;
|
||||
event: IncidentEvent;
|
||||
incidentId: string;
|
||||
refNo: string;
|
||||
title: string;
|
||||
recipientIds: (string | null | undefined)[];
|
||||
actorId?: string | null;
|
||||
detail?: string;
|
||||
/** Ergänzt den Idempotenzschlüssel (der Fristen-Job hängt Frist-Art + Datum an). */
|
||||
dedupeSuffix?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const ids = [...new Set(input.recipientIds.filter((x): x is string => !!x))].filter(
|
||||
(id) => id !== input.actorId,
|
||||
);
|
||||
|
||||
for (const id of ids) {
|
||||
const recipient = await resolveRecipient(input.tenantId, id, input.event);
|
||||
if (!recipient) continue;
|
||||
|
||||
const text = TEXTS[input.event][recipient.locale](input.title, input.refNo, input.detail);
|
||||
await enqueueMail({
|
||||
template: "incident_notification",
|
||||
to: recipient.email,
|
||||
tenantId: input.tenantId,
|
||||
locale: recipient.locale,
|
||||
dedupeKey: `${input.event}:${input.incidentId}:${recipient.id}${input.dedupeSuffix ? `:${input.dedupeSuffix}` : ""}`,
|
||||
vars: {
|
||||
name: recipient.name,
|
||||
subject: text.subject,
|
||||
body: text.body,
|
||||
actionUrl: absoluteUrl(`/incidents?detail=${input.incidentId}`),
|
||||
refNo: input.refNo,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[mail] Vorfall-Benachrichtigung fehlgeschlagen:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const DEADLINE_LABEL: Record<DeadlineKind, Record<Locale, string>> = {
|
||||
erstmeldung: { de: "Die NIS2-Erstmeldung (24 h)", en: "The NIS2 initial report (24 h)" },
|
||||
folgemeldung: { de: "Die NIS2-Folgemeldung (72 h)", en: "The NIS2 follow-up report (72 h)" },
|
||||
abschluss: { de: "Der NIS2-Abschlussbericht (1 Monat)", en: "The NIS2 final report (1 month)" },
|
||||
dsgvo: { de: "Die DSGVO-Meldung (Art. 33, 72 h)", en: "The GDPR notification (Art. 33, 72 h)" },
|
||||
reaction: { de: "Die interne Reaktionsfrist", en: "The internal reaction SLA" },
|
||||
resolution: { de: "Die interne Behebungsfrist", en: "The internal resolution SLA" },
|
||||
};
|
||||
|
||||
function deadlineDetail(kind: DeadlineKind, overdue: boolean, remainingMs: number, locale: Locale): string {
|
||||
const label = DEADLINE_LABEL[kind][locale];
|
||||
if (overdue) {
|
||||
const days = Math.max(0, Math.floor(-remainingMs / 86_400_000));
|
||||
return locale === "en"
|
||||
? `${label} is overdue${days > 0 ? ` by ${days} day(s)` : ""}`
|
||||
: `${label} ist überfällig${days > 0 ? ` (seit ${days} Tag(en))` : ""}`;
|
||||
}
|
||||
const hours = Math.max(0, Math.ceil(remainingMs / 3_600_000));
|
||||
return locale === "en"
|
||||
? `${label} is due within ${hours} h`
|
||||
: `${label} läuft in ${hours} h ab`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fristen-Erinnerung/Eskalation für Meldepflichten (§6/§7). Läuft täglich im
|
||||
* Reminder-Worker (mandantenübergreifend über den rohen Client; die Zuordnung
|
||||
* kommt aus dem Vorfall). Pro Vorfall, Frist-Art und Tag höchstens eine Mail.
|
||||
*
|
||||
* Empfänger: drohend → owner/assignee (bzw. Manager, falls unbesetzt);
|
||||
* überfällig → zusätzlich alle Incident-Manager (Eskalation).
|
||||
*/
|
||||
export async function sendIncidentDeadlineReminders(now: Date = new Date()): Promise<number> {
|
||||
const incidents = await prisma.incident.findMany({
|
||||
where: {
|
||||
status: { not: "abgeschlossen" },
|
||||
reportStatus: { not: "abschluss" },
|
||||
OR: [
|
||||
{ erstmeldungDueAt: { not: null } },
|
||||
{ folgemeldungDueAt: { not: null } },
|
||||
{ abschlussDueAt: { not: null } },
|
||||
{ dsgvoDueAt: { not: null } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
refNo: true,
|
||||
title: true,
|
||||
severity: true,
|
||||
reportStatus: true,
|
||||
ownerId: true,
|
||||
assigneeId: true,
|
||||
createdBy: true,
|
||||
detectedAt: true,
|
||||
reportedAt: true,
|
||||
occurredAt: true,
|
||||
createdAt: true,
|
||||
erstmeldungDueAt: true,
|
||||
folgemeldungDueAt: true,
|
||||
abschlussDueAt: true,
|
||||
dsgvoDueAt: true,
|
||||
},
|
||||
take: 500,
|
||||
});
|
||||
|
||||
const day = now.toISOString().slice(0, 10);
|
||||
let sent = 0;
|
||||
const managerCache = new Map<string, string[]>();
|
||||
|
||||
for (const inc of incidents) {
|
||||
const due = openReportDeadlines(inc, now).filter((d) => d.overdue || d.dueSoon);
|
||||
if (due.length === 0) continue;
|
||||
|
||||
for (const item of due) {
|
||||
// Basis-Empfänger: Bearbeiter/Owner; unbesetzt oder überfällig → Manager.
|
||||
const recipients = new Set<string>();
|
||||
if (inc.ownerId) recipients.add(inc.ownerId);
|
||||
if (inc.assigneeId) recipients.add(inc.assigneeId);
|
||||
if (item.overdue || recipients.size === 0) {
|
||||
if (!managerCache.has(inc.tenantId)) {
|
||||
managerCache.set(inc.tenantId, await incidentManagerIds(inc.tenantId));
|
||||
}
|
||||
managerCache.get(inc.tenantId)!.forEach((id) => recipients.add(id));
|
||||
}
|
||||
if (recipients.size === 0) continue;
|
||||
|
||||
// Sprache je Empfänger wird in notifyIncidentEvent aufgelöst; der Detailtext
|
||||
// wird pro Empfänger benötigt → hier einmal in beiden Sprachen vorbereiten.
|
||||
for (const id of recipients) {
|
||||
const recipient = await resolveRecipient(inc.tenantId, id, "incident_report_due");
|
||||
if (!recipient) continue;
|
||||
const detail = deadlineDetail(item.kind, item.overdue, item.remainingMs, recipient.locale);
|
||||
const text = TEXTS.incident_report_due[recipient.locale](inc.title, inc.refNo, detail);
|
||||
const result = await enqueueMail({
|
||||
template: "incident_notification",
|
||||
to: recipient.email,
|
||||
tenantId: inc.tenantId,
|
||||
locale: recipient.locale,
|
||||
dedupeKey: `incident_report_due:${inc.id}:${recipient.id}:${item.kind}:${day}`,
|
||||
vars: {
|
||||
name: recipient.name,
|
||||
subject: text.subject,
|
||||
body: text.body,
|
||||
actionUrl: absoluteUrl(`/incidents?detail=${inc.id}`),
|
||||
refNo: inc.refNo,
|
||||
},
|
||||
});
|
||||
if (result.status !== "duplicate") sent++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Locale, TemplateKey, TemplateVars } from "./templates";
|
||||
|
||||
/**
|
||||
* SEC1 — Nutzlast eines Mail-Jobs.
|
||||
*
|
||||
* Diskriminierte Union über den Template-Key: der Compiler erzwingt, dass die
|
||||
* Variablen zum Template passen — auch über die Queue-Grenze hinweg, wo sonst
|
||||
* nur noch JSON läge.
|
||||
*
|
||||
* Bewusst NICHT enthalten: Klartext-Tokens. Aufrufer übergeben fertige
|
||||
* `actionUrl`s; die Nutzlast landet in Redis und darf keine Secrets führen, die
|
||||
* über die ohnehin im Link stehende URL hinausgehen.
|
||||
*/
|
||||
export type MailJob = {
|
||||
[K in TemplateKey]: {
|
||||
mailLogId: string;
|
||||
template: K;
|
||||
to: string;
|
||||
locale: Locale;
|
||||
vars: TemplateVars[K];
|
||||
};
|
||||
}[TemplateKey];
|
||||
|
||||
export const MAIL_QUEUE = "mail";
|
||||
export const MAIL_DLQ = "mail-dead-letter";
|
||||
/**
|
||||
* Eigene Queue für zeitgesteuerte Jobs. Bewusst getrennt von `mail`: ein BullMQ-
|
||||
* Worker konsumiert **alle** Jobs seiner Queue unabhängig vom Job-Namen — lägen
|
||||
* beide auf `mail`, könnte der Zustell-Worker den Fristen-Job abgreifen (und
|
||||
* umgekehrt).
|
||||
*/
|
||||
export const SCHEDULER_QUEUE = "mail-scheduler";
|
||||
/** Wiederkehrender Job: fällige/überfällige Aufgaben erinnern. */
|
||||
export const DUE_REMINDER_JOB = "task-due-reminder";
|
||||
@@ -0,0 +1,221 @@
|
||||
import { prisma } from "@/server/db";
|
||||
import { absoluteUrl } from "./config";
|
||||
import { enqueueMail } from "./service";
|
||||
import { formatWhen, normalizeLocale, type Locale } from "./templates";
|
||||
|
||||
/**
|
||||
* SEC1 §6 — Benachrichtigungen aus Aufgaben-Ereignissen.
|
||||
*
|
||||
* Regeln:
|
||||
* - **Opt-in per Default.** Fehlt eine `NotificationPreference`-Zeile, wird
|
||||
* versendet. Erst ein bewusstes `email = false` unterdrückt.
|
||||
* - **Mandantenisolation.** Empfänger wird immer innerhalb des auslösenden
|
||||
* Mandanten aufgelöst; ein Task verweist nie über die Mandantengrenze.
|
||||
* - **Sprache je Empfänger:** Präferenz → Mandanten-Locale → `de`.
|
||||
* - **Kein Selbstversand.** Wer die Aktion auslöst, bekommt keine Mail über
|
||||
* die eigene Handlung.
|
||||
* - **Idempotenz** über `dedupeKey`; der Fristen-Job nutzt zusätzlich das
|
||||
* Datum, damit pro Aufgabe und Tag höchstens eine Erinnerung rausgeht.
|
||||
*
|
||||
* Alle Funktionen sind „fire and forget": Fehler werden geloggt, aber nie an die
|
||||
* auslösende Fachaktion durchgereicht.
|
||||
*/
|
||||
|
||||
export type NotificationEvent =
|
||||
| "task_assigned"
|
||||
| "task_approval_requested"
|
||||
| "task_decided"
|
||||
| "task_due";
|
||||
|
||||
type Recipient = { id: string; email: string; name: string; locale: Locale };
|
||||
|
||||
/**
|
||||
* Löst den Empfänger inklusive Sprache auf und berücksichtigt seine Präferenz.
|
||||
* Gibt `null` zurück, wenn nicht versendet werden soll (kein Konto, inaktiv,
|
||||
* abbestellt).
|
||||
*/
|
||||
async function resolveRecipient(
|
||||
tenantId: string,
|
||||
userId: string | null | undefined,
|
||||
event: NotificationEvent,
|
||||
): Promise<Recipient | null> {
|
||||
if (!userId) return null;
|
||||
|
||||
const [user, preference, settings] = await Promise.all([
|
||||
prisma.user.findFirst({
|
||||
where: { id: userId, tenantId, status: "ACTIVE" },
|
||||
select: { id: true, email: true, name: true },
|
||||
}),
|
||||
prisma.notificationPreference.findUnique({
|
||||
where: { userId_eventType: { userId, eventType: event } },
|
||||
select: { email: true, locale: true },
|
||||
}),
|
||||
prisma.tenantSettings.findUnique({ where: { tenantId }, select: { locale: true } }),
|
||||
]);
|
||||
|
||||
if (!user) return null;
|
||||
// Default opt-in: nur ein ausdrückliches false unterdrückt.
|
||||
if (preference && !preference.email) return null;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
locale: normalizeLocale(preference?.locale ?? settings?.locale),
|
||||
};
|
||||
}
|
||||
|
||||
const TEXTS: Record<
|
||||
NotificationEvent,
|
||||
Record<Locale, (title: string, extra?: string) => { subject: string; body: string }>
|
||||
> = {
|
||||
task_assigned: {
|
||||
de: (title) => ({
|
||||
subject: "Neue Aufgabe für Sie",
|
||||
body: `Ihnen wurde die Aufgabe „${title}" zugewiesen.`,
|
||||
}),
|
||||
en: (title) => ({
|
||||
subject: "A new task for you",
|
||||
body: `The task "${title}" has been assigned to you.`,
|
||||
}),
|
||||
},
|
||||
task_approval_requested: {
|
||||
de: (title) => ({
|
||||
subject: "Freigabe angefragt",
|
||||
body: `Sie wurden um die Freigabe von „${title}" gebeten.`,
|
||||
}),
|
||||
en: (title) => ({
|
||||
subject: "Approval requested",
|
||||
body: `You have been asked to approve "${title}".`,
|
||||
}),
|
||||
},
|
||||
task_decided: {
|
||||
de: (title, extra) => ({
|
||||
subject: "Entscheidung zu Ihrer Freigabe-Anfrage",
|
||||
body: `Zu „${title}" liegt eine Entscheidung vor: ${extra ?? "bearbeitet"}.`,
|
||||
}),
|
||||
en: (title, extra) => ({
|
||||
subject: "Decision on your approval request",
|
||||
body: `A decision has been made on "${title}": ${extra ?? "processed"}.`,
|
||||
}),
|
||||
},
|
||||
task_due: {
|
||||
de: (title, extra) => ({
|
||||
subject: "Aufgabe fällig",
|
||||
body: `Die Aufgabe „${title}" ist ${extra ?? "fällig"}.`,
|
||||
}),
|
||||
en: (title, extra) => ({
|
||||
subject: "Task due",
|
||||
body: `The task "${title}" is ${extra ?? "due"}.`,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Versendet eine Aufgaben-Benachrichtigung. Wird aus den Task-Actions heraus
|
||||
* aufgerufen und darf diese nie scheitern lassen.
|
||||
*/
|
||||
export async function notifyTaskEvent(input: {
|
||||
tenantId: string;
|
||||
event: NotificationEvent;
|
||||
taskId: string;
|
||||
taskTitle: string;
|
||||
taskType: string;
|
||||
recipientId: string | null | undefined;
|
||||
/** Auslöser — bekommt keine Mail über die eigene Handlung. */
|
||||
actorId?: string | null;
|
||||
/** Zusatz, z. B. „freigegeben" / „abgelehnt" oder „seit 3 Tagen überfällig". */
|
||||
detail?: string;
|
||||
/** Überschreibt den Idempotenzschlüssel (Fristen-Job hängt das Datum an). */
|
||||
dedupeSuffix?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
if (input.recipientId && input.actorId && input.recipientId === input.actorId) return;
|
||||
|
||||
const recipient = await resolveRecipient(input.tenantId, input.recipientId, input.event);
|
||||
if (!recipient) return;
|
||||
|
||||
const text = TEXTS[input.event][recipient.locale](input.taskTitle, input.detail);
|
||||
|
||||
await enqueueMail({
|
||||
template: "notification",
|
||||
to: recipient.email,
|
||||
tenantId: input.tenantId,
|
||||
locale: recipient.locale,
|
||||
dedupeKey: `${input.event}:${input.taskId}:${recipient.id}${input.dedupeSuffix ? `:${input.dedupeSuffix}` : ""}`,
|
||||
vars: {
|
||||
name: recipient.name,
|
||||
subject: text.subject,
|
||||
body: text.body,
|
||||
actionUrl: absoluteUrl(`/tasks?detail=${input.taskId}`),
|
||||
taskType: input.taskType,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// Eine misslungene Benachrichtigung darf die Fachaktion nicht kippen.
|
||||
console.error("[mail] Benachrichtigung fehlgeschlagen:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fristen-Erinnerung (SEC1 §6): einmal täglich für offene Aufgaben, die heute
|
||||
* fällig sind oder es bereits waren.
|
||||
*
|
||||
* Doppelversand ist über den `dedupeKey` inklusive Datum ausgeschlossen: pro
|
||||
* Aufgabe, Empfänger und Tag entsteht höchstens eine Mail — auch wenn der Job
|
||||
* (z. B. nach einem Neustart) mehrfach läuft.
|
||||
*
|
||||
* Läuft mandantenübergreifend über den rohen Client, weil es keinen
|
||||
* Request-/Session-Kontext gibt; die Mandantenzuordnung kommt aus der Aufgabe
|
||||
* selbst und wird an jede Mail durchgereicht.
|
||||
*/
|
||||
export async function sendDueReminders(now: Date = new Date()): Promise<number> {
|
||||
const endOfDay = new Date(now);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
const day = now.toISOString().slice(0, 10);
|
||||
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: { status: "OPEN", dueDate: { not: null, lte: endOfDay }, assigneeId: { not: null } },
|
||||
select: { id: true, tenantId: true, title: true, type: true, assigneeId: true, dueDate: true },
|
||||
take: 500,
|
||||
});
|
||||
|
||||
let sent = 0;
|
||||
for (const task of tasks) {
|
||||
const overdueDays = task.dueDate
|
||||
? Math.floor((now.getTime() - task.dueDate.getTime()) / 86_400_000)
|
||||
: 0;
|
||||
const detailDe = overdueDays > 0 ? `seit ${overdueDays} Tag(en) überfällig` : "heute fällig";
|
||||
const detailEn = overdueDays > 0 ? `overdue by ${overdueDays} day(s)` : "due today";
|
||||
|
||||
const recipient = await resolveRecipient(task.tenantId, task.assigneeId, "task_due");
|
||||
if (!recipient) continue;
|
||||
|
||||
const text = TEXTS.task_due[recipient.locale](
|
||||
task.title,
|
||||
recipient.locale === "en" ? detailEn : detailDe,
|
||||
);
|
||||
|
||||
const result = await enqueueMail({
|
||||
template: "notification",
|
||||
to: recipient.email,
|
||||
tenantId: task.tenantId,
|
||||
locale: recipient.locale,
|
||||
dedupeKey: `task_due:${task.id}:${recipient.id}:${day}`,
|
||||
vars: {
|
||||
name: recipient.name,
|
||||
subject: text.subject,
|
||||
body: text.body,
|
||||
actionUrl: absoluteUrl(`/tasks?detail=${task.id}`),
|
||||
taskType: task.type,
|
||||
},
|
||||
});
|
||||
if (result.status !== "duplicate") sent++;
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
/** Zeitstempel für Transaktionsmails (Passwort/MFA/E-Mail-Änderung). */
|
||||
export function nowFor(locale: Locale): string {
|
||||
return formatWhen(new Date(), locale);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import nodemailer, { type Transporter } from "nodemailer";
|
||||
import { getMailConfig, type MailConfig } from "./config";
|
||||
import { TransientMailError, type MailProvider, type OutgoingMail, type SendResult } from "./provider";
|
||||
|
||||
/**
|
||||
* SEC1 — SMTP-Transport auf Basis von nodemailer.
|
||||
*
|
||||
* Verbindungs-Pool: der Transporter wird einmal erzeugt und wiederverwendet.
|
||||
* TLS ist Pflicht — bei Port 465 implizit, sonst `requireTLS` (STARTTLS). Die
|
||||
* Zertifikatsprüfung bleibt aktiv; sie wird nur für `localhost` gelockert, weil
|
||||
* Mailpit/Mailhog in der Entwicklung ein selbstsigniertes Zertifikat verwenden
|
||||
* bzw. gar kein TLS anbieten.
|
||||
*/
|
||||
|
||||
/** SMTP-Antwortcodes 4xx sind temporär (Greylisting, Ratelimit) → Retry sinnvoll. */
|
||||
function isTransient(err: unknown): boolean {
|
||||
const e = err as { responseCode?: number; code?: string } | null;
|
||||
if (!e) return false;
|
||||
if (typeof e.responseCode === "number") return e.responseCode >= 400 && e.responseCode < 500;
|
||||
return (
|
||||
e.code === "ETIMEDOUT" ||
|
||||
e.code === "ECONNRESET" ||
|
||||
e.code === "ECONNECTION" ||
|
||||
e.code === "ESOCKET" ||
|
||||
e.code === "EDNS" ||
|
||||
e.code === "EAI_AGAIN"
|
||||
);
|
||||
}
|
||||
|
||||
function createTransport(config: MailConfig): Transporter {
|
||||
const isLocal = /^(localhost|127\.0\.0\.1|::1|mailpit|mailhog)$/i.test(config.host);
|
||||
return nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
// TLS erzwingen, außer gegen den lokalen Test-SMTP (Mailpit/Mailhog).
|
||||
requireTLS: !config.secure && !isLocal,
|
||||
auth: config.user ? { user: config.user, pass: config.pass ?? "" } : undefined,
|
||||
pool: true,
|
||||
maxConnections: 3,
|
||||
maxMessages: 100,
|
||||
connectionTimeout: 10_000,
|
||||
greetingTimeout: 10_000,
|
||||
socketTimeout: 20_000,
|
||||
tls: { rejectUnauthorized: !isLocal },
|
||||
});
|
||||
}
|
||||
|
||||
export class SmtpMailProvider implements MailProvider {
|
||||
private transporter: Transporter | null = null;
|
||||
|
||||
constructor(private readonly config: MailConfig) {}
|
||||
|
||||
private get transport(): Transporter {
|
||||
if (!this.transporter) this.transporter = createTransport(this.config);
|
||||
return this.transporter;
|
||||
}
|
||||
|
||||
async send(msg: OutgoingMail): Promise<SendResult> {
|
||||
try {
|
||||
const info = await this.transport.sendMail({
|
||||
from: msg.from,
|
||||
to: msg.to,
|
||||
replyTo: msg.replyTo,
|
||||
subject: msg.subject,
|
||||
html: msg.html,
|
||||
text: msg.text,
|
||||
headers: msg.headers,
|
||||
});
|
||||
return { messageId: info.messageId };
|
||||
} catch (err) {
|
||||
if (isTransient(err)) {
|
||||
throw new TransientMailError(
|
||||
err instanceof Error ? err.message : "SMTP-Zustellung temporär fehlgeschlagen",
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.transporter?.close();
|
||||
this.transporter = null;
|
||||
}
|
||||
|
||||
/** Verbindungstest ohne Versand (für den Admin-Testversand hilfreich). */
|
||||
async verify(): Promise<void> {
|
||||
await this.transport.verify();
|
||||
}
|
||||
}
|
||||
|
||||
let singleton: SmtpMailProvider | null = null;
|
||||
|
||||
/**
|
||||
* Der konfigurierte Provider — oder `null`, wenn keine SMTP-Konfiguration
|
||||
* vorliegt. Aufrufer müssen den Null-Fall behandeln (kein stiller Fehlversand).
|
||||
*/
|
||||
export function getMailProvider(): SmtpMailProvider | null {
|
||||
const { config } = getMailConfig();
|
||||
if (!config) return null;
|
||||
if (!singleton) singleton = new SmtpMailProvider(config);
|
||||
return singleton;
|
||||
}
|
||||
|
||||
/** Nur für Tests/Shutdown. */
|
||||
export async function closeMailProvider(): Promise<void> {
|
||||
await singleton?.close();
|
||||
singleton = null;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* SEC1 — Provider-Schnittstelle für den Mailversand.
|
||||
*
|
||||
* Der Rest des Systems kennt nur dieses Interface. Heute steckt SMTP
|
||||
* (nodemailer) dahinter; ein späterer Wechsel auf eine HTTP-API (Postmark,
|
||||
* SES, …) tauscht nur die Implementierung.
|
||||
*/
|
||||
|
||||
export type OutgoingMail = {
|
||||
from: string;
|
||||
to: string;
|
||||
replyTo?: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
text: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type SendResult = { messageId: string };
|
||||
|
||||
export interface MailProvider {
|
||||
send(msg: OutgoingMail): Promise<SendResult>;
|
||||
/** Verbindungen sauber schließen (Worker-Shutdown). */
|
||||
close?(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fehler, der einen erneuten Zustellversuch rechtfertigt (Netz, Timeout, 4xx).
|
||||
* Permanente Fehler (5xx, ungültige Adresse) werfen einen normalen Error und
|
||||
* werden vom Worker nicht wiederholt.
|
||||
*/
|
||||
export class TransientMailError extends Error {
|
||||
constructor(message: string, options?: { cause?: unknown }) {
|
||||
super(message, options);
|
||||
this.name = "TransientMailError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { Queue } from "bullmq";
|
||||
import IORedis, { type Redis } from "ioredis";
|
||||
import { MAIL_DLQ, MAIL_QUEUE, SCHEDULER_QUEUE, type MailJob } from "./job";
|
||||
|
||||
/**
|
||||
* SEC1 — Queue-Anbindung (BullMQ/Redis).
|
||||
*
|
||||
* **Betriebsmodus (Entscheidung, vgl. Aufgabenpaket §7):**
|
||||
* - Ist `REDIS_URL` gesetzt, laufen Mails asynchron über die BullMQ-Queue
|
||||
* `mail`; ein separater Worker-Prozess (`npm run worker:mail`) verarbeitet
|
||||
* sie. Das ist der Produktivmodus (eigener Container in Coolify).
|
||||
* - Ohne `REDIS_URL` gibt es **keine** Queue. Der Aufrufer versendet dann
|
||||
* inline (siehe `service.ts`). Das hält die lokale Entwicklung und die
|
||||
* Demo-Umgebung lauffähig, ohne Redis vorauszusetzen — Retry und
|
||||
* Dead-Letter entfallen in diesem Modus bewusst.
|
||||
*
|
||||
* Der Modus wird beim Start einmal geloggt, damit im Betrieb nie unklar ist,
|
||||
* welcher Pfad aktiv war.
|
||||
*/
|
||||
|
||||
let queue: Queue<MailJob> | null = null;
|
||||
let deadLetter: Queue<{ job: MailJob; error: string }> | null = null;
|
||||
let scheduler: Queue | null = null;
|
||||
let producerConnection: Redis | null = null;
|
||||
let workerConnection: Redis | null = null;
|
||||
let logged = false;
|
||||
|
||||
export function redisUrl(): string | undefined {
|
||||
const v = process.env.REDIS_URL?.trim();
|
||||
return v ? v : undefined;
|
||||
}
|
||||
|
||||
export function isQueueEnabled(): boolean {
|
||||
return redisUrl() != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist die Producer-Verbindung gerade wirklich benutzbar?
|
||||
*
|
||||
* Wird vor dem Einstellen geprüft: steht Redis nicht, versendet der Aufrufer
|
||||
* inline weiter, statt die Mail zu verlieren. Die Prüfung erfolgt bewusst
|
||||
* **vor** dem `add()` — ein Fallback *nach* einem fehlgeschlagenen `add()`
|
||||
* könnte doppelt zustellen, falls der Job doch angekommen war und nur die
|
||||
* Bestätigung verloren ging.
|
||||
*/
|
||||
export function isQueueReady(): boolean {
|
||||
const conn = getProducerConnection();
|
||||
return conn?.status === "ready";
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbindung des **Producers** (App/Server-Actions): bewusst fail-fast.
|
||||
*
|
||||
* `enableOfflineQueue: false` lässt Kommandos sofort scheitern, solange keine
|
||||
* Verbindung steht — sonst würde `queue.add()` in einer Server-Action still
|
||||
* puffern und den Request hängen lassen, wenn Redis nicht erreichbar ist. Der
|
||||
* Aufrufer fängt den Fehler ab und vermerkt ihn im MailLog.
|
||||
*/
|
||||
function getProducerConnection(): Redis | null {
|
||||
const url = redisUrl();
|
||||
if (!url) return null;
|
||||
if (!producerConnection) {
|
||||
producerConnection = new IORedis(url, {
|
||||
maxRetriesPerRequest: 1,
|
||||
enableReadyCheck: false,
|
||||
enableOfflineQueue: false,
|
||||
connectTimeout: 3_000,
|
||||
retryStrategy: (times) => Math.min(times * 500, 5_000),
|
||||
lazyConnect: false,
|
||||
});
|
||||
producerConnection.on("error", (err) => {
|
||||
console.error("[mail] Redis (Producer) nicht erreichbar:", err.message);
|
||||
});
|
||||
}
|
||||
return producerConnection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbindung des **Workers**: robust statt fail-fast.
|
||||
*
|
||||
* BullMQ verlangt hier `maxRetriesPerRequest: null` (unbegrenzt), sonst brechen
|
||||
* die blockierenden Reads ab, mit denen der Worker auf neue Jobs wartet. Ein
|
||||
* kurzer Redis-Ausfall darf den Worker nicht beenden.
|
||||
*/
|
||||
export function getConnection(): Redis | null {
|
||||
const url = redisUrl();
|
||||
if (!url) return null;
|
||||
if (!workerConnection) {
|
||||
workerConnection = new IORedis(url, {
|
||||
maxRetriesPerRequest: null,
|
||||
enableReadyCheck: false,
|
||||
});
|
||||
workerConnection.on("error", (err) => {
|
||||
console.error("[mail] Redis (Worker) Verbindungsfehler:", err.message);
|
||||
});
|
||||
}
|
||||
return workerConnection;
|
||||
}
|
||||
|
||||
export function getMailQueue(): Queue<MailJob> | null {
|
||||
const conn = getProducerConnection();
|
||||
if (!conn) {
|
||||
if (!logged) {
|
||||
console.warn(
|
||||
"[mail] REDIS_URL nicht gesetzt — Mails werden inline versendet (kein Retry, keine Dead-Letter-Queue).",
|
||||
);
|
||||
logged = true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!queue) {
|
||||
queue = new Queue<MailJob>(MAIL_QUEUE, {
|
||||
connection: conn,
|
||||
defaultJobOptions: {
|
||||
attempts: 5,
|
||||
backoff: { type: "exponential", delay: 30_000 },
|
||||
removeOnComplete: { age: 7 * 24 * 3600, count: 1000 },
|
||||
// Fehlgeschlagene behalten wir länger — für die Fehlersuche im Betrieb.
|
||||
removeOnFail: { age: 30 * 24 * 3600 },
|
||||
},
|
||||
});
|
||||
if (!logged) {
|
||||
console.info("[mail] Queue aktiv (BullMQ) — Zustellung asynchron über den Worker.");
|
||||
logged = true;
|
||||
}
|
||||
}
|
||||
return queue;
|
||||
}
|
||||
|
||||
/** Dead-Letter-Queue: Jobs, die alle Versuche ausgeschöpft haben. */
|
||||
export function getDeadLetterQueue(): Queue<{ job: MailJob; error: string }> | null {
|
||||
// Wird nur vom Worker benutzt → robuste Verbindung.
|
||||
const conn = getConnection();
|
||||
if (!conn) return null;
|
||||
if (!deadLetter) {
|
||||
deadLetter = new Queue<{ job: MailJob; error: string }>(MAIL_DLQ, {
|
||||
connection: conn,
|
||||
defaultJobOptions: { removeOnComplete: false, removeOnFail: false },
|
||||
});
|
||||
}
|
||||
return deadLetter;
|
||||
}
|
||||
|
||||
/** Queue für zeitgesteuerte Jobs (Fristen-Erinnerung). */
|
||||
export function getSchedulerQueue(): Queue | null {
|
||||
const conn = getConnection();
|
||||
if (!conn) return null;
|
||||
if (!scheduler) {
|
||||
scheduler = new Queue(SCHEDULER_QUEUE, {
|
||||
connection: conn,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: { type: "exponential", delay: 60_000 },
|
||||
removeOnComplete: { count: 50 },
|
||||
removeOnFail: { count: 50 },
|
||||
},
|
||||
});
|
||||
}
|
||||
return scheduler;
|
||||
}
|
||||
|
||||
/** Verbindungen schließen (Worker-Shutdown, Tests). */
|
||||
export async function closeQueues(): Promise<void> {
|
||||
await queue?.close();
|
||||
await deadLetter?.close();
|
||||
await scheduler?.close();
|
||||
queue = null;
|
||||
deadLetter = null;
|
||||
scheduler = null;
|
||||
producerConnection?.disconnect();
|
||||
producerConnection = null;
|
||||
workerConnection?.disconnect();
|
||||
workerConnection = null;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/server/db";
|
||||
import { deliverMail, MailNotConfiguredError } from "./deliver";
|
||||
import { getMailQueue, isQueueEnabled, isQueueReady } from "./queue";
|
||||
import { normalizeLocale, type Locale, type TemplateKey, type TemplateVars } from "./templates";
|
||||
import type { MailJob } from "./job";
|
||||
|
||||
/**
|
||||
* SEC1 — Einstiegspunkt für alle Mails: `enqueueMail(...)`.
|
||||
*
|
||||
* Ablauf:
|
||||
* 1. `MailLog(pending)` anlegen. Der **unique** `dedupeKey` ist die Sperre
|
||||
* gegen Doppelversand: kollidiert der Insert, wurde die Mail bereits
|
||||
* eingestellt und wir brechen still ab (Idempotenz ohne Read-then-Write-
|
||||
* Rennen zwischen App-Instanzen).
|
||||
* 2. Job in die Queue stellen — oder, wenn keine Queue konfiguriert ist,
|
||||
* inline zustellen (siehe queue.ts zum Betriebsmodus).
|
||||
*
|
||||
* `enqueueMail` wirft nie nach außen: eine fehlgeschlagene Benachrichtigung darf
|
||||
* die auslösende Fachaktion (Aufgabe zuweisen, Passwort setzen) nicht scheitern
|
||||
* lassen. Der Fehler steht im MailLog und im Server-Log.
|
||||
*/
|
||||
|
||||
export type EnqueueInput<K extends TemplateKey = TemplateKey> = {
|
||||
template: K;
|
||||
to: string;
|
||||
vars: TemplateVars[K];
|
||||
/** `null` = Plattform-Mail ohne Mandantenbezug (scope=platform). */
|
||||
tenantId: string | null;
|
||||
locale?: string | null;
|
||||
/**
|
||||
* Idempotenzschlüssel, z. B. `task_assigned:<taskId>:<userId>`. Ohne Schlüssel
|
||||
* ist Mehrfachversand möglich — für Transaktionsmails gewollt (jede Anfrage
|
||||
* erzeugt eine eigene Mail), für Benachrichtigungen gesetzt.
|
||||
*/
|
||||
dedupeKey?: string;
|
||||
};
|
||||
|
||||
export type EnqueueResult =
|
||||
| { status: "queued"; mailLogId: string }
|
||||
| { status: "sent"; mailLogId: string }
|
||||
| { status: "duplicate" }
|
||||
| { status: "not_configured"; mailLogId: string; reason: string }
|
||||
| { status: "error"; mailLogId: string; error: string };
|
||||
|
||||
export async function enqueueMail<K extends TemplateKey>(
|
||||
input: EnqueueInput<K>,
|
||||
): Promise<EnqueueResult> {
|
||||
const locale: Locale = normalizeLocale(input.locale);
|
||||
const to = input.to.trim().toLowerCase();
|
||||
|
||||
let mailLogId: string;
|
||||
try {
|
||||
const row = await prisma.mailLog.create({
|
||||
data: {
|
||||
tenantId: input.tenantId,
|
||||
scope: input.tenantId ? "tenant" : "platform",
|
||||
to,
|
||||
template: input.template,
|
||||
locale,
|
||||
status: "pending",
|
||||
dedupeKey: input.dedupeKey,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
mailLogId = row.id;
|
||||
} catch (err) {
|
||||
// P2002 = Unique-Verletzung auf dedupeKey → bereits eingestellt.
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
|
||||
return { status: "duplicate" };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const job = { mailLogId, template: input.template, to, locale, vars: input.vars } as MailJob;
|
||||
|
||||
// Queue nur nutzen, wenn Redis konfiguriert UND gerade erreichbar ist. Bei
|
||||
// einem Redis-Ausfall fällt der Versand auf den Inline-Pfad zurück, statt die
|
||||
// Mail zu verlieren (degradiert: ohne Retry/DLQ, aber zugestellt).
|
||||
if (isQueueEnabled() && isQueueReady()) {
|
||||
const queue = getMailQueue();
|
||||
if (queue) {
|
||||
try {
|
||||
await queue.add(input.template, job, { jobId: mailLogId });
|
||||
return { status: "queued", mailLogId };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// Bewusst KEIN Inline-Fallback an dieser Stelle: der Job könnte
|
||||
// angekommen sein und nur die Bestätigung verloren gegangen sein —
|
||||
// eine zweite Zustellung wäre dann ein Doppelversand.
|
||||
console.error("[mail] Job konnte nicht eingestellt werden:", message);
|
||||
await prisma.mailLog.update({
|
||||
where: { id: mailLogId },
|
||||
data: { error: message.slice(0, 500) },
|
||||
});
|
||||
return { status: "error", mailLogId, error: message };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inline-Modus (kein Redis konfiguriert oder gerade nicht erreichbar):
|
||||
// direkt zustellen, ohne die Fachaktion zu blockieren.
|
||||
try {
|
||||
await deliverMail(job);
|
||||
return { status: "sent", mailLogId };
|
||||
} catch (err) {
|
||||
if (err instanceof MailNotConfiguredError) {
|
||||
console.warn(`[mail] ${err.message}`);
|
||||
return { status: "not_configured", mailLogId, reason: err.message };
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[mail] Zustellung fehlgeschlagen (${input.template}):`, message);
|
||||
return { status: "error", mailLogId, error: message };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { renderHtmlEmail, renderTextEmail, type EmailContent } from "@/lib/email-brand";
|
||||
import { BRAND } from "@/lib/brand";
|
||||
|
||||
/**
|
||||
* SEC1 — Template-Katalog (de/en, HTML + Text).
|
||||
*
|
||||
* Warum ein eigener Katalog statt next-intl:
|
||||
* Die Mails werden im **Worker** gerendert — außerhalb eines Requests. Die
|
||||
* next-intl-Server-APIs (`getTranslations`) setzen einen Request-Scope voraus
|
||||
* und stehen dort nicht zur Verfügung. Der Katalog hier ist bewusst schlank
|
||||
* und worker-tauglich; die UI-Kataloge in `messages/*.json` bleiben unberührt.
|
||||
*
|
||||
* Layout, Farben und die Dachmarken-Fußzeile kommen aus `src/lib/email-brand.ts`
|
||||
* (Certvia-CD, Inline-Styles, Tabellenlayout für Outlook).
|
||||
*
|
||||
* WICHTIG: Templates erhalten fertige `actionUrl`s. Tokens werden von SEC2/SEC3/
|
||||
* SEC4 erzeugt und tauchen weder im MailLog noch in Logs auf.
|
||||
*/
|
||||
|
||||
export const LOCALES = ["de", "en"] as const;
|
||||
export type Locale = (typeof LOCALES)[number];
|
||||
|
||||
export function normalizeLocale(input?: string | null): Locale {
|
||||
return input === "en" ? "en" : "de";
|
||||
}
|
||||
|
||||
/** Variablen je Template — bewusst eng typisiert, damit Aufrufer nichts vergessen. */
|
||||
export type TemplateVars = {
|
||||
invitation: { name: string; tenantName: string; actionUrl: string; expires: string };
|
||||
password_reset: { name: string; actionUrl: string; expires: string };
|
||||
password_changed: { name: string; when: string; ip?: string };
|
||||
email_change_verify: { name: string; actionUrl: string; expires: string; newEmail: string };
|
||||
email_changed_notice: { name: string; newEmail: string; when: string };
|
||||
mfa_changed: { name: string; change: string; when: string };
|
||||
notification: {
|
||||
name: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
actionUrl?: string;
|
||||
taskType: string;
|
||||
};
|
||||
incident_notification: {
|
||||
name: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
actionUrl?: string;
|
||||
refNo: string;
|
||||
};
|
||||
test: { name: string; when: string };
|
||||
};
|
||||
|
||||
export type TemplateKey = keyof TemplateVars;
|
||||
|
||||
export const TEMPLATE_KEYS = [
|
||||
"invitation",
|
||||
"password_reset",
|
||||
"password_changed",
|
||||
"email_change_verify",
|
||||
"email_changed_notice",
|
||||
"mfa_changed",
|
||||
"notification",
|
||||
"incident_notification",
|
||||
"test",
|
||||
] as const satisfies readonly TemplateKey[];
|
||||
|
||||
/** Abmelde-/Präferenzhinweis — nur für Benachrichtigungen, nie für Transaktionsmails. */
|
||||
const FOOTER_NOTE: Record<Locale, string> = {
|
||||
de: "Sie erhalten diese Benachrichtigung, weil Ihnen eine Aufgabe zugewiesen ist. Die Einstellungen dazu finden Sie in Ihrem Profil.",
|
||||
en: "You are receiving this notification because a task is assigned to you. You can change this in your profile.",
|
||||
};
|
||||
|
||||
/** Fußnote für Vorfall-Benachrichtigungen (§7). */
|
||||
const INCIDENT_FOOTER_NOTE: Record<Locale, string> = {
|
||||
de: "Sie erhalten diese Benachrichtigung, weil Sie am Vorfallmanagement beteiligt sind. Die Einstellungen dazu finden Sie in Ihrem Profil.",
|
||||
en: "You are receiving this notification because you are involved in incident management. You can change this in your profile.",
|
||||
};
|
||||
|
||||
type Builder<K extends TemplateKey> = (vars: TemplateVars[K]) => EmailContent;
|
||||
|
||||
const de: { [K in TemplateKey]: Builder<K> } = {
|
||||
invitation: (v) => ({
|
||||
subject: `Ihr Zugang zu ${BRAND.name}`,
|
||||
heading: `Willkommen bei ${BRAND.name}`,
|
||||
paragraphs: [
|
||||
`Hallo ${v.name},`,
|
||||
`für Sie wurde ein Zugang zu ${BRAND.name} für „${v.tenantName}" eingerichtet. Über den folgenden Link vergeben Sie Ihr Passwort und schließen die Einrichtung ab.`,
|
||||
],
|
||||
action: { label: "Zugang einrichten", url: v.actionUrl },
|
||||
note: `Der Link ist bis ${v.expires} gültig und kann nur einmal verwendet werden.`,
|
||||
}),
|
||||
password_reset: (v) => ({
|
||||
subject: `${BRAND.name}: Passwort zurücksetzen`,
|
||||
heading: "Passwort zurücksetzen",
|
||||
paragraphs: [
|
||||
`Hallo ${v.name},`,
|
||||
"für Ihr Konto wurde ein Zurücksetzen des Passworts angefordert. Über den folgenden Link vergeben Sie ein neues Passwort.",
|
||||
"Haben Sie das nicht angefordert, können Sie diese E-Mail ignorieren — Ihr Passwort bleibt dann unverändert.",
|
||||
],
|
||||
action: { label: "Neues Passwort vergeben", url: v.actionUrl },
|
||||
note: `Der Link ist bis ${v.expires} gültig und kann nur einmal verwendet werden.`,
|
||||
}),
|
||||
password_changed: (v) => ({
|
||||
subject: `${BRAND.name}: Ihr Passwort wurde geändert`,
|
||||
heading: "Passwort geändert",
|
||||
paragraphs: [
|
||||
`Hallo ${v.name},`,
|
||||
`das Passwort Ihres Kontos wurde am ${v.when} geändert${v.ip ? ` (IP ${v.ip})` : ""}.`,
|
||||
"Waren Sie das nicht, wenden Sie sich bitte umgehend an Ihre Administration.",
|
||||
],
|
||||
}),
|
||||
email_change_verify: (v) => ({
|
||||
subject: `${BRAND.name}: Neue E-Mail-Adresse bestätigen`,
|
||||
heading: "E-Mail-Adresse bestätigen",
|
||||
paragraphs: [
|
||||
`Hallo ${v.name},`,
|
||||
`Sie möchten die E-Mail-Adresse Ihres Kontos auf ${v.newEmail} ändern. Bitte bestätigen Sie die neue Adresse über den folgenden Link.`,
|
||||
"Die Änderung wird erst nach dieser Bestätigung wirksam.",
|
||||
],
|
||||
action: { label: "Neue Adresse bestätigen", url: v.actionUrl },
|
||||
note: `Der Link ist bis ${v.expires} gültig und kann nur einmal verwendet werden.`,
|
||||
}),
|
||||
email_changed_notice: (v) => ({
|
||||
subject: `${BRAND.name}: Ihre E-Mail-Adresse wurde geändert`,
|
||||
heading: "E-Mail-Adresse geändert",
|
||||
paragraphs: [
|
||||
`Hallo ${v.name},`,
|
||||
`die E-Mail-Adresse Ihres Kontos wurde am ${v.when} auf ${v.newEmail} geändert. Künftige Anmeldungen erfolgen mit der neuen Adresse.`,
|
||||
"Waren Sie das nicht, wenden Sie sich bitte umgehend an Ihre Administration.",
|
||||
],
|
||||
}),
|
||||
mfa_changed: (v) => ({
|
||||
subject: `${BRAND.name}: Zwei-Faktor-Authentifizierung geändert`,
|
||||
heading: "Zwei-Faktor-Authentifizierung geändert",
|
||||
paragraphs: [
|
||||
`Hallo ${v.name},`,
|
||||
`an der Zwei-Faktor-Authentifizierung Ihres Kontos wurde am ${v.when} eine Änderung vorgenommen: ${v.change}.`,
|
||||
"Waren Sie das nicht, wenden Sie sich bitte umgehend an Ihre Administration.",
|
||||
],
|
||||
}),
|
||||
notification: (v) => ({
|
||||
subject: `${BRAND.name}: ${v.subject}`,
|
||||
heading: v.subject,
|
||||
paragraphs: [`Hallo ${v.name},`, v.body],
|
||||
action: v.actionUrl ? { label: "In Certvia öffnen", url: v.actionUrl } : undefined,
|
||||
footerNote: FOOTER_NOTE.de,
|
||||
}),
|
||||
incident_notification: (v) => ({
|
||||
subject: `${BRAND.name}: ${v.subject} (${v.refNo})`,
|
||||
heading: v.subject,
|
||||
paragraphs: [`Hallo ${v.name},`, v.body],
|
||||
action: v.actionUrl ? { label: "Vorfall öffnen", url: v.actionUrl } : undefined,
|
||||
footerNote: INCIDENT_FOOTER_NOTE.de,
|
||||
}),
|
||||
test: (v) => ({
|
||||
subject: `${BRAND.name}: Test-Mail`,
|
||||
heading: "Test-Mail",
|
||||
paragraphs: [
|
||||
`Hallo ${v.name},`,
|
||||
`diese Nachricht wurde am ${v.when} als Zustelltest aus der ${BRAND.name}-Administration versendet.`,
|
||||
"Erreicht sie Sie, sind SMTP-Konfiguration und Versandweg in Ordnung.",
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
const en: { [K in TemplateKey]: Builder<K> } = {
|
||||
invitation: (v) => ({
|
||||
subject: `Your ${BRAND.name} account`,
|
||||
heading: `Welcome to ${BRAND.name}`,
|
||||
paragraphs: [
|
||||
`Hello ${v.name},`,
|
||||
`an account has been created for you on ${BRAND.name} for "${v.tenantName}". Use the link below to set your password and finish the setup.`,
|
||||
],
|
||||
action: { label: "Set up account", url: v.actionUrl },
|
||||
note: `The link is valid until ${v.expires} and can only be used once.`,
|
||||
}),
|
||||
password_reset: (v) => ({
|
||||
subject: `${BRAND.name}: reset your password`,
|
||||
heading: "Reset your password",
|
||||
paragraphs: [
|
||||
`Hello ${v.name},`,
|
||||
"a password reset was requested for your account. Use the link below to choose a new password.",
|
||||
"If you did not request this, you can ignore this e-mail — your password stays unchanged.",
|
||||
],
|
||||
action: { label: "Choose a new password", url: v.actionUrl },
|
||||
note: `The link is valid until ${v.expires} and can only be used once.`,
|
||||
}),
|
||||
password_changed: (v) => ({
|
||||
subject: `${BRAND.name}: your password was changed`,
|
||||
heading: "Password changed",
|
||||
paragraphs: [
|
||||
`Hello ${v.name},`,
|
||||
`the password of your account was changed on ${v.when}${v.ip ? ` (IP ${v.ip})` : ""}.`,
|
||||
"If this was not you, please contact your administrator immediately.",
|
||||
],
|
||||
}),
|
||||
email_change_verify: (v) => ({
|
||||
subject: `${BRAND.name}: confirm your new e-mail address`,
|
||||
heading: "Confirm your e-mail address",
|
||||
paragraphs: [
|
||||
`Hello ${v.name},`,
|
||||
`you requested to change your account e-mail address to ${v.newEmail}. Please confirm the new address using the link below.`,
|
||||
"The change only takes effect after this confirmation.",
|
||||
],
|
||||
action: { label: "Confirm new address", url: v.actionUrl },
|
||||
note: `The link is valid until ${v.expires} and can only be used once.`,
|
||||
}),
|
||||
email_changed_notice: (v) => ({
|
||||
subject: `${BRAND.name}: your e-mail address was changed`,
|
||||
heading: "E-mail address changed",
|
||||
paragraphs: [
|
||||
`Hello ${v.name},`,
|
||||
`the e-mail address of your account was changed to ${v.newEmail} on ${v.when}. Future sign-ins use the new address.`,
|
||||
"If this was not you, please contact your administrator immediately.",
|
||||
],
|
||||
}),
|
||||
mfa_changed: (v) => ({
|
||||
subject: `${BRAND.name}: two-factor authentication changed`,
|
||||
heading: "Two-factor authentication changed",
|
||||
paragraphs: [
|
||||
`Hello ${v.name},`,
|
||||
`two-factor authentication for your account was changed on ${v.when}: ${v.change}.`,
|
||||
"If this was not you, please contact your administrator immediately.",
|
||||
],
|
||||
}),
|
||||
notification: (v) => ({
|
||||
subject: `${BRAND.name}: ${v.subject}`,
|
||||
heading: v.subject,
|
||||
paragraphs: [`Hello ${v.name},`, v.body],
|
||||
action: v.actionUrl ? { label: `Open in ${BRAND.name}`, url: v.actionUrl } : undefined,
|
||||
footerNote: FOOTER_NOTE.en,
|
||||
}),
|
||||
incident_notification: (v) => ({
|
||||
subject: `${BRAND.name}: ${v.subject} (${v.refNo})`,
|
||||
heading: v.subject,
|
||||
paragraphs: [`Hello ${v.name},`, v.body],
|
||||
action: v.actionUrl ? { label: "Open incident", url: v.actionUrl } : undefined,
|
||||
footerNote: INCIDENT_FOOTER_NOTE.en,
|
||||
}),
|
||||
test: (v) => ({
|
||||
subject: `${BRAND.name}: test message`,
|
||||
heading: "Test message",
|
||||
paragraphs: [
|
||||
`Hello ${v.name},`,
|
||||
`this message was sent on ${v.when} as a delivery test from the ${BRAND.name} administration.`,
|
||||
"If it reaches you, SMTP configuration and delivery path are working.",
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
const CATALOG: Record<Locale, { [K in TemplateKey]: Builder<K> }> = { de, en };
|
||||
|
||||
export type RenderedMail = { subject: string; html: string; text: string };
|
||||
|
||||
/** Rendert ein Template in der gewünschten Sprache zu HTML + Text. */
|
||||
export function renderTemplate<K extends TemplateKey>(
|
||||
template: K,
|
||||
locale: Locale,
|
||||
vars: TemplateVars[K],
|
||||
): RenderedMail {
|
||||
const build = CATALOG[locale][template] as Builder<K>;
|
||||
const content = build(vars);
|
||||
return {
|
||||
subject: content.subject,
|
||||
html: renderHtmlEmail(content),
|
||||
text: renderTextEmail(content),
|
||||
};
|
||||
}
|
||||
|
||||
/** Datum/Zeit für Mail-Texte — bewusst hier, damit Worker und App identisch formatieren. */
|
||||
export function formatWhen(date: Date, locale: Locale): string {
|
||||
return new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
timeZone: "Europe/Berlin",
|
||||
}).format(date);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { UnrecoverableError, Worker, type Job } from "bullmq";
|
||||
import { deliverMail, markMailFailed, MailNotConfiguredError } from "./deliver";
|
||||
import { closeQueues, getConnection, getDeadLetterQueue, getSchedulerQueue } from "./queue";
|
||||
import { DUE_REMINDER_JOB, MAIL_QUEUE, SCHEDULER_QUEUE, type MailJob } from "./job";
|
||||
import { sendDueReminders } from "./notifications";
|
||||
import { sendIncidentDeadlineReminders } from "./incident-notifications";
|
||||
import { closeMailProvider } from "./provider-smtp";
|
||||
|
||||
/**
|
||||
* SEC1 — Worker-Prozess: nimmt Mail-Jobs aus der Queue und stellt sie zu.
|
||||
*
|
||||
* Robustheit:
|
||||
* - **Retry** über die Queue-Defaults (5 Versuche, exponentielles Backoff).
|
||||
* - **Dead-Letter**: nach dem letzten Fehlversuch wandert der Job in die
|
||||
* `mail-dead-letter`-Queue und das MailLog wird auf `failed` gesetzt.
|
||||
* - **Limiter**: höchstens 20 Mails pro 10 Sekunden, Concurrency 5 — schützt
|
||||
* Reputation und Relay vor Lastspitzen.
|
||||
* - **Graceful Shutdown** auf SIGTERM/SIGINT: laufende Jobs werden beendet,
|
||||
* danach werden SMTP-Pool und Redis-Verbindung geschlossen.
|
||||
*
|
||||
* Fehlende SMTP-Konfiguration ist **kein** Retry-Grund: der Job würde beliebig
|
||||
* oft scheitern. Er wird einmal als Fehlschlag vermerkt und verworfen; das
|
||||
* MailLog bleibt `pending` mit Begründung (siehe deliver.ts).
|
||||
*/
|
||||
|
||||
export function startMailWorker(): Worker<MailJob> {
|
||||
const connection = getConnection();
|
||||
if (!connection) {
|
||||
throw new Error("REDIS_URL ist nicht gesetzt — ohne Redis gibt es keinen Worker-Betrieb.");
|
||||
}
|
||||
|
||||
const worker = new Worker<MailJob>(
|
||||
MAIL_QUEUE,
|
||||
async (job: Job<MailJob>) => {
|
||||
try {
|
||||
const { messageId } = await deliverMail(job.data);
|
||||
return { messageId };
|
||||
} catch (err) {
|
||||
if (err instanceof MailNotConfiguredError) {
|
||||
// Nicht wiederholen — die Konfiguration ändert sich nicht durch Warten.
|
||||
// BullMQ bricht die Retry-Kette bei UnrecoverableError sofort ab.
|
||||
throw new UnrecoverableError(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
{
|
||||
connection,
|
||||
concurrency: 5,
|
||||
limiter: { max: 20, duration: 10_000 },
|
||||
},
|
||||
);
|
||||
|
||||
worker.on("failed", async (job, err) => {
|
||||
if (!job) return;
|
||||
const attemptsLeft = (job.opts.attempts ?? 1) - job.attemptsMade;
|
||||
console.error(
|
||||
`[mail] Job ${job.id} fehlgeschlagen (Versuch ${job.attemptsMade}, ${Math.max(0, attemptsLeft)} verbleibend): ${err.message}`,
|
||||
);
|
||||
// UnrecoverableError beendet die Retry-Kette sofort (fehlende Konfiguration).
|
||||
if (attemptsLeft > 0 && !(err instanceof UnrecoverableError)) return;
|
||||
|
||||
// Endgültig: Dead-Letter + MailLog auf failed.
|
||||
await markMailFailed(job.data.mailLogId, err.message).catch(() => {});
|
||||
await getDeadLetterQueue()
|
||||
?.add("dead", { job: job.data, error: err.message })
|
||||
.catch(() => {});
|
||||
console.error(`[mail] ALARM — Job ${job.id} in die Dead-Letter-Queue verschoben.`);
|
||||
});
|
||||
|
||||
worker.on("completed", (job) => {
|
||||
console.info(`[mail] Job ${job.id} zugestellt (${job.data.template} → ${job.data.to}).`);
|
||||
});
|
||||
|
||||
return worker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registriert den täglichen Fristen-Job. `jobId` ist fix, damit mehrfaches
|
||||
* Starten des Workers keine parallelen Zeitpläne erzeugt.
|
||||
*/
|
||||
export async function scheduleDueReminders(): Promise<void> {
|
||||
const queue = getSchedulerQueue();
|
||||
if (!queue) return;
|
||||
// Fester Scheduler-Schlüssel: mehrfaches Starten des Workers erzeugt keine
|
||||
// parallelen Zeitpläne, der Eintrag wird nur aktualisiert.
|
||||
await queue.upsertJobScheduler(
|
||||
DUE_REMINDER_JOB,
|
||||
{ pattern: "0 7 * * *", tz: "Europe/Berlin" },
|
||||
{ name: DUE_REMINDER_JOB },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker der Scheduler-Queue. Eigene Queue, damit der Fristen-Job nicht mit der
|
||||
* Zustell-Concurrency konkurriert und nicht versehentlich vom Mail-Worker
|
||||
* konsumiert wird (ein BullMQ-Worker nimmt alle Jobs seiner Queue).
|
||||
*/
|
||||
export function startReminderWorker(): Worker | null {
|
||||
const connection = getConnection();
|
||||
if (!connection) return null;
|
||||
return new Worker(
|
||||
SCHEDULER_QUEUE,
|
||||
async (job: Job) => {
|
||||
if (job.name !== DUE_REMINDER_JOB) return;
|
||||
const count = await sendDueReminders();
|
||||
console.info(`[mail] Aufgaben-Fristen-Erinnerungen eingestellt: ${count}`);
|
||||
const incidentCount = await sendIncidentDeadlineReminders();
|
||||
console.info(`[mail] Vorfall-Meldefristen-Erinnerungen/Eskalationen eingestellt: ${incidentCount}`);
|
||||
},
|
||||
{ connection, concurrency: 1 },
|
||||
);
|
||||
}
|
||||
|
||||
/** Sauberes Herunterfahren von Worker, SMTP-Pool und Redis. */
|
||||
export async function shutdownWorkers(workers: (Worker | null)[]): Promise<void> {
|
||||
for (const w of workers) await w?.close();
|
||||
await closeMailProvider();
|
||||
await closeQueues();
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user