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.
96 lines
4.8 KiB
Markdown
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 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. |