Files
leocrm/app/routes/backups.py
T
Agent Zero 25b4d61236 fix(security): F23 (Astra P1) — DB-weiter Restore nur noch fuer System-Admins
Vorher: POST /api/v1/backups/{id}/restore war ueber automation:admin
eines Mandanten erreichbar — der Restore bearbeitet aber die GESAMTE
geteilte Datenbank ohne Mandantenfilter. Ein Tenant-Admin haette den
Zustand aller Mandanten ueberschreiben koennen.

Fix: Restore-Route auf require_admin umgestellt (echter System-Admin:
is_system_admin oder *:* via RBAC). Listen/Erstellen/Loeschen von
Backups bleibt mandantenbezogen auf automation:admin.

Abnahme (Astra): Ein Tenant-Admin kann keinen Gesamtrestore ausloesen —
erfuellt (Route-Introspektions-Tests pinnen die Verdrahtung).

Tests: test_s1_security_guards.py 12/12 (2 neue F23-Tests: restore nutzt
require_admin, restore nutzt NICHT require_permission).
2026-09-18 08:17:57 +02:00

111 lines
3.7 KiB
Python

"""API routes for Backup management."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import get_current_user, require_admin, require_permission
from app.schemas.backup import BackupListResponse, BackupResponse
from app.services import backup_service
router = APIRouter(prefix="/api/v1/backups", tags=["backups"])
@router.get(
"",
response_model=BackupListResponse,
dependencies=[Depends(require_permission("automation:admin"))],
)
async def list_backups(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List all backups for the current tenant."""
tenant_id = uuid.UUID(current_user["tenant_id"])
backups = await backup_service.list_backups(db, tenant_id)
return BackupListResponse(
backups=[BackupResponse.model_validate(b) for b in backups],
total=len(backups),
)
@router.post(
"",
response_model=BackupResponse,
status_code=201,
dependencies=[Depends(require_permission("automation:admin"))],
)
async def create_backup(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a new database backup."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
backup = await backup_service.create_backup(db, tenant_id, user_id=user_id)
return backup
@router.post(
"/{backup_id}/restore",
response_model=BackupResponse,
dependencies=[Depends(require_admin)],
)
async def restore_backup(
backup_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
):
"""Restore a database backup.
WARNING: This is a destructive operation. It drops and recreates the database.
F23 (Astra P1): a full-database restore is a GLOBAL operations action —
it affects every tenant. A tenant admin (automation:admin) must not be
able to trigger it: the restore replaces the shared database, not just
this tenant's rows. Requires a real system admin (is_system_admin or
*:* via the RBAC system).
"""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
b_id = uuid.UUID(backup_id)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": "Invalid backup_id", "code": "invalid_id"}) from None
try:
backup = await backup_service.restore_backup(db, tenant_id, b_id)
return backup
except ValueError as exc:
raise HTTPException(400, detail={"detail": str(exc), "code": "invalid_state"}) from exc
except FileNotFoundError as exc:
raise HTTPException(404, detail={"detail": str(exc), "code": "file_not_found"}) from exc
except RuntimeError as exc:
raise HTTPException(500, detail={"detail": str(exc), "code": "restore_failed"}) from exc
@router.delete(
"/{backup_id}",
status_code=204,
dependencies=[Depends(require_permission("automation:admin"))],
)
async def delete_backup(
backup_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Delete a backup record and its file."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
b_id = uuid.UUID(backup_id)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": "Invalid backup_id", "code": "invalid_id"}) from None
deleted = await backup_service.delete_backup(db, tenant_id, b_id)
if not deleted:
raise HTTPException(404, detail={"detail": "Backup not found", "code": "not_found"})
return None