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

4.8 KiB

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 secretPOST /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)

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.