Files
ss-tools/specs/031-maintenance-banner/contracts/modules.md
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

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 with maintenance:start), requires environment_id
  • POST /{id}/end — dual auth (JWT or API key with maintenance:end), validates key scope against event's stored env
  • POST /end-all — dual auth (JWT or API key with maintenance:end_all)
  • GET /events — JWT only, auto-expires stale events
  • GET /dashboard-banners — JWT only
  • GET /settings — JWT only
  • PUT /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