Admin-Konsole & Mandantenverwaltung (Phase 1)

Plattform-Admin-Konsole (Superadmin) + Kunden-Einstellungsbereich:

- Datenmodell: TenantSettings (Quelle der ISMS-Template-Variablen), TenantModule
  (Feature-Toggles je Mandant), Tenant += short/sector, TenantStatus += ARCHIVED,
  AuditLog += scope (tenant|platform); RLS für die neuen Tenant-Tabellen.
- Wiederverwendbare Provisionierung (server/provision.ts): Rollen+Permissions,
  erster Admin-User, Einstellungen, alle Module, optional Richtlinienpaket-Seed +
  TISAX-Default; idempotent + Audit (§3.7). syncPolicyVariablesFromSettings mappt
  Stammdaten → ISMS-Variablen (ORG_NAME, ISMS_SCOPE, ROLE_* …).
- Admin-Konsole /admin (Guard isPlatformAdmin): Mandantenliste, „Neuer Kunde"
  (anlegen + provisionieren), Detailseite mit Modul-Toggles, Lebenszyklus
  (aktiv/gesperrt/archiviert), Nutzerliste. Aktionen im Plattform-Audit-Log.
- Kunden-Einstellungen /settings (Guard tenant:manage): Unternehmensdaten,
  verantwortliche Rollen, Branding/Regionales, TISAX-Level — Stammdaten speisen
  die ISMS-Variablen (eine Pflegestelle). Modul-Übersicht.
- Modul-Gating der Navigation (deaktivierte Module ausgeblendet); Login bereits
  für gesperrte/archivierte Mandanten blockiert (auth.authorize).
- Seed: Demo-Mandant mit Einstellungen, 12 aktiven Modulen; admin@demo.example
  = Superadmin.

Verifiziert: tsc/lint/build grün; Seed rollt Einstellungen/Module/Superadmin aus.
(Browser-Verifikation diese Sitzung nicht möglich — Preview-Tools getrennt.)

Offen (Phase 2): separater Superadmin-Store + eigener Login + MFA-Pflicht;
Impersonation (zeitlich begrenzt, protokolliert); Plan/Limits; Logo-Upload/
Objektspeicher; SMTP/Benachrichtigungen; per-Route-Modul-Enforcement (serverseitig);
Datenexport/Löschung/Retention (DSGVO); SSO.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 20:38:10 +02:00
co-authored by Claude Opus 4.8
parent eab16c861d
commit 0e3ed82b21
14 changed files with 864 additions and 3 deletions
+71
View File
@@ -0,0 +1,71 @@
"use server";
import { join } from "node:path";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { requireSession } from "@/server/auth";
import { prisma } from "@/server/db";
import { provisionTenant } from "@/server/provision";
/** Nur Plattform-Admins (Superadmin) — mandantenübergreifend. */
async function requirePlatformAdmin() {
const session = await requireSession();
if (!session.user.isPlatformAdmin) throw new Error("Nur für Plattform-Admins.");
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";
const adminPassword = z.string().min(8).parse(formData.get("adminPassword"));
const tisaxLevel = str(formData.get("tisaxLevel")) === "AL3" ? "AL3" : "AL2";
const seedPolicies = formData.get("seedPolicies") === "on";
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,
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}`);
}
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 } },
});
revalidatePath(`/admin/${tenantId}`);
}
+56
View File
@@ -0,0 +1,56 @@
"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"));
const tisaxLevel = str(formData.get("tisaxLevel")) === "AL3" ? "AL3" : "AL2";
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",
tisaxLevel,
};
await db.tenantSettings.upsert({
where: { tenantId },
update: data,
create: { tenantId, ...data },
});
// Stammdaten → ISMS-Template-Variablen (eine Pflegestelle)
await syncPolicyVariablesFromSettings(prisma, tenantId, data);
// TISAX-Level steuert die Schutzbedarf-Flags des Richtlinienmoduls
await prisma.policyVariable.updateMany({ where: { tenantId, key: "FLAG_HIGH_PROTECTION" }, data: { value: "true" } });
await prisma.policyVariable.updateMany({ where: { tenantId, key: "FLAG_VERY_HIGH_PROTECTION" }, data: { value: tisaxLevel === "AL3" ? "true" : "false" } });
await writeAuditLog({ tenantId, actorId: session.user.id, action: "update", entity: "tenant_settings", after: { orgName, tisaxLevel } });
revalidatePath("/settings");
revalidatePath("/policies");
}
+2
View File
@@ -28,6 +28,8 @@ const TENANT_MODELS = new Set<string>([
"User",
"Role",
"AuditLog",
"TenantSettings",
"TenantModule",
"Asset",
"AssetRelation",
"Process",
+141
View File
@@ -0,0 +1,141 @@
import { hash } from "@node-rs/argon2";
import type { PrismaClient } from "@prisma/client";
import { PERMISSIONS, ROLE_DEFS } from "@/server/rbac";
import { MODULES } from "@/lib/modules";
import { importPolicies } from "../../prisma/import-policies";
import { importManaged } from "../../prisma/import-managed";
/**
* Mappt die Mandanten-Stammdaten (TenantSettings) auf die ISMS-Template-Variablen
* des Richtlinienmoduls (§4.1) — eine Pflegestelle, keine Doppeleingabe.
*/
export async function syncPolicyVariablesFromSettings(
prisma: PrismaClient,
tenantId: string,
s: {
orgName: string; orgShort?: string | null; ismsScope?: string | null; ismsScopeDescription?: string | null;
roleManagement?: string | null; roleIsb?: string | null; roleItLead?: string | null; roleDpo?: string | null;
}
) {
const map: Record<string, string | null | undefined> = {
ORG_NAME: s.orgName,
ORG_SHORT: s.orgShort,
ISMS_SCOPE: s.ismsScope,
ISMS_SCOPE_DESCRIPTION: s.ismsScopeDescription,
ROLE_MANAGEMENT: s.roleManagement,
ROLE_ISB: s.roleIsb,
ROLE_IT_LEAD: s.roleItLead,
ROLE_DPO: s.roleDpo,
};
for (const [key, value] of Object.entries(map)) {
if (value == null || value === "") continue;
await prisma.policyVariable.updateMany({ where: { tenantId, key }, data: { value } });
}
}
export interface ProvisionOpts {
name: string;
slug: string;
short?: string;
sector?: string;
admin: { email: string; name: string; password: string; isPlatformAdmin?: boolean };
tisaxLevel?: "AL2" | "AL3";
seedPoliciesDir?: string;
actorId?: string | null;
}
/**
* Legt einen Mandanten idempotent an bzw. aktualisiert ihn: Standard-Rollen +
* Permissions, erster Admin-User, Mandanten-Einstellungen, alle Module aktiv,
* optional Richtlinienpaket-Seed inkl. TISAX-Default (§3.7 Auto-Provisioning).
*/
export async function provisionTenant(prisma: PrismaClient, opts: ProvisionOpts) {
const tisaxLevel = opts.tisaxLevel ?? "AL2";
// 1. Globaler Permission-Katalog
for (const key of PERMISSIONS) {
await prisma.permission.upsert({ where: { key }, update: {}, create: { key } });
}
// 2. Mandant
const tenant = await prisma.tenant.upsert({
where: { slug: opts.slug },
update: { name: opts.name, short: opts.short ?? null, sector: opts.sector ?? null },
create: { name: opts.name, slug: opts.slug, short: opts.short ?? null, sector: opts.sector ?? null },
});
// 3. Rollen + Permissions
for (const [key, def] of Object.entries(ROLE_DEFS)) {
const role = await prisma.role.upsert({
where: { tenantId_key: { tenantId: tenant.id, key } },
update: { name: def.name },
create: { tenantId: tenant.id, key, name: def.name },
});
const perms = await prisma.permission.findMany({ where: { key: { in: [...def.permissions] } } });
for (const p of perms) {
await prisma.rolePermission.upsert({
where: { roleId_permissionId: { roleId: role.id, permissionId: p.id } },
update: {},
create: { roleId: role.id, permissionId: p.id },
});
}
}
// 4. Erster Admin-User (Mandanten-Admin + ISB)
const passwordHash = await hash(opts.admin.password);
const admin = await prisma.user.upsert({
where: { tenantId_email: { tenantId: tenant.id, email: opts.admin.email } },
update: { name: opts.admin.name, isPlatformAdmin: opts.admin.isPlatformAdmin ?? false },
create: { tenantId: tenant.id, email: opts.admin.email, name: opts.admin.name, passwordHash, isPlatformAdmin: opts.admin.isPlatformAdmin ?? false },
});
const adminRoles = await prisma.role.findMany({ where: { tenantId: tenant.id, key: { in: ["tenant-admin", "isb"] } } });
for (const r of adminRoles) {
await prisma.userRole.upsert({
where: { userId_roleId: { userId: admin.id, roleId: r.id } },
update: {},
create: { userId: admin.id, roleId: r.id },
});
}
// 5. Mandanten-Einstellungen (Quelle der ISMS-Variablen)
await prisma.tenantSettings.upsert({
where: { tenantId: tenant.id },
update: {},
create: {
tenantId: tenant.id,
orgName: opts.name,
orgShort: opts.short ?? null,
sector: opts.sector ?? null,
roleManagement: "Geschäftsführung",
roleIsb: "Informationssicherheitsbeauftragte(r) (ISB)",
roleItLead: "IT-Leitung",
roleDpo: "Datenschutzbeauftragte(r) (DSB)",
tisaxLevel,
},
});
// 6. Alle Module aktivieren
for (const m of MODULES) {
await prisma.tenantModule.upsert({
where: { tenantId_moduleKey: { tenantId: tenant.id, moduleKey: m.key } },
update: {},
create: { tenantId: tenant.id, moduleKey: m.key, enabled: true },
});
}
// 7. Richtlinienpaket + TISAX-Default (falls Seed-Verzeichnis übergeben)
if (opts.seedPoliciesDir) {
await importPolicies(prisma, tenant.id, opts.seedPoliciesDir);
await importManaged(prisma, tenant.id);
await prisma.policyVariable.updateMany({ where: { tenantId: tenant.id, key: "FLAG_HIGH_PROTECTION" }, data: { value: "true" } });
await prisma.policyVariable.updateMany({ where: { tenantId: tenant.id, key: "FLAG_VERY_HIGH_PROTECTION" }, data: { value: tisaxLevel === "AL3" ? "true" : "false" } });
const settings = await prisma.tenantSettings.findUnique({ where: { tenantId: tenant.id } });
if (settings) await syncPolicyVariablesFromSettings(prisma, tenant.id, settings);
}
await prisma.auditLog.create({
data: { tenantId: tenant.id, scope: "platform", actorId: opts.actorId ?? null, action: "provision", entity: "tenant", entityId: tenant.id, after: { name: opts.name, tisaxLevel } },
});
return tenant;
}