feat(backup): full DR bundle export + admin-configurable offsite destinations
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
62
src/app/api/v1/admin/backup/destinations/[id]/route.ts
Normal file
62
src/app/api/v1/admin/backup/destinations/[id]/route.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
38
src/app/api/v1/admin/backup/destinations/[id]/run/route.ts
Normal file
38
src/app/api/v1/admin/backup/destinations/[id]/run/route.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
24
src/app/api/v1/admin/backup/destinations/[id]/test/route.ts
Normal file
24
src/app/api/v1/admin/backup/destinations/[id]/test/route.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
47
src/app/api/v1/admin/backup/destinations/route.ts
Normal file
47
src/app/api/v1/admin/backup/destinations/route.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
71
src/app/api/v1/admin/backup/export/route.ts
Normal file
71
src/app/api/v1/admin/backup/export/route.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
43
src/app/api/v1/admin/backup/schedule/route.ts
Normal file
43
src/app/api/v1/admin/backup/schedule/route.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user