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
+56
View File
@@ -0,0 +1,56 @@
// ── §9-Verschlüsselung der Tenant-Artefakte (client-seitig, vor dem Upload) ───
//
// Entscheidung (KONZEPT §9): client-seitige AES-256-Verschlüsselung mit EINEM
// Schlüssel pro Umgebung — das Artefakt ist verschlüsselt, BEVOR es MinIO/S3
// erreicht (zero-knowledge vom Speicher, at-rest + in-transit). Konsistent zur
// bestehenden TOTP-Verschlüsselung (AES-256-GCM, src/server/secret-crypto.ts).
//
// Schlüssel: `BACKUP_ENC_KEY` (dediziert, pro Umgebung), Fallback `AUTH_SECRET`.
// Der Schlüssel liegt NIE im Artefakt (Restore-Kohärenz-Regel, KONZEPT §9):
// Pepper/MFA_ENC_KEY/BACKUP_ENC_KEY sind Umgebungs-Secrets, kein Artefakt-Inhalt.
//
// Blob-Layout (nach der Kompression):
// magic "CVB1" | iv(12) | authTag(16) | ciphertext(gzip(plaintext))
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
import { gzipSync, gunzipSync } from "node:zlib";
const MAGIC = Buffer.from("CVB1", "ascii");
function backupKey(): Buffer {
const material = process.env.BACKUP_ENC_KEY || process.env.AUTH_SECRET;
if (!material) {
throw new Error(
"BACKUP_ENC_KEY/AUTH_SECRET fehlt — Tenant-Artefakte können nicht verschlüsselt werden. " +
"Pro Umgebung eindeutig setzen; NIEMALS im selben Bucket wie die Artefakte ablegen (KONZEPT §9).",
);
}
return createHash("sha256").update(`${material}:tenant-backup`).digest(); // 32 Byte
}
/** Komprimiert (gzip) und verschlüsselt (AES-256-GCM) den Klartext-Body. */
export function sealArtifact(plaintext: string): Buffer {
const gz = gzipSync(Buffer.from(plaintext, "utf8"));
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", backupKey(), iv);
const ct = Buffer.concat([cipher.update(gz), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([MAGIC, iv, tag, ct]);
}
/** Entschlüsselt und dekomprimiert ein von `sealArtifact` erzeugtes Blob. */
export function openArtifact(blob: Buffer): string {
if (blob.length < MAGIC.length + 12 + 16 || !blob.subarray(0, 4).equals(MAGIC)) {
throw new Error("Ungültiges/beschädigtes Backup-Artefakt (Magic-Byte-Prüfung fehlgeschlagen).");
}
let off = MAGIC.length;
const iv = blob.subarray(off, off + 12);
off += 12;
const tag = blob.subarray(off, off + 16);
off += 16;
const ct = blob.subarray(off);
const decipher = createDecipheriv("aes-256-gcm", backupKey(), iv);
decipher.setAuthTag(tag);
const gz = Buffer.concat([decipher.update(ct), decipher.final()]);
return gunzipSync(gz).toString("utf8");
}
+100
View File
@@ -0,0 +1,100 @@
// ── DSGVO-Zustellpaket als ZIP (Per-Mandant / Per-Person) ────────────────────
//
// Wrappt die vorhandene DSGVO-Export-Engine (src/server/dsgvo/export.ts) und
// verpackt deren Ergebnis in ein maschinenlesbares ZIP (JSON) — für die
// Zustellung über einen zeitlich begrenzten, signierten Link (KONZEPT §5).
// Die Engine selbst wird NICHT verändert, nur aufgerufen. Secrets (passwordHash,
// mfaSecret, recoveryCodes) sind bereits engine-seitig ausgeschlossen.
import { prisma } from "../db";
import { exportTenantAsJson, exportSubject } from "../dsgvo/export";
import { buildZip, type ZipEntry } from "./zip";
export interface DsgvoPackage {
zip: Buffer;
/** Kurzbeschreibung fürs Audit/Job-Ergebnis (ohne PII). */
summary: {
scope: "tenant" | "person";
tenantId: string;
subjectIdentityId?: string;
entries: number;
bytes: number;
};
}
/** JSON-Serialisierung mit BigInt-/Date-Sicherheit (Prisma liefert beides). */
function toJson(value: unknown): string {
return JSON.stringify(
value,
(_k, v) => (typeof v === "bigint" ? v.toString() : v),
2,
);
}
/**
* Baut das DSGVO-Paket:
* - `subjectIdentityId` gesetzt → Per-Person-Auskunft (Art. 15/20) dieser
* Person in DIESEM Mandanten.
* - sonst → Per-Mandant-Paket (Art. 20 Portabilität / Offboarding-Kopie).
*/
export async function buildDsgvoPackage(
tenantId: string,
subjectIdentityId?: string | null,
): Promise<DsgvoPackage> {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { id: true, slug: true, name: true },
});
if (!tenant) throw new Error(`buildDsgvoPackage: Mandant ${tenantId} existiert nicht.`);
const generatedAt = new Date().toISOString();
const entries: ZipEntry[] = [];
if (subjectIdentityId) {
const subject = await exportSubject(tenantId, subjectIdentityId);
entries.push({
name: "README.txt",
data:
`certvia — DSGVO-Auskunft (Art. 15/20), Einzelperson\n` +
`Mandant: ${tenant.name} (${tenant.slug})\n` +
`Betroffene Identity: ${subjectIdentityId}\n` +
`Erstellt: ${generatedAt}\n\n` +
`Inhalt:\n` +
` identity.json — Identitäts-Metadaten (OHNE Secrets)\n` +
` memberships.json — Mitgliedschaft(en) in diesem Mandanten\n` +
` references.json — referenzierte Objekte (Eigentümer/Ersteller/Akteur)\n`,
});
entries.push({ name: "identity.json", data: toJson(subject.identity) });
entries.push({ name: "memberships.json", data: toJson(subject.memberships) });
entries.push({ name: "references.json", data: toJson(subject.references) });
} else {
const { manifest, tables } = await exportTenantAsJson(tenantId);
entries.push({
name: "README.txt",
data:
`certvia — DSGVO-Datenpaket (Art. 20 Portabilität), gesamter Mandant\n` +
`Mandant: ${tenant.name} (${tenant.slug})\n` +
`Erstellt: ${generatedAt}\n` +
`Zeilen gesamt: ${manifest.totalRows}\n\n` +
`Inhalt:\n` +
` manifest.json — Schema-/Migrationsversion, Zeilenzahlen, Prüfsummen\n` +
` data/<Modell>.json — je Tabelle die Zeilen dieses Mandanten\n`,
});
entries.push({ name: "manifest.json", data: toJson(manifest) });
for (const table of tables) {
entries.push({ name: `data/${table.model}.json`, data: toJson(table.rows) });
}
}
const zip = buildZip(entries);
return {
zip,
summary: {
scope: subjectIdentityId ? "person" : "tenant",
tenantId,
...(subjectIdentityId ? { subjectIdentityId } : {}),
entries: entries.length,
bytes: zip.length,
},
};
}
+208
View File
@@ -0,0 +1,208 @@
// ── exportTenant: konsistentes, mandanten-scoptes Backup-Artefakt ────────────
//
// Läuft AUSSCHLIESSLICH über den Owner-`prisma`-Client (BYPASSRLS) — NIE über
// dbForTenant/appBase. Ein konsistenter Snapshot entsteht in einer REPEATABLE-
// READ-Transaktion: je Tabelle `WHERE tenant_id = A` (Join-Tabellen über die
// id-Menge der Tenant-User/-Rollen), in FK-Reihenfolge parent→child.
//
// Ergebnis: gzip + AES-256-verschlüsseltes Blob (KONZEPT §9) + Manifest
// (Schema-/Migrationsversion, Zeilenzahlen, Prüfsummen, Identity-Referenzen).
// Umgebungs-Secrets (Pepper/MFA_ENC_KEY/BACKUP_ENC_KEY) landen NICHT im Artefakt.
import { Prisma } from "@prisma/client";
import { prisma } from "../db";
import { buildTenantTopology, assertTopologyMatchesDatabase } from "./topology";
import {
serializeTable,
ARTIFACT_FORMAT_VERSION,
type BackupManifest,
type TableManifest,
type TableRows,
} from "./serialization";
import { sealArtifact } from "./crypto";
import { getBackupStore } from "../storage/backup-store";
export interface ExportOptions {
/** Artefakt+Manifest in den Objektspeicher hochladen (Default true). */
persist?: boolean;
/** Grund/Anlass (Audit/Manifest-Kontext), z. B. "pre-restore" | "nightly". */
reason?: string;
/** Mandanten-Dateien (MinIO `${tenantId}/uploads/`) mitsichern (Default false). */
includeFiles?: boolean;
}
export interface ExportResult {
tenantId: string;
snapshotId: string;
manifest: BackupManifest;
/** Verschlüsseltes Blob (in-memory; für Restore ohne Storage-Roundtrip). */
artifact: Buffer;
/** Storage-Key des Artefakts (falls persistiert), sonst null. */
artifactKey: string | null;
}
/** Storage-Prefix eines Mandanten (mandantenpräfixiert, KONZEPT §3). */
export function tenantBackupPrefix(tenantId: string): string {
return `${tenantId}/backups/`;
}
function snapshotPrefix(tenantId: string, snapshotId: string): string {
return `${tenantId}/backups/${snapshotId}/`;
}
/** Neuen, sortierbaren Snapshot-Bezeichner erzeugen (Zeit + Zufall). */
function newSnapshotId(): string {
const ts = new Date().toISOString().replace(/[:.]/g, "-");
const rnd = Math.random().toString(36).slice(2, 8);
return `${ts}_${rnd}`;
}
/**
* Exportiert genau EINEN Mandanten. Verlässt nie `tenant_id = A` → beweisbar
* keine Fremdmandanten berührt (Isolationstest: scripts/test-backup-isolation.ts).
*/
export async function exportTenant(
tenantId: string,
opts: ExportOptions = {},
): Promise<ExportResult> {
if (!tenantId) throw new Error("exportTenant: tenantId ist erforderlich.");
const persist = opts.persist ?? true;
// Sicherheitsnachweis: abgeleitete Topologie == reale DB-FK-Constraints.
await assertTopologyMatchesDatabase(prisma);
const topo = buildTenantTopology();
const result = await prisma.$transaction(
async (tx) => {
// Mandant existiert?
const tenant = await tx.tenant.findUnique({
where: { id: tenantId },
select: { id: true, slug: true },
});
if (!tenant) throw new Error(`exportTenant: Mandant ${tenantId} existiert nicht.`);
// Zuletzt angewandte Migration (Kohärenzprüfung beim Restore).
const migRows = await tx.$queryRawUnsafe<{ migration_name: string }[]>(
`SELECT migration_name FROM _prisma_migrations WHERE finished_at IS NOT NULL ORDER BY finished_at DESC LIMIT 1`,
);
const schemaMigration = migRows[0]?.migration_name ?? null;
const lines: string[] = [];
const tableManifests: TableManifest[] = [];
// pk-id-Mengen je Modell (für idSet-Scope der Join-Tabellen).
const idSets = new Map<string, Set<string>>();
const identityRefs = new Set<string>();
let totalRows = 0;
for (const node of topo.insertOrder) {
const delegate = (tx as unknown as Record<string, {
findMany: (a: unknown) => Promise<Record<string, unknown>[]>;
}>)[node.delegate];
let rows: TableRows;
if (node.scope.by === "tenantColumn") {
rows = await delegate.findMany({ where: { tenantId } });
} else {
// Join-Tabelle: über die id-Menge des mandanten-gescopten Elternteils.
const via = node.scope.via;
const parentIds = idSets.get(via.parent) ?? new Set<string>();
const fromField = via.fromFields[0];
rows = parentIds.size
? await delegate.findMany({ where: { [fromField]: { in: [...parentIds] } } })
: [];
}
// pk-id-Menge dieses Modells festhalten (nur Single-Column-PK relevant
// als Scoping-Elternteil — User.id/Role.id).
if (node.pk.length === 1) {
const pkField = node.pk[0];
const s = new Set<string>();
for (const r of rows) {
const v = r[pkField];
if (typeof v === "string") s.add(v);
}
idSets.set(node.model, s);
}
// Identity-Referenzen aus User-Zeilen (Identity-Stub-Guard beim Restore).
if (node.model === "User") {
for (const r of rows) {
const idn = r["identityId"];
if (typeof idn === "string") identityRefs.add(idn);
}
}
const { line, manifest } = serializeTable(node.model, node.table, rows);
lines.push(line);
tableManifests.push(manifest);
totalRows += rows.length;
}
const manifest: BackupManifest = {
formatVersion: ARTIFACT_FORMAT_VERSION,
tenantId,
tenantSlug: tenant.slug,
snapshotAt: new Date().toISOString(),
schemaMigration,
tables: tableManifests,
identityRefs: [...identityRefs],
totalRows,
};
return { manifest, body: lines.join("\n") };
},
{ isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead, timeout: 120_000 },
);
const artifact = sealArtifact(result.body);
const snapshotId = newSnapshotId();
let artifactKey: string | null = null;
if (persist) {
const store = await getBackupStore();
const prefix = snapshotPrefix(tenantId, snapshotId);
artifactKey = `${prefix}artifact.cvb`;
await store.put(artifactKey, artifact);
// Manifest zusätzlich im Klartext (Dry-run/Preview/Listing; enthält keine PII).
await store.put(
`${prefix}manifest.json`,
Buffer.from(JSON.stringify(result.manifest, null, 2), "utf8"),
);
// Optional: Mandanten-Dateien (MinIO uploads-Prefix) mitsichern (best effort).
if (opts.includeFiles) {
try {
const fileKeys = await store.list(`${tenantId}/uploads/`);
for (const key of fileKeys) {
const bytes = await store.get(key);
if (bytes) await store.put(`${prefix}files/${key}`, bytes);
}
} catch {
// Datei-Snapshot ist best effort; der DB-Snapshot bleibt gültig.
}
}
}
return { tenantId, snapshotId, manifest: result.manifest, artifact, artifactKey };
}
/** Verschlüsseltes Artefakt eines Snapshots aus dem Objektspeicher laden. */
export async function loadArtifact(tenantId: string, snapshotId: string): Promise<Buffer> {
const key = `${snapshotPrefix(tenantId, snapshotId)}artifact.cvb`;
const blob = await (await getBackupStore()).get(key);
if (!blob) throw new Error(`Artefakt ${key} nicht gefunden.`);
return blob;
}
/** Snapshot-Ids eines Mandanten (neueste zuletzt), aus dem Objektspeicher. */
export async function listSnapshots(tenantId: string): Promise<string[]> {
const keys = await (await getBackupStore()).list(tenantBackupPrefix(tenantId));
const ids = new Set<string>();
for (const k of keys) {
const rest = k.slice(tenantBackupPrefix(tenantId).length);
const slash = rest.indexOf("/");
if (slash > 0) ids.add(rest.slice(0, slash));
}
return [...ids].sort();
}
+26
View File
@@ -0,0 +1,26 @@
// Öffentliche API der Backup-/Restore-Engine (Schicht B, mandanten-scoped).
export {
buildTenantTopology,
assertTopologyMatchesDatabase,
TENANT_MODELS,
JOIN_MODELS,
type TenantTopology,
type TableNode,
} from "./topology";
export {
exportTenant,
loadArtifact,
listSnapshots,
tenantBackupPrefix,
type ExportOptions,
type ExportResult,
} from "./export";
export {
restoreTenant,
type RestoreSource,
type RestoreOptions,
type RestoreResult,
type StubIdentity,
} from "./restore";
export { sealArtifact, openArtifact } from "./crypto";
export type { BackupManifest } from "./serialization";
+44
View File
@@ -0,0 +1,44 @@
// ── Job-Nutzlast der Backup-/DSGVO-Ops-Queue (Betreiber-Portal) ──────────────
//
// Analog zur Mail-Queue (SEC1). Die Nutzlast trägt NUR IDs/Metadaten — KEINE
// Secrets, KEINE PII. Der Worker lädt Artefakte/Personendaten anhand der IDs
// serverseitig; die zugehörige `BackupJob`-Zeile (Status/Ergebnis) wird über
// `jobId` fortgeschrieben (KONZEPT §4: die Action enqueued nur).
export const BACKUP_QUEUE = "backup-ops";
export const BACKUP_DLQ = "backup-ops-dead-letter";
/** Portal-Restore: destruktiver Wipe+Restore genau EINES Mandanten. */
export interface TenantRestoreJob {
kind: "tenant_restore";
jobId: string;
tenantId: string;
snapshotId: string;
actorId: string;
restoreFiles?: boolean;
}
/** „Export jetzt" — On-demand-Sicherung eines Mandanten. */
export interface TenantExportJob {
kind: "tenant_export";
jobId: string;
tenantId: string;
reason: string;
actorId: string;
includeFiles?: boolean;
}
/** DSGVO-Zustellung: ZIP-Paket (Per-Mandant oder Per-Person) + signierter Link. */
export interface DsgvoExportJob {
kind: "dsgvo_export";
jobId: string;
tenantId: string;
/** Gesetzt → Per-Person-Auskunft; leer → Per-Mandant-Paket. */
subjectIdentityId?: string | null;
actorId: string;
}
export type BackupOpsJob = TenantRestoreJob | TenantExportJob | DsgvoExportJob;
/** Gültigkeitsdauer des DSGVO-Download-Links (kurze TTL, KONZEPT §5). */
export const DSGVO_DOWNLOAD_TTL_MS = 60 * 60 * 1000; // 1 Stunde
+128
View File
@@ -0,0 +1,128 @@
// ── Ausführung der Backup-/DSGVO-Ops-Jobs (Worker-Seite) ─────────────────────
//
// `processBackupJob` ist die reine, DB-gebundene Ausführung EINES Jobs — bewusst
// getrennt vom BullMQ-Worker, damit die Enqueue-/Kontroll- und Ausführungslogik
// auch OHNE laufendes Redis testbar ist (der Test ruft processBackupJob direkt).
// Der Worker (worker.ts) reicht die Job-Nutzlast nur hierher durch.
//
// Alle Fach-Engines (restoreTenant/exportTenant/DSGVO-Export) werden NUR
// aufgerufen, nicht verändert. Status/Ergebnis wandern in die `BackupJob`-Zeile;
// dort landen KEINE Secrets und KEINE PII (nur Zeilenzahlen/Metadaten).
import { randomBytes } from "node:crypto";
import { prisma } from "../db";
import { restoreTenant } from "./restore";
import { exportTenant } from "./export";
import { buildDsgvoPackage } from "./dsgvo-zip";
import { getBackupStore } from "../storage/backup-store";
import { writePlatformAudit } from "../audit";
import { DSGVO_DOWNLOAD_TTL_MS, type BackupOpsJob } from "./job";
/** Storage-Key des DSGVO-ZIP eines Jobs (getrennter Prefix, mandantenpräfixiert). */
export function dsgvoPackageKey(tenantId: string, jobId: string): string {
return `${tenantId}/dsgvo-exports/${jobId}.zip`;
}
async function markRunning(jobId: string): Promise<void> {
await prisma.backupJob.update({ where: { id: jobId }, data: { status: "running" } });
}
/** Führt EINEN Backup-/DSGVO-Job aus und schreibt Status/Ergebnis zurück. */
export async function processBackupJob(job: BackupOpsJob): Promise<void> {
await markRunning(job.jobId);
try {
switch (job.kind) {
case "tenant_restore": {
const res = await restoreTenant(
job.tenantId,
{ snapshotId: job.snapshotId },
{ actorId: job.actorId, restoreFiles: job.restoreFiles },
);
await prisma.backupJob.update({
where: { id: job.jobId },
data: {
status: "done",
result: {
restoredRows: res.restoredRows,
deletedRows: res.deletedRows,
preRestoreSnapshotId: res.preRestoreSnapshotId,
stubIdentities: res.stubIdentities.length,
relinkedIdentities: res.relinkedIdentities.length,
tombstonesReapplied: res.tombstonesReapplied,
},
},
});
await writePlatformAudit({
actorId: job.actorId,
action: "update",
entity: "backup_job",
entityId: job.jobId,
after: { kind: job.kind, tenantId: job.tenantId, snapshotId: job.snapshotId, status: "done" },
});
break;
}
case "tenant_export": {
const res = await exportTenant(job.tenantId, {
reason: job.reason,
persist: true,
includeFiles: job.includeFiles,
});
await prisma.backupJob.update({
where: { id: job.jobId },
data: {
status: "done",
snapshotId: res.snapshotId,
result: { snapshotId: res.snapshotId, totalRows: res.manifest.totalRows },
},
});
await writePlatformAudit({
actorId: job.actorId,
action: "create",
entity: "backup_job",
entityId: job.jobId,
after: { kind: job.kind, tenantId: job.tenantId, snapshotId: res.snapshotId, status: "done" },
});
break;
}
case "dsgvo_export": {
const pkg = await buildDsgvoPackage(job.tenantId, job.subjectIdentityId);
const key = dsgvoPackageKey(job.tenantId, job.jobId);
await (await getBackupStore()).put(key, pkg.zip);
// Signierter, ablaufender Download: opakes Zufalls-Token + kurze TTL.
const token = randomBytes(32).toString("base64url");
const expiresAt = new Date(Date.now() + DSGVO_DOWNLOAD_TTL_MS);
await prisma.backupJob.update({
where: { id: job.jobId },
data: {
status: "done",
downloadToken: token,
downloadExpiresAt: expiresAt,
result: { ...pkg.summary, storageKey: key, expiresAt: expiresAt.toISOString() },
},
});
await writePlatformAudit({
actorId: job.actorId,
action: "export",
entity: "backup_job",
entityId: job.jobId,
// KEIN Token/keine PII ins Audit — nur Metadaten.
after: { kind: job.kind, tenantId: job.tenantId, scope: pkg.summary.scope, status: "done" },
});
break;
}
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await prisma.backupJob
.update({ where: { id: job.jobId }, data: { status: "failed", error: message } })
.catch(() => {});
await writePlatformAudit({
actorId: job.actorId,
action: "denied",
entity: "backup_job",
entityId: job.jobId,
after: { kind: job.kind, tenantId: job.tenantId, status: "failed", error: message },
}).catch(() => {});
throw err;
}
}
+130
View File
@@ -0,0 +1,130 @@
// ── BullMQ-Queue der Backup-/DSGVO-Ops (Betreiber-Portal) ────────────────────
//
// Eigenständig aufgebaut, analog zur Mail-Queue (SEC1, src/server/mail/queue.ts),
// aber mit EIGENER Queue (`backup-ops`) und eigenen Redis-Verbindungen — ein
// BullMQ-Worker konsumiert ALLE Jobs seiner Queue, also darf der Backup-Worker
// nicht auf `mail` liegen.
//
// Betriebsmodus:
// - `REDIS_URL` gesetzt → Jobs laufen asynchron über den Worker
// (`npm run worker:backup`, eigener Container).
// - ohne `REDIS_URL` → KEIN Queue-Betrieb. Portal-Restore/Export/DSGVO sind
// destruktiv bzw. langlaufend und werden NIE inline in der Server-Action
// ausgeführt (KONZEPT §4). Die Action meldet dann, dass Redis/Worker fehlt.
import { Queue } from "bullmq";
import IORedis, { type Redis } from "ioredis";
import { BACKUP_DLQ, BACKUP_QUEUE, type BackupOpsJob } from "./job";
let queue: Queue<BackupOpsJob> | null = null;
let deadLetter: Queue<{ job: BackupOpsJob; error: string }> | null = null;
let producerConnection: Redis | null = null;
let workerConnection: Redis | null = null;
let logged = false;
export function backupRedisUrl(): string | undefined {
const v = process.env.REDIS_URL?.trim();
return v ? v : undefined;
}
export function isBackupQueueEnabled(): boolean {
return backupRedisUrl() != null;
}
/** Producer-Verbindung (Server-Action): fail-fast, kein stilles Puffern. */
function getProducerConnection(): Redis | null {
const url = backupRedisUrl();
if (!url) return null;
if (!producerConnection) {
producerConnection = new IORedis(url, {
maxRetriesPerRequest: 1,
enableReadyCheck: false,
enableOfflineQueue: false,
connectTimeout: 3_000,
retryStrategy: (times) => Math.min(times * 500, 5_000),
lazyConnect: false,
});
producerConnection.on("error", (err) => {
console.error("[backup] Redis (Producer) nicht erreichbar:", err.message);
});
}
return producerConnection;
}
/** Ist die Producer-Verbindung gerade wirklich benutzbar? */
export function isBackupQueueReady(): boolean {
return getProducerConnection()?.status === "ready";
}
/** Worker-Verbindung: robust (blockierende Reads brauchen maxRetriesPerRequest=null). */
export function getBackupConnection(): Redis | null {
const url = backupRedisUrl();
if (!url) return null;
if (!workerConnection) {
workerConnection = new IORedis(url, {
maxRetriesPerRequest: null,
enableReadyCheck: false,
});
workerConnection.on("error", (err) => {
console.error("[backup] Redis (Worker) Verbindungsfehler:", err.message);
});
}
return workerConnection;
}
export function getBackupQueue(): Queue<BackupOpsJob> | null {
const conn = getProducerConnection();
if (!conn) {
if (!logged) {
console.warn(
"[backup] REDIS_URL nicht gesetzt — Portal-Restore/Export/DSGVO benötigen den Worker (kein Inline-Betrieb).",
);
logged = true;
}
return null;
}
if (!queue) {
queue = new Queue<BackupOpsJob>(BACKUP_QUEUE, {
connection: conn,
defaultJobOptions: {
// Restore ist destruktiv → NUR EIN Versuch, kein automatisches Retry
// (ein halb gelaufener Restore darf nicht blind wiederholt werden; die
// Engine ist zwar idempotent, aber die Wiederholung ist eine bewusste
// Betreiber-Entscheidung, kein Automatismus).
attempts: 1,
removeOnComplete: { age: 30 * 24 * 3600, count: 500 },
removeOnFail: { age: 30 * 24 * 3600 },
},
});
if (!logged) {
console.info("[backup] Queue aktiv (BullMQ) — Backup-/DSGVO-Jobs laufen über den Worker.");
logged = true;
}
}
return queue;
}
/** Dead-Letter-Queue: Jobs nach dem endgültigen Fehlversuch (nur Worker). */
export function getBackupDeadLetterQueue(): Queue<{ job: BackupOpsJob; error: string }> | null {
const conn = getBackupConnection();
if (!conn) return null;
if (!deadLetter) {
deadLetter = new Queue<{ job: BackupOpsJob; error: string }>(BACKUP_DLQ, {
connection: conn,
defaultJobOptions: { removeOnComplete: false, removeOnFail: false },
});
}
return deadLetter;
}
/** Verbindungen schließen (Worker-Shutdown, Tests). */
export async function closeBackupQueues(): Promise<void> {
await queue?.close();
await deadLetter?.close();
queue = null;
deadLetter = null;
producerConnection?.disconnect();
producerConnection = null;
workerConnection?.disconnect();
workerConnection = null;
}
+15
View File
@@ -0,0 +1,15 @@
// ── Reine Validierung der getippten Restore-Bestätigung ──────────────────────
//
// Bewusst getrennt von der Server-Action (backup-admin.ts): eine "use server"-
// Datei darf NUR async Funktionen exportieren. Diese synchronen, seiteneffekt-
// freien Helfer sind so auch direkt (unit-)testbar.
/** Erwartete getippte Bestätigung für einen Restore (exakt, case-sensitive). */
export function restoreConfirmationFor(slug: string): string {
return `RESTORE ${slug}`;
}
/** Prüft die getippte Bestätigung exakt gegen „RESTORE <slug>". */
export function matchesRestoreConfirmation(slug: string, typed: string): boolean {
return typed === restoreConfirmationFor(slug);
}
+301
View File
@@ -0,0 +1,301 @@
// ── restoreTenant: gezielter, isolierter Wiederherstellungs-Lauf ─────────────
//
// Owner-`prisma` (BYPASSRLS), EINE Transaktion für den destruktiven Replace.
// Ablauf (KONZEPT §3):
// 1. Mandant sperren (status = SUSPENDED) — verhindert parallele Schreibzugriffe.
// 2. Pre-Restore-Sicherheitsschnappschuss (Restore ist damit reversibel).
// 3. Identity-Stub-Guard: fehlende globale Identities re-provisionieren/neu verknüpfen.
// 4. Replace in einer Owner-Transaktion: DELETE child→parent + Reinsert parent→child
// mit ORIGINALEN cuid-PKs. FK-Trigger via `session_replication_role = replica`
// (SET LOCAL, superuser) deaktiviert → Selbstreferenzen (processes.parent_id)
// und Rest-Reihenfolge sind unkritisch; der Snapshot ist in sich konsistent.
// 5. Tombstones erneut anwenden (ein alter Snapshot darf gelöschte PII NICHT
// zurückbringen — DSGVO, KONZEPT §6).
// 6. Mandant reaktivieren + Audit.
//
// Beweisbar isoliert: jede Operation ist auf `tenant_id = A` (bzw. die id-Menge
// der Tenant-User/-Rollen) begrenzt (Isolationstest: test-backup-isolation.ts).
import { prisma } from "../db";
import { writeAuditLog } from "../audit";
import { buildTenantTopology, assertTopologyMatchesDatabase } from "./topology";
import { parseArtifactBody, verifyTableChecksum, type BackupManifest, type TableRows } from "./serialization";
import { openArtifact } from "./crypto";
import { exportTenant, loadArtifact } from "./export";
import { getBackupStore } from "../storage/backup-store";
import { applyTombstones } from "../dsgvo/tombstone";
/** passwordHash-Sentinel für Stub-Identities: kein gültiger Argon2-PHC → Login fällt fail-closed. */
const STUB_PASSWORD_SENTINEL = "!stub-no-login!";
export interface RestoreSource {
/** In-Memory-Artefakt (z. B. Isolationstest) ODER ... */
artifact?: Buffer;
/** ... Snapshot-Id im Objektspeicher. */
snapshotId?: string;
/** Optionales Manifest zur Checksummen-/Kohärenzprüfung (bei snapshotId geladen). */
manifest?: BackupManifest;
}
export interface RestoreOptions {
actorId?: string;
/** Pre-Restore-Schnappschuss überspringen (nur Tests). Default false. */
skipPreRestore?: boolean;
/** Schema-Migrations-Abweichung tolerieren (Default false → Abweisung). */
allowSchemaMismatch?: boolean;
/** Mandanten-Dateien aus dem Snapshot mit wiederherstellen (Default false). */
restoreFiles?: boolean;
}
export interface StubIdentity {
identityId: string;
email: string;
name: string;
}
export interface RestoreResult {
tenantId: string;
restoredRows: number;
deletedRows: number;
/** Neu re-provisionierte Identity-Stubs (Einladungs-Flow anstoßen). */
stubIdentities: StubIdentity[];
/** Auf existierende Identities per E-Mail neu verknüpfte Referenzen. */
relinkedIdentities: { from: string; to: string }[];
preRestoreSnapshotId: string | null;
tombstonesReapplied: number;
}
/** Aktuell angewandte Migration (für die Kohärenzprüfung). */
async function currentMigration(): Promise<string | null> {
const rows = await prisma.$queryRawUnsafe<{ migration_name: string }[]>(
`SELECT migration_name FROM _prisma_migrations WHERE finished_at IS NOT NULL ORDER BY finished_at DESC LIMIT 1`,
);
return rows[0]?.migration_name ?? null;
}
export async function restoreTenant(
tenantId: string,
source: RestoreSource,
opts: RestoreOptions = {},
): Promise<RestoreResult> {
if (!tenantId) throw new Error("restoreTenant: tenantId ist erforderlich.");
await assertTopologyMatchesDatabase(prisma);
const topo = buildTenantTopology();
// Artefakt beschaffen.
const blob =
source.artifact ??
(source.snapshotId ? await loadArtifact(tenantId, source.snapshotId) : null);
if (!blob) throw new Error("restoreTenant: weder artifact noch snapshotId angegeben.");
// Manifest beschaffen (für Kohärenz-/Checksummenprüfung).
let manifest = source.manifest ?? null;
if (!manifest && source.snapshotId) {
const mj = await (await getBackupStore()).get(`${tenantId}/backups/${source.snapshotId}/manifest.json`);
if (mj) manifest = JSON.parse(mj.toString("utf8")) as BackupManifest;
}
// Blob → Tabellen (geordnet parent→child).
const body = openArtifact(blob);
const tables = parseArtifactBody(body);
const rowsByModel = new Map<string, TableRows>();
for (const t of tables) rowsByModel.set(t.model, t.rows);
// Manifest-Kohärenz: Zielmandant + Schema-Migration.
if (manifest) {
if (manifest.tenantId !== tenantId) {
throw new Error(
`restoreTenant: Artefakt gehört Mandant ${manifest.tenantId}, Ziel ist ${tenantId} — abgewiesen.`,
);
}
const cur = await currentMigration();
if (!opts.allowSchemaMismatch && manifest.schemaMigration && cur && manifest.schemaMigration !== cur) {
throw new Error(
`restoreTenant: Schema-Migration im Artefakt (${manifest.schemaMigration}) ≠ DB (${cur}). ` +
`Artefakt vor dem Reinsert migrieren oder allowSchemaMismatch setzen.`,
);
}
// Integritätsnachweis je Tabelle.
for (const tm of manifest.tables) {
const rows = rowsByModel.get(tm.model) ?? [];
if (!verifyTableChecksum(tm.model, rows, tm.checksum)) {
throw new Error(`restoreTenant: Checksummen-Mismatch bei ${tm.model} — Artefakt beschädigt.`);
}
}
}
// Zielmandant prüfen + vorherigen Status merken.
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { id: true, status: true },
});
if (!tenant) throw new Error(`restoreTenant: Mandant ${tenantId} existiert nicht.`);
const previousStatus = tenant.status;
// 1. Sperren (eigener Commit, damit die Sperre unabhängig vom Replace sichtbar ist).
await prisma.tenant.update({ where: { id: tenantId }, data: { status: "SUSPENDED" } });
// 2. Pre-Restore-Sicherheitsschnappschuss.
let preRestoreSnapshotId: string | null = null;
if (!opts.skipPreRestore) {
const snap = await exportTenant(tenantId, { reason: "pre-restore", persist: true });
preRestoreSnapshotId = snap.snapshotId;
}
// 3. Identity-Stub-Guard (globale Schicht A; NICHT im Tenant-Artefakt).
const userRows = rowsByModel.get("User") ?? [];
const stubIdentities: StubIdentity[] = [];
const relinked: { from: string; to: string }[] = [];
const remap = new Map<string, string>();
{
const refs = new Map<string, { email: string; name: string }>();
for (const u of userRows) {
const idn = u["identityId"];
if (typeof idn === "string") {
refs.set(idn, {
email: String(u["email"] ?? ""),
name: String(u["name"] ?? ""),
});
}
}
for (const [identityId, info] of refs) {
const byId = await prisma.identity.findUnique({ where: { id: identityId }, select: { id: true } });
if (byId) continue;
const byEmail = info.email
? await prisma.identity.findUnique({ where: { email: info.email }, select: { id: true } })
: null;
if (byEmail) {
// Neu verknüpfen (KONZEPT §8): User zeigt künftig auf die existierende Identity.
remap.set(identityId, byEmail.id);
relinked.push({ from: identityId, to: byEmail.id });
} else {
// Stub re-provisionieren: neutraler Status, KEINE Secrets, Einladungs-Flow.
// Identity trägt KEINEN Namen (der lebt denormalisiert auf User); der
// Stub braucht nur E-Mail + neutralen Status, KEINE Secrets.
await prisma.identity.create({
data: {
id: identityId,
email: info.email || `stub+${identityId}@invalid.local`,
passwordHash: STUB_PASSWORD_SENTINEL,
mustChangePassword: true,
status: "DISABLED",
},
});
stubIdentities.push({ identityId, email: info.email, name: info.name });
}
}
// Remap auf die zu reinsertenden User-Zeilen anwenden.
if (remap.size) {
for (const u of userRows) {
const idn = u["identityId"];
if (typeof idn === "string" && remap.has(idn)) u["identityId"] = remap.get(idn);
}
}
}
// 4. + 5. Destruktiver Replace + Tombstones in EINER Owner-Transaktion.
let deletedRows = 0;
let restoredRows = 0;
let tombstonesReapplied = 0;
await prisma.$transaction(
async (tx) => {
// FK-Trigger deaktivieren (superuser) → Selbstreferenzen/Reihenfolge unkritisch.
// SET LOCAL: gilt nur in dieser Transaktion, wird bei Commit/Rollback verworfen.
await tx.$executeRawUnsafe(`SET LOCAL session_replication_role = replica`);
await tx.$executeRawUnsafe(`SET CONSTRAINTS ALL DEFERRED`);
// DELETE child→parent, streng tenant-gescopt.
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;
}
}
}
// Reinsert parent→child mit ORIGINALEN PKs.
for (const node of topo.insertOrder) {
const rows = rowsByModel.get(node.model) ?? [];
if (!rows.length) continue;
const create = (tx as unknown as Record<string, {
createMany: (a: unknown) => Promise<{ count: number }>;
}>)[node.delegate];
const r = await create.createMany({ data: rows });
restoredRows += r.count;
}
// Tombstones erneut anwenden (gelöschte PII bleibt gelöscht).
tombstonesReapplied = await applyTombstones(tx as never, tenantId);
},
{ timeout: 300_000 },
);
// 6. Reaktivieren (Vorstatus, aber nie ARCHIVED reaktivieren).
await prisma.tenant.update({
where: { id: tenantId },
data: { status: previousStatus === "ARCHIVED" ? "ACTIVE" : previousStatus },
});
// Mandanten-Dateien (best effort).
if (opts.restoreFiles && source.snapshotId) {
try {
const store = await getBackupStore();
const prefix = `${tenantId}/backups/${source.snapshotId}/files/`;
const keys = await store.list(prefix);
for (const k of keys) {
const bytes = await store.get(k);
if (bytes) await store.put(k.slice(prefix.length), bytes);
}
} catch {
/* best effort */
}
}
await writeAuditLog({
tenantId,
actorId: opts.actorId,
action: "import",
entity: "tenant_restore",
entityId: source.snapshotId ?? "in-memory",
after: {
restoredRows,
deletedRows,
preRestoreSnapshotId,
stubIdentities: stubIdentities.length,
relinkedIdentities: relinked.length,
tombstonesReapplied,
},
});
return {
tenantId,
restoredRows,
deletedRows,
stubIdentities,
relinkedIdentities: relinked,
preRestoreSnapshotId,
tombstonesReapplied,
};
}
+105
View File
@@ -0,0 +1,105 @@
// ── NDJSON-Serialisierung + Manifest für Tenant-Artefakte ────────────────────
//
// Ein Artefakt ist ein einzelner gzip+AES-verschlüsselter Blob. Vor der
// Kompression liegt es als Text vor:
//
// Zeile 1 .. N-1: je eine Tabelle als JSON { "model": "...", "rows": [...] }
// (die Tabellen in Insert-Reihenfolge parent→child)
//
// Die Zeilenzahlen/Prüfsummen je Tabelle stehen im separaten Manifest (nicht
// verschlüsselt zwingend, aber Teil des Blobs). Werte werden mit einem
// typerhaltenden Replacer serialisiert: `Date`→{__t:"date"}, `BigInt`→{__t:"bigint"}
// (BigInt kommt in Tenant-Modellen aktuell nicht vor, wird aber defensiv gestützt).
import { createHash } from "node:crypto";
/** Schema-/Format-Version des Artefakt-Layouts (nicht die Prisma-Migration). */
export const ARTIFACT_FORMAT_VERSION = 1;
export interface TableManifest {
model: string;
table: string;
rowCount: number;
/** SHA-256 über die serialisierten Zeilen (Integritätsnachweis). */
checksum: string;
}
export interface BackupManifest {
formatVersion: number;
tenantId: string;
tenantSlug: string;
/** ISO-Zeitpunkt des konsistenten Snapshots. */
snapshotAt: string;
/** Zuletzt angewandte Prisma-Migration (Kohärenzprüfung beim Restore). */
schemaMigration: string | null;
tables: TableManifest[];
/** Distinkte identityId-Referenzen der User-Zeilen (für Identity-Stub-Guard). */
identityRefs: string[];
totalRows: number;
}
interface TypedDate {
__t: "date";
v: string;
}
interface TypedBigInt {
__t: "bigint";
v: string;
}
function replacer(_key: string, value: unknown): unknown {
if (value instanceof Date) return { __t: "date", v: value.toISOString() } satisfies TypedDate;
if (typeof value === "bigint") return { __t: "bigint", v: value.toString() } satisfies TypedBigInt;
return value;
}
function reviveValue(value: unknown): unknown {
if (value && typeof value === "object") {
const t = (value as { __t?: string }).__t;
if (t === "date") return new Date((value as TypedDate).v);
if (t === "bigint") return BigInt((value as TypedBigInt).v);
if (Array.isArray(value)) return value.map(reviveValue);
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) out[k] = reviveValue(v);
return out;
}
return value;
}
export type TableRows = Record<string, unknown>[];
/** Serialisiert die Zeilen einer Tabelle zu einer NDJSON-Zeile + Manifest-Eintrag. */
export function serializeTable(
model: string,
table: string,
rows: TableRows,
): { line: string; manifest: TableManifest } {
const payload = JSON.stringify({ model, rows }, replacer);
const checksum = createHash("sha256").update(payload).digest("hex");
return {
line: payload,
manifest: { model, table, rowCount: rows.length, checksum },
};
}
/** Wandelt das gesamte NDJSON (ohne Manifest-Zeile) in geordnete Tabellen zurück. */
export function parseArtifactBody(body: string): { model: string; rows: TableRows }[] {
const out: { model: string; rows: TableRows }[] = [];
for (const line of body.split("\n")) {
if (!line.trim()) continue;
const parsed = JSON.parse(line) as { model: string; rows: TableRows };
const rows = (reviveValue(parsed.rows) as TableRows) ?? [];
out.push({ model: parsed.model, rows });
}
return out;
}
/** Prüft eine Tabellen-Zeile gegen den Manifest-Checksum (Integritätsnachweis). */
export function verifyTableChecksum(
model: string,
rows: TableRows,
expected: string,
): boolean {
const payload = JSON.stringify({ model, rows }, replacer);
return createHash("sha256").update(payload).digest("hex") === expected;
}
+450
View File
@@ -0,0 +1,450 @@
// ── Traversierungs-Engine: FK-sichere Reihenfolge der mandanten-scoped Tabellen ──
//
// Phase 1 der Backup-/Restore-/DSGVO-Lane. Leitet EINE deterministische
// Tabellen-Reihenfolge ab, die von Export (parent→child), Restore (Reinsert
// parent→child, DELETE child→parent) und DSGVO-Löschung geteilt wird.
//
// Quelle der Wahrheit:
// • Modell-Menge = `TENANT_MODELS` (aus src/server/db.ts) ∪ {UserRole, RolePermission}.
// Die beiden Join-Tabellen tragen KEIN tenant_id (kein RLS) und sind bewusst
// NICHT in TENANT_MODELS — ohne sie gingen beim Restore die Rollenzuweisungen
// verloren. RolePermission.permissionId zeigt auf den GLOBALEN Permission-
// Katalog (Schicht A) → nur die (roleId, permissionId)-Zuordnung wird
// mit-exportiert, der Katalog selbst NICHT.
// • Tabellennamen/Spalten = Prisma-DMMF (`Prisma.dmmf`), das die @@map-/@map-
// Namen autoritativ trägt.
// • FK-Kanten = die `@relation(fields: […])`-Attribute der kanonischen
// `prisma/schema.prisma`. Grund: Prisma 7 entfernt `relationFromFields` aus
// dem Laufzeit-DMMF (sowohl `Prisma.dmmf` als auch `_runtimeDataModel`), sodass
// die Kantenrichtung dort nicht mehr ableitbar ist. Die Relation-Attribute im
// Schema sind die IDENTISCHE Quelle, aus der Prisma das DMMF erzeugt.
//
// Sicherheitsnachweis (destruktive Lane): `assertTopologyMatchesDatabase()`
// vergleicht die abgeleiteten Kanten gegen die realen FK-Constraints in Postgres
// (`information_schema`). Jede Abweichung ist ein harter Fehler (fail-closed) —
// so kann eine Schemaänderung die Traversierung nicht still invalidieren.
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { Prisma } from "@prisma/client";
import type { PrismaClient } from "@prisma/client";
/**
* Mandanten-scoped Modelle mit `tenant_id`-Spalte (Spiegel von `TENANT_MODELS`
* in src/server/db.ts — dort ist die Menge NICHT exportiert, hier bewusst
* dupliziert und per `assertTenantModelsInSync()` im Test gegen die DB geprüft).
*/
export const TENANT_MODELS: readonly string[] = [
"User",
"Role",
"AuditLog",
"MailLog",
"NotificationPreference",
"AuthToken",
"Task",
"TaskComment",
"TaskParticipant",
"Evidence",
"Audit",
"AuditEvidenceItem",
"ControlDescription",
"ManagedRegister",
"RegisterRow",
"OnboardingProgress",
"ProjectFunctionAssignment",
"WizardFact",
"WizardScope",
"PolicyPackageState",
"TenantSettings",
"TenantModule",
"Asset",
"AssetRelation",
"Process",
"ProcessAsset",
"BiaEntry",
"Risk",
"RiskAsset",
"Measure",
"RiskMeasure",
"SupplierProfile",
"ITServiceProfile",
"SoftwareProfile",
"ProjectProfile",
"SupplierAssessment",
"Contract",
"Nda",
"SupplierEvidence",
"ServiceControlResponsibility",
"Subcontractor",
"ManagementDecision",
"MaturityAssessment",
"ControlAssessment",
"ControlImplementation",
"PolicyDocument",
"PolicyRequirement",
"PolicyVariable",
"PolicyBaselineParam",
"PolicyEvidence",
"CryptoEntry",
"ClassificationClass",
"HandlingAspect",
"HandlingRule",
"RiskMatrixClass",
"RiskEwLevel",
"RiskDamageDimension",
"HandbookTopic",
];
/**
* Join-Tabellen ohne eigenes tenant_id, aber tenant-relevant. Werden über die
* id-Menge der Tenant-User/-Rollen ein-/ausgeschlossen (siehe `TableNode.scope`).
*/
export const JOIN_MODELS: readonly string[] = ["UserRole", "RolePermission"];
export interface FkEdge {
/** Kind-Modell (hält die FK-Spalte). */
child: string;
/** Eltern-Modell (Ziel der FK). */
parent: string;
/** Prisma-Feldnamen der FK-Skalare im Kind. */
fromFields: string[];
/** DB-Spaltennamen der FK-Skalare im Kind. */
fromColumns: string[];
}
export type ScopeKind =
| { by: "tenantColumn"; column: string }
| { by: "idSet"; via: FkEdge };
export interface TableNode {
/** Prisma-Modellname (z. B. "ITServiceProfile"). */
model: string;
/** Prisma-Client-Delegate-Name (z. B. "iTServiceProfile"). */
delegate: string;
/** DB-Tabellenname (@@map). */
table: string;
/** Primärschlüssel-Skalarfelder (Prisma-Namen). ["id"] bzw. Join-Composite. */
pk: string[];
/** `true`, wenn das Modell eine tenant_id-Spalte trägt. */
hasTenantColumn: boolean;
/**
* Wie die Zeilen dieses Modells auf einen Mandanten begrenzt werden:
* - tenantColumn: direktes WHERE tenant_id = A.
* - idSet: über die FK auf ein bereits mandanten-gescoptes Eltern-Modell
* (Join-Tabellen: WHERE <fk> IN (ids der Tenant-Zeilen des Elternteils)).
*/
scope: ScopeKind;
}
export interface TenantTopology {
/** Knoten nach Modellname. */
nodes: Map<string, TableNode>;
/** Reihenfolge parent→child (Export-Reihenfolge, Restore-Reinsert). */
insertOrder: TableNode[];
/** Reihenfolge child→parent (Restore-/Löschungs-DELETE). */
deleteOrder: TableNode[];
/** Alle FK-Kanten innerhalb der Modell-Menge. */
edges: FkEdge[];
}
/** Prisma-Client-Delegate-Name (nur erster Buchstabe klein — Prisma-Konvention). */
function delegateName(model: string): string {
return model.charAt(0).toLowerCase() + model.slice(1);
}
// ── Schema-Parsing (FK-Kanten + PK) ─────────────────────────────────────────
interface ParsedModel {
fields: {
name: string;
/** Zielmodell, falls Relationsfeld; sonst undefined. */
relationTarget?: string;
/** FK-Feldnamen aus @relation(fields: […]) (nur auf der FK-Haltenden Seite). */
relationFromFields?: string[];
isId?: boolean;
}[];
/** Composite-PK aus @@id([...]). */
compositeId?: string[];
}
let schemaCache: Map<string, ParsedModel> | null = null;
function locateSchema(): string {
const here = dirname(fileURLToPath(import.meta.url));
// src/server/backup → Repo-Wurzel/prisma/schema.prisma
return join(here, "..", "..", "..", "prisma", "schema.prisma");
}
/**
* Minimaler, robuster Parser für die `@relation(fields: […])`-Topologie und die
* Primärschlüssel. Bewusst kein voller PSL-Parser: es genügt, je Modell die
* Relationsfelder mit `fields:[…]` und die `@id`/`@@id`-Angaben zu erkennen.
*/
function parseSchema(): Map<string, ParsedModel> {
if (schemaCache) return schemaCache;
const src = readFileSync(locateSchema(), "utf8");
const models = new Map<string, ParsedModel>();
// Zuerst alle Modellnamen sammeln (zur Relations-Ziel-Erkennung).
const modelNames = new Set<string>();
for (const m of src.matchAll(/^\s*model\s+(\w+)\s*\{/gm)) modelNames.add(m[1]);
const blockRe = /^\s*model\s+(\w+)\s*\{([\s\S]*?)^\s*\}/gm;
for (const block of src.matchAll(blockRe)) {
const name = block[1];
const body = block[2];
const parsed: ParsedModel = { fields: [] };
for (const rawLine of body.split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("//")) continue;
// Composite-PK: @@id([a, b])
const compId = line.match(/^@@id\(\[([^\]]+)\]\)/);
if (compId) {
parsed.compositeId = compId[1].split(",").map((s) => s.trim());
continue;
}
if (line.startsWith("@@")) continue;
// Feldzeile: "<name> <Type> ...".
const fieldMatch = line.match(/^(\w+)\s+([A-Za-z0-9_]+)(\[\])?\??/);
if (!fieldMatch) continue;
const fieldName = fieldMatch[1];
const typeName = fieldMatch[2];
const isRelation = modelNames.has(typeName);
const relAttr = line.match(/@relation\(([^)]*)\)/);
let relationFromFields: string[] | undefined;
if (relAttr) {
const fieldsAttr = relAttr[1].match(/fields:\s*\[([^\]]*)\]/);
if (fieldsAttr) {
relationFromFields = fieldsAttr[1]
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
}
parsed.fields.push({
name: fieldName,
relationTarget: isRelation ? typeName : undefined,
relationFromFields,
isId: /@id\b/.test(line),
});
}
models.set(name, parsed);
}
schemaCache = models;
return models;
}
// ── DMMF-Zugriff (Tabellen-/Spaltennamen) ───────────────────────────────────
interface DmmfField {
name: string;
kind: string;
dbName?: string | null;
}
interface DmmfModel {
name: string;
dbName?: string | null;
fields: DmmfField[];
}
function dmmfModels(): Map<string, DmmfModel> {
const map = new Map<string, DmmfModel>();
for (const m of Prisma.dmmf.datamodel.models as unknown as DmmfModel[]) {
map.set(m.name, m);
}
return map;
}
/** DB-Spaltenname eines Skalarfelds (aus dem DMMF, @map-aufgelöst). */
function columnOf(dmmf: DmmfModel, fieldName: string): string {
const f = dmmf.fields.find((x) => x.name === fieldName);
return f?.dbName ?? fieldName;
}
// ── Topologie-Aufbau ─────────────────────────────────────────────────────────
let topologyCache: TenantTopology | null = null;
/**
* Baut die deterministische Traversierungs-Topologie. Idempotent/gecacht.
*/
export function buildTenantTopology(): TenantTopology {
if (topologyCache) return topologyCache;
const set = new Set<string>([...TENANT_MODELS, ...JOIN_MODELS]);
const schema = parseSchema();
const dmmf = dmmfModels();
// Kanten sammeln (nur innerhalb der Menge; Selbstreferenzen ignoriert — sie
// begrenzen die Zwischen-Modell-Ordnung nicht, werden beim Reinsert über
// deaktivierte Trigger abgedeckt).
const edges: FkEdge[] = [];
for (const model of set) {
const pm = schema.get(model);
const dm = dmmf.get(model);
if (!pm || !dm) {
throw new Error(`Topologie: Modell ${model} nicht in Schema/DMMF gefunden.`);
}
for (const f of pm.fields) {
if (!f.relationTarget || !f.relationFromFields?.length) continue;
const parent = f.relationTarget;
if (parent === model) continue; // Selbstreferenz
if (!set.has(parent)) {
// Kante auf globale Schicht-A-Tabelle (Tenant/Identity/Permission …):
// für die Ordnung irrelevant (Eltern immer vorhanden).
continue;
}
edges.push({
child: model,
parent,
fromFields: f.relationFromFields,
fromColumns: f.relationFromFields.map((ff) => columnOf(dm, ff)),
});
}
}
// Knoten bauen.
const nodes = new Map<string, TableNode>();
for (const model of set) {
const dm = dmmf.get(model)!;
const pm = schema.get(model)!;
const hasTenantColumn = dm.fields.some((f) => f.name === "tenantId");
// PK bestimmen.
let pk: string[];
if (pm.compositeId) pk = pm.compositeId;
else {
const idField = pm.fields.find((f) => f.isId)?.name;
pk = idField ? [idField] : ["id"];
}
// Scope bestimmen.
let scope: ScopeKind;
if (hasTenantColumn) {
scope = { by: "tenantColumn", column: columnOf(dm, "tenantId") };
} else {
// Join-Tabelle: über die FK auf ein in-set, mandanten-gescoptes Elternteil.
// Bevorzugt eine Kante, deren Elternteil selbst eine tenant_id-Spalte hat
// (User/Role) → deterministisch die erste solche in Schema-Reihenfolge.
const scopingEdge = edges.find(
(e) => e.child === model && dmmf.get(e.parent)?.fields.some((f) => f.name === "tenantId"),
);
if (!scopingEdge) {
throw new Error(
`Topologie: ${model} hat kein tenant_id und keine FK auf ein mandanten-gescoptes Elternteil — Scope unbestimmbar.`,
);
}
scope = { by: "idSet", via: scopingEdge };
}
nodes.set(model, {
model,
delegate: delegateName(model),
table: dm.dbName ?? model,
pk,
hasTenantColumn,
scope,
});
}
// Deterministischer topologischer Sort (Kahn, alphabetische Tiebreaks).
const indeg = new Map<string, number>();
const adj = new Map<string, string[]>();
for (const model of set) {
indeg.set(model, 0);
adj.set(model, []);
}
const seenEdge = new Set<string>();
for (const e of edges) {
const key = `${e.child}|${e.parent}`;
if (seenEdge.has(key)) continue;
seenEdge.add(key);
adj.get(e.parent)!.push(e.child);
indeg.set(e.child, indeg.get(e.child)! + 1);
}
const queue = [...set].filter((m) => indeg.get(m) === 0).sort();
const insertOrderNames: string[] = [];
while (queue.length) {
const n = queue.shift()!;
insertOrderNames.push(n);
for (const c of adj.get(n)!.slice().sort()) {
indeg.set(c, indeg.get(c)! - 1);
if (indeg.get(c) === 0) {
// stabil einsortieren
queue.push(c);
queue.sort();
}
}
}
if (insertOrderNames.length !== set.size) {
const remaining = [...set].filter((m) => !insertOrderNames.includes(m));
throw new Error(
`Topologie: FK-Zyklus zwischen Tenant-Modellen entdeckt — betroffen: ${remaining.join(", ")}. ` +
`Zyklen erfordern DEFERRABLE-Constraints oder eine explizite Bruchstelle.`,
);
}
const insertOrder = insertOrderNames.map((m) => nodes.get(m)!);
const deleteOrder = [...insertOrder].reverse();
topologyCache = { nodes, insertOrder, deleteOrder, edges };
return topologyCache;
}
/**
* Sicherheitsnachweis: die abgeleitete FK-Topologie MUSS deckungsgleich mit den
* realen Postgres-Constraints sein. Prüft beide Richtungen:
* (a) jede abgeleitete In-Set-Kante existiert als FK in der DB,
* (b) jede reale FK zwischen zwei In-Set-Tabellen ist als Kante abgeleitet.
* Wirft bei Abweichung (fail-closed) — vor Export/Restore aufzurufen.
*/
export async function assertTopologyMatchesDatabase(
db: Pick<PrismaClient, "$queryRawUnsafe">,
): Promise<void> {
const topo = buildTenantTopology();
const tableToModel = new Map<string, string>();
for (const n of topo.nodes.values()) tableToModel.set(n.table, n.model);
const rows = await db.$queryRawUnsafe<
{ child: string; parent: string; col: string }[]
>(`
SELECT tc.table_name AS child, ccu.table_name AS parent, kcu.column_name AS col
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage ccu
ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = 'public'`);
const dbEdges = new Set<string>();
for (const r of rows) {
const cm = tableToModel.get(r.child);
const pm = tableToModel.get(r.parent);
if (!cm || !pm) continue; // mind. eine Seite global → für Ordnung irrelevant
if (cm === pm) continue; // Selbstreferenz
dbEdges.add(`${cm}|${pm}`);
}
const derived = new Set(topo.edges.map((e) => `${e.child}|${e.parent}`));
const missingInDerived = [...dbEdges].filter((e) => !derived.has(e));
const missingInDb = [...derived].filter((e) => !dbEdges.has(e));
if (missingInDerived.length || missingInDb.length) {
throw new Error(
"Topologie stimmt NICHT mit den DB-FK-Constraints überein (fail-closed). " +
(missingInDerived.length
? `In DB, aber nicht abgeleitet: ${missingInDerived.join(", ")}. `
: "") +
(missingInDb.length
? `Abgeleitet, aber nicht in DB: ${missingInDb.join(", ")}.`
: ""),
);
}
}
+49
View File
@@ -0,0 +1,49 @@
// ── BullMQ-Worker der Backup-/DSGVO-Ops-Queue ────────────────────────────────
//
// Analog zum Mail-Worker (SEC1). Nimmt Jobs aus `backup-ops` und führt sie über
// `processBackupJob` aus. Concurrency 1: Restore ist destruktiv und langlaufend
// (Mandant-Sperre, Pre-Restore-Snapshot) — Jobs laufen bewusst seriell, nicht
// parallel. Kein automatisches Retry (attempts=1 in queue.ts): eine Wiederholung
// eines destruktiven Restore ist eine bewusste Betreiber-Entscheidung.
import { Worker, type Job } from "bullmq";
import { BACKUP_QUEUE, type BackupOpsJob } from "./job";
import { closeBackupQueues, getBackupConnection, getBackupDeadLetterQueue } from "./queue";
import { processBackupJob } from "./ops";
export function startBackupWorker(): Worker<BackupOpsJob> {
const connection = getBackupConnection();
if (!connection) {
throw new Error("REDIS_URL ist nicht gesetzt — ohne Redis gibt es keinen Backup-Worker-Betrieb.");
}
const worker = new Worker<BackupOpsJob>(
BACKUP_QUEUE,
async (job: Job<BackupOpsJob>) => {
await processBackupJob(job.data);
},
{ connection, concurrency: 1 },
);
worker.on("failed", async (job, err) => {
if (!job) return;
console.error(`[backup] Job ${job.id} (${job.data.kind}) fehlgeschlagen: ${err.message}`);
// attempts=1 → jeder Fehlschlag ist endgültig: Dead-Letter.
await getBackupDeadLetterQueue()
?.add("dead", { job: job.data, error: err.message })
.catch(() => {});
console.error(`[backup] ALARM — Job ${job.id} in die Dead-Letter-Queue verschoben.`);
});
worker.on("completed", (job) => {
console.info(`[backup] Job ${job.id} (${job.data.kind}) fertig — Mandant ${job.data.tenantId}.`);
});
return worker;
}
/** Sauberes Herunterfahren von Worker und Redis. */
export async function shutdownBackupWorker(worker: Worker | null): Promise<void> {
await worker?.close();
await closeBackupQueues();
}
+115
View File
@@ -0,0 +1,115 @@
// ── Minimaler, abhängigkeitsfreier ZIP-Writer (DSGVO-Zustellpakete) ──────────
//
// Baut ein Standard-ZIP (PKZIP/APPNOTE) rein aus node:zlib + einer eigenen
// CRC32-Implementierung — KEINE Zusatz-Dependency (jszip/archiver sind nur
// transitiv im Baum und keine deklarierten Abhängigkeiten). Verwendet die
// Deflate-Methode; Ausgabe ist mit jedem Standard-Entpacker (`unzip`, macOS
// Archive Utility, jszip) lesbar. Bewusst klein gehalten: keine ZIP64-,
// Streaming- oder Verschlüsselungs-Features (die Vertraulichkeit liegt beim
// signierten, ablaufenden Download-Link bzw. der Transport-/Speicher-Ebene).
import { deflateRawSync } from "node:zlib";
export interface ZipEntry {
/** Pfad im Archiv (mit "/" als Trenner). */
name: string;
/** Inhalt. Strings werden als UTF-8 kodiert. */
data: Buffer | string;
}
// CRC32-Tabelle (IEEE 802.3, Polynom 0xEDB88320) — einmalig vorberechnet.
const CRC_TABLE: number[] = (() => {
const table: number[] = new Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c >>> 0;
}
return table;
})();
function crc32(buf: Buffer): number {
let crc = 0xffffffff;
for (let i = 0; i < buf.length; i++) crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
/**
* MS-DOS-Zeitstempel (feste, reproduzierbare Zeit). ZIP kennt nur lokale
* DOS-Zeit; wir setzen einen konstanten Wert, damit dieselbe Eingabe dasselbe
* Archiv ergibt (testbar). 1980-01-01 00:00:00.
*/
const DOS_DATE = 0x0021; // Jahr 1980, Monat 1, Tag 1
const DOS_TIME = 0x0000;
/** Baut ein ZIP-Archiv aus den Einträgen und gibt die Bytes zurück. */
export function buildZip(entries: ZipEntry[]): Buffer {
const chunks: Buffer[] = [];
const central: Buffer[] = [];
let offset = 0;
for (const entry of entries) {
const nameBuf = Buffer.from(entry.name, "utf8");
const raw = typeof entry.data === "string" ? Buffer.from(entry.data, "utf8") : entry.data;
const crc = crc32(raw);
const compressed = deflateRawSync(raw);
// Falls Deflate größer wäre (winzige Dateien), trotzdem Deflate nutzen — der
// Overhead ist vernachlässigbar und hält den Writer einfach (eine Methode).
const method = 8; // deflate
// Local file header.
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0); // Signatur
local.writeUInt16LE(20, 4); // benötigte Version
local.writeUInt16LE(0x0800, 6); // Flags: Bit 11 = UTF-8-Dateinamen
local.writeUInt16LE(method, 8);
local.writeUInt16LE(DOS_TIME, 10);
local.writeUInt16LE(DOS_DATE, 12);
local.writeUInt32LE(crc, 14);
local.writeUInt32LE(compressed.length, 18);
local.writeUInt32LE(raw.length, 22);
local.writeUInt16LE(nameBuf.length, 26);
local.writeUInt16LE(0, 28); // Extra-Feld-Länge
chunks.push(local, nameBuf, compressed);
// Central directory record.
const cdir = Buffer.alloc(46);
cdir.writeUInt32LE(0x02014b50, 0); // Signatur
cdir.writeUInt16LE(20, 4); // erzeugende Version
cdir.writeUInt16LE(20, 6); // benötigte Version
cdir.writeUInt16LE(0x0800, 8); // Flags
cdir.writeUInt16LE(method, 10);
cdir.writeUInt16LE(DOS_TIME, 12);
cdir.writeUInt16LE(DOS_DATE, 14);
cdir.writeUInt32LE(crc, 16);
cdir.writeUInt32LE(compressed.length, 20);
cdir.writeUInt32LE(raw.length, 24);
cdir.writeUInt16LE(nameBuf.length, 28);
cdir.writeUInt16LE(0, 30); // Extra
cdir.writeUInt16LE(0, 32); // Kommentar
cdir.writeUInt16LE(0, 34); // Disk-Nummer
cdir.writeUInt16LE(0, 36); // interne Attribute
cdir.writeUInt32LE(0, 38); // externe Attribute
cdir.writeUInt32LE(offset, 42); // Offset des Local-Headers
central.push(cdir, nameBuf);
offset += local.length + nameBuf.length + compressed.length;
}
const centralBuf = Buffer.concat(central);
const centralOffset = offset;
// End of central directory record.
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(0, 4); // Disk
eocd.writeUInt16LE(0, 6); // Disk mit Central-Dir-Start
eocd.writeUInt16LE(entries.length, 8);
eocd.writeUInt16LE(entries.length, 10);
eocd.writeUInt32LE(centralBuf.length, 12);
eocd.writeUInt32LE(centralOffset, 16);
eocd.writeUInt16LE(0, 20); // Kommentarlänge
return Buffer.concat([...chunks, centralBuf, eocd]);
}