47268f6fd6
The egress addon now selects each request's Config by the calling bottle's
source IP, so one shared sidecar serves every bottle. Opt-in and fail-closed;
single-tenant behaviour is unchanged.
Orchestrator side (source-IP-primary attribution, per the PRD invariant):
* registry: `by_source_ip` (the single active bottle at a source IP —
network-layer attribution); `attribute` now composes it + the token.
* service: `resolve(source_ip, token="")` — with a token, strict
attribution; without, source IP alone.
* control_plane: `POST /resolve`'s identity_token is now OPTIONAL (absent
→ source-IP-only); split cleanly from the token-required `/attribute`.
* policy_resolver: `resolve` token now optional.
Egress side:
* egress_addon_core: `resolve_client_config(resolver, client_ip, token)` —
fetches + parses the client's Config, **fail-closed**: unattributed, a
resolver error, or an unparseable policy all yield deny-all (no routes).
Host-testable; `PolicyResolverLike` Protocol keeps it import-free.
* egress_addon: consolidated mode when `BOT_BOTTLE_ORCHESTRATOR_URL` is
set → `_active_config(flow)` resolves per client IP (reads + strips the
`x-bot-bottle-identity` header); `request()` uses it. Unset → the static
routes file, exactly as before. `PolicyResolver` added to the bundle.
Security note: source-IP-only resolution is safe where the IP is unspoofable
(Firecracker /31 + nft) AND the control plane is reachable only by the
trusted sidecar; the identity token, when the agent injects it, strengthens
it on weaker backends.
Scope note: the egress data plane is now multi-tenant. Remaining to be fully
live: the network topology routing every bottle's proxy to the one shared
sidecar, git-gate multitenancy, and agent-side identity-token injection.
Tests: registry by_source_ip; orchestrator resolve (with/without token);
control-plane /resolve token-optional; resolver token-optional;
resolve_client_config fail-closed matrix. All 182 egress tests still pass
(single-tenant unchanged). Full suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
54 lines
2.0 KiB
Python
54 lines
2.0 KiB
Python
"""Unit: resolve_client_config — fail-closed per-client egress config (PRD 0070)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import unittest
|
|
|
|
from bot_bottle.egress_addon_core import resolve_client_config
|
|
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])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|