fix(swarm): require admin JWT on all swarm operator endpoints
Gate all 8 swarm-controller operator routes (enroll, list/get/decommission hosts, deploy, teardown, check, list deckies) with the centralized require_admin RBAC dependency alongside require_operator_cert; mTLS becomes defense-in-depth instead of the only gate. /heartbeat stays cert-fingerprint pinned (worker-facing) and /swarm/health stays open (liveness only). CLI swarm commands now send Authorization: Bearer $DECNET_API_TOKEN with a 401/403 hint covering the must_change_password bootstrap flow. Bump pyjwt to 2.13.0 and pip to 26.1.2 (pip-audit PYSEC-2026-175/177/178/179, PYSEC-2026-196); authz suite re-verified on the new pyjwt. Closes ASVS_L2_AUDIT.md V4.1.1a and V4.1.1b (CRITICAL).
This commit is contained in:
@@ -199,11 +199,27 @@ def _swarmctl_base_url(url: Optional[str]) -> str:
|
||||
return url or os.environ.get("DECNET_SWARMCTL_URL") or _DEFAULT_SWARMCTL_URL
|
||||
|
||||
|
||||
def _swarmctl_auth_headers() -> dict[str, str]:
|
||||
"""Bearer header for swarm-controller calls.
|
||||
|
||||
The controller now requires an admin-role JWT on every control-plane route
|
||||
(defense-in-depth on top of the loopback/mTLS transport gate). Operators
|
||||
export ``DECNET_API_TOKEN`` (the access_token from POST /api/v1/auth/login)
|
||||
so the CLI can authenticate. Absent the var we send no header and the
|
||||
controller answers 401 — fail closed, with a clear hint surfaced by
|
||||
:func:`_http_request`.
|
||||
"""
|
||||
token = os.environ.get("DECNET_API_TOKEN")
|
||||
return {"Authorization": f"Bearer {token}"} if token else {}
|
||||
|
||||
|
||||
def _http_request(method: str, url: str, *, json_body: Optional[dict] = None, timeout: float = 30.0):
|
||||
"""Tiny sync wrapper around httpx; avoids leaking async into the CLI."""
|
||||
import httpx
|
||||
try:
|
||||
resp = httpx.request(method, url, json=json_body, timeout=timeout)
|
||||
resp = httpx.request(
|
||||
method, url, json=json_body, timeout=timeout, headers=_swarmctl_auth_headers()
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
console.print(f"[red]Could not reach swarm controller at {url}: {exc}[/]")
|
||||
console.print("[dim]Is `decnet swarmctl` running?[/]")
|
||||
@@ -214,5 +230,14 @@ def _http_request(method: str, url: str, *, json_body: Optional[dict] = None, ti
|
||||
except Exception: # nosec B110
|
||||
detail = resp.text
|
||||
console.print(f"[red]{method} {url} failed: {resp.status_code} — {detail}[/]")
|
||||
if resp.status_code in (401, 403):
|
||||
console.print(
|
||||
"[dim]The swarm controller requires an admin JWT. Export "
|
||||
"DECNET_API_TOKEN with an access_token from "
|
||||
"POST /api/v1/auth/login (admin user). "
|
||||
"If you receive 403 'Password change required', change the "
|
||||
"password first (POST /api/v1/auth/change-password), then "
|
||||
"log in again to obtain a fresh token.[/]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
return resp
|
||||
|
||||
Reference in New Issue
Block a user