## 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_)`. 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.