feat(gateway): mandatory identity-token attribution on every data plane (PR #354 review)
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:<token>@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.<gate>/.extraHeader: x-bot-bottle-identity` in the agent's git config (only over the http transport). - supervise: `mcp add --header x-bot-bottle-identity: <token>` (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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -35,6 +35,10 @@ class DockerBottlePlan(BottlePlan):
|
||||
# Likewise the supervise MCP endpoint at the gateway (`http://<gw>: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:
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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://<bottle_id>:<token>@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(<bottle_id>:<token>)` 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("?")
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user