Files
the-other-dude/backend/app/routers/config_history.py
Jason Staack af7007df13 feat(06-02): add snapshot view and diff retrieval endpoints
- GET /config/{snapshot_id} returns decrypted full config with RBAC
- GET /config/{snapshot_id}/diff returns unified diff text with RBAC
- 404 for missing snapshots/diffs, 500 for Transit decrypt failure
- Both endpoints enforce viewer+ role and config:read scope

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:03:32 -05:00

168 lines
5.1 KiB
Python

"""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