import { readFileSync, readdirSync } from "node:fs"; import { join } from "node:path"; import type { PrismaClient, Framework } from "@prisma/client"; /** * Mapping-Datei je Framework (AP1). Beide Frameworks teilen sich denselben * Dokumentensatz (richtlinien/, verfahren/, Baseline, Nachweisregister); nur die * Anforderungs-Zuordnung unterscheidet sich → nur der Mapping-Dateiname wechselt * (Variante A). Default TISAX, damit Bestandsaufrufer unverändert bleiben. */ export function mappingFileFor(framework: Framework = "TISAX"): string { return framework === "ISO_27001" ? "mapping-iso.json" : "mapping.json"; } /** Kanonische Version des Vorlagenpakets (mapping meta.version) — Story B6. */ export function readPackageVersion(seedDir: string, mappingFile = "mapping.json"): string { const mapping = JSON.parse(readFileSync(join(seedDir, mappingFile), "utf-8")) as { meta?: { version?: string } }; return mapping.meta?.version ?? "unknown"; } /** Übernommene Paket-Version je Mandant/Framework stempeln (Story B6) — nach jedem Apply. */ export async function stampPackageState( prisma: PrismaClient, tenantId: string, seedDir: string, framework: Framework = "TISAX", ): Promise { await stampPackageVersion(prisma, tenantId, readPackageVersion(seedDir, mappingFileFor(framework)), framework); } /** Wie stampPackageState, aber mit direkt übergebener Version (DB-Vorlagen-Quelle). */ export async function stampPackageVersion( prisma: PrismaClient, tenantId: string, version: string, framework: Framework = "TISAX", ): Promise { await prisma.policyPackageState.upsert({ where: { tenantId_framework: { tenantId, framework } }, update: { importedVersion: version, importedAt: new Date() }, create: { tenantId, framework, importedVersion: version, importedAt: new Date() }, }); } /** * Importer für das VDA-ISA-2027-Vorlagenpaket (§6 der Spezifikation). * * Das Modul ist in zwei Schritte getrennt: * 1. `parsePackageFiles(seedDir)` — liest die echten .md-Dateien, mapping.json, * variables.schema.json, Technische-Sicherheits-Baseline.md und * Nachweisregister_zentral.md in ein neutrales `ParsedPackage`. * 2. `reconcilePackage(prisma, tenantId, pkg)` — schreibt ein `ParsedPackage` * **nicht-destruktiv** in die Mandanten-Tabellen (Diff/Upsert). * Dieselbe `ParsedPackage`-Struktur kommt auch aus der globalen DB-Vorlagenablage * (template-store.ts), sodass der Reconcile-Import identisch für Datei- UND * DB-Quelle funktioniert. * * **Nicht-destruktiver Re-Import (Phase-1-Härtung Paket 3):** Abgleich über stabile * Schlüssel (Dokument-`code`, Anforderungs-`reqId`, Variablen-`key`, Baseline-`blId`, * Nachweis-`nr`) per Diff/Upsert statt Löschen+Neuanlegen. * - Neu im Paket ⇒ anlegen. * - Weiterhin vorhanden ⇒ Inhalt aktualisieren, aber per-Dokument-Status, * TISAX-Override, Freigabe-/Versionsstand und Verknüpfungen (Coverage/Nachweise) * sowie **nutzergepflegte Variablenwerte** erhalten. * - Nicht mehr im Paket ⇒ **deaktivieren** (`archivedAt` setzen), nicht löschen. * Jeder Lauf liefert einen Änderungsreport (added/updated/archived/reactivated) und * protokolliert ihn im Audit-Log. `dryRun` erzeugt den Report ohne Schreibzugriffe * (Vorschau). Idempotent: erneuter Lauf ohne Änderungen ⇒ nur „unchanged". */ type DocType = "LEITLINIE" | "RICHTLINIE" | "VERFAHREN" | "REGISTER"; const arraysEqual = (a: string[], b: string[]) => a.length === b.length && a.every((v, i) => v === b[i]); // ── Neutrale Paket-Struktur (Quelle: Dateien ODER globale DB-Vorlagenablage) ────── export interface ParsedDoc { code: string; type: DocType; title: string; policyCode: string | null; fulfills: string[]; rawMarkdown: string; orderIdx: number; } export interface ParsedRequirement { reqId: string; policyCode: string; control: string; obligation: string; condition: string | null; requirement: string; implementation: string; vaCodes: string[]; nachweisLink: string | null; } export interface ParsedVariable { key: string; title: string; kind: string; groupName: string | null; value: string; required: boolean; orderIdx: number; } export interface ParsedBaselineParam { blId: string; section: string; name: string; vorgabe: string; orderIdx: number; } export interface ParsedEvidence { nr: number; policyCode: string; nachweis: string; quelle: string; verantwortlich: string; turnus: string; } export interface ParsedPackage { version: string; documents: ParsedDoc[]; requirements: ParsedRequirement[]; variables: ParsedVariable[]; baseline: ParsedBaselineParam[]; evidence: ParsedEvidence[]; } /** * Namensraum der Dokumente, die DIESER Importer verwaltet (Leitlinie, Richtlinien, * Verfahren, Baseline-/Nachweisregister). Verwaltete Register aus import-managed.ts * (CRYPTO, RISKMATRIX, CLASSIFICATION, HANDBUCH, …) gehören NICHT dazu und dürfen beim * Re-Import nicht deaktiviert werden. */ const isPackageDoc = (code: string) => code === "L00" || code === "BASELINE" || code === "NACHWEIS" || /^R\d/.test(code) || /^VA-/.test(code); function docCodeFromFile(file: string): string { return file.split("_")[0]; // "R08_…​.md" → "R08", "VA-03_…​.md" → "VA-03" } function titleOf(md: string): string { const m = md.match(/^#\s+(.+?)\s*$/m); return m ? m[1].trim() : "(ohne Titel)"; } /** Umsetzungstext je Anforderung aus den ``-Ankern extrahieren. */ function extractImpls(raw: string, into: Map) { const lines = raw.split("\n"); for (let i = 0; i < lines.length; i++) { const m = lines[i].match(/^/); if (!m) continue; const buf: string[] = []; for (let j = i + 1; j < lines.length; j++) { const l = lines[j]; const tr = l.trim(); if (tr === "" || tr.startsWith("/); const fulfills = fh ? fh[1].split(",").map((s) => s.trim()).filter(Boolean) : []; const policyCode = fh ? fh[2].trim() : null; for (const req of fulfills) { if (!reqToVas.has(req)) reqToVas.set(req, new Set()); reqToVas.get(req)!.add(code); } const orderIdx = 100 + parseInt(code.replace(/\D/g, ""), 10); documents.push({ code, type: "VERFAHREN", title: titleOf(raw), policyCode, fulfills, rawMarkdown: raw, orderIdx }); } // Register-Dokumente (Baseline + Nachweisregister) mitführen const baselineRaw = readFileSync(join(seedDir, "Technische-Sicherheits-Baseline.md"), "utf-8"); const nachweisRaw = readFileSync(join(seedDir, "Nachweisregister_zentral.md"), "utf-8"); documents.push({ code: "BASELINE", type: "REGISTER", title: titleOf(baselineRaw), policyCode: null, fulfills: [], rawMarkdown: baselineRaw, orderIdx: 900 }); documents.push({ code: "NACHWEIS", type: "REGISTER", title: titleOf(nachweisRaw), policyCode: null, fulfills: [], rawMarkdown: nachweisRaw, orderIdx: 901 }); // ── 3. Anforderungen ───────────────────────────────────────────────────── // Umsetzungstext: bevorzugt über `impl_anchor` (Control-Ebene, z. B. „IMPL 4.1.2") auflösen — // die Anforderungs-IDs (4.1.2-M1) haben keinen eigenen IMPL-Block. Fällt zurück auf die // Anforderungs-ID und zuletzt auf ein im Mapping mitgeliefertes `implementation`-Feld. const implOf = (a: { id: string; impl_anchor?: string; implementation?: string }) => implMap.get((a.impl_anchor ?? "").replace(/^IMPL\s+/, "")) ?? implMap.get(a.id) ?? a.implementation ?? ""; const requirements: ParsedRequirement[] = mapping.anforderungen.map((a) => ({ reqId: a.id, policyCode: a.policy, control: a.control, obligation: a.type, condition: a.condition, requirement: a.requirement, implementation: implOf(a), vaCodes: [...(reqToVas.get(a.id) ?? [])].sort(), nachweisLink: a.nachweis_link ?? null, })); // ── 4. Variablen + Feature-Flags ───────────────────────────────────────── const schema = JSON.parse(readFileSync(join(seedDir, "variables.schema.json"), "utf-8")) as { properties: Record; required?: string[]; }; const required = new Set(schema.required ?? []); const variables: ParsedVariable[] = Object.entries(schema.properties).map(([key, prop], i) => { const kind = prop.type === "boolean" ? "boolean" : "string"; const value = prop.default !== undefined ? String(prop.default) : prop.example !== undefined ? String(prop.example) : kind === "boolean" ? "false" : ""; return { key, title: prop.title ?? key, kind, groupName: varGroup(key), value, required: required.has(key), orderIdx: i }; }); // ── 5. Baseline-Parameter ──────────────────────────────────────────────── const baseline: ParsedBaselineParam[] = []; { let section = ""; let bi = 0; for (const line of baselineRaw.split("\n")) { const h = line.match(/^##\s+(.+?)\s*$/); if (h) { section = h[1].trim(); continue; } const row = line.match(/^\|\s*(BL-[A-Z]+-\d+)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*$/); if (row) baseline.push({ blId: row[1], section, name: row[2].trim(), vorgabe: row[3].trim(), orderIdx: bi++ }); } } // ── 6. Nachweisregister ────────────────────────────────────────────────── const evidence: ParsedEvidence[] = []; for (const line of nachweisRaw.split("\n")) { const row = line.match(/^\|\s*(\d+)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*$/); if (!row) continue; const policyMatch = row[2].match(/\{\{LINK:([^}]+)\}\}/); evidence.push({ nr: parseInt(row[1], 10), policyCode: policyMatch ? policyMatch[1] : row[2].trim(), nachweis: row[3].trim(), quelle: row[4].trim(), verantwortlich: row[5].trim(), turnus: row[6].trim(), }); } return { version, documents, requirements, variables, baseline, evidence }; } /** * Schreibt ein `ParsedPackage` nicht-destruktiv in die Mandanten-Tabellen (Diff/Upsert). * Quelle-neutral: das Paket stammt aus den Dateien (`parsePackageFiles`) oder der globalen * DB-Vorlagenablage (`loadPublishedPackage`). */ export async function reconcilePackage( prisma: PrismaClient, tenantId: string, pkg: ParsedPackage, options: ImportOptions = {} ) { const dryRun = options.dryRun ?? false; const framework: Framework = options.framework ?? "TISAX"; // Geteilte Inhalte (Dokumente/Variablen/Baseline/Nachweise) sind frameworkübergreifend // identisch → nur einmal je Mandant abgleichen (Falle 1.1). Anforderungen dagegen // IMMER, aber framework-scoped. const reconcileShared = options.reconcileShared ?? true; const now = new Date(); const report: ImportReport = { documents: { added: 0, updated: 0, archived: 0, reactivated: 0, unchanged: 0 }, requirements: { added: 0, updated: 0, archived: 0, reactivated: 0, unchanged: 0 }, variables: { added: 0, updated: 0, unchanged: 0, obsolete: 0 }, baseline: { added: 0, updated: 0, unchanged: 0, obsolete: 0 }, evidence: { added: 0, updated: 0, unchanged: 0, obsolete: 0 }, }; // ── Reconcile Dokumente (Upsert über code; Status/Override/Freigabe bleiben) ── // Geteilt → nur beim ersten Framework je Mandant (Falle 1.1). if (reconcileShared) { const existingDocs = await prisma.policyDocument.findMany({ where: { tenantId } }); const docByCode = new Map(existingDocs.map((d) => [d.code, d])); const desiredDocCodes = new Set(pkg.documents.map((d) => d.code)); for (const d of pkg.documents) { const ex = docByCode.get(d.code); if (!ex) { report.documents.added++; if (!dryRun) await prisma.policyDocument.create({ data: { tenantId, code: d.code, type: d.type, title: d.title, version: "1.0", status: "FREIGEGEBEN", policyCode: d.policyCode, fulfills: d.fulfills, rawMarkdown: d.rawMarkdown, orderIdx: d.orderIdx, }, }); continue; } const contentChanged = ex.type !== d.type || ex.title !== d.title || (ex.policyCode ?? null) !== (d.policyCode ?? null) || !arraysEqual(ex.fulfills, d.fulfills) || ex.rawMarkdown !== d.rawMarkdown || ex.orderIdx !== d.orderIdx; // Nur Inhaltsfelder + Lifecycle; Status/Version/Owner/Freigabe/Override NICHT anfassen. const contentData = { type: d.type, title: d.title, policyCode: d.policyCode, fulfills: d.fulfills, rawMarkdown: d.rawMarkdown, orderIdx: d.orderIdx }; if (ex.archivedAt) { report.documents.reactivated++; if (!dryRun) await prisma.policyDocument.update({ where: { id: ex.id }, data: { ...contentData, archivedAt: null } }); } else if (contentChanged) { report.documents.updated++; if (!dryRun) await prisma.policyDocument.update({ where: { id: ex.id }, data: contentData }); } else { report.documents.unchanged++; } } for (const ex of existingDocs) { // Nur Paket-Dokumente deaktivieren — verwaltete Register (import-managed) unberührt lassen. if (desiredDocCodes.has(ex.code) || ex.archivedAt || !isPackageDoc(ex.code)) continue; report.documents.archived++; if (!dryRun) await prisma.policyDocument.update({ where: { id: ex.id }, data: { archivedAt: now } }); } } // Ende reconcileShared (Dokumente) // ── Reconcile Anforderungen (Upsert über reqId; entfernte deaktiviert) — FRAMEWORK-SCOPED ── // Nur Anforderungen DIESES Frameworks lesen/anlegen/archivieren, damit ein ISO-Import // die TISAX-Anforderungen (und umgekehrt) nicht stilllegt (Falle 1.1). const existingReqs = await prisma.policyRequirement.findMany({ where: { tenantId, framework } }); const reqById = new Map(existingReqs.map((r) => [r.reqId, r])); const desiredReqIds = new Set(pkg.requirements.map((r) => r.reqId)); for (const r of pkg.requirements) { const ex = reqById.get(r.reqId); if (!ex) { report.requirements.added++; if (!dryRun) await prisma.policyRequirement.create({ data: { tenantId, framework, ...r } }); continue; } const changed = ex.policyCode !== r.policyCode || ex.control !== r.control || ex.obligation !== r.obligation || (ex.condition ?? null) !== (r.condition ?? null) || ex.requirement !== r.requirement || ex.implementation !== r.implementation || !arraysEqual(ex.vaCodes, r.vaCodes) || (ex.nachweisLink ?? null) !== (r.nachweisLink ?? null); if (ex.archivedAt) { report.requirements.reactivated++; if (!dryRun) await prisma.policyRequirement.update({ where: { id: ex.id }, data: { ...r, archivedAt: null } }); } else if (changed) { report.requirements.updated++; if (!dryRun) await prisma.policyRequirement.update({ where: { id: ex.id }, data: r }); } else { report.requirements.unchanged++; } } for (const ex of existingReqs) { if (desiredReqIds.has(ex.reqId) || ex.archivedAt) continue; report.requirements.archived++; if (!dryRun) await prisma.policyRequirement.update({ where: { id: ex.id }, data: { archivedAt: now } }); } // Geteilte Inhalte (Variablen/Baseline/Nachweise) wieder nur beim ersten Framework. if (reconcileShared) { // ── Reconcile Variablen + Feature-Flags (Wert bleibt IMMER erhalten) ── const existingVars = await prisma.policyVariable.findMany({ where: { tenantId } }); const varByKey = new Map(existingVars.map((v) => [v.key, v])); const desiredVarKeys = new Set(pkg.variables.map((v) => v.key)); for (const v of pkg.variables) { const ex = varByKey.get(v.key); if (!ex) { report.variables.added++; if (!dryRun) await prisma.policyVariable.create({ data: { tenantId, ...v } }); continue; } // Nur Metadaten aktualisieren — den (ggf. vom Kunden gepflegten) value NIE überschreiben. const metaChanged = ex.title !== v.title || ex.kind !== v.kind || (ex.groupName ?? null) !== (v.groupName ?? null) || ex.required !== v.required || ex.orderIdx !== v.orderIdx; if (metaChanged) { report.variables.updated++; if (!dryRun) await prisma.policyVariable.update({ where: { id: ex.id }, data: { title: v.title, kind: v.kind, groupName: v.groupName, required: v.required, orderIdx: v.orderIdx } }); } else { report.variables.unchanged++; } } // Nicht mehr im Schema enthaltene Variablen bleiben bestehen (können in eigenem // Rohtext referenziert sein) — nur als „obsolet" im Report ausgewiesen. report.variables.obsolete = existingVars.filter((v) => !desiredVarKeys.has(v.key)).length; // ── Reconcile Baseline-Parameter (Upsert über blId) ─────────────────── const existingBaseline = await prisma.policyBaselineParam.findMany({ where: { tenantId } }); const blById = new Map(existingBaseline.map((b) => [b.blId, b])); const desiredBlIds = new Set(pkg.baseline.map((b) => b.blId)); for (const b of pkg.baseline) { const ex = blById.get(b.blId); if (!ex) { report.baseline.added++; if (!dryRun) await prisma.policyBaselineParam.create({ data: { tenantId, ...b } }); continue; } const changed = ex.section !== b.section || ex.name !== b.name || ex.vorgabe !== b.vorgabe || ex.orderIdx !== b.orderIdx; if (changed) { report.baseline.updated++; if (!dryRun) await prisma.policyBaselineParam.update({ where: { id: ex.id }, data: { section: b.section, name: b.name, vorgabe: b.vorgabe, orderIdx: b.orderIdx } }); } else { report.baseline.unchanged++; } } report.baseline.obsolete = existingBaseline.filter((b) => !desiredBlIds.has(b.blId)).length; // ── Reconcile Nachweisregister (Upsert über nr) ─────────────────────── const existingEvidence = await prisma.policyEvidence.findMany({ where: { tenantId } }); const evByNr = new Map(existingEvidence.map((e) => [e.nr, e])); const desiredNrs = new Set(pkg.evidence.map((e) => e.nr)); for (const e of pkg.evidence) { const ex = evByNr.get(e.nr); if (!ex) { report.evidence.added++; if (!dryRun) await prisma.policyEvidence.create({ data: { tenantId, ...e } }); continue; } const changed = ex.policyCode !== e.policyCode || ex.nachweis !== e.nachweis || ex.quelle !== e.quelle || ex.verantwortlich !== e.verantwortlich || ex.turnus !== e.turnus; if (changed) { report.evidence.updated++; if (!dryRun) await prisma.policyEvidence.update({ where: { id: ex.id }, data: { policyCode: e.policyCode, nachweis: e.nachweis, quelle: e.quelle, verantwortlich: e.verantwortlich, turnus: e.turnus } }); } else { report.evidence.unchanged++; } } report.evidence.obsolete = existingEvidence.filter((e) => !desiredNrs.has(e.nr)).length; } // Ende reconcileShared (Variablen/Baseline/Nachweise) // Änderungsprotokoll ins Audit-Log (nur bei echtem Lauf). if (!dryRun) { await prisma.auditLog.create({ data: { tenantId, scope: "tenant", actorId: options.actorId ?? null, action: "import", entity: "policy_package", after: { framework, ...report } as unknown as object, }, }); } return { documents: pkg.documents.length, requirements: pkg.requirements.length, variables: pkg.variables.length, baseline: pkg.baseline.length, report, }; } /** * Rückwärtskompatibler Datei-Import: parst das Datei-Paket und reconciled es in den * Mandanten. Wird als Fallback genutzt, solange keine veröffentlichte DB-Vorlage vorliegt. */ export async function importPolicies( prisma: PrismaClient, tenantId: string, seedDir: string, options: ImportOptions = {} ) { // Datei-Quelle für das Framework des Imports (Default TISAX = mapping.json). const pkg = parsePackageFiles(seedDir, mappingFileFor(options.framework)); return reconcilePackage(prisma, tenantId, pkg, options); }