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>
314 lines
17 KiB
TypeScript
314 lines
17 KiB
TypeScript
"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");
|
|
}
|