Basis: Certvia dev@a48c5fb als Fundament für Craftvia
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s

Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation
und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 11:05:39 +02:00
co-authored by Claude Opus 5
commit c8e6f30a27
720 changed files with 140143 additions and 0 deletions
+245
View File
@@ -0,0 +1,245 @@
// ── DSGVO-Löschung (Art. 17) — Anonymisieren vs. Hard-Delete ─────────────────
//
// Zwei Scopes (KONZEPT §6):
// • Person (Betroffener) in EINEM Mandanten: PII der Mitgliedschaft wird
// ANONYMISIERT (Name/E-Mail → Tombstone), die referenzielle Struktur
// (ownerId/actorId/… über die anonymisierte User-Zeile) bleibt erhalten —
// Nachweis-/Aufbewahrungspflicht (Audit-Trail/Freigaben). Ein `TombstoneEntry`
// stellt sicher, dass ein späterer Restore die PII NICHT zurückbringt.
// Globale Identity ohne verbleibende Mitgliedschaft → global anonymisiert.
// • Mandant (Offboarding): alle tenant_id-Zeilen hart gelöscht (child→parent,
// eine Owner-Transaktion) + MinIO-Prefix; Identities ohne Rest-Mitgliedschaft
// werden global gelöscht. Löschnachweis (DeletionCertificate) in beiden Fällen.
//
// Alles über den Owner-`prisma`-Client, streng tenant-/personen-gescopt.
import { prisma } from "../db";
import { writeAuditLog } from "../audit";
import { buildTenantTopology } from "../backup/topology";
import { exportTenant } from "../backup/export";
import { getBackupStore } from "../storage/backup-store";
const STUB_PASSWORD_SENTINEL = "!stub-no-login!";
function tombstoneEmail(id: string): string {
return `deleted+${id}@tombstone.local`;
}
const TOMBSTONE_NAME = "Gelöschte Person";
export interface DeleteSubjectResult {
identityId: string;
anonymizedMemberships: number;
identityAnonymized: boolean;
certificateId: string;
}
/**
* Personen-Löschung innerhalb eines Mandanten: Mitgliedschafts-PII anonymisieren,
* Tombstone setzen (restore-fest), ggf. globale Identity anonymisieren, Nachweis.
*/
export async function deleteSubject(
tenantId: string,
identityId: string,
opts: { actorId?: string; reason?: string } = {},
): Promise<DeleteSubjectResult> {
const memberships = await prisma.user.findMany({ where: { tenantId, identityId } });
if (!memberships.length) {
throw new Error(`deleteSubject: keine Mitgliedschaft von ${identityId} in Mandant ${tenantId}.`);
}
const subjectEmail = memberships[0]?.email ?? null;
await prisma.$transaction(async (tx) => {
for (const m of memberships) {
const anonFields = { email: tombstoneEmail(m.id), name: TOMBSTONE_NAME };
// Live-Daten anonymisieren.
await tx.user.update({ where: { id: m.id }, data: anonFields });
// Restore-fester Tombstone: bei Rückkehr der alten Zeile erneut anonymisieren.
await tx.tombstoneEntry.upsert({
where: { tenantId_model_targetValue: { tenantId, model: "User", targetValue: m.id } },
create: {
tenantId,
model: "User",
targetField: "id",
targetValue: m.id,
action: "anonymize",
anonymizedFields: anonFields,
reason: opts.reason ?? "dsgvo-subject-deletion",
},
update: { anonymizedFields: anonFields },
});
}
});
// Globale Identity: controller-scoped (KONZEPT §6/§8). Die anonymisierte
// Mitgliedschaft in DIESEM Mandanten bleibt (referenzielle Struktur/Nachweis);
// die globale Identity wird nur anonymisiert, wenn die Person in KEINEM ANDEREN
// Mandanten noch Mitglied ist — sonst bliebe deren Auth-Zugang dort bestehen.
const remaining = await prisma.user.count({
where: { identityId, tenantId: { not: tenantId } },
});
let identityAnonymized = false;
if (remaining === 0) {
await prisma.identity.update({
where: { id: identityId },
data: {
// Identity trägt keinen Namen (nur User); hier E-Mail tilgen + Secrets entfernen.
email: tombstoneEmail(identityId),
passwordHash: STUB_PASSWORD_SENTINEL,
mfaSecret: null,
mfaEnrolledAt: null,
recoveryCodes: [],
status: "DISABLED",
mustChangePassword: true,
},
});
identityAnonymized = true;
}
const cert = await prisma.deletionCertificate.create({
data: {
scope: "person",
tenantId,
subjectIdentityId: identityId,
subjectEmail,
actorId: opts.actorId,
anonymizedCounts: { User: memberships.length, ...(identityAnonymized ? { Identity: 1 } : {}) },
reason: opts.reason,
},
});
await writeAuditLog({
tenantId,
actorId: opts.actorId,
action: "delete",
entity: "dsgvo_subject",
entityId: identityId,
after: { anonymizedMemberships: memberships.length, identityAnonymized, certificateId: cert.id },
});
return {
identityId,
anonymizedMemberships: memberships.length,
identityAnonymized,
certificateId: cert.id,
};
}
export interface OffboardResult {
tenantId: string;
deletedRows: number;
deletedIdentities: number;
portabilitySnapshotId: string | null;
certificateId: string;
}
/**
* Mandanten-Offboarding: Portabilitäts-Snapshot (optional), Hard-Delete aller
* tenant_id-Zeilen (child→parent, eine Owner-Transaktion), MinIO-Prefix,
* Identity-Aufräumung, Löschnachweis. Der Tenant-Stammsatz wird auf ARCHIVED
* gesetzt (nicht hart entfernt), damit Nachweis/Audit referenzierbar bleiben.
*/
export async function offboardTenant(
tenantId: string,
opts: { actorId?: string; reason?: string; portabilitySnapshot?: boolean; purgeFiles?: boolean } = {},
): Promise<OffboardResult> {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { id: true, slug: true },
});
if (!tenant) throw new Error(`offboardTenant: Mandant ${tenantId} existiert nicht.`);
// Optionaler Portabilitäts-/Offboarding-Snapshot vor der Löschung.
let portabilitySnapshotId: string | null = null;
if (opts.portabilitySnapshot) {
const snap = await exportTenant(tenantId, { reason: "offboarding", persist: true, includeFiles: true });
portabilitySnapshotId = snap.snapshotId;
}
// Identities der Mitglieder VOR der Löschung merken (für Aufräumung).
const memberIdentityIds = (
await prisma.user.findMany({ where: { tenantId }, select: { identityId: true } })
).map((u) => u.identityId);
const topo = buildTenantTopology();
let deletedRows = 0;
await prisma.$transaction(
async (tx) => {
await tx.$executeRawUnsafe(`SET LOCAL session_replication_role = replica`);
for (const node of topo.deleteOrder) {
const del = (tx as unknown as Record<string, {
deleteMany: (a: unknown) => Promise<{ count: number }>;
findMany: (a: unknown) => Promise<Record<string, unknown>[]>;
}>)[node.delegate];
if (node.scope.by === "tenantColumn") {
const r = await del.deleteMany({ where: { tenantId } });
deletedRows += r.count;
} else {
const via = node.scope.via;
const parentNode = topo.nodes.get(via.parent)!;
const parentPk = parentNode.pk[0];
const parentDelegate = (tx as unknown as Record<string, {
findMany: (a: unknown) => Promise<Record<string, unknown>[]>;
}>)[parentNode.delegate];
const parents = await parentDelegate.findMany({ where: { tenantId }, select: { [parentPk]: true } });
const ids = parents.map((p) => p[parentPk]).filter((v) => typeof v === "string");
if (ids.length) {
const r = await del.deleteMany({ where: { [via.fromFields[0]]: { in: ids } } });
deletedRows += r.count;
}
}
}
},
{ timeout: 300_000 },
);
// Globale Identity-Aufräumung: Identities ohne verbleibende Mitgliedschaft löschen.
let deletedIdentities = 0;
for (const idn of new Set(memberIdentityIds)) {
const remaining = await prisma.user.count({ where: { identityId: idn } });
if (remaining === 0) {
await prisma.identity.delete({ where: { id: idn } }).then(
() => deletedIdentities++,
() => {
/* bereits weg / referenziert → überspringen */
},
);
}
}
// MinIO-Prefix des Mandanten (Uploads) entfernen.
if (opts.purgeFiles !== false) {
try {
await (await getBackupStore()).remove(`${tenantId}/uploads/`);
} catch {
/* best effort */
}
}
// Tenant-Stammsatz archivieren (nicht hart entfernen).
await prisma.tenant.update({ where: { id: tenantId }, data: { status: "ARCHIVED" } });
const cert = await prisma.deletionCertificate.create({
data: {
scope: "tenant",
tenantId,
tenantSlug: tenant.slug,
actorId: opts.actorId,
deletedCounts: { rows: deletedRows, identities: deletedIdentities },
snapshotId: portabilitySnapshotId,
reason: opts.reason,
},
});
await writeAuditLog({
tenantId,
actorId: opts.actorId,
action: "delete",
entity: "dsgvo_tenant_offboarding",
entityId: tenantId,
after: { deletedRows, deletedIdentities, portabilitySnapshotId, certificateId: cert.id },
});
return { tenantId, deletedRows, deletedIdentities, portabilitySnapshotId, certificateId: cert.id };
}
+112
View File
@@ -0,0 +1,112 @@
// ── DSGVO-Export: per-Mandant (Art. 20) und per-Person (Art. 15/20) ──────────
//
// Läuft über den Owner-`prisma`-Client. Zwei Granularitäten (KONZEPT §5):
// 1. Per-Mandant — der gesamte Kundendatensatz (= das Backup-Artefakt, hier
// maschinenlesbar als JSON entschlüsselt).
// 2. Per-Person — alle personenbezogenen Zeilen einer Identity in EINEM
// Mandanten: Identity-Metadaten (OHNE Secrets), Mitgliedschaft(en) und alle
// Referenzen über die weichen PII-Felder (ownerId/assigneeId/actorId/…).
import { Prisma } from "@prisma/client";
import { prisma } from "../db";
import { exportTenant } from "../backup/export";
import { openArtifact } from "../backup/crypto";
import { parseArtifactBody } from "../backup/serialization";
import { writeAuditLog } from "../audit";
import { PII_REFERENCE_FIELDS } from "./pii-fields";
/** Per-Mandant-Export als entschlüsseltes JSON (Portabilität/Offboarding-Kopie). */
export async function exportTenantAsJson(tenantId: string) {
const res = await exportTenant(tenantId, { persist: false, reason: "dsgvo-tenant" });
const body = openArtifact(res.artifact);
const tables = parseArtifactBody(body);
await writeAuditLog({
tenantId,
action: "export",
entity: "dsgvo_tenant_export",
after: { totalRows: res.manifest.totalRows },
});
return { manifest: res.manifest, tables };
}
export interface SubjectExport {
identity: {
id: string;
email: string;
name: string;
uiLocale: string;
status: string;
mfaEnrolled: boolean;
createdAt: Date;
} | null;
memberships: Record<string, unknown>[];
/** Referenzierte Objekte je (Modell.Feld). */
references: Record<string, Record<string, unknown>[]>;
}
/**
* Per-Person-Auskunft innerhalb EINES Mandanten. Secrets (passwordHash,
* mfaSecret, recoveryCodes) werden bewusst NIE ausgegeben.
*/
export async function exportSubject(tenantId: string, identityId: string): Promise<SubjectExport> {
const identity = await prisma.identity.findUnique({
where: { id: identityId },
select: {
id: true,
email: true,
uiLocale: true,
status: true,
mfaEnrolledAt: true,
createdAt: true,
},
});
// Mitgliedschaft(en) der Person in DIESEM Mandanten (i. d. R. genau eine).
// Der Anzeigename lebt denormalisiert auf User (Identity trägt keinen Namen).
const memberships = await prisma.user.findMany({ where: { tenantId, identityId } });
const userIds = memberships.map((m) => m.id);
// Feld-Existenz aus dem DMMF prüfen (robust gegen Schema-Drift der PII-Liste).
const fieldsByModel = new Map<string, Set<string>>();
for (const m of Prisma.dmmf.datamodel.models) {
fieldsByModel.set(m.name, new Set(m.fields.map((f) => f.name)));
}
const references: Record<string, Record<string, unknown>[]> = {};
if (userIds.length) {
for (const ref of PII_REFERENCE_FIELDS) {
if (!fieldsByModel.get(ref.model)?.has(ref.field)) continue;
const delegate = (prisma as unknown as Record<string, {
findMany: (a: unknown) => Promise<Record<string, unknown>[]>;
}>)[ref.model.charAt(0).toLowerCase() + ref.model.slice(1)];
const rows = await delegate.findMany({
where: { tenantId, [ref.field]: { in: userIds } },
});
if (rows.length) references[`${ref.model}.${ref.field}`] = rows;
}
}
await writeAuditLog({
tenantId,
action: "export",
entity: "dsgvo_subject_export",
entityId: identityId,
after: { memberships: memberships.length, referenceGroups: Object.keys(references).length },
});
return {
identity: identity
? {
id: identity.id,
email: identity.email,
name: memberships[0]?.name ?? "",
uiLocale: identity.uiLocale,
status: identity.status,
mfaEnrolled: identity.mfaEnrolledAt != null,
createdAt: identity.createdAt,
}
: null,
memberships,
references,
};
}
+42
View File
@@ -0,0 +1,42 @@
// ── Klassifikation der weichen Personen-Referenzfelder (DSGVO) ───────────────
//
// Diese Felder speichern eine `User.id` (mandanten-lokale Mitgliedschaft) als
// lose Referenz auf eine Person. Grundlage für die DSGVO-Auskunft (welche
// Objekte referenzieren die Person) und die -Löschung (welche Referenzen
// bleiben — pseudonymisiert über die anonymisierte User-Zeile — vs. werden
// mitgelöscht). Zentrale, gepflegte Liste (KONZEPT §5/§6, offene DSB-Feinklassifikation).
export interface PiiReference {
/** Prisma-Modellname. */
model: string;
/** Prisma-Feldname, das eine User.id trägt. */
field: string;
}
/** Felder, die eine Person als „Eigentümer/Ersteller/Bearbeiter/Akteur" referenzieren. */
export const PII_REFERENCE_FIELDS: readonly PiiReference[] = [
{ model: "Asset", field: "ownerId" },
{ model: "Asset", field: "createdBy" },
{ model: "Process", field: "ownerId" },
{ model: "Process", field: "createdBy" },
{ model: "Process", field: "deputyOwnerId" },
{ model: "Risk", field: "ownerId" },
{ model: "Risk", field: "createdBy" },
{ model: "Measure", field: "ownerId" },
{ model: "Measure", field: "createdBy" },
{ model: "SupplierProfile", field: "createdBy" },
{ model: "ITServiceProfile", field: "createdBy" },
{ model: "SoftwareProfile", field: "createdBy" },
{ model: "ProjectProfile", field: "createdBy" },
{ model: "ManagementDecision", field: "decidedBy" },
{ model: "AuditLog", field: "actorId" },
{ model: "Task", field: "assigneeId" },
{ model: "Task", field: "createdById" },
{ model: "TaskComment", field: "authorId" },
{ model: "TaskParticipant", field: "userId" },
{ model: "Audit", field: "auditorUserId" },
{ model: "AuditEvidenceItem", field: "assignedUserId" },
{ model: "OnboardingProgress", field: "reviewerId" },
{ model: "ProjectFunctionAssignment", field: "userId" },
{ model: "NotificationPreference", field: "userId" },
];
+58
View File
@@ -0,0 +1,58 @@
// ── Tombstone-Wiederanwendung beim Restore (DSGVO, KONZEPT §6) ───────────────
//
// „Backups vs. Löschung": Eine Personen-/PII-Löschung wirkt auf Live-Daten. Ein
// späterer Tenant-Restore aus einem ÄLTEREN Snapshot würde die gelöschte/
// anonymisierte PII zurückbringen. Deshalb ist `TombstoneEntry` GLOBAL (kein
// tenant_id in TENANT_MODELS → wird beim Restore nicht überschrieben) und wird am
// Ende jedes Restore erneut angewandt: betroffene Zeilen werden erneut gelöscht
// bzw. anonymisiert.
/** Minimales Client-/Transaktions-Interface (Owner-Client oder Tx). */
type DynClient = Record<
string,
{
findMany: (a: unknown) => Promise<Record<string, unknown>[]>;
deleteMany: (a: unknown) => Promise<{ count: number }>;
updateMany: (a: unknown) => Promise<{ count: number }>;
}
>;
interface TombstoneRow {
model: string;
targetField: string;
targetValue: string;
action: string;
anonymizedFields: Record<string, unknown>;
}
function delegateName(model: string): string {
return model.charAt(0).toLowerCase() + model.slice(1);
}
/**
* Wendet alle Tombstones eines Mandanten erneut an. Läuft innerhalb der Restore-
* Transaktion über denselben (Owner-)Client. Gibt die Zahl angewandter Tombstones
* zurück (Zeile getroffen oder nicht — der Tombstone gilt als angewandt, sobald
* die Regel ausgeführt wurde).
*/
export async function applyTombstones(tx: DynClient, tenantId: string): Promise<number> {
const tombstones = (await tx["tombstoneEntry"].findMany({
where: { tenantId },
})) as unknown as TombstoneRow[];
let applied = 0;
for (const t of tombstones) {
const delegate = tx[delegateName(t.model)];
if (!delegate) continue; // unbekanntes Modell (Schema-Drift) → überspringen
const where = { [t.targetField]: t.targetValue };
if (t.action === "delete") {
await delegate.deleteMany({ where });
} else if (t.action === "anonymize") {
await delegate.updateMany({ where, data: t.anonymizedFields ?? {} });
} else {
continue;
}
applied++;
}
return applied;
}