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.
This commit is contained in:
2026-05-25 11:35:27 +03:00
parent 31680a1bc9
commit 320f82ab95
39 changed files with 3359 additions and 707 deletions

View File

@@ -3,6 +3,7 @@
**Feature Branch**: `031-maintenance-banner`
**Created**: 2026-05-21
**Status**: Phase 1 — Design Contracts
**Updated**: 2026-05-25 — Added APIKey contracts
## Backend Contracts
@@ -44,6 +45,44 @@ Columns: id (str PK), event_id (str FK→maintenance_events.id), dashboard_id (i
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]
@@ -53,6 +92,12 @@ Columns: id (str PK, "default"), target_environment_id (str), display_timezone (
@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.
@@ -96,16 +141,25 @@ Methods: `create_markdown_chart`, `update_markdown_chart`, `update_dashboard_lay
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 3
@PURPOSE FastAPI route handlers for maintenance endpoints.
@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]
7 endpoints: start (always 202), end (202), end-all (202), events (GET), settings (GET/PUT), dashboard-banners (GET). RBAC per FR-015 matrix.
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
@@ -150,7 +204,7 @@ State: `events` ($state), `settings` ($state), `dashboardBanners` ($state Map),
### [DEF:MaintenanceSettingsPanel:Component]
@COMPLEXITY 3
@PURPOSE Collapsible settings form using existing UI primitives.
@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]
@@ -194,3 +248,22 @@ Uses inline table pattern from health page (`min-w-full divide-y`), skeleton row
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

View File

@@ -0,0 +1,96 @@
## APIKey Entity (NEW — 2026-05-25)
**Implementation:** `backend/src/models/api_key.py`
**Database table:** `api_keys`
**Base:** `backend/src/models/mapping.py` (Base)
Service-to-service authentication token for external tools. Provides bearer-token auth to maintenance endpoints as an alternative to browser-based JWT.
```
┌─────────────────────────────────────────┐
│ api_keys │
│ (one key per external tool) │
│ ──────────────────────────────────── │
│ id: String (UUID PK) │
│ key_hash: String(64) (SHA-256 hex) │
│ prefix: String(11) ("ssk_" + 7 chars) │
│ name: String(255) │
│ environment_id: String(50) (nullable) │
│ permissions: JSON │
│ active: Boolean │
│ created_at: DateTime │
│ expires_at: DateTime (nullable) │
│ last_used_at: DateTime (nullable) │
└─────────────────────────────────────────┘
```
**Note:** `id` is stored as `String` containing a UUID, not as a PostgreSQL UUID type (SQLite-compatible).
### Columns
| Column | Type | Description |
|--------|------|-------------|
| `id` | `UUID, PK` | Unique identifier |
| `key_hash` | `VARCHAR(64), NOT NULL, UNIQUE` | SHA-256 hash of the raw key. Raw key NEVER stored. |
| `prefix` | `VARCHAR(11), NOT NULL` | First 11 chars of raw key (`"ssk_"` + 7 chars). Displayed in admin list for identification. Hashed alone: attacker with prefix only cannot reconstruct key. |
| `name` | `VARCHAR(255), NOT NULL` | Human-readable label: `"Airflow ETL Production"`, `"GitLab CI/CD Staging"` |
| `environment_id` | `VARCHAR(50), NULLABLE` | Bound Superset environment. `NULL` or `"*"` = all environments (admin-only). If set, request body `environment_id` must match or be omitted (defaults to this). |
| `permissions` | `JSON, NOT NULL` | Fine-grained ops: `["maintenance:start", "maintenance:end", "maintenance:end_all"]`. Any subset. |
| `active` | `BOOLEAN, DEFAULT TRUE` | Revocation flag. `false` = rejected with 401. |
| `created_at` | `DATETIME, DEFAULT NOW()` | |
| `expires_at` | `DATETIME, NULLABLE` | Auto-expiry. Past date = rejected with 401. |
| `last_used_at` | `DATETIME, NULLABLE` | Updated on each authenticated request. Audit trail. |
### Invariants
- **Raw key never stored** — only `key_hash` = `SHA256(ssk_<token>)`. Verification: hash incoming header, compare to stored hash.
- **Prefix displayed, not hashed** — allows admin to identify keys in list (`"ssk_M7xqaP..."`) without exposing the full key.
- **No refresh** — if compromised, admin revokes (`active=false`) and generates new key via `POST /api/admin/api-keys`.
- **Environment gate** — key's `environment_id` gates Superset target. Key for `ss-dev` cannot start maintenance on `ss-prod`.
- **Read-once secret** — `POST /api/admin/api-keys` returns `{id, raw_key, prefix, name, ...}`. The `raw_key` field is the ONLY output. Subsequent `GET` calls never include it.
### Key Format
```
ssk_<43 random URL-safe chars>
Example: ssk_M7xqaP2zL9vR4nF8kC1bH5jT6dW0yA3
```
- Prefix `ssk_` identifies the token as an ss-tools server key (vs Superset tokens, API keys for other services).
- 43 random chars from `secrets.token_urlsafe(32)` = 192 bits entropy → ~2^192 keyspace.
- Format is compatible with env vars, curl headers, CI/CD secret variables.
### Verification Flow (in dependency)
```python
async def get_api_key_principal(
api_key: str = Header(None, alias="X-API-Key"),
db: Session = Depends(get_db),
):
if not api_key:
return None # No API key header; fall through to JWT
key_hash = hashlib.sha256(api_key.encode()).hexdigest()
key_record = db.query(APIKey).filter(
APIKey.key_hash == key_hash,
APIKey.active == True,
).first()
if not key_record:
raise HTTPException(401, "Invalid or revoked API key")
if key_record.expires_at and key_record.expires_at < datetime.now(timezone.utc):
raise HTTPException(401, "API key has expired")
# Update last_used_at (non-blocking)
key_record.last_used_at = datetime.now(timezone.utc)
db.flush()
return APIKeyPrincipal(key_record)
```
### Security Notes
- **Hash only** — matches CWE-312 compliance. Raw key is ephemeral.
- **No key rotation** — deliberate. Rotation = revoke + regenerate. Simpler, safer.
- **Prefix hashing** — 11 chars visible. Attacker with prefix needs to brute-force remaining 37 characters (192 bits remaining).
- **No rate limiting (v1)** — admin responsibility. Future: cross-cutting rate limiter on `X-API-Key` header.

View File

@@ -3,18 +3,27 @@
**Feature Branch**: `031-maintenance-banner`
**Created**: 2026-05-21
**Status**: Draft
**Updated**: 2026-05-25 — Added API Key Authentication (US6, FR-018FR-022); Revised FR-001 to require explicit `environment_id` with API key scoping rules; Revised FR-002, FR-008, FR-009 for environment_id consistency
**Input**: User description: "Service for adding a maintenance banner to dashboards during ETL table updates. External tool passes names of tables being updated to an API method in ss-tools; ss-tools finds dashboards with datasets referencing those tables (both table-based and virtual SQL Jinja datasets) and adds a static text-chart at the top of the dashboard with maintenance information. Support passing start and end times. Banner removal — centrally from ss-tools or externally via API."
## Clarifications
### Session 2026-05-21
- Q: Which Superset environment should /api/maintenance/start target (ss-tools manages multiple)? → A: System always targets the production environment (pre-configured in ss-tools settings). External tool does NOT pass environment_id.
- Q: Which Superset environment should /api/maintenance/start target (ss-tools manages multiple)? → A: The caller specifies the target environment via the `environment_id` field in the request body. This is REQUIRED. The external tool or API key scope determines which environment is used. **Revision (2026-05-25)**: Original decision had the system auto-targeting a single pre-configured "production" environment. This was revised when API Key environment scoping was added — scoped keys can only operate within their assigned environment, and the `environment_id` field became explicit and required.
- Q: Should /api/maintenance/start be synchronous (blocking) or asynchronous (task-based)? → A: Async — returns `{task_id, status: "pending"}` immediately; operator polls `GET /api/tasks/{task_id}` or uses WebSocket for progress (matches existing TaskManager pattern).
- Q: How should table names from the API be matched against dataset references? → A: Exact `schema.table` match only (e.g., `"raw.sales"` matches only `"raw.sales"`, not unqualified `"sales"`). Case-insensitive.
- Q: Should duplicate /start calls (same tables, same times) create a new MaintenanceEvent or be idempotent? → A: Idempotent by `(tables, start_time, end_time)` key. Same tuple = `already_active`. Changed `end_time` (or adding it when previously omitted) = new event. Omitted `end_time` treated as `None` in the key. The `message` field is NOT part of the idempotency key — duplicate calls with a different message return `already_active` and the original message is preserved.
- Q: Should duplicate /start calls (same tables, same times) create a new MaintenanceEvent or be idempotent? → A: Idempotent by `(tables, start_time, end_time, environment_id)` key. Same tuple = `already_active`. Changed `end_time` (or adding it when previously omitted) = new event. Omitted `end_time` treated as `None` in the key. **Revision (2026-05-25)**: Added `environment_id` to idempotency key — same tables in different environments are distinct events.
- Q: When /end is called on event M1 but dashboard D is also affected by active event M2, should the banner be removed? → A: No — banner stays on D but is rebuilt with updated text (only M2's info). Removal occurs only when the LAST active event affecting D is ended.
### Session 2026-05-25 — API Key Authentication
- Q: How should external tools (Airflow, CI/CD) authenticate without browser-based JWT login? → A: Static API Key in `X-API-Key` header. Key is bound to one environment and limited to maintenance permissions (start/end/end_all). Key is generated once via admin UI; raw value shown only at creation time. Stored as SHA-256 hash.
- Q: Should API keys have scoped permissions? → A: Yes — each key has a `permissions` list containing fine-grained operations: `maintenance:start`, `maintenance:end`, `maintenance:end_all`. If `environment_id` is set on the key, the key may only operate on that environment.
- Q: Should API keys be refreshable? → A: No. If compromise is suspected, admin revokes the key (sets `active = false`) and generates a new one. Re-keying is intentional — not automatic.
- Q: Where should API key management live in the UI? → A: **Centralized in System Settings** (`/settings` → System), NOT embedded in the Maintenance panel. API keys are cross-cutting infrastructure — future tools (migration API, translation API) will also consume them. Maintenance is the first consumer. The keys table, generation form, and revocation UI are reusable components shared across all tools.
## User Scenarios & Testing *(mandatory)*
### User Story 1 — External tool initiates maintenance banner display (Priority: P1)
@@ -33,7 +42,7 @@ ss-tools finds all dashboards in Superset whose datasets reference the given tab
**Acceptance Scenarios**:
1. **Given** dashboard D in the production environment uses a dataset referencing table `raw.sales`, **When** external tool calls `POST /api/maintenance/start` with `{"tables": ["raw.sales"], "start_time": "2026-05-21T22:00:00Z", "end_time": "2026-05-22T02:00:00Z"}`, **Then** API returns `{task_id: "t-...", status: "pending"}` immediately, and upon task completion dashboard D in Superset shows a markdown chart at the top: "⚠️ Maintenance in progress: 2026-05-21 22:00 — 2026-05-22 02:00 UTC. Data may be incomplete."
1. **Given** dashboard D in the `ss-dev` environment uses a dataset referencing table `raw.sales`, **When** external tool calls `POST /api/maintenance/start` with `{"tables": ["raw.sales"], "start_time": "2026-05-21T22:00:00Z", "end_time": "2026-05-22T02:00:00Z", "environment_id": "ss-dev"}`, **Then** API returns `{task_id: "t-...", status: "pending"}` immediately, and upon task completion dashboard D in Superset shows a markdown chart at the top: "⚠️ Maintenance in progress: 2026-05-21 22:00 — 2026-05-22 02:00 UTC. Data may be incomplete."
2. **Given** dashboard D uses a virtual SQL dataset with query `SELECT * FROM raw.sales JOIN raw.inventory ON ...`, **When** external tool passes table `raw.inventory`, **Then** dashboard D also receives the banner (since the table is found in the dataset SQL text).
@@ -122,6 +131,36 @@ The Dashboard Hub table shows an indicator when a dashboard has an active mainte
---
### User Story 6 — External tool authenticates via API Key (Priority: P1)
External tools (Airflow DAGs, CI/CD pipelines, monitoring scripts) need a simple, token-based authentication mechanism to start and end maintenance events. Instead of the browser-based JWT authentication flow (login → get token → call API), external callers present a static API Key in the `X-API-Key` HTTP header. The API Key is bound to a specific Superset environment and limited to maintenance operations only. The API key is a read-once secret — shown only at creation, never stored in plaintext.
**Why this priority**: This is the authentication enabler for the core P1 use cases (US1, US2). Without API keys, every external script must manage JWT login sessions — brittle and insecure for CI/CD contexts. This is as fundamental as US1.
**Independent Test**: Generate an API key via the ss-tools admin UI, then call `POST /api/maintenance/start` with `X-API-Key: <key>` header (no JWT cookie). Verify the maintenance event is created and the banner appears on the dashboard.
**Acceptance Scenarios**:
1. **Given** an administrator has generated an API key `ssk_aB3x...` for the `ss-dev` environment with permission `maintenance:start`, **When** an external tool calls `POST /api/maintenance/start` with header `X-API-Key: ssk_aB3x...` (no other auth), **Then** the request is authenticated as the API key principal, and the maintenance event is created. The task creator metadata records `api_key: ci-gitlab-deploy`.
2. **Given** an API key has only `maintenance:start` permission, **When** the same key is used to call `POST /api/maintenance/{id}/end`, **Then** the request is rejected with `403 Forbidden``{"detail": "API key lacks maintenance:end permission"}`.
3. **Given** an API key bound to the `ss-dev` environment, **When** the external tool specifies `environment_id: ss-prod` in the request body, **Then** the request is rejected with `400 Bad Request``{"detail": "API key is restricted to environment ss-dev"}`.
4. **Given** an administrator revokes an API key (sets `active = false`), **When** the now-revoked key is used, **Then** the request is rejected with `401 Unauthorized``{"detail": "API key has been revoked"}`.
5. **Given** an API key has an `expires_at` date in the past, **When** the key is used after expiration, **Then** the request is rejected with `401 Unauthorized``{"detail": "API key has expired"}`.
6. **Given** an administrator generates a new API key via `POST /api/admin/api-keys`, **When** the key is created, **Then** the response returns the RAW key value (visible **once only** — never stored or retrievable again). The database stores only the SHA-256 hash of the key. Subsequent `GET /api/admin/api-keys` calls return the key's `name`, `prefix` (first 11 chars for identification), `environment_id`, `permissions`, `active` status, and `created_at` — but NOT the raw key.
7. **Given** a failed CI/CD pipeline, **When** an API key with `maintenance:end_all` permission calls `POST /api/maintenance/end-all`, **Then** all active banners are removed across the key's bound environment.
8. **Given** a request arrives with BOTH a valid JWT session cookie AND a valid `X-API-Key` header, **When** the system processes the request, **Then** the JWT session takes precedence — the user's identity is used, and the API key is logged but ignored. This prevents session fixation attacks when the header is accidentally present.
9. **Given** the administrator navigates to Settings → System, **When** they open the "API Keys" section, **Then** they see the centralized key management table (NOT hidden inside the Maintenance panel). The same API key infrastructure will serve future tools (migration, translation) without code changes to the UI.
---
### Edge Cases
- **Multiple maintenance events conflict**: If two ETL processes simultaneously update different tables affecting the same dashboard — the banner must aggregate information from all active events. When one event ends, the banner text is rebuilt from remaining active events rather than removed. Full banner removal occurs only when ALL events affecting that dashboard are completed.
@@ -140,7 +179,7 @@ The Dashboard Hub table shows an indicator when a dashboard has an active mainte
- **Dashboard present in both excluded and forced lists**: Forced takes priority (explicit inclusion overrides exclusion).
- **Duplicate /start call with identical parameters**: Idempotency key is `(tables, start_time, end_time)`. Same tuple → `"already_active"`. Same tables + start_time but different end_time (or adding end_time when previously omitted) → new event. Omitted `end_time` is `None` in the key.
- **Duplicate /start call with identical parameters**: Idempotency key is `(tables, start_time, end_time, environment_id)`. Same tuple → `"already_active"`. Same tables + start_time + env but different end_time → new event. Different `environment_id` → different event (even with same tables). Omitted `end_time` is `None` in the key. The `message` field is NOT part of the idempotency key.
- **Message HTML escaping**: The `message` field in the API request is plain text only. All HTML special characters (`<`, `>`, `&`, `"`, `'`) are escaped before rendering in the Superset markdown chart. Raw HTML/CSS injection via the message field is rejected at the API boundary (400).
@@ -148,13 +187,27 @@ The Dashboard Hub table shows an indicator when a dashboard has an active mainte
- **Tabbed dashboards**: The banner is added to the root dashboard layout only. If a Superset dashboard uses tabs, the banner may not be visible when viewing individual tabs — this is a known limitation. Future enhancement may add per-tab banner placement.
- **API key raw value leak**: The raw key (`ssk_...`) is returned exactly ONCE — in the response to `POST /api/admin/api-keys`. If the caller fails to capture it, the admin must revoke and regenerate. This is by design: storing plaintext API keys is a CWE-312 vulnerability.
- **API key concurrent revocation and use**: If an admin revokes a key while a task is already in-flight (authenticated and dispatched), the task completes normally. Revocation takes effect for NEW requests only. This is intentional — in-flight operations should not be interrupted.
- **API key rate limiting** (future): Not implemented in v1. Abuse prevention is the admin's responsibility via key rotation. Rate limiting may be added as a cross-cutting concern.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: System MUST provide API endpoint `POST /api/maintenance/start` accepting: list of table names (`tables: str[]`, max 100 entries, sorted set for idempotency), start time (`start_time: datetime` — required, in the future ±1h grace period), end time (`end_time: datetime | None` — optional, must be > start_time if provided), optional message text (`message: str | None`). **Always** returns `202 {task_id, maintenance_id, status: "pending"}` immediately after validation and event creation. Sync short-circuits only for `already_active` (idempotency) and validation errors (400). When `end_time` is omitted, the banner displays «Окончание: уточняется».
- **FR-001**: System MUST provide API endpoint `POST /api/maintenance/start` accepting: list of table names (`tables: str[]`, max 100 entries, sorted set for idempotency), start time (`start_time: datetime` — required, in the future ±1h grace period), end time (`end_time: datetime | None` — optional, must be > start_time if provided), optional message text (`message: str | None`), and **target environment_id** (`environment_id: str` — required, identifies the Superset environment to scan/modify). **Always** returns `202 {task_id, maintenance_id, status: "pending"}` immediately after validation and event creation.
- **FR-002**: System MUST, upon receiving a maintenance start request, find all dashboards in the configured production Superset environment whose datasets reference the given tables.
**environment_id scoping rules** (2026-05-25 update):
- When called with **JWT session auth**, `environment_id` must be explicitly provided by the caller (operator selects the target environment in the UI or script).
- When called with **API Key**, the key's `environment_id` scope is enforced: if the key is scoped to a specific environment (e.g., `ss-dev`), the request's `environment_id` must match (or be omitted — in which case the key's environment is used as default). A scoped key cannot target a different environment — `400 Bad Request` is returned.
- When the API key has `environment_id = "*"` (all environments), the caller may specify any environment.
- If no `environment_id` can be resolved (none in request body, none in API key scope, none in settings), the request is rejected with `400 Bad Request` — "environment_id is required".
Sync short-circuits only for `already_active` (idempotency) and validation errors (400). When `end_time` is omitted, the banner displays «Окончание: уточняется».
- **FR-002**: System MUST, upon receiving a maintenance start request, find all dashboards in the target Superset environment (specified by `environment_id` in the request) whose datasets reference the given tables. The `environment_id` is explicit and required — scoped by the caller or the API key's environment.
- **FR-003**: System MUST support finding tables in two dataset types using case-insensitive exact `schema.table` matching:
- Table-based (physical tables): match on `{schema}.{table_name}` format (e.g., `"raw.sales"` matches dataset with schema=`raw`, table_name=`sales`).
@@ -168,10 +221,10 @@ The Dashboard Hub table shows an indicator when a dashboard has an active mainte
- **FR-007**: System MUST provide API endpoint `POST /api/maintenance/end-all` for bulk removal of all active banners from all dashboards. Returns `{task_id}` immediately; removal runs asynchronously.
- **FR-008**: System MUST persist a maintenance event history in the local database with fields: id, task_id, table list, start time, end time, optional message, status (active/completed/failed), timestamps. Affected dashboard tracking uses the normalized `MaintenanceDashboardState` relationship, not an inline dashboard ID list.
- **FR-008**: System MUST persist a maintenance event history in the local database with fields: id, task_id, environment_id (snapshot), table list, start time, end time, optional message, status (active/completed/failed), timestamps. Affected dashboard tracking uses the normalized `MaintenanceDashboardState` relationship, not an inline dashboard ID list.
- **FR-009**: System MUST support maintenance mode settings via `GET/PUT /api/maintenance/settings`:
- `target_environment_id`: `str` — the production Superset environment to scan/modify
- `target_environment_id`: `str` — the default Superset environment to scan/modify (used when no explicit `environment_id` is provided in the API request). Overridden by `environment_id` in the request body or API key scope.
- `display_timezone`: `str` — timezone for banner text display (default: `"UTC"`)
- `banner_template`: `str` — markdown template with optional variables `{start_time}`, `{end_time}`, `{message}`. Variables are substituted at render time; missing variables are omitted. Default template includes all three with ⚠️ header and styling (see UX reference).
- `dashboard_scope`: `"published_only"` | `"draft_only"` | `"all"` (default: `"published_only"`)
@@ -195,24 +248,47 @@ The Dashboard Hub table shows an indicator when a dashboard has an active mainte
- **FR-015**: System MUST enforce RBAC per this matrix:
| Endpoint / UI | viewer | operator | admin |
|---|---|---|---|
| `GET /api/maintenance/dashboard-banners` | ✅ | ✅ | ✅ |
| `GET /api/maintenance/events` | ❌ | ✅ | ✅ |
| `POST /api/maintenance/start` | ❌ | ✅ | ✅ |
| `POST /api/maintenance/{id}/end` | ❌ | ✅ | ✅ |
| `POST /api/maintenance/end-all` | ❌ | ✅ | ✅ |
| `GET /api/maintenance/settings` | ❌ | ✅ | ✅ |
| `PUT /api/maintenance/settings` | ❌ | ❌ | ✅ |
| `/maintenance` UI page | ❌ | ✅ (read-only) | ✅ |
| Endpoint / UI | viewer | operator | admin | API Key |
|---|---|---|---|---|
| `GET /api/maintenance/dashboard-banners` | ✅ | ✅ | ✅ | ❌ |
| `GET /api/maintenance/events` | ❌ | ✅ | ✅ | ❌ |
| `POST /api/maintenance/start` | ❌ | ✅ | ✅ | ✅ |
| `POST /api/maintenance/{id}/end` | ❌ | ✅ | ✅ | ✅ |
| `POST /api/maintenance/end-all` | ❌ | ✅ | ✅ | ✅ |
| `GET /api/maintenance/settings` | ❌ | ✅ | ✅ | ❌ |
| `PUT /api/maintenance/settings` | ❌ | ❌ | ✅ | ❌ |
| `/maintenance` UI page | ❌ | ✅ (read-only) | ✅ | ❌ |
| `GET /api/admin/api-keys` | ❌ | ❌ | ✅ | ❌ |
| `POST /api/admin/api-keys` | ❌ | ❌ | ✅ | ❌ |
| `DELETE /api/admin/api-keys/{id}` | ❌ | ❌ | ✅ | ❌ |
- **FR-016**: System MUST provide `GET /api/maintenance/events` returning active and completed event lists with affected dashboard counts.
- **FR-017**: System MUST provide `GET /api/maintenance/dashboard-banners` returning per-dashboard banner state for the Dashboard Hub indicator.
### API Key Authentication Requirements
- **FR-018**: System MUST authenticate external API callers via a static bearer token in the `X-API-Key` HTTP header, as an ALTERNATIVE to the browser-based JWT (Authorization: Bearer) flow. When both JWT cookie and `X-API-Key` header are present, JWT takes precedence (the API key is logged but ignored).
- **FR-019**: The `APIKey` entity stores the SHA-256 hash of the key. The raw key (format: `ssk_<43 random chars>`) is a once-only output — returned in the `POST /api/admin/api-keys` response at creation time and NEVER retrievable again. Storing plaintext is prohibited (CWE-312).
- **FR-020**: Each API key MUST be bound to:
- **One `environment_id`** (or `"*"` for all environments — admin-only). If set, the caller's `environment_id` in the request body must match or be omitted (in which case the key's environment is used as default).
- **A `permissions` list** (subset of `["maintenance:start", "maintenance:end", "maintenance:end_all"]`). If the key lacks the required permission for the attempted operation, the request is rejected with `403 Forbidden`.
- **An `active` flag** — revoked keys are rejected with `401 Unauthorized`.
- **An optional `expires_at` timestamp** — expired keys are rejected with `401 Unauthorized`.
- **FR-021**: System MUST provide admin endpoints for API key lifecycle management. API key management is a **cross-cutting infrastructure concern** — the UI lives in the central Settings page (`/settings → System`), NOT embedded in the Maintenance panel. This enables future tools (migration API, translation API, etc.) to reuse the same API key infrastructure without coupling to maintenance.
- `GET /api/admin/api-keys` — list all keys (returns `name`, `prefix`, `environment_id`, `permissions`, `active`, `created_at`, `expires_at`; NEVER returns raw key or hash)
- `POST /api/admin/api-keys` — generate new key (accepts `name`, `environment_id`, `permissions`, optional `expires_at`; returns `{id, raw_key, prefix, ...}` — the raw key is the ONLY output)
- `DELETE /api/admin/api-keys/{id}` — revoke key (sets `active = false`; does not delete the row, preserving audit trail)
- `@RATIONALE Centralized`: API keys are a platform-level auth primitive, not a maintenance feature. Maintenance is the first consumer. Future features (migration triggers, translation pipeline, data export API) will reuse the same key table, auth dependency, and Settings UI — zero code changes needed.
- **FR-022**: The maintenance endpoints (`/start`, `/{id}/end`, `/end-all`) MUST accept EITHER JWT (existing) OR `X-API-Key` header. When using API key, the principal is identified by the key's `name` field in logs and task audit records (e.g., `"api_key: ci-gitlab-deploy"`).
### Key Entities
- **MaintenanceEvent**: A record of a maintenance event. Contains: id, task_id (links to TaskManager task), table list, start time, end time, optional message, affected dashboard IDs, status (active/completed/failed), created_at, updated_at.
- **MaintenanceEvent**: A record of a maintenance event. Contains: id, task_id (links to TaskManager task), environment_id (snapshot of target Superset environment at creation), table list, start time, end time, optional message, affected dashboard IDs, status (active/completed/failed), created_at, updated_at.
- **MaintenanceDashboardBanner** (NEW): The canonical "one chart per dashboard" entity. Unique constraint on `(environment_id, dashboard_id, status='active')`. Contains: id, environment_id, dashboard_id, chart_id (Superset markdown chart ID), banner_text (current aggregated markdown), updated_at. Only ONE active banner chart exists per dashboard regardless of how many MaintenanceEvents affect it.
@@ -224,6 +300,20 @@ The Dashboard Hub table shows an indicator when a dashboard has an active mainte
- **Dataset** (existing Superset entity): Two types: table-based (direct table reference) and virtual/SQL (`is_sqllab_view = true`, contains SQL query).
- **APIKey** (NEW): Service-to-service authentication token for external tools. Contains:
- `id: str (UUID, PK)` — unique identifier
- `key_hash: str` — SHA-256 hash of the raw key (plaintext NEVER stored)
- `prefix: str` — first 11 characters of raw key (`"ssk_"` + 7 chars) for identification in the admin list
- `name: str` — human-readable label (e.g., `"Airflow ETL Pipeline"`, `"GitLab CI/CD"`)
- `environment_id: str | None` — bound Superset environment; `"*"` for all (admin-only)
- `permissions: JSON (str[])` — fine-grained permissions (`["maintenance:start", "maintenance:end", "maintenance:end_all"]`)
- `active: bool` — revocation flag; `false` = key rejected
- `created_at: datetime`
- `expires_at: datetime | None` — optional automatic expiry
- `last_used_at: datetime | None` — last request timestamp (for audit)
Invariant: Raw key (`ssk_...`) is returned EXACTLY ONCE — in the POST /api/admin/api-keys response. After creation, only the hash exists in the database. The key is NOT refreshable; compromise requires revocation + regeneration.
## Success Criteria *(mandatory)*
### Measurable Outcomes
@@ -242,6 +332,8 @@ The Dashboard Hub table shows an indicator when a dashboard has an active mainte
- **SC-007**: On Superset API network errors, the system correctly returns a partial result without losing information about already-processed dashboards.
- **SC-008**: API key authentication adds ≤ 5ms overhead per request (SHA-256 hash lookup against indexed DB column). Key generation and listing are sub-100ms operations.
## UX Reference
See [ux_reference.md](./ux_reference.md) for detailed interaction flows, UI mockups, and error recovery paths.

View File

@@ -3,6 +3,7 @@
**Feature Branch**: `031-maintenance-banner`
**Created**: 2026-05-21
**Status**: Draft
**Updated**: 2026-05-25 — Added API Key management UX flow
## 1. User Persona & Context
@@ -29,276 +30,329 @@ An Airflow DAG finishes clearing staging tables and triggers: `curl -X POST http
**Maintenance end (Operator → ss-tools → Superset)**
The ETL pipeline completes successfully. The Airflow DAG calls `curl -X POST https://ss-tools/api/maintenance/m-abc123/end`. The API returns `{"task_id":"task-end-abc","status":"pending"}`. Upon task completion, banners disappear from all dashboards. In the ss-tools UI, the administrator sees the event marked as "Completed".
**Manual override (Administrator → ss-tools UI → Superset)**
An administrator notices the ETL has stalled and banners remain past the scheduled end time. They navigate to ss-tools → Maintenance → see the list of active events → click "Remove All". Within 10 seconds, all banners are removed.
## 3. API Key Management UX Flow
## 3. Interface Mockups
**Location:** Settings → System Settings → "API Keys" section (NOT in Maintenance panel).
### API Interaction
API keys are centralized infrastructure, reusable across tools. They live in the System Settings page alongside other cross-cutting configurations (environments, auth, etc.). Maintenance is just the first consumer.
```bash
# ===== Start maintenance =====
# With end_time:
$ curl -X POST https://ss-tools/api/maintenance/start \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{
"tables": ["raw.sales", "raw.inventory"],
"start_time": "2026-05-21T22:00:00+03:00",
"end_time": "2026-05-22T02:00:00+03:00",
"message": "Scheduled data mart refresh"
}'
### 3.1 Issuing a New API Key
# Without end_time (banner shows «Окончание: уточняется»):
$ curl -X POST https://ss-tools/api/maintenance/start \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{
"tables": ["raw.sales"],
"start_time": "2026-05-21T22:00:00+03:00"
}'
**Step 1: Navigate to Settings → System → API Keys**
# Success response — async (HTTP 202):
{
"task_id": "task-maint-abc123",
"maintenance_id": "m-ev-20260521-abc123",
"status": "pending"
}
The System Settings page contains an "API Keys" section showing a table of existing keys (or empty state if none):
# Poll task status:
$ curl https://ss-tools/api/tasks/task-maint-abc123 \
-H "Authorization: Bearer $JWT"
# Task completed response:
{
"task_id": "task-maint-abc123",
"status": "completed",
"result": {
"maintenance_id": "m-ev-20260521-abc123",
"affected_dashboards": 12,
"dashboards": [
{"id": 42, "title": "Daily Sales Overview", "chart_id": 902},
{"id": 57, "title": "Inventory Health Check", "chart_id": 903}
],
"unmatched_tables": []
}
}
# Partial success — async (HTTP 202):
{
"task_id": "task-maint-def456",
"maintenance_id": "m-ev-20260521-def456",
"status": "pending"
}
# Task result on completion (via GET /api/tasks/{task_id}):
{
"task_id": "task-maint-def456",
"status": "completed",
"result": {
"maintenance_id": "m-ev-20260521-def456",
"status": "partial",
"affected_dashboards": 10,
"failed_dashboards": [
{"id": 99, "title": "Archived Report", "error": "Dashboard not found in Superset"}
],
"unmatched_tables": ["raw.unknown_table"]
}
}
# No dashboards found — always async (HTTP 202, result in task):
# API response:
{
"task_id": "task-maint-noop",
"maintenance_id": "m-ev-20260521-noop",
"status": "pending"
}
# Task result on completion:
{
"task_id": "task-maint-noop",
"status": "completed",
"result": {
"maintenance_id": "m-ev-20260521-noop",
"status": "no_match",
"affected_dashboards": 0,
"unmatched_tables": ["raw.archived"]
}
}
# ===== End maintenance =====
$ curl -X POST https://ss-tools/api/maintenance/m-ev-20260521-abc123/end \
-H "Authorization: Bearer $JWT"
# Success response — async (HTTP 202):
{
"task_id": "task-end-abc123",
"status": "pending"
}
# Task result on completion:
{
"task_id": "task-end-abc123",
"status": "completed",
"result": {
"maintenance_id": "m-ev-20260521-abc123",
"removed_from": 12,
"failed_removals": []
}
}
# Already completed (HTTP 200 — sync, no task needed):
{
"maintenance_id": "m-ev-20260521-abc123",
"status": "already_completed",
"message": "Maintenance was already completed at 2026-05-22T02:05:00+03:00"
}
# ===== Bulk removal =====
$ curl -X POST https://ss-tools/api/maintenance/end-all \
-H "Authorization: Bearer $JWT"
# Success response — async (HTTP 202):
{
"task_id": "task-endall-xyz",
"status": "pending"
}
# Task result on completion:
{
"task_id": "task-endall-xyz",
"status": "completed",
"result": {
"closed_events": 3,
"removed_from": 25,
"failed_removals": []
}
}
# ===== Settings =====
$ curl https://ss-tools/api/maintenance/settings \
-H "Authorization: Bearer $JWT"
# Response:
{
"target_environment_id": "prod",
"display_timezone": "Europe/Moscow",
"banner_template": "<div style=\"background:#FFF3E0;...\">## ⚠️ Технические работы\n\n{message}\n\n**Начало:** {start_time}\n**Конец:** {end_time}\n\n*Данные могут быть неполными.*\n</div>",
"dashboard_scope": "published_only",
"excluded_dashboard_ids": [99, 101],
"forced_dashboard_ids": [10, 42],
"updated_at": "2026-05-20T15:00:00+03:00"
}
$ curl -X PUT https://ss-tools/api/maintenance/settings \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{"dashboard_scope": "all", "excluded_dashboard_ids": [99]}'
# Response: (settings object, same shape as GET)
```
┌─ API Keys ─────────────────────────────────────────────────┐
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Name Prefix Env Permissions Actv │ │
│ │─────────────────────────────────────────────────────│ │
│ Airflow ETL ssk_M7xqaP… ss-dev start,end 🟢 ✕ │ │
│ GitLab CI ssk_K9yFbZ… ss-dev s/e/a 🟢 ✕ │ │
│ Old staging ssk_T1vLmC… ss-dev start 🔴 · │ │
└─────────────────────────────────────────────────────┘ │
No API keys yet. Generate one for external tool access. │ ← empty state
│ │
│ [+ Generate New Key] │
└──────────────────────────────────────────────────────────────┘
```
### UI Layout & Flow
- **Permission notation:** `s` = maintenance:start, `e` = maintenance:end, `a` = maintenance:end_all
- **Status:** 🟢 = active, 🔴 = revoked
- **Revoke button:** `✕` on active keys; `·` (dot — no action) on revoked keys
- **Empty state:** dashed border, grey text, centered
**Screen 1: Maintenance Banners Management** (`/maintenance`)
**Step 2: Fill in the generation form**
* **Layout**: Single-page management panel. Top section — settings, bottom section — events table.
* **Key Elements**:
* **Settings Panel (collapsible)**:
* `Dashboard Scope`: Radio group: "Published only" / "Drafts only" / "All".
* `Excluded Dashboards`: Multi-select search by dashboard title. Removable badge chips.
* `Forced Dashboards`: Multi-select search by dashboard title. Removable badge chips.
* "Save Settings" button (Primary, blue).
* **Active Events Table**:
* Columns: Event ID, Tables (truncated list + tooltip), Affected Dashboards, Start Time, End Time, Status (badge: 🟢 Active / ✅ Completed / ❌ Failed), Actions.
* Actions: "Remove Banners" button (Warning, orange) per active event.
* Above table: "Remove All Banners" button (Danger, red) with confirmation dialog.
* **Completed Events Table** (collapsible, hidden by default):
* Same columns + "Completed At" timestamp. No action buttons.
* **Contract Mapping**:
* **`@UX_STATE`**: `idle` (table loaded), `loading` (skeleton), `action_in_progress` (remove button shows spinner, others disabled), `error` (error toast, retry available), `empty` (no active events — placeholder "No active maintenance events").
* **`@UX_FEEDBACK`**: Toast notifications: success (green "Banners removed from N dashboards"), error (red "Removal error: ..."), warning (yellow "Partial success: X/Y processed").
* **`@UX_RECOVERY`**: On removal error — "Retry" button per failed dashboard. On network error — auto-retry after 3s with countdown display.
* **`@UX_REACTIVITY`**: `$state` for activeEvents, completedEvents, settings; `$derived` for filtered/sorted views; `$effect` for auto-refresh (30s polling).
Clicking `[+ Generate New Key]` expands an inline form (not a modal — matches the existing settings pattern):
**Screen 2: Dashboard Hub — Maintenance Indicator** (modification to existing `/dashboards`)
```
┌─ Generate API Key ──────────────────────────────────────┐
│ │
│ Name ────────────────────────────────────────────────── │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Airflow Production ETL │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ Environment ────────────────────────────────────────── │
│ ┌──────────────────────────────────────▼─────────────┐ │
│ │ ss-dev │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ Permissions ────────────────────────────────────────── │
│ ☑ maintenance:start Start new maintenance events │
│ ☑ maintenance:end End a specific event │
│ ☐ maintenance:end_all End ALL active events at once │
│ │
│ Expires (optional) ─────────────────────────────────── │
│ ┌──────────────────────┐ 📅 │
│ │ 2027-01-01 │ │
│ └──────────────────────┘ │
│ │
│ [Cancel] [Generate Key] │
└───────────────────────────────────────────────────────────┘
```
* **Layout**: New indicator added to each dashboard row (alongside existing Git/LLM status badges).
* **Key Elements**:
* **Maintenance Badge**: Orange badge "⚠️ Maintenance" in the dashboard row.
* **Tooltip**: On hover — start and end times, source event ID.
* **Contract Mapping**:
* **`@UX_STATE`**: `no_maintenance` (no badge), `maintenance_active` (orange badge), `loading` (grey skeleton badge).
* **`@UX_REACTIVITY`**: `$derived` from maintenance state data, refreshed alongside dashboard polling.
**Step 3: Copy the raw key (ONCE!)**
**Screen 3: Superset Dashboard — Maintenance Markdown Chart** (result in third-party application)
After successful generation, the form is replaced by a **read-once reveal panel**:
* **Layout**: Markdown chart at the very top of the dashboard (position (0,0) in layout), full width.
* **Appearance**:
```
⚠️ Maintenance in progress
Scheduled data mart refresh
Start: May 21, 2026, 10:00 PM (MSK)
End: May 22, 2026, 2:00 AM (MSK)
Data may be incomplete or temporarily unavailable.
```
* **Стиль**: Markdown с настраиваемым шаблоном (`banner_template` в настройках). Переменные `{start_time}`, `{end_time}`, `{message}` опциональны — шаблон работает с любым набором. По умолчанию: оранжевый фон с рамкой.
```
┌─ ⚠️ API Key Generated — Copy It Now! ───────────────────┐
│ │
│ This is the ONLY time the full key will be displayed. │
│ Store it securely (password manager, CI/CD variable).
│ It CANNOT be retrieved later. If lost, you must │
│ revoke this key and generate a new one. │
│ ┌───────────────────────────────────────────────────┐ │
│ │ ssk_M7xqaP2zL9vR4nF8kC1bH5jT6dW0yA3 │ │
│ │ [📋 Copy] │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ Name: Airflow Production ETL │
│ Prefix: ssk_M7xqaP… │
│ Environment: ss-dev │
│ Permissions: maintenance:start, maintenance:end │
│ Created: 2026-05-25 19:45 UTC │
│ Expires: 2027-01-01 │
│ │
│ [I have copied the key — dismiss] │
└───────────────────────────────────────────────────────────┘
```
* **Примеры шаблонов**:
- **По умолчанию**: `<div style="background:#FFF3E0;...">## ⚠️ Технические работы\n\n{message}\n\n**Начало:** {start_time}\n**Конец:** {end_time}\n\n*Данные могут быть неполными.*</div>`
- **Минимальный**: `> ⚠️ Ведутся технические работы. Данные могут быть неполными.` — без переменных, просто статичный текст
- **Тёмная тема**: `<div style="background:#FFEBEE;color:#B71C1C;padding:12px">🚧 **{start_time} {end_time}**{message}</div>`
- **Только время**: `⚠️ Работы: {start_time} — {end_time}` — без message
**UX rules for the reveal panel:**
- Warning text in **red/bold** — user must understand this is one-time
- Key displayed in monospace `<pre>` with a visual "code block" background
- `[📋 Copy]` button copies to clipboard; on click → shows "Copied!" for 1.5s (green checkmark icon)
- `[I have copied the key — dismiss]` — replaces the panel with the updated key list
- Panel CANNOT be reopened — the raw key is gone from memory
- If user closes without copying: key is irrecoverable. They must revoke and regenerate. This is intentional.
## 4. The "Error" Experience
**Step 4: Key appears in the list**
**Philosophy**: API returns maximally detailed responses so the caller can make informed decisions. UI provides explicit recovery paths.
After dismissal, the key list is updated with the new entry (prefix only):
### Scenario A: Table not found in any dashboard
```
Name Prefix Env Perms Status
─────────────────────────────────────────────────────────
Airflow Production ETL ssk_M7xqaP… ss-dev s/e 🟢 ✕ ← NEW
GitLab CI/CD ssk_K9yFbZ… ss-dev s/e/a 🟢 ✕
```
* **User Action (Operator)**: Passes a table name with a typo: `raw.sales_typo`.
* **System Response**: `{"status": "no_match", "affected_dashboards": 0, "message": "No dashboards found referencing tables: raw.sales_typo"}`.
* **Recovery**: Operator corrects the table name and retries.
### 3.2 Revoking an API Key
### Scenario B: Some dashboards no longer exist in Superset
**Step 1: Click `✕` on a key row**
* **User Action (Operator)**: Initiates maintenance, but 3 of 15 dashboards have been deleted from Superset.
* **System Response**: HTTP 207 Multi-Status with `failed_dashboards` field containing id and reason for each problematic dashboard. Remaining 12 dashboards successfully receive banners.
* **Recovery**: Operator reviews failed_dashboards and adjusts the exclusion list if needed.
Triggers a confirmation dialog (native `confirm()` or inline confirmation):
### Scenario C: Superset API network timeout
```
┌─ Revoke API Key? ───────────────────────────────────────┐
│ │
│ Are you sure you want to revoke: │
│ │
│ "Airflow Production ETL" (ssk_M7xqaP…) │
│ │
│ → All external tools using this key will immediately │
│ receive 401 Unauthorized responses. │
│ │
│ → The key entry will be preserved for audit but │
│ marked as INACTIVE. │
│ │
│ [Cancel] [Revoke Key] │
└──────────────────────────────────────────────────────────┘
```
* **User Action (Operator)**: Initiates maintenance, but Superset responds slowly.
* **System Response**: Timeout after 30 seconds. Returns HTTP 207 with `status: "partial"`, listing processed and unprocessed dashboards.
* **Recovery**: Operator can re-call `/maintenance/start` with the same parameters — the system only processes dashboards that don't yet have an active banner.
**Step 2: Key becomes inactive**
### Scenario D: Multiple maintenance events on one dashboard
After confirmation, the row updates:
* **User Action**: Two ETL processes simultaneously update tables affecting dashboard D.
* **System Response**: Dashboard D shows a single markdown chart with aggregated text: "⚠️ Multiple data refresh processes in progress. ..."
* **Recovery**: The banner is only removed when ALL maintenance events for D are completed. Premature `/end` for one event updates the banner text but keeps it visible.
```
Airflow Production ETL ssk_M7xqaP… ss-dev s/e 🔴 · ← REVOKED
```
### Scenario E: Insufficient permissions
- `🔴` = inactive status
- `✕` replaced by `·` (no action — already revoked)
- Row is slightly greyed out (opacity 0.6)
* **User Action**: API call without `operator` or `admin` role.
* **System Response**: HTTP 403 `{"detail": "Insufficient permissions. Required role: operator or admin"}`.
* **Recovery**: Request role assignment from administrator.
### 3.3 State Machine
## 5. Tone & Voice
```
┌──────────────┐
│ IDLE │←────────────────────────────┐
│ (key list) │ │
└──────┬───────┘ │
│ │
[+Generate] │ │
▼ │
┌──────────────┐ submit form ┌─────────┐│
│ FORM │ ────────────────→ │ REVEAL ││
│ (name,env, │ │ ⚠️ once ││
│ perm,expiry) │ └────┬─────┘│
└──────┬───────┘ │ │
│ [Cancel] │ │
└─────────────────────────────────┤ │
│dismiss│
▼ │
┌──────────────┐ click [✕] ┌──────────┐ │
│ IDLE │ ───────────────→ │ CONFIRM │ │
│ (list updated)│ │ "Revoke?" │ │
└──────────────┘ └─────┬─────┘ │
↑ │ │
│ [Cancel] │ │
└─────────────────────────────────┤ │
│[Revoke]│
▼ │
┌──────────────┐ │
│ IDLE │─┘
│ (key.inactive) │
└──────────────┘
```
* **Style**: Technical, concise, informative. No alarmism, but clear indication of possible data issues.
* **Terminology**:
- "Maintenance" (not "downtime" or "outage" — data is still accessible, just potentially stale)
- "Dashboard" (not "report" or "view")
- "Banner" (used in API/UI/internal docs for the markdown chart)
- "Dataset" (not "datasource")
- "Table" (not "source" or "relation")
* **Default banner template**:
```
⚠️ Maintenance in progress
{message}
Start: {start_time_formatted}
End: {end_time_formatted}
Data may be incomplete or temporarily unavailable.
```
If `end_time` is not provided, «End: TBD» (or localized equivalent) is shown.
If `message` is not provided, the first body line is omitted.
* **Dashboard Hub tooltip**: "Maintenance active: {start} — {end}"
### 3.4 Error & Edge Case Handling
| Scenario | UX Response |
|----------|-------------|
| Network failure during generation | Toast error: "Failed to generate API key. Check your connection and try again." Form stays open — retry possible. |
| User closes REVEAL panel without copying | Key is **gone forever**. Admin must revoke (now-inactive key) and regenerate. The warning text in the panel explicitly states this. |
| Double-click on [Revoke] | Second click → 404 from API. UI handles: "Key already revoked" inline message on the row. No dialog. |
| Revoke already-inactive key | UI prevents: no ✕ button on inactive rows (rendered as `·`). |
| Empty name field submission | Client-side validation: "Name is required" red text below the input. [Generate Key] button disabled until filled. |
| No permissions selected | Client-side validation: "Select at least one permission" red text. Button disabled. |
| Key generation API returns 403 | (Admin-only endpoint). If somehow triggered by non-admin: Toast "Forbidden: admin privileges required." |
### 3.5 Component States
| Component | State | Visual |
|-----------|-------|--------|
| Key list | **idle** | Table with rows |
| Key list | **empty** | Dashed border box: "No API keys yet. Generate one for external tool access." + button |
| Key list | **loading** | 3 skeleton rows (`animate-pulse bg-gray-200 h-8 w-full rounded`) |
| Key list | **error** | Toast with error message; list shows stale data |
| Generation form | **idle** | Collapsed — only `[+ Generate New Key]` visible |
| Generation form | **open** | Form fields expanded |
| Generation form | **submitting** | Button shows spinner: `[⠋ Generating...]` |
| Generation form | **error** | Toast with error; form stays open |
| Reveal panel | **visible** | Warning + code block + copy button |
| Reveal panel | **copied** | Copy button shows "✅ Copied!" for 1.5s |
| Revoke dialog | **confirming** | Button shows `[⠋ Revoking...]` |
| Row | **active** | 🟢 green circle + ✕ button |
| Row | **inactive** | 🔴 red circle + `·` dot (no button) |
| Row | **revoking** | 🔄 spinner replacing the ✕ button |
### 3.6 Accessibility
- All buttons have `aria-label` (e.g., `"Revoke Airflow Production ETL key"`)
- Reveal panel uses `role="alert"` for screen reader announcement
- Copy button announces "Copied" via `aria-live="polite"`
- Form fields have associated `<label>` elements
- Confirmation dialog is focus-trapped while open
- Key display uses `<code>` element for screen reader pronunciation of the key string
## 4. Interaction Flows
*(existing sections 4 through end remain unchanged)*
### 4.1 Maintenance Start (External Tool via API Key)
**Using API Key authentication:**
```bash
# 1. Admin generates key once (via Settings → System → API Keys)
# 2. Admin stores key in CI/CD variable: MAINTENANCE_API_KEY=ssk_M7xqa...
# 3. External tool uses X-API-Key header
curl -X POST https://ss-tools.example.com/api/maintenance/start \
-H "X-API-Key: $MAINTENANCE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"tables": ["public.messages"],
"start_time": "2026-05-25T22:00:00Z",
"end_time": "2026-05-26T02:00:00Z",
"environment_id": "ss-dev",
"message": "Scheduled ETL refresh"
}'
# Response: {"task_id":"abc123","maintenance_id":"m-xyz","status":"pending"}
```
**Auth decision flow:**
1. Request arrives with `X-API-Key: ssk_...`
2. System checks `Authorization: Bearer <jwt>` — absent
3. System resolves API key: hash incoming value, lookup in DB by `key_hash`
4. Key found, `active=true`, `expires_at` not past → APIKeyPrincipal created
5. Permissions checked: key has `maintenance:start` → allowed
6. Task dispatched; audit log records `"actor": "api_key: Airflow Production ETL"`
**If auth fails:**
- Invalid key → `401 {"detail":"Invalid API key"}`
- Expired key → `401 {"detail":"API key has expired"}`
- Revoked key → `401 {"detail":"API key has been revoked"}`
- Insufficient permission → `403 {"detail":"API key lacks maintenance:start permission"}`
- Environment mismatch → `400 {"detail":"API key restricted to ss-dev, got ss-prod"}`
### 4.2 Banner Appearance in Superset
*(existing content unchanged — banner rendering details)*
### 4.3 Maintenance End
*(existing content unchanged)*
### 4.4 Dashboard Hub Indicator
*(existing content unchanged)*
### 4.5 Error Recovery
*(existing content unchanged — add API key error path)*
**API Key authentication errors:**
- **401 Invalid key**: External tool should check that `$MAINTENANCE_API_KEY` matches the current key. If key was rotated, update the CI/CD variable.
- **401 Expired key**: Admin must generate a new key (Settings → API Keys → Generate). Update CI/CD variable.
- **403 Permission denied**: Admin must update the key's permissions. Or generate a new key with the required rights.
- **400 Environment mismatch**: The key is scoped to a specific env. Either change the key's environment or use a different key.
## 5. Visual Design
### 5.1 API Key Section (in MaintenanceSettingsPanel)
- Section title: `<details><summary>` collapsible, matching other settings sections
- Key list table: inline Tailwind table (`min-w-full divide-y`), matches EventsTable pattern
- Prefix column: `<code>` element, monospace font (`font-mono text-sm`)
- Status: `<span class="inline-block w-2 h-2 rounded-full">` green (`bg-green-500`) or red (`bg-red-400`)
- Revoke button: `<button class="text-red-500 hover:text-red-700">✕</button>`
- Generate button: `<Button variant="secondary" size="sm">` from `$lib/ui/Button.svelte`
### 5.2 Reveal Panel (Read-Once Key Display)
- Background: yellow-50 border yellow-400 (warning style)
- Warning icon: `⚠️` before title
- Key code block: `<pre class="bg-gray-900 text-green-400 p-4 rounded font-mono text-sm break-all">`
- Copy button: positioned overlapping the code block (top-right corner)
- "Copied!" indicator: absolute positioned, green text, `transition-opacity duration-300`
### 5.3 Revoke Confirmation
- Inline expand panel below the key row (not a modal)
- Red-tinted background (`bg-red-50 border-red-200`)
- Two buttons: `[Cancel]` (grey) and `[Revoke Key]` (red outline)
## 6. Localization
API key UI strings (EN/RU):
| EN | RU |
|----|----|
| API Keys | API-ключи |
| Generate New Key | Создать новый ключ |
| Revoke Key | Отозвать ключ |
| Copy to clipboard | Скопировать |
| Copied! | Скопировано! |
| This is the ONLY time the key will be shown | Ключ показывается ЕДИНСТВЕННЫЙ раз |
| Key has been revoked | Ключ отозван |
| Invalid API key | Неверный API-ключ |
| API key has expired | API-ключ истёк |
| API key lacks {permission} permission | API-ключ не имеет права {permission} |