Basis: Certvia dev@a48c5fb als Fundament für Craftvia
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:
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user