chore: initialize repository with deployment baseline
This commit is contained in:
491
backend/src/routes/backup.routes.ts
Normal file
491
backend/src/routes/backup.routes.ts
Normal file
@@ -0,0 +1,491 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user