diff --git a/prisma/import-managed.ts b/prisma/import-managed.ts index 40648cf..84b4103 100644 --- a/prisma/import-managed.ts +++ b/prisma/import-managed.ts @@ -100,6 +100,34 @@ const HANDBOOK: { category: string; title: string; bodyMd: string; sourceRefs: s }, ]; +/** Generische verwaltete Register (WP3.0): Code, Titel, Pflichtspalten, Cross-Links. */ +const GENERIC_REGISTERS: { + code: string; title: string; description: string; orderIdx: number; + columns: { key: string; label: string }[]; supplierLink?: boolean; assetLink?: boolean; +}[] = [ + { code: "REG-PROJECTS", title: "Register Informationssicherheit in Projekten", orderIdx: 905, + description: "Projekte mit IS-Klassifizierung, Risikobewertung, Maßnahmen und ISB-Einbindung (R01 / VA-19).", + columns: [{ key: "projekt", label: "Projekt" }, { key: "klassifizierung", label: "IS-Klassifizierung" }, { key: "risiko", label: "Risikobewertung" }, { key: "massnahmen", label: "Maßnahmen/Status" }, { key: "isb", label: "ISB-Einbindung" }] }, + { code: "REG-SENS-ROLES", title: "Register sensibler Tätigkeiten", orderIdx: 906, + description: "Sensible Tätigkeitsbereiche/Rollen mit Eignungsnachweisen und Prüftiefe (R05 / VA-14).", + columns: [{ key: "rolle", label: "Rolle/Bereich" }, { key: "nachweise", label: "Geforderte Eignungsnachweise" }, { key: "prueftiefe", label: "Prüftiefe" }, { key: "verantwortlich", label: "Verantwortlich" }, { key: "review", label: "Review" }] }, + { code: "REG-AUDIT-PLAN", title: "Audit-Programm-Register", orderIdx: 907, + description: "Auditprogramm mit Zeitplan, Umfang, geprüften Controls, Prüfern und Ergebnissen (R03 / VA-15).", + columns: [{ key: "zeitplan", label: "Zeitplan" }, { key: "umfang", label: "Umfang" }, { key: "controls", label: "Geprüfte Controls" }, { key: "pruefer", label: "Prüfer" }, { key: "ergebnisse", label: "Ergebnisse" }] }, + { code: "REG-EXT-SERVICES", title: "Register externe IT-/Cloud-/KI-Dienste", orderIdx: 908, + description: "Externe/Cloud-/KI-Dienste mit Schutzbedarf, Datenlokation, Verschlüsselung, Exit und Freigabe (R13/VA-10 + R02/VA-08).", + columns: [{ key: "dienst", label: "Dienst" }, { key: "schutzbedarf", label: "Schutzbedarf" }, { key: "datenlokation", label: "Datenlokation (EU)" }, { key: "verschluesselung", label: "Verschlüsselung" }, { key: "exit", label: "Exit-Strategie" }, { key: "freigabe", label: "Freigabestatus" }, { key: "freigeber", label: "Freigeber" }], supplierLink: true, assetLink: true }, + { code: "REG-SW-WHITELIST", title: "Register Software-Whitelist", orderIdx: 909, + description: "Zugelassene Software mit Version/Patch-Stand, Quelle/Lieferant, Freigabestatus und Review (R02 1.3.4).", + columns: [{ key: "software", label: "Software" }, { key: "version", label: "Version/Patch-Stand" }, { key: "freigabe", label: "Freigabestatus" }, { key: "freigeber", label: "Freigeber" }, { key: "review", label: "Review" }], supplierLink: true, assetLink: true }, + { code: "REG-CRIT-SERVICES", title: "Register kritischer IT-Dienste", orderIdx: 910, + description: "Kritische IT-Dienste mit BIA-Einstufung, RTO/RPO, Abhängigkeiten und Wiederanlaufreihenfolge (R04 5.2.8 / VA-02).", + columns: [{ key: "dienst", label: "Kritischer IT-Dienst" }, { key: "bia", label: "BIA-Einstufung" }, { key: "rto", label: "RTO" }, { key: "rpo", label: "RPO" }, { key: "abhaengigkeiten", label: "Abhängigkeiten" }, { key: "wiederanlauf", label: "Wiederanlaufreihenfolge" }], assetLink: true }, + { code: "REG-NET", title: "Netz-/Netzdienste-Register", orderIdx: 911, + description: "Netzplan/Segmentierung, Netzdienste, Zonen und Review-Turnus (R09 5.1.2 / R10 5.2.7 / VA-07).", + columns: [{ key: "bezeichnung", label: "Netzplan/Segment" }, { key: "netzdienst", label: "Netzdienst" }, { key: "zone", label: "Zone" }, { key: "review", label: "Review" }] }, +]; + export async function importManaged(prisma: PrismaClient, tenantId: string) { // Idempotent: alte Datensätze pro Mandant entfernen await prisma.handlingRule.deleteMany({ where: { tenantId } }); @@ -117,12 +145,6 @@ export async function importManaged(prisma: PrismaClient, tenantId: string) { { code: "RISKMATRIX", type: "REGISTER" as const, title: "Risiko-Bewertungsmatrix", orderIdx: 903, intro: "Zentrale Bewertungsmatrix Schadensausmaß × Eintrittswahrscheinlichkeit (R03 / VA-09), Defaults nach FB-80-04." }, { code: "CLASSIFICATION", type: "REGISTER" as const, title: "Klassifizierung & Handhabungsmatrix", orderIdx: 904, intro: "Schutzklassen und Handhabungsregeln (R02 / VA-08), Defaults nach AA-80-20." }, { code: "HANDBUCH", type: "HANDBUCH" as const, title: "Anwender-Handbuch Informationssicherheit", orderIdx: 950, intro: "Kompakte Zusammenfassung der wichtigsten Regeln für Mitarbeitende – mit Deep-Links in die Quelldokumente." }, - // Verwaltete Register (GAP-Report §5.2). Vorerst als benannte Register-Dokumente mit - // dokumentierten Pflichtspalten; die editierbare Datenpflege (Attribute/Cross-Links) folgt (WP3.0). - { code: "REG-PROJECTS", type: "REGISTER" as const, title: "Register Informationssicherheit in Projekten", orderIdx: 905, intro: "Projekte mit IS-Klassifizierung, Risikobewertung, abgeleiteten Maßnahmen/Status und ISB-Einbindung (R01 / VA-19). Pflichtspalten: Projekt, IS-Klassifizierung, Risikobewertung, Maßnahmen/Status, ISB-Einbindung." }, - { code: "REG-SENS-ROLES", type: "REGISTER" as const, title: "Register sensibler Tätigkeiten", orderIdx: 906, intro: "Sensible Tätigkeitsbereiche/Rollen mit geforderten Eignungsnachweisen und Prüftiefe (R05 / VA-14). Pflichtspalten: Rolle/Bereich, geforderte Eignungsnachweise, Prüftiefe, Verantwortlich, Review." }, - { code: "REG-AUDIT-PLAN", type: "REGISTER" as const, title: "Audit-Programm-Register", orderIdx: 907, intro: "Auditprogramm mit Zeitplan, Umfang, geprüften Controls, Prüfern und Ergebnissen (R03 / VA-15). Pflichtspalten: Zeitplan, Umfang, geprüfte Controls, Prüfer, Ergebnisse." }, - { code: "REG-EXT-SERVICES", type: "REGISTER" as const, title: "Register externe IT-/Cloud-/KI-Dienste", orderIdx: 908, intro: "Externe/Cloud-/KI-Dienste mit Schutzbedarf, Datenlokation (EU), Verschlüsselung, Exit-Strategie, Quelle/Lieferant und Freigabestatus (R13/VA-10 + R02/VA-08). Cross-Link an Lieferantensteuerung und Asset-Inventar." }, ]; for (const d of registerDocs) { await prisma.policyDocument.upsert({ @@ -132,6 +154,23 @@ export async function importManaged(prisma: PrismaClient, tenantId: string) { }); } + // Generische verwaltete Register (GAP-Report WP3.0 / §5.2): Dokument (Bibliothek/Link) + // + editierbare ManagedRegister-Definition mit Pflichtspalten + Cross-Links. Zeilen + // (RegisterRow) bleiben bei Re-Import erhalten (nicht-destruktiv). + for (const g of GENERIC_REGISTERS) { + const intro = `${g.description} Pflichtspalten: ${g.columns.map((c) => c.label).join(", ")}.`; + await prisma.policyDocument.upsert({ + where: { tenantId_code: { tenantId, code: g.code } }, + update: { title: g.title, type: "REGISTER", orderIdx: g.orderIdx, rawMarkdown: `# ${g.title}\n\n${intro}` }, + create: { tenantId, code: g.code, type: "REGISTER", title: g.title, version: "1.0", status: "FREIGEGEBEN", orderIdx: g.orderIdx, rawMarkdown: `# ${g.title}\n\n${intro}` }, + }); + await prisma.managedRegister.upsert({ + where: { tenantId_code: { tenantId, code: g.code } }, + update: { title: g.title, description: g.description, columns: g.columns, supplierLink: g.supplierLink ?? false, assetLink: g.assetLink ?? false }, + create: { tenantId, code: g.code, title: g.title, description: g.description, columns: g.columns, supplierLink: g.supplierLink ?? false, assetLink: g.assetLink ?? false }, + }); + } + // Krypto-Register for (let i = 0; i < CRYPTO.length; i++) { const c = CRYPTO[i]; diff --git a/prisma/migrations/20260723090004_managed_registers/migration.sql b/prisma/migrations/20260723090004_managed_registers/migration.sql new file mode 100644 index 0000000..83e6078 --- /dev/null +++ b/prisma/migrations/20260723090004_managed_registers/migration.sql @@ -0,0 +1,55 @@ +-- CreateTable +CREATE TABLE "managed_registers" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "columns" JSONB NOT NULL DEFAULT '[]', + "review_cycle" TEXT, + "responsible" TEXT, + "supplier_link" BOOLEAN NOT NULL DEFAULT false, + "asset_link" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "managed_registers_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "register_rows" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "register_id" TEXT NOT NULL, + "values" JSONB NOT NULL DEFAULT '{}', + "supplier_ref" TEXT, + "asset_ref" TEXT, + "order_idx" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "register_rows_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "managed_registers_tenant_id_idx" ON "managed_registers"("tenant_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "managed_registers_tenant_id_code_key" ON "managed_registers"("tenant_id", "code"); + +-- CreateIndex +CREATE INDEX "register_rows_tenant_id_register_id_idx" ON "register_rows"("tenant_id", "register_id"); + +-- AddForeignKey +ALTER TABLE "register_rows" ADD CONSTRAINT "register_rows_register_id_fkey" FOREIGN KEY ("register_id") REFERENCES "managed_registers"("id") ON DELETE CASCADE ON UPDATE CASCADE; + + +-- RLS für die neuen mandantenbezogenen Tabellen. +DO $$ +DECLARE t text; +BEGIN + FOREACH t IN ARRAY ARRAY['managed_registers','register_rows'] LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t); + EXECUTE format('CREATE POLICY tenant_isolation ON %I USING (tenant_id = current_setting(''app.tenant_id'', true))', t); + END LOOP; +END $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 11d748f..7c653c4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1029,3 +1029,44 @@ model HandbookTopic { @@index([tenantId]) @@map("handbook_topics") } + +// Generisches verwaltetes Register (GAP-Report WP3.0): benannte Register mit +// Register-ID (REG-…), definierten Pflichtspalten, Review-Turnus und optionalen +// Cross-Links an Lieferanten (R13/VA-10) und Asset-Inventar (R02/VA-08). +model ManagedRegister { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + code String // REG-PROJECTS, REG-SW-WHITELIST, … + title String + description String? + columns Json @default("[]") // [{ key, label }] Pflichtspalten + reviewCycle String? @map("review_cycle") + responsible String? + supplierLink Boolean @default(false) @map("supplier_link") // Cross-Link Lieferant (Asset) + assetLink Boolean @default(false) @map("asset_link") // Cross-Link Asset + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + rows RegisterRow[] + + @@unique([tenantId, code]) + @@index([tenantId]) + @@map("managed_registers") +} + +model RegisterRow { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + registerId String @map("register_id") + values Json @default("{}") // { spaltenKey: wert } + supplierRef String? @map("supplier_ref") // Asset-ID (Lieferant) + assetRef String? @map("asset_ref") // Asset-ID + orderIdx Int @default(0) @map("order_idx") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + register ManagedRegister @relation(fields: [registerId], references: [id], onDelete: Cascade) + + @@index([tenantId, registerId]) + @@map("register_rows") +} diff --git a/scripts/check-module-guards.ts b/scripts/check-module-guards.ts index 4ab0db0..fa1f534 100644 --- a/scripts/check-module-guards.ts +++ b/scripts/check-module-guards.ts @@ -30,6 +30,7 @@ const ACTION_MODULE: Record = { "suppliers.ts": "suppliers", "services.ts": "suppliers", "policies.ts": "policies", + "register.ts": "policies", // Plattform-Betrieb (eigene Auth) und Kunden-Einstellungen (tenant:manage) sind // keine per TenantModule gegateten Fachmodule — eigene Autorisierung, kein moduleGuard. "admin.ts": "EXEMPT", diff --git a/src/app/(app)/policies/[code]/page.tsx b/src/app/(app)/policies/[code]/page.tsx index 8d54b8f..ee73ef7 100644 --- a/src/app/(app)/policies/[code]/page.tsx +++ b/src/app/(app)/policies/[code]/page.tsx @@ -14,6 +14,7 @@ import { POLICY_TYPE_LABEL, } from "@/components/policy-modals"; import { PolicyRegisterView } from "@/components/policy-registers"; +import { GenericRegisterView, type RegisterColumn } from "@/components/generic-register"; const REGISTER_CODES = new Set(["CRYPTO", "RISKMATRIX", "CLASSIFICATION", "HANDBUCH"]); // Aus Vorlagen-Markdown gerenderte Dokumente (bearbeitbar über den Editor) @@ -35,9 +36,31 @@ export default async function PolicyDetailPage({ const canWrite = hasPermission(session, "policy:write"); const isRegister = REGISTER_CODES.has(doc.code); + // Generisches verwaltetes Register (WP3.0): editierbare Zeilen nach Pflichtspalten. + const genericReg = doc.type === "REGISTER" && !isRegister + ? await db.managedRegister.findFirst({ where: { code: doc.code } }) + : null; let content: React.ReactNode; - if (isRegister) { + if (genericReg) { + const [rows, allAssets] = await Promise.all([ + db.registerRow.findMany({ where: { registerId: genericReg.id }, orderBy: { orderIdx: "asc" } }), + db.asset.findMany({ select: { id: true, name: true, type: true }, orderBy: { name: "asc" } }), + ]); + content = ( + ({ id: r.id, values: (r.values as Record) ?? {}, supplierRef: r.supplierRef, assetRef: r.assetRef }))} + canWrite={canWrite} + suppliers={allAssets.filter((a) => a.type === "SUPPLIER" || a.type === "IT_SERVICE").map((a) => ({ id: a.id, name: a.name }))} + assets={allAssets.map((a) => ({ id: a.id, name: a.name }))} + /> + ); + } else if (isRegister) { const data = doc.code === "CRYPTO" ? { crypto: await db.cryptoEntry.findMany({ orderBy: { orderIdx: "asc" } }) } @@ -81,7 +104,7 @@ export default async function PolicyDetailPage({

{POLICY_TYPE_LABEL[doc.type]} · v{doc.version} - {isRegister && canWrite ? ` · ${t("editableInline")}` : ""} + {(isRegister || genericReg) && canWrite ? ` · ${t("editableInline")}` : ""}

{showEdit && ( diff --git a/src/components/generic-register.tsx b/src/components/generic-register.tsx new file mode 100644 index 0000000..c629853 --- /dev/null +++ b/src/components/generic-register.tsx @@ -0,0 +1,88 @@ +import { Plus, Trash2 } from "lucide-react"; +import { addRegisterRow, updateRegisterRow, deleteRegisterRow } from "@/server/actions/register"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +/** + * Generischer Editor für verwaltete Register (WP3.0): rendert die Pflichtspalten + * als editierbare Zeilen inkl. optionaler Cross-Links (Lieferant/Asset). + */ +export interface RegisterColumn { key: string; label: string } +export interface RegisterInfo { + code: string; title: string; description: string | null; + columns: RegisterColumn[]; supplierLink: boolean; assetLink: boolean; +} +export interface RegisterRowData { id: string; values: Record; supplierRef: string | null; assetRef: string | null } +export interface AssetOption { id: string; name: string } + +const cell = "h-8 text-[12.5px]"; + +function RefSelect({ name, value, options, label }: { name: string; value: string | null; options: AssetOption[]; label: string }) { + return ( +
+ + +
+ ); +} + +function Fields({ register, row, suppliers, assets, disabled }: { + register: RegisterInfo; row?: RegisterRowData; suppliers: AssetOption[]; assets: AssetOption[]; disabled?: boolean; +}) { + return ( + <> + {register.columns.map((c) => ( +
+ + +
+ ))} + {register.supplierLink && } + {register.assetLink && } + + ); +} + +export function GenericRegisterView({ register, rows, canWrite, suppliers, assets }: { + register: RegisterInfo; rows: RegisterRowData[]; canWrite: boolean; suppliers: AssetOption[]; assets: AssetOption[]; +}) { + const gridCols = register.columns.length + (register.supplierLink ? 1 : 0) + (register.assetLink ? 1 : 0); + const gridStyle = { gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))` }; + return ( +
+ {register.description &&

{register.description}

} + +
+ {rows.map((r) => ( +
+
+
+ + {canWrite &&
} + + {canWrite && ( +
+ +
+ )} +
+
+ ))} + {rows.length === 0 &&

Noch keine Einträge in diesem Register.

} +
+ + {canWrite && ( +
+

Eintrag hinzufügen

+
+ +
+ +
+ )} +
+ ); +} diff --git a/src/server/actions/register.ts b/src/server/actions/register.ts new file mode 100644 index 0000000..13d4b7c --- /dev/null +++ b/src/server/actions/register.ts @@ -0,0 +1,69 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { moduleGuard } from "@/server/action-guard"; +import { writeAuditLog } from "@/server/audit"; + +/** + * Editierbare verwaltete Register (WP3.0). Zeilen werden gegen die Pflichtspalten + * der ManagedRegister-Definition gepflegt; optionale Cross-Links an Lieferant/Asset. + * Modul „policies", Recht policy:write. + */ +const guard = moduleGuard("policies"); + +interface Column { key: string; label: string } + +function readValues(columns: Column[], formData: FormData): Record { + const values: Record = {}; + for (const c of columns) values[c.key] = String(formData.get(`col_${c.key}`) ?? "").trim(); + return values; +} +const optRef = (v: FormDataEntryValue | null) => { + const s = v ? String(v).trim() : ""; + return s || null; +}; + +export async function addRegisterRow(code: string, formData: FormData) { + const { session, db } = await guard("policy:write"); + const reg = await db.managedRegister.findFirst({ where: { code } }); + if (!reg) throw new Error("Register nicht gefunden"); + const columns = reg.columns as unknown as Column[]; + const last = await db.registerRow.aggregate({ where: { registerId: reg.id }, _max: { orderIdx: true } }); + await db.registerRow.create({ + data: { + tenantId: session.user.tenantId, registerId: reg.id, + values: readValues(columns, formData), + supplierRef: reg.supplierLink ? optRef(formData.get("supplierRef")) : null, + assetRef: reg.assetLink ? optRef(formData.get("assetRef")) : null, + orderIdx: (last._max.orderIdx ?? 0) + 1, + }, + }); + await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "register_row", entityId: reg.code }); + revalidatePath(`/policies/${code}`); +} + +export async function updateRegisterRow(rowId: string, formData: FormData) { + const { session, db } = await guard("policy:write"); + const row = await db.registerRow.findUnique({ where: { id: rowId }, include: { register: true } }); + if (!row) throw new Error("Zeile nicht gefunden"); + const columns = row.register.columns as unknown as Column[]; + await db.registerRow.update({ + where: { id: rowId }, + data: { + values: readValues(columns, formData), + supplierRef: row.register.supplierLink ? optRef(formData.get("supplierRef")) : row.supplierRef, + assetRef: row.register.assetLink ? optRef(formData.get("assetRef")) : row.assetRef, + }, + }); + await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "register_row", entityId: rowId }); + revalidatePath(`/policies/${row.register.code}`); +} + +export async function deleteRegisterRow(rowId: string) { + const { session, db } = await guard("policy:write"); + const row = await db.registerRow.findUnique({ where: { id: rowId }, include: { register: { select: { code: true } } } }); + if (!row) return; + await db.registerRow.delete({ where: { id: rowId } }); + await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "register_row", entityId: rowId }); + revalidatePath(`/policies/${row.register.code}`); +} diff --git a/src/server/db.ts b/src/server/db.ts index f6b3552..e5d89aa 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -30,6 +30,8 @@ const TENANT_MODELS = new Set([ "AuditLog", "Task", "TaskComment", + "ManagedRegister", + "RegisterRow", "TenantSettings", "TenantModule", "Asset",