feat(backup): add enterprise backup & restore foundation

This commit is contained in:
Schubert Ferenc 2026-07-05 17:13:31 +02:00
parent 7835d3ca75
commit 6c917df515
30 changed files with 1538 additions and 61 deletions

View file

@ -66,6 +66,8 @@ def can_read_activity(action: str, permissions: set[str]) -> bool:
return "lexware.read" in permissions
if action.startswith("accounting."):
return "lexware.read" in permissions
if action.startswith("backups."):
return "backup.read" in permissions
if action.startswith("audit_logs."):
return "audit_logs.read" in permissions
if action.startswith("auth."):

View file

@ -0,0 +1,150 @@
from fastapi import APIRouter, Depends, status
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.core.rbac import require_permission
from app.db.database import get_db
from app.models.user import User
from app.schemas.api_response import ApiSuccess
from app.schemas.backup import BackupRestoreRequest
from app.services.audit_service import write_audit_log
from app.services.backup_service import BackupService
router = APIRouter(prefix="/backups", tags=["Backups"])
@router.get("", response_model=ApiSuccess)
def list_backups(
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("backup.read")),
):
return ApiSuccess(data=BackupService.list_backups(), message="Backups geladen")
@router.post("/create", response_model=ApiSuccess, status_code=status.HTTP_201_CREATED)
def create_backup(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("backup.create")),
):
backup = BackupService.create_backup(actor=current_user)
write_audit_log(
db,
action="backups.create",
entity_type="backup",
entity_label=backup.filename,
actor=current_user,
request=request,
metadata={
"filename": backup.filename,
"size_bytes": backup.size_bytes,
"app_version": backup.app_version,
},
)
return ApiSuccess(data=backup, message="Backup erstellt")
@router.get("/{filename}/download")
def download_backup(
filename: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("backup.download")),
):
path = BackupService.resolve_backup_path(filename)
write_audit_log(
db,
action="backups.download",
entity_type="backup",
entity_label=path.name,
actor=current_user,
request=request,
metadata={"filename": path.name, "size_bytes": path.stat().st_size},
)
return FileResponse(path=path, media_type="application/zip", filename=path.name)
@router.delete("/{filename}", response_model=ApiSuccess)
def delete_backup(
filename: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("backup.delete")),
):
path = BackupService.resolve_backup_path(filename)
size_bytes = path.stat().st_size
BackupService.delete_backup(filename)
write_audit_log(
db,
action="backups.delete",
entity_type="backup",
entity_label=path.name,
actor=current_user,
request=request,
metadata={"filename": path.name, "size_bytes": size_bytes},
)
return ApiSuccess(message="Backup geloescht")
@router.post("/{filename}/restore/validate", response_model=ApiSuccess)
def validate_backup_restore(
filename: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("backup.restore")),
):
validation = BackupService.validate_backup(filename)
write_audit_log(
db,
action="backups.validate",
entity_type="backup",
entity_label=filename,
actor=current_user,
request=request,
metadata={"filename": filename, "valid": validation.valid, "issues": validation.issues},
)
return ApiSuccess(data=validation, message=validation.message)
@router.post("/{filename}/restore", response_model=ApiSuccess)
def restore_backup(
filename: str,
payload: BackupRestoreRequest,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("backup.restore")),
):
write_audit_log(
db,
action="backups.restore_started",
entity_type="backup",
entity_label=filename,
actor=current_user,
request=request,
metadata={"filename": filename},
)
try:
validation = BackupService.restore_backup(filename, confirm_text=payload.confirm_text)
except Exception:
write_audit_log(
db,
action="backups.restore_failed",
entity_type="backup",
entity_label=filename,
actor=current_user,
request=request,
metadata={"filename": filename},
)
raise
write_audit_log(
db,
action="backups.restore_completed",
entity_type="backup",
entity_label=filename,
actor=current_user,
request=request,
metadata={"filename": filename},
)
return ApiSuccess(data=validation, message="Restore abgeschlossen")

View file

@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.rbac import get_user_permission_names, require_permission
from app.db.database import get_db
from app.models.rbac import Role
@ -16,6 +17,7 @@ from app.repositories.repair_repository import RepairRepository
from app.repositories.repair_estimate_repository import RepairEstimateRepository
from app.repositories.user_repository import UserRepository
from app.schemas.dashboard import DashboardSummary, EmptyWidget, MetricCard, SystemStatusItem
from app.services.backup_service import BackupService
from app.services.system_settings_service import SystemSettingsService
logger = logging.getLogger(__name__)
@ -162,6 +164,27 @@ def get_dashboard_summary(
),
]
if "backup.read" in permissions:
backup_stats = BackupService.get_backup_stats()
repairs.append(MetricCard(label="Backups", value=backup_stats.total_count))
system_status.extend([
SystemStatusItem(
label="Letztes Backup",
value=backup_stats.latest_backup_at.isoformat() if backup_stats.latest_backup_at else "Noch kein Backup",
status="ok" if backup_stats.latest_backup_at else "warning",
),
SystemStatusItem(
label="Backup-Speicher",
value=f"{backup_stats.total_count} Backup(s), {backup_stats.total_size_bytes} Bytes",
status="ok" if backup_stats.total_count else "warning",
),
SystemStatusItem(
label="Hermes-Version",
value=settings.app_version,
status="info",
),
])
logger.info("dashboard.summary", extra={"actor_user_id": current_user.id})
return DashboardSummary(