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
|
||||
|
||||
@@ -15,7 +15,7 @@ from fastapi import APIRouter, Depends
|
||||
from decnet.logging import get_logger
|
||||
from decnet.swarm.client import AgentClient
|
||||
from decnet.web.db.repository import BaseRepository
|
||||
from decnet.web.dependencies import get_repo
|
||||
from decnet.web.dependencies import get_repo, require_admin
|
||||
from decnet.web.router.swarm._mtls import PeerCert, require_operator_cert
|
||||
from decnet.web.db.models import SwarmCheckResponse, SwarmHostHealth
|
||||
|
||||
@@ -24,9 +24,18 @@ log = get_logger("swarm.check")
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/check", response_model=SwarmCheckResponse, tags=["Swarm Health"])
|
||||
@router.post(
|
||||
"/check",
|
||||
response_model=SwarmCheckResponse,
|
||||
tags=["Swarm Health"],
|
||||
responses={
|
||||
401: {"description": "Missing or invalid admin JWT"},
|
||||
403: {"description": "Authenticated user is not an admin, or operator cert missing"},
|
||||
},
|
||||
)
|
||||
async def api_check_hosts(
|
||||
repo: BaseRepository = Depends(get_repo),
|
||||
_admin: dict = Depends(require_admin),
|
||||
_operator: PeerCert = Depends(require_operator_cert),
|
||||
) -> SwarmCheckResponse:
|
||||
hosts = await repo.list_swarm_hosts()
|
||||
|
||||
@@ -17,7 +17,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from decnet.logging import get_logger
|
||||
from decnet.swarm.client import AgentClient
|
||||
from decnet.web.db.repository import BaseRepository
|
||||
from decnet.web.dependencies import get_repo
|
||||
from decnet.web.dependencies import get_repo, require_admin
|
||||
from decnet.web.router.swarm._mtls import PeerCert, require_operator_cert
|
||||
|
||||
log = get_logger("swarm.decommission")
|
||||
@@ -28,11 +28,16 @@ router = APIRouter()
|
||||
"/hosts/{uuid}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
tags=["Swarm Hosts"],
|
||||
responses={404: {"description": "No host with this UUID is enrolled"}},
|
||||
responses={
|
||||
401: {"description": "Missing or invalid admin JWT"},
|
||||
403: {"description": "Authenticated user is not an admin, or operator cert missing"},
|
||||
404: {"description": "No host with this UUID is enrolled"},
|
||||
},
|
||||
)
|
||||
async def api_decommission_host(
|
||||
uuid: str,
|
||||
repo: BaseRepository = Depends(get_repo),
|
||||
_admin: dict = Depends(require_admin),
|
||||
_operator: PeerCert = Depends(require_operator_cert),
|
||||
) -> None:
|
||||
row = await repo.get_swarm_host_by_uuid(uuid)
|
||||
|
||||
@@ -20,7 +20,7 @@ from decnet.config import DecnetConfig, DeckyConfig
|
||||
from decnet.logging import get_logger
|
||||
from decnet.swarm.client import AgentClient
|
||||
from decnet.web.db.repository import BaseRepository
|
||||
from decnet.web.dependencies import get_repo
|
||||
from decnet.web.dependencies import get_repo, require_admin
|
||||
from decnet.web.router.swarm._mtls import PeerCert, require_operator_cert
|
||||
from decnet.web.db.models import (
|
||||
SwarmDeployRequest,
|
||||
@@ -155,12 +155,15 @@ async def dispatch_decnet_config(
|
||||
tags=["Swarm Deployments"],
|
||||
responses={
|
||||
400: {"description": "Deployment mode must be 'swarm'"},
|
||||
401: {"description": "Missing or invalid admin JWT"},
|
||||
403: {"description": "Authenticated user is not an admin, or operator cert missing"},
|
||||
404: {"description": "A referenced host_uuid is not enrolled"},
|
||||
},
|
||||
)
|
||||
async def api_deploy_swarm(
|
||||
req: SwarmDeployRequest,
|
||||
repo: BaseRepository = Depends(get_repo),
|
||||
_admin: dict = Depends(require_admin),
|
||||
_operator: PeerCert = Depends(require_operator_cert),
|
||||
) -> SwarmDeployResponse:
|
||||
if req.config.mode != "swarm":
|
||||
|
||||
@@ -6,10 +6,15 @@ generates a fresh worker keypair + CA-signed cert, and returns the full
|
||||
bundle to the operator. Bundle delivery to the worker (scp/sshpass/etc.)
|
||||
is outside this process's trust boundary.
|
||||
|
||||
Authorization: this mints a CA-signed identity (and its private key), so it
|
||||
is gated by :func:`require_operator_cert` — an operator-CN client cert when
|
||||
the controller runs mTLS, or a local request when it is loopback-bound.
|
||||
A worker's own cert cannot enroll further hosts.
|
||||
Authorization (defense-in-depth, both must pass):
|
||||
|
||||
* :func:`require_admin` — an admin-role JWT. This is the primary
|
||||
application-layer gate: enrollment is operator-driven (admin UI / CLI),
|
||||
so the caller always carries operator credentials. A worker agent has no
|
||||
JWT and therefore cannot enroll further hosts.
|
||||
* :func:`require_operator_cert` — the transport gate: an operator-CN client
|
||||
cert when the controller runs mTLS, or a loopback request on the shipping
|
||||
single-host default.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -21,7 +26,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from decnet.swarm import pki
|
||||
from decnet.web.db.repository import BaseRepository
|
||||
from decnet.web.dependencies import get_repo
|
||||
from decnet.web.dependencies import get_repo, require_admin
|
||||
from decnet.web.router.swarm._mtls import PeerCert, require_operator_cert
|
||||
from decnet.web.db.models import SwarmEnrolledBundle, SwarmEnrollRequest, SwarmUpdaterBundle
|
||||
|
||||
@@ -35,6 +40,8 @@ router = APIRouter()
|
||||
tags=["Swarm Hosts"],
|
||||
responses={
|
||||
400: {"description": "Bad Request (malformed JSON body)"},
|
||||
401: {"description": "Missing or invalid admin JWT"},
|
||||
403: {"description": "Authenticated user is not an admin, or operator cert missing"},
|
||||
409: {"description": "A worker with this name is already enrolled"},
|
||||
422: {"description": "Request body validation error"},
|
||||
},
|
||||
@@ -42,6 +49,7 @@ router = APIRouter()
|
||||
async def api_enroll_host(
|
||||
req: SwarmEnrollRequest,
|
||||
repo: BaseRepository = Depends(get_repo),
|
||||
_admin: dict = Depends(require_admin),
|
||||
_operator: PeerCert = Depends(require_operator_cert),
|
||||
) -> SwarmEnrolledBundle:
|
||||
existing = await repo.get_swarm_host_by_name(req.name)
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from decnet.web.db.repository import BaseRepository
|
||||
from decnet.web.dependencies import get_repo
|
||||
from decnet.web.dependencies import get_repo, require_admin
|
||||
from decnet.web.router.swarm._mtls import PeerCert, require_operator_cert
|
||||
from decnet.web.db.models import SwarmHostView
|
||||
|
||||
@@ -16,11 +16,16 @@ router = APIRouter()
|
||||
"/hosts/{uuid}",
|
||||
response_model=SwarmHostView,
|
||||
tags=["Swarm Hosts"],
|
||||
responses={404: {"description": "No host with this UUID is enrolled"}},
|
||||
responses={
|
||||
401: {"description": "Missing or invalid admin JWT"},
|
||||
403: {"description": "Authenticated user is not an admin, or operator cert missing"},
|
||||
404: {"description": "No host with this UUID is enrolled"},
|
||||
},
|
||||
)
|
||||
async def api_get_host(
|
||||
uuid: str,
|
||||
repo: BaseRepository = Depends(get_repo),
|
||||
_admin: dict = Depends(require_admin),
|
||||
_operator: PeerCert = Depends(require_operator_cert),
|
||||
) -> SwarmHostView:
|
||||
row = await repo.get_swarm_host_by_uuid(uuid)
|
||||
|
||||
@@ -13,18 +13,27 @@ from typing import Optional
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from decnet.web.db.repository import BaseRepository
|
||||
from decnet.web.dependencies import get_repo
|
||||
from decnet.web.dependencies import get_repo, require_admin
|
||||
from decnet.web.router.swarm._mtls import PeerCert, require_operator_cert
|
||||
from decnet.web.db.models import DeckyShardView
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/deckies", response_model=list[DeckyShardView], tags=["Swarm Deckies"])
|
||||
@router.get(
|
||||
"/deckies",
|
||||
response_model=list[DeckyShardView],
|
||||
tags=["Swarm Deckies"],
|
||||
responses={
|
||||
401: {"description": "Missing or invalid admin JWT"},
|
||||
403: {"description": "Authenticated user is not an admin, or operator cert missing"},
|
||||
},
|
||||
)
|
||||
async def api_list_deckies(
|
||||
host_uuid: Optional[str] = None,
|
||||
state: Optional[str] = None,
|
||||
repo: BaseRepository = Depends(get_repo),
|
||||
_admin: dict = Depends(require_admin),
|
||||
_operator: PeerCert = Depends(require_operator_cert),
|
||||
) -> list[DeckyShardView]:
|
||||
shards = await repo.list_decky_shards(host_uuid)
|
||||
|
||||
@@ -7,17 +7,26 @@ from typing import Optional
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from decnet.web.db.repository import BaseRepository
|
||||
from decnet.web.dependencies import get_repo
|
||||
from decnet.web.dependencies import get_repo, require_admin
|
||||
from decnet.web.router.swarm._mtls import PeerCert, require_operator_cert
|
||||
from decnet.web.db.models import SwarmHostView
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/hosts", response_model=list[SwarmHostView], tags=["Swarm Hosts"])
|
||||
@router.get(
|
||||
"/hosts",
|
||||
response_model=list[SwarmHostView],
|
||||
tags=["Swarm Hosts"],
|
||||
responses={
|
||||
401: {"description": "Missing or invalid admin JWT"},
|
||||
403: {"description": "Authenticated user is not an admin, or operator cert missing"},
|
||||
},
|
||||
)
|
||||
async def api_list_hosts(
|
||||
host_status: Optional[str] = None,
|
||||
repo: BaseRepository = Depends(get_repo),
|
||||
_admin: dict = Depends(require_admin),
|
||||
_operator: PeerCert = Depends(require_operator_cert),
|
||||
) -> list[SwarmHostView]:
|
||||
rows = await repo.list_swarm_hosts(host_status)
|
||||
|
||||
@@ -10,7 +10,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from decnet.logging import get_logger
|
||||
from decnet.swarm.client import AgentClient
|
||||
from decnet.web.db.repository import BaseRepository
|
||||
from decnet.web.dependencies import get_repo
|
||||
from decnet.web.dependencies import get_repo, require_admin
|
||||
from decnet.web.router.swarm._mtls import PeerCert, require_operator_cert
|
||||
from decnet.web.db.models import (
|
||||
SwarmDeployResponse,
|
||||
@@ -29,6 +29,8 @@ router = APIRouter()
|
||||
tags=["Swarm Deployments"],
|
||||
responses={
|
||||
400: {"description": "Bad Request (malformed JSON body)"},
|
||||
401: {"description": "Missing or invalid admin JWT"},
|
||||
403: {"description": "Authenticated user is not an admin, or operator cert missing"},
|
||||
404: {"description": "A targeted host does not exist"},
|
||||
422: {"description": "Request body validation error"},
|
||||
},
|
||||
@@ -36,6 +38,7 @@ router = APIRouter()
|
||||
async def api_teardown_swarm(
|
||||
req: SwarmTeardownRequest,
|
||||
repo: BaseRepository = Depends(get_repo),
|
||||
_admin: dict = Depends(require_admin),
|
||||
_operator: PeerCert = Depends(require_operator_cert),
|
||||
) -> SwarmDeployResponse:
|
||||
if req.host_uuid is not None:
|
||||
|
||||
Reference in New Issue
Block a user