Implement ACTIVITY-WP-0024 operator automation console
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Container Image / build-and-push (push) Successful in 36s

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:
tegwick 2026-07-21 23:51:39 +02:00
parent 81d350de71
commit 71027f0a67
11 changed files with 1494 additions and 20 deletions

View 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)