Files
ss-tools/backend/src/api/routes/maintenance/_routes.py
busya 320f82ab95 feat(auth): implement API key authentication and management
Introduce a new API key authentication mechanism to support service-to-service
communication (e.g., Airflow, CI/CD) without browser-based JWT login.

- Add `APIKey` model and `APIKeyPrincipal` dependency for RBAC.
- Implement `require_api_key_or_jwt` dependency with environment scoping.
- Add admin CRUD endpoints for API key lifecycle management.
- Add API key management UI in System Settings.
- Update maintenance API to support explicit `environment_id` and API key auth.
- Add i18n support for API keys and connection settings.
- Include Python and Bash usage examples in the `examples/` directory.
- Update technical specifications and documentation.
2026-05-25 11:35:27 +03:00

593 lines
23 KiB
Python

# #region MaintenanceRoutesModule [C:3] [TYPE Module] [SEMANTICS fastapi, routes, maintenance, api]
# @BRIEF Route handler stubs for all 7 Maintenance Banner API endpoints with RBAC guards per FR-015.
# @LAYER API
# @RELATION DEPENDS_ON -> [MaintenanceSchemasModule]
# @RELATION DEPENDS_ON -> [MaintenanceRouter]
# @RELATION DEPENDS_ON -> [AppDependencies]
# @RELATION DEPENDS_ON -> [MaintenanceServiceModule]
# @INVARIANT All mutation endpoints return 202 {task_id} per spec.
# @INVARIANT RBAC enforced per FR-015 matrix via has_permission() dependency.
from datetime import datetime, timezone
from typing import Any, cast
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from ....core.logger import belief_scope, logger as app_logger
from ....core.task_manager import TaskManager
app_logger = cast(Any, app_logger)
from ....dependencies import (
check_api_key_environment_scope,
get_db,
get_task_manager,
has_permission,
require_api_key_or_jwt,
)
from ....models.api_key import APIKey
from ....models.maintenance import (
MaintenanceDashboardBanner,
MaintenanceDashboardBannerStatus,
MaintenanceDashboardState,
MaintenanceDashboardStateStatus,
MaintenanceEvent,
MaintenanceEventStatus,
MaintenanceSettings,
)
from ._router import router
from ._schemas import (
MaintenanceAlreadyActiveResponse,
MaintenanceDashboardBannerState,
MaintenanceEndResponse,
MaintenanceEventItem,
MaintenanceEventListResponse,
MaintenanceSettingsResponse,
MaintenanceSettingsUpdate,
MaintenanceStartRequest,
MaintenanceStartResponse,
)
# ── GET /api/maintenance/dashboard-banners ───────────────────
# FR-015: viewer, operator, admin all have READ access
# #region list_dashboard_banners [C:2] [TYPE Function]
# @BRIEF Get per-dashboard banner state for the Dashboard Hub indicator.
# @RELATION DEPENDS_ON -> [has_permission("maintenance", "READ")]
@router.get("/dashboard-banners", response_model=list[MaintenanceDashboardBannerState])
async def list_dashboard_banners(
db: Session = Depends(get_db),
_=Depends(has_permission("maintenance", "READ")),
):
with belief_scope("list_dashboard_banners"):
# Query active banners with their linked active/pending states
active_banners = (
db.query(MaintenanceDashboardBanner)
.filter(MaintenanceDashboardBanner.status == MaintenanceDashboardBannerStatus.ACTIVE)
.all()
)
result: list[MaintenanceDashboardBannerState] = []
for banner in active_banners:
linked_states = (
db.query(MaintenanceDashboardState)
.filter(
MaintenanceDashboardState.banner_id == banner.id,
MaintenanceDashboardState.status.in_([
MaintenanceDashboardStateStatus.ACTIVE,
MaintenanceDashboardStateStatus.PENDING_APPLY,
]),
)
.all()
)
event_ids: list[str] = []
start_times: list[str] = []
end_times: list[str] = []
for state in linked_states:
event = (
db.query(MaintenanceEvent)
.filter(MaintenanceEvent.id == state.event_id)
.first()
)
if event:
event_ids.append(event.id)
if event.start_time:
start_times.append(event.start_time.isoformat())
if event.end_time:
end_times.append(event.end_time.isoformat())
result.append(
MaintenanceDashboardBannerState(
dashboard_id=banner.dashboard_id,
active=True,
events=event_ids,
start_time=min(start_times) if start_times else None,
end_time=max(end_times) if end_times else None,
)
)
return result
# #endregion list_dashboard_banners
# ── GET /api/maintenance/events ──────────────────────────────
# FR-015: operator, admin (NOT viewer)
# #region list_events [C:2] [TYPE Function]
# @BRIEF Get active and completed maintenance event lists with affected dashboard counts.
# @RELATION DEPENDS_ON -> [has_permission("maintenance", "READ")]
@router.get("/events", response_model=MaintenanceEventListResponse)
async def list_events(
db: Session = Depends(get_db),
_=Depends(has_permission("maintenance", "READ")),
task_manager: TaskManager = Depends(get_task_manager),
):
with belief_scope("list_events"):
# ── Auto-expiry: end events past their end_time ──
now = datetime.now(timezone.utc)
expired = (
db.query(MaintenanceEvent)
.filter(
MaintenanceEvent.status.in_([
MaintenanceEventStatus.ACTIVE,
MaintenanceEventStatus.PARTIAL,
MaintenanceEventStatus.ENDING,
]),
MaintenanceEvent.end_time.isnot(None),
MaintenanceEvent.end_time < now,
)
.all()
)
for ev in expired:
app_logger.reason(
"Auto-ending expired maintenance event",
extra={"event_id": ev.id, "end_time": ev.end_time.isoformat()},
)
# Schedule proper cleanup via task manager (removes banner from dashboard)
task = await task_manager.create_task(
plugin_id="maintenance_banner_apply",
params={
"operation": "end",
"event_id": ev.id,
"environment_id": ev.environment_id,
"user": "system",
},
)
app_logger.reflect(
"Scheduled cleanup for expired event",
extra={"event_id": ev.id, "task_id": task.id},
)
# Active events: ACTIVE, PARTIAL (per spec T040)
active_statuses = [
MaintenanceEventStatus.ACTIVE,
MaintenanceEventStatus.PARTIAL,
]
active_events = (
db.query(MaintenanceEvent)
.filter(MaintenanceEvent.status.in_(active_statuses))
.order_by(MaintenanceEvent.created_at.desc())
.all()
)
# Completed events: COMPLETED, FAILED
completed_events = (
db.query(MaintenanceEvent)
.filter(
MaintenanceEvent.status.in_([
MaintenanceEventStatus.COMPLETED,
MaintenanceEventStatus.FAILED,
])
)
.order_by(MaintenanceEvent.created_at.desc())
.limit(50)
.all()
)
def _to_item(event: MaintenanceEvent) -> MaintenanceEventItem:
affected_count = (
db.query(MaintenanceDashboardState)
.filter(
MaintenanceDashboardState.event_id == event.id,
MaintenanceDashboardState.status.in_([
MaintenanceDashboardStateStatus.ACTIVE,
MaintenanceDashboardStateStatus.PENDING_APPLY,
]),
)
.count()
)
return MaintenanceEventItem(
id=event.id,
tables=list(event.tables) if event.tables else [],
start_time=event.start_time.isoformat() if event.start_time else "",
end_time=event.end_time.isoformat() if event.end_time else None,
message=event.message or None,
status=event.status.value,
affected_count=affected_count,
created_at=event.created_at.isoformat() if event.created_at else "",
)
return MaintenanceEventListResponse(
active=[_to_item(e) for e in active_events],
completed=[_to_item(e) for e in completed_events],
)
# #endregion list_events
# ── POST /api/maintenance/start ──────────────────────────────
# FR-001: Always returns 202 {task_id} after validation
# FR-015: operator, admin (NOT viewer)
# #region start_maintenance [C:3] [TYPE Function]
# @BRIEF Start a maintenance event. Validates input, creates event, dispatches TaskManager task.
# Always returns 202 with task_id and maintenance_id.
# Idempotency: same (tables, start_time, end_time) returns already_active (409-like but 200).
# Tables sorted & lowered for idempotency key. Message NOT in key.
# @RELATION DEPENDS_ON -> [has_permission("maintenance", "WRITE")]
# @RELATION DEPENDS_ON -> [TaskManager]
@router.post(
"/start",
status_code=status.HTTP_202_ACCEPTED,
responses={
202: {"model": MaintenanceStartResponse},
409: {"model": MaintenanceAlreadyActiveResponse},
},
)
async def start_maintenance(
request: MaintenanceStartRequest,
db: Session = Depends(get_db),
_auth=Depends(require_api_key_or_jwt("maintenance:start", "maintenance", "WRITE")),
task_manager: TaskManager = Depends(get_task_manager),
):
with belief_scope("start_maintenance"):
# ── Validation ──
# Validate end_time > start_time
if request.end_time and request.start_time and request.end_time <= request.start_time:
raise HTTPException(
status_code=400,
detail="end_time must be after start_time",
)
# Validate times are within reasonable range (future ±1h grace period)
now = datetime.now(timezone.utc)
grace_threshold = 3600 # 1 hour in seconds
if request.start_time:
diff = (request.start_time - now).total_seconds() if request.start_time.tzinfo else \
(request.start_time.replace(tzinfo=timezone.utc) - now).total_seconds()
if diff < -grace_threshold:
raise HTTPException(
status_code=400,
detail="start_time is too far in the past (max 1h grace period)",
)
# Validate tables list length
if len(request.tables) > 100:
raise HTTPException(
status_code=400,
detail="Maximum 100 tables allowed",
)
# Sanitize message (HTML escape already done in rendering, but check for raw HTML)
message = request.message
if message:
import html as _html
sanitized = _html.escape(message)
if sanitized != message:
# Message contained HTML — accept it, it will be escaped at render time
pass
# ── Idempotency check ──
from ....services.maintenance import build_idempotency_key
sorted_tables = sorted(t.strip().lower() for t in request.tables if t.strip())
idem_tables = frozenset(sorted_tables)
idem_start = request.start_time.isoformat() if request.start_time else None
idem_end = request.end_time.isoformat() if request.end_time else None
# Find existing active event with same idempotency key
active_statuses = [
MaintenanceEventStatus.PENDING,
MaintenanceEventStatus.APPLYING,
MaintenanceEventStatus.ACTIVE,
MaintenanceEventStatus.PARTIAL,
]
existing = (
db.query(MaintenanceEvent)
.filter(
MaintenanceEvent.status.in_(active_statuses),
)
.all()
)
for ev in existing:
ev_tables = frozenset(
sorted(t.lower() for t in (ev.tables or []) if t)
)
ev_start = ev.start_time.isoformat() if ev.start_time else None
ev_end = ev.end_time.isoformat() if ev.end_time else None
if ev_tables == idem_tables and ev_start == idem_start and ev_end == idem_end:
# Same key → return already_active
app_logger.reflect(
"Idempotency hit — event already active",
extra={"existing_id": ev.id},
)
return MaintenanceAlreadyActiveResponse(
maintenance_id=ev.id,
status="already_active",
)
# ── Environment scoping ──
env_id = request.environment_id
# ── Create event ──
event = MaintenanceEvent(
tables=sorted_tables,
start_time=request.start_time,
end_time=request.end_time,
message=message,
status=MaintenanceEventStatus.PENDING,
environment_id=env_id,
)
db.add(event)
db.flush()
# ── Dispatch TaskManager task ──
task = await task_manager.create_task(
plugin_id="maintenance_banner_apply",
params={
"operation": "start",
"event_id": event.id,
"environment_id": env_id,
"user": _auth.split(":", 1)[1] if ":" in _auth else _auth,
},
)
event.task_id = task.id
db.commit()
app_logger.reflect(
"Maintenance start dispatched",
extra={"event_id": event.id, "task_id": task.id},
)
return MaintenanceStartResponse(
task_id=task.id,
maintenance_id=event.id,
status="pending",
)
# #endregion start_maintenance
# ── POST /api/maintenance/{maintenance_id}/end ───────────────
# FR-006: Remove banners for a specific maintenance event
# FR-015: operator, admin (NOT viewer)
# #region end_maintenance [C:3] [TYPE Function]
# @BRIEF End a specific maintenance event by ID. Removes banners from affected dashboards.
# Always returns 202 with task_id. Idempotent: already_completed returns success.
# @RELATION DEPENDS_ON -> [has_permission("maintenance", "WRITE")]
@router.post(
"/{maintenance_id}/end",
status_code=status.HTTP_202_ACCEPTED,
response_model=MaintenanceEndResponse,
)
async def end_maintenance(
maintenance_id: str,
request: Request,
db: Session = Depends(get_db),
_auth=Depends(require_api_key_or_jwt("maintenance:end", "maintenance", "WRITE")),
task_manager: TaskManager = Depends(get_task_manager),
):
with belief_scope("end_maintenance", f"maintenance_id={maintenance_id}"):
# Check event exists
event = db.query(MaintenanceEvent).filter(
MaintenanceEvent.id == maintenance_id
).first()
if not event:
raise HTTPException(
status_code=404,
detail=f"Maintenance event {maintenance_id} not found",
)
# Validate API key environment scope against event's environment
if _auth.startswith("api_key:"):
await check_api_key_environment_scope(request, db, event.environment_id or "")
# If already completed, return idempotent success
if event.status == MaintenanceEventStatus.COMPLETED:
app_logger.reflect(
"Event already completed, returning idempotent",
extra={"event_id": maintenance_id},
)
return MaintenanceEndResponse(
task_id=event.task_id or "",
status="already_completed",
)
# ── Dispatch TaskManager task ──
task = await task_manager.create_task(
plugin_id="maintenance_banner_apply",
params={
"operation": "end",
"event_id": maintenance_id,
"environment_id": event.environment_id,
"user": _auth.split(":", 1)[1] if ":" in _auth else _auth,
},
)
app_logger.reflect(
"Maintenance end dispatched",
extra={"event_id": maintenance_id, "task_id": task.id},
)
return MaintenanceEndResponse(
task_id=task.id,
status="pending",
)
# #endregion end_maintenance
# ── POST /api/maintenance/end-all ────────────────────────────
# FR-007: Bulk removal of all active banners
# FR-015: operator, admin (NOT viewer)
# #region end_all_maintenance [C:3] [TYPE Function]
# @BRIEF End all active maintenance events. Removes all banners from all dashboards.
# Always returns 202 with task_id. Supports environment scoping for API keys.
# @RELATION DEPENDS_ON -> [has_permission("maintenance", "WRITE")]
@router.post(
"/end-all",
status_code=status.HTTP_202_ACCEPTED,
response_model=MaintenanceEndResponse,
)
async def end_all_maintenance(
request: Request,
db: Session = Depends(get_db),
_auth=Depends(require_api_key_or_jwt("maintenance:end_all", "maintenance", "WRITE")),
task_manager: TaskManager = Depends(get_task_manager),
):
with belief_scope("end_all_maintenance"):
# ── Resolve environment scope ──
effective_env_id: str | None = None
try:
body = await request.json()
effective_env_id = body.get("environment_id")
except Exception:
pass
# ── Enforce API key environment scoping ──
if _auth.startswith("api_key:"):
# Auto-scope to the key's environment if not specified in body
if not effective_env_id:
raw_key = request.headers.get("X-API-Key")
if raw_key:
from ....core.auth.api_key import hash_api_key
key_hash = hash_api_key(raw_key)
key_row = db.query(APIKey).filter(APIKey.key_hash == key_hash).first()
if key_row and key_row.environment_id:
effective_env_id = key_row.environment_id
await check_api_key_environment_scope(request, db, effective_env_id or "")
# ── Dispatch TaskManager task ──
task = await task_manager.create_task(
plugin_id="maintenance_banner_apply",
params={
"operation": "end_all",
"environment_id": effective_env_id or "",
"user": _auth.split(":", 1)[1] if ":" in _auth else _auth,
},
)
app_logger.reflect(
"End-all maintenance dispatched",
extra={"task_id": task.id, "environment_id": effective_env_id},
)
return MaintenanceEndResponse(
task_id=task.id,
status="pending",
)
# #endregion end_all_maintenance
# ── GET /api/maintenance/settings ────────────────────────────
# FR-009: Get maintenance settings configuration
# FR-015: operator, admin (NOT viewer)
# #region get_maintenance_settings [C:2] [TYPE Function]
# @BRIEF Get current maintenance settings configuration.
# @RELATION DEPENDS_ON -> [has_permission("maintenance", "READ")]
@router.get("/settings", response_model=MaintenanceSettingsResponse)
async def get_maintenance_settings(
db: Session = Depends(get_db),
_=Depends(has_permission("maintenance", "READ")),
):
with belief_scope("get_maintenance_settings"):
settings = db.query(MaintenanceSettings).filter(
MaintenanceSettings.id == "default"
).first()
if not settings:
raise HTTPException(
status_code=404,
detail="Maintenance settings not configured",
)
return MaintenanceSettingsResponse(
target_environment_id=settings.target_environment_id,
display_timezone=settings.display_timezone,
banner_template=settings.banner_template,
dashboard_scope=settings.dashboard_scope.value,
excluded_dashboard_ids=list(settings.excluded_dashboard_ids or []),
forced_dashboard_ids=list(settings.forced_dashboard_ids or []),
updated_at=settings.updated_at.isoformat() if settings.updated_at else None,
)
# #endregion get_maintenance_settings
# ── PUT /api/maintenance/settings ────────────────────────────
# FR-009: Update maintenance settings configuration
# FR-015: admin ONLY (NOT operator, NOT viewer)
# #region update_maintenance_settings [C:2] [TYPE Function]
# @BRIEF Update maintenance settings. All fields optional for partial update.
# admin role required per FR-015.
# @RELATION DEPENDS_ON -> [has_permission("admin:settings", "WRITE")]
@router.put("/settings", response_model=MaintenanceSettingsResponse)
async def update_maintenance_settings(
settings_data: MaintenanceSettingsUpdate,
db: Session = Depends(get_db),
_=Depends(has_permission("admin:settings", "WRITE")),
):
with belief_scope("update_maintenance_settings"):
settings = db.query(MaintenanceSettings).filter(
MaintenanceSettings.id == "default"
).first()
if not settings:
# Auto-create with defaults on first save (upsert behavior).
# Column-level defaults from the model apply for omitted fields.
settings = MaintenanceSettings(
id="default",
target_environment_id=settings_data.target_environment_id or "",
)
db.add(settings)
db.flush()
# Apply partial updates
update_data = settings_data.model_dump(exclude_unset=True)
if "target_environment_id" in update_data:
settings.target_environment_id = update_data["target_environment_id"]
if "display_timezone" in update_data:
settings.display_timezone = update_data["display_timezone"]
if "banner_template" in update_data:
settings.banner_template = update_data["banner_template"]
if "dashboard_scope" in update_data:
from ....models.maintenance import DashboardScope
settings.dashboard_scope = DashboardScope(update_data["dashboard_scope"])
if "excluded_dashboard_ids" in update_data:
settings.excluded_dashboard_ids = update_data["excluded_dashboard_ids"]
if "forced_dashboard_ids" in update_data:
settings.forced_dashboard_ids = update_data["forced_dashboard_ids"]
db.commit()
db.refresh(settings)
return MaintenanceSettingsResponse(
target_environment_id=settings.target_environment_id,
display_timezone=settings.display_timezone,
banner_template=settings.banner_template,
dashboard_scope=settings.dashboard_scope.value,
excluded_dashboard_ids=list(settings.excluded_dashboard_ids or []),
forced_dashboard_ids=list(settings.forced_dashboard_ids or []),
updated_at=settings.updated_at.isoformat() if settings.updated_at else None,
)
# #endregion update_maintenance_settings
# #endregion MaintenanceRoutesModule