fix(backup): seal backups and require step-up auth to restore

Backups carried every password hash, encrypted MFA secret and webhook
secret in plain JSON, and an import upserted arbitrary rows - including
users - so anyone holding backup:import, or tricking an admin into
importing a file, could overwrite the admin account with a known password.

- Export (download and scheduled) is AES-256-GCM over the gzipped bundle,
  key from BACKUP_ENCRYPTION_KEY or derived from JWT_ACCESS_SECRET via
  HKDF; header is authenticated, decompression is capped. Scheduled files
  are written 0600 in a 0700 directory; run-now returns only the file name.
- Import requires the current password (and TOTP when MFA is on).
- Only a sealed file restores users, roles, grants, webhooks and audit
  entries; an old unsealed file restores business data only and reports
  what it skipped.
- Audit entries are append-only on import.
- Imported documents: entityType checked against the enum, MIME type
  allowlisted and matched against the file signature, path kept under the
  storage root (no more directory traversal via entityType).
- Permission cache is cleared after an import.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 02:12:43 +02:00
1 parent a25265b88a
commit bd592adf66
15 files changed
+636 -38

No files matched your search

@@ -0,0 +1,67 @@
import { BadRequestException } from '@nestjs/common';
import { isSealedBackup, openBackup, sealBackup } from './backup-crypto';
describe('backup-crypto', () => {
const originalEnv = { ...process.env };
const bundle = {
version: 2,
exportedAt: '2026-10-01T09:00:00.000Z',
data: { user: [{ id: 'u1', passwordHash: '$2a$12$secret' }] },
};
beforeEach(() => {
process.env.JWT_ACCESS_SECRET = 'x'.repeat(40);
delete process.env.BACKUP_ENCRYPTION_KEY;
});
afterEach(() => {
process.env = { ...originalEnv };
});
it('round-trips a bundle and hides its contents', () => {
const sealed = sealBackup(bundle, bundle.exportedAt);
expect(isSealedBackup(sealed)).toBe(true);
expect(JSON.stringify(sealed)).not.toContain('passwordHash');
expect(JSON.stringify(sealed)).not.toContain('$2a$12$secret');
expect(openBackup(sealed)).toEqual(bundle);
});
it('rejects a file whose ciphertext was changed', () => {
const sealed = sealBackup(bundle, bundle.exportedAt);
const bytes = Buffer.from(sealed.ciphertext, 'base64');
bytes[0] ^= 0xff;
expect(() =>
openBackup({ ...sealed, ciphertext: bytes.toString('base64') }),
).toThrow(BadRequestException);
});
it('rejects a file whose header was changed', () => {
const sealed = sealBackup(bundle, bundle.exportedAt);
expect(() =>
openBackup({ ...sealed, exportedAt: '2020-01-01T00:00:00.000Z' }),
).toThrow(BadRequestException);
});
it('rejects a file sealed with another key', () => {
const sealed = sealBackup(bundle, bundle.exportedAt);
process.env.BACKUP_ENCRYPTION_KEY = 'y'.repeat(40);
expect(() => openBackup(sealed)).toThrow(BadRequestException);
});
it('uses BACKUP_ENCRYPTION_KEY independently of the JWT secret', () => {
process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40);
const sealed = sealBackup(bundle, bundle.exportedAt);
process.env.JWT_ACCESS_SECRET = 'z'.repeat(40);
expect(openBackup(sealed)).toEqual(bundle);
});
it('refuses a too short BACKUP_ENCRYPTION_KEY', () => {
process.env.BACKUP_ENCRYPTION_KEY = 'short';
expect(() => sealBackup(bundle, bundle.exportedAt)).toThrow();
});
});
+155
View File
@@ -0,0 +1,155 @@
import { BadRequestException } from '@nestjs/common';
import * as crypto from 'node:crypto';
import * as zlib from 'node:zlib';
/**
* A backup holds everything - password hashes, encrypted MFA secrets,
* webhook signing secrets - and an import writes straight into the users
* table. So the file is sealed: AES-256-GCM over the gzipped bundle, which
* keeps it unreadable outside ApiCore AND makes any change to it (or a
* hand-made file) fail authentication before a single row is touched.
*
* Key: BACKUP_ENCRYPTION_KEY when set (needed to restore into another
* deployment), otherwise derived from JWT_ACCESS_SECRET - so a deployment
* can always restore its own backups. Either way the AES key comes from
* HKDF with a backup-specific label, never the raw secret.
*/
export const SEALED_BACKUP_FORMAT = 'apicore-backup';
export const SEALED_BACKUP_VERSION = 3;
const ALGORITHM = 'aes-256-gcm';
const IV_BYTES = 12;
const MIN_KEY_LENGTH = 32;
// Decompressed size cap: a small file must not unpack into gigabytes.
const MAX_PLAINTEXT_BYTES = 512 * 1024 * 1024;
export interface SealedBackup {
format: typeof SEALED_BACKUP_FORMAT;
version: typeof SEALED_BACKUP_VERSION;
algorithm: 'A256GCM+gzip';
/** Tells which key a file needs, without revealing the key. */
keyId: string;
exportedAt: string;
iv: string;
tag: string;
ciphertext: string;
}
function rootSecret(): { secret: string; source: 'backup' | 'jwt' } {
const backupKey = process.env.BACKUP_ENCRYPTION_KEY;
if (backupKey) {
if (backupKey.length < MIN_KEY_LENGTH) {
throw new Error(
`BACKUP_ENCRYPTION_KEY must be at least ${MIN_KEY_LENGTH} characters`,
);
}
return { secret: backupKey, source: 'backup' };
}
const jwtSecret = process.env.JWT_ACCESS_SECRET;
if (!jwtSecret) {
throw new Error('JWT_ACCESS_SECRET is not set');
}
return { secret: jwtSecret, source: 'jwt' };
}
function deriveKey(): { key: Buffer; keyId: string } {
const { secret, source } = rootSecret();
const key = Buffer.from(
crypto.hkdfSync(
'sha256',
secret,
'apicore-backup',
`apicore-backup-v${SEALED_BACKUP_VERSION}:${source}`,
32,
),
);
const keyId = crypto
.createHash('sha256')
.update(key)
.digest('hex')
.slice(0, 12);
return { key, keyId };
}
function associatedData(
header: Pick<SealedBackup, 'format' | 'version' | 'keyId' | 'exportedAt'>,
): Buffer {
return Buffer.from(
`${header.format}|${header.version}|${header.keyId}|${header.exportedAt}`,
);
}
export function sealBackup(bundle: unknown, exportedAt: string): SealedBackup {
const { key, keyId } = deriveKey();
const iv = crypto.randomBytes(IV_BYTES);
const header = {
format: SEALED_BACKUP_FORMAT,
version: SEALED_BACKUP_VERSION,
keyId,
exportedAt,
} as const;
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
cipher.setAAD(associatedData(header));
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(bundle)));
const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]);
return {
...header,
algorithm: 'A256GCM+gzip',
iv: iv.toString('base64'),
tag: cipher.getAuthTag().toString('base64'),
ciphertext: ciphertext.toString('base64'),
};
}
export function isSealedBackup(value: unknown): value is SealedBackup {
if (typeof value !== 'object' || value === null) {
return false;
}
const candidate = value as Record<string, unknown>;
return (
candidate.format === SEALED_BACKUP_FORMAT &&
typeof candidate.ciphertext === 'string'
);
}
/** The bundle inside a sealed backup; throws if the file was not made with this key or was changed. */
export function openBackup(sealed: SealedBackup): unknown {
if (sealed.version !== SEALED_BACKUP_VERSION) {
throw new BadRequestException(
`Backup-Version ${String(sealed.version)} wird nicht unterstützt`,
);
}
const { key, keyId } = deriveKey();
if (sealed.keyId !== keyId) {
throw new BadRequestException(
'Backup wurde mit einem anderen Schlüssel erstellt (BACKUP_ENCRYPTION_KEY prüfen)',
);
}
let compressed: Buffer;
try {
const decipher = crypto.createDecipheriv(
ALGORITHM,
key,
Buffer.from(sealed.iv, 'base64'),
);
decipher.setAAD(associatedData(sealed));
decipher.setAuthTag(Buffer.from(sealed.tag, 'base64'));
compressed = Buffer.concat([
decipher.update(Buffer.from(sealed.ciphertext, 'base64')),
decipher.final(),
]);
} catch {
throw new BadRequestException(
'Backup-Datei ist beschädigt oder wurde verändert',
);
}
let json: string;
try {
json = zlib
.gunzipSync(compressed, { maxOutputLength: MAX_PLAINTEXT_BYTES })
.toString('utf-8');
} catch {
throw new BadRequestException('Backup-Datei ist zu groß oder beschädigt');
}
return JSON.parse(json) as unknown;
}
@@ -144,7 +144,7 @@ export class BackupSchedulerService implements OnModuleInit {
// Manual trigger (e.g. "Jetzt sichern" button) - always runs, no
// duplicate-tick guard, since a human explicitly asked for it.
async runNow(): Promise<{ filePath: string }> {
async runNow(): Promise<{ fileName: string }> {
const settings = await this.effectiveSettings();
if (!settings.path) {
throw new Error(
@@ -153,7 +153,8 @@ export class BackupSchedulerService implements OnModuleInit {
}
const filePath = await this.writeBackup(settings.path);
await this.pruneOldBackups(settings.path, settings.retentionCount);
return { filePath };
// Only the name: the server's directory layout stays on the server.
return { fileName: path.basename(filePath) };
}
private async runScheduled(directory: string): Promise<void> {
@@ -176,14 +177,15 @@ export class BackupSchedulerService implements OnModuleInit {
}
private async writeBackup(directory: string): Promise<string> {
const bundle = await this.backupService.exportAll();
await fs.mkdir(directory, { recursive: true });
const timestamp = bundle.exportedAt.replace(/[:.]/g, '-');
const sealed = await this.backupService.exportSealed();
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
const timestamp = sealed.exportedAt.replace(/[:.]/g, '-');
const filePath = path.join(
directory,
`${BACKUP_FILE_PREFIX}${timestamp}${BACKUP_FILE_SUFFIX}`,
);
await fs.writeFile(filePath, JSON.stringify(bundle, null, 2));
// Owner-only: the file holds every account's credentials (encrypted).
await fs.writeFile(filePath, JSON.stringify(sealed), { mode: 0o600 });
return filePath;
}
@@ -16,7 +16,9 @@ import { RequirePermission } from '../auth/decorators/require-permission.decorat
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { BackupSettings } from '../../generated/prisma';
import { BackupBundle, BackupService } from './backup.service';
import { BackupService } from './backup.service';
import { ReauthService } from '../auth/reauth/reauth.service';
import { BackupImportProofDto } from './dto/backup-import-proof.dto';
import {
BackupScheduleStatus,
BackupSchedulerService,
@@ -38,6 +40,7 @@ export class BackupController {
private readonly backupService: BackupService,
private readonly backupSchedulerService: BackupSchedulerService,
private readonly backupSettingsService: BackupSettingsService,
private readonly reauth: ReauthService,
) {}
@RequirePermission('backup:export')
@@ -48,7 +51,7 @@ export class BackupController {
@RequirePermission('backup:export')
@Post('schedule/run-now')
runScheduleNow(): Promise<{ filePath: string }> {
runScheduleNow(): Promise<{ fileName: string }> {
return this.backupSchedulerService.runNow();
}
@@ -72,11 +75,12 @@ export class BackupController {
@RequirePermission('backup:export')
@Get('export')
async export(@Res() res: Response): Promise<void> {
const bundle = await this.backupService.exportAll();
const sealed = await this.backupService.exportSealed();
const filename = `apicore-backup-${new Date().toISOString().slice(0, 10)}.json`;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(JSON.stringify(bundle, null, 2));
res.setHeader('Cache-Control', 'no-store');
res.send(JSON.stringify(sealed));
}
@RequirePermission('backup:import')
@@ -99,17 +103,20 @@ export class BackupController {
)
async import(
@UploadedFile() file: Express.Multer.File | undefined,
@Body() proof: BackupImportProofDto,
@CurrentUser() actor: AuthenticatedUser,
) {
// Restoring overwrites every account: prove it is really you, now.
await this.reauth.verify(actor.id, proof);
if (!file) {
throw new BadRequestException('No file uploaded');
}
let bundle: BackupBundle;
let parsed: unknown;
try {
bundle = JSON.parse(file.buffer.toString('utf-8')) as BackupBundle;
parsed = JSON.parse(file.buffer.toString('utf-8'));
} catch {
throw new BadRequestException('Datei ist kein gültiges JSON');
}
return this.backupService.importAll(bundle, actor.id);
return this.backupService.importFile(parsed, actor.id);
}
}
@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { ReauthModule } from '../auth/reauth/reauth.module';
import { BackupController } from './backup.controller';
import { BackupService } from './backup.service';
import { BackupSchedulerService } from './backup-scheduler.service';
import { BackupSettingsService } from './backup-settings.service';
@Module({
imports: [ReauthModule],
controllers: [BackupController],
providers: [BackupService, BackupSchedulerService, BackupSettingsService],
})
@@ -2,6 +2,7 @@ import { BadRequestException } from '@nestjs/common';
import { BackupService } from './backup.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
jest.mock('node:fs/promises', () => ({
readFile: jest.fn(),
@@ -69,6 +70,8 @@ describe('BackupService', () => {
user: { upsert: jest.Mock; findMany: jest.Mock };
};
let auditLog: { record: jest.Mock };
let permissionResolver: { invalidateAll: jest.Mock };
const TRUSTED = { trusted: true };
beforeEach(() => {
jest.clearAllMocks();
@@ -92,6 +95,9 @@ describe('BackupService', () => {
findUnique: jest.fn().mockResolvedValue({ id: 'permission-1' }),
};
tx.user.findMany = jest.fn().mockResolvedValue([{ id: 'known-user' }]);
(tx.role as unknown as { findMany: jest.Mock }).findMany = jest
.fn()
.mockResolvedValue([]);
prisma = {
user: {
@@ -114,9 +120,11 @@ describe('BackupService', () => {
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
permissionResolver = { invalidateAll: jest.fn() };
service = new BackupService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
permissionResolver as unknown as PermissionResolverService,
);
});
@@ -198,7 +206,7 @@ describe('BackupService', () => {
describe('importAll', () => {
it('rejects a bundle with no data object', async () => {
await expect(
service.importAll({ version: 2 } as never, 'admin-1'),
service.importAll({ version: 2 } as never, 'admin-1', TRUSTED),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
@@ -211,6 +219,7 @@ describe('BackupService', () => {
data: { role: [{ id: 'source-id', key: 'ADMIN', name: 'Admin' }] },
},
'admin-1',
TRUSTED,
);
const [[call]] = tx.role.upsert.mock.calls as [
@@ -233,6 +242,7 @@ describe('BackupService', () => {
},
},
'admin-1',
TRUSTED,
);
const [[call]] = tx.user.upsert.mock.calls as [
@@ -252,6 +262,7 @@ describe('BackupService', () => {
},
},
'admin-1',
TRUSTED,
),
).rejects.toBeInstanceOf(BadRequestException);
});
@@ -275,6 +286,7 @@ describe('BackupService', () => {
},
},
'admin-1',
TRUSTED,
);
expect(tx.rolePermission.upsert).toHaveBeenCalledTimes(1);
@@ -293,6 +305,7 @@ describe('BackupService', () => {
},
},
'admin-1',
TRUSTED,
);
expect(tx.queen.upsert).toHaveBeenCalledTimes(2);
@@ -326,6 +339,7 @@ describe('BackupService', () => {
},
},
'admin-1',
TRUSTED,
);
const [[call]] = tx.inspection.upsert.mock.calls as [
@@ -352,6 +366,7 @@ describe('BackupService', () => {
},
},
'admin-1',
TRUSTED,
);
const [[call]] = tx.inspection.upsert.mock.calls as [
@@ -372,6 +387,7 @@ describe('BackupService', () => {
},
},
'admin-1',
TRUSTED,
);
const [[call]] = tx.stockMovement.upsert.mock.calls as [
@@ -396,6 +412,7 @@ describe('BackupService', () => {
},
},
'admin-1',
TRUSTED,
);
const [[call]] = tx.automationRule.upsert.mock.calls as [
@@ -422,14 +439,14 @@ describe('BackupService', () => {
document: [
{
id: 'doc-1',
entityType: 'Colony',
entityType: 'COLONY',
mimeType: 'application/pdf',
uploadedByUserId: 'known-user',
contentBase64: Buffer.from('hello').toString('base64'),
contentBase64: Buffer.from('%PDF-1.4 hello').toString('base64'),
},
{
id: 'doc-2',
entityType: 'Colony',
entityType: 'COLONY',
mimeType: 'application/pdf',
uploadedByUserId: null,
contentBase64: null,
@@ -438,6 +455,7 @@ describe('BackupService', () => {
},
},
'admin-1',
TRUSTED,
);
expect(fs.writeFile).toHaveBeenCalledTimes(1);
@@ -457,6 +475,7 @@ describe('BackupService', () => {
data: { apiary: [{ id: 'apiary-1', name: 'Stand Wald' }] },
},
'admin-1',
TRUSTED,
);
expect(auditLog.record).toHaveBeenCalledWith(
@@ -464,9 +483,111 @@ describe('BackupService', () => {
entityType: 'Backup',
action: 'CREATE',
userId: 'admin-1',
afterData: expect.objectContaining({ apiary: 1 }) as unknown,
afterData: expect.objectContaining({
imported: expect.objectContaining({ apiary: 1 }) as unknown,
}) as unknown,
}),
);
expect(permissionResolver.invalidateAll).toHaveBeenCalled();
});
it('refuses a document whose entityType would leave the storage root', async () => {
await expect(
service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
document: [
{
id: 'doc-evil',
entityType: '../../app/dist',
mimeType: 'application/pdf',
contentBase64: Buffer.from('%PDF-1.4').toString('base64'),
},
],
},
},
'admin-1',
TRUSTED,
),
).rejects.toBeInstanceOf(BadRequestException);
expect(fs.mkdir).not.toHaveBeenCalled();
expect(fs.writeFile).not.toHaveBeenCalled();
});
it('refuses a document whose bytes do not match its declared type', async () => {
await expect(
service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
document: [
{
id: 'doc-html',
entityType: 'COLONY',
mimeType: 'application/pdf',
contentBase64: Buffer.from('<script>').toString('base64'),
},
],
},
},
'admin-1',
TRUSTED,
),
).rejects.toBeInstanceOf(BadRequestException);
expect(fs.writeFile).not.toHaveBeenCalled();
});
it('never rewrites audit entries that already exist', async () => {
await service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: { auditLog: [{ id: 'audit-1', action: 'LOGIN' }] },
},
'admin-1',
TRUSTED,
);
const [[call]] = tx.auditLog.upsert.mock.calls as [
[{ update: Record<string, unknown> }],
];
expect(call.update).toEqual({});
});
it('takes no users, roles, webhooks or audit entries from an unsealed file', async () => {
const summary = await service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
role: [{ key: 'ADMIN', name: 'Admin' }],
user: [
{
id: 'admin-victim',
email: '[email protected]',
passwordHash: 'attacker-hash',
roleKey: 'ADMIN',
},
],
webhookSubscription: [{ id: 'hook-1', url: 'https://evil' }],
auditLog: [{ id: 'audit-1' }],
apiary: [{ id: 'apiary-1', name: 'Stand Wald' }],
},
},
'admin-1',
{ trusted: false },
);
expect(tx.role.upsert).not.toHaveBeenCalled();
expect(tx.user.upsert).not.toHaveBeenCalled();
expect(tx.webhookSubscription.upsert).not.toHaveBeenCalled();
expect(tx.auditLog.upsert).not.toHaveBeenCalled();
expect(tx.apiary.upsert).toHaveBeenCalledTimes(1);
expect(summary.skipped).toEqual(
expect.arrayContaining(['role', 'user', 'webhookSubscription']),
);
});
});
});
+107 -13
View File
@@ -4,7 +4,19 @@ import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { ALLOWED_DOCUMENT_MIME_TYPES } from '../documents/document-mime-types';
import {
ALLOWED_DOCUMENT_MIME_TYPES,
isAllowedDocumentMimeType,
} from '../documents/document-mime-types';
import { matchesFileSignature } from '../common/file-signature';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { DocumentEntityType } from '../../generated/prisma';
import {
SealedBackup,
isSealedBackup,
openBackup,
sealBackup,
} from './backup-crypto';
const BACKUP_VERSION = 2;
const IMPORT_TRANSACTION_TIMEOUT_MS = 10 * 60 * 1000;
@@ -17,8 +29,28 @@ export interface BackupBundle {
export interface BackupImportSummary {
imported: Record<string, number>;
/**
* Tables left out because the file was an old, unsealed (v2) backup: its
* contents cannot be proven to come from ApiCore, so nothing that grants
* access (users, roles, webhooks) or rewrites history (audit log) is
* taken from it.
*/
skipped: string[];
}
// What an unsealed file may never write - see BackupImportSummary.skipped.
const TRUSTED_ONLY_TABLES = [
'role',
'user',
'rolePermission',
'webhookSubscription',
'auditLog',
] as const;
const DOCUMENT_ENTITY_TYPES = new Set<string>(
Object.values(DocumentEntityType),
);
// Excluded on purpose:
// - Permission: code-derived, re-synced from the catalog on every boot -
// including it would just fight that sync. RolePermission references
@@ -55,8 +87,33 @@ export class BackupService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly permissionResolver: PermissionResolverService,
) {}
/** The export as it leaves the server: encrypted and tamper-evident. */
async exportSealed(): Promise<SealedBackup> {
const bundle = await this.exportAll();
return sealBackup(bundle, bundle.exportedAt);
}
/**
* Restores an uploaded file. A sealed backup is restored completely; an
* old unsealed one only with its business data (see TRUSTED_ONLY_TABLES).
*/
async importFile(
parsed: unknown,
actingUserId: string,
): Promise<BackupImportSummary> {
if (isSealedBackup(parsed)) {
return this.importAll(openBackup(parsed) as BackupBundle, actingUserId, {
trusted: true,
});
}
return this.importAll(parsed as BackupBundle, actingUserId, {
trusted: false,
});
}
async exportAll(): Promise<BackupBundle> {
const data: Record<string, unknown[]> = {};
@@ -145,6 +202,7 @@ export class BackupService {
async importAll(
bundle: BackupBundle,
actingUserId: string,
{ trusted }: { trusted: boolean },
): Promise<BackupImportSummary> {
if (
typeof bundle !== 'object' ||
@@ -155,7 +213,18 @@ export class BackupService {
throw new BadRequestException('Invalid backup file');
}
const isTrustedOnly = (key: string): boolean =>
(TRUSTED_ONLY_TABLES as readonly string[]).includes(key);
const skipped = trusted
? []
: TRUSTED_ONLY_TABLES.filter((key) => {
const value = bundle.data[key];
return Array.isArray(value) && value.length > 0;
});
const rowsOf = (key: string): Row[] => {
if (!trusted && isTrustedOnly(key)) {
return [];
}
const value = bundle.data[key];
return Array.isArray(value) ? (value as Row[]) : [];
};
@@ -165,6 +234,13 @@ export class BackupService {
await this.prisma.$transaction(
async (tx) => {
const roleKeyToId = new Map<string, string>();
// Roles already in this database resolve too - an unsealed file
// brings none of its own.
for (const role of await tx.role.findMany({
select: { id: true, key: true },
})) {
roleKeyToId.set(role.key, role.id);
}
for (const row of rowsOf('role')) {
const withoutId: Row = { ...row };
delete withoutId.id;
@@ -693,13 +769,30 @@ export class BackupService {
if (!contentBase64) {
continue;
}
const entityType = asString(row, 'entityType');
const mimeType = asString(row, 'mimeType');
if (
!entityType ||
!DOCUMENT_ENTITY_TYPES.has(entityType) ||
!mimeType ||
!isAllowedDocumentMimeType(mimeType)
) {
throw new BadRequestException(
`Dokument ${String(row.id)} hat einen unzulässigen Typ`,
);
}
const buffer = Buffer.from(contentBase64, 'base64');
const extension =
ALLOWED_DOCUMENT_MIME_TYPES[row.mimeType as string] ?? '';
const directory = path.join(
documentsStorageRoot(),
(row.entityType as string).toLowerCase(),
);
if (!matchesFileSignature(buffer, mimeType)) {
throw new BadRequestException(
`Dokument ${String(row.id)}: Inhalt passt nicht zum Dateityp`,
);
}
const extension = ALLOWED_DOCUMENT_MIME_TYPES[mimeType];
const storageRoot = path.resolve(documentsStorageRoot());
const directory = path.join(storageRoot, entityType.toLowerCase());
if (!directory.startsWith(storageRoot + path.sep)) {
throw new BadRequestException('Unzulässiger Speicherpfad');
}
await fs.mkdir(directory, { recursive: true });
const storagePath = path.join(
directory,
@@ -739,6 +832,7 @@ export class BackupService {
}
imported.notification = rowsOf('notification').length;
// Append-only: entries already here are never rewritten by a file.
for (const row of rowsOf('auditLog')) {
await tx.auditLog.upsert({
where: { id: row.id as string },
@@ -746,10 +840,7 @@ export class BackupService {
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
} as never,
update: {
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
} as never,
update: {},
});
}
imported.auditLog = rowsOf('auditLog').length;
@@ -757,14 +848,17 @@ export class BackupService {
{ timeout: IMPORT_TRANSACTION_TIMEOUT_MS },
);
// Users, roles and grants may have changed underneath every cache.
this.permissionResolver.invalidateAll();
await this.auditLog.record({
entityType: 'Backup',
entityId: 'import',
action: 'CREATE',
userId: actingUserId,
afterData: imported,
afterData: { imported, skipped, sealed: trusted },
});
return { imported };
return { imported, skipped };
}
}
@@ -0,0 +1,16 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, Matches, MaxLength } from 'class-validator';
/** Step-up proof sent with a backup upload (multipart fields). */
export class BackupImportProofDto {
@ApiPropertyOptional({ description: 'Aktuelles Passwort' })
@IsOptional()
@IsString()
@MaxLength(200)
password?: string;
@ApiPropertyOptional({ description: 'Aktueller TOTP-Code, falls MFA aktiv' })
@IsOptional()
@Matches(/^\d{6}$/)
mfaCode?: string;
}