Plattform-Administratoren sind nicht länger isPlatformAdmin-Nutzer innerhalb eines Mandanten, sondern ein getrennter Store mit eigener Auth-Domäne — Voraussetzung für den sicheren Betrieb beim ersten echten Kunden. Store & Migration - Neues Modell PlatformAdmin (kein tenant_id): Argon2id-Hash, TOTP-Secret, Recovery-Codes (nur SHA-256-Hashes), Fehlversuchszähler + Sperre, lastLogin. - AuditLog.tenant_id nullable → mandantenlose Plattform-Ereignisse (scope=platform). - Datenmigration: bestehende isPlatformAdmin-Nutzer in den neuen Store übernommen (gleicher Hash → Login sofort möglich), Flag mandantenweit auf false gesetzt. Getrennter Login + MFA - Zweite NextAuth-Instanz (server/platform-auth.ts) mit eigenem Cookie und eigenem basePath /api/platform-auth; Session trägt bewusst KEINEN tenantId. - TOTP-MFA (otplib): Enrollment beim ersten Login (/platform/enroll-mfa, QR + Klartext-Secret), danach bei jedem Login erzwungen; 10 einmalige Recovery-Codes. - Härtung: Konto-Sperre nach 5 Fehlversuchen (15 min), Audit aller Anmeldungen, Fehlversuche und Sperren (scope=platform). Autorisierung / Trennung - Admin-Konsole nach (platform)/admin verschoben; (platform)/layout.tsx erzwingt Plattform-Session + aktivierte MFA. Mandanten-Session hat KEINEN Zugriff auf /admin. - Mandanten-Shell zeigt keinen /admin-Link mehr; admin-Actions prüfen die Plattform-Session statt des abgelösten Flags. - provision/seed setzen isPlatformAdmin nicht mehr; Seed legt den Demo-Plattform- Admin (admin@demo.example) im getrennten Store an. Browser-verifiziert: Plattform-Login → erzwungenes MFA-Enrollment → Recovery-Codes → /admin; Login ohne Code scheitert (?error=1); Mandanten-Session auf /admin wird auf /platform/login umgeleitet. tsc + lint + build + Guard-Check grün. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
72 lines
3.0 KiB
TypeScript
72 lines
3.0 KiB
TypeScript
"use server";
|
|
|
|
import { join } from "node:path";
|
|
import { revalidatePath } from "next/cache";
|
|
import { redirect } from "next/navigation";
|
|
import { z } from "zod";
|
|
import { platformAuth } from "@/server/platform-auth";
|
|
import { prisma } from "@/server/db";
|
|
import { provisionTenant } from "@/server/provision";
|
|
|
|
/** Nur Plattform-Admins über die getrennte Plattform-Session (kein Mandantenkontext). */
|
|
async function requirePlatformAdmin() {
|
|
const session = await platformAuth();
|
|
if (!session?.user?.id) 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}`);
|
|
}
|