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:
@@ -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
|
||||
Reference in New Issue
Block a user