feat(auth): implement API key authentication and management

Introduce a new API key authentication mechanism to support service-to-service
communication (e.g., Airflow, CI/CD) without browser-based JWT login.

- Add `APIKey` model and `APIKeyPrincipal` dependency for RBAC.
- Implement `require_api_key_or_jwt` dependency with environment scoping.
- Add admin CRUD endpoints for API key lifecycle management.
- Add API key management UI in System Settings.
- Update maintenance API to support explicit `environment_id` and API key auth.
- Add i18n support for API keys and connection settings.
- Include Python and Bash usage examples in the `examples/` directory.
- Update technical specifications and documentation.
This commit is contained in:
2026-05-25 11:35:27 +03:00
parent 31680a1bc9
commit 320f82ab95
39 changed files with 3359 additions and 707 deletions

View File

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

View File

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

View File

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

View File

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