Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3e08cf039 | |||
| de9da29027 | |||
| 72ea500342 |
@@ -64,6 +64,7 @@ COPY --from=gitleaks-src /usr/bin/gitleaks /usr/bin/gitleaks
|
|||||||
COPY bot_bottle/egress_addon_core.py /app/egress_addon_core.py
|
COPY bot_bottle/egress_addon_core.py /app/egress_addon_core.py
|
||||||
COPY bot_bottle/egress_dlp_config.py /app/egress_dlp_config.py
|
COPY bot_bottle/egress_dlp_config.py /app/egress_dlp_config.py
|
||||||
COPY bot_bottle/egress_addon.py /app/egress_addon.py
|
COPY bot_bottle/egress_addon.py /app/egress_addon.py
|
||||||
|
COPY bot_bottle/policy_resolver.py /app/policy_resolver.py
|
||||||
COPY bot_bottle/dlp_detectors.py /app/dlp_detectors.py
|
COPY bot_bottle/dlp_detectors.py /app/dlp_detectors.py
|
||||||
COPY bot_bottle/yaml_subset.py /app/yaml_subset.py
|
COPY bot_bottle/yaml_subset.py /app/yaml_subset.py
|
||||||
COPY bot_bottle/paths.py /app/paths.py
|
COPY bot_bottle/paths.py /app/paths.py
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from egress_addon_core import ( # type: ignore[import-not-found] # pylint: dis
|
|||||||
is_git_push_request,
|
is_git_push_request,
|
||||||
load_config,
|
load_config,
|
||||||
match_route,
|
match_route,
|
||||||
|
resolve_client_config,
|
||||||
outbound_scan_headers,
|
outbound_scan_headers,
|
||||||
route_to_yaml_dict,
|
route_to_yaml_dict,
|
||||||
scan_inbound,
|
scan_inbound,
|
||||||
@@ -51,11 +52,26 @@ try:
|
|||||||
except ImportError: # pragma: no cover - host-side path
|
except ImportError: # pragma: no cover - host-side path
|
||||||
from bot_bottle import supervise as _sv # type: ignore[import-not-found]
|
from bot_bottle import supervise as _sv # type: ignore[import-not-found]
|
||||||
|
|
||||||
|
try:
|
||||||
|
from policy_resolver import PolicyResolver # type: ignore[import-not-found]
|
||||||
|
except ImportError: # pragma: no cover - host-side path
|
||||||
|
from bot_bottle.policy_resolver import PolicyResolver
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_ROUTES_PATH = "/etc/egress/routes.yaml"
|
DEFAULT_ROUTES_PATH = "/etc/egress/routes.yaml"
|
||||||
|
|
||||||
INTROSPECT_HOST = "_egress.local"
|
INTROSPECT_HOST = "_egress.local"
|
||||||
|
|
||||||
|
# Consolidated (multi-tenant) mode: when this points at the per-host
|
||||||
|
# orchestrator's control plane, the addon resolves each client's Config by
|
||||||
|
# source IP per request instead of using a single static routes file. Unset
|
||||||
|
# → legacy per-bottle single-tenant mode (unchanged).
|
||||||
|
ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL"
|
||||||
|
|
||||||
|
# App-layer identity token (defense-in-depth over the source-IP invariant);
|
||||||
|
# the agent injects it, the addon strips it so it never leaks upstream.
|
||||||
|
IDENTITY_HEADER = "x-bot-bottle-identity"
|
||||||
|
|
||||||
# Seconds the egress proxy holds a token-blocked request open waiting for the
|
# Seconds the egress proxy holds a token-blocked request open waiting for the
|
||||||
# operator's supervisor decision (PRD 0062), overridable via env.
|
# operator's supervisor decision (PRD 0062), overridable via env.
|
||||||
DEFAULT_TOKEN_ALLOW_TIMEOUT_SECONDS = 300.0
|
DEFAULT_TOKEN_ALLOW_TIMEOUT_SECONDS = 300.0
|
||||||
@@ -72,9 +88,17 @@ _TOKEN_ALLOW_JUSTIFICATION = (
|
|||||||
|
|
||||||
|
|
||||||
class EgressAddon:
|
class EgressAddon:
|
||||||
|
# Class default so addons built via __new__ (e.g. in tests) default to
|
||||||
|
# single-tenant; __init__ sets the instance attribute for real runs.
|
||||||
|
_resolver: "PolicyResolver | None" = None
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.routes_path = os.environ.get("EGRESS_ROUTES", DEFAULT_ROUTES_PATH)
|
self.routes_path = os.environ.get("EGRESS_ROUTES", DEFAULT_ROUTES_PATH)
|
||||||
self.config: Config = Config(routes=())
|
self.config: Config = Config(routes=())
|
||||||
|
# Consolidated mode: resolve per-client Config from the orchestrator.
|
||||||
|
# Absent → single-tenant (static routes file); behaviour unchanged.
|
||||||
|
orch_url = os.environ.get(ORCHESTRATOR_URL_ENV, "").strip()
|
||||||
|
self._resolver = PolicyResolver(orch_url) if orch_url else None
|
||||||
# Tokens the operator has approved this session (PRD 0062). In-memory
|
# Tokens the operator has approved this session (PRD 0062). In-memory
|
||||||
# only — a restart re-prompts. Mutated only from the asyncio loop that
|
# only — a restart re-prompts. Mutated only from the asyncio loop that
|
||||||
# runs the addon hooks, so no lock is needed.
|
# runs the addon hooks, so no lock is needed.
|
||||||
@@ -194,6 +218,20 @@ class EgressAddon:
|
|||||||
+ "\n"
|
+ "\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _active_config(self, flow: http.HTTPFlow) -> Config:
|
||||||
|
"""The Config to apply to this request. Single-tenant → the static
|
||||||
|
`self.config`. Consolidated → the calling bottle's Config, resolved
|
||||||
|
by source IP (fail-closed to deny-all if unattributed). The identity
|
||||||
|
token, if the agent injected one, is read then stripped so it never
|
||||||
|
leaks upstream."""
|
||||||
|
if self._resolver is None:
|
||||||
|
return self.config
|
||||||
|
conn = flow.client_conn
|
||||||
|
client_ip = conn.peername[0] if conn and conn.peername else ""
|
||||||
|
token = flow.request.headers.get(IDENTITY_HEADER, "")
|
||||||
|
flow.request.headers.pop(IDENTITY_HEADER, None)
|
||||||
|
return resolve_client_config(self._resolver, client_ip, token)
|
||||||
|
|
||||||
async def request(self, flow: http.HTTPFlow) -> None:
|
async def request(self, flow: http.HTTPFlow) -> None:
|
||||||
request_path, _, query = flow.request.path.partition("?")
|
request_path, _, query = flow.request.path.partition("?")
|
||||||
|
|
||||||
@@ -201,10 +239,12 @@ class EgressAddon:
|
|||||||
self._serve_introspection(flow, request_path)
|
self._serve_introspection(flow, request_path)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
config = self._active_config(flow)
|
||||||
|
|
||||||
# DLP outbound scan BEFORE stripping auth — catches tokens the
|
# DLP outbound scan BEFORE stripping auth — catches tokens the
|
||||||
# agent tried to smuggle in any header, path, query param, or body.
|
# agent tried to smuggle in any header, path, query param, or body.
|
||||||
# Hostname is included to catch DNS-tunnelling exfiltration attempts.
|
# Hostname is included to catch DNS-tunnelling exfiltration attempts.
|
||||||
route = match_route(self.config.routes, flow.request.pretty_host)
|
route = match_route(config.routes, flow.request.pretty_host)
|
||||||
if route is not None:
|
if route is not None:
|
||||||
if not await self._handle_outbound_dlp(flow, route):
|
if not await self._handle_outbound_dlp(flow, route):
|
||||||
return
|
return
|
||||||
@@ -224,7 +264,7 @@ class EgressAddon:
|
|||||||
|
|
||||||
if is_git_fetch_request(request_path, query):
|
if is_git_fetch_request(request_path, query):
|
||||||
git_decision = decide_git_fetch(
|
git_decision = decide_git_fetch(
|
||||||
self.config.routes, flow.request.pretty_host,
|
config.routes, flow.request.pretty_host,
|
||||||
)
|
)
|
||||||
if git_decision.action == "block":
|
if git_decision.action == "block":
|
||||||
self._block(
|
self._block(
|
||||||
@@ -242,7 +282,7 @@ class EgressAddon:
|
|||||||
req_headers = {k.lower(): v for k, v in flow.request.headers.items()}
|
req_headers = {k.lower(): v for k, v in flow.request.headers.items()}
|
||||||
|
|
||||||
decision = decide(
|
decision = decide(
|
||||||
self.config.routes,
|
config.routes,
|
||||||
flow.request.pretty_host,
|
flow.request.pretty_host,
|
||||||
request_path,
|
request_path,
|
||||||
os.environ,
|
os.environ,
|
||||||
@@ -257,7 +297,7 @@ class EgressAddon:
|
|||||||
if decision.inject_authorization is not None:
|
if decision.inject_authorization is not None:
|
||||||
flow.request.headers["authorization"] = decision.inject_authorization
|
flow.request.headers["authorization"] = decision.inject_authorization
|
||||||
|
|
||||||
if self.config.log >= LOG_FULL:
|
if config.log >= LOG_FULL:
|
||||||
self._log_request(flow)
|
self._log_request(flow)
|
||||||
|
|
||||||
def _block_dlp(self, flow: http.HTTPFlow, result: ScanResult) -> None:
|
def _block_dlp(self, flow: http.HTTPFlow, result: ScanResult) -> None:
|
||||||
|
|||||||
@@ -413,6 +413,34 @@ def load_config(text: str) -> "Config":
|
|||||||
return parse_config(payload)
|
return parse_config(payload)
|
||||||
|
|
||||||
|
|
||||||
|
class PolicyResolverLike(typing.Protocol):
|
||||||
|
"""The bit of `policy_resolver.PolicyResolver` this module needs — kept a
|
||||||
|
Protocol so egress_addon_core stays free of that import."""
|
||||||
|
|
||||||
|
def resolve(self, source_ip: str, identity_token: str = ...) -> "str | None":
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_client_config(
|
||||||
|
resolver: PolicyResolverLike, client_ip: str, identity_token: str = ""
|
||||||
|
) -> "Config":
|
||||||
|
"""The calling client's egress Config, resolved from the orchestrator via
|
||||||
|
`resolver` and parsed — **fail-closed**. An unattributed client (None), a
|
||||||
|
resolver error, or an unparseable policy all yield a deny-all Config (no
|
||||||
|
routes → every request blocked). A compromised, absent, or confused
|
||||||
|
orchestrator must never *widen* a bottle's egress."""
|
||||||
|
try:
|
||||||
|
policy = resolver.resolve(client_ip, identity_token)
|
||||||
|
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||||
|
return Config(routes=()) # orchestrator unreachable/errored → deny
|
||||||
|
if not policy:
|
||||||
|
return Config(routes=()) # unattributed or empty → deny-all
|
||||||
|
try:
|
||||||
|
return load_config(policy)
|
||||||
|
except ValueError:
|
||||||
|
return Config(routes=()) # unparseable policy → deny
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Match evaluation
|
# Match evaluation
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -804,6 +832,8 @@ __all__ = [
|
|||||||
"is_git_push_request",
|
"is_git_push_request",
|
||||||
"is_git_fetch_request",
|
"is_git_fetch_request",
|
||||||
"load_config",
|
"load_config",
|
||||||
|
"resolve_client_config",
|
||||||
|
"PolicyResolverLike",
|
||||||
"match_route",
|
"match_route",
|
||||||
"outbound_scan_headers",
|
"outbound_scan_headers",
|
||||||
"parse_config",
|
"parse_config",
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
|
|||||||
return 200, {"torn_down": True}
|
return 200, {"torn_down": True}
|
||||||
return 404, {"error": "no such bottle"}
|
return 404, {"error": "no such bottle"}
|
||||||
|
|
||||||
if method == "POST" and route in ("/attribute", "/resolve"):
|
if method == "POST" and route == "/attribute":
|
||||||
try:
|
try:
|
||||||
data = _parse_json_object(body)
|
data = _parse_json_object(body)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -119,13 +119,25 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
|
|||||||
rec = orch.attribute(source_ip, token)
|
rec = orch.attribute(source_ip, token)
|
||||||
if rec is None:
|
if rec is None:
|
||||||
return 403, {"error": "unattributed"}
|
return 403, {"error": "unattributed"}
|
||||||
# /attribute is the lightweight identity check; /resolve additionally
|
|
||||||
# returns the bottle's policy — the source-IP-keyed lookup the
|
|
||||||
# multi-tenant sidecar makes per request.
|
|
||||||
if route == "/resolve":
|
|
||||||
return 200, {"bottle_id": rec.bottle_id, "policy": rec.policy}
|
|
||||||
return 200, {"bottle_id": rec.bottle_id}
|
return 200, {"bottle_id": rec.bottle_id}
|
||||||
|
|
||||||
|
if method == "POST" and route == "/resolve":
|
||||||
|
# The per-request lookup the multi-tenant sidecar makes: returns the
|
||||||
|
# bottle's policy. identity_token is OPTIONAL — absent means resolve
|
||||||
|
# by source IP alone (network-layer attribution).
|
||||||
|
try:
|
||||||
|
data = _parse_json_object(body)
|
||||||
|
except ValueError as e:
|
||||||
|
return 400, {"error": f"invalid JSON: {e}"}
|
||||||
|
source_ip = data.get("source_ip")
|
||||||
|
token = data.get("identity_token")
|
||||||
|
if not isinstance(source_ip, str) or not source_ip:
|
||||||
|
return 400, {"error": "source_ip (string) is required"}
|
||||||
|
rec = orch.resolve(source_ip, token if isinstance(token, str) else "")
|
||||||
|
if rec is None:
|
||||||
|
return 403, {"error": "unattributed"}
|
||||||
|
return 200, {"bottle_id": rec.bottle_id, "policy": rec.policy}
|
||||||
|
|
||||||
return 404, {"error": "not found"}
|
return 404, {"error": "not found"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -212,14 +212,13 @@ class RegistryStore(DbStore):
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
return [_row_to_record(r) for r in rows]
|
return [_row_to_record(r) for r in rows]
|
||||||
|
|
||||||
def attribute(self, source_ip: str, identity_token: str) -> BottleRecord | None:
|
def by_source_ip(self, source_ip: str) -> BottleRecord | None:
|
||||||
"""Fail-closed attribution. Returns the bottle only when exactly one
|
"""Network-layer attribution: the single active bottle at this source
|
||||||
active record has this source IP AND its identity token matches
|
IP, or None if unknown or ambiguous (more than one — a
|
||||||
(constant-time). Either signal alone is insufficient: an unknown IP,
|
misconfiguration). Safe as the *sole* attributor only where the
|
||||||
an ambiguous IP (more than one active bottle — a misconfiguration),
|
source IP is unspoofable (Firecracker `/31` + nft) and the control
|
||||||
an empty token, or a token mismatch all deny."""
|
plane is reachable only by the trusted sidecar; pair with the
|
||||||
if not identity_token:
|
identity token (`attribute`) elsewhere."""
|
||||||
return None
|
|
||||||
with self._connect() as conn:
|
with self._connect() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT * FROM orchestrator_bottles "
|
"SELECT * FROM orchestrator_bottles "
|
||||||
@@ -228,7 +227,17 @@ class RegistryStore(DbStore):
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
if len(rows) != 1:
|
if len(rows) != 1:
|
||||||
return None
|
return None
|
||||||
rec = _row_to_record(rows[0])
|
return _row_to_record(rows[0])
|
||||||
|
|
||||||
|
def attribute(self, source_ip: str, identity_token: str) -> BottleRecord | None:
|
||||||
|
"""Fail-closed attribution: `by_source_ip` AND a matching identity
|
||||||
|
token (constant-time). Either signal alone is insufficient here — an
|
||||||
|
unknown/ambiguous IP, an empty token, or a token mismatch all deny."""
|
||||||
|
if not identity_token:
|
||||||
|
return None
|
||||||
|
rec = self.by_source_ip(source_ip)
|
||||||
|
if rec is None:
|
||||||
|
return None
|
||||||
if not hmac.compare_digest(rec.identity_token, identity_token):
|
if not hmac.compare_digest(rec.identity_token, identity_token):
|
||||||
return None
|
return None
|
||||||
return rec
|
return rec
|
||||||
|
|||||||
@@ -79,11 +79,20 @@ class Orchestrator:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def attribute(self, source_ip: str, identity_token: str) -> BottleRecord | None:
|
def attribute(self, source_ip: str, identity_token: str) -> BottleRecord | None:
|
||||||
"""Fail-closed attribution (delegates to the registry). The returned
|
"""Fail-closed attribution (delegates to the registry)."""
|
||||||
record carries the bottle's `policy` — this is the source-IP-keyed
|
|
||||||
resolution the multi-tenant sidecar makes per request."""
|
|
||||||
return self.registry.attribute(source_ip, identity_token)
|
return self.registry.attribute(source_ip, identity_token)
|
||||||
|
|
||||||
|
def resolve(self, source_ip: str, identity_token: str = "") -> BottleRecord | None:
|
||||||
|
"""Resolve the bottle behind a request — the source-IP-keyed lookup
|
||||||
|
the multi-tenant sidecar makes per request; the returned record
|
||||||
|
carries its `policy`. With a token, full attribution (source IP +
|
||||||
|
token); without, network-layer attribution by source IP alone
|
||||||
|
(valid where the IP is unspoofable and the control plane is
|
||||||
|
sidecar-only)."""
|
||||||
|
if identity_token:
|
||||||
|
return self.registry.attribute(source_ip, identity_token)
|
||||||
|
return self.registry.by_source_ip(source_ip)
|
||||||
|
|
||||||
def set_policy(self, bottle_id: str, policy: str) -> bool:
|
def set_policy(self, bottle_id: str, policy: str) -> bool:
|
||||||
"""Update a bottle's sidecar policy in place (live reload). False if
|
"""Update a bottle's sidecar policy in place (live reload). False if
|
||||||
the bottle is unknown."""
|
the bottle is unknown."""
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""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. `identity_token` is optional — omit it to
|
||||||
|
resolve by source IP alone (the network-layer attribution).
|
||||||
|
Raises `PolicyResolveError` if the orchestrator can't be reached."""
|
||||||
|
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,53 @@
|
|||||||
|
"""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()
|
||||||
@@ -171,6 +171,22 @@ class TestDispatch(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(400, status)
|
self.assertEqual(400, status)
|
||||||
|
|
||||||
|
def test_resolve_without_token_by_source_ip(self) -> None:
|
||||||
|
_, reg = dispatch(
|
||||||
|
self.orch, "POST", "/bottles",
|
||||||
|
_body({"source_ip": "10.243.0.5", "policy": "P"}),
|
||||||
|
)
|
||||||
|
status, payload = dispatch(
|
||||||
|
self.orch, "POST", "/resolve", _body({"source_ip": "10.243.0.5"})
|
||||||
|
)
|
||||||
|
self.assertEqual(200, status)
|
||||||
|
self.assertEqual(reg["bottle_id"], payload["bottle_id"])
|
||||||
|
self.assertEqual("P", payload["policy"])
|
||||||
|
|
||||||
|
def test_resolve_requires_source_ip(self) -> None:
|
||||||
|
status, _ = dispatch(self.orch, "POST", "/resolve", _body({}))
|
||||||
|
self.assertEqual(400, status)
|
||||||
|
|
||||||
|
|
||||||
class TestServerRoundTrip(unittest.TestCase):
|
class TestServerRoundTrip(unittest.TestCase):
|
||||||
def test_http_register_health_attribute(self) -> None:
|
def test_http_register_health_attribute(self) -> None:
|
||||||
|
|||||||
@@ -121,6 +121,18 @@ class TestRegistryStore(unittest.TestCase):
|
|||||||
assert got is not None
|
assert got is not None
|
||||||
self.assertEqual("", got.policy)
|
self.assertEqual("", got.policy)
|
||||||
|
|
||||||
|
def test_by_source_ip_returns_single_active(self) -> None:
|
||||||
|
rec = self.store.register("10.243.0.1")
|
||||||
|
got = self.store.by_source_ip("10.243.0.1")
|
||||||
|
assert got is not None
|
||||||
|
self.assertEqual(rec.bottle_id, got.bottle_id)
|
||||||
|
|
||||||
|
def test_by_source_ip_unknown_or_ambiguous_denied(self) -> None:
|
||||||
|
self.assertIsNone(self.store.by_source_ip("10.243.9.9")) # unknown
|
||||||
|
self.store.register("10.243.0.1", bottle_id="a")
|
||||||
|
self.store.register("10.243.0.1", bottle_id="b")
|
||||||
|
self.assertIsNone(self.store.by_source_ip("10.243.0.1")) # ambiguous
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -102,6 +102,18 @@ class TestOrchestrator(unittest.TestCase):
|
|||||||
def test_set_policy_unknown_is_false(self) -> None:
|
def test_set_policy_unknown_is_false(self) -> None:
|
||||||
self.assertFalse(self.orch.set_policy("ghost", "{}"))
|
self.assertFalse(self.orch.set_policy("ghost", "{}"))
|
||||||
|
|
||||||
|
def test_resolve_by_source_ip_without_token(self) -> None:
|
||||||
|
rec = self.orch.launch_bottle("10.243.0.1", policy="P")
|
||||||
|
got = self.orch.resolve("10.243.0.1") # network-layer, no token
|
||||||
|
assert got is not None
|
||||||
|
self.assertEqual(rec.bottle_id, got.bottle_id)
|
||||||
|
self.assertEqual("P", got.policy)
|
||||||
|
|
||||||
|
def test_resolve_with_token_stays_strict(self) -> None:
|
||||||
|
rec = self.orch.launch_bottle("10.243.0.3")
|
||||||
|
self.assertIsNotNone(self.orch.resolve("10.243.0.3", rec.identity_token))
|
||||||
|
self.assertIsNone(self.orch.resolve("10.243.0.3", "wrong-token"))
|
||||||
|
|
||||||
def test_launch_rolls_back_registry_on_broker_failure(self) -> None:
|
def test_launch_rolls_back_registry_on_broker_failure(self) -> None:
|
||||||
orch = Orchestrator(self.store, _FailingBroker(self.secret), self.secret)
|
orch = Orchestrator(self.store, _FailingBroker(self.secret), self.secret)
|
||||||
with self.assertRaises(RuntimeError):
|
with self.assertRaises(RuntimeError):
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""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"])
|
||||||
|
|
||||||
|
def test_resolve_without_token_sends_empty(self) -> None:
|
||||||
|
with patch(_URLOPEN, return_value=_resp({"policy": "P"})) as m:
|
||||||
|
self.r.resolve("10.243.0.7") # token optional
|
||||||
|
self.assertEqual("", json.loads(m.call_args.args[0].data)["identity_token"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user