Files
certvia/src/server/provision.ts
T
msolarczekandClaude Opus 4.8 8e4040d1da Separater Superadmin-Store + eigener Login + MFA-Pflicht (Phase-1-Härtung Paket 2)
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>
2026-07-20 20:55:12 +02:00

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