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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -71,3 +71,4 @@ enterprise-attack-*.json
|
|||||||
|
|
||||||
# pytest failure dump files
|
# pytest failure dump files
|
||||||
testfail
|
testfail
|
||||||
|
.phaseloop/
|
||||||
|
|||||||
@@ -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
|
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):
|
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."""
|
"""Tiny sync wrapper around httpx; avoids leaking async into the CLI."""
|
||||||
import httpx
|
import httpx
|
||||||
try:
|
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:
|
except httpx.HTTPError as exc:
|
||||||
console.print(f"[red]Could not reach swarm controller at {url}: {exc}[/]")
|
console.print(f"[red]Could not reach swarm controller at {url}: {exc}[/]")
|
||||||
console.print("[dim]Is `decnet swarmctl` running?[/]")
|
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
|
except Exception: # nosec B110
|
||||||
detail = resp.text
|
detail = resp.text
|
||||||
console.print(f"[red]{method} {url} failed: {resp.status_code} — {detail}[/]")
|
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)
|
raise typer.Exit(1)
|
||||||
return resp
|
return resp
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from fastapi import APIRouter, Depends
|
|||||||
from decnet.logging import get_logger
|
from decnet.logging import get_logger
|
||||||
from decnet.swarm.client import AgentClient
|
from decnet.swarm.client import AgentClient
|
||||||
from decnet.web.db.repository import BaseRepository
|
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.router.swarm._mtls import PeerCert, require_operator_cert
|
||||||
from decnet.web.db.models import SwarmCheckResponse, SwarmHostHealth
|
from decnet.web.db.models import SwarmCheckResponse, SwarmHostHealth
|
||||||
|
|
||||||
@@ -24,9 +24,18 @@ log = get_logger("swarm.check")
|
|||||||
router = APIRouter()
|
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(
|
async def api_check_hosts(
|
||||||
repo: BaseRepository = Depends(get_repo),
|
repo: BaseRepository = Depends(get_repo),
|
||||||
|
_admin: dict = Depends(require_admin),
|
||||||
_operator: PeerCert = Depends(require_operator_cert),
|
_operator: PeerCert = Depends(require_operator_cert),
|
||||||
) -> SwarmCheckResponse:
|
) -> SwarmCheckResponse:
|
||||||
hosts = await repo.list_swarm_hosts()
|
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.logging import get_logger
|
||||||
from decnet.swarm.client import AgentClient
|
from decnet.swarm.client import AgentClient
|
||||||
from decnet.web.db.repository import BaseRepository
|
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.router.swarm._mtls import PeerCert, require_operator_cert
|
||||||
|
|
||||||
log = get_logger("swarm.decommission")
|
log = get_logger("swarm.decommission")
|
||||||
@@ -28,11 +28,16 @@ router = APIRouter()
|
|||||||
"/hosts/{uuid}",
|
"/hosts/{uuid}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
tags=["Swarm Hosts"],
|
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(
|
async def api_decommission_host(
|
||||||
uuid: str,
|
uuid: str,
|
||||||
repo: BaseRepository = Depends(get_repo),
|
repo: BaseRepository = Depends(get_repo),
|
||||||
|
_admin: dict = Depends(require_admin),
|
||||||
_operator: PeerCert = Depends(require_operator_cert),
|
_operator: PeerCert = Depends(require_operator_cert),
|
||||||
) -> None:
|
) -> None:
|
||||||
row = await repo.get_swarm_host_by_uuid(uuid)
|
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.logging import get_logger
|
||||||
from decnet.swarm.client import AgentClient
|
from decnet.swarm.client import AgentClient
|
||||||
from decnet.web.db.repository import BaseRepository
|
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.router.swarm._mtls import PeerCert, require_operator_cert
|
||||||
from decnet.web.db.models import (
|
from decnet.web.db.models import (
|
||||||
SwarmDeployRequest,
|
SwarmDeployRequest,
|
||||||
@@ -155,12 +155,15 @@ async def dispatch_decnet_config(
|
|||||||
tags=["Swarm Deployments"],
|
tags=["Swarm Deployments"],
|
||||||
responses={
|
responses={
|
||||||
400: {"description": "Deployment mode must be 'swarm'"},
|
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"},
|
404: {"description": "A referenced host_uuid is not enrolled"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def api_deploy_swarm(
|
async def api_deploy_swarm(
|
||||||
req: SwarmDeployRequest,
|
req: SwarmDeployRequest,
|
||||||
repo: BaseRepository = Depends(get_repo),
|
repo: BaseRepository = Depends(get_repo),
|
||||||
|
_admin: dict = Depends(require_admin),
|
||||||
_operator: PeerCert = Depends(require_operator_cert),
|
_operator: PeerCert = Depends(require_operator_cert),
|
||||||
) -> SwarmDeployResponse:
|
) -> SwarmDeployResponse:
|
||||||
if req.config.mode != "swarm":
|
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.)
|
bundle to the operator. Bundle delivery to the worker (scp/sshpass/etc.)
|
||||||
is outside this process's trust boundary.
|
is outside this process's trust boundary.
|
||||||
|
|
||||||
Authorization: this mints a CA-signed identity (and its private key), so it
|
Authorization (defense-in-depth, both must pass):
|
||||||
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.
|
* :func:`require_admin` — an admin-role JWT. This is the primary
|
||||||
A worker's own cert cannot enroll further hosts.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -21,7 +26,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
|||||||
|
|
||||||
from decnet.swarm import pki
|
from decnet.swarm import pki
|
||||||
from decnet.web.db.repository import BaseRepository
|
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.router.swarm._mtls import PeerCert, require_operator_cert
|
||||||
from decnet.web.db.models import SwarmEnrolledBundle, SwarmEnrollRequest, SwarmUpdaterBundle
|
from decnet.web.db.models import SwarmEnrolledBundle, SwarmEnrollRequest, SwarmUpdaterBundle
|
||||||
|
|
||||||
@@ -35,6 +40,8 @@ router = APIRouter()
|
|||||||
tags=["Swarm Hosts"],
|
tags=["Swarm Hosts"],
|
||||||
responses={
|
responses={
|
||||||
400: {"description": "Bad Request (malformed JSON body)"},
|
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"},
|
409: {"description": "A worker with this name is already enrolled"},
|
||||||
422: {"description": "Request body validation error"},
|
422: {"description": "Request body validation error"},
|
||||||
},
|
},
|
||||||
@@ -42,6 +49,7 @@ router = APIRouter()
|
|||||||
async def api_enroll_host(
|
async def api_enroll_host(
|
||||||
req: SwarmEnrollRequest,
|
req: SwarmEnrollRequest,
|
||||||
repo: BaseRepository = Depends(get_repo),
|
repo: BaseRepository = Depends(get_repo),
|
||||||
|
_admin: dict = Depends(require_admin),
|
||||||
_operator: PeerCert = Depends(require_operator_cert),
|
_operator: PeerCert = Depends(require_operator_cert),
|
||||||
) -> SwarmEnrolledBundle:
|
) -> SwarmEnrolledBundle:
|
||||||
existing = await repo.get_swarm_host_by_name(req.name)
|
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 fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from decnet.web.db.repository import BaseRepository
|
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.router.swarm._mtls import PeerCert, require_operator_cert
|
||||||
from decnet.web.db.models import SwarmHostView
|
from decnet.web.db.models import SwarmHostView
|
||||||
|
|
||||||
@@ -16,11 +16,16 @@ router = APIRouter()
|
|||||||
"/hosts/{uuid}",
|
"/hosts/{uuid}",
|
||||||
response_model=SwarmHostView,
|
response_model=SwarmHostView,
|
||||||
tags=["Swarm Hosts"],
|
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(
|
async def api_get_host(
|
||||||
uuid: str,
|
uuid: str,
|
||||||
repo: BaseRepository = Depends(get_repo),
|
repo: BaseRepository = Depends(get_repo),
|
||||||
|
_admin: dict = Depends(require_admin),
|
||||||
_operator: PeerCert = Depends(require_operator_cert),
|
_operator: PeerCert = Depends(require_operator_cert),
|
||||||
) -> SwarmHostView:
|
) -> SwarmHostView:
|
||||||
row = await repo.get_swarm_host_by_uuid(uuid)
|
row = await repo.get_swarm_host_by_uuid(uuid)
|
||||||
|
|||||||
@@ -13,18 +13,27 @@ from typing import Optional
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from decnet.web.db.repository import BaseRepository
|
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.router.swarm._mtls import PeerCert, require_operator_cert
|
||||||
from decnet.web.db.models import DeckyShardView
|
from decnet.web.db.models import DeckyShardView
|
||||||
|
|
||||||
router = APIRouter()
|
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(
|
async def api_list_deckies(
|
||||||
host_uuid: Optional[str] = None,
|
host_uuid: Optional[str] = None,
|
||||||
state: Optional[str] = None,
|
state: Optional[str] = None,
|
||||||
repo: BaseRepository = Depends(get_repo),
|
repo: BaseRepository = Depends(get_repo),
|
||||||
|
_admin: dict = Depends(require_admin),
|
||||||
_operator: PeerCert = Depends(require_operator_cert),
|
_operator: PeerCert = Depends(require_operator_cert),
|
||||||
) -> list[DeckyShardView]:
|
) -> list[DeckyShardView]:
|
||||||
shards = await repo.list_decky_shards(host_uuid)
|
shards = await repo.list_decky_shards(host_uuid)
|
||||||
|
|||||||
@@ -7,17 +7,26 @@ from typing import Optional
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from decnet.web.db.repository import BaseRepository
|
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.router.swarm._mtls import PeerCert, require_operator_cert
|
||||||
from decnet.web.db.models import SwarmHostView
|
from decnet.web.db.models import SwarmHostView
|
||||||
|
|
||||||
router = APIRouter()
|
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(
|
async def api_list_hosts(
|
||||||
host_status: Optional[str] = None,
|
host_status: Optional[str] = None,
|
||||||
repo: BaseRepository = Depends(get_repo),
|
repo: BaseRepository = Depends(get_repo),
|
||||||
|
_admin: dict = Depends(require_admin),
|
||||||
_operator: PeerCert = Depends(require_operator_cert),
|
_operator: PeerCert = Depends(require_operator_cert),
|
||||||
) -> list[SwarmHostView]:
|
) -> list[SwarmHostView]:
|
||||||
rows = await repo.list_swarm_hosts(host_status)
|
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.logging import get_logger
|
||||||
from decnet.swarm.client import AgentClient
|
from decnet.swarm.client import AgentClient
|
||||||
from decnet.web.db.repository import BaseRepository
|
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.router.swarm._mtls import PeerCert, require_operator_cert
|
||||||
from decnet.web.db.models import (
|
from decnet.web.db.models import (
|
||||||
SwarmDeployResponse,
|
SwarmDeployResponse,
|
||||||
@@ -29,6 +29,8 @@ router = APIRouter()
|
|||||||
tags=["Swarm Deployments"],
|
tags=["Swarm Deployments"],
|
||||||
responses={
|
responses={
|
||||||
400: {"description": "Bad Request (malformed JSON body)"},
|
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"},
|
404: {"description": "A targeted host does not exist"},
|
||||||
422: {"description": "Request body validation error"},
|
422: {"description": "Request body validation error"},
|
||||||
},
|
},
|
||||||
@@ -36,6 +38,7 @@ router = APIRouter()
|
|||||||
async def api_teardown_swarm(
|
async def api_teardown_swarm(
|
||||||
req: SwarmTeardownRequest,
|
req: SwarmTeardownRequest,
|
||||||
repo: BaseRepository = Depends(get_repo),
|
repo: BaseRepository = Depends(get_repo),
|
||||||
|
_admin: dict = Depends(require_admin),
|
||||||
_operator: PeerCert = Depends(require_operator_cert),
|
_operator: PeerCert = Depends(require_operator_cert),
|
||||||
) -> SwarmDeployResponse:
|
) -> SwarmDeployResponse:
|
||||||
if req.host_uuid is not None:
|
if req.host_uuid is not None:
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ psutil==7.2.2
|
|||||||
pydantic==2.12.5
|
pydantic==2.12.5
|
||||||
pydantic_core==2.41.5
|
pydantic_core==2.41.5
|
||||||
Pygments==2.20.0
|
Pygments==2.20.0
|
||||||
PyJWT==2.12.1
|
PyJWT==2.13.0
|
||||||
pyparsing==3.3.2
|
pyparsing==3.3.2
|
||||||
pyrate-limiter==4.1.0
|
pyrate-limiter==4.1.0
|
||||||
py-serializable==2.1.0
|
py-serializable==2.1.0
|
||||||
|
|||||||
40
tests/swarm/conftest.py
Normal file
40
tests/swarm/conftest.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
"""Shared fixtures for swarm-controller tests.
|
||||||
|
|
||||||
|
V4.1.1: every operator endpoint on the swarm controller now requires an
|
||||||
|
admin-role JWT (``require_admin``) in addition to the loopback/mTLS transport
|
||||||
|
gate (``require_operator_cert``). The vast majority of swarm-controller tests
|
||||||
|
exercise *behavior* (enroll bundles, heartbeat pinning, topology resync), not
|
||||||
|
the auth gate, and predate the JWT requirement.
|
||||||
|
|
||||||
|
To keep those tests focused on their subject without threading a real token
|
||||||
|
through every ``/swarm/enroll`` setup call, this autouse fixture installs a
|
||||||
|
no-op ``require_admin`` override on the controller app. The override returns a
|
||||||
|
synthetic admin principal, so the transport gate (``require_operator_cert``)
|
||||||
|
and the endpoint logic still run exactly as before.
|
||||||
|
|
||||||
|
The dedicated auth test (``test_swarm_authz.py``) removes this override inside
|
||||||
|
its own client context so it exercises the *real* ``require_admin`` against
|
||||||
|
real JWTs — that file is the single source of truth for the gate's behavior.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from decnet.web.dependencies import require_admin
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _bypass_swarm_admin_gate():
|
||||||
|
"""Override require_admin on the swarm-controller app for behavior tests.
|
||||||
|
|
||||||
|
Yields the override callable so a test can detect/remove it if needed.
|
||||||
|
"""
|
||||||
|
from decnet.web.swarm_api import app
|
||||||
|
|
||||||
|
async def _fake_admin() -> dict:
|
||||||
|
return {"uuid": "test-admin", "role": "admin", "must_change_password": False}
|
||||||
|
|
||||||
|
app.dependency_overrides[require_admin] = _fake_admin
|
||||||
|
yield _fake_admin
|
||||||
|
app.dependency_overrides.pop(require_admin, None)
|
||||||
@@ -15,6 +15,12 @@ from decnet.web.db.factory import get_repository
|
|||||||
from decnet.web.dependencies import get_repo
|
from decnet.web.dependencies import get_repo
|
||||||
from decnet.web.router.swarm import api_heartbeat as hb_mod
|
from decnet.web.router.swarm import api_heartbeat as hb_mod
|
||||||
|
|
||||||
|
# NOTE: /swarm/enroll now requires an admin JWT (V4.1.1). The autouse
|
||||||
|
# `_bypass_swarm_admin_gate` fixture in tests/swarm/conftest.py installs a
|
||||||
|
# no-op require_admin override so this behavior suite's enroll-based setup
|
||||||
|
# keeps working. /swarm/heartbeat itself stays worker-facing (cert fingerprint
|
||||||
|
# pinning, no JWT) and is unaffected by the gate.
|
||||||
|
|
||||||
|
|
||||||
# ------------------------- shared fixtures (mirror test_swarm_api.py) ---
|
# ------------------------- shared fixtures (mirror test_swarm_api.py) ---
|
||||||
|
|
||||||
@@ -51,8 +57,9 @@ def client(repo, ca_dir: pathlib.Path):
|
|||||||
return repo
|
return repo
|
||||||
|
|
||||||
app.dependency_overrides[get_repo] = _override
|
app.dependency_overrides[get_repo] = _override
|
||||||
# loopback client so /swarm/enroll (operator-gated) accepts the certless
|
# loopback client so /swarm/enroll accepts the certless local-operator
|
||||||
# local-operator path during test setup.
|
# transport path; the admin gate is bypassed by the autouse conftest
|
||||||
|
# fixture (this suite tests heartbeat, not the JWT gate).
|
||||||
with TestClient(app, client=("127.0.0.1", 50000)) as c:
|
with TestClient(app, client=("127.0.0.1", 50000)) as c:
|
||||||
yield c
|
yield c
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
"""Authorization for the swarm control plane.
|
"""Authorization for the swarm control plane.
|
||||||
|
|
||||||
Two layers, both fail-closed:
|
Three fail-closed layers:
|
||||||
1. ``_guard_bind`` refuses a routable bind without --tls (CLI startup).
|
1. ``_guard_bind`` refuses a routable bind without --tls (CLI startup).
|
||||||
2. ``require_operator_cert`` gates every controller endpoint (HTTP layer).
|
2. ``require_admin`` (centralized RBAC) gates every operator endpoint with an
|
||||||
|
admin-role JWT (HTTP layer, primary application-layer gate).
|
||||||
|
3. ``require_operator_cert`` is the transport gate (mTLS operator CN, or a
|
||||||
|
loopback request) — defense-in-depth, no longer the only gate.
|
||||||
|
|
||||||
No live TLS: the off-box case is simulated by giving the TestClient a
|
No live TLS: the off-box case is simulated by giving the TestClient a
|
||||||
non-loopback client address with no peer cert in scope.
|
non-loopback client address with no peer cert in scope. The JWT layer is
|
||||||
|
exercised with real HS256 tokens minted against the test repo.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pathlib
|
import pathlib
|
||||||
|
import uuid as _uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
@@ -21,6 +26,7 @@ from fastapi.testclient import TestClient
|
|||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
from decnet.cli.swarmctl import _guard_bind
|
from decnet.cli.swarmctl import _guard_bind
|
||||||
|
from decnet.web.auth import create_access_token, get_password_hash
|
||||||
from decnet.web.db.factory import get_repository
|
from decnet.web.db.factory import get_repository
|
||||||
from decnet.web.dependencies import get_repo
|
from decnet.web.dependencies import get_repo
|
||||||
|
|
||||||
@@ -77,6 +83,10 @@ def ca_dir(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.P
|
|||||||
return ca
|
return ca
|
||||||
|
|
||||||
|
|
||||||
|
ADMIN_UUID = "admin-uuid-authz"
|
||||||
|
VIEWER_UUID = "viewer-uuid-authz"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch):
|
def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch):
|
||||||
r = get_repository(db_path=str(tmp_path / "authz.db"))
|
r = get_repository(db_path=str(tmp_path / "authz.db"))
|
||||||
@@ -85,19 +95,62 @@ def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch):
|
|||||||
|
|
||||||
monkeypatch.setattr(deps, "repo", r)
|
monkeypatch.setattr(deps, "repo", r)
|
||||||
monkeypatch.setattr(swarm_api_mod, "repo", r)
|
monkeypatch.setattr(swarm_api_mod, "repo", r)
|
||||||
|
# require_admin caches user lookups for 10s; clear so a freshly-seeded
|
||||||
|
# user is visible and doesn't leak across tests.
|
||||||
|
deps._reset_user_cache()
|
||||||
return r
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_users(repo) -> None:
|
||||||
|
"""Seed one admin and one viewer so require_admin has real rows to resolve."""
|
||||||
|
await repo.create_user({
|
||||||
|
"uuid": ADMIN_UUID,
|
||||||
|
"username": "authz-admin",
|
||||||
|
"password_hash": get_password_hash("x"),
|
||||||
|
"role": "admin",
|
||||||
|
"must_change_password": False,
|
||||||
|
})
|
||||||
|
await repo.create_user({
|
||||||
|
"uuid": VIEWER_UUID,
|
||||||
|
"username": "authz-viewer",
|
||||||
|
"password_hash": get_password_hash("x"),
|
||||||
|
"role": "viewer",
|
||||||
|
"must_change_password": False,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _token(user_uuid: str) -> str:
|
||||||
|
# Mirrors api_login: uuid + per-token jti (the denylist key). create_access_token
|
||||||
|
# stamps exp + iat. Role is resolved from the DB row by require_admin, not the token.
|
||||||
|
return create_access_token(data={"uuid": user_uuid, "jti": _uuid.uuid4().hex})
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_headers() -> dict[str, str]:
|
||||||
|
return {"Authorization": f"Bearer {_token(ADMIN_UUID)}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _viewer_headers() -> dict[str, str]:
|
||||||
|
return {"Authorization": f"Bearer {_token(VIEWER_UUID)}"}
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def _client(repo, client_addr: tuple[str, int]):
|
def _client(repo, client_addr: tuple[str, int]):
|
||||||
# The `with TestClient(...)` form runs the controller lifespan, which
|
# The `with TestClient(...)` form runs the controller lifespan, which
|
||||||
# creates the swarm schema against the test repo.
|
# creates the swarm schema against the test repo.
|
||||||
|
import decnet.web.dependencies as deps
|
||||||
|
from decnet.web.dependencies import require_admin
|
||||||
from decnet.web.swarm_api import app
|
from decnet.web.swarm_api import app
|
||||||
|
|
||||||
async def _override() -> Any:
|
async def _override() -> Any:
|
||||||
return repo
|
return repo
|
||||||
|
|
||||||
app.dependency_overrides[get_repo] = _override
|
app.dependency_overrides[get_repo] = _override
|
||||||
|
# This file is the source of truth for the JWT gate — drop the autouse
|
||||||
|
# bypass installed by conftest so the REAL require_admin runs here.
|
||||||
|
app.dependency_overrides.pop(require_admin, None)
|
||||||
|
# The auth caches are module-global; a prior test may have cached a MISS
|
||||||
|
# for our seeded uuids. Clear so require_admin resolves the fresh rows.
|
||||||
|
deps._reset_user_cache()
|
||||||
try:
|
try:
|
||||||
with TestClient(app, client=client_addr) as c:
|
with TestClient(app, client=client_addr) as c:
|
||||||
yield c
|
yield c
|
||||||
@@ -105,31 +158,166 @@ def _client(repo, client_addr: tuple[str, int]):
|
|||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# Every operator route, with a body that gets past validation so the only
|
||||||
|
# thing that can reject the request is the auth layer.
|
||||||
|
# For {uuid} routes a syntactically-valid UUID is used: the auth gate fires
|
||||||
|
# before the repo lookup, so 401/403 is the only possible outcome without a
|
||||||
|
# valid token. In the admin happy-path tests a real host is seeded so that
|
||||||
|
# 200/204 confirms end-to-end gate passage.
|
||||||
|
_OPERATOR_ROUTES: list[tuple[str, str, dict | None]] = [
|
||||||
|
("POST", "/swarm/enroll", {"name": "x", "address": "10.0.0.1", "agent_port": 8765}),
|
||||||
|
("GET", "/swarm/hosts", None),
|
||||||
|
("POST", "/swarm/check", None),
|
||||||
|
("GET", "/swarm/deckies", None),
|
||||||
|
("POST", "/swarm/teardown", {}),
|
||||||
|
(
|
||||||
|
"POST",
|
||||||
|
"/swarm/deploy",
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"mode": "swarm",
|
||||||
|
"interface": "eth0",
|
||||||
|
"subnet": "10.99.0.0/24",
|
||||||
|
"gateway": "10.99.0.1",
|
||||||
|
"deckies": [
|
||||||
|
{
|
||||||
|
"name": "authz-probe",
|
||||||
|
"ip": "10.99.0.2",
|
||||||
|
"services": ["ssh"],
|
||||||
|
"distro": "debian",
|
||||||
|
"base_image": "debian:bookworm-slim",
|
||||||
|
"hostname": "probe01",
|
||||||
|
"host_uuid": "00000000-0000-0000-0000-000000000001",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
("GET", "/swarm/hosts/00000000-0000-0000-0000-000000000099", None),
|
||||||
|
("DELETE", "/swarm/hosts/00000000-0000-0000-0000-000000000099", None),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_offbox_certless_caller_is_refused_on_every_operator_route(
|
def test_offbox_certless_caller_is_refused_on_every_operator_route(
|
||||||
repo, ca_dir: pathlib.Path
|
repo, ca_dir: pathlib.Path
|
||||||
) -> None:
|
) -> None:
|
||||||
# No TLS peer cert + non-loopback client = an off-box attacker. Every
|
# No JWT + no TLS peer cert + non-loopback client = an off-box attacker.
|
||||||
# operator route must 403 (the bind guard makes this combination
|
# The JWT gate runs first, so the refusal is 401 (and the cert gate would
|
||||||
# unreachable in production, but the HTTP layer fails closed regardless).
|
# 403 it regardless). Either way: fail closed on every operator route.
|
||||||
with _client(repo, ("10.0.0.99", 40000)) as c:
|
with _client(repo, ("10.0.0.99", 40000)) as c:
|
||||||
assert c.post(
|
for method, path, body in _OPERATOR_ROUTES:
|
||||||
"/swarm/enroll",
|
resp = c.request(method, path, json=body)
|
||||||
json={"name": "evil", "address": "10.0.0.99", "agent_port": 8765},
|
assert resp.status_code in (401, 403), f"{method} {path} -> {resp.status_code}"
|
||||||
).status_code == 403
|
|
||||||
assert c.get("/swarm/hosts").status_code == 403
|
|
||||||
assert c.post("/swarm/check").status_code == 403
|
|
||||||
assert c.get("/swarm/deckies").status_code == 403
|
|
||||||
assert c.post("/swarm/teardown", json={}).status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
def test_loopback_operator_is_allowed(repo, ca_dir: pathlib.Path) -> None:
|
def test_loopback_without_jwt_is_now_rejected(repo, ca_dir: pathlib.Path) -> None:
|
||||||
# The shipping single-host default: local operator over plaintext loopback.
|
# REGRESSION GUARD (V4.1.1a): loopback transport alone is no longer enough.
|
||||||
|
# A local caller with no admin JWT must be refused — this is the whole point
|
||||||
|
# of layering require_admin on top of the loopback trust boundary.
|
||||||
|
import anyio
|
||||||
with _client(repo, ("127.0.0.1", 40000)) as c:
|
with _client(repo, ("127.0.0.1", 40000)) as c:
|
||||||
|
anyio.run(lambda: _seed_users(repo)) # schema is live once lifespan ran
|
||||||
|
for method, path, body in _OPERATOR_ROUTES:
|
||||||
|
resp = c.request(method, path, json=body)
|
||||||
|
assert resp.status_code == 401, f"{method} {path} -> {resp.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_loopback_viewer_jwt_is_forbidden(repo, ca_dir: pathlib.Path) -> None:
|
||||||
|
# A valid but non-admin JWT must be rejected by the role gate (403) on
|
||||||
|
# every operator route, not just GET /swarm/hosts.
|
||||||
|
import anyio
|
||||||
|
with _client(repo, ("127.0.0.1", 40000)) as c:
|
||||||
|
anyio.run(lambda: _seed_users(repo))
|
||||||
|
for method, path, body in _OPERATOR_ROUTES:
|
||||||
|
resp = c.request(method, path, json=body, headers=_viewer_headers())
|
||||||
|
assert resp.status_code == 403, f"{method} {path} -> {resp.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_loopback_admin_jwt_is_allowed(repo, ca_dir: pathlib.Path) -> None:
|
||||||
|
# The shipping single-host default: local operator over plaintext loopback,
|
||||||
|
# now carrying an admin JWT. Both gates pass -> the request succeeds or
|
||||||
|
# produces a domain error (never 401/403).
|
||||||
|
import anyio
|
||||||
|
import decnet.web.router.swarm.api_deploy_swarm as deploy_mod
|
||||||
|
|
||||||
|
with _client(repo, ("127.0.0.1", 40000)) as c:
|
||||||
|
anyio.run(lambda: _seed_users(repo))
|
||||||
|
|
||||||
|
# ---- enroll a host so uuid-based routes have a real target ----
|
||||||
enrolled = c.post(
|
enrolled = c.post(
|
||||||
"/swarm/enroll",
|
"/swarm/enroll",
|
||||||
json={"name": "worker-ok", "address": "10.0.0.5", "agent_port": 8765},
|
json={"name": "worker-ok", "address": "10.0.0.5", "agent_port": 8765},
|
||||||
|
headers=_admin_headers(),
|
||||||
)
|
)
|
||||||
assert enrolled.status_code == 201, enrolled.text
|
assert enrolled.status_code == 201, enrolled.text
|
||||||
listed = c.get("/swarm/hosts")
|
host_uuid = enrolled.json()["host_uuid"]
|
||||||
|
|
||||||
|
# GET /swarm/hosts — original assertion preserved
|
||||||
|
listed = c.get("/swarm/hosts", headers=_admin_headers())
|
||||||
assert listed.status_code == 200
|
assert listed.status_code == 200
|
||||||
assert any(h["name"] == "worker-ok" for h in listed.json())
|
assert any(h["name"] == "worker-ok" for h in listed.json())
|
||||||
|
|
||||||
|
# GET /swarm/hosts/{uuid} — auth gate passes; real host found -> 200
|
||||||
|
got = c.get(f"/swarm/hosts/{host_uuid}", headers=_admin_headers())
|
||||||
|
assert got.status_code == 200, got.text
|
||||||
|
assert got.json()["uuid"] == host_uuid
|
||||||
|
|
||||||
|
# POST /swarm/deploy — mock dispatch to avoid live AgentClient calls;
|
||||||
|
# assert auth passes (would be 401/403 if the gate rejected).
|
||||||
|
from decnet.web.db.models import SwarmDeployResponse
|
||||||
|
async def _fake_dispatch(config, repo, dry_run=False, no_cache=False):
|
||||||
|
return SwarmDeployResponse(results=[])
|
||||||
|
deploy_mod.dispatch_decnet_config = _fake_dispatch
|
||||||
|
try:
|
||||||
|
deploy_resp = c.post(
|
||||||
|
"/swarm/deploy",
|
||||||
|
json={
|
||||||
|
"config": {
|
||||||
|
"mode": "swarm",
|
||||||
|
"interface": "eth0",
|
||||||
|
"subnet": "10.99.0.0/24",
|
||||||
|
"gateway": "10.99.0.1",
|
||||||
|
"deckies": [
|
||||||
|
{
|
||||||
|
"name": "authz-probe",
|
||||||
|
"ip": "10.99.0.2",
|
||||||
|
"services": ["ssh"],
|
||||||
|
"distro": "debian",
|
||||||
|
"base_image": "debian:bookworm-slim",
|
||||||
|
"hostname": "probe01",
|
||||||
|
"host_uuid": host_uuid,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
headers=_admin_headers(),
|
||||||
|
)
|
||||||
|
assert deploy_resp.status_code not in (401, 403), (
|
||||||
|
f"POST /swarm/deploy auth gate rejected: {deploy_resp.status_code}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
# Restore the real dispatch so other tests aren't affected
|
||||||
|
import importlib
|
||||||
|
importlib.reload(deploy_mod)
|
||||||
|
|
||||||
|
# DELETE /swarm/hosts/{uuid} — auth gate passes; host deleted -> 204
|
||||||
|
# Enroll a second host specifically to delete so worker-ok stays available
|
||||||
|
enrolled2 = c.post(
|
||||||
|
"/swarm/enroll",
|
||||||
|
json={"name": "worker-del", "address": "10.0.0.6", "agent_port": 8765},
|
||||||
|
headers=_admin_headers(),
|
||||||
|
)
|
||||||
|
assert enrolled2.status_code == 201, enrolled2.text
|
||||||
|
del_uuid = enrolled2.json()["host_uuid"]
|
||||||
|
|
||||||
|
# Mock AgentClient.self_destruct so DELETE doesn't attempt a real network call
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
with patch(
|
||||||
|
"decnet.web.router.swarm.api_decommission_host.AgentClient"
|
||||||
|
) as mock_agent_cls:
|
||||||
|
mock_ctx = AsyncMock()
|
||||||
|
mock_ctx.self_destruct = AsyncMock(return_value=None)
|
||||||
|
mock_agent_cls.return_value.__aenter__ = AsyncMock(return_value=mock_ctx)
|
||||||
|
mock_agent_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
deleted = c.delete(f"/swarm/hosts/{del_uuid}", headers=_admin_headers())
|
||||||
|
assert deleted.status_code == 204, deleted.text
|
||||||
|
|||||||
Reference in New Issue
Block a user