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:
1 parent
a25265b88a
commit
bd592adf66
15 files changed
+636
-38
No files matched your search
@@ -38,6 +38,11 @@ BACKUP_CRON_SCHEDULE="0 3 * * *"
|
||||
# Wie viele der neuesten Backup-Dateien behalten werden, aeltere werden nach
|
||||
# jedem Lauf geloescht (Standard: 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
|
||||
# (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();
|
||||
});
|
||||
});
|
||||
@@ -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']),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
updateBackupSettings,
|
||||
} from "@/lib/backup/api";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { BackupScheduleStatus, BackupSettings } from "@/lib/backup/types";
|
||||
import { importCsv } from "@/lib/import/api";
|
||||
import { ImportEntity, ImportReport, ImportRowStatus } from "@/lib/import/types";
|
||||
@@ -48,7 +49,12 @@ function downloadBlob(blob: Blob, filename: string): void {
|
||||
}
|
||||
|
||||
function BackupSection() {
|
||||
const { user } = useAuth();
|
||||
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({
|
||||
mutationFn: exportBackup,
|
||||
@@ -64,14 +70,26 @@ function BackupSection() {
|
||||
if (!file) {
|
||||
throw new Error("Keine Datei ausgewählt");
|
||||
}
|
||||
return importBackup(file);
|
||||
return importBackup(file, { password, mfaCode: needsMfa ? mfaCode : undefined });
|
||||
},
|
||||
onSuccess: (summary) => {
|
||||
const total = Object.values(summary.imported).reduce((sum, count) => sum + count, 0);
|
||||
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);
|
||||
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>) {
|
||||
@@ -87,7 +105,9 @@ function BackupSection() {
|
||||
<p className="text-muted-foreground">
|
||||
Sichert wirklich alle Daten - Bienenstände, Völker, Ernten und mehr, dazu Benutzer,
|
||||
Rollen & 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>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -107,9 +127,35 @@ function BackupSection() {
|
||||
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"
|
||||
/>
|
||||
<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
|
||||
triggerLabel="Backup importieren"
|
||||
triggerDisabled={!file || importMutation.isPending}
|
||||
triggerDisabled={!file || !proofComplete || importMutation.isPending}
|
||||
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."
|
||||
confirmLabel="Einspielen"
|
||||
|
||||
@@ -10,8 +10,17 @@ export function exportBackup(): Promise<BlobDownload> {
|
||||
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();
|
||||
formData.append("password", proof.password);
|
||||
if (proof.mfaCode) {
|
||||
formData.append("mfaCode", proof.mfaCode);
|
||||
}
|
||||
formData.append("file", file);
|
||||
return apiFetchFormData<BackupImportSummary>("/backup/import", formData);
|
||||
}
|
||||
@@ -20,8 +29,8 @@ export function getBackupSchedule(): Promise<BackupScheduleStatus> {
|
||||
return apiFetch<BackupScheduleStatus>("/backup/schedule");
|
||||
}
|
||||
|
||||
export function runBackupNow(): Promise<{ filePath: string }> {
|
||||
return apiFetch<{ filePath: string }>("/backup/schedule/run-now", {
|
||||
export function runBackupNow(): Promise<{ fileName: string }> {
|
||||
return apiFetch<{ fileName: string }>("/backup/schedule/run-now", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export interface BackupImportSummary {
|
||||
imported: Record<string, number>;
|
||||
/** Tables an old, unencrypted backup may not restore (users, roles, webhooks, audit log). */
|
||||
skipped: string[];
|
||||
}
|
||||
|
||||
export interface BackupScheduleStatus {
|
||||
|
||||
@@ -14,6 +14,8 @@ stringData:
|
||||
# signiert jedes Access-/MFA-/SSO-Token UND verschluesselt gespeicherte
|
||||
# TOTP-Secrets. Erzeugen z.B. mit: openssl rand -hex 32
|
||||
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"
|
||||
# Entra ID App-Registrierung (SSO). Siehe configmap.example.yaml
|
||||
# fuer OIDC_ISSUER_URL/OIDC_REDIRECT_URI. Wird auch fuer die
|
||||
|
||||
Reference in new issue
Block a user