feat(git-gate): source-IP-keyed multi-tenant repo namespace (PRD 0070)
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 `<base>/<bottle_id>`, 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
This commit is contained in:
@@ -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 (`<root>/<bottle_id>`), 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/<bottle_id>`, 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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user