From 08aef30d875ffbe232eb307045528581743099b4 Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 13 Jul 2026 18:31:03 -0400 Subject: [PATCH] feat(git-gate): source-IP-keyed multi-tenant repo namespace (PRD 0070) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of git-gate consolidation: make the smart-HTTP git backend serve every bottle from one process, selecting each request's repo root by the calling bottle's source IP — the same attribution invariant + resolver the multi-tenant egress addon uses. `git daemon` can't source-IP-route per connection, so the consolidated gateway serves git-gate over this HTTP backend (the transport firecracker/macOS already use); wiring the docker path onto it lands with the launch-integration slice. - policy_resolver: factor out `_post_resolve`; add `resolve_bottle_id` (source IP -> bottle id, same fail-closed 403->None contract as `resolve`) — the git-gate has no policy blob to parse, the bottle *is* the namespace. - git_http_backend: `resolve_repo_root(resolver, base, source_ip, token)` — single-tenant passthrough when no resolver; else `/`, fail-closed on unattributed / resolver error / namespace escape. `BOT_BOTTLE_ORCHESTRATOR_URL` (same env as egress) flips the shared gateway multi-tenant; the identity header is read for attribution and never forwarded to the CGI. Per-repo creds + hooks scope by repo dir, so isolating the root per bottle isolates its creds too. Single-tenant path unchanged (existing real-git-push tests green). New unit coverage for the resolver + repo-root selection matrix. Full unit suite green (1690 tests; the 13 test_sidecar_init /bin/sleep errors are the pre-existing NixOS-local noise). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck --- bot_bottle/git_http_backend.py | 95 +++++++++++++++++++++++-- bot_bottle/policy_resolver.py | 35 +++++++-- tests/unit/test_git_http_multitenant.py | 65 +++++++++++++++++ tests/unit/test_policy_resolver.py | 17 +++++ 4 files changed, 200 insertions(+), 12 deletions(-) create mode 100644 tests/unit/test_git_http_multitenant.py diff --git a/bot_bottle/git_http_backend.py b/bot_bottle/git_http_backend.py index 04e6169..e7ee22e 100644 --- a/bot_bottle/git_http_backend.py +++ b/bot_bottle/git_http_backend.py @@ -6,6 +6,14 @@ where the guest reaches the sidecar over the point-to-point TAP). The wrapper serves the same `/git/*.git` bare repos through `git http-backend`, so pre-receive and upstream forwarding remain the git-gate enforcement point. + +Consolidated (PRD 0070): when `BOT_BOTTLE_ORCHESTRATOR_URL` is set, one +shared gateway serves every bottle, and each request is served from the +calling bottle's repo namespace (`/`), attributed from +the unspoofable source IP via the orchestrator. Per-repo credentials + +hooks scope by repo directory, so isolating the *root* per bottle isolates +its creds too. Unattributed clients fail closed (404). Unset → the legacy +per-bottle single-tenant flat root, unchanged. """ from __future__ import annotations @@ -17,9 +25,67 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import urlsplit +# policy_resolver ships flat alongside this file in the sidecar bundle +# image (see Dockerfile.sidecars); the bot_bottle.* fallback is the +# host-side / test path. Mirrors egress_addon's import shape. +try: + from policy_resolver import ( # type: ignore[import-not-found] + PolicyResolveError, + PolicyResolver, + ) +except ImportError: # pragma: no cover - host-side path + from bot_bottle.policy_resolver import PolicyResolveError, PolicyResolver + DEFAULT_PORT = 9420 +# Consolidated (multi-tenant) mode: when this points at the per-host +# orchestrator's control plane, the backend serves each request from the +# *calling* bottle's repo namespace, selected by source IP, instead of a +# single flat repo root. Unset → legacy per-bottle single-tenant mode +# (unchanged). Same env the egress addon reads, so one orchestrator setting +# flips the whole shared gateway multi-tenant. +ORCHESTRATOR_URL_ENV = "BOT_BOTTLE_ORCHESTRATOR_URL" + +# App-layer identity token (defense-in-depth over the source-IP invariant); +# the agent injects it, the backend reads it for attribution and never +# forwards it to `git http-backend`. Mirrors egress_addon.IDENTITY_HEADER +# (duplicated, not imported: egress_addon pulls in mitmproxy). +IDENTITY_HEADER = "x-bot-bottle-identity" + +# Default flat repo root (single-tenant, and the base under which +# consolidated mode nests each bottle's namespace). +DEFAULT_PROJECT_ROOT = "/git" + + +def resolve_repo_root( + resolver: "PolicyResolver | None", + base_root: Path, + source_ip: str, + identity_token: str = "", +) -> Path | None: + """The repo root to serve this request from, or None to deny (404). + + Single-tenant (`resolver is None`): the flat `base_root`, unchanged. + + Consolidated: `base_root/`, where the bottle is attributed + from the source IP via the orchestrator. Fail-closed — an unattributed + client, a resolver error, or a namespace that would escape `base_root` + all deny, so one bottle can never reach another's repos.""" + if resolver is None: + return base_root + try: + bottle_id = resolver.resolve_bottle_id(source_ip, identity_token) + except PolicyResolveError: + return None # orchestrator unreachable/errored → deny + if not bottle_id: + return None # unattributed → deny + base = base_root.resolve() + namespace = (base / bottle_id).resolve() + if base not in namespace.parents: + return None # bottle_id tried to escape the root → deny + return namespace + # Mirrors git_gate_render.GIT_GATE_TIMEOUT_SECS. Duplicated rather than # imported: this module ships as a flat top-level sibling in the sidecar # bundle image (see Dockerfile.sidecars), not as part of the bot_bottle @@ -40,10 +106,24 @@ class GitHttpHandler(BaseHTTPRequestHandler): def do_POST(self) -> None: self._run_backend() + def _project_root(self) -> Path | None: + """This request's repo root, or None to deny. Single-tenant unless + the server was started with a resolver (consolidated mode), in which + case the root is the calling bottle's source-IP-selected namespace.""" + base = Path(os.environ.get("GIT_PROJECT_ROOT", DEFAULT_PROJECT_ROOT)) + resolver = getattr(self.server, "policy_resolver", None) + token = self.headers.get(IDENTITY_HEADER, "") + return resolve_repo_root(resolver, base, self.client_address[0], token) + def _run_backend(self) -> None: + project_root = self._project_root() + if project_root is None: + # Unattributed / resolver error: deny before touching any repo. + self.send_error(404) + return parsed = urlsplit(self.path) if self._is_upload_pack(parsed.path, parsed.query): - repo_dir = self._repo_dir(parsed.path) + repo_dir = self._repo_dir(project_root, parsed.path) if repo_dir is None: self.send_error(404) return @@ -77,7 +157,7 @@ class GitHttpHandler(BaseHTTPRequestHandler): return env = os.environ.copy() env.update({ - "GIT_PROJECT_ROOT": os.environ.get("GIT_PROJECT_ROOT", "/git"), + "GIT_PROJECT_ROOT": str(project_root), "GIT_HTTP_EXPORT_ALL": "1", "REQUEST_METHOD": self.command, "PATH_INFO": parsed.path, @@ -123,8 +203,8 @@ class GitHttpHandler(BaseHTTPRequestHandler): ) self._write_cgi_response(proc.stdout) - def _repo_dir(self, path: str) -> Path | None: - root = Path(os.environ.get("GIT_PROJECT_ROOT", "/git")).resolve() + def _repo_dir(self, project_root: Path, path: str) -> Path | None: + root = project_root.resolve() relative = path.lstrip("/").split(".git", 1)[0] + ".git" candidate = (root / relative).resolve() if root not in (candidate, *candidate.parents): @@ -181,7 +261,12 @@ class GitHttpHandler(BaseHTTPRequestHandler): def main() -> int: port = int(os.environ.get("GIT_HTTP_PORT", str(DEFAULT_PORT))) server = ThreadingHTTPServer(("0.0.0.0", port), GitHttpHandler) - sys.stdout.write(f"git-http listening on 0.0.0.0:{port}\n") + orch_url = os.environ.get(ORCHESTRATOR_URL_ENV, "").strip() + # Consolidated mode: resolve each request's bottle namespace by source IP. + # Absent → single-tenant (flat repo root); behaviour unchanged. + server.policy_resolver = PolicyResolver(orch_url) if orch_url else None # type: ignore[attr-defined] + mode = "multi-tenant" if orch_url else "single-tenant" + sys.stdout.write(f"git-http listening on 0.0.0.0:{port} ({mode})\n") sys.stdout.flush() server.serve_forever() return 0 diff --git a/bot_bottle/policy_resolver.py b/bot_bottle/policy_resolver.py index 42fcd76..4d1c779 100644 --- a/bot_bottle/policy_resolver.py +++ b/bot_bottle/policy_resolver.py @@ -47,12 +47,11 @@ class PolicyResolver: 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.""" + def _post_resolve(self, source_ip: str, identity_token: str) -> dict[str, object] | None: + """The orchestrator's `/resolve` payload for this client, or None if + unattributed (a clean `403`). Raises `PolicyResolveError` on an + unreachable / unexpected-status / malformed response so every caller + can fail closed. Shared by `resolve` and `resolve_bottle_id`.""" body = json.dumps( {"source_ip": source_ip, "identity_token": identity_token} ).encode() @@ -69,8 +68,30 @@ class PolicyResolver: 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 payload if isinstance(payload, dict) else {} + + 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.""" + payload = self._post_resolve(source_ip, identity_token) + if payload is None: + return None + policy = payload.get("policy") return policy if isinstance(policy, str) else "" + def resolve_bottle_id(self, source_ip: str, identity_token: str = "") -> str | None: + """The calling bottle's id, or None if unattributed. This is the + source-IP-keyed identity the git-gate uses to select the bottle's + repo namespace (there is no per-bottle policy blob to parse — the + bottle *is* the namespace). Same fail-closed contract as `resolve`.""" + payload = self._post_resolve(source_ip, identity_token) + if payload is None: + return None + bottle_id = payload.get("bottle_id") + return bottle_id if isinstance(bottle_id, str) and bottle_id else None + __all__ = ["PolicyResolver", "PolicyResolveError"] diff --git a/tests/unit/test_git_http_multitenant.py b/tests/unit/test_git_http_multitenant.py new file mode 100644 index 0000000..752557b --- /dev/null +++ b/tests/unit/test_git_http_multitenant.py @@ -0,0 +1,65 @@ +"""Unit: resolve_repo_root — source-IP-keyed git-gate repo namespace (PRD 0070). + +The consolidated git-http backend serves every bottle from one process and +selects each request's repo root by the calling bottle's source IP. This is +the fail-closed selection logic, tested without a live server. +""" + +from __future__ import annotations + +import unittest +from pathlib import Path + +from bot_bottle.git_http_backend import resolve_repo_root +from bot_bottle.policy_resolver import PolicyResolveError + +_BASE = Path("/git") + + +class _FakeResolver: + def __init__(self, bottle_id: str | None = None, raises: bool = False) -> None: + self._bottle_id = bottle_id + self._raises = raises + self.calls: list[tuple[str, str]] = [] + + def resolve_bottle_id(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._bottle_id + + +class TestResolveRepoRoot(unittest.TestCase): + def test_single_tenant_passthrough(self) -> None: + # No resolver → the flat base root, unchanged (legacy per-bottle mode). + self.assertEqual(_BASE, resolve_repo_root(None, _BASE, "10.243.0.1")) + + def test_attributed_bottle_gets_namespaced_root(self) -> None: + root = resolve_repo_root(_FakeResolver(bottle_id="ab12cd34"), _BASE, "10.243.0.1") + self.assertEqual(Path("/git/ab12cd34"), root) + + def test_unattributed_denies(self) -> None: + self.assertIsNone(resolve_repo_root(_FakeResolver(bottle_id=None), _BASE, "10.243.0.9")) + + def test_empty_bottle_id_denies(self) -> None: + self.assertIsNone(resolve_repo_root(_FakeResolver(bottle_id=""), _BASE, "10.243.0.1")) + + def test_resolver_error_denies(self) -> None: + # Orchestrator unreachable/errored must never serve repos. + self.assertIsNone(resolve_repo_root(_FakeResolver(raises=True), _BASE, "10.243.0.1")) + + def test_namespace_escape_denies(self) -> None: + # A bottle_id that would climb out of the root is rejected (defense in + # depth — registry ids are token_hex, but never trust the namespace). + self.assertIsNone( + resolve_repo_root(_FakeResolver(bottle_id="../etc"), _BASE, "10.243.0.1") + ) + + def test_forwards_source_ip_and_token(self) -> None: + r = _FakeResolver(bottle_id="b1") + resolve_repo_root(r, _BASE, "10.243.0.1", "tok") + self.assertEqual(("10.243.0.1", "tok"), r.calls[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_policy_resolver.py b/tests/unit/test_policy_resolver.py index 1c6f772..b0502d6 100644 --- a/tests/unit/test_policy_resolver.py +++ b/tests/unit/test_policy_resolver.py @@ -71,6 +71,23 @@ class TestPolicyResolver(unittest.TestCase): self.r.resolve("10.243.0.7") # token optional self.assertEqual("", json.loads(m.call_args.args[0].data)["identity_token"]) + def test_resolve_bottle_id_returns_id(self) -> None: + with patch(_URLOPEN, return_value=_resp({"bottle_id": "b1", "policy": "P"})): + self.assertEqual("b1", self.r.resolve_bottle_id("10.243.0.1", "tok")) + + def test_resolve_bottle_id_403_is_none_fail_closed(self) -> None: + with patch(_URLOPEN, side_effect=_http_error(403)): + self.assertIsNone(self.r.resolve_bottle_id("10.243.0.9", "tok")) + + def test_resolve_bottle_id_missing_field_is_none(self) -> None: + with patch(_URLOPEN, return_value=_resp({"policy": "P"})): + self.assertIsNone(self.r.resolve_bottle_id("10.243.0.1", "tok")) + + def test_resolve_bottle_id_error_raises(self) -> None: + with patch(_URLOPEN, side_effect=_http_error(500)): + with self.assertRaises(PolicyResolveError): + self.r.resolve_bottle_id("10.243.0.1", "tok") + if __name__ == "__main__": unittest.main()