Restrict tenant imports to new tenants

This commit is contained in:
2026-08-06 15:19:11 +02:00
parent c66d04f0b5
commit cb37970074
12 changed files with 38 additions and 1867 deletions

View File

@@ -11,8 +11,6 @@ import { pool } from "../../db"
import { s3 } from "./s3"
import { secrets } from "./secrets"
import { decrypt, encrypt } from "./crypt"
import { addTenantExportGlobalResources } from "./tenantExportGlobalResources"
import { restoreImportedTenantNumberRanges } from "./tenantImportNumberRanges"
type TableRows = Record<string, Record<string, any>[]>
type TableMetadata = {
@@ -46,7 +44,6 @@ type ImportResult = {
}
type ImportOptions = {
targetTenantId?: number | null
onProgress?: (progress: { done: number; total: number; message?: string }) => Promise<void> | void
}
@@ -84,6 +81,10 @@ const ENTITY_BANKACCOUNT_PLAIN_FIELDS = {
bankName: "__plainBankName",
}
// Diese globalen Stammdaten werden installationsweit per SQL-Migration gepflegt
// und duerfen auch aus aelteren Tenant-Archiven nicht importiert werden.
const GLOBAL_MIGRATION_TABLES = new Set(["accounts", "units", "citys", "countrys"])
const quoteIdent = (value: string) => `"${value.replace(/"/g, '""')}"`
const matrixServerName = () =>
process.env.MATRIX_SERVER_NAME ||
@@ -301,14 +302,6 @@ export const buildTenantFullExport = async (
if (!tenantRows.length) throw new Error("Tenant nicht gefunden")
addRows(tables, "tenants", tenantRows)
await addTenantExportGlobalResources(
tables,
new Set(columnsByTable.keys()),
tenantRows[0],
(table, whereSql, params) => loadRows(client, table, whereSql, params),
addRows
)
for (const [table, metadata] of columnsByTable.entries()) {
if (table === "tenants") continue
const { columns } = metadata
@@ -612,58 +605,6 @@ const restoreArchiveFiles = async (
return { restored, skipped }
}
const remapTenantScopedExport = (
exportData: TenantFullExport,
targetTenantId?: number | null
): TenantFullExport => {
if (!targetTenantId || targetTenantId === exportData.tenantId) return exportData
const sourceTenantId = exportData.tenantId
const sourcePathPrefix = `${sourceTenantId}/`
const targetPathPrefix = `${targetTenantId}/`
const tables: TableRows = {}
for (const [table, rows] of Object.entries(exportData.tables || {})) {
tables[table] = rows.map((row) => {
const nextRow = { ...row }
if (table === "tenants" && nextRow.id === sourceTenantId) {
nextRow.id = targetTenantId
}
if (nextRow.tenant === sourceTenantId) {
nextRow.tenant = targetTenantId
}
if (nextRow.tenant_id === sourceTenantId) {
nextRow.tenant_id = targetTenantId
}
if (table === "files" && typeof nextRow.path === "string" && nextRow.path.startsWith(sourcePathPrefix)) {
nextRow.path = `${targetPathPrefix}${nextRow.path.slice(sourcePathPrefix.length)}`
}
if (table === "letterheads" && typeof nextRow.path === "string" && nextRow.path.startsWith(sourcePathPrefix)) {
nextRow.path = `${targetPathPrefix}${nextRow.path.slice(sourcePathPrefix.length)}`
}
return nextRow
})
}
return {
...exportData,
tenantId: targetTenantId,
tables,
files: (exportData.files || []).map((file) => ({
...file,
path: file.path?.startsWith(sourcePathPrefix)
? `${targetPathPrefix}${file.path.slice(sourcePathPrefix.length)}`
: file.path,
})),
}
}
const encryptEntityBankAccountRowsForImport = (exportData: TenantFullExport) => {
const rows = exportData.tables.entitybankaccounts || []
@@ -712,12 +653,6 @@ const prepareCommunicationRoomsForImport = (exportData: TenantFullExport) => {
}
}
export const prepareTenantFullExportRowsForImport = (exportData: TenantFullExport) => {
encryptEntityBankAccountRowsForImport(exportData)
prepareCommunicationRoomsForImport(exportData)
return exportData
}
const cleanupImportedCommunicationRooms = async (client: any, exportData: TenantFullExport) => {
const rows = exportData.tables.communication_rooms || []
if (!rows.length) return 0
@@ -905,8 +840,9 @@ export const importTenantFullExport = async (
throw new Error("Ungültiges FEDEO Mandantenexport-Format")
}
const exportData = remapTenantScopedExport(rawExportData, options.targetTenantId)
prepareTenantFullExportRowsForImport(exportData)
const exportData = rawExportData
encryptEntityBankAccountRowsForImport(exportData)
prepareCommunicationRoomsForImport(exportData)
const client = await pool.connect()
const importOrder = [
"tenants",
@@ -921,11 +857,21 @@ export const importTenantFullExport = async (
]
try {
const existingTenant = await client.query(
`select 1 from ${quoteIdent("tenants")} where ${quoteIdent("id")} = $1 limit 1`,
[exportData.tenantId]
)
if (existingTenant.rows.length) {
throw new Error("Ein Tenant mit dieser ID existiert bereits. Tenant-Exporte können nur als neuer Tenant importiert werden.")
}
const columnsByTable = await tableColumns(client)
const tableNames = [
...importOrder,
...Object.keys(exportData.tables).filter((table) => !importOrder.includes(table)).sort(),
].filter((table, index, all) => all.indexOf(table) === index)
].filter((table, index, all) =>
all.indexOf(table) === index && !GLOBAL_MIGRATION_TABLES.has(table)
)
const specialTables = [
columnsByTable.has("auth_tenant_users") ? "auth_tenant_users" : null,
columnsByTable.has("bankaccounts") ? "bankaccounts" : null,
@@ -1005,8 +951,6 @@ export const importTenantFullExport = async (
progressDone += 1
await reportProgress("Kommunikationsräume bereinigt")
await restoreImportedTenantNumberRanges(client, exportData)
await refreshSequences(client, columnsByTable)
progressDone = progressTotal
await reportProgress("Import abgeschlossen")
@@ -1030,42 +974,6 @@ export const importTenantFullExportArchive = async (
archiveBuffer: Buffer,
options: ImportOptions = {}
): Promise<ImportResult> => {
const rawExportData = await readTenantFullExportArchive(archiveBuffer)
const exportData = remapTenantScopedExport(rawExportData, options.targetTenantId)
const reader = new ZipReader(new BlobReader(new Blob([archiveBuffer])))
try {
const entries = await reader.getEntries()
const entriesByName = new Map(entries.map((entry: any) => [entry.filename, entry]))
const manifest = JSON.parse(await readZipTextEntry(entriesByName, "manifest.json")) as TenantArchiveManifest
const sourceTenantId = rawExportData.tenantId
const targetTenantId = exportData.tenantId
const sourcePrefix = `${sourceTenantId}/`
const targetPrefix = `${targetTenantId}/`
const remappedManifest: TenantArchiveManifest = {
...manifest,
tenantId: targetTenantId,
files: (manifest.files || []).map((file) => ({
...file,
path: file.path?.startsWith(sourcePrefix)
? `${targetPrefix}${file.path.slice(sourcePrefix.length)}`
: file.path,
})),
}
const result = await importTenantFullExport(server, exportData, { targetTenantId: null })
const files = await restoreArchiveFiles(entriesByName, exportData, remappedManifest)
return {
...result,
files,
}
} finally {
await reader.close()
}
}
export const readTenantFullExportArchive = async (archiveBuffer: Buffer): Promise<TenantFullExport> => {
const reader = new ZipReader(new BlobReader(new Blob([archiveBuffer])))
try {
@@ -1082,7 +990,7 @@ export const readTenantFullExportArchive = async (archiveBuffer: Buffer): Promis
tables[table.name] = JSON.parse(await readZipTextEntry(entriesByName, table.path))
}
return {
const rawExportData: TenantFullExport = {
format: "fedeo.tenant-full-export",
version: 1,
exportedAt: manifest.exportedAt,
@@ -1100,57 +1008,14 @@ export const readTenantFullExportArchive = async (archiveBuffer: Buffer): Promis
})),
}
const result = await importTenantFullExport(server, rawExportData)
const files = await restoreArchiveFiles(entriesByName, rawExportData, manifest)
return {
...result,
files,
}
} finally {
await reader.close()
}
}
export const restoreTenantMergeArchiveFiles = async (
archiveBuffer: Buffer,
rawExportData: TenantFullExport,
targetTenantId: number,
selectedFileRefs: ReadonlySet<string>
) => {
const reader = new ZipReader(new BlobReader(new Blob([archiveBuffer])))
try {
const entries = await reader.getEntries()
const entriesByName = new Map(entries.map((entry: any) => [entry.filename, entry]))
const manifest = JSON.parse(await readZipTextEntry(entriesByName, "manifest.json")) as TenantArchiveManifest
const exportData = remapTenantScopedExport(rawExportData, targetTenantId)
const sourcePrefix = `${rawExportData.tenantId}/`
const targetPrefix = `${targetTenantId}/`
const filteredFiles = (manifest.files || [])
.filter((file) => selectedFileRefs.has(String(file.id)))
.map((file) => ({
...file,
path: file.path?.startsWith(sourcePrefix)
? `${targetPrefix}${file.path.slice(sourcePrefix.length)}`
: file.path,
}))
const filteredManifest: TenantArchiveManifest = {
...manifest,
tenantId: targetTenantId,
files: filteredFiles,
}
const filteredExportData: TenantFullExport = {
...exportData,
files: exportData.files.filter((file) => selectedFileRefs.has(String(file.id))),
}
return await restoreArchiveFiles(entriesByName, filteredExportData, filteredManifest)
} finally {
await reader.close()
}
}
export const restoreTenantMergeInlineFiles = async (
rawExportData: TenantFullExport,
targetTenantId: number,
selectedFileRefs: ReadonlySet<string>
) => {
const exportData = remapTenantScopedExport(rawExportData, targetTenantId)
return await restoreFiles({
...exportData,
files: exportData.files.filter((file) => selectedFileRefs.has(String(file.id))),
})
}