feat: add multi-administrator support with RBAC and fix backup/restore for S3

## Multi-Administrator System
- Add role-based access control (RBAC) with predefined roles (Super Admin, Admin, Editor, Viewer)
- Add granular permissions system for all admin operations
- Add admin user management page with invite functionality
- Add email invitation system for new administrators
- Add permission middleware protecting all admin routes
- Add PermissionGate component for frontend permission checks
- Track event creator (created_by) for audit purposes

## Backup & Restore Fixes
- Fix S3 backup: endpoint URL handling, manifest loading, field name compatibility
- Fix S3 restore: add list-backups endpoint, transform S3 config from frontend format
- Fix PostgreSQL compatibility: add .returning('id') for insert operations
- Fix disk space check: use df command, handle unknown space gracefully
- Fix dry-run validation to not block on warnings
- Fix req.user → req.admin in restore routes

## Database Migrations
- 054: Add roles table with predefined roles
- 055: Add permissions table
- 056: Add role_permissions junction table
- 057: Add role_id to admin_users
- 058: Add admin_invitations table
- 059: Add admin email templates
- 060: Add created_by to events table

## Other Improvements
- Update .gitignore to exclude planning docs and local backup directory
- Remove SQLite database file from tracking
- Add i18n translations for user management (EN/DE)
This commit is contained in:
Paul Nothaft
2026-01-07 17:10:46 +01:00
parent b706eeb5d3
commit 892e47d017
65 changed files with 4854 additions and 493 deletions
+45 -17
View File
@@ -75,14 +75,15 @@ class RestoreService {
this.log('info', 'Starting restore operation', { options: this.sanitizeOptions(options) });
// Create restore run record
const [runId] = await db('restore_runs').insert({
const result = await db('restore_runs').insert({
started_at: startTime,
status: 'running',
restore_type: options.restoreType,
source: options.source,
manifest_path: options.manifestPath,
is_dry_run: options.dryRun || false
});
}).returning('id');
const runId = Array.isArray(result) ? (result[0]?.id || result[0]) : result;
restoreRun = { id: runId };
@@ -105,7 +106,8 @@ class RestoreService {
if (validation.warnings.length > 0) {
this.log('warn', 'Pre-restore validation warnings', { warnings: validation.warnings });
if (!options.force) {
// Only block actual restores (not dry runs/validations) on warnings
if (!options.force && !options.dryRun) {
throw new Error(`Restore blocked due to warnings (use force to override): ${validation.warnings.join(', ')}`);
}
}
@@ -400,29 +402,55 @@ class RestoreService {
* Check available disk space
*/
async checkDiskSpace(manifest, options) {
const { statvfs } = require('fs');
const statvfsAsync = promisify(statvfs);
try {
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const stats = await statvfsAsync(storagePath);
const blockSize = stats.bsize || stats.f_bsize || 4096;
const availableBytes = stats.bavail * blockSize;
// Calculate required space (with 20% buffer)
let requiredBytes = 0;
if (options.restoreType === 'full' || options.restoreType === 'files') {
requiredBytes = manifest.files.total_size * 1.2;
requiredBytes = (manifest.files?.total_size || 0) * 1.2;
}
if (options.restoreType === 'full' || options.restoreType === 'database') {
requiredBytes += (manifest.database.size || 0) * 1.2;
requiredBytes += (manifest.database?.size || 0) * 1.2;
}
// Try to get disk space using df command (works on Linux and macOS)
let availableBytes = 0;
let diskCheckSucceeded = false;
try {
const { exec } = require('child_process');
const execAsync = promisify(exec);
// Use root path as fallback if storage path doesn't exist yet
const checkPath = await fs.access(storagePath).then(() => storagePath).catch(() => '/');
const { stdout } = await execAsync(`df -k "${checkPath}" | tail -1 | awk '{print $4}'`);
const parsed = parseInt(stdout.trim());
if (!isNaN(parsed) && parsed > 0) {
availableBytes = parsed * 1024; // Convert from KB to bytes
diskCheckSucceeded = true;
}
} catch (dfError) {
this.log('warn', 'Could not determine available disk space', { error: dfError.message });
}
// If disk check failed, return optimistic result
if (!diskCheckSucceeded) {
return {
hasEnoughSpace: true,
availableBytes: null, // null indicates unknown
requiredBytes,
availableFormatted: 'Unknown',
requiredFormatted: this.formatBytes(requiredBytes)
};
}
// Add space for pre-restore backup
if (!options.skipPreBackup) {
const currentUsage = await this.calculateCurrentStorageUsage();
requiredBytes += currentUsage * 1.1; // 10% buffer for backup
try {
const currentUsage = await this.calculateCurrentStorageUsage();
requiredBytes += currentUsage * 1.1; // 10% buffer for backup
} catch (e) {
// Ignore errors calculating current usage
}
}
return {
@@ -434,11 +462,11 @@ class RestoreService {
};
} catch (error) {
// Fallback for systems without statvfs
// Fallback for any errors
this.log('warn', 'Could not check disk space', { error: error.message });
return {
hasEnoughSpace: true, // Assume we have space if we can't check
availableBytes: 0,
availableBytes: null,
requiredBytes: 0,
availableFormatted: 'Unknown',
requiredFormatted: 'Unknown'