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