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

+5
View File
@@ -38,6 +38,11 @@ BACKUP_CRON_SCHEDULE="0 3 * * *"
# Wie viele der neuesten Backup-Dateien behalten werden, aeltere werden nach # Wie viele der neuesten Backup-Dateien behalten werden, aeltere werden nach
# jedem Lauf geloescht (Standard: 14). # jedem Lauf geloescht (Standard: 14).
BACKUP_RETENTION_COUNT=14 BACKUP_RETENTION_COUNT=14
# Backups are encrypted (AES-256-GCM). Without this key they are sealed with a
# key derived from JWT_ACCESS_SECRET and restore only into this deployment.
# Set the same value on another deployment to restore there. At least 32
# characters, e.g. "openssl rand -hex 32". Keep it outside the backup storage.
# BACKUP_ENCRYPTION_KEY=""
# Entra ID SSO. Ohne alle vier Werte bleibt SSO deaktiviert # Entra ID SSO. Ohne alle vier Werte bleibt SSO deaktiviert
# (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start). # (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start).
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { MfaModule } from '../mfa/mfa.module';
import { ReauthService } from './reauth.service';
@Module({
imports: [MfaModule],
providers: [ReauthService],
exports: [ReauthService],
})
export class ReauthModule {}
@@ -0,0 +1,60 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
import { MfaService } from '../mfa/mfa.service';
export interface ReauthProof {
password?: string;
mfaCode?: string;
}
/**
* Step-up check for actions that are as powerful as the account itself
* (restoring a backup overwrites every user): a stolen access token alone
* must not be enough, so the caller proves they know the password - and,
* with MFA on, holds the second factor - right now.
*/
@Injectable()
export class ReauthService {
constructor(
private readonly prisma: PrismaService,
private readonly mfaService: MfaService,
) {}
async verify(userId: string, proof: ReauthProof): Promise<void> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: {
email: true,
passwordHash: true,
mfaEnabled: true,
mfaSecret: true,
isActive: true,
},
});
const passwordMatches =
Boolean(user?.isActive) &&
typeof proof.password === 'string' &&
(await bcrypt.compare(proof.password, user!.passwordHash));
if (!user || !passwordMatches) {
throw new UnauthorizedException(
'Bitte zur Bestätigung Ihr aktuelles Passwort eingeben',
);
}
if (user.mfaEnabled) {
const codeMatches =
typeof proof.mfaCode === 'string' &&
user.mfaSecret !== null &&
(await this.mfaService.verifyCode(
user.mfaSecret,
user.email,
proof.mfaCode,
));
if (!codeMatches) {
throw new UnauthorizedException(
'Bitte zur Bestätigung den aktuellen Code aus Ihrer Authenticator-App eingeben',
);
}
}
}
}
@@ -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 // Manual trigger (e.g. "Jetzt sichern" button) - always runs, no
// duplicate-tick guard, since a human explicitly asked for it. // 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(); const settings = await this.effectiveSettings();
if (!settings.path) { if (!settings.path) {
throw new Error( throw new Error(
@@ -153,7 +153,8 @@ export class BackupSchedulerService implements OnModuleInit {
} }
const filePath = await this.writeBackup(settings.path); const filePath = await this.writeBackup(settings.path);
await this.pruneOldBackups(settings.path, settings.retentionCount); 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> { private async runScheduled(directory: string): Promise<void> {
@@ -176,14 +177,15 @@ export class BackupSchedulerService implements OnModuleInit {
} }
private async writeBackup(directory: string): Promise<string> { private async writeBackup(directory: string): Promise<string> {
const bundle = await this.backupService.exportAll(); const sealed = await this.backupService.exportSealed();
await fs.mkdir(directory, { recursive: true }); await fs.mkdir(directory, { recursive: true, mode: 0o700 });
const timestamp = bundle.exportedAt.replace(/[:.]/g, '-'); const timestamp = sealed.exportedAt.replace(/[:.]/g, '-');
const filePath = path.join( const filePath = path.join(
directory, directory,
`${BACKUP_FILE_PREFIX}${timestamp}${BACKUP_FILE_SUFFIX}`, `${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; return filePath;
} }
@@ -16,7 +16,9 @@ import { RequirePermission } from '../auth/decorators/require-permission.decorat
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { BackupSettings } from '../../generated/prisma'; 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 { import {
BackupScheduleStatus, BackupScheduleStatus,
BackupSchedulerService, BackupSchedulerService,
@@ -38,6 +40,7 @@ export class BackupController {
private readonly backupService: BackupService, private readonly backupService: BackupService,
private readonly backupSchedulerService: BackupSchedulerService, private readonly backupSchedulerService: BackupSchedulerService,
private readonly backupSettingsService: BackupSettingsService, private readonly backupSettingsService: BackupSettingsService,
private readonly reauth: ReauthService,
) {} ) {}
@RequirePermission('backup:export') @RequirePermission('backup:export')
@@ -48,7 +51,7 @@ export class BackupController {
@RequirePermission('backup:export') @RequirePermission('backup:export')
@Post('schedule/run-now') @Post('schedule/run-now')
runScheduleNow(): Promise<{ filePath: string }> { runScheduleNow(): Promise<{ fileName: string }> {
return this.backupSchedulerService.runNow(); return this.backupSchedulerService.runNow();
} }
@@ -72,11 +75,12 @@ export class BackupController {
@RequirePermission('backup:export') @RequirePermission('backup:export')
@Get('export') @Get('export')
async export(@Res() res: Response): Promise<void> { 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`; const filename = `apicore-backup-${new Date().toISOString().slice(0, 10)}.json`;
res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); 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') @RequirePermission('backup:import')
@@ -99,17 +103,20 @@ export class BackupController {
) )
async import( async import(
@UploadedFile() file: Express.Multer.File | undefined, @UploadedFile() file: Express.Multer.File | undefined,
@Body() proof: BackupImportProofDto,
@CurrentUser() actor: AuthenticatedUser, @CurrentUser() actor: AuthenticatedUser,
) { ) {
// Restoring overwrites every account: prove it is really you, now.
await this.reauth.verify(actor.id, proof);
if (!file) { if (!file) {
throw new BadRequestException('No file uploaded'); throw new BadRequestException('No file uploaded');
} }
let bundle: BackupBundle; let parsed: unknown;
try { try {
bundle = JSON.parse(file.buffer.toString('utf-8')) as BackupBundle; parsed = JSON.parse(file.buffer.toString('utf-8'));
} catch { } catch {
throw new BadRequestException('Datei ist kein gültiges JSON'); 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 { Module } from '@nestjs/common';
import { ReauthModule } from '../auth/reauth/reauth.module';
import { BackupController } from './backup.controller'; import { BackupController } from './backup.controller';
import { BackupService } from './backup.service'; import { BackupService } from './backup.service';
import { BackupSchedulerService } from './backup-scheduler.service'; import { BackupSchedulerService } from './backup-scheduler.service';
import { BackupSettingsService } from './backup-settings.service'; import { BackupSettingsService } from './backup-settings.service';
@Module({ @Module({
imports: [ReauthModule],
controllers: [BackupController], controllers: [BackupController],
providers: [BackupService, BackupSchedulerService, BackupSettingsService], providers: [BackupService, BackupSchedulerService, BackupSettingsService],
}) })
@@ -2,6 +2,7 @@ import { BadRequestException } from '@nestjs/common';
import { BackupService } from './backup.service'; import { BackupService } from './backup.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
jest.mock('node:fs/promises', () => ({ jest.mock('node:fs/promises', () => ({
readFile: jest.fn(), readFile: jest.fn(),
@@ -69,6 +70,8 @@ describe('BackupService', () => {
user: { upsert: jest.Mock; findMany: jest.Mock }; user: { upsert: jest.Mock; findMany: jest.Mock };
}; };
let auditLog: { record: jest.Mock }; let auditLog: { record: jest.Mock };
let permissionResolver: { invalidateAll: jest.Mock };
const TRUSTED = { trusted: true };
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
@@ -92,6 +95,9 @@ describe('BackupService', () => {
findUnique: jest.fn().mockResolvedValue({ id: 'permission-1' }), findUnique: jest.fn().mockResolvedValue({ id: 'permission-1' }),
}; };
tx.user.findMany = jest.fn().mockResolvedValue([{ id: 'known-user' }]); tx.user.findMany = jest.fn().mockResolvedValue([{ id: 'known-user' }]);
(tx.role as unknown as { findMany: jest.Mock }).findMany = jest
.fn()
.mockResolvedValue([]);
prisma = { prisma = {
user: { user: {
@@ -114,9 +120,11 @@ describe('BackupService', () => {
auditLog = { record: jest.fn().mockResolvedValue(undefined) }; auditLog = { record: jest.fn().mockResolvedValue(undefined) };
permissionResolver = { invalidateAll: jest.fn() };
service = new BackupService( service = new BackupService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService, auditLog as unknown as AuditLogService,
permissionResolver as unknown as PermissionResolverService,
); );
}); });
@@ -198,7 +206,7 @@ describe('BackupService', () => {
describe('importAll', () => { describe('importAll', () => {
it('rejects a bundle with no data object', async () => { it('rejects a bundle with no data object', async () => {
await expect( await expect(
service.importAll({ version: 2 } as never, 'admin-1'), service.importAll({ version: 2 } as never, 'admin-1', TRUSTED),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.$transaction).not.toHaveBeenCalled(); expect(prisma.$transaction).not.toHaveBeenCalled();
}); });
@@ -211,6 +219,7 @@ describe('BackupService', () => {
data: { role: [{ id: 'source-id', key: 'ADMIN', name: 'Admin' }] }, data: { role: [{ id: 'source-id', key: 'ADMIN', name: 'Admin' }] },
}, },
'admin-1', 'admin-1',
TRUSTED,
); );
const [[call]] = tx.role.upsert.mock.calls as [ const [[call]] = tx.role.upsert.mock.calls as [
@@ -233,6 +242,7 @@ describe('BackupService', () => {
}, },
}, },
'admin-1', 'admin-1',
TRUSTED,
); );
const [[call]] = tx.user.upsert.mock.calls as [ const [[call]] = tx.user.upsert.mock.calls as [
@@ -252,6 +262,7 @@ describe('BackupService', () => {
}, },
}, },
'admin-1', 'admin-1',
TRUSTED,
), ),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
}); });
@@ -275,6 +286,7 @@ describe('BackupService', () => {
}, },
}, },
'admin-1', 'admin-1',
TRUSTED,
); );
expect(tx.rolePermission.upsert).toHaveBeenCalledTimes(1); expect(tx.rolePermission.upsert).toHaveBeenCalledTimes(1);
@@ -293,6 +305,7 @@ describe('BackupService', () => {
}, },
}, },
'admin-1', 'admin-1',
TRUSTED,
); );
expect(tx.queen.upsert).toHaveBeenCalledTimes(2); expect(tx.queen.upsert).toHaveBeenCalledTimes(2);
@@ -326,6 +339,7 @@ describe('BackupService', () => {
}, },
}, },
'admin-1', 'admin-1',
TRUSTED,
); );
const [[call]] = tx.inspection.upsert.mock.calls as [ const [[call]] = tx.inspection.upsert.mock.calls as [
@@ -352,6 +366,7 @@ describe('BackupService', () => {
}, },
}, },
'admin-1', 'admin-1',
TRUSTED,
); );
const [[call]] = tx.inspection.upsert.mock.calls as [ const [[call]] = tx.inspection.upsert.mock.calls as [
@@ -372,6 +387,7 @@ describe('BackupService', () => {
}, },
}, },
'admin-1', 'admin-1',
TRUSTED,
); );
const [[call]] = tx.stockMovement.upsert.mock.calls as [ const [[call]] = tx.stockMovement.upsert.mock.calls as [
@@ -396,6 +412,7 @@ describe('BackupService', () => {
}, },
}, },
'admin-1', 'admin-1',
TRUSTED,
); );
const [[call]] = tx.automationRule.upsert.mock.calls as [ const [[call]] = tx.automationRule.upsert.mock.calls as [
@@ -422,14 +439,14 @@ describe('BackupService', () => {
document: [ document: [
{ {
id: 'doc-1', id: 'doc-1',
entityType: 'Colony', entityType: 'COLONY',
mimeType: 'application/pdf', mimeType: 'application/pdf',
uploadedByUserId: 'known-user', uploadedByUserId: 'known-user',
contentBase64: Buffer.from('hello').toString('base64'), contentBase64: Buffer.from('%PDF-1.4 hello').toString('base64'),
}, },
{ {
id: 'doc-2', id: 'doc-2',
entityType: 'Colony', entityType: 'COLONY',
mimeType: 'application/pdf', mimeType: 'application/pdf',
uploadedByUserId: null, uploadedByUserId: null,
contentBase64: null, contentBase64: null,
@@ -438,6 +455,7 @@ describe('BackupService', () => {
}, },
}, },
'admin-1', 'admin-1',
TRUSTED,
); );
expect(fs.writeFile).toHaveBeenCalledTimes(1); expect(fs.writeFile).toHaveBeenCalledTimes(1);
@@ -457,6 +475,7 @@ describe('BackupService', () => {
data: { apiary: [{ id: 'apiary-1', name: 'Stand Wald' }] }, data: { apiary: [{ id: 'apiary-1', name: 'Stand Wald' }] },
}, },
'admin-1', 'admin-1',
TRUSTED,
); );
expect(auditLog.record).toHaveBeenCalledWith( expect(auditLog.record).toHaveBeenCalledWith(
@@ -464,9 +483,111 @@ describe('BackupService', () => {
entityType: 'Backup', entityType: 'Backup',
action: 'CREATE', action: 'CREATE',
userId: 'admin-1', 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 * as path from 'node:path';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.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 BACKUP_VERSION = 2;
const IMPORT_TRANSACTION_TIMEOUT_MS = 10 * 60 * 1000; const IMPORT_TRANSACTION_TIMEOUT_MS = 10 * 60 * 1000;
@@ -17,8 +29,28 @@ export interface BackupBundle {
export interface BackupImportSummary { export interface BackupImportSummary {
imported: Record<string, number>; 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: // Excluded on purpose:
// - Permission: code-derived, re-synced from the catalog on every boot - // - Permission: code-derived, re-synced from the catalog on every boot -
// including it would just fight that sync. RolePermission references // including it would just fight that sync. RolePermission references
@@ -55,8 +87,33 @@ export class BackupService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService, 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> { async exportAll(): Promise<BackupBundle> {
const data: Record<string, unknown[]> = {}; const data: Record<string, unknown[]> = {};
@@ -145,6 +202,7 @@ export class BackupService {
async importAll( async importAll(
bundle: BackupBundle, bundle: BackupBundle,
actingUserId: string, actingUserId: string,
{ trusted }: { trusted: boolean },
): Promise<BackupImportSummary> { ): Promise<BackupImportSummary> {
if ( if (
typeof bundle !== 'object' || typeof bundle !== 'object' ||
@@ -155,7 +213,18 @@ export class BackupService {
throw new BadRequestException('Invalid backup file'); 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[] => { const rowsOf = (key: string): Row[] => {
if (!trusted && isTrustedOnly(key)) {
return [];
}
const value = bundle.data[key]; const value = bundle.data[key];
return Array.isArray(value) ? (value as Row[]) : []; return Array.isArray(value) ? (value as Row[]) : [];
}; };
@@ -165,6 +234,13 @@ export class BackupService {
await this.prisma.$transaction( await this.prisma.$transaction(
async (tx) => { async (tx) => {
const roleKeyToId = new Map<string, string>(); 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')) { for (const row of rowsOf('role')) {
const withoutId: Row = { ...row }; const withoutId: Row = { ...row };
delete withoutId.id; delete withoutId.id;
@@ -693,13 +769,30 @@ export class BackupService {
if (!contentBase64) { if (!contentBase64) {
continue; 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 buffer = Buffer.from(contentBase64, 'base64');
const extension = if (!matchesFileSignature(buffer, mimeType)) {
ALLOWED_DOCUMENT_MIME_TYPES[row.mimeType as string] ?? ''; throw new BadRequestException(
const directory = path.join( `Dokument ${String(row.id)}: Inhalt passt nicht zum Dateityp`,
documentsStorageRoot(), );
(row.entityType as string).toLowerCase(), }
); 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 }); await fs.mkdir(directory, { recursive: true });
const storagePath = path.join( const storagePath = path.join(
directory, directory,
@@ -739,6 +832,7 @@ export class BackupService {
} }
imported.notification = rowsOf('notification').length; imported.notification = rowsOf('notification').length;
// Append-only: entries already here are never rewritten by a file.
for (const row of rowsOf('auditLog')) { for (const row of rowsOf('auditLog')) {
await tx.auditLog.upsert({ await tx.auditLog.upsert({
where: { id: row.id as string }, where: { id: row.id as string },
@@ -746,10 +840,7 @@ export class BackupService {
...row, ...row,
userId: resolveOptionalUserId(asString(row, 'userId')), userId: resolveOptionalUserId(asString(row, 'userId')),
} as never, } as never,
update: { update: {},
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
} as never,
}); });
} }
imported.auditLog = rowsOf('auditLog').length; imported.auditLog = rowsOf('auditLog').length;
@@ -757,14 +848,17 @@ export class BackupService {
{ timeout: IMPORT_TRANSACTION_TIMEOUT_MS }, { timeout: IMPORT_TRANSACTION_TIMEOUT_MS },
); );
// Users, roles and grants may have changed underneath every cache.
this.permissionResolver.invalidateAll();
await this.auditLog.record({ await this.auditLog.record({
entityType: 'Backup', entityType: 'Backup',
entityId: 'import', entityId: 'import',
action: 'CREATE', action: 'CREATE',
userId: actingUserId, 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;
}
@@ -13,6 +13,7 @@ import {
updateBackupSettings, updateBackupSettings,
} from "@/lib/backup/api"; } from "@/lib/backup/api";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useAuth } from "@/lib/auth/auth-context";
import { BackupScheduleStatus, BackupSettings } from "@/lib/backup/types"; import { BackupScheduleStatus, BackupSettings } from "@/lib/backup/types";
import { importCsv } from "@/lib/import/api"; import { importCsv } from "@/lib/import/api";
import { ImportEntity, ImportReport, ImportRowStatus } from "@/lib/import/types"; import { ImportEntity, ImportReport, ImportRowStatus } from "@/lib/import/types";
@@ -48,7 +49,12 @@ function downloadBlob(blob: Blob, filename: string): void {
} }
function BackupSection() { function BackupSection() {
const { user } = useAuth();
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [password, setPassword] = useState("");
const [mfaCode, setMfaCode] = useState("");
const needsMfa = Boolean(user?.mfaEnabled);
const proofComplete = password.length > 0 && (!needsMfa || /^\d{6}$/.test(mfaCode));
const exportMutation = useMutation({ const exportMutation = useMutation({
mutationFn: exportBackup, mutationFn: exportBackup,
@@ -64,14 +70,26 @@ function BackupSection() {
if (!file) { if (!file) {
throw new Error("Keine Datei ausgewählt"); throw new Error("Keine Datei ausgewählt");
} }
return importBackup(file); return importBackup(file, { password, mfaCode: needsMfa ? mfaCode : undefined });
}, },
onSuccess: (summary) => { onSuccess: (summary) => {
const total = Object.values(summary.imported).reduce((sum, count) => sum + count, 0); const total = Object.values(summary.imported).reduce((sum, count) => sum + count, 0);
toast.success(`Backup eingespielt: ${total} Datensätze verarbeitet`); toast.success(`Backup eingespielt: ${total} Datensätze verarbeitet`);
if (summary.skipped.length > 0) {
toast.warning(
"Altes, unverschlüsseltes Backup: Benutzer, Rollen, Webhooks und Audit-Log wurden nicht übernommen.",
);
}
setFile(null); setFile(null);
setPassword("");
setMfaCode("");
}, },
onError: () => toast.error("Backup konnte nicht eingespielt werden"), onError: (error) =>
toast.error(
error instanceof Error && error.message
? error.message
: "Backup konnte nicht eingespielt werden",
),
}); });
function handleFileChange(event: ChangeEvent<HTMLInputElement>) { function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
@@ -87,7 +105,9 @@ function BackupSection() {
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Sichert wirklich alle Daten - Bienenstände, Völker, Ernten und mehr, dazu Benutzer, Sichert wirklich alle Daten - Bienenstände, Völker, Ernten und mehr, dazu Benutzer,
Rollen &amp; Rechte, Audit-Log und hochgeladene Dokumente (inkl. Dateiinhalt) - in Rollen &amp; Rechte, Audit-Log und hochgeladene Dokumente (inkl. Dateiinhalt) - in
einer JSON-Datei. Für Backups und zur Übertragung auf ein anderes Deployment. einer verschlüsselten Datei. Einspielen lässt sie sich nur in ApiCore mit demselben
Backup-Schlüssel; für ein anderes Deployment dort denselben{" "}
<code className="font-mono text-xs">BACKUP_ENCRYPTION_KEY</code> setzen.
</p> </p>
<Button <Button
type="button" type="button"
@@ -107,9 +127,35 @@ function BackupSection() {
onChange={handleFileChange} onChange={handleFileChange}
className="text-sm file:mr-3 file:rounded-lg file:border-0 file:bg-muted file:px-3 file:py-2 file:text-sm file:font-medium" className="text-sm file:mr-3 file:rounded-lg file:border-0 file:bg-muted file:px-3 file:py-2 file:text-sm file:font-medium"
/> />
<p className="text-muted-foreground">
Einspielen überschreibt Konten und Daten. Zur Bestätigung Ihr Passwort
{needsMfa ? " und den aktuellen Code aus der Authenticator-App" : ""} eingeben.
</p>
<Label htmlFor="backup-password">Ihr Passwort</Label>
<Input
id="backup-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
{needsMfa && (
<>
<Label htmlFor="backup-mfa">Code aus der Authenticator-App</Label>
<Input
id="backup-mfa"
inputMode="numeric"
autoComplete="one-time-code"
pattern="\d{6}"
maxLength={6}
value={mfaCode}
onChange={(event) => setMfaCode(event.target.value.replace(/\D/g, ""))}
/>
</>
)}
<ConfirmDialog <ConfirmDialog
triggerLabel="Backup importieren" triggerLabel="Backup importieren"
triggerDisabled={!file || importMutation.isPending} triggerDisabled={!file || !proofComplete || importMutation.isPending}
title="Backup einspielen?" title="Backup einspielen?"
description="Datensätze aus der Datei werden angelegt oder mit bestehenden gleicher ID überschrieben. Bereits vorhandene Daten, die in der Datei nicht enthalten sind, bleiben unverändert. Das kann bei einer großen Datei einen Moment dauern." description="Datensätze aus der Datei werden angelegt oder mit bestehenden gleicher ID überschrieben. Bereits vorhandene Daten, die in der Datei nicht enthalten sind, bleiben unverändert. Das kann bei einer großen Datei einen Moment dauern."
confirmLabel="Einspielen" confirmLabel="Einspielen"
+12 -3
View File
@@ -10,8 +10,17 @@ export function exportBackup(): Promise<BlobDownload> {
return apiFetchBlob("/backup/export", "apicore-backup.json"); return apiFetchBlob("/backup/export", "apicore-backup.json");
} }
export function importBackup(file: File): Promise<BackupImportSummary> { export interface BackupImportProof {
password: string;
mfaCode?: string;
}
export function importBackup(file: File, proof: BackupImportProof): Promise<BackupImportSummary> {
const formData = new FormData(); const formData = new FormData();
formData.append("password", proof.password);
if (proof.mfaCode) {
formData.append("mfaCode", proof.mfaCode);
}
formData.append("file", file); formData.append("file", file);
return apiFetchFormData<BackupImportSummary>("/backup/import", formData); return apiFetchFormData<BackupImportSummary>("/backup/import", formData);
} }
@@ -20,8 +29,8 @@ export function getBackupSchedule(): Promise<BackupScheduleStatus> {
return apiFetch<BackupScheduleStatus>("/backup/schedule"); return apiFetch<BackupScheduleStatus>("/backup/schedule");
} }
export function runBackupNow(): Promise<{ filePath: string }> { export function runBackupNow(): Promise<{ fileName: string }> {
return apiFetch<{ filePath: string }>("/backup/schedule/run-now", { return apiFetch<{ fileName: string }>("/backup/schedule/run-now", {
method: "POST", method: "POST",
}); });
} }
+2
View File
@@ -1,5 +1,7 @@
export interface BackupImportSummary { export interface BackupImportSummary {
imported: Record<string, number>; imported: Record<string, number>;
/** Tables an old, unencrypted backup may not restore (users, roles, webhooks, audit log). */
skipped: string[];
} }
export interface BackupScheduleStatus { export interface BackupScheduleStatus {
+2
View File
@@ -14,6 +14,8 @@ stringData:
# signiert jedes Access-/MFA-/SSO-Token UND verschluesselt gespeicherte # signiert jedes Access-/MFA-/SSO-Token UND verschluesselt gespeicherte
# TOTP-Secrets. Erzeugen z.B. mit: openssl rand -hex 32 # TOTP-Secrets. Erzeugen z.B. mit: openssl rand -hex 32
JWT_ACCESS_SECRET: "change-me-to-a-random-value-at-least-32-characters-long" JWT_ACCESS_SECRET: "change-me-to-a-random-value-at-least-32-characters-long"
# Encrypts backup files; needed to restore them on another deployment.
BACKUP_ENCRYPTION_KEY: "change-me-to-another-random-value-at-least-32-characters"
JWT_REFRESH_SECRET: "change-me-refresh-secret" JWT_REFRESH_SECRET: "change-me-refresh-secret"
# Entra ID App-Registrierung (SSO). Siehe configmap.example.yaml # Entra ID App-Registrierung (SSO). Siehe configmap.example.yaml
# fuer OIDC_ISSUER_URL/OIDC_REDIRECT_URI. Wird auch fuer die # fuer OIDC_ISSUER_URL/OIDC_REDIRECT_URI. Wird auch fuer die