feat(auth): implement API key authentication and management

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.
This commit is contained in:
2026-05-25 11:35:27 +03:00
parent 31680a1bc9
commit 320f82ab95
39 changed files with 3359 additions and 707 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -18,6 +18,7 @@
# @DATA_CONTRACT: Package -> RouterModule mapping
__all__ = [
"admin",
"admin_api_keys",
"assistant",
"clean_release",
"clean_release_v2",

View File

@@ -0,0 +1,185 @@
# #region AdminApiKeyRoutes [C:3] [TYPE Module] [SEMANTICS fastapi, admin, api_key, crud]
# @BRIEF Admin API endpoints for API key management — list, generate (one-time reveal), and revoke.
# @LAYER API
# @RELATION DEPENDS_ON -> [APIKeyModel]
# @RELATION DEPENDS_ON -> [APIKeyUtilities]
# @RELATION DEPENDS_ON -> [has_permission("admin:settings", "WRITE")]
# @INVARIANT GET /api/admin/api-keys NEVER returns key_hash or raw_key.
# @INVARIANT POST /api/admin/api-keys returns raw_key ONCE — never stored, never retrievable again.
# @INVARIANT DELETE /api/admin/api-keys/{id} soft-deletes (active=False), preserves row for audit.
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from ...core.auth.api_key import generate_api_key
from ...core.database import get_db
from ...dependencies import has_permission
from ...models.api_key import APIKey
# #region router [TYPE Variable]
# @BRIEF APIRouter for admin API key management routes.
router = APIRouter(prefix="/api/admin/api-keys", tags=["admin", "api-keys"])
# #endregion router
# ── Pydantic schemas ──────────────────────────────────────────
# #region ApiKeyCreateRequest [C:1] [TYPE Class]
class ApiKeyCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
environment_id: str | None = None
permissions: list[str] = Field(..., min_length=1)
expires_at: datetime | None = None
# #endregion ApiKeyCreateRequest
# #region ApiKeyCreateResponse [C:1] [TYPE Class]
class ApiKeyCreateResponse(BaseModel):
id: str
raw_key: str
prefix: str
name: str
environment_id: str | None
permissions: list[str]
active: bool
created_at: datetime
expires_at: datetime | None
# #endregion ApiKeyCreateResponse
# #region ApiKeyListItem [C:1] [TYPE Class]
class ApiKeyListItem(BaseModel):
id: str
name: str
prefix: str
environment_id: str | None
permissions: list[str]
active: bool
created_at: datetime
expires_at: datetime | None
last_used_at: datetime | None
model_config = {"from_attributes": True}
# #endregion ApiKeyListItem
# #region ApiKeyRevokeResponse [C:1] [TYPE Class]
class ApiKeyRevokeResponse(BaseModel):
id: str
status: str
# #endregion ApiKeyRevokeResponse
# ── Routes ────────────────────────────────────────────────────
# #region list_api_keys [C:2] [TYPE Function]
# @BRIEF List all API keys — NEVER returns key_hash or raw_key.
# @PRE Requires admin:settings WRITE permission.
# @POST Returns list of ApiKeyListItem without sensitive fields.
@router.get("/", response_model=list[ApiKeyListItem])
async def list_api_keys(
db: Session = Depends(get_db),
_=Depends(has_permission("admin:settings", "WRITE")),
):
keys = db.query(APIKey).order_by(APIKey.created_at.desc()).all()
return [
ApiKeyListItem(
id=k.id,
name=k.name,
prefix=k.prefix,
environment_id=k.environment_id,
permissions=list(k.permissions or []),
active=k.active,
created_at=k.created_at,
expires_at=k.expires_at,
last_used_at=k.last_used_at,
)
for k in keys
]
# #endregion list_api_keys
# #region create_api_key [C:3] [TYPE Function]
# @BRIEF Generate a new API key — returns raw key ONCE, never stored or retrievable again.
# @PRE Requires admin:settings WRITE permission. name is required, at least one permission.
# @POST Creates APIKey row with SHA-256 hash. Returns raw key in response.
# @SIDE_EFFECT Generates cryptographically random key, stores hash in DB.
# @RELATION DEPENDS_ON -> [generate_api_key]
@router.post("/", response_model=ApiKeyCreateResponse, status_code=status.HTTP_201_CREATED)
async def create_api_key(
request: ApiKeyCreateRequest,
db: Session = Depends(get_db),
_=Depends(has_permission("admin:settings", "WRITE")),
):
# Validate
if not request.name.strip():
raise HTTPException(status_code=400, detail="Name is required")
if not request.permissions:
raise HTTPException(status_code=400, detail="At least one permission is required")
# Generate key
raw_key, prefix, key_hash = generate_api_key()
# Store hash only
api_key = APIKey(
key_hash=key_hash,
prefix=prefix,
name=request.name.strip(),
environment_id=request.environment_id,
permissions=request.permissions,
active=True,
expires_at=request.expires_at,
)
db.add(api_key)
db.commit()
db.refresh(api_key)
return ApiKeyCreateResponse(
id=api_key.id,
raw_key=raw_key,
prefix=api_key.prefix,
name=api_key.name,
environment_id=api_key.environment_id,
permissions=list(api_key.permissions or []),
active=api_key.active,
created_at=api_key.created_at,
expires_at=api_key.expires_at,
)
# #endregion create_api_key
# #region revoke_api_key [C:2] [TYPE Function]
# @BRIEF Revoke an API key by setting active=False. Preserves row for audit.
# @PRE Requires admin:settings WRITE permission.
# @POST Sets active=False on the key. Returns 404 if already revoked or not found.
@router.delete("/{key_id}", response_model=ApiKeyRevokeResponse)
async def revoke_api_key(
key_id: str,
db: Session = Depends(get_db),
_=Depends(has_permission("admin:settings", "WRITE")),
):
api_key = db.query(APIKey).filter(APIKey.id == key_id).first()
if not api_key:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="API key not found",
)
if not api_key.active:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="API key is already revoked",
)
api_key.active = False
db.commit()
return ApiKeyRevokeResponse(
id=api_key.id,
status="revoked",
)
# #endregion revoke_api_key
# #endregion AdminApiKeyRoutes

View File

@@ -11,14 +11,21 @@
from datetime import datetime, timezone
from typing import Any, cast
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from ....core.logger import belief_scope, logger as app_logger
from ....core.task_manager import TaskManager
app_logger = cast(Any, app_logger)
from ....dependencies import get_current_user, get_db, get_task_manager, has_permission
from ....dependencies import (
check_api_key_environment_scope,
get_db,
get_task_manager,
has_permission,
require_api_key_or_jwt,
)
from ....models.api_key import APIKey
from ....models.maintenance import (
MaintenanceDashboardBanner,
MaintenanceDashboardBannerStatus,
@@ -232,8 +239,7 @@ async def list_events(
async def start_maintenance(
request: MaintenanceStartRequest,
db: Session = Depends(get_db),
user=Depends(get_current_user),
_=Depends(has_permission("maintenance", "WRITE")),
_auth=Depends(require_api_key_or_jwt("maintenance:start", "maintenance", "WRITE")),
task_manager: TaskManager = Depends(get_task_manager),
):
with belief_scope("start_maintenance"):
@@ -313,11 +319,8 @@ async def start_maintenance(
status="already_active",
)
# ── Load settings for environment ──
settings = db.query(MaintenanceSettings).filter(
MaintenanceSettings.id == "default"
).first()
env_id = settings.target_environment_id if settings else ""
# ── Environment scoping ──
env_id = request.environment_id
# ── Create event ──
event = MaintenanceEvent(
@@ -338,7 +341,7 @@ async def start_maintenance(
"operation": "start",
"event_id": event.id,
"environment_id": env_id,
"user": getattr(user, "username", "system"),
"user": _auth.split(":", 1)[1] if ":" in _auth else _auth,
},
)
event.task_id = task.id
@@ -372,9 +375,9 @@ async def start_maintenance(
)
async def end_maintenance(
maintenance_id: str,
request: Request,
db: Session = Depends(get_db),
user=Depends(get_current_user),
_=Depends(has_permission("maintenance", "WRITE")),
_auth=Depends(require_api_key_or_jwt("maintenance:end", "maintenance", "WRITE")),
task_manager: TaskManager = Depends(get_task_manager),
):
with belief_scope("end_maintenance", f"maintenance_id={maintenance_id}"):
@@ -389,6 +392,10 @@ async def end_maintenance(
detail=f"Maintenance event {maintenance_id} not found",
)
# Validate API key environment scope against event's environment
if _auth.startswith("api_key:"):
await check_api_key_environment_scope(request, db, event.environment_id or "")
# If already completed, return idempotent success
if event.status == MaintenanceEventStatus.COMPLETED:
app_logger.reflect(
@@ -407,7 +414,7 @@ async def end_maintenance(
"operation": "end",
"event_id": maintenance_id,
"environment_id": event.environment_id,
"user": getattr(user, "username", "system"),
"user": _auth.split(":", 1)[1] if ":" in _auth else _auth,
},
)
@@ -429,7 +436,7 @@ async def end_maintenance(
# #region end_all_maintenance [C:3] [TYPE Function]
# @BRIEF End all active maintenance events. Removes all banners from all dashboards.
# Always returns 202 with task_id.
# Always returns 202 with task_id. Supports environment scoping for API keys.
# @RELATION DEPENDS_ON -> [has_permission("maintenance", "WRITE")]
@router.post(
"/end-all",
@@ -437,24 +444,47 @@ async def end_maintenance(
response_model=MaintenanceEndResponse,
)
async def end_all_maintenance(
request: Request,
db: Session = Depends(get_db),
user=Depends(get_current_user),
_=Depends(has_permission("maintenance", "WRITE")),
_auth=Depends(require_api_key_or_jwt("maintenance:end_all", "maintenance", "WRITE")),
task_manager: TaskManager = Depends(get_task_manager),
):
with belief_scope("end_all_maintenance"):
# ── Resolve environment scope ──
effective_env_id: str | None = None
try:
body = await request.json()
effective_env_id = body.get("environment_id")
except Exception:
pass
# ── Enforce API key environment scoping ──
if _auth.startswith("api_key:"):
# Auto-scope to the key's environment if not specified in body
if not effective_env_id:
raw_key = request.headers.get("X-API-Key")
if raw_key:
from ....core.auth.api_key import hash_api_key
key_hash = hash_api_key(raw_key)
key_row = db.query(APIKey).filter(APIKey.key_hash == key_hash).first()
if key_row and key_row.environment_id:
effective_env_id = key_row.environment_id
await check_api_key_environment_scope(request, db, effective_env_id or "")
# ── Dispatch TaskManager task ──
task = await task_manager.create_task(
plugin_id="maintenance_banner_apply",
params={
"operation": "end_all",
"user": getattr(user, "username", "system"),
"environment_id": effective_env_id or "",
"user": _auth.split(":", 1)[1] if ":" in _auth else _auth,
},
)
app_logger.reflect(
"End-all maintenance dispatched",
extra={"task_id": task.id},
extra={"task_id": task.id, "environment_id": effective_env_id},
)
return MaintenanceEndResponse(

View File

@@ -12,12 +12,13 @@ from pydantic import BaseModel, Field
# ── Request Schemas ──────────────────────────────────────────
# #region MaintenanceStartRequest [C:1] [TYPE Class]
# @BRIEF POST /api/maintenance/start request body.
# @BRIEF POST /api/maintenance/start request body with environment scoping.
class MaintenanceStartRequest(BaseModel):
tables: list[str] = Field(..., min_length=1, max_length=100)
start_time: datetime
end_time: datetime | None = None
message: str | None = Field(None, max_length=500)
environment_id: str = Field(..., description="Target environment for the maintenance operation")
# #endregion MaintenanceStartRequest

View File

@@ -25,6 +25,7 @@ from starlette.middleware.sessions import SessionMiddleware
from .api import auth
from .api.routes import (
admin,
admin_api_keys,
assistant,
clean_release,
clean_release_v2,
@@ -224,6 +225,7 @@ app.add_middleware(TraceContextMiddleware)
# Include API routes
app.include_router(auth.router)
app.include_router(admin.router)
app.include_router(admin_api_keys.router)
app.include_router(plugins.router, prefix="/api/plugins", tags=["Plugins"])
app.include_router(tasks.router, prefix="/api/tasks", tags=["Tasks"])
app.include_router(settings.router, prefix="/api/settings", tags=["Settings"])

View File

@@ -0,0 +1,53 @@
# #region APIKeyUtilities [C:2] [TYPE Module] [SEMANTICS auth, api_key, crypto, generation]
# @BRIEF API key generation and hashing utilities for service-to-service authentication.
# @LAYER Core
# @RELATION DEPENDS_ON -> [hashlib]
# @RELATION DEPENDS_ON -> [secrets]
# @INVARIANT generate_api_key() always returns (raw_key, prefix, key_hash) where prefix is "ssk_" + 7 chars.
# @INVARIANT hash_api_key() produces SHA-256 hex digest for lookup and storage.
import hashlib
import secrets
# #region generate_api_key [C:2] [TYPE Function]
# @BRIEF Generate a new API key in ssk_ format with SHA-256 hash.
# @POST Returns (raw_key, prefix, key_hash) — raw_key shown ONCE to caller, never stored.
# @SIDE_EFFECT Uses secrets.token_urlsafe(32) for cryptographic randomness.
# @RELATION DEPENDS_ON -> [secrets.token_urlsafe]
# @RELATION DEPENDS_ON -> [hashlib.sha256]
def generate_api_key() -> tuple[str, str, str]:
"""Generate a new API key.
Returns:
tuple[str, str, str]: (raw_key, prefix, key_hash)
- raw_key: Full key string starting with "ssk_", shown once
- prefix: First 11 characters ("ssk_" + 7 chars), stored for identification
- key_hash: SHA-256 hex digest, stored in database
"""
raw = f"ssk_{secrets.token_urlsafe(32)}" # 32 bytes -> 43 base64 chars
prefix = raw[:11] # "ssk_" + 7 chars
key_hash = hashlib.sha256(raw.encode()).hexdigest()
return raw, prefix, key_hash
# #endregion generate_api_key
# #region hash_api_key [C:1] [TYPE Function]
# @BRIEF Hash an API key string to SHA-256 hex digest for lookup.
# @PRE raw_key is a non-empty string.
# @POST Returns 64-character hex digest.
# @RELATION DEPENDS_ON -> [hashlib.sha256]
def hash_api_key(raw_key: str) -> str:
"""Hash an API key using SHA-256.
Args:
raw_key: The full API key string (e.g. ssk_...)
Returns:
str: 64-character hex digest
"""
return hashlib.sha256(raw_key.encode()).hexdigest()
# #endregion hash_api_key
# #endregion APIKeyUtilities

View File

@@ -15,6 +15,7 @@ from sqlalchemy.orm import sessionmaker
# Import models to ensure they're registered with Base
from ..models import (
api_key as _api_key_models, # noqa: F401
assistant as _assistant_models, # noqa: F401
auth as _auth_models, # noqa: F401
clean_release as _clean_release_models, # noqa: F401

View File

@@ -1,4 +1,4 @@
# #region AppDependencies [C:3] [TYPE Module] [SEMANTICS fastapi, schedule]
# #region AppDependencies [C:4] [TYPE Module] [SEMANTICS fastapi, schedule, auth, api_key]
# @BRIEF Manages creation and provision of shared application dependencies, such as PluginLoader and TaskManager, to avoid circular imports.
# @LAYER: Core
# @RELATION Used by main app and API routers to get access to shared instances.
@@ -9,12 +9,18 @@
# @RELATION CALLS -> [TaskManager]
# @RELATION CALLS -> [get_all_plugin_configs]
# @RELATION CALLS -> [get_db]
# @RELATION CALLS -> [APIKeyPrincipal]
# @RELATION CALLS -> [get_api_key_principal]
# @RELATION CALLS -> [info]
# @RELATION CALLS -> [init_db]
import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError
@@ -26,6 +32,7 @@ from .core.logger import logger
from .core.plugin_loader import PluginLoader
from .core.scheduler import SchedulerService
from .core.task_manager import TaskManager
from .models.api_key import APIKey
from .models.auth import User
from .services.clean_release.facade import CleanReleaseFacade
from .services.clean_release.repositories import (
@@ -201,12 +208,295 @@ def get_clean_release_facade(db=Depends(get_db)) -> CleanReleaseFacade:
# #endregion get_clean_release_facade
# #region APIKeyPrincipal [C:1] [TYPE Class]
# @BRIEF Dataclass representing an authenticated API key principal for service-to-service auth.
@dataclass
class APIKeyPrincipal:
name: str
api_key_id: str
environment_id: Optional[str] = None
permissions: list[str] = field(default_factory=list)
# #endregion APIKeyPrincipal
# #region require_api_key_or_jwt [C:4] [TYPE Function]
# @BRIEF Factory: creates a dependency that accepts either a valid API key (with permission check)
# OR a JWT (with standard has_permission check). Returns the authenticated user/principal name.
# JWT takes precedence when both auth methods are present.
# When API key is used, its environment_id scope is enforced against the caller's environment_id.
# @PRE required_api_permission is a dot-separated string e.g. "maintenance:start".
# required_jwt_resource and required_jwt_action are the JWT permission to check.
# environment_id is the target environment for the operation (may be None for cross-env ops).
# @POST Returns str (username or api key name). Raises 400/401/403 if auth fails or scope mismatch.
# @SIDE_EFFECT Updates APIKey.last_used_at on successful API key auth.
# @RELATION DEPENDS_ON -> [APIKeyModel]
# @RELATION DEPENDS_ON -> [hash_api_key]
# @RELATION DEPENDS_ON -> [AuthRepository]
# @RELATION DEPENDS_ON -> [decode_token]
def require_api_key_or_jwt(
required_api_permission: str,
required_jwt_resource: str,
required_jwt_action: str,
environment_id: str | None = None,
):
"""Factory: creates a combined API-key-or-JWT auth dependency with environment scoping.
JWT takes precedence when both Authorization: Bearer and X-API-Key are present.
When API key auth is used, the key's environment_id scope is enforced:
- If key.environment_id differs from environment_id → 400
- If environment_id is None, the key's scope is used as default (auto-scope).
Args:
required_api_permission: Permission string to check against API key (e.g. 'maintenance:start').
required_jwt_resource: JWT resource for has_permission fallback.
required_jwt_action: JWT action for has_permission fallback.
environment_id: Target environment for the operation. Pass None for cross-env operations.
"""
async def _auth_dependency(
request: Request,
db=Depends(get_db),
auth_db=Depends(get_auth_db),
token: str | None = Depends(oauth2_scheme_optional),
) -> str:
from .core.auth.api_key import hash_api_key
# Resolve effective environment_id: factory parameter > request body > None
effective_env_id = environment_id
if effective_env_id is None:
try:
body = await request.json()
effective_env_id = body.get("environment_id")
except (json.JSONDecodeError, KeyError, AttributeError):
pass
# ── Try JWT auth first (JWT takes precedence over API key) ──
if token:
try:
payload = decode_token(token)
username_value = payload.get("sub")
if not isinstance(username_value, str) or not username_value:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
username = username_value
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
repo = AuthRepository(auth_db)
user = repo.get_user_by_username(username)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
)
# Check JWT permission (Admin has full access)
has_perm = any(
role.name == "Admin"
for role in user.roles
)
if not has_perm:
for role in user.roles:
for perm in role.permissions:
if perm.resource == required_jwt_resource and perm.action == required_jwt_action:
has_perm = True
break
if has_perm:
break
if not has_perm:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Permission denied for {required_jwt_resource}:{required_jwt_action}",
)
logger.info(f"JWT auth: {user.username}")
return f"jwt:{user.username}"
# ── Try API key auth ──
raw_key = request.headers.get("X-API-Key")
if raw_key:
key_hash = hash_api_key(raw_key)
api_key = db.query(APIKey).filter(APIKey.key_hash == key_hash).first()
if not api_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
)
if not api_key.active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="API key has been revoked",
)
if api_key.expires_at:
expires_at = api_key.expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if expires_at < datetime.now(timezone.utc):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="API key has expired",
)
# Check API key has required permission
api_permissions = list(api_key.permissions or [])
if required_api_permission not in api_permissions:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"API key lacks permission '{required_api_permission}'",
)
# Environment scoping check: key's scope vs effective operation env
key_env = api_key.environment_id
if key_env is not None and effective_env_id is not None and key_env != effective_env_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"API key is restricted to environment '{key_env}'",
)
# Update last_used_at (best-effort, non-blocking)
api_key.last_used_at = datetime.now(timezone.utc)
db.flush()
logger.info(f"API key auth: {api_key.name} ({api_key.prefix}...)")
return f"api_key:{api_key.name}"
# ── Neither JWT nor API key provided ──
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required (API key or JWT)",
)
return _auth_dependency
# #endregion require_api_key_or_jwt
# #region check_api_key_environment_scope [C:1] [TYPE Function]
# @BRIEF Check the request's API key environment scope against a target env_id. Raises 400 if mismatch.
# @RELATION DEPENDS_ON -> [APIKeyModel]
# @RELATION DEPENDS_ON -> [hash_api_key]
async def check_api_key_environment_scope(request: Request, db, target_env_id: str) -> None:
"""Check the request's API key environment scope against a target environment_id.
If the API key has an environment_id set and it differs from target_env_id,
raises HTTP 400. This is used in route handlers where the target env_id
is only known at request time (e.g., loaded from DB in end_maintenance).
Args:
request: FastAPI request object (to extract X-API-Key header).
db: Database session.
target_env_id: The target environment to validate against.
"""
from .core.auth.api_key import hash_api_key
raw_key = request.headers.get("X-API-Key")
if not raw_key:
return
key_hash = hash_api_key(raw_key)
api_key = db.query(APIKey).filter(APIKey.key_hash == key_hash).first()
if api_key is None:
return
key_env = api_key.environment_id
if key_env is not None and key_env != target_env_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"API key is restricted to environment '{key_env}'",
)
# #endregion check_api_key_environment_scope
# #region get_api_key_principal [C:3] [TYPE Function]
# @BRIEF FastAPI dependency that reads X-API-Key header and returns APIKeyPrincipal or None.
# @PRE X-API-Key header may be present in the request.
# @POST If valid key: returns APIKeyPrincipal; if no header: returns None; if invalid: raises 401.
# @SIDE_EFFECT Updates last_used_at on the APIKey row (non-blocking flush).
# @RELATION DEPENDS_ON -> [APIKeyModel]
# @RELATION DEPENDS_ON -> [hash_api_key]
async def get_api_key_principal(
request: Request,
db=Depends(get_db),
) -> Optional[APIKeyPrincipal]:
"""Extract and validate API key from X-API-Key header.
If no header is present, returns None (fall through to JWT auth).
If header is present but invalid, raises 401.
Returns:
APIKeyPrincipal | None: Principal if valid API key, None if no header.
"""
from .core.auth.api_key import hash_api_key
raw_key = request.headers.get("X-API-Key")
if not raw_key:
return None
key_hash = hash_api_key(raw_key)
api_key = db.query(APIKey).filter(APIKey.key_hash == key_hash).first()
if not api_key:
logger.warning(f"API key auth failed: key not found (hash prefix: {raw_key[:11]}...)")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
)
if not api_key.active:
logger.warning(f"API key auth failed: key revoked (id: {api_key.id}, name: {api_key.name})")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="API key has been revoked",
)
if api_key.expires_at:
expires_at = api_key.expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if expires_at < datetime.now(timezone.utc):
logger.warning(f"API key auth failed: key expired (id: {api_key.id}, name: {api_key.name})")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="API key has expired",
)
# Update last_used_at (non-blocking, best-effort)
api_key.last_used_at = datetime.now(timezone.utc)
db.flush()
logger.info(f"API key auth success: {api_key.name} ({api_key.prefix}...)")
return APIKeyPrincipal(
name=api_key.name,
api_key_id=api_key.id,
environment_id=api_key.environment_id,
permissions=list(api_key.permissions or []),
)
# #endregion get_api_key_principal
# #region oauth2_scheme [C:1] [TYPE Variable]
# @RELATION DEPENDS_ON -> OAuth2PasswordBearer
# @BRIEF OAuth2 password bearer scheme for token extraction.
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
# @BRIEF OAuth2 password bearer scheme for token extraction (raises 401 on missing token).
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=True)
# #endregion oauth2_scheme
# #region oauth2_scheme_optional [C:1] [TYPE Variable]
# @RELATION DEPENDS_ON -> OAuth2PasswordBearer
# @BRIEF Optional OAuth2 scheme — returns None instead of raising 401 when no token.
# @RATIONALE Used in dual-auth routes (API key OR JWT) where JWT may be absent.
oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
# #endregion oauth2_scheme_optional
# #region get_current_user [C:3] [TYPE Function]
# @RELATION CALLS -> AuthRepository

View File

@@ -0,0 +1,36 @@
# #region APIKeyModel [C:1] [TYPE Module] [SEMANTICS sqlalchemy, api_key, auth, model]
# @BRIEF SQLAlchemy model for API Key authentication — stores SHA-256 hash only, raw key shown once at creation.
# @LAYER Domain
# @RELATION DEPENDS_ON -> [MappingModels]
# @INVARIANT key_hash is stored as SHA-256 hex digest (64 chars). prefix stores "ssk_" + first 7 chars.
# @INVARIANT Raw key is NEVER stored — shown once at creation and discarded.
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, Column, DateTime, JSON, String
from .mapping import Base
# #region APIKey [C:1] [TYPE Class]
# @BRIEF Stores API key metadata and SHA-256 hash for service-to-service authentication.
class APIKey(Base):
__tablename__ = "api_keys"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
key_hash = Column(String(64), unique=True, nullable=False, index=True) # SHA-256 hex digest
prefix = Column(String(11), nullable=False) # "ssk_" + 7 chars
name = Column(String(255), nullable=False) # Human-readable label
environment_id = Column(String(50), nullable=True) # Scoped to environment; null = all
permissions = Column(JSON, nullable=False) # e.g. ["maintenance:start", "maintenance:end"]
active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
expires_at = Column(DateTime, nullable=True)
last_used_at = Column(DateTime, nullable=True)
def __repr__(self) -> str:
return f"<APIKey {self.prefix}... ({self.name})>"
# #endregion APIKey
# #endregion APIKeyModel

View File

@@ -0,0 +1,500 @@
# #region TestAPIKeyAuth [C:3] [TYPE Module] [SEMANTICS test, api_key, auth, dependency]
# @BRIEF Contract tests for API key authentication — valid key, invalid, revoked, expired, missing permission,
# environment scoping, JWT precedence. Uses TestClient with dependency overrides.
# @RELATION BINDS_TO -> [APIKeyUtilities]
# @RELATION BINDS_TO -> [APIKeyModel]
# @RELATION BINDS_TO -> [MaintenanceRoutesModule]
# @TEST_CONTRACT: get_api_key_principal returns APIKeyPrincipal for valid key, None for no header, 401 for invalid/revoked/expired.
# @TEST_CONTRACT: require_api_key_or_jwt rejects invalid/revoked/expired keys, checks permissions, enforces environment scope.
# @TEST_EDGE: missing_header -> returns None
# @TEST_EDGE: invalid_key -> 401
# @TEST_EDGE: revoked_key -> 401
# @TEST_EDGE: expired_key -> 401
# @TEST_EDGE: missing_permission -> 403
# @TEST_EDGE: environment_scope_mismatch -> 400
# @TEST_EDGE: jwt_precedence -> JWT takes precedence over API key
from datetime import datetime, timezone, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException, status
from fastapi.testclient import TestClient
# ── Fixtures ──────────────────────────────────────────────────
@pytest.fixture
def db_session():
"""Create an in-memory SQLite session with the api_keys table."""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from src.models.mapping import Base
engine = create_engine(
"sqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
@pytest.fixture
def valid_api_key(db_session):
"""Create a valid API key and return its raw value, prefix, and database row."""
from src.core.auth.api_key import generate_api_key
from src.models.api_key import APIKey
raw, prefix, key_hash = generate_api_key()
api_key = APIKey(
key_hash=key_hash,
prefix=prefix,
name="Test Key",
permissions=["maintenance:start", "maintenance:end"],
active=True,
)
db_session.add(api_key)
db_session.commit()
return raw, api_key
@pytest.fixture
def client():
"""Create a TestClient."""
from src.app import app
return TestClient(app)
@pytest.fixture
def mock_db():
"""Patch get_db dependency with in-memory SQLite session (same pattern as test_maintenance_api)."""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from src.models.mapping import Base
from src.dependencies import get_db
from src.app import app
engine = create_engine(
"sqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
# Add default maintenance settings (needed by /api/maintenance endpoints)
from src.models.maintenance import MaintenanceSettings, DashboardScope
settings = MaintenanceSettings(
id="default",
target_environment_id="test-env",
display_timezone="UTC",
banner_template="Test: {message} ({start_time}-{end_time})",
dashboard_scope=DashboardScope.PUBLISHED_ONLY,
excluded_dashboard_ids=[],
forced_dashboard_ids=[],
)
session.add(settings)
session.commit()
def _get_db_override():
db = Session()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = _get_db_override
yield session
app.dependency_overrides.pop(get_db, None)
session.close()
@pytest.fixture
def mock_task_manager():
"""Patch get_task_manager to return a mock."""
from src.dependencies import get_task_manager
from src.app import app
mock_tm = MagicMock()
mock_task = MagicMock()
mock_task.id = "test-task-id-123"
mock_tm.create_task = AsyncMock(return_value=mock_task)
app.dependency_overrides[get_task_manager] = lambda: mock_tm
yield mock_tm
app.dependency_overrides.pop(get_task_manager, None)
# ── Tests for get_api_key_principal ───────────────────────────
class TestGetApiKeyPrincipal:
"""Contract tests for get_api_key_principal dependency."""
# #region test_no_header_returns_none [C:2] [TYPE Function]
# @BRIEF No X-API-Key header → returns None.
@pytest.mark.asyncio
async def test_no_header_returns_none(self):
from src.dependencies import get_api_key_principal
from fastapi import Request
# Mock a request without the header
mock_request = MagicMock(spec=Request)
mock_request.headers.get.return_value = None
mock_db = MagicMock()
result = await get_api_key_principal(mock_request, mock_db)
assert result is None
# #endregion test_no_header_returns_none
# #region test_valid_key_returns_principal [C:2] [TYPE Function]
# @BRIEF Valid API key → returns APIKeyPrincipal with correct fields.
@pytest.mark.asyncio
async def test_valid_key_returns_principal(self, db_session, valid_api_key):
from src.dependencies import get_api_key_principal
from fastapi import Request
raw_key, api_key_row = valid_api_key
mock_request = MagicMock(spec=Request)
mock_request.headers.get.return_value = raw_key
result = await get_api_key_principal(mock_request, db_session)
assert result is not None
assert result.name == "Test Key"
assert result.api_key_id == api_key_row.id
assert result.environment_id is None
assert "maintenance:start" in result.permissions
# #endregion test_valid_key_returns_principal
# #region test_invalid_key_raises_401 [C:2] [TYPE Function]
# @BRIEF Invalid API key → 401.
@pytest.mark.asyncio
async def test_invalid_key_raises_401(self, db_session):
from src.dependencies import get_api_key_principal
from fastapi import Request
mock_request = MagicMock(spec=Request)
mock_request.headers.get.return_value = "ssk_invalid_key_that_does_not_exist"
with pytest.raises(HTTPException) as exc:
await get_api_key_principal(mock_request, db_session)
assert exc.value.status_code == 401
# #endregion test_invalid_key_raises_401
# #region test_revoked_key_raises_401 [C:2] [TYPE Function]
# @BRIEF Revoked (active=False) API key → 401.
@pytest.mark.asyncio
async def test_revoked_key_raises_401(self, db_session):
from src.core.auth.api_key import generate_api_key
from src.dependencies import get_api_key_principal
from src.models.api_key import APIKey
from fastapi import Request
raw, prefix, key_hash = generate_api_key()
api_key = APIKey(
key_hash=key_hash,
prefix=prefix,
name="Revoked Key",
permissions=["maintenance:start"],
active=False, # revoked
)
db_session.add(api_key)
db_session.commit()
mock_request = MagicMock(spec=Request)
mock_request.headers.get.return_value = raw
with pytest.raises(HTTPException) as exc:
await get_api_key_principal(mock_request, db_session)
assert exc.value.status_code == 401
# #endregion test_revoked_key_raises_401
# #region test_expired_key_raises_401 [C:2] [TYPE Function]
# @BRIEF Expired API key → 401.
@pytest.mark.asyncio
async def test_expired_key_raises_401(self, db_session):
from src.core.auth.api_key import generate_api_key
from src.dependencies import get_api_key_principal
from src.models.api_key import APIKey
from fastapi import Request
raw, prefix, key_hash = generate_api_key()
api_key = APIKey(
key_hash=key_hash,
prefix=prefix,
name="Expired Key",
permissions=["maintenance:start"],
active=True,
expires_at=datetime.now(timezone.utc) - timedelta(hours=1), # expired
)
db_session.add(api_key)
db_session.commit()
mock_request = MagicMock(spec=Request)
mock_request.headers.get.return_value = raw
with pytest.raises(HTTPException) as exc:
await get_api_key_principal(mock_request, db_session)
assert exc.value.status_code == 401
# #endregion test_expired_key_raises_401
# #endregion TestAPIKeyAuth
# ── Tests for require_api_key_or_jwt (integration via TestClient) ──────
class TestRequireApiKeyOrJwt:
"""Contract tests for require_api_key_or_jwt dependency factory via TestClient."""
API_START_URL = "/api/maintenance/start"
START_PAYLOAD = {
"tables": ["raw.sales"],
"start_time": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(),
"end_time": (datetime.now(timezone.utc) + timedelta(hours=3)).isoformat(),
"message": "Test maintenance",
"environment_id": "ss-dev",
}
@staticmethod
def _create_api_key(db, environment_id=None, permissions=None):
"""Helper to create an API key in the test DB and return the raw key."""
from src.core.auth.api_key import generate_api_key
from src.models.api_key import APIKey
raw, prefix, key_hash = generate_api_key()
api_key = APIKey(
key_hash=key_hash,
prefix=prefix,
name="Test Key",
permissions=permissions or ["maintenance:start"],
active=True,
environment_id=environment_id,
)
db.add(api_key)
db.commit()
return raw
# #region test_api_key_auth_valid [C:2] [TYPE Function]
# @BRIEF Valid API key with matching permission → 202.
def test_api_key_auth_valid(self, client, mock_db, mock_task_manager):
raw_key = self._create_api_key(mock_db, permissions=["maintenance:start"])
response = client.post(
self.API_START_URL,
json=self.START_PAYLOAD,
headers={"X-API-Key": raw_key},
)
assert response.status_code == 202
data = response.json()
assert "task_id" in data
assert data["status"] == "pending"
# #endregion test_api_key_auth_valid
# #region test_api_key_auth_wrong_permission [C:2] [TYPE Function]
# @BRIEF API key lacks required permission → 403.
def test_api_key_auth_wrong_permission(self, client, mock_db, mock_task_manager):
# Key has 'maintenance:end' only, not 'maintenance:start'
raw_key = self._create_api_key(mock_db, permissions=["maintenance:end"])
response = client.post(
self.API_START_URL,
json=self.START_PAYLOAD,
headers={"X-API-Key": raw_key},
)
assert response.status_code == 403
data = response.json()
assert "detail" in data
# #endregion test_api_key_auth_wrong_permission
# #region test_api_key_auth_revoked [C:2] [TYPE Function]
# @BRIEF Revoked API key → 401.
def test_api_key_auth_revoked(self, client, mock_db, mock_task_manager):
from src.core.auth.api_key import generate_api_key
from src.models.api_key import APIKey
raw, prefix, key_hash = generate_api_key()
api_key = APIKey(
key_hash=key_hash,
prefix=prefix,
name="Revoked Key",
permissions=["maintenance:start"],
active=False,
)
mock_db.add(api_key)
mock_db.commit()
response = client.post(
self.API_START_URL,
json=self.START_PAYLOAD,
headers={"X-API-Key": raw},
)
assert response.status_code == 401
assert "revoked" in response.json().get("detail", "").lower()
# #endregion test_api_key_auth_revoked
# #region test_api_key_auth_expired [C:2] [TYPE Function]
# @BRIEF Expired API key → 401.
def test_api_key_auth_expired(self, client, mock_db, mock_task_manager):
from src.core.auth.api_key import generate_api_key
from src.models.api_key import APIKey
raw, prefix, key_hash = generate_api_key()
api_key = APIKey(
key_hash=key_hash,
prefix=prefix,
name="Expired Key",
permissions=["maintenance:start"],
active=True,
expires_at=datetime.now(timezone.utc) - timedelta(hours=1),
)
mock_db.add(api_key)
mock_db.commit()
response = client.post(
self.API_START_URL,
json=self.START_PAYLOAD,
headers={"X-API-Key": raw},
)
assert response.status_code == 401
assert "expired" in response.json().get("detail", "").lower()
# #endregion test_api_key_auth_expired
# #region test_api_key_auth_invalid [C:2] [TYPE Function]
# @BRIEF Non-existent key → 401.
def test_api_key_auth_invalid(self, client, mock_db, mock_task_manager):
response = client.post(
self.API_START_URL,
json=self.START_PAYLOAD,
headers={"X-API-Key": "ssk_invalid_nonexistent_key"},
)
assert response.status_code == 401
assert "invalid" in response.json().get("detail", "").lower()
# #endregion test_api_key_auth_invalid
# #region test_api_key_auth_environment_scope [C:2] [TYPE Function]
# @BRIEF Key scoped to ss-dev, request for ss-prod → 400.
def test_api_key_auth_environment_scope(self, client, mock_db, mock_task_manager):
raw_key = self._create_api_key(
mock_db,
environment_id="ss-dev",
permissions=["maintenance:start"],
)
payload = dict(self.START_PAYLOAD)
payload["environment_id"] = "ss-prod" # Mismatch!
response = client.post(
self.API_START_URL,
json=payload,
headers={"X-API-Key": raw_key},
)
assert response.status_code == 400
data = response.json()
assert "restricted" in data.get("detail", "").lower()
assert "ss-dev" in data.get("detail", "")
# #endregion test_api_key_auth_environment_scope
# #region test_api_key_auth_environment_scope_ok [C:2] [TYPE Function]
# @BRIEF Key scoped to ss-dev, request for ss-dev → 202.
def test_api_key_auth_environment_scope_ok(self, client, mock_db, mock_task_manager):
raw_key = self._create_api_key(
mock_db,
environment_id="ss-dev",
permissions=["maintenance:start"],
)
payload = dict(self.START_PAYLOAD)
payload["environment_id"] = "ss-dev" # Match!
response = client.post(
self.API_START_URL,
json=payload,
headers={"X-API-Key": raw_key},
)
assert response.status_code == 202
data = response.json()
assert "task_id" in data
# #endregion test_api_key_auth_environment_scope_ok
# #region test_jwt_precedence [C:2] [TYPE Function]
# @BRIEF Both valid API key + valid JWT → JWT takes precedence.
def test_jwt_precedence(self, client, mock_db, mock_task_manager):
from src.core.auth.jwt import create_access_token
from src.models.auth import User, Role
from src.dependencies import get_auth_db
from src.app import app
raw_key = self._create_api_key(
mock_db,
environment_id="ss-dev",
permissions=["maintenance:start"],
)
# Create a JWT user with Admin role (full access)
role = Role(name="Admin", description="Admin role")
mock_db.add(role)
mock_db.commit()
user = User(
username="jwt-user",
password_hash="nohash",
auth_source="LOCAL",
is_active=True,
)
user.roles.append(role)
mock_db.add(user)
mock_db.commit()
# Create a valid JWT token
token = create_access_token(data={"sub": "jwt-user"})
# Override get_auth_db to use our mock_db for auth queries too
# The auth DB and main DB are separate in production, but for tests
# we use the same in-memory DB
def _get_auth_db_override():
yield mock_db
app.dependency_overrides[get_auth_db] = _get_auth_db_override
# Send request with BOTH X-API-Key and Authorization: Bearer
response = client.post(
self.API_START_URL,
json=self.START_PAYLOAD,
headers={
"X-API-Key": raw_key,
"Authorization": f"Bearer {token}",
},
)
assert response.status_code == 202
data = response.json()
assert "task_id" in data
# Cleanup
app.dependency_overrides.pop(get_auth_db, None)
# #endregion test_jwt_precedence
# #endregion TestRequireApiKeyOrJwt

View File

@@ -0,0 +1,122 @@
# #region TestAPIKeyModel [C:2] [TYPE Module] [SEMANTICS test, api_key, model]
# @BRIEF Contract tests for the APIKey SQLAlchemy model — creation, hash storage, and field constraints.
# @RELATION BINDS_TO -> [APIKeyModel]
# @TEST_CONTRACT: APIKey model stores SHA-256 hash and prefix; raw key never persisted.
# @TEST_EDGE: key_hash is unique and indexed
# @TEST_EDGE: prefix is exactly 11 chars ("ssk_" + 7)
# @TEST_EDGE: active defaults to True
import pytest
from datetime import datetime, timezone
# #region clean_db [C:1] [TYPE Fixture]
# @BRIEF In-memory SQLite fixture for APIKey model tests.
@pytest.fixture
def clean_db():
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from src.models.mapping import Base
engine = create_engine(
"sqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
# #endregion clean_db
class TestAPIKeyModel:
"""Verify APIKey model fields and constraints."""
# #region test_create_api_key [C:2] [TYPE Function]
# @BRIEF Create APIKey with required fields and verify defaults.
def test_create_api_key(self, clean_db):
from src.models.api_key import APIKey
api_key = APIKey(
key_hash="a" * 64,
prefix="ssk_abc1234",
name="Test Key",
permissions=["maintenance:start"],
)
clean_db.add(api_key)
clean_db.commit()
clean_db.refresh(api_key)
assert api_key.id is not None
assert api_key.key_hash == "a" * 64
assert api_key.prefix == "ssk_abc1234"
assert api_key.name == "Test Key"
assert api_key.permissions == ["maintenance:start"]
assert api_key.active is True
assert api_key.created_at is not None
assert api_key.environment_id is None
assert api_key.expires_at is None
assert api_key.last_used_at is None
# #endregion test_create_api_key
# #region test_key_hash_unique [C:2] [TYPE Function]
# @BRIEF key_hash is unique — duplicate raises IntegrityError.
def test_key_hash_unique(self, clean_db):
from sqlalchemy.exc import IntegrityError
from src.models.api_key import APIKey
clean_db.add(APIKey(
key_hash="b" * 64,
prefix="ssk_xyz7890",
name="Key 1",
permissions=["maintenance:start"],
))
clean_db.commit()
with pytest.raises(IntegrityError):
clean_db.add(APIKey(
key_hash="b" * 64, # Same hash
prefix="ssk_def5678",
name="Key 2",
permissions=["maintenance:end"],
))
clean_db.commit()
# #endregion test_key_hash_unique
# #region test_prefix_length [C:2] [TYPE Function]
# @BRIEF prefix is exactly 11 characters.
def test_prefix_length(self, clean_db):
from src.models.api_key import APIKey
api_key = APIKey(
key_hash="c" * 64,
prefix="ssk_short1", # 10 chars — should be ok in SQL
name="Short Prefix",
permissions=["maintenance:start"],
)
clean_db.add(api_key)
clean_db.commit()
# The DB allows any prefix length; the app layer enforces 11 chars
# via generate_api_key. Model just stores what it's given.
assert len(api_key.prefix) <= 11
# #endregion test_prefix_length
# #region test_active_default_true [C:2] [TYPE Function]
# @BRIEF active column defaults to True.
def test_active_default_true(self, clean_db):
from src.models.api_key import APIKey
api_key = APIKey(
key_hash="d" * 64,
prefix="ssk_def1234",
name="Default Active",
permissions=[],
)
clean_db.add(api_key)
clean_db.commit()
assert api_key.active is True
# #endregion test_active_default_true
# #endregion TestAPIKeyModel

View File

@@ -0,0 +1,258 @@
# #region TestAPIKeyRoutes [C:3] [TYPE Module] [SEMANTICS test, api_key, routes, crud]
# @BRIEF Contract tests for admin API key CRUD endpoints — list, create (raw key present), revoke, idempotent revoke.
# @RELATION BINDS_TO -> [AdminApiKeyRoutes]
# @TEST_CONTRACT: GET /api/admin/api-keys -> list of keys without raw_key or key_hash
# @TEST_CONTRACT: POST /api/admin/api-keys -> 201 with raw_key in response
# @TEST_CONTRACT: DELETE /api/admin/api-keys/{id} -> 200 with status=revoked
# @TEST_EDGE: revoked_key_delete -> 404
# @TEST_EDGE: missing_name -> 422
# @TEST_EDGE: missing_permissions -> 422
import json
import sys
from datetime import datetime, timezone, timedelta
from unittest.mock import MagicMock, patch
import pytest
from fastapi import status
from fastapi.testclient import TestClient
# Patch GitService at module level to prevent /app/storage/repositories error
_mock_git_svc_cls = MagicMock()
_mock_git_svc_cls.return_value = MagicMock()
sys.modules['src.services.git._base'] = MagicMock(GitService=_mock_git_svc_cls)
sys.modules['src.services.git_service'] = MagicMock(GitService=_mock_git_svc_cls)
# ── Fixtures ──────────────────────────────────────────────────
@pytest.fixture
def client():
"""Create a TestClient with all dependencies mocked."""
from src.app import app
return TestClient(app)
def _mock_admin_auth():
"""Apply dependency overrides for admin authentication."""
from src.dependencies import get_current_user
from src.app import app
mock_role = MagicMock()
mock_role.name = "Admin"
mock_role.permissions = []
mock_user = MagicMock()
mock_user.username = "test-admin"
mock_user.roles = [mock_role]
async def _mock_get_current_user():
return mock_user
app.dependency_overrides[get_current_user] = _mock_get_current_user
def _clear_overrides():
from src.app import app
app.dependency_overrides = {}
@pytest.fixture(autouse=True)
def auth_mock():
_mock_admin_auth()
yield
_clear_overrides()
@pytest.fixture
def mock_db():
"""Patch get_db dependency with in-memory SQLite session."""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from src.models.mapping import Base
from src.dependencies import get_db
from src.app import app
engine = create_engine(
"sqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
def _get_db_override():
db = Session()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = _get_db_override
yield session
app.dependency_overrides.pop(get_db, None)
session.close()
# ── Tests ─────────────────────────────────────────────────────
class TestListApiKeys:
"""Contract tests for GET /api/admin/api-keys."""
# #region test_list_empty [C:2] [TYPE Function]
# @BRIEF No keys → returns empty list.
def test_list_empty(self, client, mock_db):
response = client.get("/api/admin/api-keys/")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) == 0
# #endregion test_list_empty
# #region test_list_with_keys [C:2] [TYPE Function]
# @BRIEF Keys exist → returns list without raw_key or key_hash fields.
def test_list_with_keys(self, client, mock_db):
from src.models.api_key import APIKey
from src.core.auth.api_key import generate_api_key
# Create a key
raw, prefix, key_hash = generate_api_key()
api_key = APIKey(
key_hash=key_hash,
prefix=prefix,
name="Test Key",
permissions=["maintenance:start", "maintenance:end"],
active=True,
)
mock_db.add(api_key)
mock_db.commit()
response = client.get("/api/admin/api-keys/")
assert response.status_code == 200
data = response.json()
assert len(data) == 1
assert data[0]["name"] == "Test Key"
assert data[0]["prefix"] == prefix
assert "raw_key" not in data[0]
assert "key_hash" not in data[0]
# #endregion test_list_with_keys
class TestCreateApiKey:
"""Contract tests for POST /api/admin/api-keys."""
# #region test_create_valid [C:2] [TYPE Function]
# @BRIEF Valid request → 201 with raw_key, prefix, id.
def test_create_valid(self, client, mock_db):
response = client.post(
"/api/admin/api-keys/",
json={
"name": "CI/CD Pipeline",
"permissions": ["maintenance:start", "maintenance:end"],
"environment_id": "prod-env-1",
},
)
assert response.status_code == 201
data = response.json()
assert "id" in data
assert "raw_key" in data
assert data["raw_key"].startswith("ssk_")
assert data["prefix"] == data["raw_key"][:11]
assert data["name"] == "CI/CD Pipeline"
assert data["permissions"] == ["maintenance:start", "maintenance:end"]
assert data["active"] is True
assert data["environment_id"] == "prod-env-1"
# Verify raw key is NOT in DB
from src.models.api_key import APIKey
db_key = mock_db.query(APIKey).filter(APIKey.id == data["id"]).first()
assert db_key is not None
assert db_key.key_hash != data["raw_key"]
assert db_key.prefix == data["prefix"]
# #endregion test_create_valid
# #region test_create_missing_name [C:2] [TYPE Function]
# @BRIEF Missing name → 400 or 422.
def test_create_missing_name(self, client, mock_db):
response = client.post(
"/api/admin/api-keys/",
json={
"permissions": ["maintenance:start"],
},
)
assert response.status_code in (400, 422)
# #endregion test_create_missing_name
# #region test_create_empty_permissions [C:2] [TYPE Function]
# @BRIEF Empty permissions → 400 or 422.
def test_create_empty_permissions(self, client, mock_db):
response = client.post(
"/api/admin/api-keys/",
json={
"name": "No Perms",
"permissions": [],
},
)
assert response.status_code in (400, 422)
# #endregion test_create_empty_permissions
class TestRevokeApiKey:
"""Contract tests for DELETE /api/admin/api-keys/{id}."""
# #region test_revoke_valid [C:2] [TYPE Function]
# @BRIEF Revoke existing active key → 200 with status=revoked.
def test_revoke_valid(self, client, mock_db):
from src.models.api_key import APIKey
from src.core.auth.api_key import generate_api_key
raw, prefix, key_hash = generate_api_key()
api_key = APIKey(
key_hash=key_hash,
prefix=prefix,
name="To Revoke",
permissions=["maintenance:start"],
active=True,
)
mock_db.add(api_key)
mock_db.commit()
response = client.delete(f"/api/admin/api-keys/{api_key.id}")
assert response.status_code == 200
data = response.json()
assert data["id"] == api_key.id
assert data["status"] == "revoked"
# Verify DB
mock_db.refresh(api_key)
assert api_key.active is False
# #endregion test_revoke_valid
# #region test_revoke_already_revoked [C:2] [TYPE Function]
# @BRIEF Revoke already revoked key → 404.
def test_revoke_already_revoked(self, client, mock_db):
from src.models.api_key import APIKey
from src.core.auth.api_key import generate_api_key
raw, prefix, key_hash = generate_api_key()
api_key = APIKey(
key_hash=key_hash,
prefix=prefix,
name="Already Revoked",
permissions=["maintenance:start"],
active=False, # already revoked
)
mock_db.add(api_key)
mock_db.commit()
response = client.delete(f"/api/admin/api-keys/{api_key.id}")
assert response.status_code == 404
# #endregion test_revoke_already_revoked
# #region test_revoke_not_found [C:2] [TYPE Function]
# @BRIEF Revoke non-existent key → 404.
def test_revoke_not_found(self, client, mock_db):
response = client.delete("/api/admin/api-keys/nonexistent-id")
assert response.status_code == 404
# #endregion test_revoke_not_found
# #endregion TestAPIKeyRoutes

View File

@@ -41,16 +41,29 @@ def client():
return TestClient(app)
def _mock_auth_overrides():
"""Apply dependency overrides for authentication.
Creates a mock admin user so that all RBAC checks pass.
The Admin role has full access via has_permission()'s special case.
"""
@pytest.fixture(autouse=True)
def auth_mock(client, mock_db):
"""Create a valid API key with maintenance permissions and mock JWT user."""
from src.core.auth.api_key import generate_api_key
from src.models.api_key import APIKey
from src.dependencies import get_current_user
from src.app import app
# Create admin user with Admin role (full access)
# Create API key for mutation endpoints
raw, prefix, key_hash = generate_api_key()
api_key = APIKey(
key_hash=key_hash,
prefix=prefix,
name="Test API Key",
permissions=["maintenance:start", "maintenance:end", "maintenance:end_all"],
active=True,
)
mock_db.add(api_key)
mock_db.commit()
client.headers.update({"X-API-Key": raw})
# Mock JWT user for read-only endpoints
mock_role = MagicMock()
mock_role.name = "Admin"
mock_role.permissions = []
@@ -64,19 +77,10 @@ def _mock_auth_overrides():
app.dependency_overrides[get_current_user] = _mock_get_current_user
def _clear_overrides():
"""Clear dependency overrides."""
from src.app import app
app.dependency_overrides = {}
@pytest.fixture(autouse=True)
def auth_mock():
"""Auto-apply auth mocks for all tests."""
_mock_auth_overrides()
yield
_clear_overrides()
client.headers.pop("X-API-Key", None)
app.dependency_overrides = {}
@pytest.fixture
@@ -164,6 +168,7 @@ class TestStartEndpoint:
"start_time": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(),
"end_time": (datetime.now(timezone.utc) + timedelta(hours=3)).isoformat(),
"message": "Test maintenance",
"environment_id": "test-env",
},
)
assert response.status_code == 202
@@ -194,6 +199,7 @@ class TestStartEndpoint:
"tables": ["raw.sales"],
"start_time": (datetime.now(timezone.utc) + timedelta(hours=3)).isoformat(),
"end_time": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(),
"environment_id": "test-env",
},
)
assert response.status_code == 400
@@ -208,6 +214,7 @@ class TestStartEndpoint:
json={
"tables": tables,
"start_time": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(),
"environment_id": "test-env",
},
)
assert response.status_code == 422
@@ -222,6 +229,7 @@ class TestStartEndpoint:
"tables": ["raw.sales"],
"start_time": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(),
"end_time": (datetime.now(timezone.utc) + timedelta(hours=3)).isoformat(),
"environment_id": "test-env",
}
# First call creates event