feat(enterprise): Phase 14 — API Key Management + SSO/SAML 2.0
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
- ApiKey model (SHA-256 hash, prefix, scopes, expiry) + Alembic migration (b040ent) - SsoConfig model for SAML 2.0 IdP settings (attribute mapping, auto-provision) - API key auth integrated into get_current_user (aegis_ prefix detection) - Routers: /api/v1/api-keys (full CRUD + revoke) and /api/v1/sso (metadata, login, callback, config) - python3-saml added to requirements; Dockerfile adds libxmlsec1-dev for SAML XML signing - QA script: 52 assertions covering key lifecycle, API key auth, SSO config Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
151
backend/app/services/api_key_service.py
Normal file
151
backend/app/services/api_key_service.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Phase 14: API Key service — create, list, revoke, authenticate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.domain.errors import EntityNotFoundError, DuplicateEntityError
|
||||
from app.models.api_key import ApiKey, generate_raw_key, hash_key, key_prefix_display
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
# ── Create ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def create_api_key(
|
||||
db: Session,
|
||||
user_id: UUID,
|
||||
name: str,
|
||||
scopes: List[str],
|
||||
description: Optional[str] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
) -> tuple[ApiKey, str]:
|
||||
"""
|
||||
Create a new API key.
|
||||
|
||||
Returns ``(ApiKey, raw_key)`` — the raw_key must be shown to the user
|
||||
immediately and is never retrievable again.
|
||||
"""
|
||||
raw_key = generate_raw_key()
|
||||
key_hash = hash_key(raw_key)
|
||||
prefix = key_prefix_display(raw_key)
|
||||
|
||||
# Detect accidental collision (astronomically unlikely)
|
||||
if db.query(ApiKey).filter(ApiKey.key_hash == key_hash).first():
|
||||
raise DuplicateEntityError("ApiKey", "key_hash", "<collision>")
|
||||
|
||||
key = ApiKey(
|
||||
name = name,
|
||||
description = description,
|
||||
key_prefix = prefix,
|
||||
key_hash = key_hash,
|
||||
user_id = user_id,
|
||||
scopes = scopes,
|
||||
expires_at = expires_at,
|
||||
)
|
||||
db.add(key)
|
||||
db.commit()
|
||||
db.refresh(key)
|
||||
return key, raw_key
|
||||
|
||||
|
||||
# ── Read ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def list_api_keys(
|
||||
db: Session,
|
||||
user_id: Optional[UUID] = None,
|
||||
include_inactive: bool = False,
|
||||
) -> List[ApiKey]:
|
||||
q = db.query(ApiKey)
|
||||
if user_id is not None:
|
||||
q = q.filter(ApiKey.user_id == user_id)
|
||||
if not include_inactive:
|
||||
q = q.filter(ApiKey.is_active == True)
|
||||
return q.order_by(ApiKey.created_at.desc()).all()
|
||||
|
||||
|
||||
def get_api_key(db: Session, key_id: UUID, user_id: Optional[UUID] = None) -> ApiKey:
|
||||
q = db.query(ApiKey).filter(ApiKey.id == key_id)
|
||||
if user_id is not None:
|
||||
q = q.filter(ApiKey.user_id == user_id)
|
||||
key = q.first()
|
||||
if not key:
|
||||
raise EntityNotFoundError("ApiKey", str(key_id))
|
||||
return key
|
||||
|
||||
|
||||
# ── Update / Revoke ───────────────────────────────────────────────────────────
|
||||
|
||||
def update_api_key(
|
||||
db: Session,
|
||||
key_id: UUID,
|
||||
user_id: Optional[UUID] = None,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
scopes: Optional[List[str]] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
) -> ApiKey:
|
||||
key = get_api_key(db, key_id, user_id)
|
||||
if name is not None:
|
||||
key.name = name
|
||||
if description is not None:
|
||||
key.description = description
|
||||
if scopes is not None:
|
||||
key.scopes = scopes
|
||||
if expires_at is not None:
|
||||
key.expires_at = expires_at
|
||||
if is_active is not None:
|
||||
key.is_active = is_active
|
||||
db.commit()
|
||||
db.refresh(key)
|
||||
return key
|
||||
|
||||
|
||||
def revoke_api_key(
|
||||
db: Session,
|
||||
key_id: UUID,
|
||||
user_id: Optional[UUID] = None,
|
||||
) -> ApiKey:
|
||||
"""Soft-revoke: set is_active = False."""
|
||||
return update_api_key(db, key_id, user_id, is_active=False)
|
||||
|
||||
|
||||
def delete_api_key(db: Session, key_id: UUID, user_id: Optional[UUID] = None) -> None:
|
||||
"""Hard delete — use revoke instead for audit trail."""
|
||||
key = get_api_key(db, key_id, user_id)
|
||||
db.delete(key)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ── Authentication ────────────────────────────────────────────────────────────
|
||||
|
||||
def authenticate_raw_key(db: Session, raw_key: str) -> Optional[User]:
|
||||
"""
|
||||
Verify a raw API key.
|
||||
|
||||
Returns the owning User if the key is valid, active, and not expired.
|
||||
Updates ``last_used_at`` (throttled to once per request — always updates).
|
||||
Returns None on any failure.
|
||||
"""
|
||||
h = hash_key(raw_key)
|
||||
key: Optional[ApiKey] = db.query(ApiKey).filter(ApiKey.key_hash == h).first()
|
||||
|
||||
if key is None or not key.is_active:
|
||||
return None
|
||||
if key.expires_at and key.expires_at < datetime.utcnow():
|
||||
return None
|
||||
|
||||
# Update last_used_at
|
||||
key.last_used_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
user: Optional[User] = db.query(User).filter(User.id == key.user_id).first()
|
||||
if user is None or not user.is_active:
|
||||
return None
|
||||
|
||||
return user
|
||||
Reference in New Issue
Block a user