Fundament: ISMS-Module entfernt; Craftvia-Rollen, Module, Navigation, i18n-Split

- ISMS-Routen, Actions, Server-/Lib-Code, Komponenten, Prisma-Modelle, Seeds,
  Importer, Skripte und ISMS-Tests entfernt (Fundament bleibt: Auth, Identity,
  MFA/WebAuthn, RBAC, Audit, Mail, Storage, Backup/DSGVO, Plattform-Admin)
- Schema auf Fundament-Modelle reduziert; TenantSettings generisch (+phone/email)
- TENANT_MODELS (db.ts, backup/topology.ts) und PII-Felder ausgedünnt
- RBAC: Rollen tenant-admin/backoffice/team-lead/technician + Craftvia-Permissions
- Modul-Katalog (customers, sites, teams, work_orders, imports, field, reports,
  emergency, documents, notifications, lotse) + Navigation aus src/lib/nav.ts
- Modul-Routen mit requireModule-Layout und Platzhalterseite
- Message-Katalog je Namespace (messages/<locale>/<namespace>.json), fs-Loader
- check-module-guards: Modul-Key aus src/server/actions/<moduleKey>/
- Provisionierung, Admin-Konsole, Einstellungen, Files-Route, Mail entkoppelt
- Seed minimal (demo/demo2, Nutzer je Rolle); Fundament-Tests auf Role/
  NotificationPreference-Fixtures umgestellt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 11:35:44 +02:00
co-authored by Claude Opus 5
parent c8e6f30a27
commit 8491c7f173
443 changed files with 1325 additions and 87773 deletions
+15 -103
View File
@@ -1,38 +1,7 @@
import type { PrismaClient, Framework } from "@prisma/client";
import type { PrismaClient } from "@prisma/client";
import { hashPassword } from "@/server/password";
import { PERMISSIONS, ROLE_DEFS } from "@/server/rbac";
import { MODULES } from "@/lib/modules";
import { reconcilePackage, stampPackageVersion } from "../../prisma/import-policies";
import { resolvePackageForTenant } from "../../prisma/template-store";
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;
@@ -40,25 +9,18 @@ export interface ProvisionOpts {
short?: string;
sector?: string;
admin: { email: string; name: string; password: string };
tisaxLevel?: "AL2" | "AL3";
seedPoliciesDir?: string;
actorId?: string | null;
/**
* AP2: Rahmenwerke, die der Mandant führt (Reihenfolge = Primär zuerst). Default
* `["TISAX"]` (rückwärtskompatibel). Steuert die TenantFramework-Zeilen, welche
* Mappings importiert und welche Sichtbarkeits-Flags (FLAG_FW_*) gesetzt werden.
*/
frameworks?: Framework[];
}
/**
* 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).
* Legt einen Mandanten idempotent an bzw. aktualisiert ihn: globaler Permission-Katalog,
* Standard-Rollen (ROLE_DEFS) inkl. Rechte, erster Mandantenadministrator (Identity +
* Mitgliedschaft), Mandanten-Einstellungen, alle Module aktiv, Audit-Eintrag.
*
* Andockpunkt Fachmodule: mandantenspezifische Stammdaten-Defaults (z. B. Auftragsarten,
* Checklisten-Vorlagen) werden künftig hier nach Schritt 6 angelegt.
*/
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 } });
@@ -88,11 +50,8 @@ export async function provisionTenant(prisma: PrismaClient, opts: ProvisionOpts)
}
}
// 4. Erster Admin-User (Mandanten-Admin + ISB)
// Option C: globale Identity (Anmeldung) + Mitgliedschaft (User) im Mandanten.
// Expand/Contract: passwordHash bleibt vorerst auch auf der Membership (Legacy),
// bis WS1 den Login gegen Identity umstellt. Idempotent: bestehende Identity/
// Passwort wird NICHT überschrieben (analog Bootstrap-Semantik).
// 4. Erster Mandantenadministrator: globale Identity (Anmeldung) + Mitgliedschaft.
// Idempotent: eine bestehende Identity/ihr Passwort wird NICHT überschrieben.
const passwordHash = await hashPassword(opts.admin.password);
const identity = await prisma.identity.upsert({
where: { email: opts.admin.email },
@@ -104,16 +63,16 @@ export async function provisionTenant(prisma: PrismaClient, opts: ProvisionOpts)
update: { name: opts.admin.name, identityId: identity.id },
create: { tenantId: tenant.id, identityId: identity.id, email: opts.admin.email, name: opts.admin.name },
});
const adminRoles = await prisma.role.findMany({ where: { tenantId: tenant.id, key: { in: ["tenant-admin", "isb"] } } });
for (const r of adminRoles) {
const adminRole = await prisma.role.findUnique({ where: { tenantId_key: { tenantId: tenant.id, key: "tenant-admin" } } });
if (adminRole) {
await prisma.userRole.upsert({
where: { userId_roleId: { userId: admin.id, roleId: r.id } },
where: { userId_roleId: { userId: admin.id, roleId: adminRole.id } },
update: {},
create: { userId: admin.id, roleId: r.id },
create: { userId: admin.id, roleId: adminRole.id },
});
}
// 5. Mandanten-Einstellungen (Quelle der ISMS-Variablen)
// 5. Mandanten-Einstellungen (Unternehmensdaten)
await prisma.tenantSettings.upsert({
where: { tenantId: tenant.id },
update: {},
@@ -122,11 +81,6 @@ export async function provisionTenant(prisma: PrismaClient, opts: ProvisionOpts)
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,
},
});
@@ -139,50 +93,8 @@ export async function provisionTenant(prisma: PrismaClient, opts: ProvisionOpts)
});
}
// 6b. Framework-Zugehörigkeit (AP2): der Mandant führt die in `frameworks` genannten
// Rahmenwerke (Default TISAX). Erstes = Primär. Idempotent je (tenant, framework).
const frameworks: Framework[] = opts.frameworks?.length ? [...new Set(opts.frameworks)] : ["TISAX"];
const runsTisax = frameworks.includes("TISAX");
for (const [i, framework] of frameworks.entries()) {
await prisma.tenantFramework.upsert({
where: { tenantId_framework: { tenantId: tenant.id, framework } },
update: { isPrimary: i === 0 },
create: { tenantId: tenant.id, framework, isPrimary: i === 0 },
});
}
// 7. Richtlinienpaket JE FRAMEWORK importieren (falls Seed-Verzeichnis übergeben)
if (opts.seedPoliciesDir) {
// Neue Mandanten erhalten die neueste veröffentlichte DB-Vorlage (Datei-Fallback).
// Anforderungen sind framework-scoped, die geteilten Inhalte werden nur beim ersten
// Framework abgeglichen (reconcileShared, Falle 1.1).
for (const [i, framework] of frameworks.entries()) {
const { pkg } = await resolvePackageForTenant(prisma, tenant.id, opts.seedPoliciesDir, framework);
await reconcilePackage(prisma, tenant.id, pkg, { framework, reconcileShared: i === 0 });
await stampPackageVersion(prisma, tenant.id, pkg.version, framework);
}
await importManaged(prisma, tenant.id);
// AL-/Schutzbedarf-Flags sind TISAX-Konzepte → nur bei TISAX-Mandanten setzen
// (reiner ISO-Mandant: keine AL-Flags, Übergabe D2/§1.3).
if (runsTisax) {
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" } });
}
// WICHTIG (Reihenfolge, Übergabe AP2): die Framework-Sichtbarkeits-Flags NACH dem
// Import setzen. reconcilePackage legt sie beim ersten Import mit dem Schema-Default
// an (TISAX=true, ISO=false) und überschreibt bestehende Werte NIE — würden wir vorher
// setzen, bliebe der Default stehen und ein ISO-Mandant sähe die VDA-ISA-Sicht.
await prisma.policyVariable.updateMany({ where: { tenantId: tenant.id, key: "FLAG_FW_TISAX" }, data: { value: runsTisax ? "true" : "false" } });
await prisma.policyVariable.updateMany({ where: { tenantId: tenant.id, key: "FLAG_FW_ISO27001" }, data: { value: frameworks.includes("ISO_27001") ? "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, frameworks } },
data: { tenantId: tenant.id, scope: "platform", actorId: opts.actorId ?? null, action: "provision", entity: "tenant", entityId: tenant.id, after: { name: opts.name } },
});
return tenant;