Spaces:
Sleeping
Sleeping
| """ | |
| api/auth.py — API key authentication and role-based authorization. | |
| Roles: | |
| admin — all permissions | |
| compliance_lead — query, view_results, manage_alerts | |
| reader — query, view_results | |
| auditor — view_audit only | |
| API keys are loaded from the env var COMPLIANCE_API_KEYS in the format: | |
| key1:role1:tenant1,key2:role2:tenant2 | |
| Default (for local dev) is set below. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import os | |
| from dataclasses import dataclass | |
| from typing import Optional | |
| from fastapi import HTTPException, status | |
| from fastapi.security import APIKeyHeader | |
| log = logging.getLogger(__name__) | |
| # ─── Security scheme ────────────────────────────────────────────────────────── | |
| API_KEY_HEADER = APIKeyHeader(name="X-API-Key", auto_error=False) | |
| # ─── Role definitions ───────────────────────────────────────────────────────── | |
| ROLE_PERMISSIONS: dict[str, set[str]] = { | |
| "admin": {"query", "view_results", "manage_alerts", "view_audit", "admin"}, | |
| "compliance_lead": {"query", "view_results", "manage_alerts"}, | |
| "reader": {"query", "view_results"}, | |
| "auditor": {"view_audit"}, | |
| } | |
| VALID_ROLES = set(ROLE_PERMISSIONS.keys()) | |
| # ─── User model ─────────────────────────────────────────────────────────────── | |
| class User: | |
| api_key: str | |
| role: str | |
| tenant_id: str | |
| def permissions(self) -> set[str]: | |
| return ROLE_PERMISSIONS.get(self.role, set()) | |
| def has_permission(self, perm: str) -> bool: | |
| return perm in self.permissions | |
| # ─── API key store ──────────────────────────────────────────────────────────── | |
| _DEFAULT_KEYS = ( | |
| "admin-key-001:admin:tenant-default," | |
| "reader-key-001:reader:tenant-default" | |
| ) | |
| # Mutable runtime dict — admin can add keys at runtime via POST /admin/api-keys | |
| _API_KEY_STORE: dict[str, tuple[str, str]] = {} # key -> (role, tenant_id) | |
| def _load_api_keys() -> None: | |
| """Parse COMPLIANCE_API_KEYS env var and populate _API_KEY_STORE.""" | |
| raw = os.environ.get("COMPLIANCE_API_KEYS", _DEFAULT_KEYS) | |
| for entry in raw.split(","): | |
| entry = entry.strip() | |
| if not entry: | |
| continue | |
| parts = entry.split(":") | |
| if len(parts) != 3: | |
| log.warning("Skipping malformed API key entry (expected key:role:tenant): %r", entry) | |
| continue | |
| key, role, tenant = parts[0].strip(), parts[1].strip(), parts[2].strip() | |
| if role not in VALID_ROLES: | |
| log.warning("Skipping API key with unknown role %r", role) | |
| continue | |
| _API_KEY_STORE[key] = (role, tenant) | |
| log.info("Loaded %d API keys", len(_API_KEY_STORE)) | |
| # Load on module import | |
| _load_api_keys() | |
| def add_api_key(key: str, role: str, tenant_id: str) -> None: | |
| """Add or update an API key at runtime (admin action).""" | |
| if role not in VALID_ROLES: | |
| raise ValueError(f"Unknown role {role!r}. Valid roles: {sorted(VALID_ROLES)}") | |
| _API_KEY_STORE[key] = (role, tenant_id) | |
| log.info("API key added/updated: role=%s tenant=%s", role, tenant_id) | |
| def list_api_keys() -> list[dict]: | |
| """Return sanitized list of keys (masked) for admin inspection.""" | |
| result = [] | |
| for key, (role, tenant) in _API_KEY_STORE.items(): | |
| masked = key[:4] + "****" + key[-4:] if len(key) >= 8 else "****" | |
| result.append({"key_preview": masked, "role": role, "tenant_id": tenant}) | |
| return result | |
| # ─── FastAPI dependency ─────────────────────────────────────────────────────── | |
| def get_current_user(api_key: Optional[str] = None) -> User: | |
| """ | |
| Validate X-API-Key header against _API_KEY_STORE. | |
| Call this with Security(get_current_user) where api_key comes from | |
| API_KEY_HEADER, or pass directly for testing. | |
| """ | |
| if not api_key: | |
| raise HTTPException( | |
| status_code=status.HTTP_401_UNAUTHORIZED, | |
| detail="Missing X-API-Key header", | |
| ) | |
| entry = _API_KEY_STORE.get(api_key) | |
| if entry is None: | |
| raise HTTPException( | |
| status_code=status.HTTP_403_FORBIDDEN, | |
| detail="Invalid API key", | |
| ) | |
| role, tenant_id = entry | |
| return User(api_key=api_key, role=role, tenant_id=tenant_id) | |
| def require_role(*roles: str): | |
| """ | |
| FastAPI dependency factory — raises 403 if the user's role is not in roles. | |
| Usage: | |
| @app.get("/admin/...", dependencies=[Depends(require_role("admin"))]) | |
| """ | |
| from fastapi import Depends, Security | |
| def _dependency(api_key: Optional[str] = Security(API_KEY_HEADER)) -> User: | |
| user = get_current_user(api_key) | |
| if user.role not in roles: | |
| raise HTTPException( | |
| status_code=status.HTTP_403_FORBIDDEN, | |
| detail=f"Role '{user.role}' is not authorized. Required: {list(roles)}", | |
| ) | |
| return user | |
| return Depends(_dependency) | |
| def require_permission(permission: str): | |
| """ | |
| FastAPI dependency factory — raises 403 if the user lacks the permission. | |
| Usage: | |
| @app.get("/audit", dependencies=[Depends(require_permission("view_audit"))]) | |
| """ | |
| from fastapi import Depends, Security | |
| def _dependency(api_key: Optional[str] = Security(API_KEY_HEADER)) -> User: | |
| user = get_current_user(api_key) | |
| if not user.has_permission(permission): | |
| raise HTTPException( | |
| status_code=status.HTTP_403_FORBIDDEN, | |
| detail=f"Permission '{permission}' required. Your role '{user.role}' does not have it.", | |
| ) | |
| return user | |
| return Depends(_dependency) | |