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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 11:05:39 +02:00
co-authored by Claude Opus 5
commit c8e6f30a27
720 changed files with 140143 additions and 0 deletions
+122
View File
@@ -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);
}
}
+90
View File
@@ -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 };
};
}
+160
View File
@@ -0,0 +1,160 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { requireSession } from "@/server/auth";
import { dbForTenant, prisma } from "@/server/db";
import { hashPassword, verifyPassword } from "@/server/password";
import { resolvePasswordPolicy, validatePassword } from "@/lib/password-policy";
import { verifyTotp, generateRecoveryCodes } from "@/server/mfa";
import { writeAuditLog } from "@/server/audit";
import { resolveMfaRequired } from "@/lib/mfa-policy";
/**
* Self-Service des angemeldeten Mandanten-Nutzers (EXEMPT vom Modul-Gating —
* keine Fachfunktion, eigene Auth über requireSession).
*/
/**
* Persönliche UI-Sprache der angemeldeten Person setzen (Option C: `Identity.uiLocale`).
* Folgt der Identity über alle Mandanten; getrennt von der Vorlagen-Import-Sprache
* (`TenantSettings.locale`). EXEMPT vom Modul-Gating (reine Selbstpräferenz).
*/
export async function setUiLocale(locale: "de" | "en") {
const session = await requireSession();
const identityId = session.user.identityId;
if (!identityId) return;
const value = locale === "en" ? "en" : "de";
await prisma.identity.update({ where: { id: identityId }, data: { uiLocale: value } });
// Die UI-Sprache wirkt auf jede Seite → das komplette Layout neu rendern.
revalidatePath("/", "layout");
}
export type ChangePwState = { status: "idle" } | { status: "error"; message: string };
/**
* Erzwungener Passwortwechsel beim ersten Login (mustChangePassword). Prüft die
* Passwort-Policy des Mandanten, setzt neuen Argon2id-Hash und löscht das Flag.
*/
export async function changeOwnPassword(_prev: ChangePwState, formData: FormData): Promise<ChangePwState> {
const session = await requireSession();
const db = dbForTenant(session.user.tenantId);
const identityId = session.user.identityId;
if (!identityId) return { status: "error", message: "Kein Identity-Kontext in der Session." };
// Option C (WS4): Passwort/MFA/mustChangePassword gehören der GLOBALEN Identity.
// Autoritativ aus der DB laden (F-08): mustChangePassword steuert die Re-Auth-
// Ausnahme, der aktuelle Hash wird serverseitig geprüft.
const identity = await prisma.identity.findUnique({ where: { id: identityId } });
if (!identity) return { status: "error", message: "Konto nicht gefunden." };
const pw = String(formData.get("password") ?? "");
const confirm = String(formData.get("confirm") ?? "");
if (pw !== confirm) return { status: "error", message: "Die beiden Passwörter stimmen nicht überein." };
// Re-Authentifizierung bei kritischer Kontoänderung (F-08): außerhalb des
// erzwungenen Erstwechsels ist das aktuelle Passwort Pflicht; bei aktiver MFA
// zusätzlich ein gültiger TOTP-Code. Ausnahme NUR beim erzwungenen Erstwechsel.
let totpStep: number | null = null;
if (!identity.mustChangePassword) {
const currentPw = String(formData.get("currentPassword") ?? "");
if (!currentPw || !(await verifyPassword(identity.passwordHash, currentPw))) {
return { status: "error", message: "Das aktuelle Passwort ist nicht korrekt." };
}
if (identity.mfaEnrolledAt && identity.mfaSecret) {
const code = String(formData.get("token") ?? "");
// Replay-Schutz (F-17): bereits verwendeter/älterer Zeitschritt wird abgelehnt.
const totp = verifyTotp(code, identity.mfaSecret, identity.lastTotpStep);
if (!totp.ok) {
return { status: "error", message: "Der MFA-Code ist ungültig. Bitte den aktuellen 6-stelligen Code eingeben." };
}
totpStep = totp.step;
}
}
const settings = await db.tenantSettings.findUnique({ where: { tenantId: session.user.tenantId } });
const policy = resolvePasswordPolicy(settings?.securityPolicy);
const missing = validatePassword(pw, policy);
if (missing.length) return { status: "error", message: `Das Passwort benötigt ${missing.join(", ")}.` };
await prisma.identity.update({
where: { id: identityId },
data: { passwordHash: await hashPassword(pw), mustChangePassword: false, ...(totpStep != null ? { lastTotpStep: BigInt(totpStep) } : {}) },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "user_password", entityId: session.user.id });
redirect("/dashboard");
}
/* ── Optionale MFA je Mandanten-Nutzer (Paket C) ── */
export type MfaEnrollState =
| { status: "idle" }
| { status: "error"; message: string }
| { status: "done"; recoveryCodes: string[] };
/** Bestätigt die eigene MFA-Einrichtung (TOTP-Code gegen das gespeicherte Secret). */
export async function confirmOwnMfaEnrollment(_prev: MfaEnrollState, formData: FormData): Promise<MfaEnrollState> {
const session = await requireSession();
const identityId = session.user.identityId;
if (!identityId) return { status: "error", message: "Kein Identity-Kontext in der Session." };
// Option C (WS4): MFA gehört der GLOBALEN Identity.
const identity = await prisma.identity.findUnique({ where: { id: identityId } });
if (!identity) return { status: "error", message: "Konto nicht gefunden." };
if (identity.mfaEnrolledAt) return { status: "error", message: "MFA ist bereits aktiv." };
if (!identity.mfaSecret) return { status: "error", message: "Kein Einrichtungs-Secret vorhanden. Bitte Seite neu laden." };
const code = String(formData.get("token") ?? "");
const totp = verifyTotp(code, identity.mfaSecret);
if (!totp.ok) return { status: "error", message: "Code ungültig. Bitte den aktuellen 6-stelligen Code eingeben." };
// F-17: den bei der Einrichtung akzeptierten Zeitschritt gleich als lastTotpStep
// festhalten, damit der Enrollment-Code nicht anschließend beim Login erneut gilt.
const { plain, hashed } = await generateRecoveryCodes(10);
await prisma.identity.update({ where: { id: identityId }, data: { mfaEnrolledAt: new Date(), recoveryCodes: hashed, lastTotpStep: BigInt(totp.step) } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "user_mfa", entityId: session.user.id, after: { mfaEnrolled: true } });
return { status: "done", recoveryCodes: plain };
}
/** Eigene MFA deaktivieren (nicht möglich, solange der Mandant MFA verlangt). */
export async function disableOwnMfa(formData: FormData) {
const session = await requireSession();
const db = dbForTenant(session.user.tenantId);
const settings = await db.tenantSettings.findUnique({ where: { tenantId: session.user.tenantId } });
if (resolveMfaRequired(settings?.securityPolicy)) throw new Error("MFA ist in diesem Mandanten verpflichtend und kann nicht deaktiviert werden.");
// Re-Authentifizierung (F-08): das Abschalten des zweiten Faktors darf nur mit
// gültigem TOTP-Code möglich sein. Prüfung gegen das Secret der GLOBALEN Identity.
const identityId = session.user.identityId;
if (!identityId) throw new Error("Kein Identity-Kontext in der Session.");
const identity = await prisma.identity.findUnique({ where: { id: identityId } });
if (!identity?.mfaEnrolledAt || !identity.mfaSecret) throw new Error("MFA ist nicht aktiv.");
const code = String(formData.get("token") ?? "");
// Replay-Schutz (F-17): ein bereits verwendeter Code kann MFA nicht deaktivieren.
if (!verifyTotp(code, identity.mfaSecret, identity.lastTotpStep).ok) throw new Error("Der MFA-Code ist ungültig. MFA wurde nicht deaktiviert.");
await prisma.identity.update({ where: { id: identityId }, data: { mfaSecret: null, mfaEnrolledAt: null, recoveryCodes: [], lastTotpStep: null } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "user_mfa", entityId: session.user.id, after: { mfaDisabled: true } });
revalidatePath("/account");
}
/**
* SEC3-c: Recovery-Codes neu erzeugen (Step-up per aktuellem TOTP-Code, F-08). Die alten
* Codes werden dabei entwertet; die neuen werden — wie bei der Ersteinrichtung — nur
* einmal im Klartext zurückgegeben und gehasht gespeichert.
*/
export async function regenerateOwnRecoveryCodes(_prev: MfaEnrollState, formData: FormData): Promise<MfaEnrollState> {
const session = await requireSession();
const identityId = session.user.identityId;
if (!identityId) return { status: "error", message: "Kein Identity-Kontext in der Session." };
// Option C (WS4): Recovery-Codes gehören der GLOBALEN Identity.
const identity = await prisma.identity.findUnique({ where: { id: identityId } });
if (!identity?.mfaEnrolledAt || !identity.mfaSecret) return { status: "error", message: "MFA ist nicht aktiv." };
const code = String(formData.get("token") ?? "");
if (!verifyTotp(code, identity.mfaSecret, identity.lastTotpStep).ok) return { status: "error", message: "Der MFA-Code ist ungültig. Recovery-Codes wurden nicht erneuert." };
const { plain, hashed } = await generateRecoveryCodes();
await prisma.identity.update({ where: { id: identityId }, data: { recoveryCodes: hashed } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "user_mfa", entityId: session.user.id, after: { recoveryCodesRegenerated: true } });
revalidatePath("/account");
return { status: "done", recoveryCodes: plain };
}
+272
View File
@@ -0,0 +1,272 @@
"use server";
import { join } from "node:path";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import type { Framework } from "@prisma/client";
import { requirePlatformFullAdmin } from "@/server/platform-auth";
import { prisma, dbForTenant } from "@/server/db";
import { provisionTenant } from "@/server/provision";
import { reconcilePackage, stampPackageVersion } from "../../../prisma/import-policies";
import { resolvePackageForTenant, getTenantFrameworks } from "../../../prisma/template-store";
import { deriveDomainsCore } from "@/server/policies/derive-domains";
import { protectionFlags } from "@/server/assessment-level";
import { DEFAULT_PASSWORD_POLICY, validatePassword } from "@/lib/password-policy";
const POLICY_SEED_DIR = join(process.cwd(), "seed", "isms-vorlagenpaket-v2");
/**
* Nur Plattform-**Voll**-Admins (SEC4: Read-only-Admins dürfen Mandanten nicht verändern).
* Getrennte Plattform-Session (kein Mandantenkontext).
*/
async function requirePlatformAdmin() {
const { session } = await requirePlatformFullAdmin();
return session;
}
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
const slugify = (s: string) =>
s.toLowerCase().normalize("NFKD").replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "kunde";
export async function createTenant(formData: FormData) {
const session = await requirePlatformAdmin();
const name = z.string().trim().min(2).parse(formData.get("name"));
const slug = str(formData.get("slug")) || slugify(name);
const adminEmail = z.string().trim().email().parse(formData.get("adminEmail"));
const adminName = str(formData.get("adminName")) || "Administrator";
// F-20: Initialpasswort gegen die vorhandene Policy prüfen (konsistent zu
// tenant-users.ts/platform-users.ts) statt nur min(8). Der Mandant existiert
// noch nicht, daher der Plattform-Default DEFAULT_PASSWORD_POLICY.
const adminPassword = z.string().parse(formData.get("adminPassword"));
const pwMissing = validatePassword(adminPassword, DEFAULT_PASSWORD_POLICY);
if (pwMissing.length) throw new Error(`Das Initialpasswort benötigt ${pwMissing.join(", ")}.`);
const tisaxLevel = str(formData.get("tisaxLevel")) === "AL3" ? "AL3" : "AL2";
const seedPolicies = formData.get("seedPolicies") === "on";
// AP2: geführte Rahmenwerke aus den Checkboxen (Reihenfolge = Primär zuerst; TISAX
// vor ISO). Nichts gewählt → TISAX (rückwärtskompatibler Default).
const frameworks: Framework[] = [];
if (formData.get("fw_tisax") === "on") frameworks.push("TISAX");
if (formData.get("fw_iso") === "on") frameworks.push("ISO_27001");
if (frameworks.length === 0) frameworks.push("TISAX");
const existing = await prisma.tenant.findUnique({ where: { slug } });
if (existing) throw new Error(`Kürzel „${slug}" ist bereits vergeben.`);
const tenant = await provisionTenant(prisma, {
name,
slug,
short: str(formData.get("short")) || undefined,
sector: str(formData.get("sector")) || undefined,
admin: { email: adminEmail, name: adminName, password: adminPassword },
tisaxLevel,
frameworks,
seedPoliciesDir: seedPolicies ? join(process.cwd(), "seed", "isms-vorlagenpaket-v2") : undefined,
actorId: session.user.id,
});
revalidatePath("/admin");
redirect(`/admin?created=${tenant.id}`);
}
export async function setTenantStatus(tenantId: string, status: "ACTIVE" | "SUSPENDED" | "ARCHIVED") {
const session = await requirePlatformAdmin();
await prisma.tenant.update({ where: { id: tenantId }, data: { status } });
await prisma.auditLog.create({
data: { tenantId, scope: "platform", actorId: session.user.id, action: "update", entity: "tenant", entityId: tenantId, after: { status } },
});
revalidatePath("/admin");
revalidatePath(`/admin/${tenantId}`);
}
/**
* Kern-Einstellung (nur Superadmin): Assessment-Level (AL2/AL3) des Mandanten —
* einzige Quelle des Schutzbedarfs (Story A2-1). Spiegelt die daraus abgeleiteten
* zentralen Schutzbedarf-Flags ins Richtlinienmodul (`protectionFlags`).
*/
export async function setTenantTisaxLevel(tenantId: string, level: "AL2" | "AL3") {
const session = await requirePlatformAdmin();
const db = dbForTenant(tenantId);
await db.tenantSettings.update({ where: { tenantId }, data: { tisaxLevel: level } });
const flags = protectionFlags(level); // einzige AL→Flag-Ableitung
await db.policyVariable.updateMany({ where: { key: "FLAG_HIGH_PROTECTION" }, data: { value: String(flags.FLAG_HIGH_PROTECTION) } });
await db.policyVariable.updateMany({ where: { key: "FLAG_VERY_HIGH_PROTECTION" }, data: { value: String(flags.FLAG_VERY_HIGH_PROTECTION) } });
await prisma.auditLog.create({
data: { tenantId, scope: "platform", actorId: session.user.id, action: "update", entity: "assessment_level", entityId: tenantId, after: { level } },
});
revalidatePath(`/admin/${tenantId}`);
}
/**
* Sprachwahl je Mandant (de|en). Steuert, in welcher Sprache das Vorlagenpaket importiert
* wird (resolvePackageForTenant). Wirkt auf künftige Importe/Updates; bereits importierte
* Richtlinien bleiben unverändert, bis der Mandant unter /policies/updates übernimmt.
*/
export async function setTenantLocale(tenantId: string, locale: string) {
const session = await requirePlatformAdmin();
const loc = locale === "en" ? "en" : "de";
await dbForTenant(tenantId).tenantSettings.update({ where: { tenantId }, data: { locale: loc } });
await prisma.auditLog.create({
data: { tenantId, scope: "platform", actorId: session.user.id, action: "update", entity: "tenant_locale", entityId: tenantId, after: { locale: loc } },
});
revalidatePath(`/admin/${tenantId}`);
}
/**
* SEC3-a: MFA-Pflicht je Mandant setzen (Superadmin). Wird in `securityPolicy.mfaRequired`
* abgelegt; das (app)-Layout erzwingt daraufhin die MFA-Einrichtung beim Login. Bestehende
* securityPolicy-Felder (z. B. Passwort-Policy) bleiben erhalten.
*/
export async function setTenantMfaRequired(tenantId: string, required: boolean) {
const session = await requirePlatformAdmin();
const db = dbForTenant(tenantId);
const current = await db.tenantSettings.findFirst({ select: { securityPolicy: true } });
const sp = (current?.securityPolicy && typeof current.securityPolicy === "object" ? current.securityPolicy : {}) as Record<string, unknown>;
await db.tenantSettings.update({ where: { tenantId }, data: { securityPolicy: { ...sp, mfaRequired: required } } });
await prisma.auditLog.create({
data: { tenantId, scope: "platform", actorId: session.user.id, action: "update", entity: "tenant_mfa_policy", entityId: tenantId, after: { mfaRequired: required } },
});
revalidatePath(`/admin/${tenantId}`);
}
export async function toggleTenantModule(tenantId: string, moduleKey: string, enabled: boolean) {
const session = await requirePlatformAdmin();
await prisma.tenantModule.upsert({
where: { tenantId_moduleKey: { tenantId, moduleKey } },
update: { enabled },
create: { tenantId, moduleKey, enabled },
});
await prisma.auditLog.create({
data: { tenantId, scope: "platform", actorId: session.user.id, action: "update", entity: "tenant_module", entityId: moduleKey, after: { enabled } },
});
// Story B4-1: Aktivierung des Richtlinien-Moduls importiert das Vorlagenpaket
// mandantenweit nicht-destruktiv (idempotent; erhält Status/Freigabe/Overrides/Variablen).
if (moduleKey === "policies" && enabled) {
// AP1: je Framework des Mandanten importieren (Anforderungen framework-scoped,
// geteilte Inhalte nur beim ersten). Einzel-Framework = unverändert.
const frameworks = await getTenantFrameworks(prisma, tenantId);
for (const [i, framework] of frameworks.entries()) {
const { pkg } = await resolvePackageForTenant(prisma, tenantId, POLICY_SEED_DIR, framework);
await reconcilePackage(prisma, tenantId, pkg, { actorId: session.user.id, framework, reconcileShared: i === 0 });
await stampPackageVersion(prisma, tenantId, pkg.version, framework); // Story B6
}
revalidatePath("/policies", "layout");
}
revalidatePath(`/admin/${tenantId}`);
// Die Mandanten-Navigation (src/app/(app)/layout.tsx) rendert die Modul-Links pro
// Seitenaufruf server-seitig aus `tenantModule`. Ohne Revalidierung der (app)-Routen
// bliebe der Server-Cache stehen → ein deaktiviertes Modul verschwände erst verzögert
// aus der Sidebar. `layout` invalidiert die gesamte Mandanten-Shell.
revalidatePath("/", "layout");
}
/**
* Frameworks eines bestehenden Mandanten setzen (ISO 27001 und/oder TISAX).
*
* Bisher wurde die Framework-Zugehörigkeit ausschließlich beim Anlegen vergeben
* (`provisionTenant`); ein Bestandsmandant liess sich weder auf ISO umstellen noch ein
* Framework wieder abschalten. Diese Aktion schliesst beides.
*
* Ablauf:
* 1. Zugehörigkeit setzen — erstes Element der Liste ist das Primär-Framework.
* 2. Neu hinzugekommenes Framework: Vorlagenpaket importieren. Der geteilte
* Dokumentensatz wird dabei einmal mit abgeglichen, damit Anforderungen des neuen
* Frameworks nicht auf Dokumente zeigen, die der Mandant noch nicht hat (z. B. VA-21/22).
* Der Abgleich ist nicht-destruktiv: Status, Freigabe, Overrides und gepflegte
* Variablenwerte bleiben erhalten.
* 3. Entferntes Framework: die Zugehörigkeit fällt weg, die **Bewertungen bleiben**.
* Anforderungen werden über `archivedAt` stillgelegt statt gelöscht — der Nachweis,
* dass in der Vergangenheit geprüft wurde, gehört zum Audit-Trail. Ein späteres
* Wiedereinschalten reaktiviert sie über den Re-Import.
* `SoaEntry` bleibt unangetastet: die SoA-Sicht blendet sich ohne aktives
* ISO-Framework ohnehin aus, und so überlebt jede Begründung eine Pause unverändert.
* 4. Sichtbarkeits-Flags nachziehen — sonst führte der Mandant die Norm im Datenmodell,
* sähe in den Dokumenten aber weiter die Anforderungssicht des anderen Frameworks.
*
* Mindestens ein Framework muss aktiv bleiben; ohne Rahmenwerk hätte der Mandant kein
* Regelwerk mehr.
*/
export async function setTenantFrameworks(tenantId: string, frameworks: Framework[]) {
const session = await requirePlatformAdmin();
const wanted = [...new Set(frameworks)];
if (wanted.length === 0) throw new Error("Mindestens ein Framework muss aktiv bleiben.");
const current = await getTenantFrameworks(prisma, tenantId);
const added = wanted.filter((f) => !current.includes(f));
const removed = current.filter((f) => !wanted.includes(f));
const db = dbForTenant(tenantId);
// 1. Zugehörigkeit (erstes = primär)
for (const [i, framework] of wanted.entries()) {
await prisma.tenantFramework.upsert({
where: { tenantId_framework: { tenantId, framework } },
update: { isPrimary: i === 0 },
create: { tenantId, framework, isPrimary: i === 0 },
});
}
// 2. Hinzugekommene Frameworks bestücken
for (const [i, framework] of added.entries()) {
const { pkg } = await resolvePackageForTenant(prisma, tenantId, POLICY_SEED_DIR, framework);
await reconcilePackage(prisma, tenantId, pkg, {
actorId: session.user.id,
framework,
// Geteilte Inhalte einmal mitnehmen: der Dokumentensatz kann älter sein als das Paket.
reconcileShared: i === 0,
});
await stampPackageVersion(prisma, tenantId, pkg.version, framework);
}
// 3. Entfernte Frameworks abmelden — stilllegen, nicht löschen
if (removed.length > 0) {
await prisma.tenantFramework.deleteMany({ where: { tenantId, framework: { in: removed } } });
await db.policyRequirement.updateMany({
where: { framework: { in: removed }, archivedAt: null },
data: { archivedAt: new Date() },
});
}
// 4. Sichtbarkeits-Flags im Richtlinienmodul nachziehen
await db.policyVariable.updateMany({
where: { key: "FLAG_FW_TISAX" },
data: { value: String(wanted.includes("TISAX")) },
});
await db.policyVariable.updateMany({
where: { key: "FLAG_FW_ISO27001" },
data: { value: String(wanted.includes("ISO_27001")) },
});
if (added.length > 0) await deriveDomainsCore(db, tenantId);
await prisma.auditLog.create({
data: {
tenantId, scope: "platform", actorId: session.user.id, action: "update",
entity: "tenant_framework", entityId: tenantId,
before: { frameworks: current }, after: { frameworks: wanted, added, removed },
},
});
revalidatePath(`/admin/${tenantId}`);
revalidatePath("/policies", "layout");
revalidatePath("/", "layout");
}
/**
* Story B4-1: Plattform-Admin stößt den Vorlagen-Import/-Aktualisierung für einen
* Mandanten manuell an (Button im Admin). Nicht-destruktiv (siehe importPolicies).
*/
export async function importPolicyPackageForTenant(tenantId: string) {
const session = await requirePlatformAdmin();
// AP1: je Framework des Mandanten (framework-scoped; geteilte Inhalte nur beim ersten).
const frameworks = await getTenantFrameworks(prisma, tenantId);
for (const [i, framework] of frameworks.entries()) {
const { pkg } = await resolvePackageForTenant(prisma, tenantId, POLICY_SEED_DIR, framework);
await reconcilePackage(prisma, tenantId, pkg, { actorId: session.user.id, framework, reconcileShared: i === 0 });
await stampPackageVersion(prisma, tenantId, pkg.version, framework); // Story B6
}
await deriveDomainsCore(dbForTenant(tenantId), tenantId); // Fachbereiche ableiten
await prisma.auditLog.create({
data: { tenantId, scope: "platform", actorId: session.user.id, action: "update", entity: "policy_package", after: { manualImport: true } },
});
revalidatePath(`/admin/${tenantId}`);
revalidatePath("/policies", "layout");
}
+163
View File
@@ -0,0 +1,163 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
const guard = moduleGuard("assets");
const level = z.coerce.number().int().min(1).max(4);
const assetSchema = z.object({
name: z.string().trim().min(1).max(200),
description: z.string().trim().max(5000).optional(),
type: z.enum(["INFORMATION", "SYSTEM", "APPLICATION", "LOCATION", "SUPPLIER", "IT_SERVICE", "PERSON", "DATA"]),
status: z.enum(["ACTIVE", "PLANNED", "RETIRED"]),
ownerId: z.string().optional(),
location: z.string().trim().max(200).optional(),
tags: z.string().optional(),
confidentiality: level,
integrity: level,
availability: level,
});
function parseAssetForm(formData: FormData) {
const parsed = assetSchema.parse({
name: formData.get("name"),
description: formData.get("description") || undefined,
type: formData.get("type"),
status: formData.get("status"),
ownerId: formData.get("ownerId") || undefined,
location: formData.get("location") || undefined,
tags: formData.get("tags") || undefined,
confidentiality: formData.get("confidentiality"),
integrity: formData.get("integrity"),
availability: formData.get("availability"),
});
return {
...parsed,
ownerId: parsed.ownerId || null,
description: parsed.description || null,
location: parsed.location || null,
tags: parsed.tags
? parsed.tags.split(",").map((t) => t.trim()).filter(Boolean)
: [],
};
}
export async function createAsset(formData: FormData) {
const { session, db } = await guard("asset:write");
const data = parseAssetForm(formData);
// tenantId doppelt gemoppelt: explizit für die Typen, der Guard injiziert ohnehin
const asset = await db.asset.create({
data: { ...data, tenantId: session.user.tenantId, createdBy: session.user.id },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "asset",
entityId: asset.id,
after: data,
});
revalidatePath("/assets");
redirect(`/assets?detail=${asset.id}`);
}
export async function updateAsset(assetId: string, formData: FormData) {
const { session, db } = await guard("asset:write");
const before = await db.asset.findUnique({ where: { id: assetId } });
if (!before) throw new Error("Asset nicht gefunden");
const data = parseAssetForm(formData);
await db.asset.update({ where: { id: assetId }, data });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "asset",
entityId: assetId,
before,
after: data,
});
revalidatePath("/assets");
redirect(`/assets?detail=${assetId}`);
}
export async function deleteAsset(assetId: string) {
const { session, db } = await guard("asset:write");
const before = await db.asset.findUnique({ where: { id: assetId } });
if (!before) throw new Error("Asset nicht gefunden");
await db.asset.delete({ where: { id: assetId } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "asset",
entityId: assetId,
before,
});
revalidatePath("/assets");
redirect("/assets");
}
export async function addAssetRelation(assetId: string, formData: FormData) {
const { session, db } = await guard("asset:write");
const relatedAssetId = z.string().min(1).parse(formData.get("relatedAssetId"));
if (relatedAssetId === assetId) return;
// Beide Assets müssen zum Mandanten gehören (Guard filtert, count prüft)
const count = await db.asset.count({ where: { id: { in: [assetId, relatedAssetId] } } });
if (count !== 2) throw new Error("Asset nicht gefunden");
await db.assetRelation.upsert({
where: {
assetId_relatedAssetId_type: { assetId, relatedAssetId, type: "depends_on" },
},
update: {},
create: {
assetId,
relatedAssetId,
type: "depends_on",
tenantId: session.user.tenantId,
},
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "asset_relation",
entityId: assetId,
after: { assetId, relatedAssetId, type: "depends_on" },
});
revalidatePath("/assets");
}
export async function removeAssetRelation(assetId: string, relationId: string) {
const { session, db } = await guard("asset:write");
const before = await db.assetRelation.findUnique({ where: { id: relationId } });
if (!before) return;
await db.assetRelation.delete({ where: { id: relationId } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "asset_relation",
entityId: relationId,
before,
});
revalidatePath("/assets");
}
+314
View File
@@ -0,0 +1,314 @@
"use server";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import type { TenantDb } from "@/server/db";
import { writeAuditLog } from "@/server/audit";
import { isAuditReadinessEnabled } from "@/lib/audit-readiness/activation";
import { loadScopeInput, controlsInScope } from "@/server/soa-context";
import { resolveControlDomain } from "@/server/control-domain";
import { createTask } from "@/server/actions/tasks";
import { storage } from "@/server/storage/adapter";
import { controlTitle } from "@/lib/control-titles";
/**
* Nachweise-Tab der Audit-Vorbereitung (V5B, M4). Erzeugt je In-Scope-Control einen
* bereitzustellenden Nachweis (`AuditEvidenceItem`), ordnet automatisch den fachlich
* zuständigen Ansprechpartner zu, erlaubt das Umhängen, erzeugt Nachweis-Aufgaben
* (`evidence_provide`) und nimmt den direkten Datei-Upload je Item entgegen.
*
* `moduleGuard("audit")` liefert die F-06-Autoritätsprüfungen (Kontostatus/Token/
* Rechte) und setzt die Modul-Aktivierung `audit` voraus. Der Wrapper `guard`
* erzwingt zusätzlich `isAuditReadinessEnabled` (Opt-in, Default = aus), analog zu
* `src/server/actions/audit-readiness.ts`.
*/
const rawGuard = moduleGuard("audit");
/** Autoritätsprüfung + Scharfschaltung des Audit-Wizards; wirft, wenn nicht aktiviert. */
async function guard(...permissions: Parameters<typeof rawGuard>) {
const { session, db } = await rawGuard(...permissions);
if (!(await isAuditReadinessEnabled(db))) throw new Error("Audit-Wizard ist nicht aktiviert.");
return { session, db };
}
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
const NACHWEISE_PATH = "/audit-readiness/[auditId]/nachweise";
/** Generische Nachweisart, wenn zum Control kein Umsetzungshinweis existiert. */
const GENERIC_EVIDENCE = "Richtlinie/Nachweis der Anwendung";
/** Audit laden + Existenz sichern (db ist mandantengebunden → Fremd-Tenant scheidet aus). */
async function requireAudit(db: TenantDb, auditId: string) {
const audit = await db.audit.findUnique({ where: { id: auditId } });
if (!audit) throw new Error("Audit nicht gefunden.");
return audit;
}
/**
* Ansprechpartner für ein Control ermitteln: Control → Bereich/Funktion
* (`resolveControlDomain`, RESPONSIBLE) → `ProjectFunctionAssignment.userId`.
* Unbesetzte Funktion → userId null (Item bleibt offen).
*/
async function resolveOwnerForControl(
db: TenantDb,
tenantId: string,
control: string,
usersByFunction: Map<string, string>,
): Promise<{ functionKey: string | null; userId: string | null }> {
const { rules } = await resolveControlDomain(db, tenantId, control);
const primary = rules.find((r) => r.raci === "RESPONSIBLE") ?? rules[0];
const functionKey = primary?.functionKey ?? null;
const userId = functionKey ? (usersByFunction.get(functionKey) ?? null) : null;
return { functionKey, userId };
}
/**
* Nachweisliste generieren (idempotent). Für jedes In-Scope-Control wird — falls noch
* nicht vorhanden — ein `AuditEvidenceItem` angelegt. Die Nachweisart stammt aus dem
* Umsetzungshinweis (`ImplementationHint.evidence`, global je Control); fehlt einer,
* greift die generische Nachweisart. Der Ansprechpartner wird automatisch über
* `resolveControlDomain` → Funktion → `ProjectFunctionAssignment` zugeordnet.
*/
export async function generateEvidenceList(auditId: string) {
const { session, db } = await guard("onboarding:use");
const tenantId = session.user.tenantId;
const audit = await requireAudit(db, auditId);
const { input } = await loadScopeInput(db, tenantId);
const controls = controlsInScope(input);
// Bereits vorhandene Items (Idempotenz je Control).
const existing = await db.auditEvidenceItem.findMany({ where: { auditId }, select: { control: true } });
const known = new Set(existing.map((e) => e.control));
// Nachweisart je Control aus den Umsetzungshinweisen (global). Erster Hinweis je
// Control liefert Nachweisart + reqId; mehrere Nachweise werden knapp zusammengeführt.
const hints = await db.implementationHint.findMany({ orderBy: { reqId: "asc" }, select: { control: true, reqId: true, evidence: true } });
const hintByControl = new Map<string, { reqId: string; evidence: string }>();
for (const h of hints) {
if (!hintByControl.has(h.control) && h.evidence.trim()) {
hintByControl.set(h.control, { reqId: h.reqId, evidence: h.evidence.trim() });
}
}
// Funktion → User einmalig laden (db ist tenant-gebunden).
const assignments = await db.projectFunctionAssignment.findMany({ where: { userId: { not: null } }, select: { functionKey: true, userId: true } });
const usersByFunction = new Map<string, string>();
for (const a of assignments) {
if (a.userId && !usersByFunction.has(a.functionKey)) usersByFunction.set(a.functionKey, a.userId);
}
let created = 0;
for (const control of controls) {
if (known.has(control)) continue;
const hint = hintByControl.get(control);
const title = (hint?.evidence ?? GENERIC_EVIDENCE).slice(0, 200);
const { functionKey, userId } = await resolveOwnerForControl(db, tenantId, control, usersByFunction);
await db.auditEvidenceItem.create({
data: {
tenantId,
auditId,
control,
title,
reqId: hint?.reqId ?? null,
assignedFunctionKey: functionKey,
assignedUserId: userId,
status: "offen",
},
});
created++;
}
await writeAuditLog({
tenantId, actorId: session.user.id,
action: "create", entity: "audit_evidence_list", entityId: auditId,
after: { created, total: controls.length, auditTitle: audit.title },
});
revalidatePath(NACHWEISE_PATH, "page");
}
/** Item laden + zum Audit gehörig sichern. */
async function requireItem(db: TenantDb, itemId: string) {
const item = await db.auditEvidenceItem.findUnique({ where: { id: itemId } });
if (!item) throw new Error("Nachweis-Position nicht gefunden.");
return item;
}
/**
* Ansprechpartner eines Nachweis-Items umhängen (Dropdown). Leerer Wert → offen lassen.
* Der neue Ansprechpartner muss ein aktiver Nutzer desselben Mandanten sein.
*/
export async function reassignEvidenceItem(itemId: string, formData: FormData) {
const { session, db } = await guard("onboarding:use");
await requireItem(db, itemId); // Existenz/Mandantenbindung sichern
const userIdRaw = str(formData.get("userId"));
let assignedUserId: string | null = null;
if (userIdRaw) {
const user = await db.user.findFirst({ where: { id: userIdRaw, status: "ACTIVE" }, select: { id: true } });
if (!user) throw new Error("Ausgewählte Person ist kein aktiver Nutzer dieses Mandanten.");
assignedUserId = user.id;
}
await db.auditEvidenceItem.update({ where: { id: itemId }, data: { assignedUserId } });
await writeAuditLog({
tenantId: session.user.tenantId, actorId: session.user.id,
action: "update", entity: "audit_evidence_item", entityId: itemId,
after: { assignedUserId },
});
revalidatePath(NACHWEISE_PATH, "page");
}
/**
* Für ein offenes Nachweis-Item eine `evidence_provide`-Aufgabe beim Ansprechpartner
* anlegen und verlinken (`AuditEvidenceItem.taskId`). Frist = `Audit.preparationDeadline`.
* `createTask` setzt Bereich (`domain`) aus dem Control und die RACI-Mitwirkenden.
* Idempotent: existiert bereits eine Aufgabe für das Item, passiert nichts.
*/
export async function createEvidenceTask(itemId: string) {
const { db } = await guard("onboarding:use");
const item = await requireItem(db, itemId);
if (item.taskId) return; // bereits verknüpft
const audit = await requireAudit(db, item.auditId);
const task = await createTask({
type: "evidence_provide",
title: `Nachweis bereitstellen: ${item.control} — ${item.title}`.slice(0, 200),
description: `Für das Audit „${audit.title}" ist ein Nachweis zu Control ${item.control} (${controlTitle(item.control)}) bereitzustellen.`.slice(0, 5000),
origin: `audit:evidence:${item.auditId}`,
owner: item.assignedUserId ?? undefined,
dueDate: audit.preparationDeadline ?? undefined,
priority: "hoch",
links: { control: item.control },
});
await db.auditEvidenceItem.update({ where: { id: itemId }, data: { taskId: task.id } });
revalidatePath(NACHWEISE_PATH, "page");
}
/**
* Für alle offenen Items eines Audits ohne verknüpfte Aufgabe je eine
* `evidence_provide`-Aufgabe erzeugen. Idempotent (überspringt bereits verknüpfte).
*/
export async function createEvidenceTasksForAudit(auditId: string) {
const { db } = await guard("onboarding:use");
await requireAudit(db, auditId);
const items = await db.auditEvidenceItem.findMany({
where: { auditId, status: "offen", taskId: null },
select: { id: true },
});
for (const { id } of items) {
await createEvidenceTask(id);
}
revalidatePath(NACHWEISE_PATH, "page");
}
// ── Datei-Upload je Item (Validierung analog policy-upload.ts, F-15) ──────────
//
// Phase 1: Ablage über den gekapselten Storage-Adapter (Stub — speichert nur
// Metadaten/Key, keine Bytes; echte MinIO-Byte-Speicherung ist Folge-Epic). Der
// Upload-Flow inkl. Evidence-Verknüpfung und Statuswechsel ist voll funktionsfähig.
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024; // 20 MB
type UploadKind = "pdf" | "docx" | "odt";
const ALLOWED_EXT: Record<string, UploadKind> = { pdf: "pdf", docx: "docx", odt: "odt" };
const CANONICAL_MIME: Record<UploadKind, string> = {
pdf: "application/pdf",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
odt: "application/vnd.oasis.opendocument.text",
};
const ALLOWED_CLIENT_MIME: Record<UploadKind, string[]> = {
pdf: ["application/pdf"],
docx: ["application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/octet-stream", ""],
odt: ["application/vnd.oasis.opendocument.text", "application/octet-stream", ""],
};
const startsWith = (bytes: Uint8Array, sig: number[]) => sig.every((b, i) => bytes[i] === b);
const PDF_MAGIC = [0x25, 0x50, 0x44, 0x46]; // "%PDF"
const ZIP_MAGIC = [0x50, 0x4b, 0x03, 0x04]; // "PK\x03\x04" (DOCX/ODT = ZIP-Container)
function magicMatches(kind: UploadKind, bytes: Uint8Array): boolean {
if (kind === "pdf") return startsWith(bytes, PDF_MAGIC);
if (!startsWith(bytes, ZIP_MAGIC)) return false;
if (kind === "odt") {
const head = Buffer.from(bytes.slice(0, 80)).toString("latin1");
return head.includes("opendocument.text");
}
return true; // docx: ZIP-Signatur genügt (OOXML), Endung + Client-MIME grenzen ein.
}
function extOf(filename: string): string {
const dot = filename.lastIndexOf(".");
return dot >= 0 ? filename.slice(dot + 1).toLowerCase() : "";
}
/**
* Datei-Upload je Nachweis-Item. Legt die Datei über den Storage-Adapter (Stub) ab,
* erzeugt eine `Evidence`-Zeile (mit `control`), verknüpft `AuditEvidenceItem.evidenceId`,
* setzt den Status auf `bereitgestellt` und schließt eine ggf. verknüpfte Aufgabe (DONE).
* Vollständige Eingangsvalidierung (Größe/Endung/MIME/Magic-Bytes), da `f.type` nicht
* vertrauenswürdig ist.
*/
export async function uploadEvidenceFile(itemId: string, formData: FormData) {
const { session, db } = await guard("onboarding:use");
const tenantId = session.user.tenantId;
const item = await requireItem(db, itemId);
const file = formData.get("file");
if (!file || typeof file !== "object" || !("arrayBuffer" in file) || (file as File).size === 0) {
throw new Error("Bitte eine Datei auswählen.");
}
const f = file as File;
// (1) Größenlimit VOR dem Einlesen (DoS-Schutz).
if (f.size > MAX_UPLOAD_BYTES) {
throw new Error(`Datei zu groß (max. ${Math.floor(MAX_UPLOAD_BYTES / (1024 * 1024))} MB).`);
}
// (2) Endungs-Allowlist.
const ext = extOf(f.name);
const kind = ALLOWED_EXT[ext];
if (!kind) throw new Error("Dateityp nicht erlaubt. Zulässig: PDF, DOCX, ODT.");
// (3) Client-MIME nur als Zusatzsignal.
const clientMime = (f.type || "").toLowerCase();
if (!ALLOWED_CLIENT_MIME[kind].includes(clientMime)) throw new Error("MIME-Typ passt nicht zur Dateiendung.");
// (4) Inhalt einlesen + Magic-Bytes prüfen (maßgeblich).
const bytes = new Uint8Array(await f.arrayBuffer());
if (!magicMatches(kind, bytes)) throw new Error("Dateiinhalt passt nicht zum angegebenen Typ.");
const stored = await storage.put({ tenantId, filename: f.name, contentType: CANONICAL_MIME[kind], bytes });
// Evidence-Zeile (mit control). Bei bereits verknüpfter Aufgabe daran hängen.
const evidence = await db.evidence.create({
data: {
tenantId,
title: `${item.control} — ${stored.filename}`.slice(0, 200),
kind: "record",
fileRef: stored.storageKey,
control: item.control,
taskId: item.taskId,
createdById: session.user.id,
},
select: { id: true },
});
await db.auditEvidenceItem.update({
where: { id: itemId },
data: { evidenceId: evidence.id, status: "bereitgestellt" },
});
// Verknüpfte Aufgabe erledigt sich.
if (item.taskId) {
await db.task.update({
where: { id: item.taskId },
data: { status: "DONE", resolvedById: session.user.id, resolvedAt: new Date() },
});
}
await writeAuditLog({
tenantId, actorId: session.user.id,
action: "create", entity: "audit_evidence_upload", entityId: itemId,
after: { evidenceId: evidence.id, storageKey: stored.storageKey, size: stored.size, taskDone: Boolean(item.taskId) },
});
revalidatePath(NACHWEISE_PATH, "page");
revalidatePath("/tasks");
}
+157
View File
@@ -0,0 +1,157 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import type { AuditStatus, AuditType } from "@prisma/client";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { isAuditReadinessEnabled } from "@/lib/audit-readiness/activation";
/**
* Audit-Verwaltung (V5A) — Planung/Terminierung von internen und externen Audits.
*
* `moduleGuard("audit")` liefert die F-06-Autoritätsprüfungen (Kontostatus/Token/
* Rechte) und setzt voraus, dass das Modul `audit` für den Mandanten aktiviert ist.
* Der lokale Wrapper `guard` erzwingt zusätzlich `isAuditReadinessEnabled` — analog
* zu `src/server/actions/audit-readiness.ts`. Als Schreibrecht dient `onboarding:use`
* (dieselbe Rolle, die den Audit-Wizard fährt: Mandanten-Admin/ISB), konsistent mit
* den bestehenden Actions des Audit-Moduls.
*
* Der Vollständigkeitscheck (`scripts/check-module-guards.ts`) verlässt sich darauf,
* dass jede exportierte Action über `await guard(...)` läuft (Datei ↔ Modul `audit`).
*/
const rawGuard = moduleGuard("audit");
/** Autoritätsprüfung + Scharfschaltung des Audit-Moduls; wirft, wenn nicht aktiviert. */
async function guard(...permissions: Parameters<typeof rawGuard>) {
const { session, db } = await rawGuard(...permissions);
if (!(await isAuditReadinessEnabled(db))) throw new Error("Audit-Modul ist nicht aktiviert.");
return { session, db };
}
const AUDIT_TYPES = ["INTERNAL", "EXTERNAL"] as const;
const AUDIT_STATUSES = ["PLANNED", "IN_PREPARATION", "DONE"] as const;
const baseSchema = z.object({
type: z.enum(AUDIT_TYPES),
title: z.string().trim().min(1, "Titel erforderlich").max(200),
plannedDate: z.string().trim().max(120).optional(),
scope: z.string().trim().max(2000).optional(),
// extern
assessmentLevel: z.string().trim().max(20).optional(),
provider: z.string().trim().max(200).optional(),
// intern
auditorUserId: z.string().trim().max(60).optional(),
interval: z.string().trim().max(60).optional(), // Turnus (in `result` als Merker abgelegt)
});
function parse(formData: FormData) {
const p = baseSchema.parse({
type: formData.get("type"),
title: formData.get("title"),
plannedDate: formData.get("plannedDate") || undefined,
scope: formData.get("scope") || undefined,
assessmentLevel: formData.get("assessmentLevel") || undefined,
provider: formData.get("provider") || undefined,
auditorUserId: formData.get("auditorUserId") || undefined,
interval: formData.get("interval") || undefined,
});
const isExternal = p.type === "EXTERNAL";
return {
type: p.type as AuditType,
title: p.title,
plannedDate: p.plannedDate || null,
scope: p.scope || null,
// Externe Felder nur für externe Audits, interne nur für interne — sauber trennen.
assessmentLevel: isExternal ? p.assessmentLevel || null : null,
provider: isExternal ? p.provider || null : null,
auditorUserId: isExternal ? null : p.auditorUserId || null,
interval: isExternal ? null : p.interval || null,
};
}
/** Neues Audit anlegen (Status PLANNED). Intern → nur terminieren, Extern → Wizard-fähig. */
export async function createAudit(formData: FormData) {
const { session, db } = await guard("onboarding:use");
const data = parse(formData);
// Turnus des internen Audits als Freitext-Merker im `result`-Feld (kein Schema-Change).
const result = data.interval ? `Turnus: ${data.interval}` : null;
const created = await db.audit.create({
data: {
tenantId: session.user.tenantId,
type: data.type,
title: data.title,
plannedDate: data.plannedDate,
scope: data.scope,
assessmentLevel: data.assessmentLevel,
provider: data.provider,
auditorUserId: data.auditorUserId,
status: "PLANNED",
result,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "audit", entityId: created.id, after: { type: data.type, title: data.title } });
revalidatePath("/audit-readiness");
// Externe Audits: direkt in die Vorbereitung springen; interne bleiben in der Übersicht.
if (created.type === "EXTERNAL") redirect(`/audit-readiness/${created.id}`);
redirect("/audit-readiness");
}
/** Audit aktualisieren (Stammdaten/Terminierung). */
export async function updateAudit(auditId: string, formData: FormData) {
const { session, db } = await guard("onboarding:use");
const before = await db.audit.findUnique({ where: { id: auditId } });
if (!before) throw new Error("Audit nicht gefunden.");
const data = parse(formData);
const result = data.interval ? `Turnus: ${data.interval}` : before.result;
await db.audit.update({
where: { id: auditId },
data: {
type: data.type,
title: data.title,
plannedDate: data.plannedDate,
scope: data.scope,
assessmentLevel: data.assessmentLevel,
provider: data.provider,
auditorUserId: data.auditorUserId,
result,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "audit", entityId: auditId, before, after: { type: data.type, title: data.title } });
revalidatePath("/audit-readiness");
revalidatePath(`/audit-readiness/${auditId}`);
redirect("/audit-readiness");
}
/** Audit löschen (inkl. hängender Nachweispositionen via onDelete: Cascade). */
export async function deleteAudit(auditId: string) {
const { session, db } = await guard("onboarding:use");
const before = await db.audit.findUnique({ where: { id: auditId } });
if (!before) throw new Error("Audit nicht gefunden.");
await db.audit.delete({ where: { id: auditId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "audit", entityId: auditId, before });
revalidatePath("/audit-readiness");
redirect("/audit-readiness");
}
const statusSchema = z.enum(AUDIT_STATUSES);
/** Audit-Status setzen (PLANNED → IN_PREPARATION → DONE). Bei DONE optional Ergebnis. */
export async function setAuditStatus(auditId: string, status: string, formData?: FormData) {
const { session, db } = await guard("onboarding:use");
const next = statusSchema.parse(status) as AuditStatus;
const before = await db.audit.findUnique({ where: { id: auditId } });
if (!before) throw new Error("Audit nicht gefunden.");
const result = formData?.get("result");
await db.audit.update({
where: { id: auditId },
data: {
status: next,
...(next === "DONE" && typeof result === "string" && result.trim() ? { result: result.trim() } : {}),
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "audit", entityId: auditId, before, after: { status: next } });
revalidatePath("/audit-readiness");
revalidatePath(`/audit-readiness/${auditId}`);
}
+457
View File
@@ -0,0 +1,457 @@
"use server";
import { requireSession } from "@/server/auth";
import { requirePlatformSession } from "@/server/platform-auth";
import { prisma } from "@/server/db";
import { hashPassword, verifyPassword } from "@/server/password";
import { resolvePasswordPolicy, validatePassword } from "@/lib/password-policy";
import { writeAuditLog, writePlatformAudit } from "@/server/audit";
import { checkRateLimit } from "@/server/rate-limit";
import { consumeToken, issueToken, peekToken } from "@/server/auth-token";
import { invalidateOtherSessions, invalidateSessions } from "@/server/sessions";
import {
clientIp,
findAccountByEmail,
findAccountById,
isEmailAvailable,
loginPathOf,
principalTypeOf,
readPasswordHash,
sendEmailChangedNotice,
sendEmailChangeVerifyMail,
sendPasswordChangedMail,
sendPasswordResetMail,
writeEmail,
writePasswordHash,
type Domain,
} from "@/server/auth-selfservice";
/**
* SEC2 — Passwort-Self-Service für Mandanten-Nutzer und Plattform-Admins.
*
* EXEMPT vom Modul-Gating: Auth-Funktionen, kein per TenantModule gegatetes
* Fachmodul. Die Reset-Abläufe laufen bewusst **ohne** Session (der Nutzer ist
* ausgesperrt); ihre Absicherung sind Rate-Limit, Enumeration-Neutralität und
* single-use-Tokens. Die angemeldeten Abläufe nutzen requireSession bzw.
* requirePlatformSession.
*
* Durchgehende Prinzipien:
* - **Enumeration-Schutz:** Reset-Anfrage und E-Mail-Änderung antworten immer
* gleich, unabhängig davon, ob das Konto existiert.
* - **Kein Auto-Login nach Reset** — der Nutzer meldet sich neu an.
* - **Keine Tokens in Logs oder Audit-Einträgen.**
*/
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
function domainOf(v: FormDataEntryValue | null): Domain {
return String(v ?? "") === "platform" ? "platform" : "tenant";
}
/** Einheitliche Antwort der Reset-Anfrage (SEC2 §2a). */
const NEUTRAL_ANSWER =
"Falls ein Konto mit dieser Adresse existiert, wurde eine E-Mail mit weiteren Schritten gesendet.";
export type RequestResetState =
| { status: "idle" }
| { status: "done"; message: string }
| { status: "error"; message: string };
/**
* „Passwort vergessen" — Schritt 1.
*
* Antwortet **immer** identisch. Ein Reset wird nur ausgelöst, wenn das Konto
* existiert und aktiv ist; deaktivierte oder gesperrte Konten bekommen weder
* Mail noch abweichende Meldung.
*/
export async function requestPasswordReset(
_prev: RequestResetState,
formData: FormData,
): Promise<RequestResetState> {
const email = str(formData.get("email")).toLowerCase();
const domain = domainOf(formData.get("domain"));
const ip = await clientIp();
const limit = checkRateLimit("passwordResetRequest", { ip, account: email });
if (!limit.allowed) {
return {
status: "error",
message: `Zu viele Anfragen. Bitte in ${Math.ceil(limit.retryAfterSeconds / 60)} Minute(n) erneut versuchen.`,
};
}
if (!email) return { status: "done", message: NEUTRAL_ANSWER };
const account = await findAccountByEmail(domain, email);
if (account && account.active) {
const { raw, expiresAt } = await issueToken({
principalType: principalTypeOf(domain),
principalId: account.id,
tenantId: account.tenantId,
type: "password_reset",
requestIp: ip,
});
await sendPasswordResetMail(account, raw, expiresAt, domain);
// Audit ohne Token und ohne Rückschluss auf den Klartext.
if (domain === "platform") {
await writePlatformAudit({
actorId: account.id,
action: "update",
entity: "password_reset_requested",
entityId: account.id,
});
} else if (account.tenantId) {
await writeAuditLog({
tenantId: account.tenantId,
actorId: account.id,
action: "update",
entity: "password_reset_requested",
entityId: account.id,
});
}
}
return { status: "done", message: NEUTRAL_ANSWER };
}
export type RedeemResetState =
| { status: "idle" }
| { status: "error"; message: string }
| { status: "done" };
const GENERIC_TOKEN_ERROR =
"Der Link ist ungültig oder abgelaufen. Bitte fordern Sie einen neuen an.";
/** Prüft einen Reset-Link, ohne ihn zu verbrauchen (für die Formularanzeige). */
export async function checkResetToken(token: string, domain: Domain): Promise<boolean> {
const resolved = await peekToken(token, "password_reset");
return resolved != null && resolved.principalType === principalTypeOf(domain);
}
/** Prüft einen Einladungs-Link (Option C, WS3), ohne ihn zu verbrauchen. */
export async function checkInvitationToken(token: string): Promise<boolean> {
const resolved = await peekToken(token, "invitation");
// Einladungen adressieren immer die (Mandanten-)Identity.
return resolved != null && resolved.principalType === "identity";
}
/**
* Einladung einlösen (Option C, WS3): der Eingeladene setzt sein Erst-Passwort an
* der GLOBALEN Identity. Anlage erfolgt ausschließlich per Einladung (goldene Regel 4).
*/
export async function redeemInvitation(
_prev: RedeemResetState,
formData: FormData,
): Promise<RedeemResetState> {
const token = str(formData.get("token"));
const password = String(formData.get("password") ?? "");
const confirm = String(formData.get("confirm") ?? "");
const ip = await clientIp();
const limit = checkRateLimit("passwordResetRedeem", { ip });
if (!limit.allowed) return { status: "error", message: "Zu viele Versuche. Bitte später erneut versuchen." };
if (password !== confirm) return { status: "error", message: "Die Passwörter stimmen nicht überein." };
// Erst prüfen (ohne Verbrauch), damit ein Policy-Fehler den Link nicht entwertet.
const preview = await peekToken(token, "invitation");
if (!preview || preview.principalType !== "identity") {
return { status: "error", message: GENERIC_TOKEN_ERROR };
}
const account = await findAccountById("tenant", preview.principalId);
if (!account) return { status: "error", message: GENERIC_TOKEN_ERROR };
const policy = await resolvePolicyFor("tenant", preview.tenantId);
const problems = validatePassword(password, policy);
if (problems.length > 0) return { status: "error", message: problems.join(" ") };
const consumed = await consumeToken(token, "invitation");
if (!consumed) return { status: "error", message: GENERIC_TOKEN_ERROR };
// Passwort an der Identity setzen (writePasswordHash räumt mustChangePassword + Lockout).
await writePasswordHash("tenant", account.id, await hashPassword(password));
if (account.tenantId) {
await writeAuditLog({ tenantId: account.tenantId, actorId: account.id, action: "update", entity: "invitation_redeemed", entityId: account.id });
}
return { status: "done" };
}
/**
* „Passwort vergessen" — Schritt 2: neues Passwort setzen.
*
* Der Token wird **vor** dem Schreiben verbraucht (`consumeToken`), damit ein
* paralleler zweiter Versuch mit demselben Link ins Leere läuft.
*/
export async function redeemPasswordReset(
_prev: RedeemResetState,
formData: FormData,
): Promise<RedeemResetState> {
const token = str(formData.get("token"));
const domain = domainOf(formData.get("domain"));
const password = String(formData.get("password") ?? "");
const confirm = String(formData.get("confirm") ?? "");
const ip = await clientIp();
const limit = checkRateLimit("passwordResetRedeem", { ip });
if (!limit.allowed) {
return { status: "error", message: "Zu viele Versuche. Bitte später erneut versuchen." };
}
if (password !== confirm) {
return { status: "error", message: "Die Passwörter stimmen nicht überein." };
}
// Erst prüfen (ohne Verbrauch), damit ein Policy-Fehler den Link nicht entwertet.
const preview = await peekToken(token, "password_reset");
if (!preview || preview.principalType !== principalTypeOf(domain)) {
return { status: "error", message: GENERIC_TOKEN_ERROR };
}
const account = await findAccountById(domain, preview.principalId);
if (!account || !account.active) {
return { status: "error", message: GENERIC_TOKEN_ERROR };
}
const policy = await resolvePolicyFor(domain, account.tenantId);
const problems = validatePassword(password, policy);
if (problems.length > 0) return { status: "error", message: problems.join(" ") };
// Jetzt verbrauchen — ab hier ist der Link verbraucht, auch bei Fehlern danach.
const consumed = await consumeToken(token, "password_reset");
if (!consumed) return { status: "error", message: GENERIC_TOKEN_ERROR };
await writePasswordHash(domain, account.id, await hashPassword(password));
// Alle Sessions entwerten: nach einem Reset darf keine alte Sitzung weiterlaufen.
await invalidateSessions({ type: principalTypeOf(domain), id: account.id });
await sendPasswordChangedMail(account, ip);
if (domain === "platform") {
await writePlatformAudit({
actorId: account.id,
action: "update",
entity: "password_reset",
entityId: account.id,
});
} else if (account.tenantId) {
await writeAuditLog({
tenantId: account.tenantId,
actorId: account.id,
action: "update",
entity: "password_reset",
entityId: account.id,
});
}
return { status: "done" };
}
/** Passwort-Policy der Domäne (Plattform nutzt die Voreinstellung). */
async function resolvePolicyFor(domain: Domain, tenantId: string | null) {
if (domain === "platform" || !tenantId) return resolvePasswordPolicy(undefined);
const settings = await prisma.tenantSettings.findUnique({
where: { tenantId },
select: { securityPolicy: true },
});
return resolvePasswordPolicy(settings?.securityPolicy);
}
export type SelfChangeState =
| { status: "idle" }
| { status: "ok"; message: string }
| { status: "error"; message: string };
/**
* Passwort selbst ändern (angemeldet, SEC2 §3).
*
* Alt-Passwort wird verifiziert; danach werden **andere** Sitzungen abgemeldet,
* die aktuelle bleibt bestehen (siehe invalidateOtherSessions).
*/
export async function changePasswordSelf(
_prev: SelfChangeState,
formData: FormData,
): Promise<SelfChangeState> {
const domain = domainOf(formData.get("domain"));
const { id, tenantId } = await currentPrincipal(domain);
const ip = await clientIp();
const limit = checkRateLimit("passwordVerify", { ip, account: id });
if (!limit.allowed) {
return { status: "error", message: "Zu viele Versuche. Bitte später erneut versuchen." };
}
const current = String(formData.get("current") ?? "");
const password = String(formData.get("password") ?? "");
const confirm = String(formData.get("confirm") ?? "");
if (password !== confirm) {
return { status: "error", message: "Die Passwörter stimmen nicht überein." };
}
const hash = await readPasswordHash(domain, id);
if (!hash) return { status: "error", message: "Konto nicht gefunden." };
// Generische Meldung: kein Rückschluss darauf, welcher Teil falsch war.
if (!(await verifyPassword(hash, current))) {
return { status: "error", message: "Die Eingaben konnten nicht bestätigt werden." };
}
const policy = await resolvePolicyFor(domain, tenantId);
const problems = validatePassword(password, policy);
if (problems.length > 0) return { status: "error", message: problems.join(" ") };
await writePasswordHash(domain, id, await hashPassword(password));
await invalidateOtherSessions({ type: principalTypeOf(domain), id });
const account = await findAccountById(domain, id);
if (account) await sendPasswordChangedMail(account, ip);
if (domain === "platform") {
await writePlatformAudit({ actorId: id, action: "update", entity: "password_self_change", entityId: id });
} else if (tenantId) {
await writeAuditLog({ tenantId, actorId: id, action: "update", entity: "password_self_change", entityId: id });
}
return {
status: "ok",
message: "Passwort geändert. Andere Sitzungen wurden abgemeldet.",
};
}
export type EmailChangeState =
| { status: "idle" }
| { status: "ok"; message: string }
| { status: "error"; message: string };
const EMAIL_NEUTRAL =
"Falls die Adresse verwendbar ist, wurde ein Bestätigungslink an sie gesendet.";
/**
* E-Mail-Änderung anfordern (SEC2 §4, Double-Opt-in).
*
* Der Verifizierungslink geht an die **neue** Adresse. Ob sie bereits vergeben
* ist, wird nicht nach außen gemeldet — sonst ließe sich darüber prüfen, welche
* Adressen im System existieren.
*/
export async function requestEmailChange(
_prev: EmailChangeState,
formData: FormData,
): Promise<EmailChangeState> {
const domain = domainOf(formData.get("domain"));
// Option C: E-Mail-Änderung als Identity-Operation ist Phase 2 (FEINDESIGN §13).
if (domain === "tenant") {
return { status: "error", message: "Die Änderung der E-Mail-Adresse ist für Mandanten-Konten derzeit nicht verfügbar." };
}
const { id, tenantId } = await currentPrincipal(domain);
const ip = await clientIp();
const limit = checkRateLimit("emailChangeRequest", { ip, account: id });
if (!limit.allowed) {
return { status: "error", message: "Zu viele Anfragen. Bitte später erneut versuchen." };
}
const newEmail = str(formData.get("newEmail")).toLowerCase();
const current = String(formData.get("current") ?? "");
if (!newEmail.includes("@")) {
return { status: "error", message: "Bitte eine gültige E-Mail-Adresse angeben." };
}
// Alt-Passwort bestätigen (Step-up folgt in SEC3).
const hash = await readPasswordHash(domain, id);
if (!hash || !(await verifyPassword(hash, current))) {
return { status: "error", message: "Die Eingaben konnten nicht bestätigt werden." };
}
const account = await findAccountById(domain, id);
if (!account) return { status: "error", message: "Konto nicht gefunden." };
if (newEmail === account.email) {
return { status: "error", message: "Das ist bereits Ihre aktuelle Adresse." };
}
if (await isEmailAvailable(domain, newEmail, tenantId, id)) {
const { raw, expiresAt } = await issueToken({
principalType: principalTypeOf(domain),
principalId: id,
tenantId,
type: "email_change",
newEmail,
requestIp: ip,
});
await sendEmailChangeVerifyMail(account, newEmail, raw, expiresAt, domain);
}
return { status: "ok", message: EMAIL_NEUTRAL };
}
/**
* E-Mail-Änderung bestätigen (Klick auf den Link in der neuen Adresse).
*
* Die Kollisionsprüfung wird **erneut** ausgeführt: zwischen Anforderung und
* Bestätigung kann die Adresse anderweitig vergeben worden sein.
*/
export async function confirmEmailChange(
token: string,
domain: Domain,
): Promise<{ ok: boolean; message: string }> {
// Option C: E-Mail-Änderung als Identity-Operation ist Phase 2 (FEINDESIGN §13).
if (domain === "tenant") {
return { ok: false, message: "Die Änderung der E-Mail-Adresse ist für Mandanten-Konten derzeit nicht verfügbar." };
}
const preview = await peekToken(token, "email_change");
if (!preview || preview.principalType !== principalTypeOf(domain) || !preview.newEmail) {
return { ok: false, message: GENERIC_TOKEN_ERROR };
}
const account = await findAccountById(domain, preview.principalId);
if (!account || !account.active) return { ok: false, message: GENERIC_TOKEN_ERROR };
if (!(await isEmailAvailable(domain, preview.newEmail, preview.tenantId, account.id))) {
return {
ok: false,
message: "Diese Adresse ist inzwischen vergeben. Bitte fordern Sie die Änderung erneut an.",
};
}
const consumed = await consumeToken(token, "email_change");
if (!consumed || !consumed.newEmail) return { ok: false, message: GENERIC_TOKEN_ERROR };
const oldEmail = account.email;
await writeEmail(domain, account.id, consumed.newEmail);
// Die E-Mail ist die Login-Identität — bestehende Sessions werden entwertet.
await invalidateSessions({ type: principalTypeOf(domain), id: account.id });
await sendEmailChangedNotice(account, oldEmail, consumed.newEmail);
if (domain === "platform") {
await writePlatformAudit({
actorId: account.id,
action: "update",
entity: "email_change",
entityId: account.id,
before: { email: oldEmail },
after: { email: consumed.newEmail },
});
} else if (account.tenantId) {
await writeAuditLog({
tenantId: account.tenantId,
actorId: account.id,
action: "update",
entity: "email_change",
entityId: account.id,
before: { email: oldEmail },
after: { email: consumed.newEmail },
});
}
return {
ok: true,
message: `Ihre E-Mail-Adresse wurde auf ${consumed.newEmail} geändert. Bitte melden Sie sich neu an (${loginPathOf(domain)}).`,
};
}
/** Angemeldetes Konto der jeweiligen Domäne. */
async function currentPrincipal(domain: Domain): Promise<{ id: string; tenantId: string | null }> {
if (domain === "platform") {
const session = await requirePlatformSession();
return { id: session.user.id, tenantId: null };
}
const session = await requireSession();
// Option C (WS4): Passwort/Session gehören der Identity → id = Identity.id.
const identityId = session.user.identityId;
if (!identityId) throw new Error("Kein Identity-Kontext in der Session.");
return { id: identityId, tenantId: session.user.tenantId };
}
+300
View File
@@ -0,0 +1,300 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/server/db";
import { requirePlatformFullAdmin } from "@/server/platform-auth";
import { verifyTotp } from "@/server/mfa";
import { writePlatformAudit } from "@/server/audit";
import { getBackupStore } from "@/server/storage/backup-store";
import { listSnapshots } from "@/server/backup/export";
import type { BackupManifest } from "@/server/backup/serialization";
import { getBackupQueue, isBackupQueueEnabled } from "@/server/backup/queue";
import type { BackupOpsJob } from "@/server/backup/job";
import { matchesRestoreConfirmation, restoreConfirmationFor } from "@/server/backup/restore-confirm";
/**
* Betreiber-Portal: Enqueue-Actions für den destruktiven Portal-Restore,
* „Export jetzt" und die DSGVO-Zustellung (KONZEPT §4/§5/§10).
*
* EXEMPT vom Modul-Gating: reine Plattform-/Betreiber-Fähigkeit (eigene Auth über
* `requirePlatformFullAdmin`), kein per TenantModule gegatetes Fachmodul.
*
* Sicherheitskontrollen des Portal-Restore (alle PFLICHT, in dieser Reihenfolge):
* 1. `requirePlatformFullAdmin` — nur aktive Voll-Admins (Read-only darf nicht).
* 2. MFA-Step-up: ein JETZT gültiger, frischer TOTP-Code des Admins; Replay-
* Schutz über `lastTotpStep` (bereits verwendeter/älterer Zeitschritt wird
* abgelehnt, der akzeptierte Schritt wird sofort verbraucht/persistiert).
* 3. Getippte Bestätigung „RESTORE <tenant-slug>" muss EXAKT matchen.
* 4. Erst dann wird enqueued (Ausführung im Worker, nie inline).
* Jede Aktion wird im Plattform-Audit vermerkt (ohne Secrets/PII).
*/
export type BackupActionState =
| { status: "idle" }
| { status: "error"; message: string }
| { status: "done"; message: string; jobId?: string };
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
type StepUpAdmin = {
id: string;
mfaEnrolledAt: Date | null;
mfaSecret: string | null;
lastTotpStep: bigint | null;
};
/**
* MFA-Step-up mit Replay-Schutz: verifiziert einen frischen TOTP-Code und
* VERBRAUCHT den akzeptierten Zeitschritt sofort (persistiert `lastTotpStep`),
* damit derselbe Code nicht ein zweites Mal für eine kritische Aktion taugt.
* Ohne eingerichtete MFA ist kein TOTP-Step-up möglich → übersprungen.
* Wirft bei ungültigem/wiederverwendetem Code.
*/
async function assertStepUpAndConsume(admin: StepUpAdmin, code: string): Promise<void> {
if (!admin.mfaEnrolledAt || !admin.mfaSecret) return;
const res = verifyTotp(code, admin.mfaSecret, admin.lastTotpStep);
if (!res.ok) {
throw new Error("Bitte die Aktion mit einem aktuellen MFA-Code bestätigen (Step-up).");
}
// Replay-Schutz: Zeitschritt sofort verbrauchen.
await prisma.platformAdmin.update({
where: { id: admin.id },
data: { lastTotpStep: BigInt(res.step) },
});
}
async function loadManifest(tenantId: string, snapshotId: string): Promise<BackupManifest | null> {
const raw = await (await getBackupStore()).get(`${tenantId}/backups/${snapshotId}/manifest.json`);
if (!raw) return null;
try {
return JSON.parse(raw.toString("utf8")) as BackupManifest;
} catch {
return null;
}
}
export interface RestorePreview {
snapshotId: string;
snapshotAt: string;
schemaMigration: string | null;
totalRows: number;
artifactTenantSlug: string;
/** Zielmandant (aktuell in der DB) — zum Abgleich in der Vorschau. */
targetSlug: string;
targetName: string;
/** Mismatch-Flag: Artefakt gehört einem anderen Mandanten (würde abgewiesen). */
tenantMismatch: boolean;
tables: { model: string; rows: number }[];
}
/**
* Read-only Dry-run/Vorschau VOR der Bestätigung (KONZEPT §4): welches Artefakt,
* Zeilenzahlen je Tabelle, Snapshot-Zeit, Zielmandant. Verändert NICHTS.
*/
export async function previewRestore(tenantId: string, snapshotId: string): Promise<RestorePreview> {
await requirePlatformFullAdmin();
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { slug: true, name: true },
});
if (!tenant) throw new Error("Mandant nicht gefunden.");
const manifest = await loadManifest(tenantId, snapshotId);
if (!manifest) throw new Error("Sicherungspunkt/Manifest nicht gefunden.");
return {
snapshotId,
snapshotAt: manifest.snapshotAt,
schemaMigration: manifest.schemaMigration,
totalRows: manifest.totalRows,
artifactTenantSlug: manifest.tenantSlug,
targetSlug: tenant.slug,
targetName: tenant.name,
tenantMismatch: manifest.tenantId !== tenantId,
tables: manifest.tables
.filter((t) => t.rowCount > 0)
.map((t) => ({ model: t.model, rows: t.rowCount }))
.sort((a, b) => b.rows - a.rows),
};
}
/** Sicherungspunkte eines Mandanten + Kurz-Metadaten (für die Auswahl im Popup). */
export async function listRestoreSnapshots(
tenantId: string,
): Promise<{ snapshotId: string; snapshotAt: string | null; totalRows: number | null }[]> {
await requirePlatformFullAdmin();
const ids = await listSnapshots(tenantId);
const out: { snapshotId: string; snapshotAt: string | null; totalRows: number | null }[] = [];
for (const snapshotId of ids) {
const manifest = await loadManifest(tenantId, snapshotId);
out.push({
snapshotId,
snapshotAt: manifest?.snapshotAt ?? null,
totalRows: manifest?.totalRows ?? null,
});
}
return out.reverse(); // neueste zuerst
}
async function enqueue(job: BackupOpsJob): Promise<void> {
const queue = getBackupQueue();
if (!queue) {
throw new Error(
"Backup-Worker/Redis ist nicht verfügbar (REDIS_URL). Portal-Restore/Export/DSGVO werden " +
"nie inline ausgeführt — bitte Redis + `npm run worker:backup` bereitstellen.",
);
}
await queue.add(job.kind, job);
}
/**
* Portal-Restore enqueuen. Volle Kontrollkette (s. Kopf). Enqueued nur — der
* destruktive Wipe+Restore läuft im Worker (Mandant-Sperre, Pre-Restore-Snapshot,
* Audit sind in der Engine eingebaut).
*/
export async function enqueueRestore(
_prev: BackupActionState,
formData: FormData,
): Promise<BackupActionState> {
try {
const { admin } = await requirePlatformFullAdmin();
const tenantId = str(formData.get("tenantId"));
const snapshotId = str(formData.get("snapshotId"));
const token = str(formData.get("token"));
const confirm = str(formData.get("confirm"));
if (!tenantId || !snapshotId) return { status: "error", message: "Mandant und Sicherungspunkt sind erforderlich." };
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { slug: true } });
if (!tenant) return { status: "error", message: "Mandant nicht gefunden." };
// 2. MFA-Step-up (frisch + Replay-Schutz).
await assertStepUpAndConsume(admin, token);
// 3. Getippte Bestätigung exakt „RESTORE <slug>".
if (!matchesRestoreConfirmation(tenant.slug, confirm)) {
return { status: "error", message: `Bitte exakt „${restoreConfirmationFor(tenant.slug)}" zur Bestätigung eingeben.` };
}
// Sicherungspunkt muss existieren (Artefakt im Store).
const artifact = await (await getBackupStore()).get(`${tenantId}/backups/${snapshotId}/artifact.cvb`);
if (!artifact) return { status: "error", message: "Der gewählte Sicherungspunkt existiert nicht mehr." };
if (!isBackupQueueEnabled()) {
return { status: "error", message: "Redis/Worker nicht konfiguriert (REDIS_URL) — Restore kann nicht eingestellt werden." };
}
const job = await prisma.backupJob.create({
data: {
kind: "tenant_restore",
status: "queued",
tenantId,
tenantSlug: tenant.slug,
actorId: admin.id,
snapshotId,
params: { restoreFiles: false },
},
});
// 4. Erst jetzt enqueuen. Scheitert das Einstellen, Job als failed markieren.
try {
await enqueue({ kind: "tenant_restore", jobId: job.id, tenantId, snapshotId, actorId: admin.id });
} catch (e) {
const message = e instanceof Error ? e.message : "Einstellen fehlgeschlagen.";
await prisma.backupJob.update({ where: { id: job.id }, data: { status: "failed", error: message } });
return { status: "error", message };
}
await writePlatformAudit({
actorId: admin.id,
action: "update",
entity: "backup_job",
entityId: job.id,
after: { kind: "tenant_restore", tenantId, tenantSlug: tenant.slug, snapshotId, status: "queued" },
});
revalidatePath(`/admin/${tenantId}`);
return { status: "done", message: "Restore eingestellt — die Ausführung übernimmt der Worker (Mandant wird gesperrt).", jobId: job.id };
} catch (err) {
return { status: "error", message: err instanceof Error ? err.message : "Restore konnte nicht eingestellt werden." };
}
}
/** „Export jetzt" enqueuen (On-demand-Sicherung). Voll-Admin + Step-up + Audit. */
export async function enqueueExport(
_prev: BackupActionState,
formData: FormData,
): Promise<BackupActionState> {
try {
const { admin } = await requirePlatformFullAdmin();
const tenantId = str(formData.get("tenantId"));
const token = str(formData.get("token"));
if (!tenantId) return { status: "error", message: "Mandant ist erforderlich." };
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { slug: true } });
if (!tenant) return { status: "error", message: "Mandant nicht gefunden." };
await assertStepUpAndConsume(admin, token);
if (!isBackupQueueEnabled()) {
return { status: "error", message: "Redis/Worker nicht konfiguriert (REDIS_URL) — Export kann nicht eingestellt werden." };
}
const job = await prisma.backupJob.create({
data: { kind: "tenant_export", status: "queued", tenantId, tenantSlug: tenant.slug, actorId: admin.id, params: { reason: "operator-ondemand" } },
});
try {
await enqueue({ kind: "tenant_export", jobId: job.id, tenantId, reason: "operator-ondemand", actorId: admin.id });
} catch (e) {
const message = e instanceof Error ? e.message : "Einstellen fehlgeschlagen.";
await prisma.backupJob.update({ where: { id: job.id }, data: { status: "failed", error: message } });
return { status: "error", message };
}
await writePlatformAudit({ actorId: admin.id, action: "create", entity: "backup_job", entityId: job.id, after: { kind: "tenant_export", tenantId, status: "queued" } });
revalidatePath(`/admin/${tenantId}`);
return { status: "done", message: "Export eingestellt — der Worker legt einen neuen Sicherungspunkt an.", jobId: job.id };
} catch (err) {
return { status: "error", message: err instanceof Error ? err.message : "Export konnte nicht eingestellt werden." };
}
}
/**
* DSGVO-Zustellung enqueuen. `subjectIdentityId` gesetzt → Per-Person-Auskunft,
* sonst Per-Mandant-Paket. Voll-Admin + Step-up + Audit; der Worker baut das ZIP,
* legt es ab und erzeugt einen signierten, ablaufenden Download-Link.
*/
export async function enqueueDsgvoExport(
_prev: BackupActionState,
formData: FormData,
): Promise<BackupActionState> {
try {
const { admin } = await requirePlatformFullAdmin();
const tenantId = str(formData.get("tenantId"));
const token = str(formData.get("token"));
const subjectIdentityId = str(formData.get("subjectIdentityId")) || null;
if (!tenantId) return { status: "error", message: "Mandant ist erforderlich." };
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { slug: true } });
if (!tenant) return { status: "error", message: "Mandant nicht gefunden." };
await assertStepUpAndConsume(admin, token);
if (!isBackupQueueEnabled()) {
return { status: "error", message: "Redis/Worker nicht konfiguriert (REDIS_URL) — DSGVO-Export kann nicht eingestellt werden." };
}
const job = await prisma.backupJob.create({
data: {
kind: "dsgvo_export",
status: "queued",
tenantId,
tenantSlug: tenant.slug,
subjectIdentityId,
actorId: admin.id,
params: { scope: subjectIdentityId ? "person" : "tenant" },
},
});
try {
await enqueue({ kind: "dsgvo_export", jobId: job.id, tenantId, subjectIdentityId, actorId: admin.id });
} catch (e) {
const message = e instanceof Error ? e.message : "Einstellen fehlgeschlagen.";
await prisma.backupJob.update({ where: { id: job.id }, data: { status: "failed", error: message } });
return { status: "error", message };
}
await writePlatformAudit({ actorId: admin.id, action: "export", entity: "backup_job", entityId: job.id, after: { kind: "dsgvo_export", tenantId, scope: subjectIdentityId ? "person" : "tenant", status: "queued" } });
revalidatePath(`/admin/${tenantId}`);
return { status: "done", message: "DSGVO-Export eingestellt — nach Fertigstellung erscheint ein zeitlich begrenzter Download-Link.", jobId: job.id };
} catch (err) {
return { status: "error", message: err instanceof Error ? err.message : "DSGVO-Export konnte nicht eingestellt werden." };
}
}
+278
View File
@@ -0,0 +1,278 @@
"use server";
import { randomBytes } from "node:crypto";
import { revalidatePath } from "next/cache";
import { prisma } from "@/server/db";
import { requirePlatformFullAdmin } from "@/server/platform-auth";
import { verifyTotp } from "@/server/mfa";
import { encryptSecret, decryptSecret } from "@/server/secret-crypto";
import { writePlatformAudit } from "@/server/audit";
import {
resolveBackupStore,
invalidateBackupStore,
type BackupTargetConfig,
} from "@/server/storage/backup-store";
/**
* Betreiber-Portal: Konfiguration des Backup-Zielspeichers (Lane „Konfigurierbarer
* Backup-Zielspeicher"). Ziel wählen (Lokal/S3), Config pflegen, „Verbindung testen",
* speichern. EXEMPT vom Modul-Gating — reine Plattform-/Betreiber-Fähigkeit.
*
* Sicherheitskontrollen (PFLICHT):
* 1. `requirePlatformFullAdmin` — nur aktive Voll-Admins.
* 2. MFA-Step-up: ein JETZT gültiger, frischer TOTP-Code des Admins. Beim
* Speichern wird der Zeitschritt verbraucht (Replay-Schutz), beim reinen
* „Verbindung testen" (nicht-destruktiv) nur verifiziert.
* Das S3-Secret wird VOR dem Schreiben verschlüsselt (secret-crypto) — nie Klartext
* in die DB. Jede Änderung wird im Plattform-Audit vermerkt (ohne Secrets).
*/
export type BackupSettingsState =
| { status: "idle" }
| { status: "error"; message: string }
| { status: "ok"; message: string };
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
/** Display-sichere Sicht der aktuellen Config (Secret NIE im Klartext). */
export interface BackupTargetView {
backupTarget: "local" | "s3";
backupLocalDir: string;
backupS3Endpoint: string;
backupS3Bucket: string;
backupS3Region: string;
backupS3AccessKey: string;
/** true, wenn ein (verschlüsseltes) S3-Secret hinterlegt ist. */
hasS3Secret: boolean;
}
/** Lädt die aktuelle Zielspeicher-Config für die Betreiber-UI (ohne Secret-Klartext). */
export async function getBackupTargetView(): Promise<BackupTargetView> {
await requirePlatformFullAdmin();
const row = await prisma.platformSetting.findUnique({
where: { id: "singleton" },
select: {
backupTarget: true,
backupLocalDir: true,
backupS3Endpoint: true,
backupS3Bucket: true,
backupS3Region: true,
backupS3AccessKey: true,
backupS3SecretKeyEnc: true,
},
});
return {
backupTarget: row?.backupTarget === "s3" ? "s3" : "local",
backupLocalDir: row?.backupLocalDir ?? "",
backupS3Endpoint: row?.backupS3Endpoint ?? "",
backupS3Bucket: row?.backupS3Bucket ?? "",
backupS3Region: row?.backupS3Region ?? "",
backupS3AccessKey: row?.backupS3AccessKey ?? "",
hasS3Secret: !!row?.backupS3SecretKeyEnc,
};
}
/**
* Baut aus dem eingereichten Formular eine (unverschlüsselte, nur-im-Speicher)
* Zielkonfiguration. Beim S3-Secret gilt: leeres Feld → bestehendes Secret behalten
* (der Aufrufer reicht dazu das bereits entschlüsselte Alt-Secret als Fallback herein).
*/
function configFromForm(formData: FormData, fallbackSecret: string | null): BackupTargetConfig {
const target = str(formData.get("backupTarget")) === "s3" ? "s3" : "local";
const secretInput = str(formData.get("backupS3SecretKey"));
return {
backupTarget: target,
backupLocalDir: str(formData.get("backupLocalDir")) || null,
backupS3Endpoint: str(formData.get("backupS3Endpoint")) || null,
backupS3Bucket: str(formData.get("backupS3Bucket")) || null,
backupS3Region: str(formData.get("backupS3Region")) || null,
backupS3AccessKey: str(formData.get("backupS3AccessKey")) || null,
backupS3SecretKey: secretInput || fallbackSecret,
};
}
/** Aktuell hinterlegtes (verschlüsseltes) S3-Secret entschlüsseln (für „Feld leer = behalten"). */
async function currentDecryptedSecret(): Promise<string | null> {
const row = await prisma.platformSetting.findUnique({
where: { id: "singleton" },
select: { backupS3SecretKeyEnc: true },
});
if (!row?.backupS3SecretKeyEnc) return null;
return decryptSecret(row.backupS3SecretKeyEnc);
}
/**
* „Verbindung testen": baut mit der EINGEREICHTEN (noch nicht gespeicherten) Config
* einen Store und probiert put→get→remove eines winzigen Test-Keys. Non-destruktiv
* (eigener `__connectivity-test__/`-Prefix, wird sofort wieder entfernt).
*/
export async function testBackupConnection(
_prev: BackupSettingsState,
formData: FormData,
): Promise<BackupSettingsState> {
try {
const { admin } = await requirePlatformFullAdmin();
// Step-up nur verifizieren (kein Verbrauch — der Test ist nicht-destruktiv).
assertFreshTotp(admin, str(formData.get("token")));
const cfg = configFromForm(formData, await currentDecryptedSecret());
let store;
try {
store = resolveBackupStore(cfg);
} catch (e) {
return { status: "error", message: e instanceof Error ? e.message : "Konfiguration ungültig." };
}
const key = `__connectivity-test__/${randomBytes(8).toString("hex")}.txt`;
const payload = Buffer.from(`certvia backup connectivity ${new Date().toISOString()}`, "utf8");
try {
await store.put(key, payload);
const back = await store.get(key);
if (!back || !back.equals(payload)) {
return { status: "error", message: "Test-Objekt konnte nicht identisch zurückgelesen werden (get≠put)." };
}
await store.remove(key);
} catch (e) {
// best effort: falls put gelang, den Test-Key noch aufräumen.
try { await store.remove(key); } catch { /* ignore */ }
return {
status: "error",
message: `Verbindung fehlgeschlagen: ${e instanceof Error ? e.message : String(e)}`,
};
}
await writePlatformAudit({
actorId: admin.id,
action: "update",
entity: "platform_setting",
entityId: "singleton",
after: { backupConnectionTest: cfg.backupTarget, result: "ok" },
});
return {
status: "ok",
message: `Verbindung erfolgreich (${cfg.backupTarget === "s3" ? "S3/MinIO" : "Lokal"}): put/get/remove ok.`,
};
} catch (err) {
return { status: "error", message: err instanceof Error ? err.message : "Verbindungstest fehlgeschlagen." };
}
}
/**
* Zielspeicher-Konfiguration speichern. Voll-Admin + Step-up (verbraucht). Das
* S3-Secret wird vor dem Schreiben verschlüsselt; ein leeres Secret-Feld behält das
* bestehende Secret. Bei `backupTarget=s3` wird auf Vollständigkeit geprüft
* (fail-secure) — eine unvollständige S3-Config wird abgelehnt, nicht gespeichert.
*/
export async function saveBackupTarget(
_prev: BackupSettingsState,
formData: FormData,
): Promise<BackupSettingsState> {
try {
const { admin } = await requirePlatformFullAdmin();
await assertFreshTotpAndConsume(admin, str(formData.get("token")));
const target = str(formData.get("backupTarget")) === "s3" ? "s3" : "local";
const localDir = str(formData.get("backupLocalDir"));
const endpoint = str(formData.get("backupS3Endpoint"));
const bucket = str(formData.get("backupS3Bucket"));
const region = str(formData.get("backupS3Region"));
const accessKey = str(formData.get("backupS3AccessKey"));
const secretInput = str(formData.get("backupS3SecretKey"));
// Bestehendes Secret behalten, wenn das Feld leer bleibt.
const existing = await prisma.platformSetting.findUnique({
where: { id: "singleton" },
select: { backupS3SecretKeyEnc: true },
});
const keepSecretEnc = existing?.backupS3SecretKeyEnc ?? null;
if (target === "s3") {
// Fail-secure-Validierung VOR dem Schreiben (das Secret kann aus dem Bestand kommen).
const hasSecret = !!secretInput || !!keepSecretEnc;
const missing = [
!endpoint && "Endpoint",
!bucket && "Bucket",
!accessKey && "Access-Key",
!hasSecret && "Secret-Key",
].filter(Boolean);
if (missing.length) {
return {
status: "error",
message: `S3-Konfiguration unvollständig (fehlt: ${missing.join(", ")}). Nicht gespeichert.`,
};
}
} else if (!localDir) {
return {
status: "error",
message: "Für „Lokal“ bitte einen Ablagepfad angeben (muss auf ein gemountetes Volume zeigen, z. B. /app/.backups).",
};
}
// S3-Secret vor dem Schreiben verschlüsseln (nur wenn neu eingegeben).
const secretEnc = secretInput ? encryptSecret(secretInput) : keepSecretEnc;
await prisma.platformSetting.upsert({
where: { id: "singleton" },
update: {
backupTarget: target,
backupLocalDir: localDir || null,
backupS3Endpoint: endpoint || null,
backupS3Bucket: bucket || null,
backupS3Region: region || null,
backupS3AccessKey: accessKey || null,
backupS3SecretKeyEnc: secretEnc,
},
create: {
id: "singleton",
backupTarget: target,
backupLocalDir: localDir || null,
backupS3Endpoint: endpoint || null,
backupS3Bucket: bucket || null,
backupS3Region: region || null,
backupS3AccessKey: accessKey || null,
backupS3SecretKeyEnc: secretEnc,
},
});
// Cache invalidieren, damit der nächste getBackupStore() das neue Ziel nutzt.
invalidateBackupStore();
await writePlatformAudit({
actorId: admin.id,
action: "update",
entity: "platform_setting",
entityId: "singleton",
// KEIN Secret/kein Klartext ins Audit — nur Metadaten.
after: {
backupTarget: target,
backupLocalDir: localDir || null,
backupS3Endpoint: endpoint || null,
backupS3Bucket: bucket || null,
backupS3Region: region || null,
s3SecretSet: !!secretEnc,
},
});
revalidatePath("/admin/backup");
return { status: "ok", message: "Backup-Zielspeicher gespeichert." };
} catch (err) {
return { status: "error", message: err instanceof Error ? err.message : "Speichern fehlgeschlagen." };
}
}
type StepUpAdmin = { id: string; mfaEnrolledAt: Date | null; mfaSecret: string | null; lastTotpStep: bigint | null };
/** Verifiziert einen frischen TOTP-Code (ohne Verbrauch). Ohne MFA übersprungen. */
function assertFreshTotp(admin: StepUpAdmin, code: string): void {
if (!admin.mfaEnrolledAt || !admin.mfaSecret) return;
if (!verifyTotp(code, admin.mfaSecret, admin.lastTotpStep).ok) {
throw new Error("Bitte die Aktion mit einem aktuellen MFA-Code bestätigen (Step-up).");
}
}
/** Wie assertFreshTotp, aber verbraucht den akzeptierten Zeitschritt (Replay-Schutz). */
async function assertFreshTotpAndConsume(admin: StepUpAdmin, code: string): Promise<void> {
if (!admin.mfaEnrolledAt || !admin.mfaSecret) return;
const res = verifyTotp(code, admin.mfaSecret, admin.lastTotpStep);
if (!res.ok) throw new Error("Bitte die Aktion mit einem aktuellen MFA-Code bestätigen (Step-up).");
await prisma.platformAdmin.update({ where: { id: admin.id }, data: { lastTotpStep: BigInt(res.step) } });
}
+124
View File
@@ -0,0 +1,124 @@
"use server";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { isAuditReadinessEnabled } from "@/lib/audit-readiness/activation";
import { buildDraftInput } from "@/server/control-descriptions-context";
import { draftControlDescription as draftAI } from "@/server/ai/draft-control-description";
/**
* Server-Actions der Control-Beschreibungen (VDA-ISA-Spalte 4) im Audit-Wizard.
*
* `moduleGuard("audit")` liefert die F-06-Autoritätsprüfungen (Kontostatus/Token/
* Rechte); der Wrapper `guard` erzwingt zusätzlich die Scharfschaltung des
* Audit-Wizards (`isAuditReadinessEnabled`, Default = aus). Der KI-Entwurf läuft
* über `src/server/ai/*` und degradiert bei fehlendem API-Key sauber (kein
* Schreibvorgang, Status bleibt „manuell zu erfassen").
*/
const rawGuard = moduleGuard("audit");
/** Autoritätsprüfung + Scharfschaltung; wirft, wenn der Wizard nicht aktiviert ist. */
async function guard(...permissions: Parameters<typeof rawGuard>) {
const { session, db } = await rawGuard(...permissions);
if (!(await isAuditReadinessEnabled(db))) throw new Error("Audit-Wizard ist nicht aktiviert.");
return { session, db };
}
const revalidate = () => revalidatePath("/audit-readiness", "layout");
/**
* KI-Entwurf für eine einzelne Anforderung erzeugen und als Entwurf speichern.
* Ohne konfigurierten API-Key (oder bei API-Fehler) passiert nichts — der Status
* bleibt „open" (manuell zu erfassen).
*/
export async function draftControlDescription(reqId: string) {
const { session, db } = await guard("onboarding:use");
const input = await buildDraftInput(db, reqId);
if (!input) throw new Error("Anforderung nicht gefunden.");
const result = await draftAI(input);
if (!result) return; // graceful degradation: kein Entwurf, kein Schreibvorgang.
const tenantId = session.user.tenantId;
await db.controlDescription.upsert({
where: { tenantId_reqId: { tenantId, reqId } },
update: { draftText: result.draftText, sourceRef: result.sourceRef, confidence: result.confidence, status: "draft", updatedById: session.user.id },
create: { tenantId, control: input.control, reqId, draftText: result.draftText, sourceRef: result.sourceRef, confidence: result.confidence, status: "draft", updatedById: session.user.id },
});
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "control_description", entityId: reqId, after: { status: "draft", confidence: result.confidence } });
revalidate();
}
/** KI-Entwurf für alle Anforderungen eines Controls erzeugen (sequentiell). */
export async function draftControl(control: string) {
const { session, db } = await guard("onboarding:use");
const tenantId = session.user.tenantId;
const reqs = await db.policyRequirement.findMany({ where: { control, archivedAt: null }, select: { reqId: true } });
let drafted = 0;
for (const r of reqs) {
const input = await buildDraftInput(db, r.reqId);
if (!input) continue;
const result = await draftAI(input);
if (!result) continue;
await db.controlDescription.upsert({
where: { tenantId_reqId: { tenantId, reqId: r.reqId } },
update: { draftText: result.draftText, sourceRef: result.sourceRef, confidence: result.confidence, status: "draft", updatedById: session.user.id },
create: { tenantId, control, reqId: r.reqId, draftText: result.draftText, sourceRef: result.sourceRef, confidence: result.confidence, status: "draft", updatedById: session.user.id },
});
drafted++;
}
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "control_description", entityId: control, after: { drafted } });
revalidate();
}
/** Entwurf übernehmen (Status → „confirmed"); der Nutzer bestätigt den KI-Vorschlag. */
export async function confirmDescription(reqId: string) {
const { session, db } = await guard("onboarding:use");
const tenantId = session.user.tenantId;
const row = await db.controlDescription.findUnique({ where: { tenantId_reqId: { tenantId, reqId } } });
if (!row?.draftText) throw new Error("Kein Entwurf zum Übernehmen vorhanden.");
await db.controlDescription.update({ where: { tenantId_reqId: { tenantId, reqId } }, data: { status: "confirmed", updatedById: session.user.id } });
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "control_description", entityId: reqId, after: { status: "confirmed" } });
revalidate();
}
/** Beschreibung manuell anpassen/erfassen (Status → „draft"). */
export async function saveDescription(reqId: string, formData: FormData) {
const { session, db } = await guard("onboarding:use");
const tenantId = session.user.tenantId;
const draftText = (formData.get("draftText") as string | null)?.trim() || null;
const sourceRef = (formData.get("sourceRef") as string | null)?.trim() || null;
const confidenceRaw = (formData.get("confidence") as string | null)?.trim();
const confidence = confidenceRaw === "high" || confidenceRaw === "medium" || confidenceRaw === "low" ? confidenceRaw : null;
const req = await db.policyRequirement.findFirst({ where: { reqId, archivedAt: null }, select: { control: true } });
if (!req) throw new Error("Anforderung nicht gefunden.");
await db.controlDescription.upsert({
where: { tenantId_reqId: { tenantId, reqId } },
update: { draftText, sourceRef, confidence, status: "draft", updatedById: session.user.id },
create: { tenantId, control: req.control, reqId, draftText, sourceRef, confidence, status: "draft", updatedById: session.user.id },
});
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "control_description", entityId: reqId, after: { status: "draft", manual: true } });
revalidate();
}
/** Gezielte Rückfrage zum fehlenden Beleg beantworten/speichern (`openAnswer`). */
export async function saveOpenAnswer(reqId: string, formData: FormData) {
const { session, db } = await guard("onboarding:use");
const tenantId = session.user.tenantId;
const openAnswer = (formData.get("openAnswer") as string | null)?.trim() || null;
const req = await db.policyRequirement.findFirst({ where: { reqId, archivedAt: null }, select: { control: true } });
if (!req) throw new Error("Anforderung nicht gefunden.");
await db.controlDescription.upsert({
where: { tenantId_reqId: { tenantId, reqId } },
update: { openAnswer, updatedById: session.user.id },
create: { tenantId, control: req.control, reqId, openAnswer, updatedById: session.user.id },
});
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "control_description", entityId: reqId, after: { openAnswer: Boolean(openAnswer) } });
revalidate();
}
+73
View File
@@ -0,0 +1,73 @@
"use server";
import { revalidatePath } from "next/cache";
import type { ObjectReviewStatus } from "@prisma/client";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { buildGapItems } from "@/server/gap-context";
import { domainForControl, assignTaskParticipants } from "@/server/control-domain";
/**
* Gap-Konsolidierung (Story A8) — Server-Aktion für den GAP-Schritt des Audit-Wizards
* (M4, Step-Key „audit_gap"). Legt für konsolidierte offene Punkte ohne bestehende
* Aufgabe idempotente Aufgaben an (dedup-bewusst: ein Punkt = eine Aufgabe, C8 §2), mit
* Priorität aus C8 §1 und allen Control-/Risiko-Bezügen. Bereits verknüpfte Punkte
* werden nicht doppelt angelegt.
*/
const guard = moduleGuard("onboarding");
type Db = Awaited<ReturnType<typeof guard>>["db"];
// Fortschritts-Key des GAP-Schritts im Audit-Wizard (s. AUDIT_STEP_KEYS).
const GAP_STEP_KEY = "audit_gap";
async function nudgeStep(db: Db, tenantId: string) {
const prog = await db.onboardingProgress.findUnique({ where: { tenantId_stepKey: { tenantId, stepKey: GAP_STEP_KEY } } });
if (!prog || prog.status === "offen") {
const status: ObjectReviewStatus = "in_bearbeitung";
await db.onboardingProgress.upsert({
where: { tenantId_stepKey: { tenantId, stepKey: GAP_STEP_KEY } },
update: { status },
create: { tenantId, stepKey: GAP_STEP_KEY, status },
});
}
}
/** Aufgaben für alle konsolidierten offenen Punkte ohne bestehende Aufgabe anlegen. */
export async function createConsolidatedGapTasks() {
const { session, db } = await guard("onboarding:use");
const { items } = await buildGapItems(db, session.user.tenantId);
let created = 0;
for (const item of items) {
if (item.taskId) continue; // bereits verknüpfte Aufgabe → keine neue (C8 §2)
const exists = await db.task.findFirst({ where: { origin: item.taskOrigin, status: { in: ["PROPOSED", "OPEN"] } }, select: { id: true } });
if (exists) continue;
const links: Record<string, string> = {};
if (item.controls[0]) links.control = item.controls[0];
if (item.source === "risk") links.risk = item.id.replace(/^risk:/, "");
// Cockpit (M3, 1.4): Bereich aus dem führenden Control ableiten (falls vorhanden).
const domain = await domainForControl(db, session.user.tenantId, item.controls[0] ?? null);
const task = await db.task.create({
data: {
tenantId: session.user.tenantId,
type: "organizational",
title: item.action,
origin: item.taskOrigin,
status: "PROPOSED",
priority: item.priority,
createdById: session.user.id,
domain,
links,
},
});
// Cockpit (M3, 2.3): RACI-Mitwirkende automatisch aus Control/Domain befüllen.
await assignTaskParticipants(db, session.user.tenantId, { taskId: task.id, control: item.controls[0] ?? null, domain });
created++;
}
await nudgeStep(db, session.user.tenantId);
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "gap_consolidation", after: { points: items.length, created } });
revalidatePath("/audit-readiness");
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
+34
View File
@@ -0,0 +1,34 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { createTask } from "@/server/actions/tasks";
/**
* Aus einem C6-Umsetzungshinweis (Story B5-2) eine Aufgabe erzeugen — v. a. bei
* Beschaffungs-/Umsetzungsbedarf (`procurement`). Nutzt createTask (F1). Idempotent
* über `origin = "hint:<reqId>"`: existiert bereits ein offener Task, passiert nichts.
*/
const guard = moduleGuard("policies");
export async function createHintTask(reqId: string) {
const { db } = await guard("policy:write");
const hint = await db.implementationHint.findUnique({ where: { reqId } });
if (!hint) throw new Error("Kein Umsetzungshinweis gefunden.");
const origin = `hint:${reqId}`;
const existing = await db.task.findFirst({ where: { origin, status: { in: ["PROPOSED", "OPEN"] } }, select: { id: true } });
if (!existing) {
await createTask({
type: "technical",
title: `Umsetzung ${hint.control}: ${hint.requirement.slice(0, 90)}`,
origin,
priority: "mittel",
links: { control: hint.control },
});
}
revalidatePath("/tasks");
revalidatePath("/policies/hints");
redirect("/tasks");
}
+105
View File
@@ -0,0 +1,105 @@
"use server";
import { revalidatePath } from "next/cache";
import { requirePlatformFullAdmin } from "@/server/platform-auth";
import { prisma } from "@/server/db";
import { writePlatformAudit } from "@/server/audit";
import { generateIntakeToken } from "@/server/incident-inbound/route";
/**
* IM-D — Betreiber-Provisionierung der E-Mail-Intake-Konfiguration.
*
* Der Betreiber legt beim Onboarding (oder später) je Kunde die Intake-Konfiguration
* an: ein global eindeutiger Token wird erzeugt, daraus die Catch-all-Adresse
* `vorfall-<token>@in.certvia.de` abgeleitet. Der Kunde richtet dann bei sich eine
* Weiterleitung ein; sobald die erste Test-Mail als Ticket ankommt, springt der
* Status automatisch auf `verifiziert` (process.ts). Der Betreiber kann den Status
* hier zusätzlich manuell setzen/zurücksetzen.
*
* Plattform-Fähigkeit (eigene Auth über requirePlatformFullAdmin), kein Fachmodul-Guard.
* Alle Änderungen landen im Plattform-Audit.
*/
function parseDomains(raw: FormDataEntryValue | null): string[] {
if (!raw) return [];
return [
...new Set(
String(raw)
.split(/[\s,;]+/)
.map((d) => d.trim().toLowerCase().replace(/^@/, ""))
.filter((d) => d.length > 0 && d.includes(".")),
),
];
}
/** Intake-Konfiguration anlegen/aktualisieren (Onboarding + spätere Pflege). */
export async function provisionIncidentIntake(tenantId: string, formData: FormData) {
const { admin } = await requirePlatformFullAdmin();
if (!tenantId) throw new Error("Kein Mandant angegeben.");
const allowlistDomains = parseDomains(formData.get("allowlistDomains"));
const sourceRaw = String(formData.get("sourceAddress") ?? "").trim().toLowerCase();
const sourceAddress = sourceRaw && sourceRaw.includes("@") ? sourceRaw : null;
const existing = await prisma.incidentIntakeConfig.findUnique({ where: { tenantId }, select: { id: true } });
const row = existing
? await prisma.incidentIntakeConfig.update({
where: { tenantId },
data: { allowlistDomains, sourceAddress },
select: { id: true, token: true },
})
: await prisma.incidentIntakeConfig.create({
data: { tenantId, token: generateIntakeToken(), allowlistDomains, sourceAddress },
select: { id: true, token: true },
});
await writePlatformAudit({
actorId: admin.id,
action: existing ? "update" : "create",
entity: "incident_intake_config",
entityId: row.id,
after: { tenantId, allowlistDomains, sourceAddress },
});
revalidatePath(`/admin/${tenantId}`);
revalidatePath("/admin");
}
/** Provisionierungs-Status manuell setzen (verifiziert ⇄ weiterleitung_ausstehend). */
export async function setIncidentIntakeStatus(tenantId: string, verified: boolean) {
const { admin } = await requirePlatformFullAdmin();
const row = await prisma.incidentIntakeConfig.update({
where: { tenantId },
data: {
status: verified ? "verifiziert" : "weiterleitung_ausstehend",
verifiedAt: verified ? new Date() : null,
},
select: { id: true },
});
await writePlatformAudit({
actorId: admin.id,
action: "update",
entity: "incident_intake_config",
entityId: row.id,
after: { tenantId, status: verified ? "verifiziert" : "weiterleitung_ausstehend" },
});
revalidatePath(`/admin/${tenantId}`);
revalidatePath("/admin");
}
/** Betreiber-Review erledigen (Inbound-Mail ohne/mit unbekanntem Token gesichtet). */
export async function resolveInboundReview(reviewId: string, status: "erledigt" | "zugeordnet") {
const { admin } = await requirePlatformFullAdmin();
const row = await prisma.incidentInboundReview.update({
where: { id: reviewId },
data: { status },
select: { id: true },
});
await writePlatformAudit({
actorId: admin.id,
action: "update",
entity: "incident_inbound_review",
entityId: row.id,
after: { status },
});
revalidatePath("/admin");
}
+70
View File
@@ -0,0 +1,70 @@
"use server";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { generateIntakeToken } from "@/server/incident-inbound/route";
/**
* IM-D — Mandantenseitige Pflege der E-Mail-Intake-Konfiguration (Modul „Vorfälle").
*
* Der Kunden-Admin pflegt hier die Allowlist-Domänen, die optionale Quelladresse und
* den Benachrichtigungsempfänger. Die Intake-Adresse selbst (`vorfall-<token>@…`) wird
* NICHT hier gesetzt, sondern aus dem global eindeutigen Token abgeleitet und nur
* read-only angezeigt. Existiert noch keine Konfiguration (Betreiber hat nicht
* provisioniert), wird sie beim ersten Speichern mit frischem Token angelegt —
* Status bleibt `weiterleitung_ausstehend`, bis die erste Test-Mail ankommt.
*
* moduleGuard("incidents") stellt sicher, dass das Modul aktiv ist; die Bearbeitung
* verlangt zusätzlich `tenant:manage`.
*/
const guard = moduleGuard("incidents");
/** Domänenliste aus Textarea/Feld normalisieren (eine je Zeile oder komma-/leerzeichengetrennt). */
function parseDomains(raw: FormDataEntryValue | null): string[] {
if (!raw) return [];
return [
...new Set(
String(raw)
.split(/[\s,;]+/)
.map((d) => d.trim().toLowerCase().replace(/^@/, ""))
.filter((d) => d.length > 0 && d.includes(".")),
),
];
}
export async function updateIncidentIntake(formData: FormData) {
// Guard prüft aktives Modul „incidents" + tenant:manage (autoritativ aus DB, F-06).
const { session, db } = await guard("tenant:manage");
const tenantId = session.user.tenantId;
const allowlistDomains = parseDomains(formData.get("allowlistDomains"));
const sourceRaw = String(formData.get("sourceAddress") ?? "").trim().toLowerCase();
const sourceAddress = sourceRaw && sourceRaw.includes("@") ? sourceRaw : null;
const notifyRaw = String(formData.get("notifyEmail") ?? "").trim().toLowerCase();
const notifyEmail = notifyRaw && notifyRaw.includes("@") ? notifyRaw : null;
const existing = await db.incidentIntakeConfig.findUnique({ where: { tenantId }, select: { id: true } });
if (existing) {
await db.incidentIntakeConfig.update({
where: { tenantId },
data: { allowlistDomains, sourceAddress, notifyEmail },
});
} else {
// Erste Pflege durch den Kunden: Token erzeugen (global eindeutig; bei extrem
// unwahrscheinlicher Kollision greift der UNIQUE-Constraint → seltener Retry).
await db.incidentIntakeConfig.create({
data: { tenantId, token: generateIntakeToken(), allowlistDomains, sourceAddress, notifyEmail },
});
}
await writeAuditLog({
tenantId,
actorId: session.user.id,
action: "update",
entity: "incident_intake_config",
after: { allowlistDomains, sourceAddress, notifyEmail },
});
revalidatePath("/settings/incident-intake");
}
+872
View File
@@ -0,0 +1,872 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { prisma } from "@/server/db";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { severityFromIncident, INCIDENT_SEVERITIES } from "@/lib/incident-severity";
import { nextIncidentRefNo } from "@/server/incident-refno";
import {
INCIDENT_CATEGORIES,
INCIDENT_PRIORITIES,
INCIDENT_SOURCES,
INCIDENT_STATUSES,
canSeeRestricted,
canTransition,
missingRequiredFields,
type IncidentStatus,
} from "@/lib/incident";
import {
computeDeadlines,
isReportable,
knowledgeTime,
nextReportStatus,
REPORT_STATUS_ORDER,
type ReportStatus,
} from "@/lib/incident-deadlines";
import {
incidentManagerIds,
notifyIncidentEvent,
} from "@/server/mail/incident-notifications";
const guard = moduleGuard("incidents");
type GuardDb = Awaited<ReturnType<typeof guard>>["db"];
/**
* Meldepflicht-Felder (Fristen + Meldung-Track-Status) aus den Flags und dem
* Kenntniszeitpunkt berechnen (§6). NIS2-Timer greifen nur bei
* nis2Category ∈ {wichtig, wesentlich} UND nis2Relevant; DSGVO bei Personenbezug.
* Wird ein noch nicht gemeldeter Vorfall meldepflichtig, springt reportStatus
* `none → pruefung`; entfällt die Meldepflicht wieder, zurück auf `none`.
*/
async function reportingFieldsFor(
db: GuardDb,
tenantId: string,
flags: { nis2Relevant: boolean; dsgvoRelevant: boolean },
times: { detectedAt: Date | null; reportedAt: Date | null; occurredAt: Date | null },
currentReportStatus: string,
) {
const settings = await db.tenantSettings.findUnique({
where: { tenantId },
select: { nis2Category: true },
});
const nis2Category = settings?.nis2Category ?? "keine";
const knownAt = knowledgeTime({ ...times, createdAt: new Date() });
const deadlines = computeDeadlines({
nis2Category,
nis2Relevant: flags.nis2Relevant,
dsgvoRelevant: flags.dsgvoRelevant,
knownAt,
});
const reportable = isReportable({
nis2Category,
nis2Relevant: flags.nis2Relevant,
dsgvoRelevant: flags.dsgvoRelevant,
});
let reportStatus = currentReportStatus;
if (reportable) {
if (currentReportStatus === "none") reportStatus = "pruefung";
} else if (currentReportStatus === "none" || currentReportStatus === "pruefung") {
reportStatus = "none";
}
return { ...deadlines, reportStatus };
}
const impactScale = z.coerce.number().int().min(0).max(4);
const optDate = z
.string()
.trim()
.optional()
.transform((v) => (v ? new Date(v) : null));
const incidentBaseSchema = z.object({
title: z.string().trim().min(1).max(200),
description: z.string().trim().max(10000).optional(),
source: z.enum(INCIDENT_SOURCES).default("manual"),
reporterName: z.string().trim().max(200).optional(),
reporterContact: z.string().trim().max(200).optional(),
occurredAt: optDate,
detectedAt: optDate,
reportedAt: optDate,
category: z.enum(INCIDENT_CATEGORIES).default("other"),
impactC: impactScale,
impactI: impactScale,
impactA: impactScale,
urgency: impactScale,
affectedDataCategories: z.string().trim().max(2000).optional(),
personalData: z.coerce.boolean().default(false),
prototypeData: z.coerce.boolean().default(false),
priority: z.enum(INCIDENT_PRIORITIES).default("mittel"),
nis2Relevant: z.coerce.boolean().default(false),
});
function parseIncidentForm(formData: FormData) {
const p = incidentBaseSchema.parse({
title: formData.get("title"),
description: formData.get("description") || undefined,
source: formData.get("source") || undefined,
reporterName: formData.get("reporterName") || undefined,
reporterContact: formData.get("reporterContact") || undefined,
occurredAt: formData.get("occurredAt") || undefined,
detectedAt: formData.get("detectedAt") || undefined,
reportedAt: formData.get("reportedAt") || undefined,
category: formData.get("category") || undefined,
impactC: formData.get("impactC") ?? 0,
impactI: formData.get("impactI") ?? 0,
impactA: formData.get("impactA") ?? 0,
urgency: formData.get("urgency") ?? 2,
affectedDataCategories: formData.get("affectedDataCategories") || undefined,
personalData: formData.get("personalData") === "on" || formData.get("personalData") === "true",
prototypeData:
formData.get("prototypeData") === "on" || formData.get("prototypeData") === "true",
priority: formData.get("priority") || undefined,
nis2Relevant: formData.get("nis2Relevant") === "on" || formData.get("nis2Relevant") === "true",
});
const dataCategories = (p.affectedDataCategories ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
// Severity aus der Default-Matrix (§5); dsgvoRelevant folgt dem Personenbezug (§4).
const severity = severityFromIncident(p.impactC, p.impactI, p.impactA, p.urgency);
return {
title: p.title,
description: p.description || null,
source: p.source,
reporterName: p.reporterName || null,
reporterContact: p.reporterContact || null,
occurredAt: p.occurredAt,
detectedAt: p.detectedAt,
reportedAt: p.reportedAt,
category: p.category,
impactC: p.impactC,
impactI: p.impactI,
impactA: p.impactA,
urgency: p.urgency,
affectedDataCategories: dataCategories,
personalData: p.personalData,
prototypeData: p.prototypeData,
priority: p.priority,
severity,
nis2Relevant: p.nis2Relevant,
dsgvoRelevant: p.personalData,
};
}
export async function createIncident(formData: FormData) {
const { session, db } = await guard("incident:report");
const data = parseIncidentForm(formData);
const refNo = await nextIncidentRefNo(prisma, session.user.tenantId);
// Meldefristen (§6) aus Kenntniszeitpunkt setzen, falls bereits meldepflichtig.
const reporting = await reportingFieldsFor(
db,
session.user.tenantId,
{ nis2Relevant: data.nis2Relevant, dsgvoRelevant: data.dsgvoRelevant },
{ detectedAt: data.detectedAt, reportedAt: data.reportedAt, occurredAt: data.occurredAt },
"none",
);
const incident = await db.incident.create({
data: {
...data,
...reporting,
refNo,
tenantId: session.user.tenantId,
createdBy: session.user.id,
// Melder wird, sofern nicht gesetzt, mit dem anlegenden Nutzer initialisiert.
ownerId: null,
},
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "incident",
entityId: incident.id,
after: { refNo, title: data.title, severity: data.severity, status: "neu" },
});
// §7 — neuer Vorfall → ISB/Incident-Manager.
await notifyIncidentEvent({
tenantId: session.user.tenantId,
event: "incident_created",
incidentId: incident.id,
refNo,
title: data.title,
recipientIds: await incidentManagerIds(session.user.tenantId),
actorId: session.user.id,
});
revalidatePath("/incidents");
redirect(`/incidents?detail=${incident.id}`);
}
export async function updateIncident(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const before = await db.incident.findFirst({
where: { id: incidentId, tenantId: session.user.tenantId },
});
if (!before) throw new Error("Vorfall nicht gefunden");
const data = parseIncidentForm(formData);
// Meldepflicht/Fristen bei geänderten Flags/Zeiten neu berechnen (§6).
const reporting = await reportingFieldsFor(
db,
session.user.tenantId,
{ nis2Relevant: data.nis2Relevant, dsgvoRelevant: data.dsgvoRelevant },
{ detectedAt: data.detectedAt, reportedAt: data.reportedAt, occurredAt: data.occurredAt },
before.reportStatus,
);
await db.incident.update({ where: { id: incidentId }, data: { ...data, ...reporting } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "incident",
entityId: incidentId,
before: { title: before.title, severity: before.severity, category: before.category },
after: { title: data.title, severity: data.severity, category: data.category },
});
revalidatePath("/incidents");
redirect(`/incidents?detail=${incidentId}`);
}
/**
* Statuswechsel (§3) mit Pflichtfeld-Prüfung je Übergang (§8) und Audit-Eintrag.
* Übergang nach „abgeschlossen" verlangt das Recht incident:close, sonst manage.
*/
export async function transitionIncident(incidentId: string, formData: FormData) {
const target = z.enum(INCIDENT_STATUSES).parse(formData.get("status"));
const permission = target === "abgeschlossen" ? "incident:close" : "incident:manage";
const { session, db } = await guard(permission);
const before = await db.incident.findFirst({
where: { id: incidentId, tenantId: session.user.tenantId },
});
if (!before) throw new Error("Vorfall nicht gefunden");
const from = before.status as IncidentStatus;
if (from === target) throw new Error("Kein Statuswechsel (gleicher Status).");
if (!canTransition(from, target)) {
throw new Error(`Übergang ${from} → ${target} ist nicht erlaubt.`);
}
// Pflichtfelder je Zielstatus (§8): entweder bereits gesetzt oder in diesem Formular mitgeliefert.
const patch: Record<string, string | null> = {};
const textField = (key: string): string | null => {
const raw = formData.get(key);
return raw !== null && String(raw).trim() ? String(raw).trim() : null;
};
const effective: Record<string, unknown> = {
rootCause: before.rootCause,
resolution: before.resolution,
closingNote: before.closingNote,
lessonsLearned: before.lessonsLearned,
};
for (const field of ["rootCause", "resolution", "closingNote", "lessonsLearned"] as const) {
const provided = textField(field);
if (provided) {
patch[field] = provided;
effective[field] = provided;
}
}
// Optionale Abschluss-Zusatzfelder (§8 IM-C): Wirksamkeit der Maßnahmen und
// Post-Incident-Review — beim Abschluss mitschreibbar, nicht erzwungen.
for (const field of ["measuresEffectiveness", "postIncidentReview"] as const) {
const provided = textField(field);
if (provided) patch[field] = provided;
}
const missing = missingRequiredFields(target, effective);
if (missing.length > 0) {
throw new Error(`Pflichtfeld(er) für Status „${target}" fehlt/fehlen: ${missing.join(", ")}`);
}
// interne Meldung (reportedAt) beim ersten Verlassen von „neu" festhalten
const reportedAt = from === "neu" && !before.reportedAt ? new Date() : before.reportedAt;
// Fristen ggf. neu ableiten (Kenntniszeitpunkt kann sich durch reportedAt ändern).
const reporting = await reportingFieldsFor(
db,
session.user.tenantId,
{ nis2Relevant: before.nis2Relevant, dsgvoRelevant: before.dsgvoRelevant },
{ detectedAt: before.detectedAt, reportedAt, occurredAt: before.occurredAt },
before.reportStatus,
);
await db.incident.update({
where: { id: incidentId },
data: {
status: target,
...patch,
...reporting,
reportedAt,
},
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "incident",
entityId: incidentId,
before: { status: from },
after: { status: target },
});
// §7 — Statuswechsel an Beteiligte; Abschluss zusätzlich an Melder + Manager.
const beteiligte = [before.ownerId, before.assigneeId, before.createdBy];
if (target === "abgeschlossen") {
await notifyIncidentEvent({
tenantId: session.user.tenantId,
event: "incident_closed",
incidentId,
refNo: before.refNo,
title: before.title,
recipientIds: [...beteiligte, ...(await incidentManagerIds(session.user.tenantId))],
actorId: session.user.id,
});
} else {
await notifyIncidentEvent({
tenantId: session.user.tenantId,
event: "incident_status_changed",
incidentId,
refNo: before.refNo,
title: before.title,
recipientIds: beteiligte,
actorId: session.user.id,
detail: target,
dedupeSuffix: target,
});
}
revalidatePath("/incidents");
redirect(`/incidents?detail=${incidentId}`);
}
/**
* Meldepflicht setzen/prüfen (§3/§6): NIS2-Relevanz und Personenbezug markieren,
* Fristen aus dem Kenntniszeitpunkt (neu) berechnen und den Meldung-Track ggf.
* `none → pruefung` schalten. Recht incident:manage, mit Audit.
*/
export async function setIncidentReportability(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const before = await db.incident.findFirst({
where: { id: incidentId, tenantId: session.user.tenantId },
});
if (!before) throw new Error("Vorfall nicht gefunden");
const nis2Relevant =
formData.get("nis2Relevant") === "on" || formData.get("nis2Relevant") === "true";
const personalData =
formData.get("personalData") === "on" || formData.get("personalData") === "true";
const reporting = await reportingFieldsFor(
db,
session.user.tenantId,
{ nis2Relevant, dsgvoRelevant: personalData },
{ detectedAt: before.detectedAt, reportedAt: before.reportedAt, occurredAt: before.occurredAt },
before.reportStatus,
);
await db.incident.update({
where: { id: incidentId },
data: { nis2Relevant, personalData, dsgvoRelevant: personalData, ...reporting },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "incident",
entityId: incidentId,
before: {
nis2Relevant: before.nis2Relevant,
dsgvoRelevant: before.dsgvoRelevant,
reportStatus: before.reportStatus,
},
after: { nis2Relevant, dsgvoRelevant: personalData, reportStatus: reporting.reportStatus },
});
revalidatePath("/incidents");
redirect(`/incidents?detail=${incidentId}`);
}
/**
* Meldung-Track vorwärts schalten (§3 Parallel-Track): pruefung → Erstmeldung →
* Folgemeldung → Abschlussbericht. Übermittlung an die Behörde MANUELL — hier nur
* Status + Timer. Abschluss verlangt incident:close, sonst incident:manage. Audit.
*/
export async function advanceReportStatus(incidentId: string, formData: FormData) {
const target = z
.enum(["erstmeldung", "folgemeldung", "abschluss"])
.parse(formData.get("reportStatus"));
const permission = target === "abschluss" ? "incident:close" : "incident:manage";
const { session, db } = await guard(permission);
const before = await db.incident.findFirst({
where: { id: incidentId, tenantId: session.user.tenantId },
});
if (!before) throw new Error("Vorfall nicht gefunden");
const current = before.reportStatus as ReportStatus;
if (REPORT_STATUS_ORDER[current] === undefined || current === "none") {
throw new Error("Für diesen Vorfall besteht keine Meldepflicht (Track nicht aktiv).");
}
const allowed = nextReportStatus(current);
if (allowed !== target) {
throw new Error(`Meldung-Übergang ${current} → ${target} ist nicht erlaubt.`);
}
await db.incident.update({ where: { id: incidentId }, data: { reportStatus: target } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "incident",
entityId: incidentId,
before: { reportStatus: current },
after: { reportStatus: target },
});
await notifyIncidentEvent({
tenantId: session.user.tenantId,
event: "incident_status_changed",
incidentId,
refNo: before.refNo,
title: before.title,
recipientIds: [before.ownerId, before.assigneeId, before.createdBy],
actorId: session.user.id,
detail: `Meldung: ${target}`,
dedupeSuffix: `report_${target}`,
});
revalidatePath("/incidents");
redirect(`/incidents?detail=${incidentId}`);
}
export async function addIncidentComment(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:report");
const body = z.string().trim().min(1).max(5000).parse(formData.get("body"));
const internal = formData.get("internal") === "on" || formData.get("internal") === "true";
const incident = await db.incident.findFirst({
where: { id: incidentId, tenantId: session.user.tenantId },
select: { id: true },
});
if (!incident) throw new Error("Vorfall nicht gefunden");
await db.incidentComment.create({
data: { tenantId: session.user.tenantId, incidentId, authorId: session.user.id, body, internal },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "incident_comment",
entityId: incidentId,
});
revalidatePath("/incidents");
redirect(`/incidents?detail=${incidentId}`);
}
export async function editIncidentComment(commentId: string, formData: FormData) {
const { session, db } = await guard("incident:report");
const body = z.string().trim().min(1).max(5000).parse(formData.get("body"));
const before = await db.incidentComment.findFirst({
where: { id: commentId, tenantId: session.user.tenantId },
select: { id: true, authorId: true, incidentId: true },
});
if (!before) throw new Error("Kommentar nicht gefunden");
// Nur der Autor darf seinen Kommentar bearbeiten (sofern nicht manage/close).
if (before.authorId !== session.user.id && !canSeeRestricted(session)) {
throw new Error("Nur der Autor darf den Kommentar bearbeiten.");
}
await db.incidentComment.update({ where: { id: commentId }, data: { body } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "incident_comment",
entityId: before.incidentId,
});
revalidatePath("/incidents");
redirect(`/incidents?detail=${before.incidentId}`);
}
async function requireIncident(
db: Awaited<ReturnType<typeof guard>>["db"],
tenantId: string,
incidentId: string,
) {
const inc = await db.incident.findFirst({
where: { id: incidentId, tenantId },
select: { id: true },
});
if (!inc) throw new Error("Vorfall nicht gefunden");
}
export async function linkIncidentAsset(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const assetId = z.string().min(1).parse(formData.get("assetId"));
await requireIncident(db, session.user.tenantId, incidentId);
if ((await db.asset.count({ where: { id: assetId } })) !== 1) throw new Error("Asset nicht gefunden");
await db.incidentAsset.upsert({
where: { incidentId_assetId: { incidentId, assetId } },
update: {},
create: { tenantId: session.user.tenantId, incidentId, assetId },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "incident_asset", entityId: incidentId, after: { assetId } });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
export async function unlinkIncidentAsset(incidentId: string, linkId: string) {
const { session, db } = await guard("incident:manage");
const row = await db.incidentAsset.findFirst({ where: { id: linkId, tenantId: session.user.tenantId } });
if (row) await db.incidentAsset.delete({ where: { id: linkId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "incident_asset", entityId: incidentId });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
export async function linkIncidentProcess(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const processId = z.string().min(1).parse(formData.get("processId"));
await requireIncident(db, session.user.tenantId, incidentId);
if ((await db.process.count({ where: { id: processId } })) !== 1) throw new Error("Prozess nicht gefunden");
await db.incidentProcess.upsert({
where: { incidentId_processId: { incidentId, processId } },
update: {},
create: { tenantId: session.user.tenantId, incidentId, processId },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "incident_process", entityId: incidentId, after: { processId } });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
export async function unlinkIncidentProcess(incidentId: string, linkId: string) {
const { session, db } = await guard("incident:manage");
const row = await db.incidentProcess.findFirst({ where: { id: linkId, tenantId: session.user.tenantId } });
if (row) await db.incidentProcess.delete({ where: { id: linkId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "incident_process", entityId: incidentId });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
export async function linkIncidentRisk(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const riskId = z.string().min(1).parse(formData.get("riskId"));
await requireIncident(db, session.user.tenantId, incidentId);
if ((await db.risk.count({ where: { id: riskId } })) !== 1) throw new Error("Risiko nicht gefunden");
await db.incidentRisk.upsert({
where: { incidentId_riskId: { incidentId, riskId } },
update: {},
create: { tenantId: session.user.tenantId, incidentId, riskId },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "incident_risk", entityId: incidentId, after: { riskId } });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
export async function unlinkIncidentRisk(incidentId: string, linkId: string) {
const { session, db } = await guard("incident:manage");
const row = await db.incidentRisk.findFirst({ where: { id: linkId, tenantId: session.user.tenantId } });
if (row) await db.incidentRisk.delete({ where: { id: linkId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "incident_risk", entityId: incidentId });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
export async function linkIncidentControl(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const controlRef = z.string().trim().min(1).max(50).parse(formData.get("controlRef"));
await requireIncident(db, session.user.tenantId, incidentId);
await db.incidentControl.upsert({
where: { incidentId_controlRef: { incidentId, controlRef } },
update: {},
create: { tenantId: session.user.tenantId, incidentId, controlRef },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "incident_control", entityId: incidentId, after: { controlRef } });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
export async function unlinkIncidentControl(incidentId: string, linkId: string) {
const { session, db } = await guard("incident:manage");
const row = await db.incidentControl.findFirst({ where: { id: linkId, tenantId: session.user.tenantId } });
if (row) await db.incidentControl.delete({ where: { id: linkId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "incident_control", entityId: incidentId });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
/** Verknüpfung zum zentralen Maßnahmen-Modul (§9) — nur verknüpfen, Detailausbau IM-C. */
export async function linkIncidentMeasure(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const measureId = z.string().min(1).parse(formData.get("measureId"));
await requireIncident(db, session.user.tenantId, incidentId);
if ((await db.measure.count({ where: { id: measureId } })) !== 1) throw new Error("Maßnahme nicht gefunden");
await db.incidentMeasure.upsert({
where: { incidentId_measureId: { incidentId, measureId } },
update: {},
create: { tenantId: session.user.tenantId, incidentId, measureId },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "incident_measure", entityId: incidentId, after: { measureId } });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
export async function unlinkIncidentMeasure(incidentId: string, linkId: string) {
const { session, db } = await guard("incident:manage");
const row = await db.incidentMeasure.findFirst({ where: { id: linkId, tenantId: session.user.tenantId } });
if (row) await db.incidentMeasure.delete({ where: { id: linkId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "incident_measure", entityId: incidentId });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
/** Gültige aktive Nutzer-ID des eigenen Mandanten oder null (für FK-Sicherheit). */
async function validOwnerOrNull(db: GuardDb, ownerId: string | null): Promise<string | null> {
if (!ownerId) return null;
return (await db.user.count({ where: { id: ownerId, status: "ACTIVE" } })) === 1 ? ownerId : null;
}
/**
* §9 — „Maßnahme direkt aus dem Vorfall anlegen": legt eine Maßnahme im ZENTRALEN
* Maßnahmen-Modul an (nicht doppelt) und verknüpft sie mit dem Vorfall
* (IncidentMeasure). Verlangt incident:manage UND measure:write.
*/
export async function createMeasureForIncident(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage", "measure:write");
await requireIncident(db, session.user.tenantId, incidentId);
const title = z.string().trim().min(1).max(200).parse(formData.get("title"));
const description = (formData.get("description") as string | null)?.trim() || null;
const priority = z.enum(["LOW", "MEDIUM", "HIGH"]).catch("MEDIUM").parse(formData.get("priority") ?? "MEDIUM");
const ownerId = await validOwnerOrNull(db, (formData.get("ownerId") as string | null)?.trim() || null);
const dueRaw = (formData.get("dueDate") as string | null)?.trim() || null;
const last = await prisma.measure.aggregate({
where: { tenantId: session.user.tenantId },
_max: { refNo: true },
});
const measure = await db.measure.create({
data: {
title,
description,
priority,
ownerId,
dueDate: dueRaw ? new Date(dueRaw) : null,
refNo: (last._max.refNo ?? 0) + 1,
tenantId: session.user.tenantId,
createdBy: session.user.id,
},
});
await db.incidentMeasure.create({
data: { tenantId: session.user.tenantId, incidentId, measureId: measure.id },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "measure", entityId: measure.id, after: { title, fromIncident: incidentId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "incident_measure", entityId: incidentId, after: { measureId: measure.id, created: true } });
revalidatePath("/incidents");
revalidatePath("/measures");
redirect(`/incidents?edit=${incidentId}`);
}
/**
* §9 — Vorfall erzeugt ein NEUES Risiko: legt es im Risiko-Modul an (Register)
* und verknüpft es (IncidentRisk). Verlangt incident:manage UND risk:write.
* (Ein BESTEHENDES Risiko bestätigt der Vorfall über linkIncidentRisk.)
*/
export async function createRiskForIncident(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage", "risk:write");
await requireIncident(db, session.user.tenantId, incidentId);
const title = z.string().trim().min(1).max(200).parse(formData.get("title"));
const description = (formData.get("description") as string | null)?.trim() || null;
const likelihood = z.coerce.number().int().min(1).max(5).catch(3).parse(formData.get("likelihood") ?? 3);
const impact = z.coerce.number().int().min(1).max(5).catch(3).parse(formData.get("impact") ?? 3);
const last = await prisma.risk.aggregate({
where: { tenantId: session.user.tenantId },
_max: { refNo: true },
});
const risk = await db.risk.create({
data: {
title,
description,
likelihood,
impact,
score: likelihood * impact,
refNo: (last._max.refNo ?? 0) + 1,
tenantId: session.user.tenantId,
createdBy: session.user.id,
},
});
await db.incidentRisk.create({
data: { tenantId: session.user.tenantId, incidentId, riskId: risk.id },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "risk", entityId: risk.id, after: { title, fromIncident: incidentId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "incident_risk", entityId: incidentId, after: { riskId: risk.id, created: true } });
revalidatePath("/incidents");
revalidatePath("/risks");
redirect(`/incidents?edit=${incidentId}`);
}
/** Bestehenden Nachweis (zentrales Evidence-Modell) mit dem Vorfall verknüpfen. */
export async function linkIncidentEvidence(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const evidenceId = z.string().min(1).parse(formData.get("evidenceId"));
const note = (formData.get("note") as string | null)?.trim() || null;
await requireIncident(db, session.user.tenantId, incidentId);
if ((await db.evidence.count({ where: { id: evidenceId } })) !== 1) throw new Error("Nachweis nicht gefunden");
await db.incidentEvidence.upsert({
where: { incidentId_evidenceId: { incidentId, evidenceId } },
update: { note },
create: { tenantId: session.user.tenantId, incidentId, evidenceId, note },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "incident_evidence", entityId: incidentId, after: { evidenceId } });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
/**
* Neuen Nachweis (Referenz/Text) direkt am Vorfall anlegen und verknüpfen — als
* Beweissicherung (ISO A.5.28); Datei-Persistenz folgt mit dem Storage-Paket.
* Verlangt incident:manage UND evidence:write.
*/
export async function createEvidenceForIncident(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage", "evidence:write");
await requireIncident(db, session.user.tenantId, incidentId);
const title = z.string().trim().min(1).max(200).parse(formData.get("title"));
const kind = z.enum(["record", "protocol", "screenshot", "export"]).catch("record").parse(formData.get("kind") ?? "record");
const fileRef = (formData.get("fileRef") as string | null)?.trim() || null;
const note = (formData.get("note") as string | null)?.trim() || null;
const ev = await db.evidence.create({
data: { tenantId: session.user.tenantId, title, kind, fileRef, createdById: session.user.id },
});
await db.incidentEvidence.create({
data: { tenantId: session.user.tenantId, incidentId, evidenceId: ev.id, note },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "incident_evidence", entityId: incidentId, after: { evidenceId: ev.id, created: true } });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
export async function unlinkIncidentEvidence(incidentId: string, linkId: string) {
const { session, db } = await guard("incident:manage");
const row = await db.incidentEvidence.findFirst({ where: { id: linkId, tenantId: session.user.tenantId } });
if (row) await db.incidentEvidence.delete({ where: { id: linkId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "incident_evidence", entityId: incidentId });
revalidatePath("/incidents");
redirect(`/incidents?edit=${incidentId}`);
}
/**
* §8 — Post-Incident-Review (Kurzbericht) + Wirksamkeit der Maßnahmen setzen.
* Der Review-Text speist das Management-Review (als Feld/Notiz, da kein eigenes
* Review-Modul). Recht incident:manage.
*/
export async function setIncidentReview(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
await requireIncident(db, session.user.tenantId, incidentId);
const measuresEffectiveness = (formData.get("measuresEffectiveness") as string | null)?.trim() || null;
const postIncidentReview = (formData.get("postIncidentReview") as string | null)?.trim() || null;
await db.incident.update({ where: { id: incidentId }, data: { measuresEffectiveness, postIncidentReview } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "incident", entityId: incidentId, after: { postIncidentReview: Boolean(postIncidentReview), measuresEffectiveness: Boolean(measuresEffectiveness) } });
revalidatePath("/incidents");
redirect(`/incidents?detail=${incidentId}`);
}
/** Severity/Kategorie manuell setzen (überschreibt/bestätigt die Default-Matrix). */
export async function setIncidentSeverity(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const severity = z.enum(INCIDENT_SEVERITIES).parse(formData.get("severity"));
await requireIncident(db, session.user.tenantId, incidentId);
await db.incident.update({ where: { id: incidentId }, data: { severity } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "incident", entityId: incidentId, after: { severity } });
revalidatePath("/incidents");
redirect(`/incidents?detail=${incidentId}`);
}
export async function setIncidentCategory(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const category = z.enum(INCIDENT_CATEGORIES).parse(formData.get("category"));
await requireIncident(db, session.user.tenantId, incidentId);
await db.incident.update({ where: { id: incidentId }, data: { category } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "incident", entityId: incidentId, after: { category } });
revalidatePath("/incidents");
redirect(`/incidents?detail=${incidentId}`);
}
/** Vertraulichkeit umschalten (§11). Nur manage/close. */
export async function setIncidentRestricted(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
const restricted = formData.get("restricted") === "on" || formData.get("restricted") === "true";
await requireIncident(db, session.user.tenantId, incidentId);
await db.incident.update({ where: { id: incidentId }, data: { restricted } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "incident", entityId: incidentId, after: { restricted } });
revalidatePath("/incidents");
redirect(`/incidents?detail=${incidentId}`);
}
/** Owner (Incident-Manager) und Bearbeiter (assignee) setzen (§3 Steuerung). */
export async function setIncidentOwners(incidentId: string, formData: FormData) {
const { session, db } = await guard("incident:manage");
await requireIncident(db, session.user.tenantId, incidentId);
const ownerId = (formData.get("ownerId") as string | null)?.trim() || null;
const assigneeId = (formData.get("assigneeId") as string | null)?.trim() || null;
// Zuweisungen müssen aktive Nutzer des eigenen Mandanten sein.
for (const uid of [ownerId, assigneeId]) {
if (uid && (await db.user.count({ where: { id: uid, status: "ACTIVE" } })) !== 1) {
throw new Error("Zugewiesener Nutzer ist ungültig.");
}
}
const inc = await db.incident.findFirst({
where: { id: incidentId, tenantId: session.user.tenantId },
select: { refNo: true, title: true, assigneeId: true },
});
await db.incident.update({ where: { id: incidentId }, data: { ownerId, assigneeId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "incident", entityId: incidentId, after: { ownerId, assigneeId } });
// §7 — Zuweisung → Bearbeiter (nur bei echter Änderung des Bearbeiters).
if (assigneeId && assigneeId !== inc?.assigneeId && inc) {
await notifyIncidentEvent({
tenantId: session.user.tenantId,
event: "incident_assigned",
incidentId,
refNo: inc.refNo,
title: inc.title,
recipientIds: [assigneeId],
actorId: session.user.id,
});
}
revalidatePath("/incidents");
redirect(`/incidents?detail=${incidentId}`);
}
+116
View File
@@ -0,0 +1,116 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/server/db";
import { requirePlatformSession } from "@/server/platform-auth";
import { writePlatformAudit } from "@/server/audit";
import { getMailConfig } from "@/server/mail/config";
import { enqueueMail } from "@/server/mail/service";
import { isQueueEnabled } from "@/server/mail/queue";
import { formatWhen, normalizeLocale } from "@/server/mail/templates";
/**
* SEC1 §9 — Admin-Testversand.
*
* EXEMPT vom Modul-Gating: Betriebsfunktion der Plattform-Administration, kein
* mandantengebundenes Fachmodul. Autorisierung läuft über
* `requirePlatformSession()` (getrennte Auth-Domäne, prüft Session positiv und
* den Kontostatus autoritativ aus der DB), nicht über `moduleGuard`.
*
* Der Versand geht bewusst nur an die **eigene** Adresse des angemeldeten
* Plattform-Admins — die Aktion soll die Zustellstrecke prüfen, nicht als
* Mail-Relay für beliebige Empfänger dienen.
*/
export type TestMailState =
| { status: "idle" }
| { status: "ok"; message: string }
| { status: "error"; message: string };
export async function sendTestMail(): Promise<TestMailState> {
try {
const session = await requirePlatformSession();
const admin = await prisma.platformAdmin.findUnique({
where: { id: session.user.id },
select: { id: true, email: true, name: true },
});
if (!admin) return { status: "error", message: "Konto nicht gefunden." };
const { config, reason } = getMailConfig();
if (!config) return { status: "error", message: reason ?? "SMTP ist nicht konfiguriert." };
const locale = normalizeLocale("de");
const result = await enqueueMail({
template: "test",
to: admin.email,
// Plattform-Mail ohne Mandantenbezug → scope=platform, tenantId=null.
tenantId: null,
locale,
vars: { name: admin.name, when: formatWhen(new Date(), locale) },
});
await writePlatformAudit({
actorId: admin.id,
action: "create",
entity: "mail_test",
entityId: "mailLogId" in result ? result.mailLogId : undefined,
after: { to: admin.email, status: result.status },
});
revalidatePath("/admin");
switch (result.status) {
case "sent":
return { status: "ok", message: `Test-Mail an ${admin.email} versendet.` };
case "queued":
return {
status: "ok",
message: `Test-Mail an ${admin.email} eingestellt — die Zustellung übernimmt der Worker.`,
};
case "duplicate":
return { status: "ok", message: "Es liegt bereits eine identische Mail in der Warteschlange." };
case "not_configured":
return { status: "error", message: result.reason };
case "error":
return { status: "error", message: `Versand fehlgeschlagen: ${result.error}` };
}
} catch (err) {
// Kein harter Wurf → globale Error-Boundary: fehlende Plattform-Session o. Ä.
// wird als anzeigbare Meldung zurückgegeben.
return { status: "error", message: err instanceof Error ? err.message : "Test-Versand fehlgeschlagen." };
}
}
/** Betriebszustand der Mail-Strecke für die Anzeige im Adminportal. */
export async function getMailStatus(): Promise<{
configured: boolean;
reason?: string;
host?: string;
from?: string;
queue: "bullmq" | "inline";
recent: { id: string; to: string; template: string; status: string; error: string | null; createdAt: Date }[];
}> {
try {
await requirePlatformSession();
const { config, reason } = getMailConfig();
const recent = await prisma.mailLog.findMany({
orderBy: { createdAt: "desc" },
take: 10,
select: { id: true, to: true, template: true, status: true, error: true, createdAt: true },
});
return {
configured: config != null,
reason,
host: config ? `${config.host}:${config.port}` : undefined,
from: config?.from,
queue: isQueueEnabled() ? "bullmq" : "inline",
recent,
};
} catch (err) {
// Defensive: nie die (platform)/admin-Seite mit einer Ausnahme zerlegen.
return { configured: false, reason: err instanceof Error ? err.message : "Mailstatus nicht verfügbar.", queue: isQueueEnabled() ? "bullmq" : "inline", recent: [] };
}
}
+253
View File
@@ -0,0 +1,253 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { prisma } from "@/server/db";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
const guard = moduleGuard("measures");
import { recomputeResidualRisk } from "@/server/risk-calc";
const measureSchema = z.object({
title: z.string().trim().min(1).max(200),
description: z.string().trim().max(5000).optional(),
status: z.enum(["OPEN", "IN_PROGRESS", "DONE"]),
priority: z.enum(["LOW", "MEDIUM", "HIGH"]),
ownerId: z.string().optional(),
dueDate: z.string().optional(),
});
// Minderung dezimal (0,00–4,00) — Komma-Eingaben werden akzeptiert
const reduction = z
.preprocess(
(v) => (typeof v === "string" ? v.replace(",", ".") : v),
z.coerce.number().min(0).max(4)
)
.transform((v) => Math.round(v * 100) / 100);
function parseMeasureForm(formData: FormData) {
const parsed = measureSchema.parse({
title: formData.get("title"),
description: formData.get("description") || undefined,
status: formData.get("status") ?? "OPEN",
priority: formData.get("priority") ?? "MEDIUM",
ownerId: formData.get("ownerId") || undefined,
dueDate: formData.get("dueDate") || undefined,
});
return {
title: parsed.title,
description: parsed.description || null,
status: parsed.status,
priority: parsed.priority,
ownerId: parsed.ownerId || null,
dueDate: parsed.dueDate ? new Date(parsed.dueDate) : null,
};
}
async function nextRefNo(tenantId: string) {
const last = await prisma.measure.aggregate({
where: { tenantId },
_max: { refNo: true },
});
return (last._max.refNo ?? 0) + 1;
}
export async function createMeasure(formData: FormData) {
const { session, db } = await guard("measure:write");
const data = parseMeasureForm(formData);
const measure = await db.measure.create({
data: {
...data,
refNo: await nextRefNo(session.user.tenantId),
tenantId: session.user.tenantId,
createdBy: session.user.id,
},
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "measure",
entityId: measure.id,
after: data,
});
revalidatePath("/measures");
redirect(`/measures?detail=${measure.id}`);
}
export async function updateMeasure(measureId: string, formData: FormData) {
const { session, db } = await guard("measure:write");
const before = await db.measure.findUnique({ where: { id: measureId } });
if (!before) throw new Error("Maßnahme nicht gefunden");
const data = parseMeasureForm(formData);
await db.measure.update({ where: { id: measureId }, data });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "measure",
entityId: measureId,
before,
after: data,
});
revalidatePath("/measures");
revalidatePath("/risks");
redirect(`/measures?detail=${measureId}`);
}
/** Status-Wechsel per Drag-and-Drop auf dem Kanban-Board. */
export async function updateMeasureStatus(measureId: string, status: string) {
const { session, db } = await guard("measure:write");
const parsed = z.enum(["OPEN", "IN_PROGRESS", "DONE"]).parse(status);
const before = await db.measure.findUnique({ where: { id: measureId } });
if (!before || before.status === parsed) return;
await db.measure.update({ where: { id: measureId }, data: { status: parsed } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "measure",
entityId: measureId,
before: { status: before.status },
after: { status: parsed },
});
revalidatePath("/measures");
revalidatePath("/risks");
}
export async function deleteMeasure(measureId: string) {
const { session, db } = await guard("measure:write");
const before = await db.measure.findUnique({
where: { id: measureId },
include: { riskMeasures: { select: { riskId: true } } },
});
if (!before) throw new Error("Maßnahme nicht gefunden");
await db.measure.delete({ where: { id: measureId } });
// Rest-Risiken der betroffenen Risiken neu berechnen (Links kaskadiert gelöscht)
for (const rm of before.riskMeasures) {
await recomputeResidualRisk(db, rm.riskId);
}
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "measure",
entityId: measureId,
before,
});
revalidatePath("/measures");
revalidatePath("/risks");
redirect("/measures");
}
/** Bestehende Maßnahme mit einem Risiko verknüpfen (inkl. Minderung). */
export async function linkMeasureToRisk(riskId: string, formData: FormData) {
const { session, db } = await guard("risk:write");
const measureId = z.string().min(1).parse(formData.get("measureId"));
const reductionLikelihood = reduction.parse(formData.get("reductionLikelihood") ?? 0);
const reductionImpact = reduction.parse(formData.get("reductionImpact") ?? 0);
const riskCount = await db.risk.count({ where: { id: riskId } });
const measureCount = await db.measure.count({ where: { id: measureId } });
if (riskCount !== 1 || measureCount !== 1) throw new Error("Nicht gefunden");
await db.riskMeasure.upsert({
where: { riskId_measureId: { riskId, measureId } },
update: { reductionLikelihood, reductionImpact },
create: {
riskId,
measureId,
reductionLikelihood,
reductionImpact,
tenantId: session.user.tenantId,
},
});
await recomputeResidualRisk(db, riskId);
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "risk_measure",
entityId: riskId,
after: { riskId, measureId, reductionLikelihood, reductionImpact },
});
revalidatePath("/risks");
revalidatePath("/measures");
}
/** Neue Maßnahme direkt aus dem Risiko heraus anlegen und verknüpfen (SPEC §4.2). */
export async function createMeasureForRisk(riskId: string, formData: FormData) {
const { session, db } = await guard("risk:write", "measure:write");
const riskCount = await db.risk.count({ where: { id: riskId } });
if (riskCount !== 1) throw new Error("Risiko nicht gefunden");
const title = z.string().trim().min(1).max(200).parse(formData.get("title"));
const reductionLikelihood = reduction.parse(formData.get("reductionLikelihood") ?? 0);
const reductionImpact = reduction.parse(formData.get("reductionImpact") ?? 0);
const measure = await db.measure.create({
data: {
title,
refNo: await nextRefNo(session.user.tenantId),
tenantId: session.user.tenantId,
createdBy: session.user.id,
},
});
await db.riskMeasure.create({
data: {
riskId,
measureId: measure.id,
reductionLikelihood,
reductionImpact,
tenantId: session.user.tenantId,
},
});
await recomputeResidualRisk(db, riskId);
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "measure",
entityId: measure.id,
after: { title, linkedRisk: riskId, reductionLikelihood, reductionImpact },
});
revalidatePath("/risks");
revalidatePath("/measures");
}
export async function unlinkMeasureFromRisk(riskId: string, riskMeasureId: string) {
const { session, db } = await guard("risk:write");
const before = await db.riskMeasure.findUnique({ where: { id: riskMeasureId } });
if (!before) return;
await db.riskMeasure.delete({ where: { id: riskMeasureId } });
await recomputeResidualRisk(db, riskId);
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "risk_measure",
entityId: riskMeasureId,
before,
});
revalidatePath("/risks");
revalidatePath("/measures");
}
+62
View File
@@ -0,0 +1,62 @@
"use server";
import { Prisma } from "@prisma/client";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { WIZARD_QUESTIONS } from "@/lib/onboarding/questions";
import { deriveResult } from "@/lib/onboarding/facts";
import { getAssessmentLevel, protectionFlags } from "@/server/assessment-level";
import { proposeTasksFromTriggers } from "@/server/actions/tasks";
/**
* Fragebogen (Story B3, Schritt „context"): Antworten als WizardFacts speichern und
* die Wirkung propagieren — Flags via B2-Regel-Engine ableiten, Aufgaben-Vorschläge
* via B1 (proposeTasksFromTriggers) erzeugen. Schreibt NUR Fakten/Flags, nie die
* gesperrten Zentralvariablen. Nur sichtbare (übermittelte) Fragen werden geschrieben.
*/
const guard = moduleGuard("onboarding");
export async function saveFacts(formData: FormData) {
const { session, db } = await guard("onboarding:use");
let saved = 0;
for (const q of WIZARD_QUESTIONS) {
const raw = formData.get(q.id);
if (raw === null) continue; // nicht sichtbar/nicht übermittelt → Bestand bleibt
const value: Prisma.InputJsonValue = q.type === "boolean" ? String(raw) === "true" : String(raw);
await db.wizardFact.upsert({
where: { tenantId_key: { tenantId: session.user.tenantId, key: q.id } },
update: { value, section: q.section },
create: { tenantId: session.user.tenantId, key: q.id, section: q.section, value },
});
saved++;
}
// Propagation via B2: alle Fakten → Flags + Aufgaben-Trigger. Schutzbedarf-Flags
// zentral aus dem Assessment-Level (A2-1), Prüfziele zentral aus WizardScope (A2-2).
const facts = await db.wizardFact.findMany({ select: { key: true, value: true } });
const level = await getAssessmentLevel(db);
const scope = await db.wizardScope.findFirst({ select: { pruefziele: true } });
const result = deriveResult(facts, protectionFlags(level), scope?.pruefziele ?? ["informationssicherheit"]);
const proposed = result.tasks.length > 0 ? await proposeTasksFromTriggers(result.tasks) : { created: 0, skipped: 0 };
// State-Machine: Schritt „context" von offen → in_bearbeitung anschieben.
const prog = await db.onboardingProgress.findUnique({ where: { tenantId_stepKey: { tenantId: session.user.tenantId, stepKey: "context" } } });
if (!prog || prog.status === "offen") {
await db.onboardingProgress.upsert({
where: { tenantId_stepKey: { tenantId: session.user.tenantId, stepKey: "context" } },
update: { status: "in_bearbeitung" },
create: { tenantId: session.user.tenantId, stepKey: "context", status: "in_bearbeitung" },
});
}
await writeAuditLog({
tenantId: session.user.tenantId, actorId: session.user.id,
action: "update", entity: "wizard_facts",
after: { saved, activeFlags: Object.keys(result.flags).filter((k) => result.flags[k]), proposedTasks: proposed.created },
});
revalidatePath("/onboarding");
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
+63
View File
@@ -0,0 +1,63 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { createTask } from "@/server/actions/tasks";
import type { TaskType } from "@/lib/tasks";
import type { TenantDb } from "@/server/db";
/**
* Task-Erzeugung für die Wizard-Schritte 4 (policies) und 6 (risks). Setzen auf den
* Bestandsmodulen /policies und /risks auf (keine Doppel-Datenhaltung); die Aktionen
* erzeugen für erkannte Lücken Aufgaben (F1), idempotent je `origin`.
*/
const guard = moduleGuard("onboarding");
interface Gap { origin: string; title: string; type: TaskType; }
async function createGapTasks(db: TenantDb, gaps: Gap[]): Promise<number> {
let created = 0;
for (const g of gaps) {
const existing = await db.task.findFirst({ where: { origin: g.origin, status: { in: ["PROPOSED", "OPEN"] } }, select: { id: true } });
if (existing) continue;
await createTask({ type: g.type, title: g.title, origin: g.origin, priority: "mittel" });
created++;
}
return created;
}
/** Schritt 4 (policies): Lücken im Richtlinienmodul → Aufgaben. */
export async function createPolicyGapTasks() {
const { db } = await guard("onboarding:use");
const [state, entwurf, openApprovals] = await Promise.all([
db.policyPackageState.findFirst({ select: { importedVersion: true } }),
db.policyDocument.count({ where: { archivedAt: null, status: "ENTWURF" } }),
db.task.count({ where: { type: "policy_approval", status: "OPEN" } }),
]);
const gaps: Gap[] = [];
if (!state?.importedVersion) gaps.push({ origin: "wizard-policies:import", title: "Richtlinien-Vorlagenpaket übernehmen (Paket-Updates)", type: "document_create" });
if (entwurf > 0) gaps.push({ origin: "wizard-policies:entwurf", title: `${entwurf} Richtlinie(n) im Entwurf zur Freigabe bringen`, type: "organizational" });
if (openApprovals === 0 && entwurf > 0) gaps.push({ origin: "wizard-policies:freigabe", title: "Freigabe-Workflow für offene Richtlinien starten", type: "organizational" });
await createGapTasks(db, gaps);
revalidatePath("/tasks");
redirect("/onboarding?step=policy");
}
/** Schritt 6 (risks): Lücken im Risikomodul → Aufgaben. */
export async function createRiskGapTasks() {
const { db } = await guard("onboarding:use");
const [risks, highUntreated, catalogTotal, adopted] = await Promise.all([
db.risk.count(),
db.risk.count({ where: { score: { gte: 10 }, treatment: "MITIGATE", riskMeasures: { none: {} } } }),
db.riskCatalogEntry.count(),
db.risk.count({ where: { catalogCode: { not: null } } }),
]);
const gaps: Gap[] = [];
if (risks === 0) gaps.push({ origin: "wizard-risks:empty", title: "Risikoregister aufbauen — Standard-Risikokatalog sichten und relevante Risiken übernehmen", type: "organizational" });
else if (adopted === 0 && catalogTotal > 0) gaps.push({ origin: "wizard-risks:catalog", title: "Standard-Risikokatalog (C4) auf Relevanz prüfen und Risiken übernehmen", type: "organizational" });
if (highUntreated > 0) gaps.push({ origin: "wizard-risks:high-untreated", title: `${highUntreated} hohe(s) Risiko/Risiken ohne verknüpfte Maßnahme behandeln`, type: "technical" });
await createGapTasks(db, gaps);
revalidatePath("/tasks");
redirect("/onboarding?step=risks");
}
+210
View File
@@ -0,0 +1,210 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import type { Domain, ObjectReviewStatus } from "@prisma/client";
import { moduleGuard } from "@/server/action-guard";
import { prisma } from "@/server/db";
import { writeAuditLog } from "@/server/audit";
import { getFunctionDef } from "@/lib/onboarding/functions";
import { hashPassword, generateCompliantPassword } from "@/server/password";
import { resolvePasswordPolicy } from "@/lib/password-policy";
import { issueToken } from "@/server/auth-token";
import { sendUserInvitationMail } from "@/server/auth-selfservice";
/**
* Team / Funktionszuordnung — Ebene 1 „Fundament" (M1).
*
* Bildet die echte Zuweisung ISMS-Funktion → User(n) ab (`ProjectFunctionAssignment`):
* - `assignFunction` : bestehenden Account einer Funktion zuweisen
* - `unassignFunction` : eine Zuweisung wieder entfernen
* - `markFunctionUnfilled` : Funktion bewusst unbesetzt lassen → Task „Funktion besetzen"
* - `inviteFunctionHolder` : neuen Account per E-Mail einladen (SEC2: Set-Passwort-Link)
*
* Fachregel (im Code erzwungen): der/die unabhängige interne Auditor:in (AUDITOR_INT)
* darf nicht dieselbe Person wie der/die ISB sein (keine Selbstprüfung).
*/
const guard = moduleGuard("onboarding");
type Db = Awaited<ReturnType<typeof guard>>["db"];
const emailSchema = z.string().trim().email();
/** Schiebt den Schritt „roles" von offen → in_bearbeitung an. */
async function nudgeRolesStep(db: Db, tenantId: string) {
const prog = await db.onboardingProgress.findUnique({
where: { tenantId_stepKey: { tenantId, stepKey: "roles" } },
});
if (!prog || prog.status === "offen") {
const status: ObjectReviewStatus = "in_bearbeitung";
await db.onboardingProgress.upsert({
where: { tenantId_stepKey: { tenantId, stepKey: "roles" } },
update: { status },
create: { tenantId, stepKey: "roles", status },
});
}
}
/** Prüft die Funktionstrennung ISB ≠ AUDITOR_INT (keine Selbstprüfung). */
async function assertAuditorIndependence(db: Db, functionKey: string, userId: string) {
if (functionKey === "AUDITOR_INT") {
const isb = await db.projectFunctionAssignment.findFirst({ where: { functionKey: "ISB", userId } });
if (isb) throw new Error("Der/die unabhängige interne Auditor:in darf nicht der/die ISB sein (Funktionstrennung).");
}
if (functionKey === "ISB") {
const auditor = await db.projectFunctionAssignment.findFirst({ where: { functionKey: "AUDITOR_INT", userId } });
if (auditor) throw new Error("Diese Person ist bereits als unabhängige:r interne:r Auditor:in geführt — ISB und Auditor:in müssen getrennt sein.");
}
}
/** Funktion → bestehenden Account zuweisen. */
export async function assignFunction(functionKey: string, formData: FormData) {
const { session, db } = await guard("onboarding:use");
const tenantId = session.user.tenantId;
const def = getFunctionDef(functionKey);
if (!def) throw new Error("Unbekannte Funktion.");
const userId = String(formData.get("userId") ?? "").trim();
if (!userId) throw new Error("Bitte einen Account auswählen.");
const user = await db.user.findFirst({ where: { id: userId, status: "ACTIVE" }, select: { id: true } });
if (!user) throw new Error("Account nicht gefunden oder nicht aktiv.");
await assertAuditorIndependence(db, functionKey, userId);
// Nicht-Mehrfach-Funktionen: bestehende Halter ersetzen. Mehrfach-Funktionen
// (Asset-/Risk-Owner): denselben Account nicht doppelt eintragen.
if (!def.multi) {
await db.projectFunctionAssignment.deleteMany({ where: { functionKey } });
} else if (await db.projectFunctionAssignment.findFirst({ where: { functionKey, userId } })) {
return; // bereits zugewiesen
}
await db.projectFunctionAssignment.create({
data: { tenantId, functionKey, userId, domain: def.domain ?? undefined },
});
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "project_function_assignment", entityId: functionKey, after: { userId } });
await nudgeRolesStep(db, tenantId);
revalidatePath("/onboarding");
revalidatePath("/dashboard");
}
/** Eine Funktionszuweisung entfernen. */
export async function unassignFunction(assignmentId: string) {
const { session, db } = await guard("onboarding:use");
const tenantId = session.user.tenantId;
const row = await db.projectFunctionAssignment.findFirst({ where: { id: assignmentId } });
if (!row) return;
await db.projectFunctionAssignment.delete({ where: { id: assignmentId } });
await writeAuditLog({ tenantId, actorId: session.user.id, action: "delete", entity: "project_function_assignment", entityId: row.functionKey, after: { removed: assignmentId } });
revalidatePath("/onboarding");
revalidatePath("/dashboard");
}
/** Funktion bewusst unbesetzt lassen → Task „Funktion besetzen" (idempotent). */
export async function markFunctionUnfilled(functionKey: string) {
const { session, db } = await guard("onboarding:use");
const tenantId = session.user.tenantId;
const def = getFunctionDef(functionKey);
if (!def) throw new Error("Unbekannte Funktion.");
const origin = `wizard:fill-function:${functionKey}`;
const exists = await db.task.findFirst({ where: { origin, status: { in: ["PROPOSED", "OPEN"] } }, select: { id: true } });
if (!exists) {
await db.task.create({
data: {
tenantId,
type: "organizational",
title: `Funktion besetzen: ${def.label}`,
description: def.desc,
origin,
status: "OPEN",
priority: "hoch",
createdById: session.user.id,
...(def.domain ? { domain: def.domain as Domain } : {}),
},
});
}
await writeAuditLog({ tenantId, actorId: session.user.id, action: "create", entity: "task", entityId: origin, after: { functionKey } });
await nudgeRolesStep(db, tenantId);
revalidatePath("/onboarding");
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
/**
* Account per E-Mail einladen. Existiert bereits ein aktiver Account mit der Adresse,
* wird dieser der Funktion zugewiesen. Andernfalls wird ein neuer Account angelegt
* (Basisrolle „user", Passwortwechsel erzwungen) und über die bestehende SEC2-
* Infrastruktur ein Set-Passwort-Link (password_reset) versendet — das Klartext-
* Passwort verlässt den Server nie.
*/
export async function inviteFunctionHolder(functionKey: string, formData: FormData) {
// Kontenanlage erfordert zusätzlich user:manage.
const { session, db } = await guard("onboarding:use", "user:manage");
const tenantId = session.user.tenantId;
const def = getFunctionDef(functionKey);
if (!def) throw new Error("Unbekannte Funktion.");
const parsed = emailSchema.safeParse(String(formData.get("email") ?? "").toLowerCase());
if (!parsed.success) throw new Error("Bitte eine gültige E-Mail-Adresse angeben.");
const email = parsed.data;
const name = String(formData.get("name") ?? "").trim() || email.split("@")[0]!;
const existing = await db.user.findFirst({ where: { email }, select: { id: true, status: true } });
let userId: string;
let invited = false;
if (existing) {
if (existing.status !== "ACTIVE") throw new Error("Ein Account mit dieser Adresse existiert, ist aber nicht aktiv.");
userId = existing.id;
} else {
const settings = await db.tenantSettings.findUnique({ where: { tenantId } });
const policy = resolvePasswordPolicy(settings?.securityPolicy);
const initialPassword = generateCompliantPassword(policy);
const baseRole = await db.role.findFirst({ where: { key: "user" }, select: { id: true } });
// Option C (WS3): Anlage NUR per Einladung. Bekannte Identity → nur Mitgliedschaft
// (kein Passwort-Reset); unbekannt → Identity anlegen + Einladung (/invite).
// Membership.passwordHash ist Legacy (Login nutzt Identity).
const throwaway = await hashPassword(initialPassword);
const prior = await prisma.identity.findUnique({ where: { email } });
const identity = prior ?? (await prisma.identity.create({
data: { email, passwordHash: throwaway, mustChangePassword: true, status: "ACTIVE" },
}));
const created = await db.user.create({
data: {
tenantId,
identityId: identity.id,
email,
name,
status: "ACTIVE",
...(baseRole ? { userRoles: { create: [{ roleId: baseRole.id }] } } : {}),
},
});
userId = created.id;
invited = true;
if (!prior) {
const tenant = await db.tenant.findUnique({ where: { id: tenantId }, select: { name: true } });
const { raw, expiresAt } = await issueToken({ principalType: "identity", principalId: identity.id, tenantId, type: "invitation" });
await sendUserInvitationMail({ to: email, name, tenantId, tenantName: tenant?.name ?? "", rawToken: raw, expiresAt, locale: settings?.locale });
}
}
await assertAuditorIndependence(db, functionKey, userId);
if (!def.multi) {
await db.projectFunctionAssignment.deleteMany({ where: { functionKey } });
} else if (await db.projectFunctionAssignment.findFirst({ where: { functionKey, userId } })) {
return;
}
await db.projectFunctionAssignment.create({
data: { tenantId, functionKey, userId, invitedEmail: invited ? email : undefined, domain: def.domain ?? undefined },
});
await writeAuditLog({ tenantId, actorId: session.user.id, action: invited ? "create" : "update", entity: "project_function_assignment", entityId: functionKey, after: { userId, invited } });
await nudgeRolesStep(db, tenantId);
revalidatePath("/onboarding");
revalidatePath("/settings/users");
revalidatePath("/dashboard");
}
+211
View File
@@ -0,0 +1,211 @@
"use server";
import { revalidatePath } from "next/cache";
import type { ObjectReviewStatus } from "@prisma/client";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { MODULE_KEYS } from "@/lib/modules";
import { getVisibleSteps, STEP_KEYS, type StepCtx, type StepKey } from "@/lib/onboarding/registry";
import { advanceTarget, isAwaitingValidation } from "@/lib/onboarding/state";
import { loadRoleContext } from "@/server/roles";
import { evaluateFt } from "@/lib/ft-rules";
import { proposeTasksFromTriggers } from "@/server/actions/tasks";
/**
* Onboarding-Wizard — Fortschritts-/Review-State-Machine (Story A1-1/F2).
* Bearbeiter (`onboarding:use`): `advanceStep` (offen→in_bearbeitung→zur_validierung,
* zurueckgewiesen→in_bearbeitung), `resetStep`. Validator (`validate_objects`):
* `validateStep` (→validiert) und `rejectStep` (→zurueckgewiesen, mit Kommentar).
* Bearbeitung und Validierung sind entkoppelt: jeder sichtbare Schritt ist jederzeit
* bearbeitbar (Parallelisierung), die Freigaben erfolgen unabhängig.
*/
const guard = moduleGuard("onboarding");
type Db = Awaited<ReturnType<typeof guard>>["db"];
function isStepKey(v: unknown): v is StepKey {
return typeof v === "string" && (STEP_KEYS as readonly string[]).includes(v);
}
/** Sichtbarkeits-Kontext: alle Modul-Keys außer den ausdrücklich deaktivierten. */
async function loadCtx(db: Db): Promise<StepCtx> {
const rows = await db.tenantModule.findMany();
const disabled = new Set(rows.filter((m) => !m.enabled).map((m) => m.moduleKey));
return { enabledModules: new Set(MODULE_KEYS.filter((k) => !disabled.has(k))) };
}
async function statusMap(db: Db): Promise<Map<string, ObjectReviewStatus>> {
const rows = await db.onboardingProgress.findMany();
return new Map(rows.map((r) => [r.stepKey, r.status]));
}
/**
* Gemeinsame Vorprüfung: Schritt existiert und ist im aktuellen Scope sichtbar.
* Bearbeitung ist von der Validierung entkoppelt (Parallelisierung) — es gibt keinen
* Vorgänger-Validierungs-Zwang mehr; jeder sichtbare Schritt ist bearbeitbar.
*/
async function requireVisibleStep(db: Db, stepKey: string): Promise<{ steps: ReturnType<typeof getVisibleSteps>; current: ObjectReviewStatus }> {
if (!isStepKey(stepKey)) throw new Error("Unbekannter Wizard-Schritt.");
const steps = getVisibleSteps(await loadCtx(db));
const idx = steps.findIndex((s) => s.key === stepKey);
if (idx === -1) throw new Error("Schritt ist im aktuellen Scope nicht sichtbar.");
const map = await statusMap(db);
const statusOf = (k: StepKey) => map.get(k) ?? "offen";
return { steps, current: statusOf(stepKey as StepKey) };
}
async function setStatus(db: Db, tenantId: string, stepKey: string, status: ObjectReviewStatus, review?: { reviewerId: string | null; reviewComment: string | null }) {
await db.onboardingProgress.upsert({
where: { tenantId_stepKey: { tenantId, stepKey } },
update: { status, ...(review ?? {}) },
create: { tenantId, stepKey, status, ...(review ?? {}) },
});
}
/** Bearbeiter-Aktion: Schritt eine Stufe weiterschieben bzw. nach Rückweisung nacharbeiten. */
export async function advanceStep(stepKey: string) {
const { session, db } = await guard("onboarding:use");
const { current } = await requireVisibleStep(db, stepKey);
const next = advanceTarget(current);
if (!next) return; // zur_validierung/validiert: keine Bearbeiter-Aktion
await setStatus(db, session.user.tenantId, stepKey, next);
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "onboarding_step", entityId: stepKey, after: { status: next } });
revalidatePath("/onboarding");
revalidatePath("/dashboard");
}
/** Validator-Aktion: Schritt bestätigen (nur aus `zur_validierung`). */
export async function validateStep(stepKey: string) {
const { session, db } = await guard("validate_objects");
const { current } = await requireVisibleStep(db, stepKey);
if (!isAwaitingValidation(current)) throw new Error("Der Schritt steht nicht zur Validierung an.");
await setStatus(db, session.user.tenantId, stepKey, "validiert", { reviewerId: session.user.id, reviewComment: null });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "onboarding_step", entityId: stepKey, after: { status: "validiert" } });
revalidatePath("/onboarding");
revalidatePath("/dashboard");
}
/** Validator-Aktion: Schritt zurückweisen (nur aus `zur_validierung`), mit Begründung. */
export async function rejectStep(stepKey: string, formData: FormData) {
const { session, db } = await guard("validate_objects");
const { current } = await requireVisibleStep(db, stepKey);
if (!isAwaitingValidation(current)) throw new Error("Der Schritt steht nicht zur Validierung an.");
const comment = (formData.get("comment") as string | null)?.trim() || null;
await setStatus(db, session.user.tenantId, stepKey, "zurueckgewiesen", { reviewerId: session.user.id, reviewComment: comment });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "onboarding_step", entityId: stepKey, after: { status: "zurueckgewiesen" } });
revalidatePath("/onboarding");
revalidatePath("/dashboard");
}
/** Bearbeiter-Aktion: Schritt wieder öffnen. */
export async function resetStep(stepKey: string) {
const { session, db } = await guard("onboarding:use");
if (!isStepKey(stepKey)) throw new Error("Unbekannter Wizard-Schritt.");
await setStatus(db, session.user.tenantId, stepKey, "offen", { reviewerId: null, reviewComment: null });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "onboarding_step", entityId: stepKey, after: { status: "offen" } });
revalidatePath("/onboarding");
revalidatePath("/dashboard");
}
/**
* Scope (Ebene 1 „Fundament", Story A2-2): Prüfziele, Geltungsbereich, Standorte und
* Ausschlüsse als `WizardScope` speichern. Informationssicherheit ist stets aktiv;
* Prototypenschutz/Datenschutz optional. Schiebt den Schritt „scope" von offen →
* in_bearbeitung an. Das Assessment-Level (Schutzbedarf) ist read-only (A2-1) und
* wird hier nicht geschrieben.
*/
export async function saveScope(formData: FormData) {
const { session, db } = await guard("onboarding:use");
const pruefziele = ["informationssicherheit"];
if (formData.get("pz_prototypenschutz")) pruefziele.push("prototypenschutz");
if (formData.get("pz_datenschutz")) pruefziele.push("datenschutz");
const geltungsbereich = (formData.get("geltungsbereich") as string | null)?.trim() || null;
const ausschluesse = (formData.get("ausschluesse") as string | null)?.trim() || null;
const standorte = ((formData.get("standorte") as string | null) ?? "")
.split("\n").map((s) => s.trim()).filter(Boolean);
await db.wizardScope.upsert({
where: { tenantId: session.user.tenantId },
update: { pruefziele, geltungsbereich, standorte, ausschluesse },
create: { tenantId: session.user.tenantId, pruefziele, geltungsbereich, standorte, ausschluesse },
});
// State-Machine: „scope" von offen → in_bearbeitung anschieben.
const prog = await db.onboardingProgress.findUnique({ where: { tenantId_stepKey: { tenantId: session.user.tenantId, stepKey: "scope" } } });
if (!prog || prog.status === "offen") {
await setStatus(db, session.user.tenantId, "scope", "in_bearbeitung");
}
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "wizard_scope", after: { pruefziele, standorte: standorte.length } });
revalidatePath("/onboarding");
revalidatePath("/dashboard");
}
/**
* Rollen/Funktionstrennung (Schritt 3, Story A4-2): FT-01…06 auswerten und für die
* task-würdigen Findings Aufgaben anlegen. FT-01/FT-04 laufen über die bestehenden
* Fragebogen-Trigger (`isb_equals_it`/`isb_not_named`, idempotent), FT-03/FT-05 als
* neue Vorschläge (idempotent über `origin`). Schiebt den Schritt „roles" an.
*/
export async function createFtTasks() {
const { session, db } = await guard("onboarding:use");
const findings = evaluateFt(await loadRoleContext(db));
const reuse = findings.filter((f) => f.task?.reuse).map((f) => f.task!.origin);
if (reuse.length) await proposeTasksFromTriggers(reuse);
for (const f of findings.filter((x) => x.task && !x.task.reuse)) {
const origin = `wizard:${f.task!.origin}`;
const exists = await db.task.findFirst({ where: { origin, status: { in: ["PROPOSED", "OPEN"] } }, select: { id: true } });
if (exists) continue;
await db.task.create({
data: {
tenantId: session.user.tenantId, type: "organizational", title: f.task!.title, origin,
status: "PROPOSED", priority: "hoch", createdById: session.user.id,
links: f.task!.control ? { control: f.task!.control } : undefined,
},
});
}
const prog = await db.onboardingProgress.findUnique({ where: { tenantId_stepKey: { tenantId: session.user.tenantId, stepKey: "roles" } } });
if (!prog || prog.status === "offen") await setStatus(db, session.user.tenantId, "roles", "in_bearbeitung");
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "ft_check", after: { findings: findings.map((f) => f.code) } });
revalidatePath("/onboarding");
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
/**
* Assets (Schritt 5, Story A5-1): nutzt das bestehende Assetinventar/BIA (keine
* Doppel-Datenhaltung) und erzeugt Aufgaben für Lücken (Assets ohne Eigentümer bzw.
* ohne Schutzbedarfsbewertung). Idempotent über `origin`. Schiebt den Schritt „assets" an.
*/
export async function createAssetGapTasks() {
const { session, db } = await guard("onboarding:use");
const [withoutOwner, unrated] = await Promise.all([
db.asset.count({ where: { ownerId: null } }),
db.asset.count({ where: { confidentiality: 1, integrity: 1, availability: 1 } }),
]);
const gaps: { origin: string; title: string; control: string }[] = [];
if (withoutOwner > 0) gaps.push({ origin: "asset-no-owner", title: `Eigentümer für ${withoutOwner} Asset(s) zuweisen`, control: "1.3.1" });
if (unrated > 0) gaps.push({ origin: "asset-unrated", title: `Schutzbedarf (C/I/A) für ${unrated} Asset(s) bewerten`, control: "1.3.2" });
for (const g of gaps) {
const origin = `wizard:${g.origin}`;
const exists = await db.task.findFirst({ where: { origin, status: { in: ["PROPOSED", "OPEN"] } }, select: { id: true } });
if (exists) continue;
await db.task.create({
data: {
tenantId: session.user.tenantId, type: "organizational", title: g.title, origin,
status: "PROPOSED", priority: "mittel", createdById: session.user.id, links: { control: g.control },
},
});
}
const prog = await db.onboardingProgress.findUnique({ where: { tenantId_stepKey: { tenantId: session.user.tenantId, stepKey: "assets" } } });
if (!prog || prog.status === "offen") await setStatus(db, session.user.tenantId, "assets", "in_bearbeitung");
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "asset_gap_check", after: { withoutOwner, unrated } });
revalidatePath("/onboarding");
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
+104
View File
@@ -0,0 +1,104 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/server/db";
import { hashPassword } from "@/server/password";
import { writePlatformAudit } from "@/server/audit";
import { invalidateSessions } from "@/server/sessions";
import { requirePlatformFullAdmin, assertPlatformStepUp, countActiveFullAdmins } from "@/server/platform-auth";
import { DEFAULT_PASSWORD_POLICY, validatePassword } from "@/lib/password-policy";
/**
* SEC4: Verwaltung weiterer Plattform-Administratoren. Nur Voll-Admins (Read-only-Admins
* dürfen nichts verändern), kritische Aktionen mit Step-up-Re-Auth (TOTP), Selbst-Aussperr-
* Schutz (letzter aktiver Voll-Admin), vollständiges Plattform-Audit. EXEMPT vom Modul-Gating
* (eigene Auth über requirePlatformFullAdmin).
*/
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
const toRole = (v: string): "full" | "readonly" => (v === "full" ? "full" : "readonly");
const isStatus = (v: string): v is "ACTIVE" | "LOCKED" | "DISABLED" => v === "ACTIVE" || v === "LOCKED" || v === "DISABLED";
export type AdminActionState = { status: "idle" } | { status: "error"; message: string } | { status: "done"; message: string };
/** Verhindert, dass der letzte aktive Voll-Admin herabgestuft/gesperrt/deaktiviert wird. */
async function assertNotLastFullAdmin(target: { role: string; status: string }): Promise<void> {
if (target.role === "full" && target.status === "ACTIVE" && (await countActiveFullAdmins()) <= 1) {
throw new Error("Der letzte aktive Voll-Administrator kann nicht herabgestuft, gesperrt oder deaktiviert werden.");
}
}
/** Weiteren Plattform-Admin anlegen (Initial-Passwort). Voll-Admin + Step-up. */
export async function createPlatformAdmin(_prev: AdminActionState, formData: FormData): Promise<AdminActionState> {
const { admin } = await requirePlatformFullAdmin();
try {
assertPlatformStepUp(admin, str(formData.get("token")));
} catch (e) {
return { status: "error", message: e instanceof Error ? e.message : "Step-up erforderlich." };
}
const email = str(formData.get("email")).toLowerCase();
const name = str(formData.get("name"));
const role = toRole(str(formData.get("role")));
const password = String(formData.get("password") ?? "");
if (!email.includes("@") || !name) return { status: "error", message: "Gültige E-Mail und Name sind erforderlich." };
const pwMissing = validatePassword(password, DEFAULT_PASSWORD_POLICY);
if (pwMissing.length) return { status: "error", message: `Das Passwort benötigt ${pwMissing.join(", ")}.` };
if (await prisma.platformAdmin.findUnique({ where: { email }, select: { id: true } })) {
return { status: "error", message: "Diese E-Mail ist bereits vergeben." };
}
const created = await prisma.platformAdmin.create({
data: { email, name, role, status: "ACTIVE", passwordHash: await hashPassword(password) },
});
await writePlatformAudit({ actorId: admin.id, action: "create", entity: "platform_admin", entityId: created.id, after: { email, role } });
revalidatePath("/admins");
return { status: "done", message: `Plattform-Admin ${email} (${role === "full" ? "Voll-Admin" : "Read-only"}) angelegt.` };
}
/** Rolle ändern (full/readonly). Voll-Admin + Step-up + Last-Admin-Schutz. */
export async function setPlatformAdminRole(adminId: string, role: string, formData: FormData) {
const { admin } = await requirePlatformFullAdmin();
assertPlatformStepUp(admin, str(formData.get("token")));
const target = await prisma.platformAdmin.findUnique({ where: { id: adminId }, select: { id: true, role: true, status: true } });
if (!target) throw new Error("Plattform-Admin nicht gefunden.");
const newRole = toRole(role);
if (newRole !== "full") await assertNotLastFullAdmin(target);
await prisma.platformAdmin.update({ where: { id: adminId }, data: { role: newRole } });
await writePlatformAudit({ actorId: admin.id, action: "update", entity: "platform_admin", entityId: adminId, after: { role: newRole } });
revalidatePath("/admins");
}
/** Sperren/Reaktivieren/Deaktivieren. Voll-Admin + Step-up + Last-Admin-Schutz. */
export async function setPlatformAdminStatus(adminId: string, status: string, formData: FormData) {
const { admin } = await requirePlatformFullAdmin();
assertPlatformStepUp(admin, str(formData.get("token")));
if (!isStatus(status)) throw new Error("Ungültiger Status.");
const target = await prisma.platformAdmin.findUnique({ where: { id: adminId }, select: { id: true, role: true, status: true } });
if (!target) throw new Error("Plattform-Admin nicht gefunden.");
if (status !== "ACTIVE") await assertNotLastFullAdmin(target);
await prisma.platformAdmin.update({ where: { id: adminId }, data: { status } });
if (status !== "ACTIVE") await invalidateSessions({ type: "platform_admin", id: adminId });
await writePlatformAudit({ actorId: admin.id, action: "update", entity: "platform_admin", entityId: adminId, after: { status } });
revalidatePath("/admins");
}
/** Passwort eines Admins zurücksetzen. Voll-Admin + Step-up; meldet dessen Sitzungen ab. */
export async function resetPlatformAdminPassword(adminId: string, _prev: AdminActionState, formData: FormData): Promise<AdminActionState> {
const { admin } = await requirePlatformFullAdmin();
try {
assertPlatformStepUp(admin, str(formData.get("token")));
} catch (e) {
return { status: "error", message: e instanceof Error ? e.message : "Step-up erforderlich." };
}
const password = String(formData.get("password") ?? "");
const pwMissing = validatePassword(password, DEFAULT_PASSWORD_POLICY);
if (pwMissing.length) return { status: "error", message: `Das Passwort benötigt ${pwMissing.join(", ")}.` };
const target = await prisma.platformAdmin.findUnique({ where: { id: adminId }, select: { id: true } });
if (!target) return { status: "error", message: "Plattform-Admin nicht gefunden." };
await prisma.platformAdmin.update({ where: { id: adminId }, data: { passwordHash: await hashPassword(password) } });
await invalidateSessions({ type: "platform_admin", id: adminId });
await writePlatformAudit({ actorId: admin.id, action: "update", entity: "platform_admin", entityId: adminId, after: { passwordReset: true } });
revalidatePath("/admins");
return { status: "done", message: "Passwort zurückgesetzt; andere Sitzungen des Kontos abgemeldet." };
}
+169
View File
@@ -0,0 +1,169 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { requirePlatformSession } from "@/server/platform-auth";
import { prisma, dbForTenant, type TenantDb } from "@/server/db";
import { hashPassword, generateCompliantPassword } from "@/server/password";
import { resolvePasswordPolicy } from "@/lib/password-policy";
import { issueToken } from "@/server/auth-token";
import { sendUserInvitationMail } from "@/server/auth-selfservice";
import { writePlatformAudit } from "@/server/audit";
/**
* Benutzerverwaltung durch den Plattform-Admin je Mandant (Paket A).
* EXEMPT vom Modul-Gating; Autorisierung über die Plattform-Session. Alle
* Schreibzugriffe laufen über dbForTenant(zielTenant) → strikt mandantengebunden.
* Audit-Scope: platform (Betreiber handelt mandantenübergreifend).
*
* Zukunftssicher: `createTenantUser` kapselt die Aktivierung über ein Initial-/
* Einmal-Passwort (mustChangePassword). Der spätere Einladungs-Flow (Paket 4) kann
* hier als alternative Aktivierung (Token statt Passwort) eingehängt werden.
*/
export type CreateUserState =
| { status: "idle" }
| { status: "error"; message: string }
// invited = Einladungsmail verschickt (Nutzer setzt Passwort selbst);
// generatedPassword gesetzt = Fallback-Anzeige, falls die Mail nicht ging.
| { status: "done"; email: string; generatedPassword: string | null; invited: boolean };
export type EditUserState =
| { status: "idle" }
| { status: "error"; message: string }
| { status: "ok" };
/** Zählt aktive Nutzer mit Mandanten-Admin-Rolle (Lockout-Schutz). */
async function countActiveTenantAdmins(db: TenantDb): Promise<number> {
return db.user.count({
where: { status: "ACTIVE", userRoles: { some: { role: { key: "tenant-admin" } } } },
});
}
async function tenantPolicy(db: TenantDb, tenantId: string) {
const s = await db.tenantSettings.findUnique({ where: { tenantId } });
return resolvePasswordPolicy(s?.securityPolicy);
}
const emailSchema = z.string().trim().email();
/** Benutzer anlegen (Initial-/Einmal-Passwort, erzwungener Wechsel). */
export async function createTenantUser(tenantId: string, _prev: CreateUserState, formData: FormData): Promise<CreateUserState> {
const session = await requirePlatformSession();
const db = dbForTenant(tenantId);
const name = String(formData.get("name") ?? "").trim();
const emailRaw = String(formData.get("email") ?? "").trim().toLowerCase();
const roleIds = formData.getAll("roles").map(String).filter(Boolean);
if (!name) return { status: "error", message: "Bitte einen Namen angeben." };
const email = emailSchema.safeParse(emailRaw);
if (!email.success) return { status: "error", message: "Bitte eine gültige E-Mail-Adresse angeben." };
if (roleIds.length === 0) return { status: "error", message: "Bitte mindestens eine Rolle zuweisen." };
const existing = await db.user.findFirst({ where: { email: email.data } });
if (existing) return { status: "error", message: "In diesem Mandanten existiert bereits ein Nutzer mit dieser E-Mail." };
// Rollen müssen zum Mandanten gehören (dbForTenant filtert; count verifiziert).
const validRoles = await db.role.count({ where: { id: { in: roleIds } } });
if (validRoles !== roleIds.length) return { status: "error", message: "Ungültige Rollenauswahl." };
// Option C (WS3): Anlage NUR per Einladung (goldene Regel 4) — kein „Passwort direkt
// setzen" mehr. Bekannte Identity → nur Mitgliedschaft ergänzen (Betreiber darf direkt
// verknüpfen, KEIN Passwort-Reset). Unbekannt → Identity anlegen + Einladung zum
// Passwort-Setzen (/invite). Membership.passwordHash ist Legacy (Login nutzt Identity).
const policy = await tenantPolicy(db, tenantId);
const throwaway = await hashPassword(generateCompliantPassword(policy));
const prior = await prisma.identity.findUnique({ where: { email: email.data } });
const identity = prior ?? (await prisma.identity.create({
data: { email: email.data, passwordHash: throwaway, mustChangePassword: true, status: "ACTIVE" },
}));
const user = await db.user.create({
data: {
tenantId, identityId: identity.id, email: email.data, name, status: "ACTIVE",
userRoles: { create: roleIds.map((roleId) => ({ roleId })) },
},
});
await writePlatformAudit({ actorId: session.user.id, action: "create", entity: "user", entityId: user.id, after: { tenantId, email: email.data, roleIds, linkedExisting: !!prior } });
revalidatePath(`/admin/${tenantId}`);
// Nur für NEUE Identitäten einen Einladungslink zum Passwort-Setzen verschicken;
// eine bestehende Person hat bereits Zugangsdaten und sieht den Mandanten künftig
// in ihrer Auswahl. Antwort ist in beiden Fällen identisch (neutral).
if (!prior) {
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { name: true } });
const { raw, expiresAt } = await issueToken({ principalType: "identity", principalId: identity.id, tenantId, type: "invitation" });
const invite = await sendUserInvitationMail({ to: email.data, name, tenantId, tenantName: tenant?.name ?? "", rawToken: raw, expiresAt });
if (invite.status !== "queued" && invite.status !== "sent") {
await writePlatformAudit({ actorId: session.user.id, action: "update", entity: "user_invite_failed", entityId: user.id, after: { tenantId, email: email.data, mail: invite.status } });
}
}
return { status: "done", email: email.data, generatedPassword: null, invited: true };
}
/** Stammdaten (Name, E-Mail) eines Nutzers ändern; E-Mail bleibt je Mandant eindeutig. */
export async function updateTenantUser(tenantId: string, userId: string, _prev: EditUserState, formData: FormData): Promise<EditUserState> {
const session = await requirePlatformSession();
const db = dbForTenant(tenantId);
const user = await db.user.findUnique({ where: { id: userId } });
if (!user) return { status: "error", message: "Nutzer nicht gefunden." };
const name = String(formData.get("name") ?? "").trim();
const email = emailSchema.safeParse(String(formData.get("email") ?? "").trim().toLowerCase());
if (!name) return { status: "error", message: "Bitte einen Namen angeben." };
if (!email.success) return { status: "error", message: "Bitte eine gültige E-Mail-Adresse angeben." };
if (email.data !== user.email && (await db.user.findFirst({ where: { email: email.data, id: { not: userId } } }))) {
return { status: "error", message: "Diese E-Mail ist in diesem Mandanten bereits vergeben." };
}
await db.user.update({ where: { id: userId }, data: { name, email: email.data } });
await writePlatformAudit({ actorId: session.user.id, action: "update", entity: "user", entityId: userId, after: { tenantId, name, email: email.data } });
revalidatePath(`/admin/${tenantId}`);
return { status: "ok" };
}
/** Deaktivieren/Reaktivieren mit Lockout-Schutz (letzter aktiver Mandanten-Admin). */
export async function setTenantUserStatus(tenantId: string, userId: string, status: "ACTIVE" | "DEACTIVATED") {
const session = await requirePlatformSession();
const db = dbForTenant(tenantId);
const user = await db.user.findUnique({ where: { id: userId }, include: { userRoles: { include: { role: true } } } });
if (!user) throw new Error("Nutzer nicht gefunden");
if (status === "DEACTIVATED") {
const isAdmin = user.userRoles.some((ur) => ur.role.key === "tenant-admin");
if (isAdmin && user.status === "ACTIVE" && (await countActiveTenantAdmins(db)) <= 1) {
throw new Error("Der letzte aktive Mandanten-Admin kann nicht deaktiviert werden.");
}
}
await db.user.update({ where: { id: userId }, data: { status } });
await writePlatformAudit({ actorId: session.user.id, action: "update", entity: "user", entityId: userId, after: { tenantId, status } });
revalidatePath(`/admin/${tenantId}`);
}
/** Rollen eines Nutzers setzen (Mehrfachauswahl), mit Lockout-Schutz. */
export async function setTenantUserRoles(tenantId: string, userId: string, formData: FormData) {
const session = await requirePlatformSession();
const db = dbForTenant(tenantId);
const roleIds = formData.getAll("roles").map(String).filter(Boolean);
const user = await db.user.findUnique({ where: { id: userId }, include: { userRoles: { include: { role: true } } } });
if (!user) throw new Error("Nutzer nicht gefunden");
const validRoles = roleIds.length ? await db.role.count({ where: { id: { in: roleIds } } }) : 0;
if (validRoles !== roleIds.length) throw new Error("Ungültige Rollenauswahl");
// Lockout-Schutz: dem letzten aktiven Mandanten-Admin nicht die Admin-Rolle entziehen.
const adminRole = await db.role.findFirst({ where: { key: "tenant-admin" } });
const hadAdmin = user.userRoles.some((ur) => ur.role.key === "tenant-admin");
const keepsAdmin = adminRole ? roleIds.includes(adminRole.id) : false;
if (hadAdmin && !keepsAdmin && user.status === "ACTIVE" && (await countActiveTenantAdmins(db)) <= 1) {
throw new Error("Dem letzten aktiven Mandanten-Admin kann die Admin-Rolle nicht entzogen werden.");
}
await db.$transaction([
db.userRole.deleteMany({ where: { userId } }),
db.userRole.createMany({ data: roleIds.map((roleId) => ({ userId, roleId })) }),
]);
await writePlatformAudit({ actorId: session.user.id, action: "update", entity: "user_roles", entityId: userId, after: { tenantId, roleIds } });
revalidatePath(`/admin/${tenantId}`);
}
+88
View File
@@ -0,0 +1,88 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/server/db";
import { requirePlatformSession, platformSignOut } from "@/server/platform-auth";
import { getPlatformSettings } from "@/server/platform-settings";
import { verifyTotp, generateRecoveryCodes } from "@/server/mfa";
import { writePlatformAudit } from "@/server/audit";
/**
* Plattform-Actions (Phase-1-Härtung Paket 2): MFA-Enrollment und Logout.
* EXEMPT vom Modul-Gating (keine per TenantModule gegatete Fachfunktion) — eigene
* Autorisierung über requirePlatformSession.
*/
export type EnrollState =
| { status: "idle" }
| { status: "error"; message: string }
| { status: "done"; recoveryCodes: string[] };
/**
* Bestätigt die MFA-Einrichtung: prüft den TOTP-Code gegen das (beim Seitenaufruf
* gespeicherte) Einrichtungs-Secret, aktiviert MFA und liefert einmalig die
* Recovery-Codes im Klartext zurück (nur Hashes werden gespeichert).
*/
export async function confirmMfaEnrollment(_prev: EnrollState, formData: FormData): Promise<EnrollState> {
const session = await requirePlatformSession();
const admin = await prisma.platformAdmin.findUnique({ where: { id: session.user.id } });
if (!admin) return { status: "error", message: "Konto nicht gefunden." };
if (admin.mfaEnrolledAt) return { status: "error", message: "MFA ist bereits eingerichtet." };
if (!admin.mfaSecret) return { status: "error", message: "Kein Einrichtungs-Secret vorhanden. Bitte Seite neu laden." };
const code = String(formData.get("token") ?? "");
const totp = verifyTotp(code, admin.mfaSecret);
if (!totp.ok) {
return { status: "error", message: "Code ungültig. Bitte den aktuellen 6-stelligen Code aus der App eingeben." };
}
// F-17: den bei der Einrichtung akzeptierten Zeitschritt gleich als lastTotpStep
// festhalten, damit der Enrollment-Code nicht anschließend beim Login erneut gilt.
const { plain, hashed } = await generateRecoveryCodes(10);
await prisma.platformAdmin.update({
where: { id: admin.id },
data: { mfaEnrolledAt: new Date(), recoveryCodes: hashed, failedLogins: 0, lockedUntil: null, lastTotpStep: BigInt(totp.step) },
});
await writePlatformAudit({ actorId: admin.id, action: "update", entity: "platform_admin", entityId: admin.id, after: { mfaEnrolled: true } });
return { status: "done", recoveryCodes: plain };
}
/** MFA freiwillig deaktivieren (nur zulässig, solange keine MFA-Pflicht gilt). */
export async function disablePlatformMfa(formData: FormData) {
const session = await requirePlatformSession();
const settings = await getPlatformSettings();
if (settings.mfaRequired) throw new Error("MFA ist per Plattform-Policy verpflichtend und kann nicht deaktiviert werden.");
// Re-Authentifizierung (F-08): das Abschalten des zweiten Faktors darf nur mit
// gültigem TOTP-Code möglich sein, sonst entfernt ein Angreifer mit gekaperter
// Session die MFA einfach. Prüfung gegen das gespeicherte Secret aus der DB.
const admin = await prisma.platformAdmin.findUnique({ where: { id: session.user.id } });
if (!admin?.mfaEnrolledAt || !admin.mfaSecret) throw new Error("MFA ist nicht aktiv.");
const code = String(formData.get("token") ?? "");
// Replay-Schutz (F-17): ein bereits verwendeter Code kann MFA nicht deaktivieren.
if (!verifyTotp(code, admin.mfaSecret, admin.lastTotpStep).ok) throw new Error("Der MFA-Code ist ungültig. MFA wurde nicht deaktiviert.");
await prisma.platformAdmin.update({
where: { id: session.user.id },
data: { mfaSecret: null, mfaEnrolledAt: null, recoveryCodes: [], lastTotpStep: null },
});
await writePlatformAudit({ actorId: session.user.id, action: "update", entity: "platform_admin", entityId: session.user.id, after: { mfaDisabled: true } });
revalidatePath("/profile");
}
/** Plattformweite MFA-Pflicht schalten (stellt die Erzwingung wieder her — nur Flag). */
export async function setPlatformMfaRequired(required: boolean) {
const session = await requirePlatformSession();
await prisma.platformSetting.upsert({
where: { id: "singleton" },
update: { mfaRequired: required },
create: { id: "singleton", mfaRequired: required },
});
await writePlatformAudit({ actorId: session.user.id, action: "update", entity: "platform_setting", entityId: "singleton", after: { mfaRequired: required } });
revalidatePath("/profile");
}
/** Plattform-Logout (eigene Session-Domäne). */
export async function platformSignOutAction() {
await platformSignOut({ redirectTo: "/platform/login" });
}
+320
View File
@@ -0,0 +1,320 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { moduleGuard } from "@/server/action-guard";
import { type Permission } from "@/server/rbac";
import { isCentralVariable } from "@/lib/policy-variables";
import { writeAuditLog } from "@/server/audit";
import { notifyTaskEvent } from "@/server/mail/notifications";
import { deriveDomainsCore } from "@/server/policies/derive-domains";
import { DOMAIN_LABELS } from "@/lib/control-domain";
import type { Domain } from "@prisma/client";
const optDate = (v: FormDataEntryValue | null) => (v && String(v) ? new Date(String(v)) : null);
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
/**
* Optionales Redirect-Ziel aus dem Formular („im Wizard bleiben"). Lässt NUR interne,
* absolute Pfade zu (kein Protokoll/Host, kein `//`, kein Backslash) — verhindert
* Open-Redirects. Ohne gültiges Ziel greift der jeweilige Default.
*/
const safeInternalPath = (v: FormDataEntryValue | null): string | null => {
const s = typeof v === "string" ? v.trim() : "";
if (!s.startsWith("/") || s.startsWith("//") || s.includes("\\") || s.includes("://")) return null;
return s;
};
const guardModule = moduleGuard("policies");
/** Modul-Gating „policies" + RBAC. Default-Recht policy:write; Freigabe nutzt policy:approve. */
async function guard(permission: Permission = "policy:write") {
return guardModule(permission);
}
/* ── Krypto-Register (VA-07) ── */
export async function addCryptoEntry(formData: FormData) {
const { session, db } = await guard();
const last = await db.cryptoEntry.aggregate({ _max: { orderIdx: true } });
await db.cryptoEntry.create({
data: {
tenantId: session.user.tenantId,
dienst: z.string().trim().min(1).parse(formData.get("dienst")),
schluessel: z.string().trim().min(1).parse(formData.get("schluessel")),
algorithmus: str(formData.get("algorithmus")) || null,
ablaufdatum: optDate(formData.get("ablaufdatum")),
verantwortlich: str(formData.get("verantwortlich")) || "—",
speicherort: str(formData.get("speicherort")) || null,
baselineRef: str(formData.get("baselineRef")) || null,
orderIdx: (last._max.orderIdx ?? 0) + 1,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "crypto_entry" });
revalidatePath("/policies", "layout");
}
export async function updateCryptoEntry(id: string, formData: FormData) {
const { session, db } = await guard();
await db.cryptoEntry.update({
where: { id },
data: {
dienst: z.string().trim().min(1).parse(formData.get("dienst")),
schluessel: z.string().trim().min(1).parse(formData.get("schluessel")),
algorithmus: str(formData.get("algorithmus")) || null,
ablaufdatum: optDate(formData.get("ablaufdatum")),
verantwortlich: str(formData.get("verantwortlich")) || "—",
speicherort: str(formData.get("speicherort")) || null,
baselineRef: str(formData.get("baselineRef")) || null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "crypto_entry", entityId: id });
revalidatePath("/policies", "layout");
}
export async function deleteCryptoEntry(id: string) {
const { session, db } = await guard();
await db.cryptoEntry.delete({ where: { id } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "crypto_entry", entityId: id });
revalidatePath("/policies", "layout");
}
/* ── Klassifizierungs-Handhabungsmatrix (R02 / VA-08) ── */
export async function updateHandlingRule(classId: string, aspectId: string, formData: FormData) {
const { session, db } = await guard();
const text = str(formData.get("text"));
await db.handlingRule.upsert({
where: { classId_aspectId: { classId, aspectId } },
update: { text },
create: { tenantId: session.user.tenantId, classId, aspectId, text },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "handling_rule" });
revalidatePath("/policies", "layout");
}
export async function addHandlingAspect(formData: FormData) {
const { session, db } = await guard();
const last = await db.handlingAspect.aggregate({ _max: { orderIdx: true } });
await db.handlingAspect.create({
data: {
tenantId: session.user.tenantId,
name: z.string().trim().min(1).parse(formData.get("name")),
category: str(formData.get("category")) || null,
orderIdx: (last._max.orderIdx ?? 0) + 1,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "handling_aspect" });
revalidatePath("/policies", "layout");
}
export async function addClassificationClass(formData: FormData) {
const { session, db } = await guard();
const last = await db.classificationClass.aggregate({ _max: { orderIdx: true } });
await db.classificationClass.create({
data: {
tenantId: session.user.tenantId,
name: z.string().trim().min(1).parse(formData.get("name")),
description: str(formData.get("description")) || null,
orderIdx: (last._max.orderIdx ?? 0) + 1,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "classification_class" });
revalidatePath("/policies", "layout");
}
/* ── Risiko-Bewertungsmatrix (R03 / VA-09) ── */
export async function updateRiskClass(id: string, formData: FormData) {
const { session, db } = await guard();
await db.riskMatrixClass.update({
where: { id },
data: {
name: z.string().trim().min(1).parse(formData.get("name")),
maxScore: z.coerce.number().int().min(1).max(999).parse(formData.get("maxScore")),
acceptance: str(formData.get("acceptance")),
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "risk_matrix_class", entityId: id });
revalidatePath("/policies", "layout");
}
export async function updateEwLevel(id: string, formData: FormData) {
const { session, db } = await guard();
await db.riskEwLevel.update({
where: { id },
data: { label: z.string().trim().min(1).parse(formData.get("label")), definition: str(formData.get("definition")) },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "risk_ew_level", entityId: id });
revalidatePath("/policies", "layout");
}
export async function updateDamageDimension(id: string, formData: FormData) {
const { session, db } = await guard();
await db.riskDamageDimension.update({
where: { id },
data: {
name: z.string().trim().min(1).parse(formData.get("name")),
levels: { "1": str(formData.get("l1")), "2": str(formData.get("l2")), "3": str(formData.get("l3")), "4": str(formData.get("l4")) },
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "risk_damage_dimension", entityId: id });
revalidatePath("/policies", "layout");
}
/* ── Bearbeitungsmodus Richtlinien/Verfahren ── */
/** Vorlagen-Markdown eines Dokuments speichern (Experten-Modus). */
export async function updatePolicyTemplate(code: string, formData: FormData) {
const { session, db } = await guard();
const doc = await db.policyDocument.findFirst({ where: { code } });
if (!doc) throw new Error("Dokument nicht gefunden");
const rawMarkdown = String(formData.get("rawMarkdown") ?? "");
await db.policyDocument.update({ where: { id: doc.id }, data: { rawMarkdown } });
// Fehlende {{VARIABLEN}} automatisch anlegen (Experten-Modus: neue Variablen)
const referenced = new Set([...rawMarkdown.matchAll(/\{\{\s*([A-Z][A-Z0-9_]*)\s*\}\}/g)].map((m) => m[1]));
if (referenced.size) {
const existing = new Set((await db.policyVariable.findMany({ select: { key: true } })).map((v) => v.key));
const last = await db.policyVariable.aggregate({ _max: { orderIdx: true } });
let idx = (last._max.orderIdx ?? 0) + 1;
for (const key of referenced) {
if (existing.has(key)) continue;
const isFlag = key.startsWith("FLAG_");
await db.policyVariable.create({
data: { tenantId: session.user.tenantId, key, title: key, kind: isFlag ? "boolean" : "string", groupName: "Eigene", value: isFlag ? "false" : "", orderIdx: idx++ },
});
}
}
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document", entityId: doc.id });
revalidatePath("/policies", "layout");
redirect(`/policies/${encodeURIComponent(code)}/edit?expert=1`);
}
/** Eine dokument-bezogene Variable ändern (eine Pflegestelle, §7/§8) — propagiert in alle Dokumente. */
export async function updatePolicyVariable(key: string, formData: FormData) {
const { session, db } = await guard();
const variable = await db.policyVariable.findFirst({ where: { key }, select: { key: true, groupName: true } });
if (variable && isCentralVariable(variable)) throw new Error("Zentrale Variablen werden ausschließlich in den Einstellungen gepflegt.");
const value = String(formData.get("value") ?? "");
await db.policyVariable.updateMany({ where: { key }, data: { value } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_variable", after: { key, value } });
revalidatePath("/policies", "layout");
}
/**
* Dokument-bezogene Variablen gebündelt speichern (Bearbeitungsmodus). Nur die
* übergebenen Schlüssel (Feld `keys` = kommagetrennt) werden aus dem Formular
* gelesen; Werte gelten zentral für alle Dokumente (eine Pflegestelle, §8).
*/
export async function updateScopedVariables(code: string, formData: FormData) {
const { session, db } = await guard();
const keys = String(formData.get("keys") ?? "").split(",").map((k) => k.trim()).filter(Boolean);
// Zentrale Variablen (Organisation/Rollen) niemals aus dem Richtlinien-Editor überschreiben.
const rows = keys.length ? await db.policyVariable.findMany({ where: { key: { in: keys } }, select: { key: true, groupName: true } }) : [];
const centralKeys = new Set(rows.filter(isCentralVariable).map((r) => r.key));
for (const key of keys) {
if (centralKeys.has(key) || !formData.has(`var_${key}`)) continue;
const value = String(formData.get(`var_${key}`) ?? "");
await db.policyVariable.updateMany({ where: { key }, data: { value } });
}
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_variables", entityId: code, after: { keys } });
revalidatePath("/policies", "layout");
redirect(`/policies/${encodeURIComponent(code)}/edit`);
}
/* ── Freigabe-Workflow (Vier-Augen, §8) — Einreichen erzeugt eine Aufgabe ── */
/**
* Richtlinie zur Freigabe einreichen: der Einreicher wählt einen konkreten
* Freigeber (aktiver Nutzer mit policy:approve, ≠ Einreicher). Das Dokument geht
* auf IN_FREIGABE und es entsteht eine Aufgabe (policy_approval) für den Freigeber.
* Freigabe/Ablehnung erfolgen im Aufgaben-Modul (actions/tasks.ts).
*/
export async function submitForApproval(code: string, formData: FormData) {
const { session, db } = await guard();
const approverId = String(formData.get("approverId") ?? "").trim();
if (!approverId) throw new Error("Bitte einen Freigeber auswählen.");
if (approverId === session.user.id) throw new Error("Vier-Augen-Prinzip: Der Freigeber muss eine andere Person sein.");
const doc = await db.policyDocument.findFirst({ where: { code } });
if (!doc) throw new Error("Dokument nicht gefunden");
const approver = await db.user.findFirst({
where: { id: approverId, status: "ACTIVE", userRoles: { some: { role: { rolePermissions: { some: { permission: { key: "policy:approve" } } } } } } },
select: { id: true },
});
if (!approver) throw new Error("Ungültiger Freigeber (kein aktiver Nutzer mit Freigaberecht).");
// Vorherige offene Freigabe-Aufgaben dieses Dokuments abschließen (Resubmit).
await db.task.updateMany({ where: { entityType: "policy_document", entityId: doc.id, status: "OPEN" }, data: { status: "CANCELLED" } });
await db.policyDocument.update({ where: { id: doc.id }, data: { status: "IN_FREIGABE", submittedBy: session.user.id, approvedBy: null, approvedAt: null } });
const note = str(formData.get("note"));
const task = await db.task.create({
data: {
tenantId: session.user.tenantId, type: "policy_approval",
title: `Freigabe: ${doc.code} — ${doc.title}`,
entityType: "policy_document", entityId: doc.id, entityRef: doc.code,
assigneeId: approver.id, createdById: session.user.id, status: "OPEN",
comments: note ? { create: { tenantId: session.user.tenantId, authorId: session.user.id, kind: "submit", body: note } } : undefined,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document", entityId: doc.id, after: { status: "IN_FREIGABE", approverId: approver.id, taskId: task.id } });
// SEC1: der ausgewählte Freigeber wird über die Anfrage informiert.
await notifyTaskEvent({
tenantId: session.user.tenantId,
event: "task_approval_requested",
taskId: task.id,
taskTitle: task.title,
taskType: task.type,
recipientId: approver.id,
actorId: session.user.id,
});
revalidatePath("/policies", "layout");
revalidatePath("/tasks");
revalidatePath("/onboarding", "layout");
const back = safeInternalPath(formData.get("returnTo"));
redirect(back ?? `/policies/${encodeURIComponent(code)}/edit`);
}
/* ── Fachbereich (Domain) — Ableitung & manuelles Überschreiben ── */
/** Fachbereiche idempotent ableiten (programmatisch wiederverwendbar, z. B. Import). */
export async function derivePolicyDomains(): Promise<number> {
const { session, db } = await guard();
const filled = await deriveDomainsCore(db, session.user.tenantId);
if (filled) {
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document", after: { deriveDomains: filled } });
revalidatePath("/policies", "layout");
}
return filled;
}
/** Formular-Variante des Ableitens (Button in der Ansicht): leitet mit Ergebnis-Banner zurück. */
export async function submitDeriveDomains(formData: FormData) {
const { session, db } = await guard();
const filled = await deriveDomainsCore(db, session.user.tenantId);
if (filled) {
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document", after: { deriveDomains: filled } });
revalidatePath("/policies", "layout");
revalidatePath("/onboarding", "layout");
}
const back = safeInternalPath(formData.get("returnTo"));
redirect(back ?? `/policies?view=domains&derived=${filled}`);
}
/** Manuelles Setzen/Zurücksetzen des Fachbereichs (Dropdown in der Ansicht). Leerwert = zurücksetzen. */
export async function setPolicyDomain(code: string, formData: FormData) {
const { session, db } = await guard();
const raw = str(formData.get("domain"));
const domain: Domain | null = raw && raw in DOMAIN_LABELS ? (raw as Domain) : null;
const doc = await db.policyDocument.findFirst({ where: { code } });
if (!doc) throw new Error("Dokument nicht gefunden");
await db.policyDocument.update({ where: { id: doc.id }, data: { domain } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document", entityId: doc.id, after: { domain } });
revalidatePath("/policies", "layout");
revalidatePath("/onboarding", "layout");
}
+75
View File
@@ -0,0 +1,75 @@
"use server";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { REVIEW_CYCLES, computeNextReview, bumpMinorVersion } from "@/lib/review-cycle";
/**
* AP5 — Dokumentenlenkung (Modul „policies"): Prüfzyklus/-termin (A.5.1), dokumentierte
* Neuversion mit Historie und Lesebestätigung je Version (SPEC §4.6, Klausel 7.3, A.6.3).
* moduleGuard("policies"); Verwaltung verlangt `policy:write`, Bestätigen `policy:read`.
*/
const guard = moduleGuard("policies");
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
/** Prüfzyklus setzen und daraus den nächsten Prüftermin berechnen. */
export async function setDocReviewCycle(docId: string, formData: FormData) {
const { session, db } = await guard("policy:write");
const cycleRaw = str(formData.get("reviewCycle"));
const cycle = cycleRaw in REVIEW_CYCLES ? cycleRaw : null;
await db.policyDocument.update({
where: { id: docId },
data: { reviewCycle: cycle, nextReviewAt: computeNextReview(new Date(), cycle) },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document_review", entityId: docId, after: { reviewCycle: cycle } });
revalidatePath("/policies/control");
}
/**
* Dokument als geprüft markieren: aktuelle Version in die Historie schreiben, Minor-
* Version erhöhen (neue Version → Lesebestätigungen müssen erneuert werden, da
* versions-scoped) und den nächsten Prüftermin aus dem Zyklus neu setzen.
*/
export async function markDocReviewed(docId: string, formData: FormData) {
const { session, db } = await guard("policy:write");
const doc = await db.policyDocument.findUnique({ where: { id: docId }, select: { version: true, title: true, reviewCycle: true } });
if (!doc) throw new Error("Dokument nicht gefunden.");
// Momentaufnahme der bisherigen Version.
await db.policyDocumentVersion.create({
data: {
tenantId: session.user.tenantId,
policyDocumentId: docId,
version: doc.version,
title: doc.title,
changeNote: str(formData.get("changeNote")) || "Turnusmäßige Überprüfung",
createdBy: session.user.id,
},
});
const newVersion = bumpMinorVersion(doc.version);
await db.policyDocument.update({
where: { id: docId },
data: { version: newVersion, nextReviewAt: computeNextReview(new Date(), doc.reviewCycle) },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "policy_document_review", entityId: docId, after: { from: doc.version, to: newVersion } });
revalidatePath("/policies/control");
}
/** Aktuelle Version des Dokuments als gelesen bestätigen (versions-scoped, idempotent). */
export async function acknowledgePolicy(docId: string) {
const { session, db } = await guard("policy:read");
const doc = await db.policyDocument.findUnique({ where: { id: docId }, select: { version: true } });
if (!doc) throw new Error("Dokument nicht gefunden.");
await db.policyAcknowledgement.upsert({
where: {
tenantId_policyDocumentId_userId_version: {
tenantId: session.user.tenantId, policyDocumentId: docId, userId: session.user.id, version: doc.version,
},
},
update: {},
create: { tenantId: session.user.tenantId, policyDocumentId: docId, userId: session.user.id, version: doc.version },
});
revalidatePath("/policies/control");
}
+54
View File
@@ -0,0 +1,54 @@
"use server";
import { join } from "node:path";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { prisma, dbForTenant } from "@/server/db";
import { moduleGuard } from "@/server/action-guard";
import { reconcilePackage, stampPackageVersion } from "../../../prisma/import-policies";
import { resolvePackageForTenant, getTenantFrameworks } from "../../../prisma/template-store";
import { deriveDomainsCore } from "@/server/policies/derive-domains";
/**
* Richtlinien-Vorlagen (Story B4-1): das Vorlagenpaket mandantenweit nicht-destruktiv
* importieren/aktualisieren (Diff/Upsert, archivedAt; Status/Freigabe/Overrides und
* nutzergepflegte Variablenwerte bleiben erhalten). Kapselt prisma/import-policies.ts
* als serverseitig aufrufbare Action. Einstieg: Button unter /policies (Self-Service)
* und im Admin (siehe importPolicyPackageForTenant). Import bei Modul-Aktivierung:
* siehe toggleTenantModule.
*/
const guard = moduleGuard("policies");
const SEED_DIR = join(process.cwd(), "seed", "isms-vorlagenpaket-v2");
export async function importPolicyPackage() {
const { session } = await guard("policy:write");
const tenantId = session.user.tenantId;
// AP1: je Framework des Mandanten importieren. Anforderungen sind framework-scoped,
// die geteilten Inhalte (Dokumente/Variablen/…) werden nur beim ersten Framework
// abgeglichen (reconcileShared). Einzel-Framework-Mandant = unverändertes Verhalten.
const frameworks = await getTenantFrameworks(prisma, tenantId);
const agg = { dA: 0, dU: 0, dAr: 0, dR: 0, rA: 0, rU: 0, rAr: 0 };
for (const [i, framework] of frameworks.entries()) {
// Quelle: veröffentlichte DB-Vorlage in der Sprache des Mandanten (DE-/Datei-Fallback).
const { pkg } = await resolvePackageForTenant(prisma, tenantId, SEED_DIR, framework);
const result = await reconcilePackage(prisma, tenantId, pkg, {
actorId: session.user.id,
framework,
reconcileShared: i === 0,
});
// Story B6: übernommene Paket-Version je Framework stempeln (kontrollierte Übernahme).
await stampPackageVersion(prisma, tenantId, pkg.version, framework);
const d = result.report.documents;
const r = result.report.requirements;
agg.dA += d.added; agg.dU += d.updated; agg.dAr += d.archived; agg.dR += d.reactivated;
agg.rA += r.added; agg.rU += r.updated; agg.rAr += r.archived;
}
// Fachbereiche neuer Richtlinien direkt ableiten (idempotent, tenant-scoped).
await deriveDomainsCore(dbForTenant(tenantId), tenantId);
revalidatePath("/policies", "layout");
revalidatePath("/policies/updates");
revalidatePath("/dashboard");
const q = `d${agg.dA}-${agg.dU}-${agg.dAr}-${agg.dR}_r${agg.rA}-${agg.rU}-${agg.rAr}`;
redirect(`/policies?import=${q}`);
}
+313
View File
@@ -0,0 +1,313 @@
"use server";
import { revalidatePath } from "next/cache";
import type { Framework } from "@prisma/client";
import { prisma } from "@/server/db";
import { requirePlatformFullAdmin } from "@/server/platform-auth";
import { writePlatformAudit } from "@/server/audit";
/**
* Plattform-Bearbeitung der globalen Richtlinien-VORLAGEN (Phase 2). Getrennte
* Plattform-Session (requirePlatformFullAdmin, kein Mandantenkontext). Bearbeitet wird
* ausschließlich der aktuelle ENTWURF (DRAFT) — veröffentlichte Versionen sind
* unveränderlich. Inhalte je Sprache (locale de|en). Die Veröffentlichung (Phase 3)
* wandelt den Entwurf in eine neue veröffentlichte Version.
*
* EXEMPT vom Modul-Guard (eigene Plattform-Auth, kein per TenantModule gegatetes Fachmodul).
*/
const LOCALES = new Set(["de", "en"]);
const str = (fd: FormData, k: string) => String(fd.get(k) ?? "").trim();
// Der Plattform-Vorlagen-Editor ist framework-fähig (TISAX ODER ISO 27001). Alle
// Draft-/Publish-Operationen sind auf das jeweilige Framework gescoped, damit ein
// Publish nicht die Version des anderen Frameworks archiviert (Falle 1.2). Form-Actions
// erhalten das Framework als verstecktes Feld, gebundene Actions per .bind().
function frameworkFrom(fd: FormData): Framework {
return String(fd.get("framework")) === "ISO_27001" ? "ISO_27001" : "TISAX";
}
function bumpVersion(v?: string | null): string {
if (!v) return "1.0";
const m = v.match(/^(\d+)\.(\d+)/);
return m ? `${m[1]}.${Number(m[2]) + 1}` : `${v}.1`;
}
async function getDraft(framework: Framework) {
return prisma.policyTemplateVersion.findFirst({ where: { status: "DRAFT", framework }, orderBy: { createdAt: "desc" } });
}
/** Sicherstellen, dass die Ziel-Version ein Entwurf ist (nur Entwürfe sind bearbeitbar). */
async function assertDraftVersion(versionId: string) {
const v = await prisma.policyTemplateVersion.findUnique({ where: { id: versionId }, select: { status: true } });
if (!v || v.status !== "DRAFT") {
throw new Error("Nur der Entwurf ist bearbeitbar. Bitte zuerst einen Entwurf anlegen.");
}
}
/** Kinder einer Version (alle Sprachen) in eine andere Version kopieren. */
async function cloneChildren(fromId: string, toId: string) {
const [docs, reqs, vars, baseline, evidence] = await Promise.all([
prisma.policyTemplateDoc.findMany({ where: { versionId: fromId } }),
prisma.policyTemplateRequirement.findMany({ where: { versionId: fromId } }),
prisma.policyTemplateVariable.findMany({ where: { versionId: fromId } }),
prisma.policyTemplateBaselineParam.findMany({ where: { versionId: fromId } }),
prisma.policyTemplateEvidence.findMany({ where: { versionId: fromId } }),
]);
await prisma.$transaction([
prisma.policyTemplateDoc.createMany({
data: docs.map((d) => ({
versionId: toId, locale: d.locale, code: d.code, type: d.type, title: d.title,
docVersion: d.docVersion, policyCode: d.policyCode, fulfills: d.fulfills, domain: d.domain,
rawMarkdown: d.rawMarkdown, orderIdx: d.orderIdx,
})),
}),
prisma.policyTemplateRequirement.createMany({
data: reqs.map((r) => ({
versionId: toId, locale: r.locale, reqId: r.reqId, policyCode: r.policyCode, control: r.control,
obligation: r.obligation, condition: r.condition, requirement: r.requirement,
implementation: r.implementation, vaCodes: r.vaCodes, nachweisLink: r.nachweisLink, orderIdx: r.orderIdx,
})),
}),
prisma.policyTemplateVariable.createMany({
data: vars.map((v) => ({
versionId: toId, locale: v.locale, key: v.key, title: v.title, kind: v.kind,
groupName: v.groupName, value: v.value, required: v.required, orderIdx: v.orderIdx,
})),
}),
prisma.policyTemplateBaselineParam.createMany({
data: baseline.map((b) => ({
versionId: toId, locale: b.locale, blId: b.blId, section: b.section, name: b.name, vorgabe: b.vorgabe, orderIdx: b.orderIdx,
})),
}),
prisma.policyTemplateEvidence.createMany({
data: evidence.map((e) => ({
versionId: toId, locale: e.locale, nr: e.nr, policyCode: e.policyCode, nachweis: e.nachweis,
quelle: e.quelle, verantwortlich: e.verantwortlich, turnus: e.turnus,
})),
}),
]);
}
/**
* Aktuellen Entwurf liefern oder neu anlegen. Ein neuer Entwurf wird als Kopie der
* zuletzt veröffentlichten Version erstellt (Bearbeitung startet vom aktuellen Stand,
* inkl. beider Sprachen). Version des Entwurfs = nächste freie Minor-Version.
*/
export async function ensureDraft(framework: Framework): Promise<string> {
const { admin } = await requirePlatformFullAdmin();
const existing = await getDraft(framework);
if (existing) return existing.id;
const published = await prisma.policyTemplateVersion.findFirst({
where: { status: "PUBLISHED", framework },
orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }],
});
// Freie Versionsnummer finden (darf mit keiner bestehenden Version dieses Frameworks kollidieren).
let version = bumpVersion(published?.version);
while (await prisma.policyTemplateVersion.findUnique({ where: { framework_version: { framework, version } }, select: { id: true } })) {
version = bumpVersion(version);
}
const draft = await prisma.policyTemplateVersion.create({ data: { framework, version, status: "DRAFT" } });
if (published) await cloneChildren(published.id, draft.id);
await writePlatformAudit({ actorId: admin.id, action: "create", entity: "policy_template_version", entityId: draft.id, after: { framework, version, clonedFrom: published?.version ?? null } });
revalidatePath("/templates");
return draft.id;
}
/** Form-tauglicher Void-Wrapper (Button „Entwurf anlegen/bearbeiten"), per .bind(framework). */
export async function startDraftAction(framework: Framework): Promise<void> {
await ensureDraft(framework);
}
/** Entwurf verwerfen (löscht die Entwurfs-Version samt Inhalten; Cascade). */
export async function discardDraft(framework: Framework): Promise<void> {
const { admin } = await requirePlatformFullAdmin();
const draft = await getDraft(framework);
if (!draft) return;
await prisma.policyTemplateVersion.delete({ where: { id: draft.id } });
await writePlatformAudit({ actorId: admin.id, action: "delete", entity: "policy_template_version", entityId: draft.id, after: { discarded: draft.version } });
revalidatePath("/templates");
}
/**
* Entwurf veröffentlichen: wird zur neuen veröffentlichten Version. Bisher veröffentlichte
* Versionen werden archiviert (nur die neue bleibt PUBLISHED). Neue Mandanten erhalten sie
* automatisch bei der Provisionierung; bestehende sehen unter /policies/updates „Update
* verfügbar" und übernehmen selbst (nicht-destruktiv). Optionaler Änderungshinweis (notes).
*/
export async function publishDraft(formData: FormData): Promise<void> {
const { admin } = await requirePlatformFullAdmin();
const framework = frameworkFrom(formData);
const draft = await getDraft(framework);
if (!draft) throw new Error("Kein Entwurf zum Veröffentlichen vorhanden.");
const docCount = await prisma.policyTemplateDoc.count({ where: { versionId: draft.id } });
if (docCount === 0) throw new Error("Der Entwurf enthält keine Dokumente.");
const version = (str(formData, "version") || draft.version).trim();
if (!/^\d+(\.\d+)*$/.test(version)) throw new Error("Ungültige Versionsnummer (z. B. 2.2).");
const notes = str(formData, "notes") || null;
const clash = await prisma.policyTemplateVersion.findFirst({ where: { version, framework, id: { not: draft.id } }, select: { id: true } });
if (clash) throw new Error(`Version ${version} existiert bereits. Bitte eine andere Versionsnummer wählen.`);
await prisma.$transaction([
// Bisher veröffentlichte Versionen DIESES Frameworks archivieren — nur die neue bleibt PUBLISHED.
prisma.policyTemplateVersion.updateMany({ where: { status: "PUBLISHED", framework }, data: { status: "ARCHIVED" } }),
prisma.policyTemplateVersion.update({
where: { id: draft.id },
data: { status: "PUBLISHED", version, notes, publishedAt: new Date(), publishedBy: admin.id },
}),
]);
await writePlatformAudit({ actorId: admin.id, action: "update", entity: "policy_template_version", entityId: draft.id, after: { published: version } });
revalidatePath("/templates");
revalidatePath("/policies/updates");
revalidatePath("/policies", "layout");
}
// ── Dokumente ───────────────────────────────────────────────────────────────
/** Fehlende {{VARIABLEN}} aus dem Rohtext als String-Variablen im Entwurf anlegen (Parität zum Mandanten-Editor). */
async function ensureVariablesForMarkdown(versionId: string, locale: string, markdown: string) {
const found = new Set<string>();
for (const m of markdown.matchAll(/\{\{([A-Z0-9_]+)\}\}/g)) found.add(m[1]);
if (found.size === 0) return;
const existing = await prisma.policyTemplateVariable.findMany({ where: { versionId, locale }, select: { key: true } });
const have = new Set(existing.map((v) => v.key));
const missing = [...found].filter((k) => !have.has(k));
if (missing.length === 0) return;
const maxOrder = existing.length;
await prisma.policyTemplateVariable.createMany({
data: missing.map((key, i) => ({ versionId, locale, key, title: key, kind: "string", groupName: "Eigene", value: "", required: false, orderIdx: maxOrder + i })),
skipDuplicates: true,
});
}
export async function updateTemplateDoc(docId: string, formData: FormData): Promise<void> {
const { admin } = await requirePlatformFullAdmin();
const doc = await prisma.policyTemplateDoc.findUnique({ where: { id: docId } });
if (!doc) throw new Error("Dokument nicht gefunden.");
await assertDraftVersion(doc.versionId);
const rawMarkdown = String(formData.get("rawMarkdown") ?? "");
const title = str(formData, "title") || doc.title;
await prisma.policyTemplateDoc.update({ where: { id: docId }, data: { rawMarkdown, title } });
await ensureVariablesForMarkdown(doc.versionId, doc.locale, rawMarkdown);
await writePlatformAudit({ actorId: admin.id, action: "update", entity: "policy_template_doc", entityId: docId, after: { code: doc.code, locale: doc.locale } });
revalidatePath(`/templates/${doc.locale}/${doc.code}`);
revalidatePath("/templates");
}
export async function createTemplateDoc(formData: FormData): Promise<void> {
const { admin } = await requirePlatformFullAdmin();
const draft = await getDraft(frameworkFrom(formData));
if (!draft) throw new Error("Kein Entwurf vorhanden. Bitte zuerst einen Entwurf anlegen.");
const locale = str(formData, "locale");
const code = str(formData, "code").toUpperCase();
const type = str(formData, "type") || "RICHTLINIE";
const title = str(formData, "title") || code;
if (!LOCALES.has(locale)) throw new Error("Ungültige Sprache.");
if (!/^[A-Z0-9][A-Z0-9-]*$/.test(code)) throw new Error("Ungültiger Code (z. B. R15, VA-21, EIG-1).");
const dup = await prisma.policyTemplateDoc.findUnique({ where: { versionId_locale_code: { versionId: draft.id, locale, code } }, select: { id: true } });
if (dup) throw new Error(`Dokument ${code} (${locale}) existiert bereits im Entwurf.`);
const maxOrder = await prisma.policyTemplateDoc.aggregate({ where: { versionId: draft.id, locale }, _max: { orderIdx: true } });
await prisma.policyTemplateDoc.create({
data: {
versionId: draft.id, locale, code, type: type as never, title,
rawMarkdown: `# ${title}\n\n`, orderIdx: (maxOrder._max.orderIdx ?? 0) + 1,
},
});
await writePlatformAudit({ actorId: admin.id, action: "create", entity: "policy_template_doc", entityId: `${code}`, after: { code, locale, type } });
revalidatePath("/templates");
}
// ── Anforderungen (Control-Mapping) ───────────────────────────────────────────
export async function upsertTemplateRequirement(formData: FormData): Promise<void> {
const { admin } = await requirePlatformFullAdmin();
const id = str(formData, "id");
const data = {
reqId: str(formData, "reqId"),
policyCode: str(formData, "policyCode"),
control: str(formData, "control"),
obligation: str(formData, "obligation") || "MUSS",
condition: str(formData, "condition") || null,
requirement: str(formData, "requirement"),
implementation: str(formData, "implementation"),
vaCodes: str(formData, "vaCodes").split(",").map((s) => s.trim()).filter(Boolean),
nachweisLink: str(formData, "nachweisLink") || null,
};
if (id) {
const ex = await prisma.policyTemplateRequirement.findUnique({ where: { id } });
if (!ex) throw new Error("Anforderung nicht gefunden.");
await assertDraftVersion(ex.versionId);
await prisma.policyTemplateRequirement.update({ where: { id }, data });
await writePlatformAudit({ actorId: admin.id, action: "update", entity: "policy_template_requirement", entityId: ex.reqId, after: { locale: ex.locale } });
revalidatePath(`/templates/${ex.locale}/requirements`);
return;
}
const draft = await getDraft(frameworkFrom(formData));
if (!draft) throw new Error("Kein Entwurf vorhanden.");
const locale = str(formData, "locale");
if (!LOCALES.has(locale)) throw new Error("Ungültige Sprache.");
if (!data.reqId) throw new Error("Anforderungs-ID fehlt.");
const dup = await prisma.policyTemplateRequirement.findUnique({ where: { versionId_locale_reqId: { versionId: draft.id, locale, reqId: data.reqId } }, select: { id: true } });
if (dup) throw new Error(`Anforderung ${data.reqId} (${locale}) existiert bereits.`);
const maxOrder = await prisma.policyTemplateRequirement.aggregate({ where: { versionId: draft.id, locale }, _max: { orderIdx: true } });
await prisma.policyTemplateRequirement.create({ data: { versionId: draft.id, locale, ...data, orderIdx: (maxOrder._max.orderIdx ?? 0) + 1 } });
await writePlatformAudit({ actorId: admin.id, action: "create", entity: "policy_template_requirement", entityId: data.reqId, after: { locale } });
revalidatePath(`/templates/${locale}/requirements`);
}
export async function deleteTemplateRequirement(id: string): Promise<void> {
const { admin } = await requirePlatformFullAdmin();
const ex = await prisma.policyTemplateRequirement.findUnique({ where: { id } });
if (!ex) return;
await assertDraftVersion(ex.versionId);
await prisma.policyTemplateRequirement.delete({ where: { id } });
await writePlatformAudit({ actorId: admin.id, action: "delete", entity: "policy_template_requirement", entityId: ex.reqId, after: { locale: ex.locale } });
revalidatePath(`/templates/${ex.locale}/requirements`);
}
// ── Variablen ─────────────────────────────────────────────────────────────────
export async function upsertTemplateVariable(formData: FormData): Promise<void> {
const { admin } = await requirePlatformFullAdmin();
const id = str(formData, "id");
const data = {
key: str(formData, "key").toUpperCase().replace(/[^A-Z0-9_]/g, "_"),
title: str(formData, "title"),
kind: str(formData, "kind") === "boolean" ? "boolean" : "string",
groupName: str(formData, "groupName") || null,
value: str(formData, "value"),
required: formData.get("required") === "on" || formData.get("required") === "true",
};
if (id) {
const ex = await prisma.policyTemplateVariable.findUnique({ where: { id } });
if (!ex) throw new Error("Variable nicht gefunden.");
await assertDraftVersion(ex.versionId);
await prisma.policyTemplateVariable.update({ where: { id }, data: { title: data.title || ex.title, kind: data.kind, groupName: data.groupName, value: data.value, required: data.required } });
await writePlatformAudit({ actorId: admin.id, action: "update", entity: "policy_template_variable", entityId: ex.key, after: { locale: ex.locale } });
revalidatePath(`/templates/${ex.locale}/variables`);
return;
}
const draft = await getDraft(frameworkFrom(formData));
if (!draft) throw new Error("Kein Entwurf vorhanden.");
const locale = str(formData, "locale");
if (!LOCALES.has(locale)) throw new Error("Ungültige Sprache.");
if (!data.key) throw new Error("Variablen-Key fehlt.");
const dup = await prisma.policyTemplateVariable.findUnique({ where: { versionId_locale_key: { versionId: draft.id, locale, key: data.key } }, select: { id: true } });
if (dup) throw new Error(`Variable ${data.key} (${locale}) existiert bereits.`);
const maxOrder = await prisma.policyTemplateVariable.aggregate({ where: { versionId: draft.id, locale }, _max: { orderIdx: true } });
await prisma.policyTemplateVariable.create({ data: { versionId: draft.id, locale, ...data, title: data.title || data.key, orderIdx: (maxOrder._max.orderIdx ?? 0) + 1 } });
await writePlatformAudit({ actorId: admin.id, action: "create", entity: "policy_template_variable", entityId: data.key, after: { locale } });
revalidatePath(`/templates/${locale}/variables`);
}
export async function deleteTemplateVariable(id: string): Promise<void> {
const { admin } = await requirePlatformFullAdmin();
const ex = await prisma.policyTemplateVariable.findUnique({ where: { id } });
if (!ex) return;
await assertDraftVersion(ex.versionId);
await prisma.policyTemplateVariable.delete({ where: { id } });
await writePlatformAudit({ actorId: admin.id, action: "delete", entity: "policy_template_variable", entityId: ex.key, after: { locale: ex.locale } });
revalidatePath(`/templates/${ex.locale}/variables`);
}
+186
View File
@@ -0,0 +1,186 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { storage } from "@/server/storage/adapter";
import { escapeHtml } from "@/lib/policy-render";
/**
* Eigene Richtlinie hochladen (Story B4-2). Legt ein PolicyDocument (Typ EIGENES)
* mit Pflicht-Control-Zuordnung an; die Zuordnung erzeugt PolicyRequirement-Zeilen
* (Namensraum EIG-*, außerhalb des Paket-Namensraums → vom Re-Import unberührt) und
* fließt zusätzlich als <!-- REQ <control> -->-Anker in den Rohtext (Nachweislage,
* Schritt 7). Die Datei wird über den gekapselten Storage-Adapter (Stub, S1 folgt)
* abgelegt — im Stub nur Metadaten/Key, keine Bytes.
*/
const guard = moduleGuard("policies");
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
/**
* Upload-Vertrag (F-15) — verbindliche Anforderungen an JEDES Storage-Backend,
* insbesondere die echte Implementierung aus Epic S1 (S3/MinIO). Der aktuelle
* Storage-Adapter ist ein Stub, der keine Bytes persistiert; die untenstehenden
* Kontrollen dürfen daher NICHT als „durch den Stub abgesichert" missverstanden
* werden, sondern sind Pflicht auf Anwendungsebene:
* 1. Größenlimit VOR dem Einlesen in den RAM prüfen (kein DoS über Riesendatei).
* 2. Allowlist für Endungen UND MIME-Typen (PDF, DOCX, ODT).
* 3. Inhaltsprüfung über Magic Bytes — dem clientseitig gesetzten `f.type`
* wird NICHT vertraut; der abgeleitete, geprüfte Typ wird gespeichert.
* Beim echten Backend zusätzlich: Ablage außerhalb des Web-Roots, Auslieferung
* mit `Content-Disposition: attachment` und `X-Content-Type-Options: nosniff`
* (F-07), sowie AV-Scan vor Freigabe.
*/
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024; // 20 MB Obergrenze
type UploadKind = "pdf" | "docx" | "odt";
// Endung → erlaubter Typ.
const ALLOWED_EXT: Record<string, UploadKind> = { pdf: "pdf", docx: "docx", odt: "odt" };
// Kanonischer, serverseitig gesetzter MIME-Typ je geprüftem Inhalt.
const CANONICAL_MIME: Record<UploadKind, string> = {
pdf: "application/pdf",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
odt: "application/vnd.oasis.opendocument.text",
};
// Vom Client zulässigerweise gesetzte MIME-Typen (nur als Zusatzsignal geprüft,
// nicht als alleinige Wahrheit — die Magic-Byte-Prüfung ist maßgeblich).
const ALLOWED_CLIENT_MIME: Record<UploadKind, string[]> = {
pdf: ["application/pdf"],
docx: [
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/octet-stream",
"",
],
odt: ["application/vnd.oasis.opendocument.text", "application/octet-stream", ""],
};
const startsWith = (bytes: Uint8Array, sig: number[]) =>
sig.every((b, i) => bytes[i] === b);
const PDF_MAGIC = [0x25, 0x50, 0x44, 0x46]; // "%PDF"
const ZIP_MAGIC = [0x50, 0x4b, 0x03, 0x04]; // "PK\x03\x04" (DOCX/ODT sind ZIP-Container)
/** Prüft, ob der Inhalt (Magic Bytes) zum per Endung erwarteten Typ passt. */
function magicMatches(kind: UploadKind, bytes: Uint8Array): boolean {
if (kind === "pdf") return startsWith(bytes, PDF_MAGIC);
// DOCX und ODT sind beide ZIP-Container.
if (!startsWith(bytes, ZIP_MAGIC)) return false;
if (kind === "odt") {
// ODT legt als erstes, unkomprimiertes Element die "mimetype"-Datei ab; ihr
// Inhalt "…opendocument.text" liegt in den ersten ~80 Bytes im Klartext.
const head = Buffer.from(bytes.slice(0, 80)).toString("latin1");
return head.includes("opendocument.text");
}
return true; // docx: ZIP-Signatur genügt (OOXML), Endung + Client-MIME grenzen ein.
}
/** Endung aus dem (bereits an anderer Stelle bereinigten) Dateinamen ziehen. */
function extOf(filename: string): string {
const dot = filename.lastIndexOf(".");
return dot >= 0 ? filename.slice(dot + 1).toLowerCase() : "";
}
export async function uploadOwnPolicy(formData: FormData) {
const { session, db } = await guard("policy:write");
const title = str(formData.get("title"));
if (!title) throw new Error("Bitte einen Titel angeben.");
const controls = formData.getAll("controls").map((c) => String(c).trim()).filter(Boolean);
if (controls.length === 0) throw new Error("Bitte mindestens ein Control zuordnen (Pflicht).");
const reqIds = str(formData.get("reqIds")).split(",").map((s) => s.trim()).filter(Boolean);
// Datei über den gekapselten Storage-Adapter (Stub) ablegen — mit vollständiger
// Eingangsvalidierung (F-15), da der clientseitige `f.type` nicht vertrauenswürdig ist.
const file = formData.get("file");
let stored: { storageKey: string; filename: string; size: number } | null = null;
if (file && typeof file === "object" && "arrayBuffer" in file && (file as File).size > 0) {
const f = file as File;
// (1) Größenlimit VOR dem Einlesen in den RAM (DoS-Schutz).
if (f.size > MAX_UPLOAD_BYTES) {
throw new Error(`Datei zu groß (max. ${Math.floor(MAX_UPLOAD_BYTES / (1024 * 1024))} MB).`);
}
// (2) Endungs-Allowlist.
const ext = extOf(f.name);
const kind = ALLOWED_EXT[ext];
if (!kind) {
throw new Error("Dateityp nicht erlaubt. Zulässig: PDF, DOCX, ODT.");
}
// (3) Vom Client gemeldeten MIME-Typ nur als Zusatzsignal prüfen.
const clientMime = (f.type || "").toLowerCase();
if (!ALLOWED_CLIENT_MIME[kind].includes(clientMime)) {
throw new Error("MIME-Typ passt nicht zur Dateiendung.");
}
// (4) Inhalt einlesen und über Magic Bytes verifizieren (maßgeblich).
const bytes = new Uint8Array(await f.arrayBuffer());
if (!magicMatches(kind, bytes)) {
throw new Error("Dateiinhalt passt nicht zum angegebenen Typ.");
}
// Nicht dem clientseitigen `f.type` vertrauen: kanonischen, geprüften MIME speichern.
stored = await storage.put({
tenantId: session.user.tenantId,
filename: f.name,
contentType: CANONICAL_MIME[kind],
bytes,
});
}
// Nächsten freien EIG-Code vergeben.
const count = await db.policyDocument.count({ where: { type: "EIGENES" } });
const code = `EIG-${count + 1}`;
// Titel für die Markdown-Einbettung HTML-kodieren (F-03/F-15): der Titel fließt
// roh in den gespeicherten Rohtext und damit in die Renderpipeline; anders als
// der Dateiname war er bislang unbereinigt. Das DB-Feld `title` bleibt der
// Klartext (React kodiert bei der Anzeige selbst).
const titleMd = escapeHtml(title);
const anchors = controls.map((c) => `<!-- REQ ${c} -->`).join("\n");
const meta = [
`# ${titleMd}`,
"",
`> Eigenes Dokument (Upload).${stored ? ` Datei: ${stored.filename} (${stored.size} Bytes). Storage: ${stored.storageKey}` : " Ohne Datei."}`,
reqIds.length ? `> Zugeordnete Anforderungs-IDs: ${reqIds.join(", ")}` : "",
"",
`Zugeordnete Controls: ${controls.join(", ")}.`,
"",
anchors,
"",
].filter((l) => l !== "").join("\n");
await db.policyDocument.create({
data: {
tenantId: session.user.tenantId,
code, type: "EIGENES", title, status: "ENTWURF",
owner: session.user.id, rawMarkdown: meta, orderIdx: 1000 + count,
},
});
// Control-Mapping → PolicyRequirement-Zeilen (erscheinen in Coverage/Nachweislage).
for (const control of controls) {
await db.policyRequirement.create({
data: {
tenantId: session.user.tenantId,
reqId: `${code}-${control}`, policyCode: code, control,
obligation: "MUSS", condition: null,
requirement: title, implementation: "Eigenes hochgeladenes Dokument.",
vaCodes: [], nachweisLink: stored?.storageKey ?? null,
},
});
}
await writeAuditLog({
tenantId: session.user.tenantId, actorId: session.user.id,
action: "create", entity: "policy_document", entityId: code,
after: { type: "EIGENES", controls, hasFile: Boolean(stored) },
});
revalidatePath("/policies", "layout");
redirect(`/policies/${code}`);
}
+553
View File
@@ -0,0 +1,553 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import type { AssetType, InfoLabel, ProcessCategory } from "@prisma/client";
import { moduleGuard } from "@/server/action-guard";
import type { TenantDb } from "@/server/db";
import { writeAuditLog } from "@/server/audit";
const guard = moduleGuard("bia");
/** Katalog-Vorschläge zu einem übernommenen Prozess (M2, prozessgeführter Flow). */
export interface ProcessCatalogSuggestion {
processId: string;
processName: string;
/** Code des zugrunde liegenden ProcessCatalogEntry (Match über den Namen). */
catalogCode: string;
/** Vorgeschlagene Träger-Asset-Typen (SECONDARY) — Assets-Step. */
suggestedAssetTypes: AssetType[];
/** Vorgeschlagene Klassifizierungslabels primärer Werte — Information-Step. */
suggestedInfoLabels: InfoLabel[];
/** Vorgeschlagene Standard-Risiken (→ RiskCatalogEntry.code) — Risks-Step. */
suggestedRiskCodes: string[];
}
/**
* M2 Strukturanalyse: Lädt die geseedeten Katalog-Vorschläge
* (`ProcessCatalogEntry.suggestedAssetTypes/suggestedInfoLabels/suggestedRiskCodes`)
* zu den bereits ÜBERNOMMENEN Prozessen des Mandanten. Zuordnung Prozess → Katalog
* erfolgt über den Namen (adoptCatalogProcess legt den Prozess unter `entry.name` an).
* Genutzt von den Wizard-Schritten Information/Assets/Risiken, um die Vorschläge als
* Chips/Defaults bzw. Ein-Klick-Übernahme auszuspielen.
*/
export async function getProcessCatalogSuggestions(): Promise<ProcessCatalogSuggestion[]> {
const { db } = await guard("bia:read");
const [processes, catalog] = await Promise.all([
db.process.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true, catalogCode: true } }),
db.processCatalogEntry.findMany({
select: {
code: true,
name: true,
suggestedAssetTypes: true,
suggestedInfoLabels: true,
suggestedRiskCodes: true,
},
}),
]);
// v3A: bevorzugt über catalogCode zuordnen (robust), Name nur als Fallback für Altbestand.
const byCode = new Map(catalog.map((c) => [c.code, c]));
const byName = new Map(catalog.map((c) => [c.name, c]));
return processes.flatMap((p) => {
const entry = (p.catalogCode ? byCode.get(p.catalogCode) : undefined) ?? byName.get(p.name);
if (!entry) return [];
return [
{
processId: p.id,
processName: p.name,
catalogCode: entry.code,
suggestedAssetTypes: entry.suggestedAssetTypes,
suggestedInfoLabels: entry.suggestedInfoLabels,
suggestedRiskCodes: entry.suggestedRiskCodes,
},
];
});
}
const processSchema = z.object({
name: z.string().trim().min(1).max(200),
description: z.string().trim().max(5000).optional(),
category: z.enum(["CORE", "MANAGEMENT", "SUPPORT"]),
ownerId: z.string().optional(),
// TISAX v3A — mehr fachliche Prozess-Informationen (additiv).
purpose: z.string().trim().max(5000).optional(),
parentId: z.string().optional(),
deputyOwnerId: z.string().optional(),
legalBasis: z.string().trim().max(2000).optional(),
interfaces: z.string().trim().max(2000).optional(),
catalogCode: z.string().trim().max(100).optional(),
});
/** Checkbox („on"/fehlt) → Boolean. */
const bool = (v: FormDataEntryValue | null) => v === "on" || v === "true" || v === "1";
function parseProcessForm(formData: FormData) {
const parsed = processSchema.parse({
name: formData.get("name"),
description: formData.get("description") || undefined,
category: formData.get("category"),
ownerId: formData.get("ownerId") || undefined,
purpose: formData.get("purpose") || undefined,
parentId: formData.get("parentId") || undefined,
deputyOwnerId: formData.get("deputyOwnerId") || undefined,
legalBasis: formData.get("legalBasis") || undefined,
interfaces: formData.get("interfaces") || undefined,
catalogCode: formData.get("catalogCode") || undefined,
});
return {
...parsed,
description: parsed.description || null,
ownerId: parsed.ownerId || null,
purpose: parsed.purpose || null,
parentId: parsed.parentId || null,
deputyOwnerId: parsed.deputyOwnerId || null,
legalBasis: parsed.legalBasis || null,
interfaces: parsed.interfaces || null,
catalogCode: parsed.catalogCode || null,
dataProtectionRelevant: bool(formData.get("dataProtectionRelevant")),
prototypeRelevant: bool(formData.get("prototypeRelevant")),
};
}
/** Ein Katalog-Eintrag, wie ihn `adoptEntry` zum Anlegen/Verknüpfen benötigt. */
type AdoptableEntry = { code: string; name: string; category: ProcessCategory };
/**
* Übernimmt EINEN Katalog-Eintrag ins mandanteneigene Register (idempotent über
* catalogCode/Name) und setzt optional `parentId` (Katalog-`parentCode` → Instanz).
* Liefert die Instanz-Id. Interner Helfer für {@link adoptCatalogProcess} — keine
* Server-Action (nicht exportiert).
*/
async function adoptEntry(
db: TenantDb,
actor: { id: string; tenantId: string },
entry: AdoptableEntry,
ownerId: string | null,
parentId: string | null,
): Promise<string> {
// Zuordnung Prozess↔Katalog über catalogCode (Name nur Fallback für Altbestand).
const already = await db.process.findFirst({
where: { OR: [{ catalogCode: entry.code }, { name: entry.name }] },
select: { id: true },
});
if (already) {
await db.process.update({
where: { id: already.id },
data: {
catalogCode: entry.code,
...(ownerId ? { ownerId } : {}),
...(parentId ? { parentId } : {}),
},
});
return already.id;
}
const process = await db.process.create({
data: {
tenantId: actor.tenantId,
name: entry.name,
category: entry.category,
catalogCode: entry.code,
parentId,
ownerId,
createdBy: actor.id,
},
select: { id: true },
});
await writeAuditLog({
tenantId: actor.tenantId,
actorId: actor.id,
action: "create",
entity: "process",
entityId: process.id,
after: { name: entry.name, category: entry.category, fromCatalog: entry.code, parentId },
});
return process.id;
}
/**
* M2 Strukturanalyse: Übernimmt einen Prozess aus dem globalen `ProcessCatalogEntry`
* ins mandanteneigene Register (idempotent je catalogCode). Owner optional. Wird im
* prozessgeführten Wizard-Schritt „Prozesse" genutzt.
*
* TISAX v4B: Beim Übernehmen eines Katalog-Prozesses werden dessen **Teilprozesse**
* (`ProcessCatalogEntry.parentCode == code`) mit angelegt und über `Process.parentId`
* an die übernommene Instanz gehängt (Katalog-`parentCode` → Instanz-`parentId`). Ist
* der übernommene Eintrag selbst ein Teilprozess, wird — sofern der Hauptprozess
* bereits übernommen wurde — dessen Instanz als `parentId` gesetzt.
*/
export async function adoptCatalogProcess(formData: FormData) {
const { session, db } = await guard("bia:write");
const code = z.string().min(1).parse(formData.get("code"));
const ownerId = (formData.get("ownerId") as string) || null;
const actor = { id: session.user.id, tenantId: session.user.tenantId };
const entry = await db.processCatalogEntry.findUnique({ where: { code } });
if (!entry) throw new Error("Katalog-Eintrag nicht gefunden");
// Ist dieser Eintrag ein Teilprozess: bereits übernommene Hauptprozess-Instanz suchen.
const parentInstanceId = entry.parentCode
? (
await db.process.findFirst({
where: { catalogCode: entry.parentCode },
select: { id: true },
})
)?.id ?? null
: null;
const processId = await adoptEntry(db, actor, entry, ownerId, parentInstanceId);
// Teilprozesse des übernommenen Katalog-Prozesses mit anlegen (parentId = Instanz).
const children = await db.processCatalogEntry.findMany({
where: { parentCode: code },
orderBy: { orderIdx: "asc" },
});
for (const child of children) {
await adoptEntry(db, actor, child, null, processId);
}
revalidatePath("/onboarding");
revalidatePath("/processes");
}
export async function createProcess(formData: FormData) {
const { session, db } = await guard("bia:write");
const data = parseProcessForm(formData);
// tenantId explizit für die Typen, der Guard injiziert ohnehin
const process = await db.process.create({
data: { ...data, tenantId: session.user.tenantId, createdBy: session.user.id },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "process",
entityId: process.id,
after: data,
});
revalidatePath("/processes");
revalidatePath("/onboarding");
// Aus dem Prozesshaus (Onboarding) heraus angelegt → direkt ins BIA-Popup springen;
// sonst (Modul) in den Bearbeiten-Dialog. Steuerung über verstecktes `returnTo`.
const returnTo = (formData.get("returnTo") as string | null)?.trim();
if (returnTo === "house") {
redirect(`/onboarding?step=processes&bia=${process.id}&biaStep=1`);
}
redirect(`/processes?edit=${process.id}`);
}
export async function updateProcess(processId: string, formData: FormData) {
const { session, db } = await guard("bia:write");
const before = await db.process.findUnique({ where: { id: processId } });
if (!before) throw new Error("Prozess nicht gefunden");
const data = parseProcessForm(formData);
await db.process.update({ where: { id: processId }, data });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "process",
entityId: processId,
before,
after: data,
});
revalidatePath("/processes");
redirect(`/processes?detail=${processId}`);
}
/**
* Prozess endgültig entfernen (nicht nur `inScope` deaktivieren). Rechte-/Tenant-gegated
* über den Guard; Audit-Log. Verknüpfungen werden sauber behandelt:
* - **Risiken** (`Risk.processId` optional): Prozessbezug wird gelöst (SetNull), die
* Risiken selbst bleiben erhalten.
* - **Teilprozesse** (`parentId`): werden auf die oberste Ebene gehoben statt
* mitgelöscht (kein stiller Datenverlust).
* - **Träger-Zuordnungen** (`ProcessAsset`) und **BIA-Eintrag** (`BiaEntry`): entfernt
* (DB-seitig ohnehin `onDelete: Cascade`, hier zusätzlich explizit).
*
* `returnTo=house` kehrt ins Prozesshaus (Onboarding) zurück, sonst ins Prozess-Modul.
*/
export async function deleteProcess(processId: string, formData?: FormData) {
const { session, db } = await guard("bia:write");
const before = await db.process.findUnique({ where: { id: processId }, include: { bia: true } });
if (!before) throw new Error("Prozess nicht gefunden");
await db.risk.updateMany({ where: { processId }, data: { processId: null } });
await db.process.updateMany({ where: { parentId: processId }, data: { parentId: null } });
await db.processAsset.deleteMany({ where: { processId } });
await db.biaEntry.deleteMany({ where: { processId } });
// Prozess-Abhängigkeiten in beide Richtungen lösen (DB-seitig ohnehin Cascade).
await db.processDependency.deleteMany({
where: { OR: [{ sourceProcessId: processId }, { targetProcessId: processId }] },
});
await db.process.delete({ where: { id: processId } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "process",
entityId: processId,
before,
});
revalidatePath("/processes");
revalidatePath("/onboarding");
const returnTo = (formData?.get("returnTo") as string | null)?.trim();
if (returnTo === "house") redirect("/onboarding?step=processes");
redirect("/processes");
}
export async function assignAsset(processId: string, formData: FormData) {
const { session, db } = await guard("bia:write");
const assetId = z.string().min(1).parse(formData.get("assetId"));
const role = z.enum(["PRIMARY", "SECONDARY"]).parse(formData.get("role"));
const assetCount = await db.asset.count({ where: { id: assetId } });
const processCount = await db.process.count({ where: { id: processId } });
if (assetCount !== 1 || processCount !== 1) throw new Error("Nicht gefunden");
await db.processAsset.upsert({
where: { processId_assetId: { processId, assetId } },
update: { role },
create: { processId, assetId, role, tenantId: session.user.tenantId },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "process_asset",
entityId: processId,
after: { processId, assetId, role },
});
revalidatePath("/processes");
revalidatePath("/onboarding");
}
export async function unassignAsset(processId: string, processAssetId: string) {
const { session, db } = await guard("bia:write");
const before = await db.processAsset.findUnique({ where: { id: processAssetId } });
if (!before) return;
await db.processAsset.delete({ where: { id: processAssetId } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "process_asset",
entityId: processAssetId,
before,
});
revalidatePath("/processes");
revalidatePath("/onboarding");
}
/**
* Strukturierte Prozess-Abhängigkeit anlegen/aktualisieren: `source` benötigt `target`
* (Auswahl innerhalb des Mandanten). Idempotent über das Unique-Paar; Selbstbezug
* ausgeschlossen. Beide Prozesse müssen im Mandanten existieren (RLS + Zählung).
*/
export async function addProcessDependency(sourceProcessId: string, formData: FormData) {
const { session, db } = await guard("bia:write");
const targetProcessId = z.string().min(1).parse(formData.get("targetProcessId"));
const note = (formData.get("note") as string)?.trim() || null;
if (targetProcessId === sourceProcessId) {
throw new Error("Ein Prozess kann nicht von sich selbst abhängen.");
}
const count = await db.process.count({ where: { id: { in: [sourceProcessId, targetProcessId] } } });
if (count !== 2) throw new Error("Prozess nicht gefunden");
await db.processDependency.upsert({
where: { sourceProcessId_targetProcessId: { sourceProcessId, targetProcessId } },
update: { note },
create: { tenantId: session.user.tenantId, sourceProcessId, targetProcessId, note },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "process_dependency",
entityId: sourceProcessId,
after: { sourceProcessId, targetProcessId, note },
});
revalidatePath("/processes");
revalidatePath("/onboarding");
}
/** Prozess-Abhängigkeit wieder entfernen (per Kanten-Id). */
export async function removeProcessDependency(dependencyId: string) {
const { session, db } = await guard("bia:write");
const before = await db.processDependency.findUnique({ where: { id: dependencyId } });
if (!before) return;
await db.processDependency.delete({ where: { id: dependencyId } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "process_dependency",
entityId: dependencyId,
before,
});
revalidatePath("/processes");
revalidatePath("/onboarding");
}
const hours = z
.union([z.literal(""), z.coerce.number().int().min(0).max(100000)])
.transform((v) => (v === "" ? null : v));
export async function saveBia(processId: string, formData: FormData) {
const { session, db } = await guard("bia:write");
const processCount = await db.process.count({ where: { id: processId } });
if (processCount !== 1) throw new Error("Prozess nicht gefunden");
const level = z.coerce.number().int().min(1).max(4);
const data = {
rtoHours: hours.parse(formData.get("rtoHours") ?? ""),
rpoHours: hours.parse(formData.get("rpoHours") ?? ""),
mtdHours: hours.parse(formData.get("mtdHours") ?? ""),
impactC: level.parse(formData.get("impactC")),
impactI: level.parse(formData.get("impactI")),
impactA: level.parse(formData.get("impactA")),
notes: (formData.get("notes") as string)?.trim() || null,
};
// Kritikalität nach Max-Prinzip aus den Schadenshöhen (SPEC §4.1.2)
const criticality = Math.max(data.impactC, data.impactI, data.impactA);
const before = await db.biaEntry.findUnique({ where: { processId } });
await db.biaEntry.upsert({
where: { processId },
update: { ...data, criticality },
create: { ...data, criticality, processId, tenantId: session.user.tenantId },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: before ? "update" : "create",
entity: "bia_entry",
entityId: processId,
before: before ?? undefined,
after: { ...data, criticality },
});
revalidatePath("/processes");
}
/**
* Kombiniertes Speichern für den aufgeräumten Bearbeiten-Dialog: Stammdaten
* und BIA in einem Vorgang (ein Speichern-Button). Asset-Zuordnung läuft
* weiter über die inkrementellen Aktionen assignAsset/unassignAsset.
*/
export async function saveProcessAll(processId: string, formData: FormData) {
const { session, db } = await guard("bia:write");
const before = await db.process.findUnique({
where: { id: processId },
include: { bia: true },
});
if (!before) throw new Error("Prozess nicht gefunden");
const master = parseProcessForm(formData);
await db.process.update({ where: { id: processId }, data: master });
const level = z.coerce.number().int().min(1).max(4);
const biaData = {
rtoHours: hours.parse(formData.get("rtoHours") ?? ""),
rpoHours: hours.parse(formData.get("rpoHours") ?? ""),
mtdHours: hours.parse(formData.get("mtdHours") ?? ""),
impactC: level.parse(formData.get("impactC")),
impactI: level.parse(formData.get("impactI")),
impactA: level.parse(formData.get("impactA")),
notes: (formData.get("notes") as string)?.trim() || null,
};
const criticality = Math.max(biaData.impactC, biaData.impactI, biaData.impactA);
await db.biaEntry.upsert({
where: { processId },
update: { ...biaData, criticality },
create: { ...biaData, criticality, processId, tenantId: session.user.tenantId },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "process",
entityId: processId,
before,
after: { ...master, bia: { ...biaData, criticality } },
});
revalidatePath("/processes");
redirect(`/processes?detail=${processId}`);
}
// ── TISAX v3A: Prozesshaus & BIA-Popup ───────────────────────────────────────
/**
* Prozesshaus-Aktivierung: `inScope` je Prozess umschalten (Kachel-Schalter). Kein
* Redirect — der Nutzer bleibt im Prozesshaus.
*/
export async function toggleProcessScope(processId: string, formData: FormData) {
const { session, db } = await guard("bia:write");
const inScope = bool(formData.get("inScope"));
const before = await db.process.findFirst({ where: { id: processId }, select: { id: true, inScope: true } });
if (!before) throw new Error("Prozess nicht gefunden");
await db.process.update({ where: { id: processId }, data: { inScope } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "process",
entityId: processId,
before: { inScope: before.inScope },
after: { inScope },
});
revalidatePath("/onboarding");
revalidatePath("/processes");
}
/**
* Abschluss des BIA-Popups (Schritt 5): setzt `process.biaStatus` (offen | teilweise |
* komplett) → steuert die Farbkennzeichnung der Prozesshaus-Kachel. Kehrt ins
* Prozesshaus zurück (schließt das Popup).
*/
export async function setBiaStatus(processId: string, formData: FormData) {
const { session, db } = await guard("bia:write");
const biaStatus = z.enum(["offen", "teilweise", "komplett"]).parse(formData.get("biaStatus"));
const before = await db.process.findFirst({ where: { id: processId }, select: { id: true, biaStatus: true } });
if (!before) throw new Error("Prozess nicht gefunden");
await db.process.update({ where: { id: processId }, data: { biaStatus } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "process",
entityId: processId,
before: { biaStatus: before.biaStatus },
after: { biaStatus },
});
revalidatePath("/onboarding");
revalidatePath("/processes");
redirect("/onboarding?step=processes");
}
+92
View File
@@ -0,0 +1,92 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { prisma } from "@/server/db";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { withQuery } from "@/lib/supplier";
const guard = moduleGuard("assets");
const level = z.coerce.number().int().min(1).max(4);
const projectSchema = z.object({
name: z.string().trim().min(1).max(200),
confidentiality: level,
integrity: level,
availability: level,
ownerId: z.string().optional(),
classification: z.string().trim().max(200).optional(),
status: z.enum(["GEPLANT", "LAUFEND", "ABGESCHLOSSEN", "ABGEBROCHEN"]),
isbInvolved: z.string().optional(),
notes: z.string().trim().max(5000).optional(),
});
function parse(formData: FormData) {
const p = projectSchema.parse({
name: formData.get("name"),
confidentiality: formData.get("confidentiality"),
integrity: formData.get("integrity"),
availability: formData.get("availability"),
ownerId: formData.get("ownerId") || undefined,
classification: formData.get("classification") || undefined,
status: formData.get("status") || "GEPLANT",
isbInvolved: formData.get("isbInvolved") || undefined,
notes: formData.get("notes") || undefined,
});
return {
asset: {
name: p.name,
confidentiality: p.confidentiality,
integrity: p.integrity,
availability: p.availability,
ownerId: p.ownerId || null,
},
profile: {
classification: p.classification || null,
status: p.status,
isbInvolved: p.isbInvolved === "on",
notes: p.notes || null,
},
};
}
export async function createProject(formData: FormData) {
const { session, db } = await guard("asset:write");
const { asset, profile } = parse(formData);
const created = await db.asset.create({
data: { ...asset, type: "PROJECT", tenantId: session.user.tenantId, createdBy: session.user.id },
});
const last = await prisma.projectProfile.aggregate({ where: { tenantId: session.user.tenantId }, _max: { refNo: true } });
await db.projectProfile.create({
data: { ...profile, assetId: created.id, refNo: (last._max.refNo ?? 0) + 1, tenantId: session.user.tenantId, createdBy: session.user.id },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "project", entityId: created.id, after: { ...asset, ...profile } });
revalidatePath("/assets");
redirect(`/assets?type=PROJECT&edit=${created.id}`);
}
export async function updateProject(assetId: string, formData: FormData) {
const { session, db } = await guard("asset:write");
const before = await db.asset.findUnique({ where: { id: assetId }, include: { projectProfile: true } });
if (!before) throw new Error("Projekt nicht gefunden");
const { asset, profile } = parse(formData);
await db.asset.update({ where: { id: assetId }, data: asset });
await db.projectProfile.update({ where: { assetId }, data: profile });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "project", entityId: assetId, before, after: { ...asset, ...profile } });
revalidatePath("/assets");
const returnTo = (formData.get("returnTo") as string) || "/assets?type=PROJECT";
redirect(withQuery(returnTo, "detail", assetId));
}
export async function deleteProject(assetId: string, returnTo: string = "/assets?type=PROJECT") {
const { session, db } = await guard("asset:write");
const before = await db.asset.findUnique({ where: { id: assetId } });
if (!before) throw new Error("Projekt nicht gefunden");
await db.asset.delete({ where: { id: assetId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "project", entityId: assetId, before });
revalidatePath("/assets");
redirect(returnTo);
}
+69
View File
@@ -0,0 +1,69 @@
"use server";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
/**
* Editierbare verwaltete Register (WP3.0). Zeilen werden gegen die Pflichtspalten
* der ManagedRegister-Definition gepflegt; optionale Cross-Links an Lieferant/Asset.
* Modul „policies", Recht policy:write.
*/
const guard = moduleGuard("policies");
interface Column { key: string; label: string }
function readValues(columns: Column[], formData: FormData): Record<string, string> {
const values: Record<string, string> = {};
for (const c of columns) values[c.key] = String(formData.get(`col_${c.key}`) ?? "").trim();
return values;
}
const optRef = (v: FormDataEntryValue | null) => {
const s = v ? String(v).trim() : "";
return s || null;
};
export async function addRegisterRow(code: string, formData: FormData) {
const { session, db } = await guard("policy:write");
const reg = await db.managedRegister.findFirst({ where: { code } });
if (!reg) throw new Error("Register nicht gefunden");
const columns = reg.columns as unknown as Column[];
const last = await db.registerRow.aggregate({ where: { registerId: reg.id }, _max: { orderIdx: true } });
await db.registerRow.create({
data: {
tenantId: session.user.tenantId, registerId: reg.id,
values: readValues(columns, formData),
supplierRef: reg.supplierLink ? optRef(formData.get("supplierRef")) : null,
assetRef: reg.assetLink ? optRef(formData.get("assetRef")) : null,
orderIdx: (last._max.orderIdx ?? 0) + 1,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "register_row", entityId: reg.code });
revalidatePath(`/policies/${code}`);
}
export async function updateRegisterRow(rowId: string, formData: FormData) {
const { session, db } = await guard("policy:write");
const row = await db.registerRow.findUnique({ where: { id: rowId }, include: { register: true } });
if (!row) throw new Error("Zeile nicht gefunden");
const columns = row.register.columns as unknown as Column[];
await db.registerRow.update({
where: { id: rowId },
data: {
values: readValues(columns, formData),
supplierRef: row.register.supplierLink ? optRef(formData.get("supplierRef")) : row.supplierRef,
assetRef: row.register.assetLink ? optRef(formData.get("assetRef")) : row.assetRef,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "register_row", entityId: rowId });
revalidatePath(`/policies/${row.register.code}`);
}
export async function deleteRegisterRow(rowId: string) {
const { session, db } = await guard("policy:write");
const row = await db.registerRow.findUnique({ where: { id: rowId }, include: { register: { select: { code: true } } } });
if (!row) return;
await db.registerRow.delete({ where: { id: rowId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "register_row", entityId: rowId });
revalidatePath(`/policies/${row.register.code}`);
}
+234
View File
@@ -0,0 +1,234 @@
"use server";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import type { TenantDb } from "@/server/db";
/**
* AP4 — Managementklauseln (Modul „review"): Kennzahlen (9.1), Managementbewertung
* (9.3) und Nichtkonformität/Korrekturmaßnahme (10.2). moduleGuard("review") stellt das
* aktive Modul sicher; Bearbeitung verlangt `review:manage`.
*/
const guard = moduleGuard("review");
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
const dateOrNull = (v: FormDataEntryValue | null) => {
const s = str(v);
if (!s) return null;
const d = new Date(s);
return isNaN(d.getTime()) ? null : d;
};
async function audit(tenantId: string, actorId: string, action: "create" | "update" | "delete", entity: string, entityId: string, after?: unknown) {
await writeAuditLog({ tenantId, actorId, action, entity, entityId, after: after as object | undefined });
}
// ── 9.1 Kennzahlen ────────────────────────────────────────────────────────────
export async function createKpi(formData: FormData) {
const { session, db } = await guard("review:manage");
const name = str(formData.get("name"));
if (!name) throw new Error("Name der Kennzahl fehlt.");
const kpi = await db.kpi.create({
data: {
tenantId: session.user.tenantId,
name,
description: str(formData.get("description")) || null,
dataSource: str(formData.get("dataSource")) || null,
unit: str(formData.get("unit")) || null,
target: str(formData.get("target")) || null,
cadence: str(formData.get("cadence")) || "monatlich",
ownerId: str(formData.get("ownerId")) || null,
},
select: { id: true },
});
await audit(session.user.tenantId, session.user.id, "create", "kpi", kpi.id, { name });
revalidatePath("/review");
}
export async function updateKpi(kpiId: string, formData: FormData) {
const { session, db } = await guard("review:manage");
await db.kpi.update({
where: { id: kpiId },
data: {
name: str(formData.get("name")),
description: str(formData.get("description")) || null,
dataSource: str(formData.get("dataSource")) || null,
unit: str(formData.get("unit")) || null,
target: str(formData.get("target")) || null,
cadence: str(formData.get("cadence")) || "monatlich",
ownerId: str(formData.get("ownerId")) || null,
active: formData.get("active") !== "false",
},
});
await audit(session.user.tenantId, session.user.id, "update", "kpi", kpiId);
revalidatePath("/review");
}
/** Messwert je Periode erfassen (Upsert über (kpi, period)). */
export async function recordKpiValue(kpiId: string, formData: FormData) {
const { session, db } = await guard("review:manage");
const period = str(formData.get("period"));
const value = str(formData.get("value"));
if (!period || !value) throw new Error("Periode und Wert sind erforderlich.");
await db.kpiValue.upsert({
where: { tenantId_kpiId_period: { tenantId: session.user.tenantId, kpiId, period } },
update: { value, note: str(formData.get("note")) || null, recordedAt: new Date() },
create: { tenantId: session.user.tenantId, kpiId, period, value, note: str(formData.get("note")) || null },
});
await audit(session.user.tenantId, session.user.id, "update", "kpi_value", kpiId, { period, value });
revalidatePath("/review");
}
// ── 9.3 Managementbewertung ───────────────────────────────────────────────────
export async function createManagementReview(formData: FormData) {
const { session, db } = await guard("review:manage");
const review = await db.managementReview.create({
data: {
tenantId: session.user.tenantId,
reviewDate: dateOrNull(formData.get("reviewDate")) ?? new Date(),
inputs: str(formData.get("inputs")) || null,
results: str(formData.get("results")) || null,
ownerId: str(formData.get("ownerId")) || null,
},
select: { id: true },
});
await audit(session.user.tenantId, session.user.id, "create", "management_review", review.id);
revalidatePath("/review");
}
export async function updateManagementReview(reviewId: string, formData: FormData) {
const { session, db } = await guard("review:manage");
const statusRaw = str(formData.get("status"));
await db.managementReview.update({
where: { id: reviewId },
data: {
reviewDate: dateOrNull(formData.get("reviewDate")) ?? undefined,
inputs: str(formData.get("inputs")) || null,
results: str(formData.get("results")) || null,
ownerId: str(formData.get("ownerId")) || null,
status: statusRaw === "abgeschlossen" ? "abgeschlossen" : "entwurf",
},
});
await audit(session.user.tenantId, session.user.id, "update", "management_review", reviewId);
revalidatePath("/review");
}
export async function addReviewDecision(reviewId: string, formData: FormData) {
const { session, db } = await guard("review:manage");
const decision = str(formData.get("decision"));
if (!decision) throw new Error("Beschlusstext fehlt.");
await db.managementReviewDecision.create({
data: {
tenantId: session.user.tenantId,
reviewId,
decision,
ownerId: str(formData.get("ownerId")) || null,
dueDate: dateOrNull(formData.get("dueDate")),
},
});
await audit(session.user.tenantId, session.user.id, "create", "management_review_decision", reviewId);
revalidatePath("/review");
}
export async function setReviewDecisionStatus(decisionId: string, done: boolean) {
const { session, db } = await guard("review:manage");
await db.managementReviewDecision.update({ where: { id: decisionId }, data: { status: done ? "erledigt" : "offen" } });
await audit(session.user.tenantId, session.user.id, "update", "management_review_decision", decisionId, { status: done ? "erledigt" : "offen" });
revalidatePath("/review");
}
// ── 10.2 Nichtkonformität & Korrekturmaßnahme ─────────────────────────────────
/** Nächste Nichtkonformitäts-Kennung „NC-<Jahr>-<lfd>" (mandantengebunden). */
async function nextNcRefNo(db: TenantDb, tenantId: string): Promise<string> {
const year = new Date().getFullYear();
const prefix = `NC-${year}-`;
const rows = await db.nonconformity.findMany({ where: { tenantId, refNo: { startsWith: prefix } }, select: { refNo: true } });
let max = 0;
for (const r of rows) {
const n = parseInt(r.refNo.slice(prefix.length), 10);
if (!isNaN(n) && n > max) max = n;
}
return `${prefix}${String(max + 1).padStart(4, "0")}`;
}
export async function createNonconformity(formData: FormData) {
const { session, db } = await guard("review:manage");
const description = str(formData.get("description"));
const source = str(formData.get("source"));
if (!description || !source) throw new Error("Herkunft und Beschreibung sind erforderlich.");
const refNo = await nextNcRefNo(db, session.user.tenantId);
const nc = await db.nonconformity.create({
data: {
tenantId: session.user.tenantId,
refNo,
source,
description,
immediateCorrection: str(formData.get("immediateCorrection")) || null,
ownerId: str(formData.get("ownerId")) || null,
},
select: { id: true },
});
await audit(session.user.tenantId, session.user.id, "create", "nonconformity", nc.id, { refNo });
revalidatePath("/review");
}
export async function updateNonconformity(ncId: string, formData: FormData) {
const { session, db } = await guard("review:manage");
const statusRaw = str(formData.get("status"));
const status = ["offen", "in_bearbeitung", "abgeschlossen"].includes(statusRaw) ? statusRaw : "offen";
await db.nonconformity.update({
where: { id: ncId },
data: {
source: str(formData.get("source")),
description: str(formData.get("description")),
immediateCorrection: str(formData.get("immediateCorrection")) || null,
ownerId: str(formData.get("ownerId")) || null,
status,
closedAt: status === "abgeschlossen" ? new Date() : null,
},
});
await audit(session.user.tenantId, session.user.id, "update", "nonconformity", ncId, { status });
revalidatePath("/review");
}
export async function addCorrectiveAction(ncId: string, formData: FormData) {
const { session, db } = await guard("review:manage");
const action = str(formData.get("action"));
if (!action) throw new Error("Maßnahmentext fehlt.");
await db.correctiveAction.create({
data: {
tenantId: session.user.tenantId,
nonconformityId: ncId,
action,
rootCause: str(formData.get("rootCause")) || null,
ownerId: str(formData.get("ownerId")) || null,
dueDate: dateOrNull(formData.get("dueDate")),
},
});
await audit(session.user.tenantId, session.user.id, "create", "corrective_action", ncId);
revalidatePath("/review");
}
/** Maßnahme pflegen inkl. dokumentierter Wirksamkeitsprüfung (10.2 d/e). */
export async function updateCorrectiveAction(actionId: string, formData: FormData) {
const { session, db } = await guard("review:manage");
const statusRaw = str(formData.get("status"));
const status = statusRaw === "umgesetzt" ? "umgesetzt" : "geplant";
const effectivenessCheck = str(formData.get("effectivenessCheck")) || null;
const confirmed = formData.get("effectivenessConfirmed") === "on" || formData.get("effectivenessConfirmed") === "true";
await db.correctiveAction.update({
where: { id: actionId },
data: {
action: str(formData.get("action")),
rootCause: str(formData.get("rootCause")) || null,
ownerId: str(formData.get("ownerId")) || null,
dueDate: dateOrNull(formData.get("dueDate")),
status,
effectivenessCheck,
// Wirksamkeit gilt erst als bestätigt, wenn Haken UND eine Bewertung vorliegen.
effectivenessConfirmedAt: confirmed && effectivenessCheck ? new Date() : null,
},
});
await audit(session.user.tenantId, session.user.id, "update", "corrective_action", actionId, { status });
revalidatePath("/review");
}
+242
View File
@@ -0,0 +1,242 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import type { TenantDb } from "@/server/db";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { recomputeResidualRisk } from "@/server/risk-calc";
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
/** Risikowert > 9 (hoch/sehr hoch) erfordert dokumentierte Akzeptanz (VA-09). */
const ACCEPT_THRESHOLD = 9;
/**
* Standard-Risikokatalog (Story A6-1). Übernimmt ein Katalog-Risiko (C4) mit seiner
* Default-Bewertung (E×S) ins mandanteneigene Risk-Register — dort frei anpassbar
* (Bewertung, Behandlung, Maßnahmen → A6-2). Idempotent je `catalogCode`.
*/
const guard = moduleGuard("risk");
/**
* Gemeinsame, idempotente Übernahme eines Katalog-Risikos ins Register. Gibt `true`
* zurück, wenn ein neues Risiko angelegt wurde, `false`, wenn es bereits existierte.
* Kapselt die Logik für den Katalog-Pfad (`adoptCatalogRisk`, mit Redirect) und den
* prozessgeführten Wizard-Pfad (`adoptSuggestedRisk`, ohne Redirect).
*/
async function createRiskFromCatalog(
db: TenantDb,
tenantId: string,
userId: string,
code: string,
processId?: string,
): Promise<boolean> {
const entry = await db.riskCatalogEntry.findUnique({ where: { code } });
if (!entry) throw new Error("Katalog-Risiko nicht gefunden.");
// Im prozessgeführten BIA-Popup wird das Katalog-Risiko JE PROZESS übernommen: schon
// vorhandenes Risiko am selben Prozess = idempotent; an anderem Prozess = eigener
// Eintrag. Ohne Prozess (globaler Katalog-Pfad) bleibt es idempotent je Code.
const existing = await db.risk.findFirst({
where: processId ? { catalogCode: code, processId } : { catalogCode: code },
select: { id: true },
});
if (existing) return false;
const last = await db.risk.aggregate({ _max: { refNo: true } });
const likelihood = entry.defaultLikelihood;
const impact = entry.defaultImpact;
await db.risk.create({
data: {
tenantId,
refNo: (last._max.refNo ?? 0) + 1,
title: entry.title,
description: entry.description,
threat: `Standard-Risikokatalog ${entry.code} (${entry.category})`,
catalogCode: entry.code,
processId: processId ?? null,
likelihood, impact, score: likelihood * impact,
treatment: "MITIGATE",
createdBy: userId,
},
});
await writeAuditLog({ tenantId, actorId: userId, action: "create", entity: "risk", after: { fromCatalog: code, likelihood, impact, processId } });
return true;
}
export async function adoptCatalogRisk(code: string) {
const { session, db } = await guard("risk:write");
const created = await createRiskFromCatalog(db, session.user.tenantId, session.user.id, code);
if (!created) redirect("/risks?catalog=exists");
revalidatePath("/risks");
revalidatePath("/risks/catalog");
redirect("/risks?catalog=adopted");
}
/** Ein Katalog-Vorschlag im Risks-Step, mit Übernahme-Status je Prozess. */
export interface SuggestedRisk {
code: string;
title: string;
category: string;
adopted: boolean;
}
export interface ProcessRiskSuggestion {
processId: string;
processName: string;
risks: SuggestedRisk[];
}
/**
* M2 Risks-Step: löst die `suggestedRiskCodes` der übernommenen Katalog-Prozesse
* (Match über den Prozessnamen) zu den zugehörigen `RiskCatalogEntry` auf und markiert,
* welche bereits ins Register übernommen wurden. Grundlage für die Ein-Klick-Übernahme.
*/
export async function getSuggestedRisksForProcesses(): Promise<ProcessRiskSuggestion[]> {
const { db } = await guard("risk:read");
const [processes, catalog] = await Promise.all([
db.process.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true, catalogCode: true } }),
db.processCatalogEntry.findMany({ select: { code: true, name: true, suggestedRiskCodes: true } }),
]);
// v3A: Zuordnung Prozess↔Katalog bevorzugt über catalogCode (Name nur als Fallback).
const byCatCode = new Map(catalog.map((c) => [c.code, c.suggestedRiskCodes]));
const byCatName = new Map(catalog.map((c) => [c.name, c.suggestedRiskCodes]));
const allCodes = Array.from(new Set(catalog.flatMap((c) => c.suggestedRiskCodes)));
if (allCodes.length === 0) return [];
const [entries, adoptedRows] = await Promise.all([
db.riskCatalogEntry.findMany({
where: { code: { in: allCodes } },
select: { code: true, title: true, category: true },
}),
// Übernahme JE PROZESS bewerten (processId), damit derselbe Katalog-Code an mehreren
// Prozessen unabhängig als „übernommen" markiert werden kann.
db.risk.findMany({ where: { catalogCode: { in: allCodes } }, select: { catalogCode: true, processId: true } }),
]);
const entryByCode = new Map(entries.map((e) => [e.code, e]));
const adoptedByProcess = new Set(
adoptedRows.filter((r) => r.processId).map((r) => `${r.processId}:${r.catalogCode}`),
);
return processes.flatMap((p) => {
const codes = (p.catalogCode ? byCatCode.get(p.catalogCode) : undefined) ?? byCatName.get(p.name);
if (!codes || codes.length === 0) return [];
const risks = codes.flatMap((code) => {
const e = entryByCode.get(code);
if (!e) return [];
return [{ code: e.code, title: e.title, category: e.category, adopted: adoptedByProcess.has(`${p.id}:${code}`) }];
});
if (risks.length === 0) return [];
return [{ processId: p.id, processName: p.name, risks }];
});
}
/**
* Prozessgeführte Ein-Klick-Übernahme eines vorgeschlagenen Katalog-Risikos aus dem
* Wizard (Risks-Step). Wie `adoptCatalogRisk`, aber ohne Redirect — der Nutzer bleibt
* im Wizard. Idempotent je `catalogCode`.
*/
export async function adoptSuggestedRisk(code: string) {
const { session, db } = await guard("risk:write");
await createRiskFromCatalog(db, session.user.tenantId, session.user.id, code);
revalidatePath("/onboarding");
revalidatePath("/risks");
revalidatePath("/risks/catalog");
}
/**
* BIA-Popup Schritt 4: übernimmt ein vorgeschlagenes Katalog-Risiko und verknüpft es
* direkt mit DIESEM Prozess (processId gesetzt). So erscheint es im selben Popup-Schritt
* zur Bewertung. Idempotent je (catalogCode, processId). Kein Redirect (bleibt im Popup).
*/
export async function adoptProcessRisk(processId: string, code: string) {
const { session, db } = await guard("risk:write");
if ((await db.process.count({ where: { id: processId } })) !== 1) {
throw new Error("Prozess nicht gefunden");
}
await createRiskFromCatalog(db, session.user.tenantId, session.user.id, code, processId);
revalidatePath("/onboarding");
revalidatePath("/risks");
}
/**
* Standardmaßnahme eines Katalog-Risikos als echte, mit dem Risiko verknüpfte Maßnahme
* übernehmen (nicht als separate Aufgabe). So erscheint sie im Risiko unter „Maßnahmen"
* und mindert nach Pflege der Minderung das Restrisiko. Idempotent je Risiko/Maßnahmentitel.
*/
export async function adoptStandardMeasure(riskId: string) {
const { session, db } = await guard("risk:write");
// Defense in Depth: tenantId explizit filtern (F-02). Nichtssagende
// Fehlermeldung für den Objekt-Lookup (F-16, Score-/Existenz-Oracle schließen).
const risk = await db.risk.findFirst({
where: { id: riskId, tenantId: session.user.tenantId },
select: { id: true, catalogCode: true, score: true },
});
if (!risk) throw new Error("Vorgang nicht möglich.");
if (!risk.catalogCode) throw new Error("Standardmaßnahme nur für Katalog-Risiken verfügbar.");
const entry = await db.riskCatalogEntry.findUnique({ where: { code: risk.catalogCode } });
if (!entry) throw new Error("Katalog-Eintrag nicht gefunden.");
const title = entry.standardMeasure.slice(0, 200);
const existing = await db.riskMeasure.findFirst({ where: { riskId, measure: { title } }, select: { id: true } });
if (!existing) {
// Transaktionsklammer (F-02): Maßnahme UND Verknüpfung entstehen atomar —
// kein Teilzustand, falls ein Schritt scheitert.
const measure = await db.$transaction(async (tx) => {
const last = await tx.measure.aggregate({ _max: { refNo: true } });
const m = await tx.measure.create({
data: {
tenantId: session.user.tenantId,
refNo: (last._max.refNo ?? 0) + 1,
title,
description: entry.rationale || null,
priority: risk.score >= 10 ? "HIGH" : "MEDIUM",
createdBy: session.user.id,
},
});
await tx.riskMeasure.create({ data: { tenantId: session.user.tenantId, riskId, measureId: m.id, reductionLikelihood: 0, reductionImpact: 0 } });
return m;
});
// Restrisiko-Neuberechnung NACH dem Commit: der Tenant-Guard schreibt
// `findUnique` auf ein `findFirst` des rohen Clients um (F-02, Verhindern statt
// Erkennen); dieser sähe die in der Transaktion frisch angelegte Verknüpfung
// nicht. Erst nach dem Commit liefert der Lesezugriff den konsistenten Stand.
await recomputeResidualRisk(db, riskId);
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "measure", entityId: measure.id, after: { title, linkedRisk: riskId, fromCatalog: entry.code } });
}
revalidatePath("/risks");
revalidatePath("/measures");
redirect(`/risks?detail=${riskId}&measure=1`);
}
/**
* Story A6-2: Risiko akzeptieren (Behandlung ACCEPT). Liegt das (Rest-)Risiko über
* der Akzeptanzschwelle, ist eine dokumentierte Akzeptanz (VA-09) Pflicht.
*/
export async function acceptRisk(riskId: string, formData: FormData) {
const { session, db } = await guard("risk:write");
// Defense in Depth: tenantId explizit filtern (F-02). Der Objekt-Lookup gibt eine
// nichtssagende Meldung zurück — sonst ließe der Unterschied zwischen „nicht
// gefunden" und „über der Akzeptanzschwelle" ein Existenz-/Score-Oracle zu (F-16).
const risk = await db.risk.findFirst({
where: { id: riskId, tenantId: session.user.tenantId },
select: { id: true, score: true, residualScore: true },
});
if (!risk) throw new Error("Vorgang nicht möglich.");
const rationale = str(formData.get("rationale"));
const effScore = risk.residualScore ?? risk.score;
if (effScore > ACCEPT_THRESHOLD && !rationale) {
throw new Error("Restrisiko über der Akzeptanzschwelle — bitte die Akzeptanz dokumentiert begründen (VA-09).");
}
await db.risk.update({
where: { id: riskId },
data: { treatment: "ACCEPT", acceptanceRationale: rationale || null, acceptedById: session.user.id, acceptedAt: new Date() },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "risk", entityId: riskId, after: { treatment: "ACCEPT", effScore, documented: Boolean(rationale) } });
revalidatePath("/risks");
redirect(`/risks?detail=${riskId}&accepted=1`);
}
+368
View File
@@ -0,0 +1,368 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { prisma } from "@/server/db";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { createObjectReviewTask } from "@/server/object-review";
import { syncTaskFromObject } from "@/server/task-sync";
import { riskRef } from "@/lib/risk";
const guard = moduleGuard("risk");
import { recomputeResidualRisk } from "@/server/risk-calc";
const scale = z.coerce.number().int().min(1).max(5);
const riskSchema = z.object({
title: z.string().trim().min(1).max(200),
description: z.string().trim().max(5000).optional(),
threat: z.string().trim().max(500).optional(),
vulnerability: z.string().trim().max(500).optional(),
likelihood: scale,
impact: scale,
treatment: z.enum(["AVOID", "MITIGATE", "TRANSFER", "ACCEPT"]),
status: z.enum(["OPEN", "IN_TREATMENT", "ACCEPTED", "CLOSED"]),
ownerId: z.string().optional(),
processId: z.string().optional(),
});
function parseRiskForm(formData: FormData) {
const parsed = riskSchema.parse({
title: formData.get("title"),
description: formData.get("description") || undefined,
threat: formData.get("threat") || undefined,
vulnerability: formData.get("vulnerability") || undefined,
likelihood: formData.get("likelihood"),
impact: formData.get("impact"),
treatment: formData.get("treatment"),
status: formData.get("status"),
ownerId: formData.get("ownerId") || undefined,
processId: formData.get("processId") || undefined,
});
return {
title: parsed.title,
description: parsed.description || null,
threat: parsed.threat || null,
vulnerability: parsed.vulnerability || null,
likelihood: parsed.likelihood,
impact: parsed.impact,
score: parsed.likelihood * parsed.impact,
treatment: parsed.treatment,
status: parsed.status,
ownerId: parsed.ownerId || null,
processId: parsed.processId || null,
};
}
export async function createRisk(formData: FormData) {
const { session, db } = await guard("risk:write");
const data = parseRiskForm(formData);
// Laufende Nummer je Mandant (MVP: max+1; bei Kollision greift der Unique-Index)
const last = await prisma.risk.aggregate({
where: { tenantId: session.user.tenantId },
_max: { refNo: true },
});
const risk = await db.risk.create({
data: {
...data,
refNo: (last._max.refNo ?? 0) + 1,
tenantId: session.user.tenantId,
createdBy: session.user.id,
},
});
// Optional: direkt aus einem Asset heraus angelegt → Asset sofort verknüpfen
const assetId = formData.get("assetId") as string | null;
if (assetId) {
const assetCount = await db.asset.count({ where: { id: assetId } });
if (assetCount === 1) {
await db.riskAsset.create({
data: { riskId: risk.id, assetId, tenantId: session.user.tenantId },
});
}
}
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "risk",
entityId: risk.id,
after: data,
});
revalidatePath("/risks");
revalidatePath("/assets");
redirect(`/risks?edit=${risk.id}`);
}
export async function updateRisk(riskId: string, formData: FormData) {
const { session, db } = await guard("risk:write");
const before = await db.risk.findUnique({ where: { id: riskId } });
if (!before) throw new Error("Risiko nicht gefunden");
const data = parseRiskForm(formData);
await db.risk.update({ where: { id: riskId }, data });
// Brutto-Bewertung geändert → Rest-Risiko aus den Maßnahmen neu ableiten
await recomputeResidualRisk(db, riskId);
// Cockpit (M3, 2.2): Auto-Completion GEDECKELT — Risiko ACCEPTED/CLOSED schließt die
// verknüpften Aufgaben bis DONE (Restrisiko dokumentiert). „audit-ready" bleibt separat.
if (data.status === "ACCEPTED" || data.status === "CLOSED") {
await syncTaskFromObject(
{ db, tenantId: session.user.tenantId, actorId: session.user.id },
"risk",
{ matchKey: [riskId, riskRef(before.refNo), String(before.refNo)], reached: true },
);
}
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "risk",
entityId: riskId,
before,
after: data,
});
revalidatePath("/risks");
redirect(`/risks?detail=${riskId}`);
}
/**
* BIA-Popup Schritt 4: Inline-Bewertung eines prozessverknüpften Risikos (Eintritts-
* wahrscheinlichkeit × Auswirkung = Wert, Behandlung) — dieselbe Bewertungslogik wie
* `updateRisk`, aber schlank (nur Bewertungsfelder) und mit Rücksprung ins BIA-Popup.
*/
export async function rateProcessRisk(processId: string, riskId: string, formData: FormData) {
const { session, db } = await guard("risk:write");
const before = await db.risk.findFirst({
where: { id: riskId, tenantId: session.user.tenantId },
select: { id: true, likelihood: true, impact: true, treatment: true, status: true },
});
if (!before) throw new Error("Vorgang nicht möglich.");
const data = z
.object({
likelihood: scale,
impact: scale,
treatment: z.enum(["AVOID", "MITIGATE", "TRANSFER", "ACCEPT"]),
status: z.enum(["OPEN", "IN_TREATMENT", "ACCEPTED", "CLOSED"]),
})
.parse({
likelihood: formData.get("likelihood"),
impact: formData.get("impact"),
treatment: formData.get("treatment"),
status: formData.get("status"),
});
await db.risk.update({
where: { id: riskId },
data: { ...data, score: data.likelihood * data.impact, processId },
});
await recomputeResidualRisk(db, riskId);
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "risk",
entityId: riskId,
before,
after: { ...data, score: data.likelihood * data.impact, viaBiaPopup: processId },
});
revalidatePath("/onboarding");
revalidatePath("/risks");
redirect(`/onboarding?step=processes&bia=${processId}&biaStep=4`);
}
/**
* BIA-Popup Schritt 4 (bestehendes Risiko zuordnen): ordnet ein bereits vorhandenes
* Risiko diesem Prozess zu (setzt `processId`). Rücksprung ins Popup. Idempotent.
*/
export async function linkProcessRisk(processId: string, formData: FormData) {
const { session, db } = await guard("risk:write");
const riskId = z.string().min(1).parse(formData.get("riskId"));
const [risk, processCount] = await Promise.all([
db.risk.findFirst({ where: { id: riskId, tenantId: session.user.tenantId }, select: { id: true, processId: true } }),
db.process.count({ where: { id: processId } }),
]);
if (!risk || processCount !== 1) throw new Error("Vorgang nicht möglich.");
await db.risk.update({ where: { id: riskId }, data: { processId } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "risk",
entityId: riskId,
before: { processId: risk.processId },
after: { processId, viaBiaPopup: true },
});
revalidatePath("/onboarding");
revalidatePath("/risks");
redirect(`/onboarding?step=processes&bia=${processId}&biaStep=4`);
}
/**
* BIA-Popup Schritt 4 (neues Risiko anlegen): legt ein neues Risiko an und verknüpft es
* direkt mit DIESEM Prozess — inkl. der vorhandenen Bewertung (Eintritt × Auswirkung =
* Wert, Behandlung, Status). Schlanke Variante von `createRisk`, mit Rücksprung ins Popup.
*/
export async function createProcessRisk(processId: string, formData: FormData) {
const { session, db } = await guard("risk:write");
if ((await db.process.count({ where: { id: processId } })) !== 1) {
throw new Error("Prozess nicht gefunden");
}
const parsed = z
.object({
title: z.string().trim().min(1).max(200),
description: z.string().trim().max(5000).optional(),
likelihood: scale,
impact: scale,
treatment: z.enum(["AVOID", "MITIGATE", "TRANSFER", "ACCEPT"]).default("MITIGATE"),
status: z.enum(["OPEN", "IN_TREATMENT", "ACCEPTED", "CLOSED"]).default("OPEN"),
})
.parse({
title: formData.get("title"),
description: formData.get("description") || undefined,
likelihood: formData.get("likelihood"),
impact: formData.get("impact"),
treatment: formData.get("treatment") || undefined,
status: formData.get("status") || undefined,
});
const last = await prisma.risk.aggregate({
where: { tenantId: session.user.tenantId },
_max: { refNo: true },
});
const risk = await db.risk.create({
data: {
tenantId: session.user.tenantId,
refNo: (last._max.refNo ?? 0) + 1,
title: parsed.title,
description: parsed.description || null,
likelihood: parsed.likelihood,
impact: parsed.impact,
score: parsed.likelihood * parsed.impact,
treatment: parsed.treatment,
status: parsed.status,
processId,
createdBy: session.user.id,
},
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "risk",
entityId: risk.id,
after: { title: parsed.title, score: parsed.likelihood * parsed.impact, processId, viaBiaPopup: true },
});
revalidatePath("/onboarding");
revalidatePath("/risks");
redirect(`/onboarding?step=processes&bia=${processId}&biaStep=4`);
}
export async function deleteRisk(riskId: string) {
const { session, db } = await guard("risk:write");
const before = await db.risk.findUnique({ where: { id: riskId } });
if (!before) throw new Error("Risiko nicht gefunden");
await db.risk.delete({ where: { id: riskId } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "risk",
entityId: riskId,
before,
});
revalidatePath("/risks");
redirect("/risks");
}
export async function addRiskAsset(riskId: string, formData: FormData) {
const { session, db } = await guard("risk:write");
const assetId = z.string().min(1).parse(formData.get("assetId"));
const assetCount = await db.asset.count({ where: { id: assetId } });
const riskCount = await db.risk.count({ where: { id: riskId } });
if (assetCount !== 1 || riskCount !== 1) throw new Error("Nicht gefunden");
await db.riskAsset.upsert({
where: { riskId_assetId: { riskId, assetId } },
update: {},
create: { riskId, assetId, tenantId: session.user.tenantId },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "risk_asset",
entityId: riskId,
after: { riskId, assetId },
});
revalidatePath("/risks");
revalidatePath("/assets");
}
export async function removeRiskAsset(riskId: string, riskAssetId: string) {
const { session, db } = await guard("risk:write");
const before = await db.riskAsset.findUnique({ where: { id: riskAssetId } });
if (!before) return;
await db.riskAsset.delete({ where: { id: riskAssetId } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "risk_asset",
entityId: riskAssetId,
before,
});
revalidatePath("/risks");
revalidatePath("/assets");
}
/**
* Risiko zur Validierung einreichen (Story A3-1, generisches Objekt-Review). Erzeugt
* eine `validation`-Aufgabe an einen berechtigten Validator (Vier-Augen). Der
* Review-Status wird aus der Aufgabe abgeleitet — kein Statusfeld am Risiko.
*/
export async function submitRiskForReview(riskId: string, formData: FormData) {
const { session, db } = await guard("risk:write");
// Defense in Depth: tenantId explizit in die Abfrage, damit die Isolation
// nicht allein am zentralen Guard hängt (F-02). findFirst statt findUnique,
// damit der tenantId-Filter direkt greift. Nichtssagende Fehlermeldung (F-16).
const risk = await db.risk.findFirst({
where: { id: riskId, tenantId: session.user.tenantId },
select: { refNo: true, title: true },
});
if (!risk) throw new Error("Vorgang nicht möglich.");
await createObjectReviewTask(db, {
tenantId: session.user.tenantId,
entityType: "risk",
entityId: riskId,
entityRef: riskRef(risk.refNo),
title: `Validierung: ${riskRef(risk.refNo)} — ${risk.title}`,
approverId: String(formData.get("approverId") ?? "").trim(),
submitterId: session.user.id,
note: (formData.get("note") as string | null)?.trim() || null,
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "risk", entityId: riskId, after: { review: "submitted" } });
revalidatePath("/risks", "layout");
revalidatePath("/tasks");
}
+126
View File
@@ -0,0 +1,126 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { prisma } from "@/server/db";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
const guard = moduleGuard("suppliers");
import { withQuery } from "@/lib/supplier";
const level = z.coerce.number().int().min(1).max(4);
const serviceSchema = z.object({
name: z.string().trim().min(1).max(200),
confidentiality: level,
integrity: level,
availability: level,
criticality: level,
providerAssetId: z.string().optional(),
internal: z.string().optional(),
notes: z.string().trim().max(5000).optional(),
});
function parse(formData: FormData) {
const p = serviceSchema.parse({
name: formData.get("name"),
confidentiality: formData.get("confidentiality"),
integrity: formData.get("integrity"),
availability: formData.get("availability"),
criticality: formData.get("criticality"),
providerAssetId: formData.get("providerAssetId") || undefined,
internal: formData.get("internal") || undefined,
notes: formData.get("notes") || undefined,
});
return {
asset: { name: p.name, confidentiality: p.confidentiality, integrity: p.integrity, availability: p.availability },
profile: {
criticality: p.criticality,
providerAssetId: p.providerAssetId || null,
internal: p.internal === "on",
notes: p.notes || null,
},
};
}
export async function createService(formData: FormData) {
const { session, db } = await guard("supplier:write");
const { asset, profile } = parse(formData);
const created = await db.asset.create({
data: { ...asset, type: "IT_SERVICE", tenantId: session.user.tenantId, createdBy: session.user.id },
});
const last = await prisma.iTServiceProfile.aggregate({ where: { tenantId: session.user.tenantId }, _max: { refNo: true } });
await db.iTServiceProfile.create({
data: { ...profile, assetId: created.id, refNo: (last._max.refNo ?? 0) + 1, tenantId: session.user.tenantId, createdBy: session.user.id },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "it_service", entityId: created.id, after: { ...asset, ...profile } });
revalidatePath("/suppliers");
revalidatePath("/assets");
redirect(`/suppliers?tab=services&edit=${created.id}`);
}
export async function updateService(assetId: string, formData: FormData) {
const { session, db } = await guard("supplier:write");
const before = await db.asset.findUnique({ where: { id: assetId }, include: { serviceProfile: true } });
if (!before) throw new Error("Service nicht gefunden");
const { asset, profile } = parse(formData);
await db.asset.update({ where: { id: assetId }, data: asset });
await db.iTServiceProfile.update({ where: { assetId }, data: profile });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "it_service", entityId: assetId, before, after: { ...asset, ...profile } });
revalidatePath("/suppliers");
revalidatePath("/assets");
const returnTo = (formData.get("returnTo") as string) || "/suppliers?tab=services";
redirect(withQuery(returnTo, "detail", assetId));
}
export async function deleteService(assetId: string, returnTo: string = "/suppliers?tab=services") {
const { session, db } = await guard("supplier:write");
const before = await db.asset.findUnique({ where: { id: assetId } });
if (!before) throw new Error("Service nicht gefunden");
await db.asset.delete({ where: { id: assetId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "it_service", entityId: assetId, before });
revalidatePath("/suppliers");
revalidatePath("/assets");
redirect(returnTo);
}
export async function addRaci(assetId: string, formData: FormData) {
const { session, db } = await guard("supplier:write");
if ((await db.asset.count({ where: { id: assetId } })) !== 1) throw new Error("Nicht gefunden");
await db.serviceControlResponsibility.create({
data: {
assetId,
tenantId: session.user.tenantId,
controlRef: z.string().trim().min(1).parse(formData.get("controlRef")),
title: (formData.get("title") as string)?.trim() || null,
applicable: formData.get("applicable") !== "off",
responsibility: z.enum(["PROVIDER", "US", "SHARED"]).parse(formData.get("responsibility") ?? "SHARED"),
evidenceRef: (formData.get("evidenceRef") as string)?.trim() || null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "service_raci", entityId: assetId });
revalidatePath("/suppliers");
revalidatePath("/assets");
}
/** Verantwortung einer RACI-Zeile per Klick ändern (PROVIDER/US/SHARED durchschalten). */
export async function cycleRaci(assetId: string, raciId: string) {
const { session, db } = await guard("supplier:write");
const row = await db.serviceControlResponsibility.findUnique({ where: { id: raciId } });
if (!row) return;
const next = { PROVIDER: "US", US: "SHARED", SHARED: "PROVIDER" } as const;
await db.serviceControlResponsibility.update({ where: { id: raciId }, data: { responsibility: next[row.responsibility] } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "service_raci", entityId: assetId, after: { responsibility: next[row.responsibility] } });
revalidatePath("/suppliers");
revalidatePath("/assets");
}
export async function deleteRaci(assetId: string, raciId: string) {
const { session, db } = await guard("supplier:write");
await db.serviceControlResponsibility.delete({ where: { id: raciId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "service_raci", entityId: assetId });
revalidatePath("/suppliers");
revalidatePath("/assets");
}
+52
View File
@@ -0,0 +1,52 @@
"use server";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { SOA_STATUS } from "@/lib/soa";
/**
* AP3 — Pflege der Anwendbarkeitserklärung (SoA). Der ISB bearbeitet je Control die
* normativen Pflichtangaben (Anwendbarkeit + Begründung/Ausschluss + Umsetzungsstatus)
* sowie Herkunft, Verantwortlichen, Richtlinie und Nachweis. moduleGuard("soa") stellt
* das aktive Modul sicher; die Bearbeitung verlangt `soa:write`. Die Vorbefüllung der
* 93 Controls erledigt der Seiten-Load idempotent (ensureSoaEntries).
*/
const guard = moduleGuard("soa");
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
export async function updateSoaEntry(entryId: string, formData: FormData) {
const { session, db } = await guard("soa:write");
// RLS stellt die Mandantenbindung sicher; existierende Zeile laden (für Audit-Kontext).
const entry = await db.soaEntry.findUnique({ where: { id: entryId }, select: { id: true, control: true } });
if (!entry) throw new Error("SoA-Eintrag nicht gefunden.");
const applicable = formData.get("applicable") === "on" || formData.get("applicable") === "true";
const statusRaw = str(formData.get("implementationStatus"));
const implementationStatus = (SOA_STATUS as readonly string[]).includes(statusRaw) ? statusRaw : "geplant";
await db.soaEntry.update({
where: { id: entryId },
data: {
applicable,
justification: str(formData.get("justification")),
source: str(formData.get("source")) || null,
implementationStatus,
ownerId: str(formData.get("ownerId")) || null,
policyCode: str(formData.get("policyCode")) || null,
evidenceId: str(formData.get("evidenceId")) || null,
},
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "soa_entry",
entityId: entryId,
after: { control: entry.control, applicable, implementationStatus },
});
revalidatePath("/soa");
}
+185
View File
@@ -0,0 +1,185 @@
"use server";
import { revalidatePath } from "next/cache";
import type { ObjectReviewStatus } from "@prisma/client";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { buildControlRows } from "@/server/soa-context";
import { domainForControl, assignTaskParticipants } from "@/server/control-domain";
import { syncTaskFromObject } from "@/server/task-sync";
/**
* Control-Assessment (Story A7) — Server-Aktionen für Schritt 7 „controls".
* Bearbeiter (`onboarding:use`): Reifegrad je Control bestätigen/überschreiben
* (Pflichtbegründung bei Absenkung unter den Vorschlag, C5 §4.6), alle offenen
* Vorschläge übernehmen und Gap-Aufgaben (C5 §4) anlegen. Der Reifegrad-Vorschlag
* selbst ist reine Logik (src/lib/maturity.ts) und wird hier nur persistiert.
*/
const guard = moduleGuard("onboarding");
type Db = Awaited<ReturnType<typeof guard>>["db"];
async function nudgeStep(db: Db, tenantId: string) {
const prog = await db.onboardingProgress.findUnique({ where: { tenantId_stepKey: { tenantId, stepKey: "controls" } } });
if (!prog || prog.status === "offen") {
const status: ObjectReviewStatus = "in_bearbeitung";
await db.onboardingProgress.upsert({
where: { tenantId_stepKey: { tenantId, stepKey: "controls" } },
update: { status },
create: { tenantId, stepKey: "controls", status },
});
}
}
/** Einen Control-Reifegrad bestätigen oder überschreiben (Pflichtbestätigung, C5 §5-Vier-Augen). */
export async function confirmControlMaturity(formData: FormData) {
const { session, db } = await guard("onboarding:use");
const control = String(formData.get("control") ?? "");
const value = Number(formData.get("value"));
const justification = String(formData.get("justification") ?? "").trim() || null;
if (!Number.isInteger(value) || value < 0 || value > 3) throw new Error("Ungültiger Reifegrad (0–3).");
const { rows } = await buildControlRows(db, session.user.tenantId);
const row = rows.find((r) => r.control === control);
if (!row) throw new Error("Control liegt nicht im aktuellen Scope.");
// Absenkung unter den Vorschlag erfordert eine Begründung (C5 §4.6).
if (value < row.suggestion.value && !justification) {
throw new Error("Absenkung unter den Vorschlag erfordert eine Begründung.");
}
await db.controlAssessment.upsert({
where: { tenantId_control: { tenantId: session.user.tenantId, control } },
update: { suggested: row.suggestion.value, confirmedValue: value, target: row.target, justification, confirmedById: session.user.id },
create: { tenantId: session.user.tenantId, control, suggested: row.suggestion.value, confirmedValue: value, target: row.target, justification, confirmedById: session.user.id },
});
await nudgeStep(db, session.user.tenantId);
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "control_assessment", entityId: control, after: { confirmedValue: value, suggested: row.suggestion.value, target: row.target } });
revalidatePath("/onboarding");
revalidatePath("/dashboard");
}
/** Alle noch nicht bestätigten Controls mit ihrem Reifegrad-Vorschlag bestätigen. */
export async function confirmAllSuggestions() {
const { session, db } = await guard("onboarding:use");
const { rows } = await buildControlRows(db, session.user.tenantId);
let count = 0;
for (const row of rows) {
if (row.confirmed !== null) continue;
await db.controlAssessment.upsert({
where: { tenantId_control: { tenantId: session.user.tenantId, control: row.control } },
update: { suggested: row.suggestion.value, confirmedValue: row.suggestion.value, target: row.target, confirmedById: session.user.id },
create: { tenantId: session.user.tenantId, control: row.control, suggested: row.suggestion.value, confirmedValue: row.suggestion.value, target: row.target, confirmedById: session.user.id },
});
count++;
}
await nudgeStep(db, session.user.tenantId);
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "control_assessment", after: { bulkConfirmed: count } });
revalidatePath("/onboarding");
revalidatePath("/dashboard");
}
/** Aus den offenen Punkten (C5 §4) idempotente Aufgaben je Control/Belegklasse anlegen. */
export async function createControlGapTasks() {
const { session, db } = await guard("onboarding:use");
const { rows } = await buildControlRows(db, session.user.tenantId);
const gaps = rows.flatMap((r) => r.gaps);
let created = 0;
for (const g of gaps) {
const origin = `wizard:control-gap:${g.control}:${g.kind}`;
const exists = await db.task.findFirst({ where: { origin, status: { in: ["PROPOSED", "OPEN"] } }, select: { id: true } });
if (exists) continue;
// Cockpit (M3, 1.4): Bereich aus Kapitel→Domain-Mapping ableiten.
const domain = await domainForControl(db, session.user.tenantId, g.control);
const task = await db.task.create({
data: {
tenantId: session.user.tenantId,
type: "organizational",
title: g.action,
origin,
status: "PROPOSED",
priority: g.kind === "widerspruch" ? "hoch" : "mittel",
createdById: session.user.id,
domain,
links: { control: g.control },
},
});
// Cockpit (M3, 2.3): RACI-Mitwirkende automatisch aus Control/Domain befüllen.
await assignTaskParticipants(db, session.user.tenantId, { taskId: task.id, control: g.control, domain });
created++;
}
await nudgeStep(db, session.user.tenantId);
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "control_gap_check", after: { gaps: gaps.length, created } });
revalidatePath("/onboarding");
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
const IMPL_STATUS = new Set(["offen", "in_umsetzung", "erledigt"]);
/**
* Umsetzungsstatus eines Umsetzungshinweises (Spiegelstrich) dokumentieren (#10):
* offen / in Umsetzung / erledigt (+ Notiz). „erledigt" aller relevanten Hinweise eines
* Controls zählt als operativer Nachweis und hebt den Reifegrad-Vorschlag.
*/
export async function setImplementationStatus(reqId: string, formData: FormData) {
const { session, db } = await guard("onboarding:use");
const hint = await db.implementationHint.findUnique({ where: { reqId }, select: { control: true } });
if (!hint) throw new Error("Umsetzungshinweis nicht gefunden.");
const statusRaw = String(formData.get("status") ?? "");
const status = IMPL_STATUS.has(statusRaw) ? statusRaw : "offen";
const note = String(formData.get("note") ?? "").trim() || null;
await db.controlImplementation.upsert({
where: { tenantId_reqId: { tenantId: session.user.tenantId, reqId } },
update: { status, note, control: hint.control, updatedById: session.user.id },
create: { tenantId: session.user.tenantId, reqId, control: hint.control, status, note, updatedById: session.user.id },
});
// Cockpit (M3, 2.2): Auto-Completion GEDECKELT — „erledigt" schließt die dem
// Control zugeordneten Aufgaben bis DONE (umgesetzt). Reifegrad-Anhebung bleibt
// ein separater, manuell zu bestätigender Schritt („Existenz ≠ Wirksamkeit").
if (status === "erledigt") {
await syncTaskFromObject(
{ db, tenantId: session.user.tenantId, actorId: session.user.id },
"control",
{ matchKey: hint.control, reached: true },
);
}
await nudgeStep(db, session.user.tenantId);
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "control_implementation", entityId: reqId, after: { status } });
revalidatePath("/onboarding");
revalidatePath("/dashboard");
}
/** Aus einem offenen Umsetzungshinweis eine Aufgabe erzeugen (idempotent je reqId). */
export async function createImplementationTask(reqId: string) {
const { session, db } = await guard("onboarding:use");
const hint = await db.implementationHint.findUnique({ where: { reqId } });
if (!hint) throw new Error("Umsetzungshinweis nicht gefunden.");
const origin = `hint:${reqId}`;
const exists = await db.task.findFirst({ where: { origin, status: { in: ["PROPOSED", "OPEN"] } }, select: { id: true } });
if (!exists) {
const domain = await domainForControl(db, session.user.tenantId, hint.control);
const task = await db.task.create({
data: {
tenantId: session.user.tenantId,
type: "technical",
title: `Umsetzung ${hint.control}: ${hint.requirement.slice(0, 90)}`,
description: `Organisatorisch: ${hint.organisational} | Technisch: ${hint.technical} | Nachweise: ${hint.evidence}`,
origin,
status: "PROPOSED",
priority: "mittel",
createdById: session.user.id,
domain,
links: { control: hint.control },
},
});
// Cockpit (M3, 2.3): RACI-Mitwirkende automatisch aus Control/Domain befüllen.
await assignTaskParticipants(db, session.user.tenantId, { taskId: task.id, control: hint.control, domain });
}
await nudgeStep(db, session.user.tenantId);
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "control_impl_task", entityId: reqId, after: { origin } });
revalidatePath("/onboarding");
revalidatePath("/tasks");
}
+96
View File
@@ -0,0 +1,96 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { prisma } from "@/server/db";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { withQuery } from "@/lib/supplier";
const guard = moduleGuard("suppliers");
const level = z.coerce.number().int().min(1).max(4);
const softwareSchema = z.object({
name: z.string().trim().min(1).max(200),
confidentiality: level,
integrity: level,
availability: level,
criticality: level,
providerAssetId: z.string().optional(),
version: z.string().trim().max(100).optional(),
approvalStatus: z.enum(["BEANTRAGT", "FREIGEGEBEN", "GESPERRT"]),
approvedBy: z.string().trim().max(200).optional(),
nextReview: z.string().optional(),
notes: z.string().trim().max(5000).optional(),
});
function parse(formData: FormData) {
const p = softwareSchema.parse({
name: formData.get("name"),
confidentiality: formData.get("confidentiality"),
integrity: formData.get("integrity"),
availability: formData.get("availability"),
criticality: formData.get("criticality"),
providerAssetId: formData.get("providerAssetId") || undefined,
version: formData.get("version") || undefined,
approvalStatus: formData.get("approvalStatus") || "BEANTRAGT",
approvedBy: formData.get("approvedBy") || undefined,
nextReview: formData.get("nextReview") || undefined,
notes: formData.get("notes") || undefined,
});
return {
asset: { name: p.name, confidentiality: p.confidentiality, integrity: p.integrity, availability: p.availability },
profile: {
criticality: p.criticality,
providerAssetId: p.providerAssetId || null,
version: p.version || null,
approvalStatus: p.approvalStatus,
approvedBy: p.approvedBy || null,
nextReview: p.nextReview ? new Date(p.nextReview) : null,
notes: p.notes || null,
},
};
}
export async function createSoftware(formData: FormData) {
const { session, db } = await guard("supplier:write");
const { asset, profile } = parse(formData);
const created = await db.asset.create({
data: { ...asset, type: "SOFTWARE", tenantId: session.user.tenantId, createdBy: session.user.id },
});
const last = await prisma.softwareProfile.aggregate({ where: { tenantId: session.user.tenantId }, _max: { refNo: true } });
await db.softwareProfile.create({
data: { ...profile, assetId: created.id, refNo: (last._max.refNo ?? 0) + 1, tenantId: session.user.tenantId, createdBy: session.user.id },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "software", entityId: created.id, after: { ...asset, ...profile } });
revalidatePath("/suppliers");
revalidatePath("/assets");
redirect(`/suppliers?tab=software&edit=${created.id}`);
}
export async function updateSoftware(assetId: string, formData: FormData) {
const { session, db } = await guard("supplier:write");
const before = await db.asset.findUnique({ where: { id: assetId }, include: { softwareProfile: true } });
if (!before) throw new Error("Software nicht gefunden");
const { asset, profile } = parse(formData);
await db.asset.update({ where: { id: assetId }, data: asset });
await db.softwareProfile.update({ where: { assetId }, data: profile });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "software", entityId: assetId, before, after: { ...asset, ...profile } });
revalidatePath("/suppliers");
revalidatePath("/assets");
const returnTo = (formData.get("returnTo") as string) || "/suppliers?tab=software";
redirect(withQuery(returnTo, "detail", assetId));
}
export async function deleteSoftware(assetId: string, returnTo: string = "/suppliers?tab=software") {
const { session, db } = await guard("supplier:write");
const before = await db.asset.findUnique({ where: { id: assetId } });
if (!before) throw new Error("Software nicht gefunden");
await db.asset.delete({ where: { id: assetId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "software", entityId: assetId, before });
revalidatePath("/suppliers");
revalidatePath("/assets");
redirect(returnTo);
}
+446
View File
@@ -0,0 +1,446 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { Prisma } from "@prisma/client";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { normalizeAssetName } from "@/lib/normalize-asset";
/**
* M2 Strukturanalyse (Ebene 2) — prozessgeführte Erhebung.
*
* Kernprinzip (nicht verletzen): Ein Informationswert ist KEIN neues Objekt, sondern
* ein `Asset` mit `type = INFORMATION|DATA` (primärer Wert). Diese Actions erfassen
* primäre Informations-Assets über den Prozess — mit Dedup + Autovervollständigung
* (§2.1) — und übernehmen Prozesse aus dem globalen `ProcessCatalogEntry`.
*
* Träger-Assets (SECONDARY) laufen weiter über `assignAsset` in
* `src/server/actions/processes.ts`; Schutzbedarf C/I/A bleibt am Asset.
*/
const guard = moduleGuard("assets");
const INFO_TYPES = ["INFORMATION", "DATA"] as const;
const INFO_LABELS = ["NONE", "INFO_HIGH", "INFO_VERY_HIGH", "PROTOTYPE", "PERSONAL_DATA"] as const;
export interface AssetSuggestion {
id: string;
name: string;
type: (typeof INFO_TYPES)[number];
label: string;
normalizedName: string | null;
/** Prozessnamen, in denen dieses Asset bereits erfasst ist ("bereits erfasst in Prozess X"). */
processes: string[];
}
/**
* As-you-type-Suche für die Combobox (§2.1). Liefert bestehende primäre
* Informations-Assets (type INFORMATION/DATA) des Mandanten, deren normalisierter
* Name den (ebenfalls normalisierten) Query enthält, inkl. der Prozesse, in denen
* sie bereits verknüpft sind. Der Client rechnet darauf die weiche Fuzzy-Warnung
* (Levenshtein) — die harte Exakt-Dedup erzwingt die DB (@@unique).
*/
export async function searchAssets(query: string): Promise<AssetSuggestion[]> {
const { db } = await guard("asset:read");
const norm = normalizeAssetName(query ?? "");
if (norm.length < 2) return [];
const rows = await db.asset.findMany({
where: {
type: { in: [...INFO_TYPES] },
OR: [
{ normalizedName: { contains: norm } },
// Fallback für Altbestand ohne gesetzten normalizedName.
{ name: { contains: query.trim(), mode: "insensitive" } },
],
},
select: {
id: true,
name: true,
type: true,
label: true,
normalizedName: true,
processAssets: { select: { process: { select: { name: true } } } },
},
orderBy: { name: "asc" },
take: 12,
});
return rows.map((r) => ({
id: r.id,
name: r.name,
type: r.type as (typeof INFO_TYPES)[number],
label: r.label,
normalizedName: r.normalizedName,
processes: r.processAssets.map((pa) => pa.process.name),
}));
}
const newInfoSchema = z.object({
name: z.string().trim().min(1).max(200),
type: z.enum(INFO_TYPES).default("INFORMATION"),
label: z.enum(INFO_LABELS).default("NONE"),
});
/**
* Verknüpft ein bestehendes Asset als PRIMÄREN Informationswert mit dem Prozess
* (ProcessAsset role=PRIMARY) — der „ein Klick verknüpft"-Pfad der Autocomplete.
*/
export async function linkPrimaryInformation(processId: string, formData: FormData) {
const { session, db } = await guard("asset:write");
const assetId = z.string().min(1).parse(formData.get("assetId"));
const [assetCount, processCount] = await Promise.all([
db.asset.count({ where: { id: assetId, type: { in: [...INFO_TYPES] } } }),
db.process.count({ where: { id: processId } }),
]);
if (assetCount !== 1 || processCount !== 1) throw new Error("Nicht gefunden");
await db.processAsset.upsert({
where: { processId_assetId: { processId, assetId } },
update: { role: "PRIMARY" },
create: { processId, assetId, role: "PRIMARY", tenantId: session.user.tenantId },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "process_asset",
entityId: processId,
after: { processId, assetId, role: "PRIMARY", via: "m2-link-primary" },
});
revalidatePath("/onboarding");
revalidatePath("/processes");
}
/**
* Erfasst einen NEUEN primären Informationswert am Prozess — MIT Exakt-Dedup:
* Der normalisierte Name (§2.1) ist über `@@unique([tenantId, normalizedName])`
* eindeutig. Existiert bereits ein Asset mit gleichem Schlüssel, wird es VERKNÜPFT
* statt neu angelegt (auch bei Groß-/Kleinschreibung, Umlaut-Faltung, Satzzeichen).
* Der Unique-Constraint fängt zusätzlich Wettläufe (P2002) ab.
*/
export async function addPrimaryInformation(processId: string, formData: FormData) {
const { session, db } = await guard("asset:write");
const tenantId = session.user.tenantId;
const data = newInfoSchema.parse({
name: formData.get("name"),
type: formData.get("type") || undefined,
label: formData.get("label") || undefined,
});
const normalizedName = normalizeAssetName(data.name);
if (!normalizedName) throw new Error("Name ergibt keinen gültigen Wert.");
if ((await db.process.count({ where: { id: processId } })) !== 1) {
throw new Error("Prozess nicht gefunden");
}
// 1) Exakt-Dedup: bestehendes (primäres) Asset mit gleichem Schlüssel verknüpfen.
const existing = await db.asset.findFirst({
where: { normalizedName, type: { in: [...INFO_TYPES] } },
select: { id: true },
});
let assetId = existing?.id;
let created = false;
if (!assetId) {
try {
const asset = await db.asset.create({
data: {
tenantId,
name: data.name,
type: data.type,
label: data.label,
normalizedName,
createdBy: session.user.id,
},
select: { id: true },
});
assetId = asset.id;
created = true;
} catch (e) {
// Wettlauf: paralleler Insert hat den Schlüssel belegt → bestehendes verknüpfen.
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
const race = await db.asset.findFirst({
where: { normalizedName, type: { in: [...INFO_TYPES] } },
select: { id: true },
});
if (!race) throw e;
assetId = race.id;
} else {
throw e;
}
}
}
await db.processAsset.upsert({
where: { processId_assetId: { processId, assetId: assetId! } },
update: { role: "PRIMARY" },
create: { processId, assetId: assetId!, role: "PRIMARY", tenantId },
});
await writeAuditLog({
tenantId,
actorId: session.user.id,
action: created ? "create" : "update",
entity: created ? "asset" : "process_asset",
entityId: assetId!,
after: { name: data.name, type: data.type, label: data.label, normalizedName, processId, deduped: !created },
});
revalidatePath("/onboarding");
revalidatePath("/processes");
revalidatePath("/assets");
}
const level = z.coerce.number().int().min(1).max(4);
// Träger-/Sekundär-Assets sind alle Asset-Typen AUSSER den primären Informationswerten
// (INFORMATION/DATA). Default SYSTEM (häufigster Träger).
const CARRIER_TYPES = ["SYSTEM", "APPLICATION", "LOCATION", "SUPPLIER", "IT_SERVICE", "SOFTWARE", "PROJECT", "PERSON"] as const;
/**
* BIA-Popup Schritt 2 (Träger neu anlegen): erfasst ein NEUES Träger-Asset über die
* volle Asset-Inventar-Maske (AssetForm) und verknüpft es als SECONDARY mit dem Prozess.
* Analog zu `createPrimaryInformationAsset`, aber für Träger-Typen (kein INFORMATION/DATA).
* Exakt-Dedup über den normalisierten Namen: gleicher Schlüssel → verknüpfen statt doppeln.
*/
export async function createSecondaryCarrierAsset(processId: string, formData: FormData) {
const { session, db } = await guard("asset:write");
const tenantId = session.user.tenantId;
const schema = z.object({
name: z.string().trim().min(1).max(200),
description: z.string().trim().max(5000).optional(),
type: z.enum(CARRIER_TYPES).default("SYSTEM"),
status: z.enum(["ACTIVE", "PLANNED", "RETIRED"]).default("ACTIVE"),
ownerId: z.string().optional(),
location: z.string().trim().max(200).optional(),
tags: z.string().optional(),
confidentiality: level,
integrity: level,
availability: level,
});
const data = schema.parse({
name: formData.get("name"),
description: formData.get("description") || undefined,
type: formData.get("type") || undefined,
status: formData.get("status") || undefined,
ownerId: formData.get("ownerId") || undefined,
location: formData.get("location") || undefined,
tags: formData.get("tags") || undefined,
confidentiality: formData.get("confidentiality"),
integrity: formData.get("integrity"),
availability: formData.get("availability"),
});
const normalizedName = normalizeAssetName(data.name);
if (!normalizedName) throw new Error("Name ergibt keinen gültigen Wert.");
if ((await db.process.count({ where: { id: processId } })) !== 1) {
throw new Error("Prozess nicht gefunden");
}
// Exakt-Dedup über den Mandanten-Schlüssel (@@unique tenantId,normalizedName): ein
// bestehendes Asset gleichen Namens (beliebigen Typs) wird als Träger verknüpft.
const existing = await db.asset.findFirst({
where: { normalizedName },
select: { id: true },
});
let assetId = existing?.id;
let created = false;
if (!assetId) {
try {
const asset = await db.asset.create({
data: {
tenantId,
name: data.name,
description: data.description || null,
type: data.type,
status: data.status,
ownerId: data.ownerId || null,
location: data.location || null,
tags: data.tags ? data.tags.split(",").map((t) => t.trim()).filter(Boolean) : [],
confidentiality: data.confidentiality,
integrity: data.integrity,
availability: data.availability,
normalizedName,
createdBy: session.user.id,
},
select: { id: true },
});
assetId = asset.id;
created = true;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
const race = await db.asset.findFirst({ where: { normalizedName }, select: { id: true } });
if (!race) throw e;
assetId = race.id;
} else {
throw e;
}
}
}
await db.processAsset.upsert({
where: { processId_assetId: { processId, assetId: assetId! } },
update: { role: "SECONDARY" },
create: { processId, assetId: assetId!, role: "SECONDARY", tenantId },
});
await writeAuditLog({
tenantId,
actorId: session.user.id,
action: created ? "create" : "update",
entity: created ? "asset" : "process_asset",
entityId: assetId!,
after: { name: data.name, type: data.type, carrierFor: processId, deduped: !created },
});
revalidatePath("/onboarding");
revalidatePath("/processes");
revalidatePath("/assets");
}
/**
* BIA-Popup Schritt 1 (manueller Pfad): erfasst einen primären Informationswert über
* die VOLLE Asset-Inventar-Maske (AssetForm) und verknüpft ihn als PRIMARY mit dem
* Prozess. Der Werttyp ist auf INFORMATION/DATA beschränkt (primärer Wert). Exakt-Dedup
* wie bei `addPrimaryInformation`: gleicher normalisierter Name → verknüpfen statt doppeln.
*/
export async function createPrimaryInformationAsset(processId: string, formData: FormData) {
const { session, db } = await guard("asset:write");
const tenantId = session.user.tenantId;
const schema = z.object({
name: z.string().trim().min(1).max(200),
description: z.string().trim().max(5000).optional(),
type: z.enum(INFO_TYPES).default("INFORMATION"),
label: z.enum(INFO_LABELS).default("NONE"),
status: z.enum(["ACTIVE", "PLANNED", "RETIRED"]).default("ACTIVE"),
ownerId: z.string().optional(),
location: z.string().trim().max(200).optional(),
tags: z.string().optional(),
confidentiality: level,
integrity: level,
availability: level,
});
const data = schema.parse({
name: formData.get("name"),
description: formData.get("description") || undefined,
type: formData.get("type") || undefined,
label: formData.get("label") || undefined,
status: formData.get("status") || undefined,
ownerId: formData.get("ownerId") || undefined,
location: formData.get("location") || undefined,
tags: formData.get("tags") || undefined,
confidentiality: formData.get("confidentiality"),
integrity: formData.get("integrity"),
availability: formData.get("availability"),
});
const normalizedName = normalizeAssetName(data.name);
if (!normalizedName) throw new Error("Name ergibt keinen gültigen Wert.");
if ((await db.process.count({ where: { id: processId } })) !== 1) {
throw new Error("Prozess nicht gefunden");
}
const existing = await db.asset.findFirst({
where: { normalizedName, type: { in: [...INFO_TYPES] } },
select: { id: true },
});
let assetId = existing?.id;
let created = false;
if (!assetId) {
try {
const asset = await db.asset.create({
data: {
tenantId,
name: data.name,
description: data.description || null,
type: data.type,
status: data.status,
label: data.label,
ownerId: data.ownerId || null,
location: data.location || null,
tags: data.tags ? data.tags.split(",").map((t) => t.trim()).filter(Boolean) : [],
confidentiality: data.confidentiality,
integrity: data.integrity,
availability: data.availability,
normalizedName,
createdBy: session.user.id,
},
select: { id: true },
});
assetId = asset.id;
created = true;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
const race = await db.asset.findFirst({
where: { normalizedName, type: { in: [...INFO_TYPES] } },
select: { id: true },
});
if (!race) throw e;
assetId = race.id;
} else {
throw e;
}
}
}
await db.processAsset.upsert({
where: { processId_assetId: { processId, assetId: assetId! } },
update: { role: "PRIMARY" },
create: { processId, assetId: assetId!, role: "PRIMARY", tenantId },
});
await writeAuditLog({
tenantId,
actorId: session.user.id,
action: created ? "create" : "update",
entity: created ? "asset" : "process_asset",
entityId: assetId!,
after: { name: data.name, type: data.type, primaryFor: processId, deduped: !created },
});
revalidatePath("/onboarding");
revalidatePath("/processes");
revalidatePath("/assets");
}
/**
* BIA-Popup Schritt 3: Schutzbedarf C/I/A direkt am (primären) Informations-Asset
* setzen. Träger erben per Maximum-Prinzip (Anzeige/Analyse), der echte Wert liegt am
* primären Wert. Kein Redirect — der Nutzer bleibt im Popup-Schritt.
*/
export async function saveInfoProtection(assetId: string, formData: FormData) {
const { session, db } = await guard("asset:write");
const before = await db.asset.findFirst({
where: { id: assetId },
select: { id: true, confidentiality: true, integrity: true, availability: true },
});
if (!before) throw new Error("Asset nicht gefunden");
const data = {
confidentiality: level.parse(formData.get("confidentiality")),
integrity: level.parse(formData.get("integrity")),
availability: level.parse(formData.get("availability")),
};
await db.asset.update({ where: { id: assetId }, data });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "asset",
entityId: assetId,
before,
after: data,
});
revalidatePath("/onboarding");
revalidatePath("/processes");
revalidatePath("/assets");
}
+299
View File
@@ -0,0 +1,299 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { prisma, type TenantDb } from "@/server/db";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { withQuery } from "@/lib/supplier";
const guard = moduleGuard("suppliers");
const level = z.coerce.number().int().min(1).max(4);
const supplierSchema = z.object({
name: z.string().trim().min(1).max(200),
sector: z.string().trim().max(200).optional(),
serviceDesc: z.string().trim().max(2000).optional(),
criticality: level,
dataCategories: z.string().optional(),
confidentiality: level,
integrity: level,
availability: level,
nis2Relevant: z.string().optional(),
lifecycle: z.enum(["ACTIVE", "ONBOARDING", "UNDER_REVIEW", "OFFBOARDED"]),
contact: z.string().trim().max(200).optional(),
nextReview: z.string().optional(),
notes: z.string().trim().max(5000).optional(),
});
function parse(formData: FormData) {
const p = supplierSchema.parse({
name: formData.get("name"),
sector: formData.get("sector") || undefined,
serviceDesc: formData.get("serviceDesc") || undefined,
criticality: formData.get("criticality"),
dataCategories: formData.get("dataCategories") || undefined,
confidentiality: formData.get("confidentiality"),
integrity: formData.get("integrity"),
availability: formData.get("availability"),
nis2Relevant: formData.get("nis2Relevant") || undefined,
lifecycle: formData.get("lifecycle"),
contact: formData.get("contact") || undefined,
nextReview: formData.get("nextReview") || undefined,
notes: formData.get("notes") || undefined,
});
return {
asset: {
name: p.name,
confidentiality: p.confidentiality,
integrity: p.integrity,
availability: p.availability,
},
profile: {
sector: p.sector || null,
serviceDesc: p.serviceDesc || null,
criticality: p.criticality,
dataCategories: p.dataCategories
? p.dataCategories.split(",").map((s) => s.trim()).filter(Boolean)
: [],
nis2Relevant: p.nis2Relevant === "on",
lifecycle: p.lifecycle,
contact: p.contact || null,
nextReview: p.nextReview ? new Date(p.nextReview) : null,
notes: p.notes || null,
},
};
}
export async function createSupplier(formData: FormData) {
const { session, db } = await guard("supplier:write");
const { asset, profile } = parse(formData);
const created = await db.asset.create({
data: {
...asset,
type: "SUPPLIER",
tenantId: session.user.tenantId,
createdBy: session.user.id,
},
});
const last = await prisma.supplierProfile.aggregate({
where: { tenantId: session.user.tenantId },
_max: { refNo: true },
});
await db.supplierProfile.create({
data: {
...profile,
assetId: created.id,
refNo: (last._max.refNo ?? 0) + 1,
tenantId: session.user.tenantId,
createdBy: session.user.id,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "supplier", entityId: created.id, after: { ...asset, ...profile } });
revalidatePath("/suppliers");
revalidatePath("/assets");
redirect(`/suppliers?edit=${created.id}`);
}
export async function updateSupplier(assetId: string, formData: FormData) {
const { session, db } = await guard("supplier:write");
const before = await db.asset.findUnique({ where: { id: assetId }, include: { supplierProfile: true } });
if (!before) throw new Error("Lieferant nicht gefunden");
const { asset, profile } = parse(formData);
await db.asset.update({ where: { id: assetId }, data: asset });
await db.supplierProfile.update({ where: { assetId }, data: profile });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "supplier", entityId: assetId, before, after: { ...asset, ...profile } });
revalidatePath("/suppliers");
revalidatePath("/assets");
const returnTo = (formData.get("returnTo") as string) || "/suppliers";
redirect(withQuery(returnTo, "detail", assetId));
}
export async function deleteSupplier(assetId: string, returnTo: string = "/suppliers") {
const { session, db } = await guard("supplier:write");
const before = await db.asset.findUnique({ where: { id: assetId } });
if (!before) throw new Error("Lieferant nicht gefunden");
await db.asset.delete({ where: { id: assetId } }); // Profil + Kinder kaskadieren
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "supplier", entityId: assetId, before });
revalidatePath("/suppliers");
revalidatePath("/assets");
redirect(returnTo);
}
/** Stellt sicher, dass das Asset zum Mandanten gehört (Guard hat Modul/Recht bereits geprüft). */
async function ensure(db: TenantDb, assetId: string) {
if ((await db.asset.count({ where: { id: assetId } })) !== 1) throw new Error("Nicht gefunden");
return db;
}
const optDate = (v: FormDataEntryValue | null) => (v && String(v) ? new Date(String(v)) : null);
export async function addContract(assetId: string, formData: FormData) {
const { session, db } = await guard("supplier:write");
await ensure(db, assetId);
const bool = (n: string) => formData.get(n) === "on";
await db.contract.create({
data: {
assetId,
tenantId: session.user.tenantId,
type: (formData.get("type") as string) || "service",
avDpa: bool("avDpa"),
securityClauses: bool("securityClauses"),
flowdown: bool("flowdown"),
customerRequirementsPassed: bool("customerRequirementsPassed"),
validFrom: optDate(formData.get("validFrom")),
validTo: optDate(formData.get("validTo")),
reference: (formData.get("reference") as string)?.trim() || null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "contract", entityId: assetId });
revalidatePath("/suppliers");
revalidatePath("/assets");
}
export async function addNda(assetId: string, formData: FormData) {
const { session, db } = await guard("supplier:write");
await ensure(db, assetId);
await db.nda.create({
data: {
assetId,
tenantId: session.user.tenantId,
parties: (formData.get("parties") as string)?.trim() || null,
infoScope: (formData.get("infoScope") as string)?.trim() || null,
subject: (formData.get("subject") as string)?.trim() || null,
validFrom: optDate(formData.get("validFrom")),
validTo: optDate(formData.get("validTo")),
extensionStatus: (formData.get("extensionStatus") as string)?.trim() || null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "nda", entityId: assetId });
revalidatePath("/suppliers");
revalidatePath("/assets");
}
export async function addEvidence(assetId: string, formData: FormData) {
const { session, db } = await guard("supplier:write");
await ensure(db, assetId);
await db.supplierEvidence.create({
data: {
assetId,
tenantId: session.user.tenantId,
kind: z.enum(["CERTIFICATE", "TISAX_LABEL", "ATTESTATION", "AUDIT_REPORT", "SELF_ASSESSMENT"]).parse(formData.get("kind")),
name: (formData.get("name") as string)?.trim() || null,
protectsCia: (formData.get("protectsCia") as string)?.trim() || null,
validTo: optDate(formData.get("validTo")),
adequacyChecked: formData.get("adequacyChecked") === "on",
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "supplier_evidence", entityId: assetId });
revalidatePath("/suppliers");
revalidatePath("/assets");
}
export async function addAssessment(assetId: string, formData: FormData) {
const { session, db } = await guard("supplier:write");
await ensure(db, assetId);
await db.supplierAssessment.create({
data: {
assetId,
tenantId: session.user.tenantId,
type: z.enum(["QUESTIONNAIRE", "SELF_ASSESSMENT", "AUDIT"]).parse(formData.get("type")),
score: formData.get("score") ? Number(formData.get("score")) : null,
date: optDate(formData.get("date")),
nextReview: optDate(formData.get("nextReview")),
result: (formData.get("result") as string)?.trim() || null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "supplier_assessment", entityId: assetId });
revalidatePath("/suppliers");
revalidatePath("/assets");
}
export async function addDecision(assetId: string, formData: FormData) {
const { session, db } = await guard("supplier:write");
await ensure(db, assetId);
await db.managementDecision.create({
data: {
assetId,
tenantId: session.user.tenantId,
reasonNoAudit: z.string().trim().min(1).parse(formData.get("reasonNoAudit")),
decision: z.string().trim().min(1).parse(formData.get("decision")),
decidedBy: (formData.get("decidedBy") as string)?.trim() || session.user.name || null,
recordRef: (formData.get("recordRef") as string)?.trim() || null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "management_decision", entityId: assetId });
revalidatePath("/suppliers");
revalidatePath("/assets");
}
/** Gate-Kompensation: echtes Risiko im Risikomodul anlegen und mit dem Lieferanten verknüpfen. */
export async function createGateRisk(assetId: string) {
const { session, db } = await guard("supplier:write", "risk:write");
const asset = await db.asset.findUnique({ where: { id: assetId } });
if (!asset) throw new Error("Lieferant nicht gefunden");
const last = await prisma.risk.aggregate({ where: { tenantId: session.user.tenantId }, _max: { refNo: true } });
const risk = await db.risk.create({
data: {
tenantId: session.user.tenantId,
refNo: (last._max.refNo ?? 0) + 1,
title: `Third-Party-Risiko: ${asset.name}`,
description: "Sehr hoher Schutzbedarf ohne gültiges Third-Party-Audit/TISAX-Label — Kompensationsrisiko (VDA-ISA 6.1.1).",
threat: "Ausfall/Kompromittierung eines kritischen Lieferanten",
vulnerability: "Fehlender unabhängiger Sicherheitsnachweis",
likelihood: 3,
impact: 4,
score: 12,
treatment: "MITIGATE",
status: "IN_TREATMENT",
createdBy: session.user.id,
},
});
await db.riskAsset.create({ data: { tenantId: session.user.tenantId, riskId: risk.id, assetId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "risk", entityId: risk.id, after: { gateRiskFor: assetId } });
revalidatePath("/suppliers");
revalidatePath("/risks");
redirect(`/risks?detail=${risk.id}`);
}
/** ISB-Freigabe des Reifegrads (Abweichung nur mit Begründung → Audit-Log). */
export async function approveMaturity(assetId: string, computed: number, formData: FormData) {
const { session, db } = await guard("supplier:write");
await ensure(db, assetId);
const isbValue = z.coerce.number().min(0).max(3).parse(formData.get("isbValue"));
const justification = (formData.get("justification") as string)?.trim() || null;
if (Math.abs(isbValue - computed) > 0.01 && !justification) {
throw new Error("Abweichung vom berechneten Wert erfordert eine Begründung.");
}
await db.maturityAssessment.upsert({
where: { assetId },
update: { computedValue: computed, isbValue, isbJustification: justification, approvedBy: session.user.name, approvedAt: new Date() },
create: { assetId, tenantId: session.user.tenantId, computedValue: computed, isbValue, isbJustification: justification, approvedBy: session.user.name, approvedAt: new Date() },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "maturity_assessment", entityId: assetId, after: { computed, isbValue, justification } });
revalidatePath("/suppliers");
revalidatePath("/assets");
}
export async function deleteChild(
kind: "contract" | "nda" | "evidence" | "assessment" | "decision" | "subcontractor",
id: string
) {
const { session, db } = await guard("supplier:write");
const map = {
contract: db.contract,
nda: db.nda,
evidence: db.supplierEvidence,
assessment: db.supplierAssessment,
decision: db.managementDecision,
subcontractor: db.subcontractor,
} as const;
// @ts-expect-error dynamischer Delegate
await map[kind].delete({ where: { id } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: `supplier_${kind}`, entityId: id });
revalidatePath("/suppliers");
revalidatePath("/assets");
}
+643
View File
@@ -0,0 +1,643 @@
"use server";
import { Prisma, type Task } from "@prisma/client";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { moduleGuard } from "@/server/action-guard";
import type { TenantDb } from "@/server/db";
import { requirePermission } from "@/server/rbac";
import { writeAuditLog } from "@/server/audit";
import { pathForEntity } from "@/server/object-review";
import { notifyTaskEvent } from "@/server/mail/notifications";
import { TASK_PRIORITIES, TASK_TYPES, EVIDENCE_KINDS, type CreateTaskInput, isTaskPriority, isTaskType } from "@/lib/tasks";
import { originForTrigger, triggerById } from "@/lib/task-triggers";
import { domainForControl, assignTaskParticipants } from "@/server/control-domain";
import { spawnRecurrence, syncTaskFromObject } from "@/server/task-sync";
/**
* Aufgaben-Modul (generisch). Erster Typ: policy_approval — Freigabe/Ablehnung
* einer Richtlinie durch den zugewiesenen Freigeber. Vier-Augen: der Freigeber
* ist eine andere Person als der Einreicher. Kommentare werden historisiert.
*/
const guard = moduleGuard("tasks");
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
/**
* F-10: Schema-Validierung für `createTask`. Bislang wurde ein beliebiges JS-Objekt
* mit freien JSON-Feldern (`resources`, `links`) ohne Schema und ohne Größenlimit
* entgegengenommen. Zod erzwingt jetzt Typ, Längen- und Feldbegrenzung, damit keine
* beliebig großen JSON-Strukturen in die DB geschrieben werden können.
*/
const shortText = z.string().trim().max(200);
const longText = z.string().trim().max(5000);
const resourceValue = z.string().trim().max(500);
const createTaskSchema = z.object({
type: z.enum(TASK_TYPES),
title: shortText.min(1, "Aufgabe braucht einen Titel."),
description: longText.nullish(),
origin: shortText.min(1, "Aufgabe braucht eine Herkunft (origin)."),
owner: z.string().trim().max(64).nullish(),
dueDate: z.union([z.string().trim().max(64), z.date()]).nullish(),
priority: z.enum(TASK_PRIORITIES).optional(),
resources: z
.object({
tool: resourceValue.optional(),
budget: resourceValue.optional(),
personnel: resourceValue.optional(),
time: resourceValue.optional(),
})
.strict()
.optional(),
links: z
.object({
control: shortText.optional(),
risk: shortText.optional(),
document: shortText.optional(),
asset: shortText.optional(),
})
.strict()
.optional(),
});
/**
* F-10: `assigneeId`/`owner` muss auf einen AKTIVEN Nutzer DESSELBEN Mandanten zeigen.
* `db` ist bereits mandantengebunden (`dbForTenant`), daher genügt id+status. Ein
* leerer/`null`-Wert ist zulässig (keine Zuweisung). Gibt die geprüfte owner-Id oder
* `null` zurück.
*/
async function assertValidOwner(db: TenantDb, owner: string | null | undefined): Promise<string | null> {
const id = (owner ?? "").trim();
if (!id) return null;
const user = await db.user.findFirst({ where: { id, status: "ACTIVE" }, select: { id: true } });
if (!user) throw new Error("Zugewiesene Person ist kein aktiver Nutzer dieses Mandanten.");
return user.id;
}
/** Erforderliches Recht je Review-Typ: generische Objekt-Validierung vs. Richtlinien-Freigabe (A3-1). */
function reviewPermission(type: string): "validate_objects" | "policy:approve" {
return type === "validation" ? "validate_objects" : "policy:approve";
}
/** Review-Aufgabe bestätigen (nur zugewiesener Validator/Freigeber, ≠ Einreicher). */
export async function approveTask(taskId: string, formData: FormData) {
const { session, db } = await guard();
const task = await db.task.findUnique({ where: { id: taskId } });
if (!task || task.status !== "OPEN") throw new Error("Aufgabe ist nicht (mehr) offen.");
if (task.assigneeId !== session.user.id) throw new Error("Nur der zugewiesene Validator kann diese Aufgabe bearbeiten.");
if (task.createdById === session.user.id) throw new Error("Vier-Augen-Prinzip: nicht selbst bestätigen.");
requirePermission(session, reviewPermission(task.type));
// Richtlinien-Freigabe hat einen Modell-Seiteneffekt; generische Objekt-Reviews
// (validation) leiten ihren Status aus der Aufgabe ab (keine Statusspalte).
let approvedPolicyCode: string | null = null;
if (task.type === "policy_approval" && task.entityId) {
const doc = await db.policyDocument.update({ where: { id: task.entityId }, data: { status: "FREIGEGEBEN", approvedBy: session.user.id, approvedAt: new Date() }, select: { code: true } });
approvedPolicyCode = doc.code;
}
const note = str(formData.get("note"));
await db.task.update({
where: { id: taskId },
data: {
status: "DONE", resolvedById: session.user.id, resolvedAt: new Date(),
comments: { create: { tenantId: session.user.tenantId, authorId: session.user.id, kind: "approve", body: note || "Bestätigt." } },
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "task", entityId: taskId, after: { status: "DONE", approved: true } });
// Cockpit (M3, 2.2): Wiedervorlage-Serie fortschreiben + Auto-Completion (gedeckelt)
// für weitere Aufgaben, die auf dasselbe (jetzt freigegebene) Dokument verweisen.
await spawnRecurrence({ db, tenantId: session.user.tenantId, actorId: session.user.id }, task);
if (task.type === "policy_approval" && task.entityId) {
await syncTaskFromObject(
{ db, tenantId: session.user.tenantId, actorId: session.user.id },
"policy_document",
{ entityId: task.entityId, matchKey: approvedPolicyCode ?? undefined, reached: true },
);
}
// SEC1: der Einreicher erfährt die Entscheidung per Mail.
await notifyTaskEvent({
tenantId: session.user.tenantId,
event: "task_decided",
taskId,
taskTitle: task.title,
taskType: task.type,
recipientId: task.createdById,
actorId: session.user.id,
detail: "freigegeben",
});
revalidatePath("/tasks");
revalidatePath("/dashboard");
const p = pathForEntity(task.entityType);
if (p) revalidatePath(p, "layout");
}
/** Review-Aufgabe zurückweisen (mit Grund). Bei Richtlinien zurück auf ENTWURF. */
export async function rejectTask(taskId: string, formData: FormData) {
const { session, db } = await guard();
const task = await db.task.findUnique({ where: { id: taskId } });
if (!task || task.status !== "OPEN") throw new Error("Aufgabe ist nicht (mehr) offen.");
if (task.assigneeId !== session.user.id) throw new Error("Nur der zugewiesene Validator kann diese Aufgabe bearbeiten.");
requirePermission(session, reviewPermission(task.type));
const reason = str(formData.get("note"));
if (!reason) throw new Error("Bitte einen Ablehnungs-/Rückweisungsgrund angeben.");
if (task.type === "policy_approval" && task.entityId) {
await db.policyDocument.update({ where: { id: task.entityId }, data: { status: "ENTWURF", submittedBy: null } });
}
await db.task.update({
where: { id: taskId },
data: {
status: "REJECTED", resolvedById: session.user.id, resolvedAt: new Date(),
comments: { create: { tenantId: session.user.tenantId, authorId: session.user.id, kind: "reject", body: reason } },
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "task", entityId: taskId, after: { status: "REJECTED", reason } });
// SEC1: der Einreicher erfährt die Entscheidung. Der Begründungstext bleibt
// bewusst in der Aufgabe — die Mail verlinkt nur darauf.
await notifyTaskEvent({
tenantId: session.user.tenantId,
event: "task_decided",
taskId,
taskTitle: task.title,
taskType: task.type,
recipientId: task.createdById,
actorId: session.user.id,
detail: "zurückgewiesen",
});
revalidatePath("/tasks");
revalidatePath("/dashboard");
const p = pathForEntity(task.entityType);
if (p) revalidatePath(p, "layout");
}
/** Kommentar zu einer Aufgabe (nur Beteiligte: Freigeber oder Einreicher). */
export async function commentTask(taskId: string, formData: FormData) {
const { session, db } = await guard();
const task = await db.task.findUnique({ where: { id: taskId } });
if (!task) throw new Error("Aufgabe nicht gefunden.");
if (task.assigneeId !== session.user.id && task.createdById !== session.user.id) {
throw new Error("Nur Beteiligte können kommentieren.");
}
const body = str(formData.get("body"));
if (!body) return;
await db.taskComment.create({ data: { tenantId: session.user.tenantId, taskId, authorId: session.user.id, kind: "comment", body } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "task_comment", entityId: taskId });
revalidatePath("/tasks");
}
/**
* Generische Aufgabe erzeugen (Contracts §1). Einheitlicher Einstieg für Dev A's
* Wizard-Gates/Trigger und spätere Auto-Generierung (B1). `owner` → `assigneeId`;
* `status` wird serverseitig gesetzt (Default "OPEN"). Der bestehende
* policy_approval-Flow (submitForApproval) bleibt unberührt.
*/
export async function createTask(input: CreateTaskInput): Promise<Task> {
const { session, db } = await guard("task:write");
// F-10: Eingabe strikt validieren (Typ, Längen, JSON-Felder) statt beliebiges Objekt.
const parsed = createTaskSchema.safeParse(input);
if (!parsed.success) {
throw new Error(parsed.error.issues[0]?.message ?? "Ungültige Aufgabendaten.");
}
const data = parsed.data;
const priority = data.priority ?? "mittel";
const dueDate = data.dueDate ? new Date(data.dueDate) : null;
if (dueDate && Number.isNaN(dueDate.getTime())) throw new Error("Ungültiges Fälligkeitsdatum.");
// F-10: Owner muss aktiver Nutzer desselben Mandanten sein.
const assigneeId = await assertValidOwner(db, data.owner);
// Cockpit (M3, 1.4): Bereich aus dem verknüpften Control ableiten.
const domain = await domainForControl(db, session.user.tenantId, data.links?.control ?? null);
const task = await db.task.create({
data: {
tenantId: session.user.tenantId,
type: data.type,
title: data.title,
description: data.description?.trim() || null,
origin: data.origin,
priority,
assigneeId,
createdById: session.user.id,
dueDate,
domain,
resources: data.resources ? (data.resources as Prisma.InputJsonValue) : undefined,
links: data.links ? (data.links as Prisma.InputJsonValue) : undefined,
},
});
// Cockpit (M3, 2.3): RACI-Mitwirkende automatisch aus Control/Domain befüllen,
// sobald Funktion→User (Ebene 1) gepflegt ist. Der assignee bleibt primär RESPONSIBLE.
await assignTaskParticipants(db, session.user.tenantId, {
taskId: task.id,
control: data.links?.control ?? null,
domain,
assigneeId,
});
await writeAuditLog({
tenantId: session.user.tenantId, actorId: session.user.id,
action: "create", entity: "task", entityId: task.id,
after: { type: data.type, origin: data.origin, priority },
});
// SEC1: Zuweisung benachrichtigen (nicht bei Vorschlägen ohne Owner und nicht,
// wenn sich jemand die Aufgabe selbst anlegt).
await notifyTaskEvent({
tenantId: session.user.tenantId,
event: "task_assigned",
taskId: task.id,
taskTitle: task.title,
taskType: task.type,
recipientId: assigneeId,
actorId: session.user.id,
});
revalidatePath("/tasks");
revalidatePath("/dashboard");
return task;
}
/**
* Aus einem Trigger-Set (C2 §8) Aufgaben-*Vorschläge* erzeugen (Story B1). Wird von
* Dev A's Wizard-Gates/Zurückweisungen aufgerufen. Idempotent über `origin`: existiert
* je Trigger bereits ein offener Vorschlag oder eine offene Aufgabe, wird übersprungen.
*/
export async function proposeTasksFromTriggers(triggerIds: string[]): Promise<{ created: number; skipped: number }> {
const { session, db } = await guard("task:write");
let created = 0;
let skipped = 0;
for (const id of triggerIds) {
const trg = triggerById(id);
if (!trg) { skipped++; continue; }
const origin = originForTrigger(id);
const existing = await db.task.findFirst({ where: { origin, status: { in: ["PROPOSED", "OPEN"] } }, select: { id: true } });
if (existing) { skipped++; continue; }
// Cockpit (M3): Kontext-Verteiler (F2) — expliziter Trigger-Bereich (Fakt→Domain)
// hat Vorrang; sonst aus dem Control-Bezug ableiten (inkl. Tenant-Override).
const domain = trg.domain ?? (await domainForControl(db, session.user.tenantId, trg.links.control ?? null));
// `task` wird für die RACI-Auto-Befüllung (F1) unten benötigt.
const task = await db.task.create({
data: {
tenantId: session.user.tenantId,
type: trg.taskType,
title: trg.title,
origin,
status: "PROPOSED",
priority: trg.priority,
createdById: session.user.id,
domain,
links: trg.links as Prisma.InputJsonValue,
resources: trg.resourcesHint ? (trg.resourcesHint as Prisma.InputJsonValue) : undefined,
},
});
// Cockpit (M3, 2.3): RACI-Mitwirkende automatisch befüllen (Vorschlag ohne Owner).
await assignTaskParticipants(db, session.user.tenantId, {
taskId: task.id,
control: trg.links.control ?? null,
domain,
});
created++;
}
if (created > 0) {
await writeAuditLog({
tenantId: session.user.tenantId, actorId: session.user.id,
action: "create", entity: "task_proposal", after: { created, skipped, triggers: triggerIds },
});
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
return { created, skipped };
}
/**
* Aufgaben-Vorschlag bestätigen (Story B1): PROPOSED → OPEN. Der Bearbeiter kann
* Owner, Fälligkeit, Priorität und Ressourcenfelder anpassen. Nur Beteiligte
* (Ersteller oder zugewiesener Owner) dürfen bestätigen.
*/
export async function confirmProposal(taskId: string, formData: FormData) {
const { session, db } = await guard();
const task = await db.task.findUnique({ where: { id: taskId } });
if (!task || task.status !== "PROPOSED") throw new Error("Kein offener Vorschlag.");
if (task.createdById !== session.user.id && task.assigneeId !== session.user.id) {
throw new Error("Nur Beteiligte können den Vorschlag bestätigen.");
}
const resources = {
tool: str(formData.get("res_tool")),
budget: str(formData.get("res_budget")),
personnel: str(formData.get("res_personnel")),
time: str(formData.get("res_time")),
};
const owner = str(formData.get("owner"));
const priorityRaw = str(formData.get("priority"));
const dueRaw = str(formData.get("dueDate"));
const due = dueRaw ? new Date(dueRaw) : null;
if (due && Number.isNaN(due.getTime())) throw new Error("Ungültiges Fälligkeitsdatum.");
const hasResources = Object.values(resources).some(Boolean);
// F-10: Owner (falls gesetzt) muss aktiver Nutzer desselben Mandanten sein.
const assigneeId = (await assertValidOwner(db, owner)) ?? task.assigneeId;
await db.task.update({
where: { id: taskId },
data: {
status: "OPEN",
assigneeId,
dueDate: due ?? task.dueDate,
priority: isTaskPriority(priorityRaw) ? priorityRaw : task.priority,
resources: (hasResources ? resources : (task.resources ?? undefined)) as Prisma.InputJsonValue,
comments: { create: { tenantId: session.user.tenantId, authorId: session.user.id, kind: "submit", body: "Vorschlag bestätigt und übernommen." } },
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "task", entityId: taskId, after: { status: "OPEN", confirmed: true } });
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
/**
* Nicht zugewiesene Aufgabe aus dem Pool übernehmen: setzt sich selbst als Owner.
* Ein Vorschlag (PROPOSED) wird dabei zur offenen Aufgabe. Nur möglich, solange
* niemand sonst zugewiesen ist (kein „Wegnehmen").
*/
export async function claimTask(taskId: string) {
const { session, db } = await guard("task:write");
const task = await db.task.findUnique({ where: { id: taskId } });
if (!task) throw new Error("Aufgabe nicht gefunden.");
if (task.assigneeId) throw new Error("Aufgabe ist bereits zugewiesen.");
if (!["PROPOSED", "OPEN"].includes(task.status)) throw new Error("Aufgabe ist nicht mehr offen.");
await db.task.update({
where: { id: taskId },
data: {
assigneeId: session.user.id,
status: "OPEN",
comments: { create: { tenantId: session.user.tenantId, authorId: session.user.id, kind: "submit", body: "Aufgabe aus dem Pool übernommen." } },
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "task", entityId: taskId, after: { assigneeId: session.user.id, claimed: true } });
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
/** Aufgaben-Vorschlag verwerfen (Story B1): PROPOSED → DISCARDED, Grund pflicht. */
export async function discardProposal(taskId: string, formData: FormData) {
const { session, db } = await guard();
const task = await db.task.findUnique({ where: { id: taskId } });
if (!task || task.status !== "PROPOSED") throw new Error("Kein offener Vorschlag.");
if (task.createdById !== session.user.id && task.assigneeId !== session.user.id) {
throw new Error("Nur Beteiligte können den Vorschlag verwerfen.");
}
const reason = str(formData.get("note"));
if (!reason) throw new Error("Bitte einen Grund fürs Verwerfen angeben.");
await db.task.update({
where: { id: taskId },
data: {
status: "DISCARDED", resolvedById: session.user.id, resolvedAt: new Date(),
comments: { create: { tenantId: session.user.tenantId, authorId: session.user.id, kind: "reject", body: reason } },
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "task", entityId: taskId, after: { status: "DISCARDED", reason } });
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
/** Editier-/Verwaltungsrecht: Beteiligte(r) oder Aufgaben-Oversight (task:read_all, z. B. PM/Admin). */
function canManageTask(
session: { user: { id: string; permissions?: string[] } },
task: { assigneeId: string | null; createdById: string | null },
): boolean {
return task.assigneeId === session.user.id || task.createdById === session.user.id || Boolean(session.user.permissions?.includes("task:read_all"));
}
const EDITABLE_STATUS = new Set(["OPEN", "IN_PROGRESS", "DONE", "CANCELLED"]);
const BOARD_STATUS = new Set(["OPEN", "IN_PROGRESS", "DONE"]);
/** Statuswechsel per Kanban-Drag (OPEN/IN_PROGRESS/DONE). */
export async function updateTaskStatus(taskId: string, status: string) {
const { session, db } = await guard();
if (!BOARD_STATUS.has(status)) throw new Error("Ungültiger Status.");
const task = await db.task.findUnique({ where: { id: taskId } });
if (!task) throw new Error("Aufgabe nicht gefunden.");
if (!canManageTask(session, task)) throw new Error("Keine Berechtigung, diese Aufgabe zu verschieben.");
if (task.status === status) return;
await db.task.update({
where: { id: taskId },
data: {
status,
resolvedById: status === "DONE" ? session.user.id : null,
resolvedAt: status === "DONE" ? new Date() : null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "task", entityId: taskId, after: { status } });
// Cockpit (M3, 2.2): Wiedervorlage — bei DONE eine Folge-Aufgabe der Serie erzeugen.
if (status === "DONE" && task.status !== "DONE") {
await spawnRecurrence({ db, tenantId: session.user.tenantId, actorId: session.user.id }, task);
}
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
/**
* Cockpit (M3, 2.3): Reihenfolge einer Kanban-Spalte per DnD persistieren. Der Client
* schickt die neue Reihenfolge der Aufgaben-IDs einer Spalte; `orderIdx` wird
* sequenziell (0..n) geschrieben. Nur verwaltbare Aufgaben werden angefasst.
*/
export async function reorderTasks(orderedIds: string[]) {
const { session, db } = await guard();
if (!Array.isArray(orderedIds) || orderedIds.length === 0) return;
const tasks = await db.task.findMany({
where: { id: { in: orderedIds } },
select: { id: true, assigneeId: true, createdById: true, orderIdx: true },
});
const byId = new Map(tasks.map((t) => [t.id, t]));
await db.$transaction(
orderedIds.flatMap((id, idx) => {
const t = byId.get(id);
if (!t || t.orderIdx === idx || !canManageTask(session, t)) return [];
return [db.task.update({ where: { id }, data: { orderIdx: idx } })];
}),
);
revalidatePath("/tasks");
}
/**
* Aufgabe bearbeiten (analog Maßnahme): Titel, Beschreibung, Status, Owner, Priorität,
* Fälligkeit. Für Beteiligte oder Aufgaben-Oversight. Kein Zugriff auf Review-Zustände
* (PROPOSED/REJECTED/DISCARDED laufen über die eigenen Aktionen).
*/
export async function updateTask(taskId: string, formData: FormData) {
const { session, db } = await guard("task:write");
const task = await db.task.findUnique({ where: { id: taskId } });
if (!task) throw new Error("Aufgabe nicht gefunden.");
if (!canManageTask(session, task)) throw new Error("Keine Berechtigung, diese Aufgabe zu bearbeiten.");
const title = str(formData.get("title")).slice(0, 200) || task.title;
const description = str(formData.get("description")).slice(0, 5000) || null;
const owner = str(formData.get("owner"));
const priorityRaw = str(formData.get("priority"));
const statusRaw = str(formData.get("status"));
const dueRaw = str(formData.get("dueDate"));
const due = dueRaw ? new Date(dueRaw) : null;
if (due && Number.isNaN(due.getTime())) throw new Error("Ungültiges Fälligkeitsdatum.");
const status = EDITABLE_STATUS.has(statusRaw) ? statusRaw : task.status;
// F-10: Owner (falls gesetzt) muss aktiver Nutzer desselben Mandanten sein.
const assigneeId = await assertValidOwner(db, owner);
await db.task.update({
where: { id: taskId },
data: {
title,
description,
status,
assigneeId,
priority: isTaskPriority(priorityRaw) ? priorityRaw : task.priority,
dueDate: dueRaw ? due : null,
resolvedById: status === "DONE" ? session.user.id : null,
resolvedAt: status === "DONE" ? new Date() : null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "task", entityId: taskId, after: { status, title } });
// Cockpit (M3, 2.2): Wiedervorlage — bei DONE eine Folge-Aufgabe der Serie erzeugen.
if (status === "DONE" && task.status !== "DONE") {
await spawnRecurrence({ db, tenantId: session.user.tenantId, actorId: session.user.id }, task);
}
// SEC1: nur bei einem *Wechsel* des Zuständigen benachrichtigen — sonst würde
// jede Bearbeitung (Titel, Fälligkeit) eine Mail auslösen.
if (assigneeId && assigneeId !== task.assigneeId) {
await notifyTaskEvent({
tenantId: session.user.tenantId,
event: "task_assigned",
taskId,
taskTitle: title,
taskType: task.type,
recipientId: assigneeId,
actorId: session.user.id,
});
}
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
/** Aufgabe löschen (nur Beteiligte oder Aufgaben-Oversight). */
export async function deleteTask(taskId: string) {
const { session, db } = await guard();
const task = await db.task.findUnique({ where: { id: taskId } });
if (!task) throw new Error("Aufgabe nicht gefunden.");
if (!canManageTask(session, task)) throw new Error("Keine Berechtigung, diese Aufgabe zu löschen.");
await db.task.delete({ where: { id: taskId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "task", entityId: taskId });
revalidatePath("/tasks");
revalidatePath("/dashboard");
}
/**
* Eigene Aufgabe über die UI anlegen (z. B. Projektdurchlauf) — direkt als offene Aufgabe.
* Für Maßnahmen (z. B. zu Risiken) gibt es den eigenen Maßnahmen-Weg.
*/
export async function createOwnTask(formData: FormData) {
await guard(); // Modul-/Rechtezugriff (createTask guardet zusätzlich)
const type = str(formData.get("type")) || "organizational";
const prio = str(formData.get("priority"));
const owner = str(formData.get("owner"));
const dueRaw = str(formData.get("dueDate"));
await createTask({
type: isTaskType(type) ? type : "organizational",
title: str(formData.get("title")),
description: str(formData.get("description")) || null,
origin: "manual",
priority: isTaskPriority(prio) ? prio : "mittel",
owner: owner || undefined,
dueDate: dueRaw || undefined,
});
redirect("/tasks");
}
// ── Cockpit (M3, 1.6): Nachweis-/Evidence-Register je Aufgabe ─────────────────
//
// Ein Nachweis (Evidence) ist die Voraussetzung für „audit-ready" (Wirksamkeit):
// die Auto-Completion schließt eine Aufgabe nur bis DONE (dokumentiert/umgesetzt),
// Reifegrad 3 verlangt zusätzlich einen verknüpften Nachweis + Vier-Augen (2.2).
// validUntil koppelt an Task.effectiveUntil (Wiedervorlage) und markiert Ablauf.
const evidenceKindSchema = z.enum(EVIDENCE_KINDS);
/** Optionales Datum aus dem Formular (YYYY-MM-DD) parsen; leer → null. */
function parseDate(raw: string, label: string): Date | null {
if (!raw) return null;
const d = new Date(raw);
if (Number.isNaN(d.getTime())) throw new Error(`Ungültiges ${label}.`);
return d;
}
/** Zugriff auf die Nachweise einer Aufgabe: Beteiligte(r) oder Aufgaben-Oversight. */
async function requireManageableTask(db: TenantDb, session: { user: { id: string; permissions?: string[] } }, taskId: string) {
const task = await db.task.findUnique({ where: { id: taskId }, select: { id: true, assigneeId: true, createdById: true } });
if (!task) throw new Error("Aufgabe nicht gefunden.");
if (!canManageTask(session, task)) throw new Error("Keine Berechtigung für die Nachweise dieser Aufgabe.");
return task;
}
/**
* Nachweis anlegen und direkt mit der Aufgabe verknüpfen (kein Datei-Upload —
* `fileRef` ist ein Text-/URL-Verweis auf die Ablage). Setzt optional das
* Wirksamkeitsintervall (validFrom/validUntil).
*/
export async function createEvidence(taskId: string, formData: FormData) {
const { session, db } = await guard("task:write");
await requireManageableTask(db, session, taskId);
const title = str(formData.get("title")).slice(0, 200);
if (!title) throw new Error("Der Nachweis braucht einen Titel.");
const kindParsed = evidenceKindSchema.safeParse(str(formData.get("kind")));
if (!kindParsed.success) throw new Error("Ungültige Nachweisart.");
const fileRef = str(formData.get("fileRef")).slice(0, 500) || null;
const control = str(formData.get("control")).slice(0, 200) || null;
const validFrom = parseDate(str(formData.get("validFrom")), "Gültig-ab-Datum");
const validUntil = parseDate(str(formData.get("validUntil")), "Gültig-bis-Datum");
const evidence = await db.evidence.create({
data: {
tenantId: session.user.tenantId,
title,
kind: kindParsed.data,
fileRef,
control,
validFrom,
validUntil,
taskId,
createdById: session.user.id,
},
select: { id: true },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "evidence", entityId: evidence.id, after: { taskId, kind: kindParsed.data } });
revalidatePath("/tasks");
}
/** Bestehenden (unverknüpften) Nachweis mit der Aufgabe verknüpfen. */
export async function linkEvidence(taskId: string, formData: FormData) {
const { session, db } = await guard("task:write");
await requireManageableTask(db, session, taskId);
const evidenceId = str(formData.get("evidenceId"));
if (!evidenceId) throw new Error("Kein Nachweis ausgewählt.");
// db ist mandantengebunden → das update trifft nur Nachweise dieses Mandanten.
const evidence = await db.evidence.findUnique({ where: { id: evidenceId }, select: { id: true } });
if (!evidence) throw new Error("Nachweis nicht gefunden.");
await db.evidence.update({ where: { id: evidenceId }, data: { taskId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "evidence", entityId: evidenceId, after: { taskId, linked: true } });
revalidatePath("/tasks");
}
/** Nachweis von der Aufgabe entfernen (Verknüpfung lösen — der Nachweis bleibt erhalten). */
export async function unlinkEvidence(evidenceId: string) {
const { session, db } = await guard("task:write");
const evidence = await db.evidence.findUnique({ where: { id: evidenceId }, select: { id: true, taskId: true } });
if (!evidence) throw new Error("Nachweis nicht gefunden.");
if (evidence.taskId) await requireManageableTask(db, session, evidence.taskId);
await db.evidence.update({ where: { id: evidenceId }, data: { taskId: null } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "evidence", entityId: evidenceId, after: { unlinked: true } });
revalidatePath("/tasks");
}
/** Nachweise einer Aufgabe auflisten (für Server-Komponenten/Detailansicht). */
export async function listTaskEvidence(taskId: string) {
const { db } = await guard();
return db.evidence.findMany({ where: { taskId }, orderBy: { createdAt: "desc" } });
}
+57
View File
@@ -0,0 +1,57 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { requireSession } from "@/server/auth";
import { dbForTenant, prisma } from "@/server/db";
import { requirePermission } from "@/server/rbac";
import { writeAuditLog } from "@/server/audit";
import { syncPolicyVariablesFromSettings } from "@/server/provision";
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
/** Kunden-Admin pflegt Stammdaten/Branding/Policy; Stammdaten speisen die ISMS-Variablen. */
export async function updateTenantSettings(formData: FormData) {
const session = await requireSession();
requirePermission(session, "tenant:manage");
const tenantId = session.user.tenantId;
const db = dbForTenant(tenantId);
const orgName = z.string().trim().min(1).parse(formData.get("orgName"));
// TISAX-/Schutzbedarf-Tiefe ist eine Kern-Einstellung und wird ausschließlich vom
// Superadmin (Admin-Konsole) gesteuert — hier bewusst nicht anfassen.
const data = {
orgName,
orgShort: str(formData.get("orgShort")) || null,
address: str(formData.get("address")) || null,
sector: str(formData.get("sector")) || null,
duns: str(formData.get("duns")) || null,
ismsScope: str(formData.get("ismsScope")) || null,
ismsScopeDescription: str(formData.get("ismsScopeDescription")) || null,
roleManagement: str(formData.get("roleManagement")) || null,
roleIsb: str(formData.get("roleIsb")) || null,
roleItLead: str(formData.get("roleItLead")) || null,
roleDpo: str(formData.get("roleDpo")) || null,
accent: str(formData.get("accent")) || null,
locale: str(formData.get("locale")) || "de",
timezone: str(formData.get("timezone")) || "Europe/Berlin",
// NIS2-Betroffenheit des Mandanten (steuert später die Meldefristen-Timer, IM-B).
nis2Category: (["keine", "wichtig", "wesentlich"].includes(str(formData.get("nis2Category")))
? str(formData.get("nis2Category"))
: "keine"),
};
await db.tenantSettings.upsert({
where: { tenantId },
update: data,
create: { tenantId, ...data },
});
// Stammdaten → ISMS-Template-Variablen (eine Pflegestelle)
await syncPolicyVariablesFromSettings(prisma, tenantId, data);
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "tenant_settings", after: { orgName } });
revalidatePath("/settings");
revalidatePath("/policies");
}
+47
View File
@@ -0,0 +1,47 @@
"use server";
import { redirect } from "next/navigation";
import { auth, unstable_update } from "@/server/auth";
import { prisma } from "@/server/db";
import { writeAuditLog } from "@/server/audit";
/**
* WS2 (Option C) — aktiven Mandanten wählen/wechseln.
*
* EXEMPT vom Modul-Gating: kein per TenantModule gegatetes Fachmodul, sondern der
* Session-/Mandantenkontext selbst. Sicherheitskontrollen (Pflicht, §6 KONZEPT):
* - Der aktive Mandant ist SERVER-autoritativ: die gewählte Mitgliedschaft muss
* zur Session-Identity gehören und Mitgliedschaft + Mandant ACTIVE sein.
* - Beim Wechsel werden Rechte NEU aufgelöst (im jwt-Callback via unstable_update,
* nicht token-eingefroren) → Entzug/Sperre wirkt sofort.
* - Das MFA-Netz beim Betreten eines Pflicht-Mandanten greift über das bestehende
* Enrollment-Gate in (app)/layout.tsx (prüft die securityPolicy des AKTIVEN
* Mandanten und leitet ggf. auf /enroll-mfa).
* - Der Wechsel wird auditiert ("Identity → Mandant").
*/
export async function setActiveTenant(membershipId: string) {
const session = await auth();
const identityId = session?.user?.identityId;
if (!identityId) redirect("/login");
// Gehört die Mitgliedschaft zur Session-Identity und ist alles ACTIVE?
const membership = await prisma.user.findFirst({
where: { id: membershipId, identityId, status: "ACTIVE", tenant: { status: "ACTIVE" } },
select: { id: true, tenantId: true },
});
if (!membership) throw new Error("Ungültige Mandantenauswahl.");
// Token neu prägen: activeTenantId/activeMembershipId/permissions/tenantSlug (jwt-Callback).
await unstable_update({ user: { activeMembershipId: membershipId } });
await writeAuditLog({
tenantId: membership.tenantId,
actorId: membership.id,
action: "login",
entity: "tenant_switch",
entityId: membership.tenantId,
after: { identityId },
});
redirect("/dashboard");
}
+313
View File
@@ -0,0 +1,313 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { requireSession } from "@/server/auth";
import { prisma, dbForTenant, type TenantDb } from "@/server/db";
import { requirePermission } from "@/server/rbac";
import { PERMISSIONS, ROLE_DEFS } from "@/server/rbac";
import { hashPassword, generateCompliantPassword } from "@/server/password";
import { resolvePasswordPolicy } from "@/lib/password-policy";
import { issueToken } from "@/server/auth-token";
import { sendUserInvitationMail } from "@/server/auth-selfservice";
import { writeAuditLog } from "@/server/audit";
/**
* Benutzer- & Rollenverwaltung durch den Mandanten-Admin (Paket B). EXEMPT vom
* Modul-Gating; Autorisierung über RBAC (user:manage / role:manage). Alle Zugriffe
* laufen strikt über die eigene Session (dbForTenant(session.tenantId)) — Cross-Tenant
* ist ausgeschlossen. Audit-Scope: tenant.
*/
const STANDARD_ROLE_KEYS = new Set(Object.keys(ROLE_DEFS));
const PERMISSION_SET = new Set<string>(PERMISSIONS);
export type CreateUserValues = { name: string; email: string; roleIds: string[] };
export type CreateUserState =
| { status: "idle" }
| { status: "error"; message: string; values?: CreateUserValues }
// invited = Einladungsmail verschickt (Nutzer setzt Passwort selbst);
// generatedPassword gesetzt = Fallback-Anzeige, falls die Mail nicht ging.
| { status: "done"; email: string; generatedPassword: string | null; invited: boolean };
export type EditUserState =
| { status: "idle" }
| { status: "error"; message: string }
| { status: "ok" };
async function ctx(permission: "user:manage" | "role:manage") {
const session = await requireSession();
requirePermission(session, permission);
return { session, tenantId: session.user.tenantId, db: dbForTenant(session.user.tenantId) };
}
/**
* Wandelt eine geworfene Ausnahme (fehlende Berechtigung/Session aus ctx(),
* DB-Fehler etc.) in eine anzeigbare Meldung — damit die Action einen
* Fehlerzustand ZURÜCKGIBT statt die globale Error-Boundary auszulösen
* ("Aktion konnte nicht abgeschlossen werden" + Formularverlust).
*/
function actionError(err: unknown): string {
const msg = err instanceof Error ? err.message : String(err);
return msg || "Die Aktion konnte nicht ausgeführt werden.";
}
/**
* F-13: Effektive Rechtemenge des Handelnden AUTORITATIV aus der DB (nicht aus dem
* JWT), konsistent zum moduleGuard-Muster aus F-06 (src/server/action-guard.ts).
* Grundlage für die Begrenzung der vergebbaren Rechte („kein Grant über das eigene
* Niveau hinaus"). Wirft, wenn das Konto nicht (mehr) aktiv ist.
*/
async function actorEffectivePermissions(db: TenantDb, userId: string): Promise<Set<string>> {
const account = await db.user.findFirst({
where: { id: userId, status: "ACTIVE" },
select: {
userRoles: {
select: {
role: { select: { rolePermissions: { select: { permission: { select: { key: true } } } } } },
},
},
},
});
if (!account) throw new Error("Konto ist nicht aktiv.");
return new Set(account.userRoles.flatMap((ur) => ur.role.rolePermissions.map((rp) => rp.permission.key)));
}
/** Vereinigte Rechtemenge mehrerer Rollen (für die Selbstzuweisungs-Prüfung, F-13). */
async function permissionsOfRoles(db: TenantDb, roleIds: string[]): Promise<Set<string>> {
if (roleIds.length === 0) return new Set();
const rows = await db.rolePermission.findMany({
where: { roleId: { in: roleIds } },
select: { permission: { select: { key: true } } },
});
return new Set(rows.map((r) => r.permission.key));
}
async function countActiveTenantAdmins(db: TenantDb): Promise<number> {
return db.user.count({ where: { status: "ACTIVE", userRoles: { some: { role: { key: "tenant-admin" } } } } });
}
async function tenantPolicy(db: TenantDb, tenantId: string) {
const s = await db.tenantSettings.findUnique({ where: { tenantId } });
return resolvePasswordPolicy(s?.securityPolicy);
}
const emailSchema = z.string().trim().email();
/* ── Benutzer (user:manage) ── */
export async function createUser(_prev: CreateUserState, formData: FormData): Promise<CreateUserState> {
const name = String(formData.get("name") ?? "").trim();
const emailRaw = String(formData.get("email") ?? "").trim().toLowerCase();
const roleIds = formData.getAll("roles").map(String).filter(Boolean);
// Eingaben im Fehlerfall zurückgeben, damit das Formular nicht geleert wird.
const values: CreateUserValues = { name, email: emailRaw, roleIds };
const fail = (message: string): CreateUserState => ({ status: "error", message, values });
try {
const { session, tenantId, db } = await ctx("user:manage");
if (!name) return fail("Bitte einen Namen angeben.");
const email = emailSchema.safeParse(emailRaw);
if (!email.success) return fail("Bitte eine gültige E-Mail-Adresse angeben.");
if (roleIds.length === 0) return fail("Bitte mindestens eine Rolle zuweisen.");
if (await db.user.findFirst({ where: { email: email.data } })) return fail("Es existiert bereits ein Nutzer mit dieser E-Mail.");
if ((await db.role.count({ where: { id: { in: roleIds } } })) !== roleIds.length) return fail("Ungültige Rollenauswahl.");
// Option C (WS3): Anlage NUR per Einladung (goldene Regel 4). Neutralität — der
// Mandanten-Admin sieht IMMER dieselbe Rückmeldung, unabhängig davon, ob die
// Identity global schon existierte (kein Cross-Tenant-Leak). Bekannte Identity →
// nur Mitgliedschaft ergänzen (KEIN Passwort-Reset), sie bestätigt den Beitritt
// implizit beim nächsten Login über die Mandantenauswahl. Unbekannt → Identity +
// Einladung zum Passwort-Setzen (/invite). Membership.passwordHash = Legacy.
const policy = await tenantPolicy(db, tenantId);
const throwaway = await hashPassword(generateCompliantPassword(policy));
const prior = await prisma.identity.findUnique({ where: { email: email.data } });
const identity = prior ?? (await prisma.identity.create({
data: { email: email.data, passwordHash: throwaway, mustChangePassword: true, status: "ACTIVE" },
}));
const user = await db.user.create({
data: {
tenantId, identityId: identity.id, email: email.data, name, status: "ACTIVE",
userRoles: { create: roleIds.map((roleId) => ({ roleId })) },
},
});
await writeAuditLog({ tenantId, actorId: session.user.id, action: "create", entity: "user", entityId: user.id, after: { email: email.data, roleIds, linkedExisting: !!prior } });
revalidatePath("/settings/users");
if (!prior) {
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { name: true } });
const { raw, expiresAt } = await issueToken({ principalType: "identity", principalId: identity.id, tenantId, type: "invitation" });
const invite = await sendUserInvitationMail({ to: email.data, name, tenantId, tenantName: tenant?.name ?? "", rawToken: raw, expiresAt });
if (invite.status !== "queued" && invite.status !== "sent") {
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "user_invite_failed", entityId: user.id, after: { email: email.data, mail: invite.status } });
}
}
return { status: "done", email: email.data, generatedPassword: null, invited: true };
} catch (err) {
return fail(actionError(err));
}
}
/** Stammdaten (Name, E-Mail) eines Nutzers ändern; E-Mail bleibt je Mandant eindeutig. */
export async function updateUser(userId: string, _prev: EditUserState, formData: FormData): Promise<EditUserState> {
try {
const { session, tenantId, db } = await ctx("user:manage");
const user = await db.user.findUnique({ where: { id: userId } });
if (!user) return { status: "error", message: "Nutzer nicht gefunden." };
const name = String(formData.get("name") ?? "").trim();
const email = emailSchema.safeParse(String(formData.get("email") ?? "").trim().toLowerCase());
if (!name) return { status: "error", message: "Bitte einen Namen angeben." };
if (!email.success) return { status: "error", message: "Bitte eine gültige E-Mail-Adresse angeben." };
if (email.data !== user.email && (await db.user.findFirst({ where: { email: email.data, id: { not: userId } } }))) {
return { status: "error", message: "Diese E-Mail ist bereits vergeben." };
}
await db.user.update({ where: { id: userId }, data: { name, email: email.data } });
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "user", entityId: userId, after: { name, email: email.data } });
revalidatePath("/settings/users");
return { status: "ok" };
} catch (err) {
return { status: "error", message: actionError(err) };
}
}
export async function setUserStatus(userId: string, status: "ACTIVE" | "DEACTIVATED") {
const { session, tenantId, db } = await ctx("user:manage");
const user = await db.user.findUnique({ where: { id: userId }, include: { userRoles: { include: { role: true } } } });
if (!user) throw new Error("Nutzer nicht gefunden");
if (status === "DEACTIVATED" && user.userRoles.some((ur) => ur.role.key === "tenant-admin") && user.status === "ACTIVE" && (await countActiveTenantAdmins(db)) <= 1) {
throw new Error("Der letzte aktive Mandanten-Admin kann nicht deaktiviert werden.");
}
await db.user.update({ where: { id: userId }, data: { status } });
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "user", entityId: userId, after: { status } });
revalidatePath("/settings/users");
}
export async function setUserRoles(userId: string, formData: FormData) {
const { session, tenantId, db } = await ctx("user:manage");
const roleIds = formData.getAll("roles").map(String).filter(Boolean);
const user = await db.user.findUnique({ where: { id: userId }, include: { userRoles: { include: { role: true } } } });
if (!user) throw new Error("Nutzer nicht gefunden");
if (roleIds.length && (await db.role.count({ where: { id: { in: roleIds } } })) !== roleIds.length) throw new Error("Ungültige Rollenauswahl");
// F-13: Selbstzuweisung darf keine Rechte oberhalb des eigenen Niveaus einbringen.
// Sonst könnte ein tenant-admin (role:manage + user:manage) sich z. B. die ISB-Rolle
// mit policy:approve/risk:accept geben und so die Aufgabentrennung (ISO 27001 A.5.3)
// aushebeln. Die eigenen bereits gehaltenen Rollen sind per Definition ⊆ effektiv,
// bleiben also zulässig; nur das Hinzufügen höher privilegierter Rollen wird geblockt.
if (userId === session.user.id) {
const effective = await actorEffectivePermissions(db, session.user.id);
const targetPerms = await permissionsOfRoles(db, roleIds);
const excess = [...targetPerms].filter((p) => !effective.has(p));
if (excess.length) {
throw new Error(`Selbstzuweisung von Rechten oberhalb des eigenen Niveaus ist nicht zulässig: ${excess.join(", ")}.`);
}
}
const adminRole = await db.role.findFirst({ where: { key: "tenant-admin" } });
const hadAdmin = user.userRoles.some((ur) => ur.role.key === "tenant-admin");
const keepsAdmin = adminRole ? roleIds.includes(adminRole.id) : false;
if (hadAdmin && !keepsAdmin && user.status === "ACTIVE" && (await countActiveTenantAdmins(db)) <= 1) {
throw new Error("Dem letzten aktiven Mandanten-Admin kann die Admin-Rolle nicht entzogen werden.");
}
await db.$transaction([
db.userRole.deleteMany({ where: { userId } }),
db.userRole.createMany({ data: roleIds.map((roleId) => ({ userId, roleId })) }),
]);
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "user_roles", entityId: userId, after: { roleIds } });
revalidatePath("/settings/users");
}
/* ── Rollen (role:manage) ── */
const slugify = (s: string) =>
s.toLowerCase().normalize("NFKD").replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "rolle";
function validPermissionKeys(formData: FormData): string[] {
return formData.getAll("perms").map(String).filter((k) => PERMISSION_SET.has(k));
}
export type RoleFormState = { status: "idle" } | { status: "error"; message: string } | { status: "done" };
/** Eigene Rolle anlegen (Standardrollen-Keys sind reserviert). */
export async function createRole(_prev: RoleFormState, formData: FormData): Promise<RoleFormState> {
const { session, tenantId, db } = await ctx("role:manage");
const name = String(formData.get("name") ?? "").trim();
if (name.length < 2) return { status: "error", message: "Bitte einen Rollennamen (min. 2 Zeichen) angeben." };
const perms = validPermissionKeys(formData);
// F-13 (pragmatische Variante): Delegation ist zulässig — ein Admin darf für ANDERE
// Rollen mit Rechten oberhalb seines eigenen Niveaus anlegen (sonst könnte er keine
// fachlichen Rollen einrichten und keine Standardrollen wie ISB klonen+bearbeiten).
// Die eigentliche Privilege-Escalation (sich selbst hochstufen) wird in setUserRoles
// durch die Selbstzuweisungs-Sperre verhindert. Die Vergabe wird vollständig auditiert.
let key = slugify(name);
if (STANDARD_ROLE_KEYS.has(key)) key = `${key}-eigen`;
// Eindeutigkeit je Mandant sicherstellen.
let suffix = 1;
const base = key;
while (await db.role.findFirst({ where: { key } })) key = `${base}-${suffix++}`;
const role = await db.role.create({ data: { tenantId, key, name } });
if (perms.length) {
const permRows = await db.permission.findMany({ where: { key: { in: perms } } });
await db.rolePermission.createMany({ data: permRows.map((p) => ({ roleId: role.id, permissionId: p.id })) });
}
await writeAuditLog({ tenantId, actorId: session.user.id, action: "create", entity: "role", entityId: role.id, after: { key, name, perms } });
revalidatePath("/settings/users");
return { status: "done" };
}
/** Permissions einer eigenen Rolle setzen (Standardrollen sind schreibgeschützt). */
export async function updateRolePermissions(roleId: string, formData: FormData) {
const { session, tenantId, db } = await ctx("role:manage");
const role = await db.role.findUnique({ where: { id: roleId } });
if (!role) throw new Error("Rolle nicht gefunden");
if (STANDARD_ROLE_KEYS.has(role.key)) throw new Error("Standardrollen sind schreibgeschützt. Bitte klonen und die Kopie anpassen.");
const perms = validPermissionKeys(formData);
// F-13 (pragmatische Variante): Delegation zulässig (s. createRole); Selbst-
// Eskalation ist über die Selbstzuweisungs-Sperre in setUserRoles ausgeschlossen.
const permRows = await db.permission.findMany({ where: { key: { in: perms } } });
await db.$transaction([
db.rolePermission.deleteMany({ where: { roleId } }),
db.rolePermission.createMany({ data: permRows.map((p) => ({ roleId, permissionId: p.id })) }),
]);
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "role", entityId: roleId, after: { perms } });
revalidatePath("/settings/users");
}
/** Rolle klonen (auch Standardrollen) → neue eigene, editierbare Rolle. */
export async function cloneRole(roleId: string, formData: FormData) {
const { session, tenantId, db } = await ctx("role:manage");
const source = await db.role.findUnique({ where: { id: roleId }, include: { rolePermissions: true } });
if (!source) throw new Error("Rolle nicht gefunden");
const name = String(formData.get("name") ?? "").trim() || `${source.name} (Kopie)`;
let key = slugify(name);
if (STANDARD_ROLE_KEYS.has(key)) key = `${key}-eigen`;
let suffix = 1;
const base = key;
while (await db.role.findFirst({ where: { key } })) key = `${base}-${suffix++}`;
const clone = await db.role.create({ data: { tenantId, key, name } });
if (source.rolePermissions.length) {
await db.rolePermission.createMany({ data: source.rolePermissions.map((rp) => ({ roleId: clone.id, permissionId: rp.permissionId })) });
}
await writeAuditLog({ tenantId, actorId: session.user.id, action: "create", entity: "role", entityId: clone.id, after: { clonedFrom: source.key, key, name } });
revalidatePath("/settings/users");
}
/** Rolle löschen: nur eigene Rollen, nur ohne zugewiesene Nutzer (Umzug-Hinweis). */
export async function deleteRole(roleId: string) {
const { session, tenantId, db } = await ctx("role:manage");
const role = await db.role.findUnique({ where: { id: roleId }, include: { _count: { select: { userRoles: true } } } });
if (!role) throw new Error("Rolle nicht gefunden");
if (STANDARD_ROLE_KEYS.has(role.key)) throw new Error("Standardrollen können nicht gelöscht werden.");
if (role._count.userRoles > 0) throw new Error(`Rolle ist ${role._count.userRoles} Nutzer(n) zugewiesen. Bitte zuerst umziehen/entfernen.`);
await db.role.delete({ where: { id: roleId } });
await writeAuditLog({ tenantId, actorId: session.user.id, action: "delete", entity: "role", entityId: roleId, after: { key: role.key } });
revalidatePath("/settings/users");
}
+96
View File
@@ -0,0 +1,96 @@
"use server";
import { cookies } from "next/headers";
import { revalidatePath } from "next/cache";
import { requireSession } from "@/server/auth";
import { prisma } from "@/server/db";
import { writeAuditLog } from "@/server/audit";
import { buildRegistrationOptions, buildAuthenticationOptions, verifyReg, toB64Url, LOGIN_CHALLENGE_COOKIE } from "@/server/webauthn";
import type { RegistrationResponseJSON } from "@simplewebauthn/types";
/**
* SEC3-b: Passkey-Login vorbereiten (öffentlich, pre-session). Erzeugt Authentifizierungs-
* Optionen für discoverable Credentials (leere allowCredentials → der Browser wählt einen
* resident Passkey) und legt die Challenge in einem kurzlebigen httpOnly-Cookie ab.
*/
export async function beginPasskeyLogin() {
const options = await buildAuthenticationOptions([]);
const jar = await cookies();
jar.set(LOGIN_CHALLENGE_COOKIE, options.challenge, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 120,
});
return options;
}
/**
* SEC3-b: Passkey-Registrierung & -Verwaltung des angemeldeten Mandanten-Nutzers
* (EXEMPT vom Modul-Gating — eigene Auth über requireSession). Die Challenge wird
* zwischen Options- und Verify-Schritt in einem kurzlebigen httpOnly-Cookie gehalten.
*/
const REG_COOKIE = "wa_reg_challenge";
export async function beginPasskeyRegistration() {
const session = await requireSession();
// WS4b: Passkeys gehören der GLOBALEN Identity (stabile userID über Mandanten hinweg).
const identityId = session.user.identityId;
if (!identityId) throw new Error("Kein Identity-Kontext in der Session.");
const creds = await prisma.webAuthnCredential.findMany({ where: { identityId }, select: { credentialId: true, transports: true } });
const options = await buildRegistrationOptions({
userId: identityId,
userName: session.user.email ?? identityId,
userDisplayName: session.user.name ?? session.user.email ?? "Nutzer",
existing: creds,
});
const jar = await cookies();
jar.set(REG_COOKIE, options.challenge, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 300,
});
return options;
}
export async function finishPasskeyRegistration(response: RegistrationResponseJSON, deviceName: string) {
const session = await requireSession();
const jar = await cookies();
const expectedChallenge = jar.get(REG_COOKIE)?.value;
if (!expectedChallenge) throw new Error("Registrierung abgelaufen — bitte erneut starten.");
const verification = await verifyReg(response, expectedChallenge);
if (!verification.verified || !verification.registrationInfo) throw new Error("Passkey konnte nicht verifiziert werden.");
const { credentialID, credentialPublicKey, counter } = verification.registrationInfo;
const identityId = session.user.identityId;
if (!identityId) throw new Error("Kein Identity-Kontext in der Session.");
await prisma.webAuthnCredential.create({
data: {
identityId,
credentialId: toB64Url(credentialID),
publicKey: toB64Url(credentialPublicKey),
counter: BigInt(counter),
transports: response.response.transports ?? [],
deviceName: deviceName.trim().slice(0, 60) || null,
},
});
jar.delete(REG_COOKIE);
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "webauthn_credential", entityId: session.user.id, after: { deviceName: deviceName.trim() || null } });
revalidatePath("/account");
}
export async function removePasskey(credentialDbId: string) {
const session = await requireSession();
const identityId = session.user.identityId;
if (!identityId) throw new Error("Kein Identity-Kontext in der Session.");
const cred = await prisma.webAuthnCredential.findFirst({ where: { id: credentialDbId, identityId }, select: { id: true } });
if (!cred) throw new Error("Passkey nicht gefunden.");
await prisma.webAuthnCredential.delete({ where: { id: cred.id } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "webauthn_credential", entityId: cred.id });
revalidatePath("/account");
}
+41
View File
@@ -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;
}
+127
View File
@@ -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;
}
}
+30
View File
@@ -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,
};
}
+136
View File
@@ -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);
}
+56
View File
@@ -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,
},
});
}
+321
View File
@@ -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),
},
});
}
+157
View File
@@ -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;
}
+442
View File
@@ -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;
}
+56
View File
@@ -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");
}
+100
View File
@@ -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,
},
};
}
+208
View File
@@ -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();
}
+26
View File
@@ -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";
+44
View File
@@ -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
+128
View File
@@ -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;
}
}
+130
View File
@@ -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;
}
+15
View File
@@ -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);
}
+301
View File
@@ -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,
};
}
+105
View File
@@ -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;
}
+450
View File
@@ -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(", ")}.`
: ""),
);
}
}
+49
View File
@@ -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();
}
+115
View File
@@ -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]);
}
+145
View File
@@ -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),
};
}
+201
View File
@@ -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;
}
+430
View File
@@ -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>;
+284
View File
@@ -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),
},
};
}
+245
View File
@@ -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 };
}
+112
View File
@@ -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,
};
}
+42
View File
@@ -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" },
];
+58
View File
@@ -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;
}
+59
View File
@@ -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;
}
+69
View File
@@ -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,
};
});
}
+37
View File
@@ -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);
}
+30
View File
@@ -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);
}
+57
View File
@@ -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);
}
+122
View File
@@ -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) };
}
+206
View File
@@ -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,
};
}
+210
View File
@@ -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,
};
}
+135
View File
@@ -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 };
}
+144
View File
@@ -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");
}
+35
View File
@@ -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")}`;
}
+80
View File
@@ -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 };
}
+62
View File
@@ -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");
}
+102
View File
@@ -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(/^\/+/, "")}`;
}
+91
View File
@@ -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) },
});
}
+294
View File
@@ -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;
}
+34
View File
@@ -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";
+221
View File
@@ -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);
}
+110
View File
@@ -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;
}
+37
View File
@@ -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";
}
}
+174
View File
@@ -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;
}
+115
View File
@@ -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 };
}
}
+276
View File
@@ -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);
}
+120
View File
@@ -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