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).
324 lines
13 KiB
Python
324 lines
13 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
"""Authorization for the swarm control plane.
|
|
|
|
Three fail-closed layers:
|
|
1. ``_guard_bind`` refuses a routable bind without --tls (CLI startup).
|
|
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. 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
|
|
|
|
import pytest
|
|
import typer
|
|
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
|
|
|
|
|
|
# ------------------------- layer 1: bind guard ------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize("host", ["127.0.0.1", "::1", "localhost"])
|
|
def test_guard_bind_allows_loopback_plaintext(host: str) -> None:
|
|
_guard_bind(host, tls=False) # must not raise
|
|
|
|
|
|
@pytest.mark.parametrize("host", ["0.0.0.0", "10.0.0.5", "192.168.1.10"])
|
|
def test_guard_bind_allows_routable_with_tls(host: str) -> None:
|
|
_guard_bind(host, tls=True) # mTLS makes a routable bind legitimate
|
|
|
|
|
|
@pytest.mark.parametrize("host", ["0.0.0.0", "10.0.0.5"])
|
|
def test_guard_bind_refuses_routable_plaintext(host: str) -> None:
|
|
with pytest.raises(typer.Exit) as ei:
|
|
_guard_bind(host, tls=False)
|
|
assert ei.value.exit_code == 2
|
|
|
|
|
|
def test_swarmctl_cli_refuses_routable_plaintext(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
# Wiring check: the guard fires before any subprocess is spawned.
|
|
import subprocess
|
|
|
|
from decnet.cli import app
|
|
|
|
called = {"popen": False}
|
|
|
|
def _no_popen(*a: Any, **k: Any): # pragma: no cover - must not run
|
|
called["popen"] = True
|
|
raise AssertionError("subprocess.Popen must not be reached")
|
|
|
|
monkeypatch.setattr(subprocess, "Popen", _no_popen)
|
|
result = CliRunner().invoke(app, ["swarmctl", "--host", "0.0.0.0", "--no-listener"])
|
|
assert result.exit_code == 2
|
|
assert called["popen"] is False
|
|
|
|
|
|
# ------------------------- layer 2: endpoint operator gate ------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def ca_dir(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
|
|
ca = tmp_path / "ca"
|
|
from decnet.swarm import pki
|
|
from decnet.web.router.swarm import api_enroll_host as enroll_mod
|
|
|
|
monkeypatch.setattr(pki, "DEFAULT_CA_DIR", ca)
|
|
monkeypatch.setattr(enroll_mod, "pki", pki)
|
|
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"))
|
|
import decnet.web.dependencies as deps
|
|
import decnet.web.swarm_api as swarm_api_mod
|
|
|
|
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
|
|
finally:
|
|
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 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:
|
|
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_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
|
|
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
|