Lieferantenmodul neu: Asset-basiert + Anforderungs-Engine (Phase 1)

Referenz: docs/ISMS-Lieferantenmanagement-GEFIM.html

Architektur: Lieferanten (und IT-Services) sind jetzt Assets.
- AssetType += IT_SERVICE; SupplierProfile/ITServiceProfile als 1:1-
  Erweiterung eines Asset; alle Kind-Entitäten (Contract, Nda,
  SupplierEvidence, SupplierAssessment, ManagementDecision, Subcontractor,
  ServiceControlResponsibility, MaturityAssessment) hängen am Asset
  (keine parallele Datenhaltung). Migration + RLS; Demo neu geseedet.
- Lieferanten erscheinen im Asset-Inventar (Filter „Lieferant"/„IT-Service"),
  tragen C/I/A, nutzbar in Graph/BIA/Risiko.

Anforderungs-Engine (Herzstück, src/lib/supplier.ts):
- Schutzbedarf aus max C/I/A der verknüpften Assets → Stufe normal/hoch/
  sehr hoch; schaltet Anforderungen gestuft scharf (must/should/high/veryhigh)
- Flache Anforderungsliste (nach Themen gruppiert) mit Status-Ableitung aus
  den hinterlegten Objekten, Referenz + Herkunfts-Control (6.1.1–6.1.3)
- Gate-Logik: sehr hoch ohne Audit/Label → Kompensation (Management-
  entscheidung UND verknüpftes Risiko); „Risiko anlegen" erzeugt echten Risk
- Konformität (Konform/Teilweise/Lücken/Handlung nötig) + berechneter
  Reifegrad; ISB-Freigabe (Abweichung nur mit Begründung → Audit-Log)
- Cockpit-Detail mit Schutzbedarf-Banner + Live-Simulation der Stufe

Verifiziert: Register mit abgeleiteter Stufe/Reifegrad/Konformität,
Cockpit-Simulation (Tiers werden inaktiv), Gate bei sehr hoch ohne Audit,
„Risiko anlegen" erzeugt R-005 im Risikomodul.

Offen (Phase 2): Fragebogen-Builder, Self-Service-Portal, IT-Service-
Detail mit RACI-Matrix, Klick im Asset-Inventar öffnet Cockpit,
Scheduler-Automatisierung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Martin
2026-07-03 11:57:17 +02:00
co-authored by Claude Opus 4.8
parent 32ab91efbf
commit dc884062b2
17 changed files with 1605 additions and 972 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ const level = z.coerce.number().int().min(1).max(4);
const assetSchema = z.object({
name: z.string().trim().min(1).max(200),
description: z.string().trim().max(5000).optional(),
type: z.enum(["INFORMATION", "SYSTEM", "APPLICATION", "LOCATION", "SUPPLIER", "PERSON", "DATA"]),
type: z.enum(["INFORMATION", "SYSTEM", "APPLICATION", "LOCATION", "SUPPLIER", "IT_SERVICE", "PERSON", "DATA"]),
status: z.enum(["ACTIVE", "PLANNED", "RETIRED"]),
ownerId: z.string().optional(),
location: z.string().trim().max(200).optional(),
+136 -165
View File
@@ -13,51 +13,55 @@ const level = z.coerce.number().int().min(1).max(4);
const supplierSchema = z.object({
name: z.string().trim().min(1).max(200),
sector: z.string().trim().max(200).optional(),
services: z.string().trim().max(2000).optional(),
serviceDesc: z.string().trim().max(2000).optional(),
criticality: level,
dataCategories: z.string().optional(),
confidentiality: level,
integrity: level,
availability: level,
nis2Relevant: z.union([z.literal("on"), z.literal(null), z.string()]).optional(),
status: z.enum(["ACTIVE", "ONBOARDING", "UNDER_REVIEW", "OFFBOARDED"]),
nis2Relevant: z.string().optional(),
lifecycle: z.enum(["ACTIVE", "ONBOARDING", "UNDER_REVIEW", "OFFBOARDED"]),
contact: z.string().trim().max(200).optional(),
nextReview: z.string().optional(),
notes: z.string().trim().max(5000).optional(),
});
function parseSupplier(formData: FormData) {
function parse(formData: FormData) {
const p = supplierSchema.parse({
name: formData.get("name"),
sector: formData.get("sector") || undefined,
services: formData.get("services") || undefined,
serviceDesc: formData.get("serviceDesc") || undefined,
criticality: formData.get("criticality"),
dataCategories: formData.get("dataCategories") || undefined,
confidentiality: formData.get("confidentiality"),
integrity: formData.get("integrity"),
availability: formData.get("availability"),
nis2Relevant: formData.get("nis2Relevant"),
status: formData.get("status"),
nis2Relevant: formData.get("nis2Relevant") || undefined,
lifecycle: formData.get("lifecycle"),
contact: formData.get("contact") || undefined,
nextReview: formData.get("nextReview") || undefined,
notes: formData.get("notes") || undefined,
});
return {
name: p.name,
sector: p.sector || null,
services: p.services || null,
criticality: p.criticality,
dataCategories: p.dataCategories
? p.dataCategories.split(",").map((s) => s.trim()).filter(Boolean)
: [],
confidentiality: p.confidentiality,
integrity: p.integrity,
availability: p.availability,
nis2Relevant: p.nis2Relevant === "on",
status: p.status,
contact: p.contact || null,
nextReview: p.nextReview ? new Date(p.nextReview) : null,
notes: p.notes || null,
asset: {
name: p.name,
confidentiality: p.confidentiality,
integrity: p.integrity,
availability: p.availability,
},
profile: {
sector: p.sector || null,
serviceDesc: p.serviceDesc || null,
criticality: p.criticality,
dataCategories: p.dataCategories
? p.dataCategories.split(",").map((s) => s.trim()).filter(Boolean)
: [],
nis2Relevant: p.nis2Relevant === "on",
lifecycle: p.lifecycle,
contact: p.contact || null,
nextReview: p.nextReview ? new Date(p.nextReview) : null,
notes: p.notes || null,
},
};
}
@@ -65,216 +69,158 @@ export async function createSupplier(formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = dbForTenant(session.user.tenantId);
const { asset, profile } = parse(formData);
const data = parseSupplier(formData);
const last = await prisma.supplier.aggregate({
const created = await db.asset.create({
data: {
...asset,
type: "SUPPLIER",
tenantId: session.user.tenantId,
createdBy: session.user.id,
},
});
const last = await prisma.supplierProfile.aggregate({
where: { tenantId: session.user.tenantId },
_max: { refNo: true },
});
const supplier = await db.supplier.create({
await db.supplierProfile.create({
data: {
...data,
...profile,
assetId: created.id,
refNo: (last._max.refNo ?? 0) + 1,
tenantId: session.user.tenantId,
createdBy: session.user.id,
},
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "create",
entity: "supplier",
entityId: supplier.id,
after: data,
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "supplier", entityId: created.id, after: { ...asset, ...profile } });
revalidatePath("/suppliers");
redirect(`/suppliers?edit=${supplier.id}`);
revalidatePath("/assets");
redirect(`/suppliers?edit=${created.id}`);
}
export async function updateSupplier(supplierId: string, formData: FormData) {
export async function updateSupplier(assetId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = dbForTenant(session.user.tenantId);
const before = await db.supplier.findUnique({ where: { id: supplierId } });
const before = await db.asset.findUnique({ where: { id: assetId }, include: { supplierProfile: true } });
if (!before) throw new Error("Lieferant nicht gefunden");
const data = parseSupplier(formData);
await db.supplier.update({ where: { id: supplierId }, data });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "supplier",
entityId: supplierId,
before,
after: data,
});
const { asset, profile } = parse(formData);
await db.asset.update({ where: { id: assetId }, data: asset });
await db.supplierProfile.update({ where: { assetId }, data: profile });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "supplier", entityId: assetId, before, after: { ...asset, ...profile } });
revalidatePath("/suppliers");
redirect(`/suppliers?detail=${supplierId}`);
revalidatePath("/assets");
redirect(`/suppliers?detail=${assetId}`);
}
export async function deleteSupplier(supplierId: string) {
export async function deleteSupplier(assetId: string) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = dbForTenant(session.user.tenantId);
const before = await db.supplier.findUnique({ where: { id: supplierId } });
const before = await db.asset.findUnique({ where: { id: assetId } });
if (!before) throw new Error("Lieferant nicht gefunden");
await db.supplier.delete({ where: { id: supplierId } });
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "delete",
entity: "supplier",
entityId: supplierId,
before,
});
await db.asset.delete({ where: { id: assetId } }); // Profil + Kinder kaskadieren
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "supplier", entityId: assetId, before });
revalidatePath("/suppliers");
revalidatePath("/assets");
redirect("/suppliers");
}
/** Generischer Helper zum Anlegen einer Kind-Entität (mit Tenant/Owner-Prüfung). */
async function ensureSupplier(session: Awaited<ReturnType<typeof requireSession>>, supplierId: string) {
async function ensure(session: Awaited<ReturnType<typeof requireSession>>, assetId: string) {
const db = dbForTenant(session.user.tenantId);
const count = await db.supplier.count({ where: { id: supplierId } });
if (count !== 1) throw new Error("Lieferant nicht gefunden");
if ((await db.asset.count({ where: { id: assetId } })) !== 1) throw new Error("Nicht gefunden");
return db;
}
const optDate = (v: FormDataEntryValue | null) => (v && String(v) ? new Date(String(v)) : null);
const optDate = (v: FormDataEntryValue | null) =>
v && String(v) ? new Date(String(v)) : null;
export async function addAssessment(supplierId: string, formData: FormData) {
export async function addContract(assetId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = await ensureSupplier(session, supplierId);
await db.supplierAssessment.create({
data: {
supplierId,
tenantId: session.user.tenantId,
type: z.enum(["QUESTIONNAIRE", "SELF_ASSESSMENT", "AUDIT"]).parse(formData.get("type")),
status: z
.enum(["SENT", "RECEIVED", "EVALUATED", "OVERDUE"])
.parse(formData.get("status") ?? "SENT"),
score: formData.get("score") ? Number(formData.get("score")) : null,
date: optDate(formData.get("date")),
nextReview: optDate(formData.get("nextReview")),
result: (formData.get("result") as string)?.trim() || null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "supplier_assessment", entityId: supplierId });
revalidatePath("/suppliers");
}
export async function addContract(supplierId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = await ensureSupplier(session, supplierId);
const db = await ensure(session, assetId);
const bool = (n: string) => formData.get(n) === "on";
await db.contract.create({
data: {
supplierId,
assetId,
tenantId: session.user.tenantId,
type: (formData.get("type") as string) || "service",
avDpa: bool("avDpa"),
securityClauses: bool("securityClauses"),
flowdown: bool("flowdown"),
customerTransparency: bool("customerTransparency"),
customerRequirementsPassed: bool("customerRequirementsPassed"),
validFrom: optDate(formData.get("validFrom")),
validTo: optDate(formData.get("validTo")),
reference: (formData.get("reference") as string)?.trim() || null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "contract", entityId: supplierId });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "contract", entityId: assetId });
revalidatePath("/suppliers");
}
export async function addNda(supplierId: string, formData: FormData) {
export async function addNda(assetId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = await ensureSupplier(session, supplierId);
const db = await ensure(session, assetId);
await db.nda.create({
data: {
supplierId,
assetId,
tenantId: session.user.tenantId,
parties: (formData.get("parties") as string)?.trim() || null,
infoScope: (formData.get("infoScope") as string)?.trim() || null,
subject: (formData.get("subject") as string)?.trim() || null,
validFrom: optDate(formData.get("validFrom")),
validTo: optDate(formData.get("validTo")),
obligations: (formData.get("obligations") as string)?.trim() || null,
extensionStatus: (formData.get("extensionStatus") as string)?.trim() || null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "nda", entityId: supplierId });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "nda", entityId: assetId });
revalidatePath("/suppliers");
}
export async function addEvidence(supplierId: string, formData: FormData) {
export async function addEvidence(assetId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = await ensureSupplier(session, supplierId);
const db = await ensure(session, assetId);
await db.supplierEvidence.create({
data: {
supplierId,
assetId,
tenantId: session.user.tenantId,
kind: z
.enum(["CERTIFICATE", "TISAX_LABEL", "ATTESTATION", "AUDIT_REPORT", "SELF_ASSESSMENT"])
.parse(formData.get("kind")),
kind: z.enum(["CERTIFICATE", "TISAX_LABEL", "ATTESTATION", "AUDIT_REPORT", "SELF_ASSESSMENT"]).parse(formData.get("kind")),
name: (formData.get("name") as string)?.trim() || null,
protectsCia: (formData.get("protectsCia") as string)?.trim() || null,
validTo: optDate(formData.get("validTo")),
adequacyChecked: formData.get("adequacyChecked") === "on",
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "supplier_evidence", entityId: supplierId });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "supplier_evidence", entityId: assetId });
revalidatePath("/suppliers");
}
export async function addResponsibility(supplierId: string, formData: FormData) {
export async function addAssessment(assetId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = await ensureSupplier(session, supplierId);
await db.serviceResponsibility.create({
const db = await ensure(session, assetId);
await db.supplierAssessment.create({
data: {
supplierId,
assetId,
tenantId: session.user.tenantId,
itService: z.string().trim().min(1).parse(formData.get("itService")),
requirement: z.string().trim().min(1).parse(formData.get("requirement")),
responsibleParty: z
.enum(["CLIENT", "SUPPLIER", "SHARED"])
.parse(formData.get("responsibleParty") ?? "SHARED"),
isaApplicability: (formData.get("isaApplicability") as string)?.trim() || null,
evidence: (formData.get("evidence") as string)?.trim() || null,
integratedLocalControls: (formData.get("integratedLocalControls") as string)?.trim() || null,
type: z.enum(["QUESTIONNAIRE", "SELF_ASSESSMENT", "AUDIT"]).parse(formData.get("type")),
score: formData.get("score") ? Number(formData.get("score")) : null,
date: optDate(formData.get("date")),
nextReview: optDate(formData.get("nextReview")),
result: (formData.get("result") as string)?.trim() || null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "service_responsibility", entityId: supplierId });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "supplier_assessment", entityId: assetId });
revalidatePath("/suppliers");
}
export async function addSubcontractor(supplierId: string, formData: FormData) {
export async function addDecision(assetId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = await ensureSupplier(session, supplierId);
await db.subcontractor.create({
data: {
supplierId,
tenantId: session.user.tenantId,
name: z.string().trim().min(1).parse(formData.get("name")),
flowdownObligation: formData.get("flowdownObligation") === "on",
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "subcontractor", entityId: supplierId });
revalidatePath("/suppliers");
}
export async function addDecision(supplierId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = await ensureSupplier(session, supplierId);
const db = await ensure(session, assetId);
await db.managementDecision.create({
data: {
supplierId,
assetId,
tenantId: session.user.tenantId,
reasonNoAudit: z.string().trim().min(1).parse(formData.get("reasonNoAudit")),
decision: z.string().trim().min(1).parse(formData.get("decision")),
@@ -282,53 +228,78 @@ export async function addDecision(supplierId: string, formData: FormData) {
recordRef: (formData.get("recordRef") as string)?.trim() || null,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "management_decision", entityId: supplierId });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "management_decision", entityId: assetId });
revalidatePath("/suppliers");
}
export async function saveMaturity(supplierId: string, controlId: string, formData: FormData) {
await setControlMaturity(supplierId, controlId, Number(formData.get("maturity")));
}
export async function setControlMaturity(supplierId: string, controlId: string, maturity: number) {
/** Gate-Kompensation: echtes Risiko im Risikomodul anlegen und mit dem Lieferanten verknüpfen. */
export async function createGateRisk(assetId: string) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = await ensureSupplier(session, supplierId);
const m = z.coerce.number().int().min(0).max(5).parse(maturity);
await db.supplierControlMaturity.upsert({
where: { supplierId_controlId: { supplierId, controlId } },
update: { maturity: m },
create: { supplierId, controlId, maturity: m, tenantId: session.user.tenantId },
requirePermission(session, "risk:write");
const db = dbForTenant(session.user.tenantId);
const asset = await db.asset.findUnique({ where: { id: assetId } });
if (!asset) throw new Error("Lieferant nicht gefunden");
const last = await prisma.risk.aggregate({ where: { tenantId: session.user.tenantId }, _max: { refNo: true } });
const risk = await db.risk.create({
data: {
tenantId: session.user.tenantId,
refNo: (last._max.refNo ?? 0) + 1,
title: `Third-Party-Risiko: ${asset.name}`,
description: "Sehr hoher Schutzbedarf ohne gültiges Third-Party-Audit/TISAX-Label — Kompensationsrisiko (VDA-ISA 6.1.1).",
threat: "Ausfall/Kompromittierung eines kritischen Lieferanten",
vulnerability: "Fehlender unabhängiger Sicherheitsnachweis",
likelihood: 3,
impact: 4,
score: 12,
treatment: "MITIGATE",
status: "IN_TREATMENT",
createdBy: session.user.id,
},
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "supplier_control_maturity", entityId: supplierId, after: { controlId, maturity: m } });
await db.riskAsset.create({ data: { tenantId: session.user.tenantId, riskId: risk.id, assetId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "risk", entityId: risk.id, after: { gateRiskFor: assetId } });
revalidatePath("/suppliers");
revalidatePath("/risks");
redirect(`/risks?detail=${risk.id}`);
}
/** ISB-Freigabe des Reifegrads (Abweichung nur mit Begründung → Audit-Log). */
export async function approveMaturity(assetId: string, computed: number, formData: FormData) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = await ensure(session, assetId);
const isbValue = z.coerce.number().min(0).max(3).parse(formData.get("isbValue"));
const justification = (formData.get("justification") as string)?.trim() || null;
if (Math.abs(isbValue - computed) > 0.01 && !justification) {
throw new Error("Abweichung vom berechneten Wert erfordert eine Begründung.");
}
await db.maturityAssessment.upsert({
where: { assetId },
update: { computedValue: computed, isbValue, isbJustification: justification, approvedBy: session.user.name, approvedAt: new Date() },
create: { assetId, tenantId: session.user.tenantId, computedValue: computed, isbValue, isbJustification: justification, approvedBy: session.user.name, approvedAt: new Date() },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "maturity_assessment", entityId: assetId, after: { computed, isbValue, justification } });
revalidatePath("/suppliers");
}
/** Generisches Löschen einer Kind-Entität. */
export async function deleteChild(
kind:
| "assessment"
| "contract"
| "nda"
| "evidence"
| "responsibility"
| "subcontractor"
| "decision",
kind: "contract" | "nda" | "evidence" | "assessment" | "decision" | "subcontractor",
id: string
) {
const session = await requireSession();
requirePermission(session, "supplier:write");
const db = dbForTenant(session.user.tenantId);
const map = {
assessment: db.supplierAssessment,
contract: db.contract,
nda: db.nda,
evidence: db.supplierEvidence,
responsibility: db.serviceResponsibility,
subcontractor: db.subcontractor,
assessment: db.supplierAssessment,
decision: db.managementDecision,
subcontractor: db.subcontractor,
} as const;
// @ts-expect-error dynamischer Delegate-Zugriff
// @ts-expect-error dynamischer Delegate
await map[kind].delete({ where: { id } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: `supplier_${kind}`, entityId: id });
revalidatePath("/suppliers");
+4 -4
View File
@@ -37,16 +37,16 @@ const TENANT_MODELS = new Set<string>([
"RiskAsset",
"Measure",
"RiskMeasure",
"Supplier",
"SupplierAsset",
"SupplierProfile",
"ITServiceProfile",
"SupplierAssessment",
"Contract",
"Nda",
"SupplierEvidence",
"ServiceResponsibility",
"ServiceControlResponsibility",
"Subcontractor",
"ManagementDecision",
"SupplierControlMaturity",
"MaturityAssessment",
]);
/**
+1
View File
@@ -20,6 +20,7 @@ export type GraphNodeKind =
| "APPLICATION"
| "LOCATION"
| "SUPPLIER"
| "IT_SERVICE"
| "PERSON"
| "DATA";