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:
Binary file not shown.
Binary file not shown.
@@ -18,6 +18,7 @@
|
||||
# @DATA_CONTRACT: Package -> RouterModule mapping
|
||||
__all__ = [
|
||||
"admin",
|
||||
"admin_api_keys",
|
||||
"assistant",
|
||||
"clean_release",
|
||||
"clean_release_v2",
|
||||
|
||||
185
backend/src/api/routes/admin_api_keys.py
Normal file
185
backend/src/api/routes/admin_api_keys.py
Normal 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
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
53
backend/src/core/auth/api_key.py
Normal file
53
backend/src/core/auth/api_key.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
36
backend/src/models/api_key.py
Normal file
36
backend/src/models/api_key.py
Normal 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
|
||||
500
backend/tests/test_api_key_auth.py
Normal file
500
backend/tests/test_api_key_auth.py
Normal 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
|
||||
122
backend/tests/test_api_key_model.py
Normal file
122
backend/tests/test_api_key_model.py
Normal 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
|
||||
258
backend/tests/test_api_key_routes.py
Normal file
258
backend/tests/test_api_key_routes.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user