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