From 8d18c592012489c05e4d987e0c81f24c000e0511 Mon Sep 17 00:00:00 2001 From: anti Date: Tue, 9 Jun 2026 17:08:10 -0400 Subject: [PATCH] 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). --- .gitignore | 1 + decnet/cli/utils.py | 27 ++- decnet/web/router/swarm/api_check_hosts.py | 13 +- .../web/router/swarm/api_decommission_host.py | 9 +- decnet/web/router/swarm/api_deploy_swarm.py | 5 +- decnet/web/router/swarm/api_enroll_host.py | 18 +- decnet/web/router/swarm/api_get_host.py | 9 +- decnet/web/router/swarm/api_list_deckies.py | 13 +- decnet/web/router/swarm/api_list_hosts.py | 13 +- decnet/web/router/swarm/api_teardown_swarm.py | 5 +- requirements.lock | 2 +- tests/swarm/conftest.py | 40 ++++ tests/swarm/test_heartbeat.py | 11 +- tests/swarm/test_swarm_authz.py | 222 ++++++++++++++++-- 14 files changed, 350 insertions(+), 38 deletions(-) create mode 100644 tests/swarm/conftest.py diff --git a/.gitignore b/.gitignore index e7b7efc9..23784f91 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,4 @@ enterprise-attack-*.json # pytest failure dump files testfail +.phaseloop/ diff --git a/decnet/cli/utils.py b/decnet/cli/utils.py index c866f5a6..fcbe2c22 100644 --- a/decnet/cli/utils.py +++ b/decnet/cli/utils.py @@ -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 diff --git a/decnet/web/router/swarm/api_check_hosts.py b/decnet/web/router/swarm/api_check_hosts.py index 8956f7ce..5a931c42 100644 --- a/decnet/web/router/swarm/api_check_hosts.py +++ b/decnet/web/router/swarm/api_check_hosts.py @@ -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() diff --git a/decnet/web/router/swarm/api_decommission_host.py b/decnet/web/router/swarm/api_decommission_host.py index fb69d2ce..974e2fe8 100644 --- a/decnet/web/router/swarm/api_decommission_host.py +++ b/decnet/web/router/swarm/api_decommission_host.py @@ -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) diff --git a/decnet/web/router/swarm/api_deploy_swarm.py b/decnet/web/router/swarm/api_deploy_swarm.py index a5561bc6..7a95a5e8 100644 --- a/decnet/web/router/swarm/api_deploy_swarm.py +++ b/decnet/web/router/swarm/api_deploy_swarm.py @@ -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": diff --git a/decnet/web/router/swarm/api_enroll_host.py b/decnet/web/router/swarm/api_enroll_host.py index 37aa6f2e..a64829f9 100644 --- a/decnet/web/router/swarm/api_enroll_host.py +++ b/decnet/web/router/swarm/api_enroll_host.py @@ -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) diff --git a/decnet/web/router/swarm/api_get_host.py b/decnet/web/router/swarm/api_get_host.py index a96686d9..f88bc491 100644 --- a/decnet/web/router/swarm/api_get_host.py +++ b/decnet/web/router/swarm/api_get_host.py @@ -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) diff --git a/decnet/web/router/swarm/api_list_deckies.py b/decnet/web/router/swarm/api_list_deckies.py index e0973d33..66e3ffab 100644 --- a/decnet/web/router/swarm/api_list_deckies.py +++ b/decnet/web/router/swarm/api_list_deckies.py @@ -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) diff --git a/decnet/web/router/swarm/api_list_hosts.py b/decnet/web/router/swarm/api_list_hosts.py index bb5fbdd1..9216541f 100644 --- a/decnet/web/router/swarm/api_list_hosts.py +++ b/decnet/web/router/swarm/api_list_hosts.py @@ -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) diff --git a/decnet/web/router/swarm/api_teardown_swarm.py b/decnet/web/router/swarm/api_teardown_swarm.py index 2530fc65..3983f03b 100644 --- a/decnet/web/router/swarm/api_teardown_swarm.py +++ b/decnet/web/router/swarm/api_teardown_swarm.py @@ -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: diff --git a/requirements.lock b/requirements.lock index a50ce9de..88300159 100644 --- a/requirements.lock +++ b/requirements.lock @@ -49,7 +49,7 @@ psutil==7.2.2 pydantic==2.12.5 pydantic_core==2.41.5 Pygments==2.20.0 -PyJWT==2.12.1 +PyJWT==2.13.0 pyparsing==3.3.2 pyrate-limiter==4.1.0 py-serializable==2.1.0 diff --git a/tests/swarm/conftest.py b/tests/swarm/conftest.py new file mode 100644 index 00000000..08464b46 --- /dev/null +++ b/tests/swarm/conftest.py @@ -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) diff --git a/tests/swarm/test_heartbeat.py b/tests/swarm/test_heartbeat.py index 4ae38f6e..1e8b57a3 100644 --- a/tests/swarm/test_heartbeat.py +++ b/tests/swarm/test_heartbeat.py @@ -15,6 +15,12 @@ from decnet.web.db.factory import get_repository from decnet.web.dependencies import get_repo 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) --- @@ -51,8 +57,9 @@ def client(repo, ca_dir: pathlib.Path): return repo app.dependency_overrides[get_repo] = _override - # loopback client so /swarm/enroll (operator-gated) accepts the certless - # local-operator path during test setup. + # loopback client so /swarm/enroll accepts the certless local-operator + # 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: yield c app.dependency_overrides.clear() diff --git a/tests/swarm/test_swarm_authz.py b/tests/swarm/test_swarm_authz.py index eb4bf6b5..67cdb1fd 100644 --- a/tests/swarm/test_swarm_authz.py +++ b/tests/swarm/test_swarm_authz.py @@ -1,16 +1,21 @@ # SPDX-License-Identifier: AGPL-3.0-or-later """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). - 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 -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 import pathlib +import uuid as _uuid from typing import Any import contextlib @@ -21,6 +26,7 @@ from fastapi.testclient import TestClient from typer.testing import CliRunner 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.dependencies import get_repo @@ -77,6 +83,10 @@ def ca_dir(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.P return ca +ADMIN_UUID = "admin-uuid-authz" +VIEWER_UUID = "viewer-uuid-authz" + + @pytest.fixture def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch): 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(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 +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 def _client(repo, client_addr: tuple[str, int]): # The `with TestClient(...)` form runs the controller lifespan, which # 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 async def _override() -> Any: return repo 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: with TestClient(app, client=client_addr) as c: yield c @@ -105,31 +158,166 @@ def _client(repo, client_addr: tuple[str, int]): 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( repo, ca_dir: pathlib.Path ) -> None: - # No TLS peer cert + non-loopback client = an off-box attacker. Every - # operator route must 403 (the bind guard makes this combination - # unreachable in production, but the HTTP layer fails closed regardless). + # No JWT + no TLS peer cert + non-loopback client = an off-box attacker. + # The JWT gate runs first, so the refusal is 401 (and the cert gate would + # 403 it regardless). Either way: fail closed on every operator route. with _client(repo, ("10.0.0.99", 40000)) as c: - assert c.post( - "/swarm/enroll", - json={"name": "evil", "address": "10.0.0.99", "agent_port": 8765}, - ).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 + for method, path, body in _OPERATOR_ROUTES: + resp = c.request(method, path, json=body) + assert resp.status_code in (401, 403), f"{method} {path} -> {resp.status_code}" -def test_loopback_operator_is_allowed(repo, ca_dir: pathlib.Path) -> None: - # The shipping single-host default: local operator over plaintext loopback. +def test_loopback_without_jwt_is_now_rejected(repo, ca_dir: pathlib.Path) -> None: + # 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: + 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( "/swarm/enroll", json={"name": "worker-ok", "address": "10.0.0.5", "agent_port": 8765}, + headers=_admin_headers(), ) 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 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