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.
14 KiB
Semantic Contracts: Maintenance Banner for Dashboards
Feature Branch: 031-maintenance-banner
Created: 2026-05-21
Status: Phase 1 — Design Contracts
Updated: 2026-05-25 — Added APIKey contracts
Backend Contracts
[DEF:MaintenanceEvent:Model]
@COMPLEXITY 1 @PURPOSE SQLAlchemy ORM model for maintenance event records. @LAYER models @FILE backend/src/models/maintenance.py @RELATION DEPENDS_ON -> [Base:Model]
Columns: id (str PK), task_id (str, loose reference to TaskManager), environment_id (str — snapshot of target env at creation), tables (JSON), start_time (datetime tz, required), end_time (datetime tz, nullable), message (text?), status (enum: pending/applying/active/partial/ending/completed/failed), created_at, updated_at.
[DEF:MaintenanceDashboardBanner:Model]
@COMPLEXITY 1 @PURPOSE Canonical "one chart per dashboard" entity — enforces invariant exactly one banner chart per dashboard regardless of active event count. Unique partial index on (environment_id, dashboard_id) WHERE status='active'. @LAYER models @FILE backend/src/models/maintenance.py @RELATION DEPENDS_ON -> [Base:Model]
Columns: id (str PK), environment_id (str), dashboard_id (int), chart_id (int? — Superset markdown chart ID), banner_text (text — current aggregated markdown), status (enum: active/removed), created_at, updated_at.
[DEF:MaintenanceDashboardState:Model]
@COMPLEXITY 1 @PURPOSE Junction linking events to dashboards. References shared MaintenanceDashboardBanner — does not own chart_id directly. @LAYER models @FILE backend/src/models/maintenance.py @RELATION DEPENDS_ON -> [Base:Model] @RELATION DEPENDS_ON -> [MaintenanceEvent:Model] @RELATION DEPENDS_ON -> [MaintenanceDashboardBanner:Model]
Columns: id (str PK), event_id (str FK→maintenance_events.id), dashboard_id (int), banner_id (str FK→maintenance_dashboard_banners.id), status (enum: pending_apply/active/removing/removed/apply_failed/removal_failed), created_at, updated_at. Index: (dashboard_id, status).
[DEF:MaintenanceSettings:Model]
@COMPLEXITY 1 @PURPOSE Single-row configuration table for maintenance mode settings. @LAYER models @FILE backend/src/models/maintenance.py @RELATION DEPENDS_ON -> [Base:Model]
Columns: id (str PK, "default"), target_environment_id (str), display_timezone (str, default "UTC"), banner_template (text — markdown with optional {start_time}, {end_time}, {message}; default includes all three), dashboard_scope (enum: published_only/draft_only/all), excluded_dashboard_ids (JSON), forced_dashboard_ids (JSON), updated_at.
[DEF:APIKey:Model]
@COMPLEXITY 1
@PURPOSE Service-to-service static bearer token for external tool authentication. Raw key (format: ssk_<43 URL-safe chars>) shown once at creation; only SHA-256 hash stored. Environment-scoped, permission-gated, revocable.
@LAYER models
@FILE backend/src/models/maintenance.py (or backend/src/models/api_key.py)
@RELATION DEPENDS_ON -> [Base:Model]
@INVARIANT Raw key NEVER stored in DB. Only key_hash = SHA256(token) persisted. Prefix (first 11 chars of token) stored unhashed for admin identification.
@INVARIANT One key per external tool (named). Multiple keys per tool possible but not recommended — use key rotation.
Columns: id (UUID PK), key_hash (str UNIQUE, SHA-256 of raw key), prefix (str, first 11 chars), name (str), environment_id (str|null — scoped to one env; null = all), permissions (JSON str[]), active (bool), created_at, expires_at (datetime|null), last_used_at (datetime|null).
[DEF:APIKeyPrincipal:Dependency]
@COMPLEXITY 2
@PURPOSE FastAPI dependency resolving an APIKey DB row into a lightweight principal object for RBAC checks. Activated by X-API-Key header. Falls through when header absent (allowing JWT to take over).
@LAYER dependencies
@FILE backend/src/dependencies.py or backend/src/core/auth/api_key.py
@RELATION DEPENDS_ON -> [APIKey:Model]
@RELATION DEPENDS_ON -> [FastAPI.Header]
@PRE X-API-Key header may be present. DB session available.
@POST If header valid and key active → returns APIKeyPrincipal(name, environment_id, permissions). If header absent → returns None (fall through to JWT). If invalid/expired → raises HTTPException(401).
Verification flow: hash incoming header, lookup in DB by hash, check active + expires_at, update last_used_at.
[DEF:APIKeyAdminRoutes:Module]
@COMPLEXITY 2 @PURPOSE Admin-only API key CRUD endpoints. Keys are read-once secrets. @LAYER api @FILE backend/src/api/routes/admin/api_keys.py (new) @RELATION DEPENDS_ON -> [APIKey:Model] @RELATION DEPENDS_ON -> [has_permission("admin:settings", "WRITE")] @INVARIANT Raw key returned ONLY in POST response — never in GET. @INVARIANT DELETE sets active=false — preserves audit trail (no physical deletion).
Endpoints:
GET /api/admin/api-keys— list all (active + revoked)POST /api/admin/api-keys— generate new key (returns{id, raw_key, prefix, name, ...})DELETE /api/admin/api-keys/{id}— revoke key (active=false)
[DEF:MaintenanceSchemas:Module]
@COMPLEXITY 1 @PURPOSE Pydantic request/response schemas for maintenance API endpoints. @LAYER schemas @FILE backend/src/api/routes/maintenance/_schemas.py @RELATION DEPENDS_ON -> [pydantic.BaseModel]
Request schemas:
MaintenanceStartRequest— tables, start_time, end_time(opt), message(opt), environment_id (required — per FR-001 2026-05-25 revision)MaintenanceSettingsUpdate— all optional partial-update fields
Response schemas: MaintenanceStartResponse, MaintenanceEndResponse, MaintenanceSettingsResponse, MaintenanceEventItem, MaintenanceEventListResponse, MaintenanceDashboardBannerState.
[DEF:SupersetDashboardsWriteMixin:Class]
@COMPLEXITY 4 @PURPOSE Extends SupersetClient with dashboard write operations. @LAYER core @FILE backend/src/core/superset_client/_dashboards_write.py @RELATION DEPENDS_ON -> [SupersetClientBase:Class] @RELATION DEPENDS_ON -> [NetworkClient:Class] @PRE SupersetClient is initialized with valid bearer token and environment base URL. @POST Chart created/updated/deleted in Superset. Dashboard position_json updated. @SIDE_EFFECT Modifies Superset dashboards via REST API. @RATIONALE Direct chart API faster than export-import cycle for single-chart ops.
Methods: create_markdown_chart, update_markdown_chart, update_dashboard_layout (insert at (0,0), 12 cols, shift existing down), remove_chart_from_layout, delete_chart, get_dashboard_layout.
[DEF:SqlTableExtractor:Module]
@COMPLEXITY 2 @PURPOSE Extract schema.table from virtual dataset SQL+Jinja. Two-phase: Jinja span detection, regex global extraction, sqlparse filtering only on SQL spans (string literals). @LAYER services @FILE backend/src/services/sql_table_extractor.py @RELATION DEPENDS_ON -> [sqlparse] @RATIONALE Table names in {% set %} blocks must survive; stripping Jinja first loses them.
[DEF:MaintenanceService:Class]
@COMPLEXITY 4 @PURPOSE Core business logic: discovery, banner placement/removal, text rebuild. @LAYER services @FILE backend/src/services/maintenance_service.py @RELATION DEPENDS_ON -> [SupersetClient:Class] @RELATION DEPENDS_ON -> [SupersetDashboardsWriteMixin:Class] @RELATION DEPENDS_ON -> [SqlTableExtractor:Module] @RELATION DEPENDS_ON -> [MaintenanceEvent:Model] @RELATION DEPENDS_ON -> [MaintenanceDashboardState:Model] @RELATION DEPENDS_ON -> [MaintenanceDashboardBanner:Model] @RELATION DEPENDS_ON -> [MaintenanceSettings:Model] @RELATION DEPENDS_ON -> [TaskManager:Class] @PRE Active DB session. Superset client initialized. @POST Event/state/banner records updated. Charts created/updated/deleted. @SIDE_EFFECT Superset mutations; DB writes; WebSocket progress events. @RATIONALE Single orchestrator ensures consistency between local state and Superset.
Methods: start_maintenance (idempotency by (tables, start, end)), end_maintenance, end_all_maintenance, find_affected_dashboards, ensure_banner_chart (shared chart per dashboard), build_banner_text (template substitution), rebuild_banner (update_markdown_chart).
[DEF:MaintenanceRoutes:Module]
@COMPLEXITY 4
@PURPOSE FastAPI route handlers for maintenance endpoints. Dual auth: JWT (existing) + API Key via require_api_key_or_jwt. Environment scoping enforced per FR-001 revision.
@LAYER api
@FILE backend/src/api/routes/maintenance/_routes.py
@RELATION DEPENDS_ON -> [MaintenanceService:Class]
@RELATION DEPENDS_ON -> [MaintenanceSchemas:Module]
@RELATION DEPENDS_ON -> [TaskManager:Class]
@RELATION DEPENDS_ON -> [AuthMiddleware:Class]
@RELATION DEPENDS_ON -> [APIKeyPrincipal:Dependency]
@RELATION DEPENDS_ON -> [require_api_key_or_jwt:Function]
8 endpoints:
POST /start— dual auth (JWT or API key withmaintenance:start), requiresenvironment_idPOST /{id}/end— dual auth (JWT or API key withmaintenance:end), validates key scope against event's stored envPOST /end-all— dual auth (JWT or API key withmaintenance:end_all)GET /events— JWT only, auto-expires stale eventsGET /dashboard-banners— JWT onlyGET /settings— JWT onlyPUT /settings— JWT only (admin)
[DEF:MaintenanceRouter:Module]
@COMPLEXITY 2 @PURPOSE APIRouter assembly with prefix and tag. @LAYER api @FILE backend/src/api/routes/maintenance/_router.py @RELATION DEPENDS_ON -> [MaintenanceRoutes:Module]
Frontend Contracts
[DEF:MaintenanceStore:Module]
@COMPLEXITY 3
@PURPOSE Svelte 5 rune store for maintenance state. Shared by management page and Dashboard Hub badge.
@LAYER store
@FILE frontend/src/lib/stores/maintenance.svelte.js
@RELATION DEPENDS_ON -> [requestApi:Function]
@RATIONALE Shared store avoids prop drilling across routes. Uses $effect(subscribe) per ADR-0007.
State: events ($state), settings ($state), dashboardBanners ($state Map), isLoading, error. Auto-polls events every 30s; WebSocket covers task progress.
[DEF:MaintenanceApiClient:Module]
@COMPLEXITY 2 @PURPOSE Typed fetch wrappers for maintenance API. @LAYER api @FILE frontend/src/lib/api/maintenance.js @RELATION DEPENDS_ON -> [requestApi:Function]
7 functions mirroring the 7 API endpoints.
[DEF:MaintenanceBannerPage:Component]
@COMPLEXITY 3
@PURPOSE Management page at /maintenance. Compose settings panel + events table from store.
@LAYER route
@FILE frontend/src/routes/maintenance/+page.svelte
@RELATION DEPENDS_ON -> [MaintenanceStore:Module]
@RELATION DEPENDS_ON -> [MaintenanceSettingsPanel:Component]
@RELATION DEPENDS_ON -> [MaintenanceEventsTable:Component]
@UX_STATE idle/loading/action_in_progress/error/empty
@UX_FEEDBACK Toast via addToast() from $lib/toasts.js
[DEF:MaintenanceSettingsPanel:Component]
@COMPLEXITY 3
@PURPOSE Collapsible settings form for maintenance-specific configuration. API key management is handled centrally in Settings (see [DEF:ApiKeySettingsPanel:Component]).
@LAYER component
@FILE frontend/src/lib/components/MaintenanceSettingsPanel.svelte
@RELATION DEPENDS_ON -> [MaintenanceStore:Module]
@RELATION DEPENDS_ON -> [Select:Component] (from $lib/ui/Select.svelte)
@RELATION DEPENDS_ON -> [Input:Component] (from $lib/ui/Input.svelte)
@RELATION DEPENDS_ON -> [SearchableMultiSelect:Component] (from $lib/components/ui/SearchableMultiSelect.svelte)
@RATIONALE Reuses existing form atoms (Select, Input) and dashboard picker (SearchableMultiSelect) — zero new UI primitives needed.
Form fields:
- Environment:
<Select>with env options - Timezone:
<Input>text - Scope: native
<input type="radio" bind:group>(matches ScheduleConfig pattern) - Excluded/Forced:
<SearchableMultiSelect>with dashboards as{id, name} - Banner template:
<textarea>with live preview div - Save:
<Button isLoading={isSaving}>from$lib/ui/Button.svelte
Collapsible via <details><summary> (matches migration page pattern).
@UX_STATE idle/saving/saved/error
[DEF:MaintenanceEventsTable:Component]
@COMPLEXITY 3
@PURPOSE Active/completed events table with removal actions.
@LAYER component
@FILE frontend/src/lib/components/MaintenanceEventsTable.svelte
@RELATION DEPENDS_ON -> [MaintenanceStore:Module]
@RELATION DEPENDS_ON -> [MaintenanceApiClient:Module]
@RELATION DEPENDS_ON -> [Button:Component] (from $lib/ui/Button.svelte)
Uses inline table pattern from health page (min-w-full divide-y), skeleton rows (animate-pulse), empty state (border-dashed bg-gray-50), badges (inline Tailwind rounded-full), confirmation via native confirm(). Spinner via Button(isLoading={true}).
@UX_STATE idle/removing/error
[DEF:DashboardGridBannerIndicator:Component]
@COMPLEXITY 2 @PURPOSE Orange badge in Dashboard Hub row when maintenance active. @LAYER component @FILE frontend/src/lib/components/DashboardMaintenanceBadge.svelte @RELATION DEPENDS_ON -> [MaintenanceStore:Module]
Badge: rounded-full px-2.5 py-0.5 text-xs font-medium bg-orange-100 text-orange-700 (matches existing badge convention). Tooltip via native title attribute (matches codebase-wide pattern). No badge when inactive. Skeleton: animate-pulse bg-gray-200 h-5 w-24 rounded-full.
@UX_STATE no_maintenance/maintenance_active/loading
[DEF:ApiKeySettingsPanel:Component]
@COMPLEXITY 2
@PURPOSE Centralized admin section in System Settings (/settings) for managing API keys. Keys are cross-cutting infrastructure — any tool (maintenance, migration, translation) can consume them. NOT embedded in Maintenance panel.
@LAYER component
@FILE frontend/src/lib/components/settings/ApiKeySettingsPanel.svelte (new) or embedded in frontend/src/routes/settings/SystemSettings.svelte
@RELATION DEPENDS_ON -> [requestApi:Function]
@PRE User has admin role (admin:settings WRITE).
@POST API key CRUD operations dispatched. New key raw value shown once in a copyable <code> block.
@RATIONALE Read-once key display: raw key shown in a <pre> block with Copy button. After user dismisses, raw key is gone forever. List shows prefix only.
@RATIONALE Centralized architecture: API keys live in Settings — not coupled to Maintenance. Future tools (migration API, data export, etc.) all use the same key infrastructure. Maintenance endpoints are just the first consumer.
UI elements:
- Table: columns
Name,Prefix(monospace),Environment,Permissions,Active,Created,Revoke(button) - "Generate Key" form: name input, environment select, permissions checkboxes (start/end/end_all), optional expiry date
- After generation: modal/expanding panel with raw key in
<pre>block + "Copy to clipboard" button + warning "Copy this key now — it won't be shown again" - Revoke: confirmation dialog, then
DELETE /api/admin/api-keys/{id}
@UX_STATE idle/generating/generated/revoking/error