"""Config history timeline API endpoint. Provides: - GET /tenants/{tenant_id}/devices/{device_id}/config-history Paginated timeline of config changes for a device. RBAC: viewer+ can read. Scope: config:read. """ import logging import uuid from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.middleware.rbac import require_min_role, require_scope from app.middleware.tenant_context import CurrentUser, get_current_user from app.services.config_history_service import ( get_config_history, get_snapshot, get_snapshot_diff, ) logger = logging.getLogger(__name__) router = APIRouter(tags=["config-history"]) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- async def _check_tenant_access( current_user: CurrentUser, tenant_id: uuid.UUID, db: AsyncSession ) -> None: """Verify the current user is allowed to access the given tenant. - super_admin can access any tenant -- re-sets DB tenant context to target tenant. - All other roles must match their own tenant_id. """ if current_user.is_super_admin: from app.database import set_tenant_context await set_tenant_context(db, str(tenant_id)) return if current_user.tenant_id != tenant_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied: you do not belong to this tenant.", ) # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @router.get( "/tenants/{tenant_id}/devices/{device_id}/config-history", summary="Get config change timeline for a device", dependencies=[require_scope("config:read")], ) async def list_config_history( tenant_id: uuid.UUID, device_id: uuid.UUID, limit: int = Query(default=50, ge=1, le=200), offset: int = Query(default=0, ge=0), current_user: CurrentUser = Depends(get_current_user), _role: CurrentUser = Depends(require_min_role("viewer")), db: AsyncSession = Depends(get_db), ) -> list[dict[str, Any]]: """Return paginated config change timeline for a device, newest first. Each entry includes: id, component, summary, created_at, diff_id, lines_added, lines_removed, snapshot_id. """ await _check_tenant_access(current_user, tenant_id, db) return await get_config_history( device_id=str(device_id), tenant_id=str(tenant_id), session=db, limit=limit, offset=offset, ) @router.get( "/tenants/{tenant_id}/devices/{device_id}/config/{snapshot_id}", summary="Get decrypted config snapshot", dependencies=[require_scope("config:read")], ) async def view_snapshot( tenant_id: uuid.UUID, device_id: uuid.UUID, snapshot_id: uuid.UUID, current_user: CurrentUser = Depends(get_current_user), _role: CurrentUser = Depends(require_min_role("viewer")), db: AsyncSession = Depends(get_db), ) -> dict[str, Any]: """Return the decrypted full config text for a single snapshot. Returns 404 if the snapshot does not exist or belongs to a different device/tenant. """ await _check_tenant_access(current_user, tenant_id, db) try: result = await get_snapshot( snapshot_id=str(snapshot_id), device_id=str(device_id), tenant_id=str(tenant_id), session=db, ) except Exception: logger.exception("Failed to decrypt snapshot %s for device %s", snapshot_id, device_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to decrypt snapshot content", ) if result is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Snapshot not found", ) return result @router.get( "/tenants/{tenant_id}/devices/{device_id}/config/{snapshot_id}/diff", summary="Get diff for a config snapshot", dependencies=[require_scope("config:read")], ) async def view_snapshot_diff( tenant_id: uuid.UUID, device_id: uuid.UUID, snapshot_id: uuid.UUID, current_user: CurrentUser = Depends(get_current_user), _role: CurrentUser = Depends(require_min_role("viewer")), db: AsyncSession = Depends(get_db), ) -> dict[str, Any]: """Return the unified diff associated with a snapshot. Returns 404 if no diff exists (e.g., first snapshot for a device). """ await _check_tenant_access(current_user, tenant_id, db) result = await get_snapshot_diff( snapshot_id=str(snapshot_id), device_id=str(device_id), tenant_id=str(tenant_id), session=db, ) if result is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="No diff found for this snapshot", ) return result