fix(egress+orchestrator): inject per-bottle auth tokens in the shared gateway

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
This commit is contained in:
2026-07-14 01:55:49 -04:00
parent 485d101430
commit b2f61053ad
12 changed files with 212 additions and 78 deletions
+20 -10
View File
@@ -51,45 +51,55 @@ class TestResolveClientConfig(unittest.TestCase):
class _FakeContextResolver:
def __init__(
self, policy: str | None = None, bottle_id: str | None = None, raises: bool = False,
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]:
) -> tuple[str | None, str | None, dict[str, str]]:
if self._raises:
raise PolicyResolveError("orchestrator down")
return self._policy, self._bottle_id
return self._policy, self._bottle_id, self._tokens
class TestResolveClientContext(unittest.TestCase):
def test_returns_config_and_bottle_id(self) -> None:
cfg, slug = resolve_client_context(
_FakeContextResolver(policy="routes:\n - host: example.com\n", bottle_id="b1"),
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 = resolve_client_context(
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_and_empty_slug(self) -> None:
cfg, slug = resolve_client_context(_FakeContextResolver(raises=True), "10.243.0.1")
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 = resolve_client_context(
cfg, slug, _tokens = resolve_client_context(
_FakeContextResolver(policy="routes: notalist\n", bottle_id="b2"), "10.243.0.1",
)
self.assertEqual((), cfg.routes)