Extend tenant maintenance lock for imports
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 27s
Build and Push Docker Images / build-frontend (push) Successful in 1m11s
Build and Push Docker Images / build-website (push) Successful in 18s
Build and Push Docker Images / build-docs (push) Successful in 16s

This commit is contained in:
2026-07-14 11:40:48 +02:00
parent ee7d3d3afc
commit a945aaffe3
8 changed files with 253 additions and 4 deletions

View File

@@ -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 $$;

View File

@@ -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
}
]
}

View File

@@ -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"),

View File

@@ -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"),
}
)

View File

@@ -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
// --------------------------------------------------------

View File

@@ -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<string, any> = {
@@ -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;
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");
}
}
}
});

View File

@@ -11,6 +11,7 @@ export type AdminTenant = {
short: string
user_count: number
locked?: string | null
lockedByExportJobId?: string | null
}
export type AdminUserProfile = {

View File

@@ -29,6 +29,13 @@ const lastImportResult = ref<null | {
restoredFiles: number
skippedFiles: number
}>(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<AdminTenant | null>(null)
const assignedUsers = ref<AdminUser[]>([])
@@ -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 () => {
<UFormField label="Kürzel">
<UInput v-model="tenantForm.short" />
</UFormField>
<UFormField label="Locked Mode">
<USelectMenu
v-model="tenantForm.locked"
:items="lockedOptions"
value-key="value"
label-key="label"
/>
</UFormField>
</UForm>
<UAlert
v-if="tenantForm.lockedByExportJobId"
class="mt-4"
title="Automatische Sperre aktiv"
:description="`Dieser Tenant wird gerade durch Export/Import ${tenantForm.lockedByExportJobId} pausiert.`"
color="warning"
variant="soft"
/>
</UCard>
<UCard v-if="!loading && tenantForm" class="mt-3">