From d4b27ebf1f6a20eb53046e1cf746d0a488cbfdd9 Mon Sep 17 00:00:00 2001 From: didericis Date: Thu, 16 Jul 2026 17:02:41 -0400 Subject: [PATCH] feat(gateway): mandatory identity-token attribution on every data plane (PR #354 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review: the /31 TAP doesn't make source IP unspoofable, and the app-layer token was returned by launch but never delivered or enforced, so a spoofed source could select a victim bottle's policy/tokens. Make the token mandatory and deliver it on each attributed plane (anti-spoof landed separately as the network boundary). Enforcement (control plane): - `Orchestrator.resolve` now requires a matching (source_ip, identity_token) pair (constant-time) — no source-IP-only fallback. `/resolve` fail-closes (403) on a missing/empty/mismatched token. Delivery, per plane (the token is `token_urlsafe`, safe in a URL): - egress: proxy credentials (`HTTPS_PROXY=http://bottle:@gw`). The addon reads `Proxy-Authorization` — from the request (HTTP) or captured at the CONNECT for HTTPS tunnels (keyed by client conn, cleared on disconnect) — validates, and strips it (+ the legacy header) before upstream. - git-http: a URL-scoped `http./.extraHeader: x-bot-bottle-identity` in the agent's git config (only over the http transport). - supervise: `mcp add --header x-bot-bottle-identity: ` (claude + codex); the server reads the header and passes it to resolve. Wiring: thread `ctx.identity_token` onto the firecracker + docker plans and into the agent env/config at launch. Verified on a KVM host: egress with the correct proxy-cred token returns 200 (HTTP and HTTPS/CONNECT), and no-token / wrong-token return 403; a real `cli.py start --backend=firecracker` launch provisions git config + the supervise MCP header and reaches the agent session, all under mandatory enforcement. Fixed a `claude mcp add` arg-order bug (--header must follow the positional name/url) found by that launch. Transparent proxy for tools that ignore proxy env is deferred to a follow-up (see thread); anti-spoof + host firewall remain the fail-closed boundary. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck --- bot_bottle/agent_provider.py | 1 + bot_bottle/backend/docker/bottle_plan.py | 4 ++ .../backend/docker/consolidated_compose.py | 8 ++- bot_bottle/backend/docker/launch.py | 1 + bot_bottle/backend/firecracker/bottle_plan.py | 4 ++ bot_bottle/backend/firecracker/launch.py | 13 +++- bot_bottle/contrib/claude/agent_provider.py | 10 ++- bot_bottle/contrib/codex/agent_provider.py | 10 ++- bot_bottle/egress_addon.py | 66 +++++++++++++++++-- bot_bottle/git_gate_render.py | 13 ++++ bot_bottle/orchestrator/control_plane.py | 5 +- bot_bottle/orchestrator/service.py | 22 ++++--- bot_bottle/supervise_server.py | 9 ++- tests/unit/test_git_gate_render_provision.py | 13 ++++ tests/unit/test_orchestrator_control_plane.py | 18 ++++- tests/unit/test_orchestrator_service.py | 7 +- 16 files changed, 177 insertions(+), 27 deletions(-) diff --git a/bot_bottle/agent_provider.py b/bot_bottle/agent_provider.py index f6b0425..3ef816b 100644 --- a/bot_bottle/agent_provider.py +++ b/bot_bottle/agent_provider.py @@ -266,6 +266,7 @@ class AgentProvider(ABC): gate_scheme = getattr(plan, "git_gate_insteadof_scheme", "git") content = git_gate_render_gitconfig( manifest_bottle.git, gate_host, scheme=gate_scheme, + identity_token=getattr(plan, "identity_token", ""), ) guest_gitconfig = f"{plan.guest_home}/.gitconfig" with tempfile.NamedTemporaryFile( diff --git a/bot_bottle/backend/docker/bottle_plan.py b/bot_bottle/backend/docker/bottle_plan.py index 774a3c3..55cee06 100644 --- a/bot_bottle/backend/docker/bottle_plan.py +++ b/bot_bottle/backend/docker/bottle_plan.py @@ -35,6 +35,10 @@ class DockerBottlePlan(BottlePlan): # Likewise the supervise MCP endpoint at the gateway (`http://:9100/`); # empty → the single-tenant `supervise` alias. agent_supervise_url: str = "" + # Per-bottle identity token the agent presents on every attributed request + # (egress proxy credentials, git-gate/supervise headers); set by launch + # from the orchestrator registration. Empty pre-registration. + identity_token: str = "" @property def container_name(self) -> str: diff --git a/bot_bottle/backend/docker/consolidated_compose.py b/bot_bottle/backend/docker/consolidated_compose.py index 596c351..fde30c9 100644 --- a/bot_bottle/backend/docker/consolidated_compose.py +++ b/bot_bottle/backend/docker/consolidated_compose.py @@ -31,7 +31,13 @@ def consolidated_agent_compose( ) -> dict[str, Any]: """A compose spec with only the agent service, on the external gateway network at `source_ip`, proxying egress through `gateway_ip`.""" - proxy_url = f"http://{gateway_ip}:{EGRESS_PORT}" + # Deliver the identity token as egress proxy credentials — the gateway + # reads Proxy-Authorization, validates the (source_ip, token) pair, and + # strips it before upstream. git-http/supervise get it via their own + # headers (git config extraHeader / MCP header). + token = getattr(plan, "identity_token", "") + cred = f"bottle:{token}@" if token else "" + proxy_url = f"http://{cred}{gateway_ip}:{EGRESS_PORT}" # git-http + supervise live on the gateway too and must NOT go through the # egress proxy — the agent reaches them directly by the gateway address. no_proxy = f"localhost,127.0.0.1,{gateway_ip}" diff --git a/bot_bottle/backend/docker/launch.py b/bot_bottle/backend/docker/launch.py index e99d87c..432b142 100644 --- a/bot_bottle/backend/docker/launch.py +++ b/bot_bottle/backend/docker/launch.py @@ -167,6 +167,7 @@ def launch( egress_plan=egress_plan, agent_git_gate_url=git_gate_url, agent_supervise_url=supervise_url, + identity_token=ctx.identity_token, ) # Step 5: render + up the agent-only compose, pinned on the shared diff --git a/bot_bottle/backend/firecracker/bottle_plan.py b/bot_bottle/backend/firecracker/bottle_plan.py index 9631957..92c17c4 100644 --- a/bot_bottle/backend/firecracker/bottle_plan.py +++ b/bot_bottle/backend/firecracker/bottle_plan.py @@ -18,6 +18,10 @@ class FirecrackerBottlePlan(BottlePlan): agent_proxy_url: str = "" agent_git_gate_url: str = "" agent_supervise_url: str = "" + # Per-bottle identity token the agent presents on every attributed request + # (egress proxy credentials, git-gate/supervise headers); set by launch + # from the orchestrator registration. Empty pre-registration. + identity_token: str = "" @property def container_name(self) -> str: diff --git a/bot_bottle/backend/firecracker/launch.py b/bot_bottle/backend/firecracker/launch.py index 0ebb916..a486894 100644 --- a/bot_bottle/backend/firecracker/launch.py +++ b/bot_bottle/backend/firecracker/launch.py @@ -147,7 +147,15 @@ def launch( plan, git_gate_plan=git_gate_plan, egress_plan=egress_plan, - agent_proxy_url=f"http://{slot.host_ip}:{EGRESS_PORT}", + identity_token=ctx.identity_token, + # Deliver the identity token as egress proxy credentials — clients + # honor `HTTPS_PROXY=http://id:token@gw` without app changes; the + # gateway reads Proxy-Authorization, validates the (source_ip, + # token) pair, and strips it before upstream. + agent_proxy_url=( + f"http://bottle:{ctx.identity_token}" + f"@{slot.host_ip}:{EGRESS_PORT}" + ), agent_git_gate_url=git_gate_url, agent_supervise_url=supervise_url, ) @@ -226,7 +234,8 @@ def _agent_guest_env(plan: FirecrackerBottlePlan, host_ip: str) -> dict[str, str """Env injected into every agent/exec call over SSH. The VM has no baked process env (it just runs init), so the proxy/CA/git/supervise wiring is applied per-invocation.""" - proxy_url = f"http://{host_ip}:{EGRESS_PORT}" + # Carries the identity token as proxy credentials (set in `launch`). + proxy_url = plan.agent_proxy_url or f"http://{host_ip}:{EGRESS_PORT}" no_proxy = f"localhost,127.0.0.1,{host_ip}" env: dict[str, str] = { "HTTPS_PROXY": proxy_url, "HTTP_PROXY": proxy_url, diff --git a/bot_bottle/contrib/claude/agent_provider.py b/bot_bottle/contrib/claude/agent_provider.py index 8d6dd47..7c1043b 100644 --- a/bot_bottle/contrib/claude/agent_provider.py +++ b/bot_bottle/contrib/claude/agent_provider.py @@ -32,6 +32,8 @@ if TYPE_CHECKING: _SUPERVISE_MCP_NAME = "supervise" +# App-layer identity token header (mirrors egress_addon / git_http_backend). +_IDENTITY_HEADER = "x-bot-bottle-identity" def _skills_dir(guest_home: str) -> str: @@ -301,9 +303,15 @@ class ClaudeAgentProvider(AgentProvider): if plan.supervise_plan is None: return info(f"registering supervise MCP server in agent claude config → {supervise_url}") + # Deliver the identity token as an MCP request header — the supervise + # daemon requires it (mandatory (source_ip, token) attribution). + token = getattr(plan, "identity_token", "") + header = ( + f" --header {shlex.quote(f'{_IDENTITY_HEADER}: {token}')}" if token else "" + ) r = bottle.exec( f"claude mcp add --scope user --transport http " - f"{_SUPERVISE_MCP_NAME} {supervise_url}", + f"{_SUPERVISE_MCP_NAME} {supervise_url}{header}", user="node", ) if r.returncode != 0: diff --git a/bot_bottle/contrib/codex/agent_provider.py b/bot_bottle/contrib/codex/agent_provider.py index 6a148f2..7b33edd 100644 --- a/bot_bottle/contrib/codex/agent_provider.py +++ b/bot_bottle/contrib/codex/agent_provider.py @@ -274,9 +274,17 @@ class CodexAgentProvider(AgentProvider): if plan.supervise_plan is None: return info(f"registering supervise MCP server in agent codex config → {supervise_url}") + # Deliver the identity token as an MCP request header — supervise + # requires it (mandatory (source_ip, token) attribution). If the codex + # CLI's header flag differs, mcp add just warns (non-fatal) and + # supervise fail-closes for this bottle until the flag is corrected. + token = getattr(plan, "identity_token", "") + header = ( + f"--header {shlex.quote(f'x-bot-bottle-identity: {token}')} " if token else "" + ) r = bottle.exec( f"{shlex.quote(_CODEX_CLI)} mcp add {_SUPERVISE_MCP_NAME} --url " - f"{shlex.quote(supervise_url)}", + f"{shlex.quote(supervise_url)} {header}".rstrip(), user="node", ) if r.returncode != 0: diff --git a/bot_bottle/egress_addon.py b/bot_bottle/egress_addon.py index 174f740..c69a635 100644 --- a/bot_bottle/egress_addon.py +++ b/bot_bottle/egress_addon.py @@ -6,6 +6,8 @@ egress container.""" from __future__ import annotations import asyncio +import base64 +import binascii import json import os import signal @@ -69,10 +71,28 @@ INTROSPECT_HOST = "_egress.local" # → legacy per-bottle single-tenant mode (unchanged). ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL" -# App-layer identity token (defense-in-depth over the source-IP invariant); -# the agent injects it, the addon strips it so it never leaks upstream. +# App-layer identity token. Delivered as proxy credentials +# (`HTTPS_PROXY=http://:@gw`): clients honor it as part of +# the proxy protocol without app changes, and the addon reads + strips it so +# it never leaks upstream. The legacy `x-bot-bottle-identity` request header +# is still stripped defensively (git-http uses that header on its own port). IDENTITY_HEADER = "x-bot-bottle-identity" + +def _token_from_proxy_auth(header: str) -> str: + """Extract the identity token (the password) from a `Proxy-Authorization: + Basic base64(:)` header. Empty on any malformed value — + the mandatory `/resolve` then fail-closes on the empty token.""" + scheme, _, encoded = header.partition(" ") + if scheme.lower() != "basic" or not encoded: + return "" + try: + decoded = base64.b64decode(encoded, validate=True).decode("utf-8") + except (binascii.Error, ValueError, UnicodeDecodeError): + return "" + _, _, password = decoded.partition(":") + return password + # Seconds the egress proxy holds a token-blocked request open waiting for the # operator's supervisor decision (PRD 0062), overridable via env. DEFAULT_TOKEN_ALLOW_TIMEOUT_SECONDS = 300.0 @@ -92,6 +112,10 @@ class EgressAddon: # Class default so addons built via __new__ (e.g. in tests) default to # single-tenant; __init__ sets the instance attribute for real runs. _resolver: "PolicyResolver | None" = None + # Class default so __new__-built addons have it (real runs get a fresh + # per-instance dict in __init__; only http_connect mutates it, which the + # request-flow tests don't exercise). + _conn_tokens: "dict[str, str]" = {} def __init__(self) -> None: self.routes_path = os.environ.get("EGRESS_ROUTES", DEFAULT_ROUTES_PATH) @@ -106,6 +130,10 @@ class EgressAddon: # scan. In-memory only (a restart re-prompts); mutated only from the # asyncio loop that runs the addon hooks, so no lock is needed. self._safe_tokens: dict[str, set[str]] = {} + # Per-client-connection identity token captured from the CONNECT's + # `Proxy-Authorization` (HTTPS tunnels don't repeat it on the bumped + # inner requests). Keyed by client_conn.id; cleared on disconnect. + self._conn_tokens: dict[str, str] = {} self._supervise_slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "").strip() self._token_allow_timeout = _token_allow_timeout_from_env(os.environ) self._reload(initial=True) @@ -247,12 +275,42 @@ class EgressAddon: return self.config, self._supervise_slug, os.environ conn = flow.client_conn client_ip = conn.peername[0] if conn and conn.peername else "" - token = flow.request.headers.get(IDENTITY_HEADER, "") - flow.request.headers.pop(IDENTITY_HEADER, None) + token = self._request_token(flow) config, slug, tokens = resolve_client_context(self._resolver, client_ip, token) env = {**os.environ, **tokens} if tokens else os.environ return config, slug, env + def _request_token(self, flow: http.HTTPFlow) -> str: + """The per-bottle identity token for this request, from the proxy + credentials — the delivery mechanism (`HTTPS_PROXY=http://id:token@gw`) + that clients honor without app changes. Plain-HTTP requests carry + `Proxy-Authorization` directly; HTTPS bumped requests inherit the token + captured from their tunnel's CONNECT. Read then stripped so it never + leaks upstream (also strips the legacy header, if present).""" + token = _token_from_proxy_auth( + flow.request.headers.get("Proxy-Authorization", "")) + flow.request.headers.pop("Proxy-Authorization", None) + flow.request.headers.pop(IDENTITY_HEADER, None) + conn = flow.client_conn + if not token and conn is not None: + token = self._conn_tokens.get(getattr(conn, "id", ""), "") + return token + + def http_connect(self, flow: http.HTTPFlow) -> None: + """Capture the identity token from an HTTPS tunnel's CONNECT (the inner + bumped requests won't carry `Proxy-Authorization`), keyed by client + connection, and strip it so it never reaches upstream.""" + token = _token_from_proxy_auth( + flow.request.headers.get("Proxy-Authorization", "")) + flow.request.headers.pop("Proxy-Authorization", None) + conn = flow.client_conn + if conn is not None and getattr(conn, "id", ""): + self._conn_tokens[conn.id] = token + + def client_disconnected(self, client: typing.Any) -> None: + """Drop the per-connection token when the client goes away.""" + self._conn_tokens.pop(getattr(client, "id", ""), None) + async def request(self, flow: http.HTTPFlow) -> None: request_path, _, query = flow.request.path.partition("?") diff --git a/bot_bottle/git_gate_render.py b/bot_bottle/git_gate_render.py index fb23bb2..c33cb89 100644 --- a/bot_bottle/git_gate_render.py +++ b/bot_bottle/git_gate_render.py @@ -19,6 +19,9 @@ from .manifest import ManifestBottle, ManifestGitEntry # Short network alias for git-gate inside the gateway. The # agent's `.gitconfig` insteadOf rewrites resolve through this name. GIT_GATE_HOSTNAME = "git-gate" +# App-layer identity token header the agent's git sends to git-http and the +# gateway validates (mirrors egress_addon / git_http_backend IDENTITY_HEADER). +IDENTITY_HEADER = "x-bot-bottle-identity" # Shared timeout (seconds) for all git-gate subprocess and CGI calls: # git daemon (--timeout/--init-timeout), the access-hook subprocess in # git_http_backend, and the git http-backend CGI subprocess. @@ -75,6 +78,7 @@ def _gitconfig_validate_value(field: str, value: str) -> None: def git_gate_render_gitconfig( entries: tuple[ManifestGitEntry, ...], gate_host: str, *, scheme: str = "git", + identity_token: str = "", ) -> str: """Render the agent's ~/.gitconfig content for git-gate `insteadOf` rewrites. Pure host-side, no docker / VM; @@ -96,6 +100,15 @@ def git_gate_render_gitconfig( "# the upstream bidirectionally (gitleaks-scanned push;\n", "# fetch-from-upstream-before-every-upload-pack via access-hook).\n", ] + # Over the smart-HTTP transport (VM backends), attach the per-bottle + # identity token as a request header on requests to the gate, scoped to + # its URL so it never goes to any other remote. git-http requires it (the + # gateway's mandatory (source_ip, token) attribution). git:// (single-tenant + # docker) carries no header — attribution there is the network alias. + if identity_token and scheme == "http": + _gitconfig_validate_value("identity_token", identity_token) + out.append(f'[http "http://{gate_host}/"]\n') + out.append(f"\textraHeader = {IDENTITY_HEADER}: {identity_token}\n") for entry in entries: _gitconfig_validate_value(f"repos[{entry.Name!r}].url", entry.Upstream) out.append(f'[url "{scheme}://{gate_host}/{entry.Name}.git"]\n') diff --git a/bot_bottle/orchestrator/control_plane.py b/bot_bottle/orchestrator/control_plane.py index 2e5dec1..c590915 100644 --- a/bot_bottle/orchestrator/control_plane.py +++ b/bot_bottle/orchestrator/control_plane.py @@ -129,8 +129,9 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches if method == "POST" and route == "/resolve": # The per-request lookup the multi-tenant gateway makes: returns the - # bottle's policy. identity_token is OPTIONAL — absent means resolve - # by source IP alone (network-layer attribution). + # bottle's policy. Requires a matching (source_ip, identity_token) + # pair — a missing/empty/mismatched token fail-closes (403), no + # source-IP-only fallback. try: data = _parse_json_object(body) except ValueError as e: diff --git a/bot_bottle/orchestrator/service.py b/bot_bottle/orchestrator/service.py index ecd1e86..c44e5be 100644 --- a/bot_bottle/orchestrator/service.py +++ b/bot_bottle/orchestrator/service.py @@ -99,16 +99,18 @@ class Orchestrator: """Fail-closed attribution (delegates to the registry).""" return self.registry.attribute(source_ip, identity_token) - def resolve(self, source_ip: str, identity_token: str = "") -> BottleRecord | None: - """Resolve the bottle behind a request — the source-IP-keyed lookup - the multi-tenant gateway makes per request; the returned record - carries its `policy`. With a token, full attribution (source IP + - token); without, network-layer attribution by source IP alone - (valid where the IP is unspoofable and the control plane is - gateway-only).""" - if identity_token: - return self.registry.attribute(source_ip, identity_token) - return self.registry.by_source_ip(source_ip) + def resolve(self, source_ip: str, identity_token: str) -> BottleRecord | None: + """Resolve the bottle behind a request — the per-request lookup the + multi-tenant gateway makes; the returned record carries its `policy`. + + **Mandatory pair**: requires a matching `(source_ip, identity_token)` + (constant-time). There is no source-IP-only fallback — the app-layer + token is delivered on every attributed data plane (egress proxy + credentials, git-gate/supervise headers), so a missing or mismatched + token fail-closes. This keeps a spoofed source IP (which the /31 TAP + alone does not prevent) from selecting another bottle's policy/tokens + without also holding that bottle's unguessable token.""" + return self.registry.attribute(source_ip, identity_token) def set_policy(self, bottle_id: str, policy: str) -> bool: """Update a bottle's gateway policy in place (live reload). False if diff --git a/bot_bottle/supervise_server.py b/bot_bottle/supervise_server.py index e52bf74..c9dff9c 100644 --- a/bot_bottle/supervise_server.py +++ b/bot_bottle/supervise_server.py @@ -65,6 +65,8 @@ except ModuleNotFoundError: MCP_PROTOCOL_VERSION = "2024-11-05" +# App-layer identity token header (mirrors egress_addon / git_http_backend). +IDENTITY_HEADER = "x-bot-bottle-identity" SERVER_NAME = "bot-bottle-supervise" SERVER_VERSION = "0.1.0" @@ -541,8 +543,13 @@ class MCPHandler(http.server.BaseHTTPRequestHandler): resolver = getattr(self.server, "policy_resolver", None) if resolver is None: return config + # The agent's MCP client sends the identity token as a request header + # (provisioned via `mcp add --header`); the orchestrator requires the + # (source_ip, token) pair, so a missing/wrong token fail-closes below. + headers = getattr(self, "headers", None) + token = headers.get(IDENTITY_HEADER, "") if headers is not None else "" try: - bottle_id = resolver.resolve_bottle_id(self.client_address[0]) + bottle_id = resolver.resolve_bottle_id(self.client_address[0], token) except PolicyResolveError as e: raise _RpcInternalError(f"orchestrator unreachable, cannot attribute: {e}") from e if not bottle_id: diff --git a/tests/unit/test_git_gate_render_provision.py b/tests/unit/test_git_gate_render_provision.py index cabb0be..9dabdd9 100644 --- a/tests/unit/test_git_gate_render_provision.py +++ b/tests/unit/test_git_gate_render_provision.py @@ -74,6 +74,19 @@ class TestRenderGitconfig(unittest.TestCase): out = git_gate_render_gitconfig((_entry(),), "1.2.3.4:9418", scheme="http") self.assertIn('[url "http://1.2.3.4:9418/repo.git"]', out) + def test_identity_token_extraheader_over_http(self) -> None: + # Delivered as a URL-scoped http.extraHeader so git-http can enforce + # the mandatory (source_ip, token) pair; only over the http transport. + out = git_gate_render_gitconfig( + (_entry(),), "1.2.3.4:9420", scheme="http", identity_token="TOK123") + self.assertIn('[http "http://1.2.3.4:9420/"]', out) + self.assertIn("extraHeader = x-bot-bottle-identity: TOK123", out) + + def test_identity_token_omitted_over_git_scheme(self) -> None: + out = git_gate_render_gitconfig( + (_entry(),), "git-gate", scheme="git", identity_token="TOK123") + self.assertNotIn("extraHeader", out) + def test_remote_key_alias_with_nondefault_port(self) -> None: out = git_gate_render_gitconfig( (_entry(RemoteKey="10.0.0.5", UpstreamPort="2222"),), "git-gate", diff --git a/tests/unit/test_orchestrator_control_plane.py b/tests/unit/test_orchestrator_control_plane.py index 9ce9ebb..6816990 100644 --- a/tests/unit/test_orchestrator_control_plane.py +++ b/tests/unit/test_orchestrator_control_plane.py @@ -171,14 +171,26 @@ class TestDispatch(unittest.TestCase): ) self.assertEqual(400, status) - def test_resolve_without_token_by_source_ip(self) -> None: - _, reg = dispatch( + def test_resolve_without_token_denies(self) -> None: + # Mandatory token: source-IP alone no longer resolves (fail-closed 403). + dispatch( self.orch, "POST", "/bottles", _body({"source_ip": "10.243.0.5", "policy": "P"}), ) - status, payload = dispatch( + status, _ = dispatch( self.orch, "POST", "/resolve", _body({"source_ip": "10.243.0.5"}) ) + self.assertEqual(403, status) + + def test_resolve_with_matching_token(self) -> None: + _, reg = dispatch( + self.orch, "POST", "/bottles", + _body({"source_ip": "10.243.0.6", "policy": "P"}), + ) + status, payload = dispatch( + self.orch, "POST", "/resolve", + _body({"source_ip": "10.243.0.6", "identity_token": reg["identity_token"]}), + ) self.assertEqual(200, status) self.assertEqual(reg["bottle_id"], payload["bottle_id"]) self.assertEqual("P", payload["policy"]) diff --git a/tests/unit/test_orchestrator_service.py b/tests/unit/test_orchestrator_service.py index 5dcbbd1..c8139c2 100644 --- a/tests/unit/test_orchestrator_service.py +++ b/tests/unit/test_orchestrator_service.py @@ -114,9 +114,12 @@ class TestOrchestrator(unittest.TestCase): def test_set_policy_unknown_is_false(self) -> None: self.assertFalse(self.orch.set_policy("ghost", "{}")) - def test_resolve_by_source_ip_without_token(self) -> None: + def test_resolve_requires_matching_token(self) -> None: + # Mandatory (source_ip, token) pair — no source-IP-only fallback. rec = self.orch.launch_bottle("10.243.0.1", policy="P") - got = self.orch.resolve("10.243.0.1") # network-layer, no token + self.assertIsNone(self.orch.resolve("10.243.0.1", "")) # empty token denies + self.assertIsNone(self.orch.resolve("10.243.0.1", "wrong")) # mismatch denies + got = self.orch.resolve("10.243.0.1", rec.identity_token) # exact pair assert got is not None self.assertEqual(rec.bottle_id, got.bottle_id) self.assertEqual("P", got.policy)