# Feature Specification: Maintenance Banner for Dashboards

**Feature Branch**: `031-maintenance-banner`  
**Created**: 2026-05-21  
**Status**: Draft  
**Updated**: 2026-05-25 — Added API Key Authentication (US6, FR-018–FR-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: 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, 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)

An ETL operator triggers a table refresh. The external tool (e.g., Airflow DAG) calls an ss-tools API endpoint, passing:
- List of table names being updated (e.g., `["raw.sales", "raw.inventory"]`)
- Maintenance start time
- Planned end time
- (optional) custom message text

ss-tools finds all dashboards in Superset whose datasets reference the given tables (both table-based datasets and virtual/SQL Jinja datasets whose SQL query references the tables). For each affected dashboard, ss-tools adds a markdown chart at the very top with the maintenance warning.

**Why this priority**: This is the core use case — without it, the feature has no value. It enables automatic notification of dashboard users about potentially stale data during ETL.

**Independent Test**: Can be tested by calling the API with a table name known to be used in a test dashboard, then verifying a markdown chart appears at the top of that dashboard in Superset.

**Acceptance Scenarios**:

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).

3. **Given** dashboard D already has a banner from a previous maintenance event, **When** a new `POST /api/maintenance/start` request arrives referencing a different table that also affects D, **Then** the banner is updated (time and text replaced) rather than duplicated.

4. **Given** table `raw.archived` is not used in any dashboard, **When** external tool passes `{"tables": ["raw.archived"]}`, **Then** API returns `{task_id, maintenance_id, status: "pending"}` immediately. Upon task completion, the task result contains `{status: "no_match", affected_dashboards: 0, unmatched_tables: ["raw.archived"]}`. No dashboard is modified.

5. **Given** an active MaintenanceEvent E already exists with tables `["raw.sales"]`, **When** external tool calls `/start` again with the same tables, **Then** API returns `{maintenance_id: E.id, status: "already_active"}` without creating a duplicate event.

6. **Given** external tool does not know the end time, **When** it calls `/start` with `{"tables": ["raw.sales"], "start_time": "2026-05-21T22:00:00Z"}` (no end_time), **Then** API returns `202 {task_id}`, and the banner shows «Окончание: уточняется».

---

### User Story 2 — External tool removes maintenance banner upon completion (Priority: P1)

The ETL process completes. The external tool calls an ss-tools API endpoint to remove banners. ss-tools removes markdown charts from all dashboards affected by the given maintenance event.

**Why this priority**: Critical for restoring normal dashboard appearance after maintenance. Users must not see stale warnings.

**Independent Test**: After US1, call `POST /api/maintenance/{id}/end` and verify charts disappear from dashboards.

**Acceptance Scenarios**:

1. **Given** maintenance event M added banners to dashboards D1, D2 (no other active events affect D1 or D2), **When** external tool calls `POST /api/maintenance/{maintenance_id}/end`, **Then** banners are removed from D1 and D2, and maintenance event M is marked as completed.

2. **Given** maintenance event M is already completed, **When** external tool calls `POST /api/maintenance/{maintenance_id}/end` again, **Then** API returns `{"status": "already_completed", "message": "Maintenance M was already completed at ..."}` (idempotent).

3. **Given** dashboard D is affected by active events M1 and M2, **When** external tool calls `/end` on M1, **Then** the banner on D is NOT removed — instead, the banner text is rebuilt to show only M2's information. M1 is marked as completed. The banner is fully removed only when M2 also ends.

---

### User Story 3 — Administrator manages banners via ss-tools UI (Priority: P2)

An administrator opens the ss-tools web interface, sees a list of active maintenance events and affected dashboards. They can selectively or bulk-remove banners without waiting for the external tool.

**Why this priority**: Administrators need manual override capability in case of ETL tool failure or emergency banner removal.

**Independent Test**: Navigate to maintenance management page, view active events, click "Remove All" and verify banners disappear.

**Acceptance Scenarios**:

1. **Given** active maintenance events M1 (affects D1, D2) and M2 (affects D3), **When** administrator opens the "Maintenance Banners" page, **Then** they see a table with M1, M2, affected dashboard counts, start/end times, and action buttons.

2. **Given** administrator is on the management page, **When** they select M1 and click "Remove Banners", **Then** markdown charts are removed from D1, D2, and M1 status changes to "Completed".

3. **Given** administrator is on the management page, **When** they click "Remove All", **Then** banners are removed from all dashboards across all active maintenance events.

---

### User Story 4 — Maintenance mode scope configuration (Priority: P3)

Administrator configures which dashboards are affected by automatic banner placement:
- Published only, drafts only, or both
- List of dashboards excluded from maintenance mode (hard ban — banner is never added)
- List of dashboards forcibly included (banner always added, even if no table match is found)

**Why this priority**: Configuration flexibility allows adaptation to specific processes, but the system works with sensible defaults.

**Independent Test**: Configure exclusion of dashboard D1, then trigger maintenance on a table used in D1 — banner must NOT appear on D1, but must appear on D2.

**Acceptance Scenarios**:

1. **Given** default settings (scope = `published_only`, excluded = `[]`, forced = `[]`), **When** maintenance affects draft dashboard D, **Then** banner is NOT added to D.

2. **Given** administrator added dashboard D to the excluded list, **When** maintenance affects a table used in D, **Then** banner is NOT added to D, and logs record the reason: "Dashboard D is in exclusion list".

3. **Given** administrator added dashboard D to the forced list, **When** maintenance does NOT affect any table in D, **Then** banner IS STILL added to D (forced inclusion).

4. **Given** scope = `all` (published + draft), **When** maintenance affects tables in D1 (published) and D2 (draft), **Then** banner is added to BOTH dashboards.

---

### User Story 5 — Maintenance status indicator in dashboard grid (Priority: P3)

The Dashboard Hub table shows an indicator when a dashboard has an active maintenance banner. Users can see this information without opening Superset.

**Why this priority**: Improves operator UX but does not block core functionality.

**Independent Test**: After US1, the dashboard grid shows a maintenance badge in the status column.

**Acceptance Scenarios**:

1. **Given** dashboard D has an active maintenance banner, **When** a user opens the Dashboard Hub, **Then** dashboard D's row shows an orange badge "⚠️ Maintenance" with a tooltip showing start and end times.

2. **Given** the maintenance banner is removed from dashboard D, **When** the user refreshes the Dashboard Hub page, **Then** the badge disappears.

---

### 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.

- **Dashboard with no datasets** (empty dashboard): Must not receive a banner, even if tables are listed in the maintenance event.

- **Table referenced in multiple datasets within one dashboard**: Banner is added only once per dashboard, regardless of how many dataset references the table has within it.

- **Network error contacting Superset API**: If some dashboards are processed successfully and others fail due to Superset errors, the API returns a partial success response listing the problematic dashboards.

- **Very large table list** (100+): API must process Superset queries in batches and return progress or a warning about extended processing time.

- **Dashboard deleted from Superset between discovery and banner placement**: Handle 404 from Superset API, log the event, and continue processing the rest.

- **Virtual dataset with Jinja and SQL**: Table names may appear both in plain SQL (FROM/JOIN) and inside Jinja `{% set %}` blocks (e.g., dictionary mapping dataset types to table names). Extraction uses global pattern matching on raw text (not Jinja-stripping first) to capture ALL `schema.table` occurrences, then sqlparse filters out false positives inside SQL string literals. Unqualified table references are not matched.

- **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, 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).

- **Time zones**: Maintenance start and end times must be correctly displayed in the banner text respecting timezone (accept ISO 8601 with timezone, display in local timezone or UTC as configured).

- **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`), 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. 

  **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`).
  - Virtual/SQL (`is_sqllab_view = true`): extract ALL fully-qualified `schema.table` references from the raw SQL+Jinja text using global pattern matching (regex), then filter false positives via sqlparse tokenizer. This captures table names in FROM/JOIN clauses, Jinja `{% set %}` blocks, CTE bodies, and subqueries. Jinja is NOT pre-stripped — table names inside `{% set %}` blocks are preserved. Unqualified references are NOT matched.

- **FR-004**: System MUST add a markdown chart (Superset `viz_type: "markdown"`) at the top of each affected dashboard with maintenance information.

- **FR-005**: The banner text is rendered from `banner_template` (configured in MaintenanceSettings). For a single active event, variables `{start_time}`, `{end_time}`, `{message}` are directly substituted. For multiple active events, aggregated text is built by rendering each event's info as a sub-block (e.g., "Event N: {start} – {end}"), and the combined text is substituted as `{message}`. Variables not present in the template are replaced with an empty string. The `message` field from the API request is plain text — HTML special characters are escaped before substitution.

- **FR-006**: System MUST provide API endpoint `POST /api/maintenance/{maintenance_id}/end` for removing banners from dashboards affected by a specific maintenance event. Returns `{task_id}` immediately; removal runs asynchronously.

- **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, 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 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"`)
  - `excluded_dashboard_ids`: `int[]` — dashboards NEVER receiving banners
  - `forced_dashboard_ids`: `int[]` — dashboards ALWAYS receiving banners (overrides excluded per FR-009-b)

- **FR-009-b**: When a dashboard is present in BOTH `forced_dashboard_ids` and `excluded_dashboard_ids`, forced inclusion takes priority — the banner IS placed.

- **FR-010**: System MUST provide a UI management page for maintenance mode with:
  - Table of active and completed maintenance events
  - Selective and bulk banner removal
  - Settings configuration form (scope, excluded, forced)

- **FR-011**: System MUST display an active banner indicator in the Dashboard Hub table as a badge with a tooltip.

- **FR-012**: System MUST handle multiple maintenance events on the same dashboard: aggregate information and remove the banner only after ALL affecting events are completed.

- **FR-013**: System MUST, on partial success, return a detailed response listing successfully processed and problematic dashboards.

- **FR-014**: All dashboard-modifying operations via Superset API MUST require the `operator` or `admin` role (per RBAC ADR-0005).

- **FR-015**: System MUST enforce RBAC per this matrix:

| 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), 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.

- **MaintenanceDashboardState**: Links a maintenance event to a dashboard. Contains: id, maintenance_event_id, dashboard_id, banner_id (FK → MaintenanceDashboardBanner), status (pending_apply/active/removing/removed/apply_failed/removal_failed), created_at, updated_at. Does NOT own chart_id directly — references the shared MaintenanceDashboardBanner.

- **MaintenanceSettings**: Maintenance mode configuration. Contains: id, target_environment_id (str), display_timezone (str, default "UTC"), banner_template (str — markdown with optional `{start_time}`, `{end_time}`, `{message}` variables; default has all three), dashboard_scope (enum), excluded_dashboard_ids (int[]), forced_dashboard_ids (int[]), updated_at.

- **Dashboard** (existing Superset entity): Used for the dashboard → datasets → tables relationship. Has `published`/`draft` status.

- **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

- **SC-001**: `POST /api/maintenance/start` returns `{task_id}` within 1 second (immediate async dispatch). The background task finds all affected dashboards (up to 50) and places banners within 60 seconds.

- **SC-002**: After task completion, the banner appears on each dashboard in Superset within the same 60-second window (including markdown chart creation via Superset API).

- **SC-003**: `POST /api/maintenance/{id}/end` returns `{task_id}` within 1 second; the background task removes banners from all affected dashboards (up to 50) within 30 seconds.

- **SC-004**: SQL table name extraction from virtual datasets correctly identifies all fully-qualified `schema.table` references embedded in Jinja `{% set %}` blocks, plain SQL FROM/JOIN, CTE bodies, and subqueries, while filtering out string-literal false positives (date literals like `'2026.04.30'`) — with ≥ 95% accuracy on a test corpus of production virtual dataset SQL+Jinja samples.

- **SC-005**: Administrator can remove banners from all dashboards via UI in fewer than 3 clicks.

- **SC-006**: Conflicting maintenance events on the same dashboard do not produce duplicate banners — exactly one markdown chart per dashboard at all times.

- **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.
