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>
142 lines
5.4 KiB
TypeScript
142 lines
5.4 KiB
TypeScript
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 };
|
|
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 },
|
|
create: { tenantId: tenant.id, email: opts.admin.email, name: opts.admin.name, passwordHash },
|
|
});
|
|
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;
|
|
}
|