Implement ACTIVITY-WP-0024 operator automation console
Add /ops REST inventory, status, runs, and fail-closed operator-token mutations (trigger, enable/disable, pause/unpause) with audit trail. Ship thin HTML UI at /ops/ui, runbook/k8s access docs, and contract tests.
This commit is contained in:
parent
81d350de71
commit
71027f0a67
11 changed files with 1494 additions and 20 deletions
89
src/activity_core/ops_auth.py
Normal file
89
src/activity_core/ops_auth.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"""Operator token auth for activity-core ops console (ACTIVITY-WP-0024).
|
||||
|
||||
Mutations under ``/ops`` are fail-closed:
|
||||
- If ``ACTIVITY_CORE_OPERATOR_TOKEN`` is set, requests must send matching
|
||||
``X-Operator-Token`` (or ``Authorization: Bearer <token>``).
|
||||
- If the token is **unset**, mutations are refused unless
|
||||
``ACTIVITY_CORE_OPS_ALLOW_UNAUTH_MUTATIONS`` is truthy (local dev only).
|
||||
|
||||
Read endpoints do not require the token (ClusterIP / port-forward posture).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import os
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Header, HTTPException, Request
|
||||
|
||||
OPERATOR_TOKEN_ENV = "ACTIVITY_CORE_OPERATOR_TOKEN"
|
||||
ALLOW_UNAUTH_ENV = "ACTIVITY_CORE_OPS_ALLOW_UNAUTH_MUTATIONS"
|
||||
HEADER_NAME = "X-Operator-Token"
|
||||
|
||||
|
||||
def operator_token_configured() -> bool:
|
||||
return bool((os.environ.get(OPERATOR_TOKEN_ENV) or "").strip())
|
||||
|
||||
|
||||
def allow_unauth_mutations() -> bool:
|
||||
return (os.environ.get(ALLOW_UNAUTH_ENV) or "").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
|
||||
|
||||
def extract_operator_token(
|
||||
*,
|
||||
x_operator_token: str | None = None,
|
||||
authorization: str | None = None,
|
||||
) -> str | None:
|
||||
if x_operator_token and x_operator_token.strip():
|
||||
return x_operator_token.strip()
|
||||
if authorization and authorization.lower().startswith("bearer "):
|
||||
return authorization[7:].strip() or None
|
||||
return None
|
||||
|
||||
|
||||
def verify_operator_token(provided: str | None) -> str:
|
||||
"""Return operator principal label or raise HTTPException."""
|
||||
expected = (os.environ.get(OPERATOR_TOKEN_ENV) or "").strip()
|
||||
if not expected:
|
||||
if allow_unauth_mutations():
|
||||
return "anonymous-dev"
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=(
|
||||
"operator auth not configured; set "
|
||||
f"{OPERATOR_TOKEN_ENV} or enable {ALLOW_UNAUTH_ENV} for local dev"
|
||||
),
|
||||
)
|
||||
if not provided:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=f"missing operator token ({HEADER_NAME} or Authorization Bearer)",
|
||||
)
|
||||
if not hmac.compare_digest(provided, expected):
|
||||
raise HTTPException(status_code=401, detail="invalid operator token")
|
||||
return "operator"
|
||||
|
||||
|
||||
async def require_operator(
|
||||
request: Request,
|
||||
x_operator_token: Annotated[str | None, Header(alias=HEADER_NAME)] = None,
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
) -> str:
|
||||
"""FastAPI dependency: require valid operator token for mutations."""
|
||||
# Prefer dependency headers; fall back to raw request (HTML form headers rare).
|
||||
provided = extract_operator_token(
|
||||
x_operator_token=x_operator_token,
|
||||
authorization=authorization,
|
||||
)
|
||||
if provided is None:
|
||||
provided = extract_operator_token(
|
||||
x_operator_token=request.headers.get(HEADER_NAME),
|
||||
authorization=request.headers.get("Authorization"),
|
||||
)
|
||||
return verify_operator_token(provided)
|
||||
Loading…
Add table
Add a link
Reference in a new issue