import { readFileSync, readdirSync } from "node:fs"; import { join } from "node:path"; import type { PrismaClient } from "@prisma/client"; /** * Importer für das VDA-ISA-2027-Vorlagenpaket (§6 der Spezifikation). * Liest die echten .md-Dateien, mapping.json, variables.schema.json, * Technische-Sicherheits-Baseline.md und Nachweisregister_zentral.md in die * Policy-Tabellen. * * **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]); /** * 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); docs.push({ code, type: "VERFAHREN", title: titleOf(raw), policyCode, fulfills, 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"); docs.push({ code: "BASELINE", type: "REGISTER", title: titleOf(baselineRaw), policyCode: null, fulfills: [], raw: baselineRaw, orderIdx: 900 }); docs.push({ code: "NACHWEIS", type: "REGISTER", title: titleOf(nachweisRaw), policyCode: null, fulfills: [], raw: nachweisRaw, orderIdx: 901 }); // ── Reconcile Dokumente (Upsert über code; Status/Override/Freigabe bleiben) ── const existingDocs = await prisma.policyDocument.findMany({ where: { tenantId } }); const docByCode = new Map(existingDocs.map((d) => [d.code, d])); const desiredDocCodes = new Set(docs.map((d) => d.code)); for (const d of docs) { 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.raw, 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.raw || 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.raw, 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 } }); } // ── 3. Reconcile Anforderungen (Upsert über reqId; entfernte werden deaktiviert) ── const desiredReqs = mapping.anforderungen.map((a) => ({ reqId: a.id, policyCode: a.policy, control: a.control, obligation: a.type, condition: a.condition, requirement: a.requirement, implementation: implMap.get(a.id) ?? a.implementation ?? "", vaCodes: [...(reqToVas.get(a.id) ?? [])].sort(), nachweisLink: a.nachweis_link ?? null, })); const existingReqs = await prisma.policyRequirement.findMany({ where: { tenantId } }); const reqById = new Map(existingReqs.map((r) => [r.reqId, r])); const desiredReqIds = new Set(desiredReqs.map((r) => r.reqId)); for (const r of desiredReqs) { const ex = reqById.get(r.reqId); if (!ex) { report.requirements.added++; if (!dryRun) await prisma.policyRequirement.create({ data: { tenantId, ...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 } }); } // ── 4. Reconcile Variablen + Feature-Flags (Wert bleibt IMMER erhalten) ── const schema = JSON.parse(readFileSync(join(seedDir, "variables.schema.json"), "utf-8")) as { properties: Record; required?: string[]; }; const required = new Set(schema.required ?? []); const desiredVars = 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 }; }); const existingVars = await prisma.policyVariable.findMany({ where: { tenantId } }); const varByKey = new Map(existingVars.map((v) => [v.key, v])); const desiredVarKeys = new Set(desiredVars.map((v) => v.key)); for (const v of desiredVars) { 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; // ── 5. Reconcile Baseline-Parameter (Upsert über blId) ─────────────────── const desiredBaseline: Array<{ blId: string; section: string; name: string; vorgabe: string; orderIdx: number }> = []; { 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) desiredBaseline.push({ blId: row[1], section, name: row[2].trim(), vorgabe: row[3].trim(), orderIdx: bi++ }); } } const existingBaseline = await prisma.policyBaselineParam.findMany({ where: { tenantId } }); const blById = new Map(existingBaseline.map((b) => [b.blId, b])); const desiredBlIds = new Set(desiredBaseline.map((b) => b.blId)); for (const b of desiredBaseline) { 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; // ── 6. Reconcile Nachweisregister (Upsert über nr) ─────────────────────── const desiredEvidence: Array<{ nr: number; policyCode: string; nachweis: string; quelle: string; verantwortlich: string; turnus: string }> = []; 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:([^}]+)\}\}/); desiredEvidence.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(), }); } const existingEvidence = await prisma.policyEvidence.findMany({ where: { tenantId } }); const evByNr = new Map(existingEvidence.map((e) => [e.nr, e])); const desiredNrs = new Set(desiredEvidence.map((e) => e.nr)); for (const e of desiredEvidence) { 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; // Ä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: report as unknown as object, }, }); } return { documents: docs.length, requirements: desiredReqs.length, variables: desiredVars.length, baseline: desiredBaseline.length, report, }; }