- 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>
168 lines
5.1 KiB
Python
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
|