"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(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> { 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> { 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 { 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 { 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 { 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 { 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"); }