import { BackupRestoreMode, BackupRestoreStatus, BackupSchedule, BackupSource, BackupStatus, BackupType, SnapshotFrequency } from "@prisma/client"; import { Router } from "express"; import { z } from "zod"; import { HttpError } from "../lib/http-error"; import { prisma } from "../lib/prisma"; import { authorize, isTenantScopedUser, requireAuth } from "../middleware/auth"; import { logAudit } from "../services/audit.service"; import { createBackup, createRestoreTask, createSnapshotJob, deleteBackup, deleteSnapshotJob, listBackupPolicies, listBackups, listRestoreTasks, listSnapshotJobs, runRestoreTaskNow, runSnapshotJobNow, toggleBackupProtection, updateSnapshotJob, upsertBackupPolicy } from "../services/backup.service"; const router = Router(); const createBackupSchema = z.object({ vm_id: z.string().min(1), type: z.nativeEnum(BackupType).optional(), source: z.nativeEnum(BackupSource).optional(), schedule: z.nativeEnum(BackupSchedule).optional(), retention_days: z.number().int().positive().optional(), storage: z.string().optional(), route_key: z.string().optional(), is_protected: z.boolean().optional(), notes: z.string().optional(), requested_size_mb: z.number().positive().optional() }); const protectionSchema = z.object({ is_protected: z.boolean() }); const createRestoreSchema = z.object({ backup_id: z.string().min(1), target_vm_id: z.string().optional(), mode: z.nativeEnum(BackupRestoreMode), requested_files: z.array(z.string().min(1)).optional(), pbs_enabled: z.boolean().optional(), run_immediately: z.boolean().default(true) }); const createSnapshotSchema = z.object({ vm_id: z.string().min(1), name: z.string().min(2), frequency: z.nativeEnum(SnapshotFrequency), interval: z.number().int().positive().optional(), day_of_week: z.number().int().min(0).max(6).optional(), hour_utc: z.number().int().min(0).max(23).optional(), minute_utc: z.number().int().min(0).max(59).optional(), retention: z.number().int().positive().optional(), enabled: z.boolean().optional() }); const updateSnapshotSchema = z.object({ name: z.string().min(2).optional(), frequency: z.nativeEnum(SnapshotFrequency).optional(), interval: z.number().int().positive().optional(), day_of_week: z.number().int().min(0).max(6).nullable().optional(), hour_utc: z.number().int().min(0).max(23).optional(), minute_utc: z.number().int().min(0).max(59).optional(), retention: z.number().int().positive().optional(), enabled: z.boolean().optional() }); const upsertPolicySchema = z.object({ tenant_id: z.string().optional(), billing_plan_id: z.string().optional(), max_files: z.number().int().positive().optional(), max_total_size_mb: z.number().positive().optional(), max_protected_files: z.number().int().positive().optional(), allow_file_restore: z.boolean().optional(), allow_cross_vm_restore: z.boolean().optional(), allow_pbs_restore: z.boolean().optional() }); function parseOptionalBackupStatus(value: unknown) { if (typeof value !== "string") return undefined; const normalized = value.toUpperCase(); return Object.values(BackupStatus).includes(normalized as BackupStatus) ? (normalized as BackupStatus) : undefined; } function parseOptionalRestoreStatus(value: unknown) { if (typeof value !== "string") return undefined; const normalized = value.toUpperCase(); return Object.values(BackupRestoreStatus).includes(normalized as BackupRestoreStatus) ? (normalized as BackupRestoreStatus) : undefined; } async function ensureVmTenantScope(vmId: string, req: Express.Request) { const vm = await prisma.virtualMachine.findUnique({ where: { id: vmId }, select: { id: true, tenant_id: true, name: true } }); if (!vm) throw new HttpError(404, "VM not found", "VM_NOT_FOUND"); if (isTenantScopedUser(req) && req.user?.tenant_id && vm.tenant_id !== req.user.tenant_id) { throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); } return vm; } async function ensureBackupTenantScope(backupId: string, req: Express.Request) { const backup = await prisma.backup.findUnique({ where: { id: backupId }, include: { vm: { select: { id: true, tenant_id: true, name: true } } } }); if (!backup) throw new HttpError(404, "Backup not found", "BACKUP_NOT_FOUND"); const tenantId = backup.tenant_id ?? backup.vm.tenant_id; if (isTenantScopedUser(req) && req.user?.tenant_id && tenantId !== req.user.tenant_id) { throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); } return backup; } async function ensureRestoreTaskTenantScope(taskId: string, req: Express.Request) { const task = await prisma.backupRestoreTask.findUnique({ where: { id: taskId }, include: { source_vm: { select: { tenant_id: true } } } }); if (!task) throw new HttpError(404, "Restore task not found", "RESTORE_TASK_NOT_FOUND"); if (isTenantScopedUser(req) && req.user?.tenant_id && task.source_vm.tenant_id !== req.user.tenant_id) { throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); } return task; } async function ensureSnapshotJobTenantScope(jobId: string, req: Express.Request) { const job = await prisma.snapshotJob.findUnique({ where: { id: jobId }, include: { vm: { select: { tenant_id: true } } } }); if (!job) throw new HttpError(404, "Snapshot job not found", "SNAPSHOT_JOB_NOT_FOUND"); if (isTenantScopedUser(req) && req.user?.tenant_id && job.vm.tenant_id !== req.user.tenant_id) { throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); } return job; } router.get("/", requireAuth, authorize("backup:read"), async (req, res, next) => { try { const status = parseOptionalBackupStatus(req.query.status); const vmId = typeof req.query.vm_id === "string" ? req.query.vm_id : undefined; const limit = typeof req.query.limit === "string" ? Number(req.query.limit) : undefined; const offset = typeof req.query.offset === "string" ? Number(req.query.offset) : undefined; if (vmId) { await ensureVmTenantScope(vmId, req); } const result = await listBackups({ tenantId: isTenantScopedUser(req) ? req.user?.tenant_id ?? undefined : undefined, status, vmId, limit, offset }); res.json(result); } catch (error) { next(error); } }); router.post("/", requireAuth, authorize("backup:manage"), async (req, res, next) => { try { const payload = createBackupSchema.parse(req.body ?? {}); await ensureVmTenantScope(payload.vm_id, req); const backup = await createBackup({ vmId: payload.vm_id, type: payload.type, source: payload.source, schedule: payload.schedule, retentionDays: payload.retention_days, storage: payload.storage, routeKey: payload.route_key, isProtected: payload.is_protected, notes: payload.notes, requestedSizeMb: payload.requested_size_mb, createdBy: req.user?.email }); await logAudit({ action: "backup.create", resource_type: "BACKUP", resource_id: backup.id, resource_name: backup.vm_name, actor_email: req.user!.email, actor_role: req.user!.role, details: payload, ip_address: req.ip }); res.status(201).json(backup); } catch (error) { next(error); } }); router.patch("/:id/protection", requireAuth, authorize("backup:manage"), async (req, res, next) => { try { const payload = protectionSchema.parse(req.body ?? {}); await ensureBackupTenantScope(req.params.id, req); const backup = await toggleBackupProtection(req.params.id, payload.is_protected); res.json(backup); } catch (error) { next(error); } }); router.delete("/:id", requireAuth, authorize("backup:manage"), async (req, res, next) => { try { await ensureBackupTenantScope(req.params.id, req); const force = req.query.force === "true"; await deleteBackup(req.params.id, force); res.status(204).send(); } catch (error) { next(error); } }); router.get("/restores", requireAuth, authorize("backup:read"), async (req, res, next) => { try { const status = parseOptionalRestoreStatus(req.query.status); const limit = typeof req.query.limit === "string" ? Number(req.query.limit) : undefined; const offset = typeof req.query.offset === "string" ? Number(req.query.offset) : undefined; const result = await listRestoreTasks({ tenantId: isTenantScopedUser(req) ? req.user?.tenant_id ?? undefined : undefined, status, limit, offset }); res.json(result); } catch (error) { next(error); } }); router.post("/restores", requireAuth, authorize("backup:manage"), async (req, res, next) => { try { const payload = createRestoreSchema.parse(req.body ?? {}); await ensureBackupTenantScope(payload.backup_id, req); if (payload.target_vm_id) { await ensureVmTenantScope(payload.target_vm_id, req); } const task = await createRestoreTask({ backupId: payload.backup_id, targetVmId: payload.target_vm_id, mode: payload.mode, requestedFiles: payload.requested_files, pbsEnabled: payload.pbs_enabled, createdBy: req.user?.email, runImmediately: payload.run_immediately }); await logAudit({ action: "backup.restore.create", resource_type: "BACKUP", resource_id: payload.backup_id, actor_email: req.user!.email, actor_role: req.user!.role, details: payload, ip_address: req.ip }); res.status(201).json(task); } catch (error) { next(error); } }); router.post("/restores/:id/run", requireAuth, authorize("backup:manage"), async (req, res, next) => { try { await ensureRestoreTaskTenantScope(req.params.id, req); const task = await runRestoreTaskNow(req.params.id); res.json(task); } catch (error) { next(error); } }); router.get("/snapshot-jobs", requireAuth, authorize("backup:read"), async (req, res, next) => { try { const jobs = await listSnapshotJobs({ tenantId: isTenantScopedUser(req) ? req.user?.tenant_id ?? undefined : undefined }); res.json({ data: jobs }); } catch (error) { next(error); } }); router.post("/snapshot-jobs", requireAuth, authorize("backup:manage"), async (req, res, next) => { try { const payload = createSnapshotSchema.parse(req.body ?? {}); await ensureVmTenantScope(payload.vm_id, req); const job = await createSnapshotJob({ vmId: payload.vm_id, name: payload.name, frequency: payload.frequency, interval: payload.interval, dayOfWeek: payload.day_of_week, hourUtc: payload.hour_utc, minuteUtc: payload.minute_utc, retention: payload.retention, enabled: payload.enabled, createdBy: req.user?.email }); await logAudit({ action: "snapshot_job.create", resource_type: "BACKUP", resource_id: job.id, resource_name: job.name, actor_email: req.user!.email, actor_role: req.user!.role, details: payload, ip_address: req.ip }); res.status(201).json(job); } catch (error) { next(error); } }); router.patch("/snapshot-jobs/:id", requireAuth, authorize("backup:manage"), async (req, res, next) => { try { const payload = updateSnapshotSchema.parse(req.body ?? {}); await ensureSnapshotJobTenantScope(req.params.id, req); const job = await updateSnapshotJob(req.params.id, { name: payload.name, frequency: payload.frequency, interval: payload.interval, dayOfWeek: payload.day_of_week, hourUtc: payload.hour_utc, minuteUtc: payload.minute_utc, retention: payload.retention, enabled: payload.enabled }); res.json(job); } catch (error) { next(error); } }); router.delete("/snapshot-jobs/:id", requireAuth, authorize("backup:manage"), async (req, res, next) => { try { await ensureSnapshotJobTenantScope(req.params.id, req); await deleteSnapshotJob(req.params.id); res.status(204).send(); } catch (error) { next(error); } }); router.post("/snapshot-jobs/:id/run", requireAuth, authorize("backup:manage"), async (req, res, next) => { try { await ensureSnapshotJobTenantScope(req.params.id, req); const result = await runSnapshotJobNow(req.params.id); res.json(result); } catch (error) { next(error); } }); router.get("/policies", requireAuth, authorize("backup:read"), async (_req, res, next) => { try { const all = await listBackupPolicies(); const data = isTenantScopedUser(_req) && _req.user?.tenant_id ? all.filter((item) => item.tenant_id === _req.user?.tenant_id) : all; res.json({ data }); } catch (error) { next(error); } }); router.post("/policies", requireAuth, authorize("backup:manage"), async (req, res, next) => { try { const payload = upsertPolicySchema.parse(req.body ?? {}); const tenantId = isTenantScopedUser(req) ? req.user?.tenant_id ?? undefined : payload.tenant_id; if (isTenantScopedUser(req) && payload.tenant_id && req.user?.tenant_id && payload.tenant_id !== req.user.tenant_id) { throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); } const policy = await upsertBackupPolicy({ tenantId, billingPlanId: payload.billing_plan_id, maxFiles: payload.max_files, maxTotalSizeMb: payload.max_total_size_mb, maxProtectedFiles: payload.max_protected_files, allowFileRestore: payload.allow_file_restore, allowCrossVmRestore: payload.allow_cross_vm_restore, allowPbsRestore: payload.allow_pbs_restore }); res.status(201).json(policy); } catch (error) { next(error); } }); router.patch("/policies/:id", requireAuth, authorize("backup:manage"), async (req, res, next) => { try { const payload = upsertPolicySchema.parse(req.body ?? {}); const tenantId = isTenantScopedUser(req) ? req.user?.tenant_id ?? undefined : payload.tenant_id; if (isTenantScopedUser(req) && payload.tenant_id && req.user?.tenant_id && payload.tenant_id !== req.user.tenant_id) { throw new HttpError(403, "Access denied for tenant scope", "TENANT_SCOPE_VIOLATION"); } const policy = await upsertBackupPolicy({ policyId: req.params.id, tenantId, billingPlanId: payload.billing_plan_id, maxFiles: payload.max_files, maxTotalSizeMb: payload.max_total_size_mb, maxProtectedFiles: payload.max_protected_files, allowFileRestore: payload.allow_file_restore, allowCrossVmRestore: payload.allow_cross_vm_restore, allowPbsRestore: payload.allow_pbs_restore }); res.json(policy); } catch (error) { next(error); } }); export default router;