diff --git a/bot_bottle/policy_resolver.py b/bot_bottle/policy_resolver.py new file mode 100644 index 0000000..76f3a33 --- /dev/null +++ b/bot_bottle/policy_resolver.py @@ -0,0 +1,75 @@ +"""Sidecar-side per-client policy resolver (PRD 0070). + +The consolidated sidecar serves every bottle from one process, so for each +request it must apply the *calling* bottle's policy, selected by the source +IP the attribution invariant makes unspoofable. This resolves that policy +from the orchestrator's control plane (`POST /resolve`), keyed on the +`(source_ip, identity_token)` pair. + +**Always fresh — no cache.** The resolver is called rarely enough that a +round-trip doesn't matter, and correctness matters more than speed: every +resolve reflects the orchestrator's *current* view, so a revocation, a +policy change, or a bottle teardown the orchestrator knows about is honored +immediately rather than lingering for a cache TTL. (If this ever becomes a +hot path, add caching with orchestrator-driven invalidation — not a blind +TTL.) + +**Fail-closed:** an unattributed client (the orchestrator answers `403`) +resolves to `None`, and the caller (the egress addon, git-gate) must then +deny — exactly as an unknown bottle should be treated. Orchestrator +*errors* (unreachable / unexpected status) raise, so the caller can fail +closed too rather than silently serving stale or empty policy. + +The resolved value is the policy blob the orchestrator stores verbatim; the +consumer parses it (e.g. the egress addon's `load_config`). This module is +stdlib-only and free of bot-bottle imports so it can be COPYed flat into +the sidecar bundle. +""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.request + +DEFAULT_TIMEOUT_SECONDS = 2.0 + + +class PolicyResolveError(RuntimeError): + """The orchestrator was unreachable or returned an unexpected status — + distinct from a clean `403` (unattributed), which returns None.""" + + +class PolicyResolver: + """Resolves each client's policy from the orchestrator, fresh per call.""" + + def __init__(self, base_url: str, *, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> None: + self._base = base_url.rstrip("/") + self._timeout = timeout + + def resolve(self, source_ip: str, identity_token: str) -> str | None: + """The calling bottle's policy blob, or None if unattributed. Always + fetches from the orchestrator so revocations / changes / teardowns + are honored immediately. Raises `PolicyResolveError` if the + orchestrator can't be reached / errors.""" + body = json.dumps( + {"source_ip": source_ip, "identity_token": identity_token} + ).encode() + req = urllib.request.Request( + f"{self._base}/resolve", data=body, method="POST", + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=self._timeout) as resp: + payload = json.loads(resp.read()) + except urllib.error.HTTPError as e: + if e.code == 403: + return None # unattributed → fail closed (caller denies) + raise PolicyResolveError(f"/resolve returned HTTP {e.code}") from e + except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e: + raise PolicyResolveError(f"/resolve unreachable or malformed: {e}") from e + policy = payload.get("policy") if isinstance(payload, dict) else None + return policy if isinstance(policy, str) else "" + + +__all__ = ["PolicyResolver", "PolicyResolveError"] diff --git a/tests/unit/test_policy_resolver.py b/tests/unit/test_policy_resolver.py new file mode 100644 index 0000000..bde11a1 --- /dev/null +++ b/tests/unit/test_policy_resolver.py @@ -0,0 +1,71 @@ +"""Unit tests for the sidecar-side PolicyResolver (PRD 0070). HTTP mocked.""" + +from __future__ import annotations + +import json +import unittest +import urllib.error +from unittest.mock import MagicMock, patch + +from bot_bottle.policy_resolver import PolicyResolveError, PolicyResolver + +_URLOPEN = "bot_bottle.policy_resolver.urllib.request.urlopen" + + +def _resp(payload: object) -> MagicMock: + """A urlopen() return value: a context manager whose read() yields JSON.""" + m = MagicMock() + m.__enter__.return_value.read.return_value = json.dumps(payload).encode() + return m + + +def _http_error(code: int) -> urllib.error.HTTPError: + return urllib.error.HTTPError("http://x/resolve", code, "err", {}, None) # type: ignore[arg-type] + + +class TestPolicyResolver(unittest.TestCase): + def setUp(self) -> None: + self.r = PolicyResolver("http://orch:8080") + + def test_resolve_returns_policy(self) -> None: + with patch(_URLOPEN, return_value=_resp({"bottle_id": "b1", "policy": "P"})): + self.assertEqual("P", self.r.resolve("10.243.0.1", "tok")) + + def test_resolve_always_fetches_fresh(self) -> None: + # No cache — every resolve hits the orchestrator so revocations / + # policy changes are honored immediately. + with patch(_URLOPEN, return_value=_resp({"policy": "P"})) as m: + self.r.resolve("10.243.0.1", "tok") + self.r.resolve("10.243.0.1", "tok") + self.assertEqual(2, m.call_count) + + def test_unattributed_403_is_none_fail_closed(self) -> None: + with patch(_URLOPEN, side_effect=_http_error(403)): + self.assertIsNone(self.r.resolve("10.243.0.9", "tok")) + + def test_other_http_error_raises(self) -> None: + with patch(_URLOPEN, side_effect=_http_error(500)): + with self.assertRaises(PolicyResolveError): + self.r.resolve("10.243.0.1", "tok") + + def test_unreachable_raises(self) -> None: + with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")): + with self.assertRaises(PolicyResolveError): + self.r.resolve("10.243.0.1", "tok") + + def test_missing_policy_field_is_empty(self) -> None: + with patch(_URLOPEN, return_value=_resp({"bottle_id": "b1"})): + self.assertEqual("", self.r.resolve("10.243.0.1", "tok")) + + def test_posts_source_ip_and_token(self) -> None: + with patch(_URLOPEN, return_value=_resp({"policy": "P"})) as m: + self.r.resolve("10.243.0.7", "the-token") + req = m.call_args.args[0] + self.assertTrue(req.full_url.endswith("/resolve")) + sent = json.loads(req.data) + self.assertEqual("10.243.0.7", sent["source_ip"]) + self.assertEqual("the-token", sent["identity_token"]) + + +if __name__ == "__main__": + unittest.main()