Files
craftvia/src/server/actions/tenant-users.ts
T
msolarczekandClaude Opus 5 c8e6f30a27
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s
Basis: Certvia dev@a48c5fb als Fundament für Craftvia
Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation
und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:05:39 +02:00

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