fix(egress+orchestrator): inject per-bottle auth tokens in the shared gateway
lint / lint (push) Successful in 2m5s
test / unit (pull_request) Successful in 1m6s
test / integration (pull_request) Successful in 20s
test / coverage (pull_request) Successful in 1m18s

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 4132b09ebc
commit f36c42f038
12 changed files with 212 additions and 78 deletions
+13 -8
View File
@@ -102,21 +102,26 @@ class PolicyResolver:
def resolve_policy_and_bottle_id(
self, source_ip: str, identity_token: str = "",
) -> tuple[str | None, str | None]:
"""Both the policy blob and the bottle id in a single `/resolve` — so
a caller that needs each (the egress addon: policy for routing, bottle
id to key the calling bottle's supervise queue + safelist) makes one
round-trip, not two. Returns `(None, None)` when unattributed (a clean
`403`). Raises `PolicyResolveError` if the orchestrator can't be
reached, so the caller still fails closed."""
) -> tuple[str | None, str | None, dict[str, str]]:
"""The policy blob, bottle id, **and per-bottle egress auth tokens** in
a single `/resolve` — so the egress addon gets everything it needs
(policy for routing, bottle id for the supervise queue/safelist, tokens
to inject upstream auth) in one round-trip. Returns `(None, None, {})`
when unattributed (a clean `403`). Raises `PolicyResolveError` if the
orchestrator can't be reached, so the caller still fails closed."""
payload = self._post_resolve(source_ip, identity_token)
if payload is None:
return None, None
return None, None, {}
policy = payload.get("policy")
bottle_id = payload.get("bottle_id")
raw = payload.get("tokens")
tokens = {
k: v for k, v in raw.items() if isinstance(k, str) and isinstance(v, str)
} if isinstance(raw, dict) else {}
return (
policy if isinstance(policy, str) else "",
bottle_id if isinstance(bottle_id, str) and bottle_id else None,
tokens,
)