Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de9da29027 | |||
| 72ea500342 |
@@ -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"]
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user