From a945aaffe37b87106bc7dbf06d274ce4c480c90c Mon Sep 17 00:00:00 2001 From: flfeders Date: Tue, 14 Jul 2026 11:40:48 +0200 Subject: [PATCH] Extend tenant maintenance lock for imports --- .../0055_tenant_export_maintenance_lock.sql | 22 +++ backend/db/migrations/meta/_journal.json | 7 + backend/db/schema/tenant_export_jobs.ts | 3 + backend/db/schema/tenants.ts | 1 + backend/src/plugins/auth.ts | 33 ++++ backend/src/routes/admin.ts | 164 +++++++++++++++++- frontend/composables/useAdmin.ts | 1 + .../pages/administration/tenants/[id].vue | 26 +++ 8 files changed, 253 insertions(+), 4 deletions(-) create mode 100644 backend/db/migrations/0055_tenant_export_maintenance_lock.sql diff --git a/backend/db/migrations/0055_tenant_export_maintenance_lock.sql b/backend/db/migrations/0055_tenant_export_maintenance_lock.sql new file mode 100644 index 0000000..7b300df --- /dev/null +++ b/backend/db/migrations/0055_tenant_export_maintenance_lock.sql @@ -0,0 +1,22 @@ +ALTER TABLE "tenant_export_jobs" + ADD COLUMN IF NOT EXISTS "operation" text DEFAULT 'export' NOT NULL, + ADD COLUMN IF NOT EXISTS "previous_tenant_locked" "locked_tenant"; + +ALTER TABLE "tenants" + ADD COLUMN IF NOT EXISTS "locked_by_export_job_id" uuid; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE constraint_schema = 'public' + AND constraint_name = 'tenants_locked_by_export_job_id_tenant_export_jobs_id_fk' + ) THEN + ALTER TABLE "tenants" + ADD CONSTRAINT "tenants_locked_by_export_job_id_tenant_export_jobs_id_fk" + FOREIGN KEY ("locked_by_export_job_id") + REFERENCES "tenant_export_jobs"("id") + ON DELETE SET NULL; + END IF; +END $$; diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index e2be355..2b5a13b 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -365,6 +365,13 @@ "when": 1783247083934, "tag": "0054_tenant_export_jobs", "breakpoints": true + }, + { + "idx": 52, + "version": "7", + "when": 1784021669805, + "tag": "0055_tenant_export_maintenance_lock", + "breakpoints": true } ] } diff --git a/backend/db/schema/tenant_export_jobs.ts b/backend/db/schema/tenant_export_jobs.ts index 84277ed..8d19ab2 100644 --- a/backend/db/schema/tenant_export_jobs.ts +++ b/backend/db/schema/tenant_export_jobs.ts @@ -9,6 +9,7 @@ import { import { tenants } from "./tenants" import { authUsers } from "./auth_users" +import { lockedTenantEnum } from "./enums" export const tenantExportJobs = pgTable("tenant_export_jobs", { id: uuid("id").primaryKey().defaultRandom(), @@ -26,6 +27,8 @@ export const tenantExportJobs = pgTable("tenant_export_jobs", { createdBy: uuid("created_by").references(() => authUsers.id), + operation: text("operation").notNull().default("export"), + previousTenantLocked: lockedTenantEnum("previous_tenant_locked"), status: text("status").notNull().default("queued"), filename: text("filename").notNull(), storagePath: text("storage_path"), diff --git a/backend/db/schema/tenants.ts b/backend/db/schema/tenants.ts index 2b09a01..c481452 100644 --- a/backend/db/schema/tenants.ts +++ b/backend/db/schema/tenants.ts @@ -189,6 +189,7 @@ export const tenants = pgTable( updatedBy: uuid("updated_by").references(() => authUsers.id), locked: lockedTenantEnum("locked"), + lockedByExportJobId: uuid("locked_by_export_job_id"), } ) diff --git a/backend/src/plugins/auth.ts b/backend/src/plugins/auth.ts index ca5532c..22bd53d 100644 --- a/backend/src/plugins/auth.ts +++ b/backend/src/plugins/auth.ts @@ -9,6 +9,7 @@ import { authRolePermissions, authUsers, m2mApiKeys, + tenants, } from "../../db/schema" import { eq, and, inArray } from "drizzle-orm" @@ -23,6 +24,19 @@ export default fp(async (server: FastifyInstance) => { url === "/api/mcp" || url.startsWith("/api/mcp/") + const isWriteMethod = (method: string) => + ["POST", "PUT", "PATCH", "DELETE"].includes(method) + + const isTenantLockAllowedRoute = (urlPath: string) => + urlPath === "/api/auth/me" || + urlPath === "/auth/me" || + urlPath === "/api/tenant/switch" || + urlPath === "/tenant/switch" || + urlPath.startsWith("/api/admin/") || + urlPath.startsWith("/admin/") || + urlPath.startsWith("/api/auth/") || + urlPath.startsWith("/auth/") + const authenticateMcpApiKey = async (apiKey: string) => { if (!apiKey.startsWith("fedeo_mcp_")) return false @@ -150,6 +164,25 @@ export default fp(async (server: FastifyInstance) => { const tenantId = req.user.tenant_id const userId = req.user.user_id + if (isWriteMethod(req.method) && !isTenantLockAllowedRoute(urlPath)) { + const [tenant] = await server.db + .select({ + locked: tenants.locked, + lockedByExportJobId: tenants.lockedByExportJobId, + }) + .from(tenants) + .where(eq(tenants.id, tenantId)) + .limit(1) + + if (tenant?.locked === "maintenance_tenant") { + return reply.code(423).send({ + error: "Tenant is locked for maintenance", + locked: tenant.locked, + lockedByExportJobId: tenant.lockedByExportJobId, + }) + } + } + // -------------------------------------------------------- // 3️⃣ Rollen des Nutzers im Tenant holen // -------------------------------------------------------- diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index e5cb46d..9d2fd9b 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -267,8 +267,83 @@ export default async function adminRoutes(server: FastifyInstance) { return `fedeo-tenant-${safeTenantName || tenantId}-${new Date().toISOString().slice(0, 10)}.fedeo-export.zip`; }; + const lockTenantForJob = async (tenantId: number, jobId: string) => { + const [tenant] = await server.db + .select({ + locked: tenants.locked, + lockedByExportJobId: tenants.lockedByExportJobId, + }) + .from(tenants) + .where(eq(tenants.id, tenantId)) + .limit(1); + + if (!tenant) throw new Error("Tenant not found"); + if (tenant.lockedByExportJobId && tenant.lockedByExportJobId !== jobId) { + throw new Error("Tenant ist bereits durch einen anderen Export oder Import gesperrt"); + } + + await server.db + .update(tenantExportJobs) + .set({ + previousTenantLocked: tenant.locked, + updatedAt: new Date(), + }) + .where(eq(tenantExportJobs.id, jobId)); + + await server.db + .update(tenants) + .set({ + locked: "maintenance_tenant", + lockedByExportJobId: jobId, + updatedAt: new Date(), + }) + .where(eq(tenants.id, tenantId)); + }; + + const unlockTenantForJob = async (tenantId: number, jobId: string) => { + const [job] = await server.db + .select({ + previousTenantLocked: tenantExportJobs.previousTenantLocked, + }) + .from(tenantExportJobs) + .where(eq(tenantExportJobs.id, jobId)) + .limit(1); + + await server.db + .update(tenants) + .set({ + locked: job?.previousTenantLocked || null, + lockedByExportJobId: null, + updatedAt: new Date(), + }) + .where(and( + eq(tenants.id, tenantId), + eq(tenants.lockedByExportJobId, jobId) + )); + }; + + const createTenantImportJob = async (tenantId: number, currentUserId: string, filename: string) => { + const [job] = await server.db + .insert(tenantExportJobs) + .values({ + tenantId, + createdBy: currentUserId, + operation: "import", + status: "running", + filename, + updatedAt: new Date(), + }) + .returning(); + + await lockTenantForJob(tenantId, job.id); + + return job; + }; + const startTenantExportJob = async (jobId: string, tenantId: number, filename: string) => { try { + await lockTenantForJob(tenantId, jobId); + await server.db .update(tenantExportJobs) .set({ @@ -317,6 +392,8 @@ export default async function adminRoutes(server: FastifyInstance) { updatedAt: new Date(), }) .where(eq(tenantExportJobs.id, jobId)); + } finally { + await unlockTenantForJob(tenantId, jobId); } }; @@ -339,6 +416,7 @@ export default async function adminRoutes(server: FastifyInstance) { .values({ tenantId, createdBy: currentUserId, + operation: "export", status: "queued", filename, updatedAt: new Date(), @@ -417,6 +495,7 @@ export default async function adminRoutes(server: FastifyInstance) { short: tenants.short, createdAt: tenants.createdAt, locked: tenants.locked, + lockedByExportJobId: tenants.lockedByExportJobId, }) .from(tenants), server.db @@ -931,6 +1010,7 @@ export default async function adminRoutes(server: FastifyInstance) { const body = req.body as { name?: string; short?: string; + locked?: string | null; }; const name = body.name?.trim(); @@ -954,6 +1034,7 @@ export default async function adminRoutes(server: FastifyInstance) { short: tenants.short, createdAt: tenants.createdAt, locked: tenants.locked, + lockedByExportJobId: tenants.lockedByExportJobId, }); await createTenantSeeds(createdTenant.id, currentUser.id); @@ -1027,6 +1108,7 @@ export default async function adminRoutes(server: FastifyInstance) { const body = req.body as { name?: string; short?: string; + locked?: string | null; }; const updateData: Record = { @@ -1036,6 +1118,16 @@ export default async function adminRoutes(server: FastifyInstance) { if (typeof body.name === "string") updateData.name = body.name.trim(); if (typeof body.short === "string") updateData.short = body.short.trim(); + if (Object.prototype.hasOwnProperty.call(body, "locked")) { + const allowedLockedValues = new Set([null, "maintenance_tenant", "maintenance", "general", "no_subscription"]); + const lockedValue = body.locked || null; + if (!allowedLockedValues.has(lockedValue)) { + return reply.code(400).send({ error: "Invalid locked value" }); + } + + updateData.locked = lockedValue; + updateData.lockedByExportJobId = null; + } const [updatedTenant] = await server.db .update(tenants) @@ -1047,6 +1139,7 @@ export default async function adminRoutes(server: FastifyInstance) { short: tenants.short, createdAt: tenants.createdAt, locked: tenants.locked, + lockedByExportJobId: tenants.lockedByExportJobId, }); if (!updatedTenant) { @@ -1200,6 +1293,9 @@ export default async function adminRoutes(server: FastifyInstance) { // POST /admin/tenant-imports // ------------------------------------------------------------- server.post("/admin/tenant-imports", { bodyLimit: 1024 * 1024 * 1024 }, async (req, reply) => { + let importJob: any = null; + let targetTenantId: number | null = null; + try { const currentUser = await requireAdmin(req, reply); if (!currentUser) return; @@ -1212,19 +1308,71 @@ export default async function adminRoutes(server: FastifyInstance) { if (!data?.file) return reply.code(400).send({ error: "export file required" }); const archiveBuffer = await data.toBuffer(); - const targetTenantId = Number(data.fields?.targetTenantId?.value || 0) || null; - result = await importTenantFullExportArchive(server, archiveBuffer, { targetTenantId }); + targetTenantId = Number(data.fields?.targetTenantId?.value || 0) || null; + + if (targetTenantId) { + importJob = await createTenantImportJob(targetTenantId, currentUser.id, data.filename || "tenant-import.zip"); + } + + try { + result = await importTenantFullExportArchive(server, archiveBuffer, { targetTenantId }); + } catch (err: any) { + if (importJob) { + await server.db + .update(tenantExportJobs) + .set({ + status: "failed", + error: err?.message || String(err), + completedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(tenantExportJobs.id, importJob.id)); + } + throw err; + } } else { const body = req.body as TenantFullExport | { exportData?: TenantFullExport; targetTenantId?: number }; const exportData = "format" in body ? body : body.exportData; - const targetTenantId = "format" in body ? null : Number(body.targetTenantId || 0) || null; + targetTenantId = "format" in body ? null : Number(body.targetTenantId || 0) || null; if (!exportData) { return reply.code(400).send({ error: "exportData required" }); } - result = await importTenantFullExport(server, exportData, { targetTenantId }); + if (targetTenantId) { + importJob = await createTenantImportJob(targetTenantId, currentUser.id, "tenant-import.json"); + } + + try { + result = await importTenantFullExport(server, exportData, { targetTenantId }); + } catch (err: any) { + if (importJob) { + await server.db + .update(tenantExportJobs) + .set({ + status: "failed", + error: err?.message || String(err), + completedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(tenantExportJobs.id, importJob.id)); + } + throw err; + } } + + if (importJob) { + await server.db + .update(tenantExportJobs) + .set({ + status: "ready", + completedAt: new Date(), + updatedAt: new Date(), + error: null, + }) + .where(eq(tenantExportJobs.id, importJob.id)); + } + const fallbackName = deriveNameFromEmail(currentUser.email); await server.db @@ -1284,6 +1432,14 @@ export default async function adminRoutes(server: FastifyInstance) { } catch (err: any) { console.error("ERROR /admin/tenant-imports:", err); return reply.code(500).send({ error: err?.message || "Internal Server Error" }); + } finally { + if (importJob && targetTenantId) { + try { + await unlockTenantForJob(targetTenantId, importJob.id); + } catch (unlockErr) { + req.log.error({ err: unlockErr, importJobId: importJob.id, targetTenantId }, "Tenant konnte nach Import nicht entsperrt werden"); + } + } } }); diff --git a/frontend/composables/useAdmin.ts b/frontend/composables/useAdmin.ts index 89cc0e1..4820fc4 100644 --- a/frontend/composables/useAdmin.ts +++ b/frontend/composables/useAdmin.ts @@ -11,6 +11,7 @@ export type AdminTenant = { short: string user_count: number locked?: string | null + lockedByExportJobId?: string | null } export type AdminUserProfile = { diff --git a/frontend/pages/administration/tenants/[id].vue b/frontend/pages/administration/tenants/[id].vue index 87edd36..3d766a8 100644 --- a/frontend/pages/administration/tenants/[id].vue +++ b/frontend/pages/administration/tenants/[id].vue @@ -29,6 +29,13 @@ const lastImportResult = ref(null) +const lockedOptions = [ + { label: "Aktiv", value: null }, + { label: "Tenant-Wartung", value: "maintenance_tenant" }, + { label: "Globale Wartung", value: "maintenance" }, + { label: "Allgemeine Sperre", value: "general" }, + { label: "Kein Abonnement", value: "no_subscription" }, +] const tenantForm = ref(null) const assignedUsers = ref([]) @@ -77,6 +84,7 @@ const saveTenant = async () => { await admin.updateTenant(tenantForm.value.id, { name: tenantForm.value.name, short: tenantForm.value.short, + locked: tenantForm.value.locked || null, }) await fetchTenant() @@ -316,7 +324,25 @@ onMounted(async () => { + + + + + +