feat(backup): full DR bundle export + admin-configurable offsite destinations
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m52s
Build & Push Docker Images / build-and-push (push) Successful in 11m59s

Backend-agnostic disaster-recovery backup engine that runs on the current
storage backend (no storage cutover required):

- Full-bundle export: db.dump (pg_dump custom) + every storage blob +
  manifest.json with per-object SHA-256, streamed as a tar. Entry points:
  admin UI download, GET /api/v1/admin/backup/export, scripts/create-full-backup.ts.
- Admin-configurable push destinations (backup_destinations table, migration
  0091): SFTP/SSH, S3-compatible (reuses the minio client), and mounted
  path/NAS behind one transport interface (test/push/prune). Secrets AES-GCM
  at rest; API returns only *IsSet markers.
- Opt-in per-destination AES-256 bundle encryption (scrypt KDF, streamed) +
  scripts/decrypt-backup.ts for restore.
- Wired the previously-dead database-backup cron to runScheduledBackupPush
  (push to enabled destinations, prune to retention, alert super-admins on
  failure).

Tests: 1608 unit/integration pass; tsc + lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 11:23:42 +02:00
parent 05950ae0b6
commit fe863a588e
35 changed files with 3125 additions and 15 deletions

View File

@@ -1,4 +1,5 @@
import { BackupAdminPanel } from '@/components/admin/backup-admin-panel';
import { BackupDestinationsCard } from '@/components/admin/backup-destinations-card';
import { PageHeader } from '@/components/shared/page-header';
export default function BackupManagementPage() {
@@ -7,9 +8,10 @@ export default function BackupManagementPage() {
<PageHeader
title="Backup & Restore"
eyebrow="ADMIN"
description="Trigger ad-hoc database snapshots, browse the history, and download a .dump file for offline restore."
description="Download a full backup, configure where automated backups are pushed, and browse history. Restore steps live in docs/backup-restore-runbook.md."
/>
<BackupAdminPanel />
<BackupDestinationsCard />
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { NextResponse } from 'next/server';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse, NotFoundError } from '@/lib/errors';
import {
deleteDestination,
updateDestination,
type DestinationInput,
} from '@/lib/services/backup-destinations.service';
import { backupDestinationSchema } from '@/lib/validators/backup-destinations';
export const runtime = 'nodejs';
/** Update a backup destination. Super-admin only. */
export const PUT = withAuth(async (req, ctx, params) => {
try {
requireSuperAdmin(ctx, 'admin.backup.destinations.update');
const id = params.id;
if (!id) throw new NotFoundError('Backup destination');
const body = await parseBody(req, backupDestinationSchema);
const updated = await updateDestination(id, body as DestinationInput);
await createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'update',
entityType: 'backup_destination',
entityId: id,
severity: 'warning',
metadata: { name: updated.name, type: updated.type, enabled: updated.enabled },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: updated });
} catch (error) {
return errorResponse(error);
}
});
/** Delete a backup destination. Super-admin only. */
export const DELETE = withAuth(async (_req, ctx, params) => {
try {
requireSuperAdmin(ctx, 'admin.backup.destinations.delete');
const id = params.id;
if (!id) throw new NotFoundError('Backup destination');
await deleteDestination(id);
await createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'delete',
entityType: 'backup_destination',
entityId: id,
severity: 'warning',
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,38 @@
import { NextResponse } from 'next/server';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { pushBackupToDestination } from '@/lib/services/backup-destinations.service';
export const runtime = 'nodejs';
// A full backup (pg_dump + every blob) is assembled before the push, so allow
// a long run on large datasets.
export const maxDuration = 3600;
/** Assemble a fresh full backup and push it to this destination now. Super-admin. */
export const POST = withAuth(async (_req, ctx, params) => {
try {
requireSuperAdmin(ctx, 'admin.backup.destinations.run');
const id = params.id;
if (!id) throw new NotFoundError('Backup destination');
const result = await pushBackupToDestination(id, {
trigger: 'manual',
triggeredBy: ctx.userId,
});
await createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'backup_export',
entityType: 'backup_destination',
entityId: id,
severity: 'warning',
metadata: { bytes: result.bytes, remoteRef: result.remoteRef },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { testDestination } from '@/lib/services/backup-destinations.service';
export const runtime = 'nodejs';
/**
* Test connectivity to a destination (connect + verify the target dir/bucket).
* Returns `{ data: { ok: true } }` or a structured error the UI can surface.
* Super-admin only.
*/
export const POST = withAuth(async (_req, ctx, params) => {
try {
requireSuperAdmin(ctx, 'admin.backup.destinations.test');
const id = params.id;
if (!id) throw new NotFoundError('Backup destination');
await testDestination(id);
return NextResponse.json({ data: { ok: true } });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse } from '@/lib/errors';
import {
createDestination,
listDestinations,
type DestinationInput,
} from '@/lib/services/backup-destinations.service';
import { backupDestinationSchema } from '@/lib/validators/backup-destinations';
export const runtime = 'nodejs';
/** List configured backup destinations (secrets masked). Super-admin only. */
export const GET = withAuth(async (_req, ctx) => {
try {
requireSuperAdmin(ctx, 'admin.backup.destinations.list');
return NextResponse.json({ data: await listDestinations() });
} catch (error) {
return errorResponse(error);
}
});
/** Create a backup destination. Super-admin only. */
export const POST = withAuth(async (req, ctx) => {
try {
requireSuperAdmin(ctx, 'admin.backup.destinations.create');
const body = await parseBody(req, backupDestinationSchema);
const created = await createDestination(body as DestinationInput);
await createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'create',
entityType: 'backup_destination',
entityId: created.id,
severity: 'warning',
metadata: { name: created.name, type: created.type, enabled: created.enabled },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: created }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,71 @@
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { Readable } from 'node:stream';
import { NextResponse } from 'next/server';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse } from '@/lib/errors';
import { createFullBackupTar } from '@/lib/services/backup-export.service';
export const runtime = 'nodejs';
// A full backup pg_dumps the DB and streams every blob; on a large dataset the
// assembly phase can run for a while before the download starts. Lift the
// platform timeout accordingly (no-op on hosts without a hard cap).
export const maxDuration = 3600;
/**
* Stream a full disaster-recovery bundle (db.dump + all blobs + manifest.json)
* as a tar download. Super-admin only — this egresses every tenant's data.
*
* The bundle is assembled to a temp file first, so any failure (pg_dump,
* storage read) surfaces as a clean JSON error *before* the download begins
* rather than as a truncated tar. The temp file is removed once the response
* stream closes (including on client disconnect).
*/
export const GET = withAuth(async (_req, ctx) => {
try {
requireSuperAdmin(ctx, 'admin.backup.export');
const { tarPath, filename, manifest, cleanup } = await createFullBackupTar();
await createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'backup_export',
entityType: 'system_backup',
entityId: filename,
severity: 'warning',
source: 'user',
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
metadata: {
storageBackend: manifest.storageBackend,
blobs: manifest.counts.blobs,
blobBytes: manifest.counts.blobBytes,
skipped: manifest.counts.skipped,
dbDumpBytes: manifest.database.sizeBytes,
},
});
const { size } = await stat(tarPath);
const nodeStream = createReadStream(tarPath);
// Remove the temp tar once it's been fully sent or the client bails.
nodeStream.on('close', () => void cleanup());
nodeStream.on('error', () => void cleanup());
const webStream = Readable.toWeb(nodeStream) as ReadableStream<Uint8Array>;
return new NextResponse(webStream, {
status: 200,
headers: {
'Content-Type': 'application/x-tar',
'Content-Length': String(size),
'Content-Disposition': `attachment; filename="${filename}"`,
'Cache-Control': 'no-store',
},
});
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,43 @@
import { NextResponse } from 'next/server';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse } from '@/lib/errors';
import { getSchedule, setSchedule } from '@/lib/services/backup-destinations.service';
import { backupScheduleSchema } from '@/lib/validators/backup-destinations';
export const runtime = 'nodejs';
/** Read the global automated-backup schedule. Super-admin only. */
export const GET = withAuth(async (_req, ctx) => {
try {
requireSuperAdmin(ctx, 'admin.backup.schedule.get');
return NextResponse.json({ data: { schedule: await getSchedule() } });
} catch (error) {
return errorResponse(error);
}
});
/** Set the global automated-backup schedule (off | daily | weekly). Super-admin. */
export const PUT = withAuth(async (req, ctx) => {
try {
requireSuperAdmin(ctx, 'admin.backup.schedule.set');
const { schedule } = await parseBody(req, backupScheduleSchema);
await setSchedule(schedule, ctx.userId);
await createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'update',
entityType: 'backup_schedule',
entityId: 'global',
severity: 'warning',
metadata: { schedule },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: { schedule } });
} catch (error) {
return errorResponse(error);
}
});