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,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");
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(", ")}.`
|
||||
: ""),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user