feat(gateway): mandatory identity-token attribution on every data plane (PR #354 review)
lint / lint (push) Successful in 2m12s
test / unit (pull_request) Successful in 1m12s
test / integration (pull_request) Successful in 27s
test / coverage (pull_request) Successful in 1m21s

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:
2026-07-16 17:02:41 -04:00
parent 914f01fa8f
commit d4b27ebf1f
16 changed files with 177 additions and 27 deletions
+1
View File
@@ -266,6 +266,7 @@ class AgentProvider(ABC):
gate_scheme = getattr(plan, "git_gate_insteadof_scheme", "git") gate_scheme = getattr(plan, "git_gate_insteadof_scheme", "git")
content = git_gate_render_gitconfig( content = git_gate_render_gitconfig(
manifest_bottle.git, gate_host, scheme=gate_scheme, manifest_bottle.git, gate_host, scheme=gate_scheme,
identity_token=getattr(plan, "identity_token", ""),
) )
guest_gitconfig = f"{plan.guest_home}/.gitconfig" guest_gitconfig = f"{plan.guest_home}/.gitconfig"
with tempfile.NamedTemporaryFile( with tempfile.NamedTemporaryFile(
+4
View File
@@ -35,6 +35,10 @@ class DockerBottlePlan(BottlePlan):
# Likewise the supervise MCP endpoint at the gateway (`http://<gw>:9100/`); # Likewise the supervise MCP endpoint at the gateway (`http://<gw>:9100/`);
# empty → the single-tenant `supervise` alias. # empty → the single-tenant `supervise` alias.
agent_supervise_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 @property
def container_name(self) -> str: def container_name(self) -> str:
@@ -31,7 +31,13 @@ def consolidated_agent_compose(
) -> dict[str, Any]: ) -> dict[str, Any]:
"""A compose spec with only the agent service, on the external gateway """A compose spec with only the agent service, on the external gateway
network at `source_ip`, proxying egress through `gateway_ip`.""" 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 # 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. # egress proxy — the agent reaches them directly by the gateway address.
no_proxy = f"localhost,127.0.0.1,{gateway_ip}" no_proxy = f"localhost,127.0.0.1,{gateway_ip}"
+1
View File
@@ -167,6 +167,7 @@ def launch(
egress_plan=egress_plan, egress_plan=egress_plan,
agent_git_gate_url=git_gate_url, agent_git_gate_url=git_gate_url,
agent_supervise_url=supervise_url, agent_supervise_url=supervise_url,
identity_token=ctx.identity_token,
) )
# Step 5: render + up the agent-only compose, pinned on the shared # Step 5: render + up the agent-only compose, pinned on the shared
@@ -18,6 +18,10 @@ class FirecrackerBottlePlan(BottlePlan):
agent_proxy_url: str = "" agent_proxy_url: str = ""
agent_git_gate_url: str = "" agent_git_gate_url: str = ""
agent_supervise_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 @property
def container_name(self) -> str: def container_name(self) -> str:
+11 -2
View File
@@ -147,7 +147,15 @@ def launch(
plan, plan,
git_gate_plan=git_gate_plan, git_gate_plan=git_gate_plan,
egress_plan=egress_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_git_gate_url=git_gate_url,
agent_supervise_url=supervise_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 """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 baked process env (it just runs init), so the proxy/CA/git/supervise
wiring is applied per-invocation.""" 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}" no_proxy = f"localhost,127.0.0.1,{host_ip}"
env: dict[str, str] = { env: dict[str, str] = {
"HTTPS_PROXY": proxy_url, "HTTP_PROXY": proxy_url, "HTTPS_PROXY": proxy_url, "HTTP_PROXY": proxy_url,
+9 -1
View File
@@ -32,6 +32,8 @@ if TYPE_CHECKING:
_SUPERVISE_MCP_NAME = "supervise" _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: def _skills_dir(guest_home: str) -> str:
@@ -301,9 +303,15 @@ class ClaudeAgentProvider(AgentProvider):
if plan.supervise_plan is None: if plan.supervise_plan is None:
return return
info(f"registering supervise MCP server in agent claude config → {supervise_url}") 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( r = bottle.exec(
f"claude mcp add --scope user --transport http " f"claude mcp add --scope user --transport http "
f"{_SUPERVISE_MCP_NAME} {supervise_url}", f"{_SUPERVISE_MCP_NAME} {supervise_url}{header}",
user="node", user="node",
) )
if r.returncode != 0: if r.returncode != 0:
+9 -1
View File
@@ -274,9 +274,17 @@ class CodexAgentProvider(AgentProvider):
if plan.supervise_plan is None: if plan.supervise_plan is None:
return return
info(f"registering supervise MCP server in agent codex config → {supervise_url}") 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( r = bottle.exec(
f"{shlex.quote(_CODEX_CLI)} mcp add {_SUPERVISE_MCP_NAME} --url " 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", user="node",
) )
if r.returncode != 0: if r.returncode != 0:
+62 -4
View File
@@ -6,6 +6,8 @@ egress container."""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import base64
import binascii
import json import json
import os import os
import signal import signal
@@ -69,10 +71,28 @@ INTROSPECT_HOST = "_egress.local"
# → legacy per-bottle single-tenant mode (unchanged). # → legacy per-bottle single-tenant mode (unchanged).
ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL" ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL"
# App-layer identity token (defense-in-depth over the source-IP invariant); # App-layer identity token. Delivered as proxy credentials
# the agent injects it, the addon strips it so it never leaks upstream. # (`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" 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 # Seconds the egress proxy holds a token-blocked request open waiting for the
# operator's supervisor decision (PRD 0062), overridable via env. # operator's supervisor decision (PRD 0062), overridable via env.
DEFAULT_TOKEN_ALLOW_TIMEOUT_SECONDS = 300.0 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 # Class default so addons built via __new__ (e.g. in tests) default to
# single-tenant; __init__ sets the instance attribute for real runs. # single-tenant; __init__ sets the instance attribute for real runs.
_resolver: "PolicyResolver | None" = None _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: def __init__(self) -> None:
self.routes_path = os.environ.get("EGRESS_ROUTES", DEFAULT_ROUTES_PATH) 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 # scan. In-memory only (a restart re-prompts); mutated only from the
# asyncio loop that runs the addon hooks, so no lock is needed. # asyncio loop that runs the addon hooks, so no lock is needed.
self._safe_tokens: dict[str, set[str]] = {} 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._supervise_slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "").strip()
self._token_allow_timeout = _token_allow_timeout_from_env(os.environ) self._token_allow_timeout = _token_allow_timeout_from_env(os.environ)
self._reload(initial=True) self._reload(initial=True)
@@ -247,12 +275,42 @@ class EgressAddon:
return self.config, self._supervise_slug, os.environ return self.config, self._supervise_slug, os.environ
conn = flow.client_conn conn = flow.client_conn
client_ip = conn.peername[0] if conn and conn.peername else "" client_ip = conn.peername[0] if conn and conn.peername else ""
token = flow.request.headers.get(IDENTITY_HEADER, "") token = self._request_token(flow)
flow.request.headers.pop(IDENTITY_HEADER, None)
config, slug, tokens = resolve_client_context(self._resolver, client_ip, token) config, slug, tokens = resolve_client_context(self._resolver, client_ip, token)
env = {**os.environ, **tokens} if tokens else os.environ env = {**os.environ, **tokens} if tokens else os.environ
return config, slug, env 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: async def request(self, flow: http.HTTPFlow) -> None:
request_path, _, query = flow.request.path.partition("?") request_path, _, query = flow.request.path.partition("?")
+13
View File
@@ -19,6 +19,9 @@ from .manifest import ManifestBottle, ManifestGitEntry
# Short network alias for git-gate inside the gateway. The # Short network alias for git-gate inside the gateway. The
# agent's `.gitconfig` insteadOf rewrites resolve through this name. # agent's `.gitconfig` insteadOf rewrites resolve through this name.
GIT_GATE_HOSTNAME = "git-gate" 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: # Shared timeout (seconds) for all git-gate subprocess and CGI calls:
# git daemon (--timeout/--init-timeout), the access-hook subprocess in # git daemon (--timeout/--init-timeout), the access-hook subprocess in
# git_http_backend, and the git http-backend CGI subprocess. # 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( def git_gate_render_gitconfig(
entries: tuple[ManifestGitEntry, ...], gate_host: str, *, scheme: str = "git", entries: tuple[ManifestGitEntry, ...], gate_host: str, *, scheme: str = "git",
identity_token: str = "",
) -> str: ) -> str:
"""Render the agent's ~/.gitconfig content for git-gate """Render the agent's ~/.gitconfig content for git-gate
`insteadOf` rewrites. Pure host-side, no docker / VM; `insteadOf` rewrites. Pure host-side, no docker / VM;
@@ -96,6 +100,15 @@ def git_gate_render_gitconfig(
"# the upstream bidirectionally (gitleaks-scanned push;\n", "# the upstream bidirectionally (gitleaks-scanned push;\n",
"# fetch-from-upstream-before-every-upload-pack via access-hook).\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: for entry in entries:
_gitconfig_validate_value(f"repos[{entry.Name!r}].url", entry.Upstream) _gitconfig_validate_value(f"repos[{entry.Name!r}].url", entry.Upstream)
out.append(f'[url "{scheme}://{gate_host}/{entry.Name}.git"]\n') out.append(f'[url "{scheme}://{gate_host}/{entry.Name}.git"]\n')
+3 -2
View File
@@ -129,8 +129,9 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
if method == "POST" and route == "/resolve": if method == "POST" and route == "/resolve":
# The per-request lookup the multi-tenant gateway makes: returns the # The per-request lookup the multi-tenant gateway makes: returns the
# bottle's policy. identity_token is OPTIONAL — absent means resolve # bottle's policy. Requires a matching (source_ip, identity_token)
# by source IP alone (network-layer attribution). # pair — a missing/empty/mismatched token fail-closes (403), no
# source-IP-only fallback.
try: try:
data = _parse_json_object(body) data = _parse_json_object(body)
except ValueError as e: except ValueError as e:
+12 -10
View File
@@ -99,16 +99,18 @@ class Orchestrator:
"""Fail-closed attribution (delegates to the registry).""" """Fail-closed attribution (delegates to the registry)."""
return self.registry.attribute(source_ip, identity_token) return self.registry.attribute(source_ip, identity_token)
def resolve(self, source_ip: str, identity_token: str = "") -> BottleRecord | None: def resolve(self, source_ip: str, identity_token: str) -> BottleRecord | None:
"""Resolve the bottle behind a request — the source-IP-keyed lookup """Resolve the bottle behind a request — the per-request lookup the
the multi-tenant gateway makes per request; the returned record multi-tenant gateway makes; the returned record carries its `policy`.
carries its `policy`. With a token, full attribution (source IP +
token); without, network-layer attribution by source IP alone **Mandatory pair**: requires a matching `(source_ip, identity_token)`
(valid where the IP is unspoofable and the control plane is (constant-time). There is no source-IP-only fallback the app-layer
gateway-only).""" token is delivered on every attributed data plane (egress proxy
if identity_token: credentials, git-gate/supervise headers), so a missing or mismatched
return self.registry.attribute(source_ip, identity_token) token fail-closes. This keeps a spoofed source IP (which the /31 TAP
return self.registry.by_source_ip(source_ip) 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: def set_policy(self, bottle_id: str, policy: str) -> bool:
"""Update a bottle's gateway policy in place (live reload). False if """Update a bottle's gateway policy in place (live reload). False if
+8 -1
View File
@@ -65,6 +65,8 @@ except ModuleNotFoundError:
MCP_PROTOCOL_VERSION = "2024-11-05" 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_NAME = "bot-bottle-supervise"
SERVER_VERSION = "0.1.0" SERVER_VERSION = "0.1.0"
@@ -541,8 +543,13 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
resolver = getattr(self.server, "policy_resolver", None) resolver = getattr(self.server, "policy_resolver", None)
if resolver is None: if resolver is None:
return config 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: 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: except PolicyResolveError as e:
raise _RpcInternalError(f"orchestrator unreachable, cannot attribute: {e}") from e raise _RpcInternalError(f"orchestrator unreachable, cannot attribute: {e}") from e
if not bottle_id: 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") 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) 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: def test_remote_key_alias_with_nondefault_port(self) -> None:
out = git_gate_render_gitconfig( out = git_gate_render_gitconfig(
(_entry(RemoteKey="10.0.0.5", UpstreamPort="2222"),), "git-gate", (_entry(RemoteKey="10.0.0.5", UpstreamPort="2222"),), "git-gate",
+15 -3
View File
@@ -171,14 +171,26 @@ class TestDispatch(unittest.TestCase):
) )
self.assertEqual(400, status) self.assertEqual(400, status)
def test_resolve_without_token_by_source_ip(self) -> None: def test_resolve_without_token_denies(self) -> None:
_, reg = dispatch( # Mandatory token: source-IP alone no longer resolves (fail-closed 403).
dispatch(
self.orch, "POST", "/bottles", self.orch, "POST", "/bottles",
_body({"source_ip": "10.243.0.5", "policy": "P"}), _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.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(200, status)
self.assertEqual(reg["bottle_id"], payload["bottle_id"]) self.assertEqual(reg["bottle_id"], payload["bottle_id"])
self.assertEqual("P", payload["policy"]) self.assertEqual("P", payload["policy"])
+5 -2
View File
@@ -114,9 +114,12 @@ class TestOrchestrator(unittest.TestCase):
def test_set_policy_unknown_is_false(self) -> None: def test_set_policy_unknown_is_false(self) -> None:
self.assertFalse(self.orch.set_policy("ghost", "{}")) 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") 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 assert got is not None
self.assertEqual(rec.bottle_id, got.bottle_id) self.assertEqual(rec.bottle_id, got.bottle_id)
self.assertEqual("P", got.policy) self.assertEqual("P", got.policy)