f36c42f038
The cut-over dropped the per-bottle token flow, so an authed egress route on the shared gateway failed with 'env var EGRESS_TOKEN_0 is unset' — the gateway reads the token from its env, but a shared gateway has no per-bottle env. Now the bottle's egress auth tokens travel to the gateway over /resolve and the addon injects from them, mirroring what the per-bottle sidecar's env did: - launch resolves the token values from the host env and hands them to the orchestrator, which holds them IN MEMORY (keyed by bottle_id, never written to the registry DB) and serves them on /resolve; - PolicyResolver.resolve_policy_and_bottle_id + resolve_client_context now return the token map alongside policy + bottle_id (one round-trip); - the egress addon overlays the process env with the bottle's tokens per request and uses that env for auth injection AND DLP — the agent never sees the credential. Secrets stay off disk (validated: /resolve returns the token, the registry DB does not contain it). SecretProvider (#355) is the future hardening. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
111 lines
4.3 KiB
Python
111 lines
4.3 KiB
Python
"""Unit: fail-closed per-client egress resolution — config + context (PRD 0070)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import unittest
|
|
|
|
from bot_bottle.egress_addon_core import resolve_client_config, resolve_client_context
|
|
from bot_bottle.policy_resolver import PolicyResolveError
|
|
|
|
|
|
class _FakeResolver:
|
|
def __init__(self, result: str | None = None, raises: bool = False) -> None:
|
|
self._result = result
|
|
self._raises = raises
|
|
self.calls: list[tuple[str, str]] = []
|
|
|
|
def resolve(self, source_ip: str, identity_token: str = "") -> str | None:
|
|
self.calls.append((source_ip, identity_token))
|
|
if self._raises:
|
|
raise PolicyResolveError("orchestrator down")
|
|
return self._result
|
|
|
|
|
|
class TestResolveClientConfig(unittest.TestCase):
|
|
def test_valid_policy_is_parsed(self) -> None:
|
|
cfg = resolve_client_config(
|
|
_FakeResolver(result="routes:\n - host: example.com\n"), "10.243.0.1"
|
|
)
|
|
self.assertEqual(("example.com",), tuple(r.host for r in cfg.routes))
|
|
|
|
def test_unattributed_none_denies_all(self) -> None:
|
|
cfg = resolve_client_config(_FakeResolver(result=None), "10.243.0.9")
|
|
self.assertEqual((), cfg.routes) # no routes → default-deny
|
|
|
|
def test_empty_policy_denies_all(self) -> None:
|
|
self.assertEqual((), resolve_client_config(_FakeResolver(result=""), "10.243.0.1").routes)
|
|
|
|
def test_resolver_error_denies_all(self) -> None:
|
|
# Orchestrator unreachable/errored must never widen egress.
|
|
self.assertEqual((), resolve_client_config(_FakeResolver(raises=True), "10.243.0.1").routes)
|
|
|
|
def test_unparseable_policy_denies_all(self) -> None:
|
|
cfg = resolve_client_config(_FakeResolver(result="routes: notalist\n"), "10.243.0.1")
|
|
self.assertEqual((), cfg.routes)
|
|
|
|
def test_forwards_source_ip_and_token(self) -> None:
|
|
r = _FakeResolver(result=None)
|
|
resolve_client_config(r, "10.243.0.1", "tok")
|
|
self.assertEqual(("10.243.0.1", "tok"), r.calls[0])
|
|
|
|
|
|
class _FakeContextResolver:
|
|
def __init__(
|
|
self, policy: str | None = None, bottle_id: str | None = None,
|
|
raises: bool = False, tokens: dict[str, str] | None = None,
|
|
) -> None:
|
|
self._policy = policy
|
|
self._bottle_id = bottle_id
|
|
self._raises = raises
|
|
self._tokens = tokens or {}
|
|
|
|
def resolve_policy_and_bottle_id(
|
|
self, source_ip: str, identity_token: str = "",
|
|
) -> tuple[str | None, str | None, dict[str, str]]:
|
|
if self._raises:
|
|
raise PolicyResolveError("orchestrator down")
|
|
return self._policy, self._bottle_id, self._tokens
|
|
|
|
|
|
class TestResolveClientContext(unittest.TestCase):
|
|
def test_returns_config_bottle_id_and_tokens(self) -> None:
|
|
cfg, slug, tokens = resolve_client_context(
|
|
_FakeContextResolver(
|
|
policy="routes:\n - host: example.com\n", bottle_id="b1",
|
|
tokens={"EGRESS_TOKEN_0": "sekret"},
|
|
),
|
|
"10.243.0.1",
|
|
)
|
|
self.assertEqual(("example.com",), tuple(r.host for r in cfg.routes))
|
|
self.assertEqual("b1", slug)
|
|
self.assertEqual({"EGRESS_TOKEN_0": "sekret"}, tokens) # for auth injection
|
|
|
|
def test_unattributed_denies_and_empty_slug(self) -> None:
|
|
cfg, slug, tokens = resolve_client_context(
|
|
_FakeContextResolver(policy=None, bottle_id=None), "10.243.0.9",
|
|
)
|
|
self.assertEqual((), cfg.routes)
|
|
self.assertEqual("", slug) # no bottle → supervise unavailable
|
|
self.assertEqual({}, tokens)
|
|
|
|
def test_resolver_error_denies_empty_slug_no_tokens(self) -> None:
|
|
cfg, slug, tokens = resolve_client_context(
|
|
_FakeContextResolver(raises=True), "10.243.0.1",
|
|
)
|
|
self.assertEqual((), cfg.routes)
|
|
self.assertEqual("", slug)
|
|
self.assertEqual({}, tokens)
|
|
|
|
def test_unparseable_policy_denies_but_keeps_slug(self) -> None:
|
|
# A bad policy denies egress, but the bottle is still attributed (its
|
|
# supervise queue is keyed by the id, independent of route parsing).
|
|
cfg, slug, _tokens = resolve_client_context(
|
|
_FakeContextResolver(policy="routes: notalist\n", bottle_id="b2"), "10.243.0.1",
|
|
)
|
|
self.assertEqual((), cfg.routes)
|
|
self.assertEqual("b2", slug)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|