Files
ss-tools/specs/031-maintenance-banner/data-model-api-key.md
busya ec6421de35 rename ss-tools to superset-tools across the entire project
- Replace all occurrences of 'ss-tools' with 'superset-tools' in 104 files
- Rename git bundle file ss-tools.bundle → superset-tools.bundle
- Update .gitignore pattern accordingly
- Preserve variable names (hasSsTools etc.) and code identifiers
2026-06-16 11:15:19 +03:00

96 lines
4.8 KiB
Markdown

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