feat(control-plane): role-scoped signed tokens so the gateway can't drive operator routes
test / integration-docker (pull_request) Successful in 17s
tracker-policy-pr / check-pr (pull_request) Successful in 16s
test / unit (pull_request) Successful in 39s
lint / lint (push) Successful in 56s
test / integration-firecracker (pull_request) Successful in 3m21s
test / coverage (pull_request) Successful in 19s
test / publish-infra (pull_request) Has been skipped
test / integration-docker (pull_request) Successful in 17s
tracker-policy-pr / check-pr (pull_request) Successful in 16s
test / unit (pull_request) Successful in 39s
lint / lint (push) Successful in 56s
test / integration-firecracker (pull_request) Successful in 3m21s
test / coverage (pull_request) Successful in 19s
test / publish-infra (pull_request) Has been skipped
Review follow-up on #469: the data plane held the same control-plane secret that authorizes every route, so a compromised egress/git-gate could queue a supervise proposal AND approve it (or rewrite policy, read injected tokens) — the (source_ip, identity_token) checks attribute the *bottle*, not the caller. Replace the single shared bearer secret with role-scoped, HMAC-signed tokens (compact HS256 JWTs, stdlib-only — no new dependency): * new `control_auth` mints/verifies `{role}` tokens; roles are `gateway` (data plane) and `cli` (host operator/launcher). * the orchestrator holds only the signing *key* and verifies; `dispatch` gates each route by role — `gateway` reaches /resolve + /supervise/ {propose,poll}, everything else is `cli`-only (401 unauthenticated, 403 wrong role). * the gateway is handed a pre-minted `gateway` token it cannot rewrite into `cli`; the host CLI mints its own `cli` token from the host key. * `gateway_init` scopes the signing key to the orchestrator process and the gateway token to the data-plane daemons, so even in the combined infra container a compromised data-plane daemon never sees the key. Launchers (docker gateway + infra, macOS infra) inject the minted token(s); Firecracker stays open behind its nft boundary. Open mode (no key) still grants full `cli` access — the fail-visible fallback for tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,7 @@ import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from bot_bottle.control_auth import ROLE_CLI, ROLE_GATEWAY, mint
|
||||
from bot_bottle.orchestrator.client import OrchestratorClient
|
||||
from bot_bottle.orchestrator.lifecycle import OrchestratorService
|
||||
from bot_bottle.paths import host_control_plane_token
|
||||
@@ -85,7 +86,11 @@ class TestDockerControlPlaneAuthIntegration(unittest.TestCase):
|
||||
host_root=host_root,
|
||||
)
|
||||
cls.svc.ensure_running()
|
||||
cls.token = host_control_plane_token()
|
||||
# The control plane now verifies role-scoped signed tokens, not the raw
|
||||
# key. Mint one of each role from the host signing key (issue #469 review).
|
||||
signing_key = host_control_plane_token()
|
||||
cls.cli_token = mint(ROLE_CLI, signing_key)
|
||||
cls.gateway_token = mint(ROLE_GATEWAY, signing_key)
|
||||
|
||||
@staticmethod
|
||||
def _teardown_docker(
|
||||
@@ -136,11 +141,17 @@ class TestDockerControlPlaneAuthIntegration(unittest.TestCase):
|
||||
status, _ = self._request("GET", "/bottles", token="not-the-real-secret")
|
||||
self.assertEqual(401, status)
|
||||
|
||||
def test_bottles_accepts_the_real_token(self) -> None:
|
||||
status, payload = self._request("GET", "/bottles", token=self.token)
|
||||
def test_bottles_accepts_the_cli_token(self) -> None:
|
||||
status, payload = self._request("GET", "/bottles", token=self.cli_token)
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual([], payload["bottles"])
|
||||
|
||||
def test_bottles_rejects_the_gateway_token(self) -> None:
|
||||
"""A compromised gateway holds only a `gateway` token — it must not be
|
||||
able to enumerate bottles (an operator route) with it (issue #469)."""
|
||||
status, _ = self._request("GET", "/bottles", token=self.gateway_token)
|
||||
self.assertEqual(403, status)
|
||||
|
||||
def test_resolve_rejects_a_caller_with_no_token(self) -> None:
|
||||
"""The credential-lift attack from issue #400: an agent could POST
|
||||
/resolve directly and read back the upstream tokens it's never meant
|
||||
@@ -148,6 +159,13 @@ class TestDockerControlPlaneAuthIntegration(unittest.TestCase):
|
||||
status, _ = self._request("POST", "/resolve")
|
||||
self.assertEqual(401, status)
|
||||
|
||||
def test_resolve_accepts_the_gateway_token(self) -> None:
|
||||
"""The gateway's own token DOES reach /resolve: with an empty body it
|
||||
gets 400 (missing source_ip) rather than the 401 a rejected caller sees
|
||||
— proving the role gate let the gateway token through."""
|
||||
status, _ = self._request("POST", "/resolve", token=self.gateway_token)
|
||||
self.assertEqual(400, status) # past the role gate; body validation fails
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user