diff --git a/deploy/proxy/swap.py b/deploy/proxy/swap.py index 1494bae..dfd8059 100644 --- a/deploy/proxy/swap.py +++ b/deploy/proxy/swap.py @@ -42,10 +42,11 @@ class ExchangeUnavailable(Exception): def placeholder_in(value: str) -> str: - """Basic carries it as the password. Inside a longer value it never counts.""" + """Bearer and gh's ``token`` carry it after the scheme, Basic as the + password. Inside a longer value it never counts.""" scheme, _, rest = value.partition(" ") candidate = value - if scheme.lower() == "bearer": + if scheme.lower() in ("bearer", "token"): candidate = rest.strip() elif scheme.lower() == "basic": try: diff --git a/docs/architecture.md b/docs/architecture.md index 107ed4f..38a06e4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -107,10 +107,13 @@ Caller `env` is stored for provisioning and never returned by the API; keys in `hosts.schemas.RESERVED_HOST_ENV_KEYS` are rejected. `POST /hosts` takes `secrets`, keyed by service handle. A built-in handle -resolves through the catalog. A custom entry names its own `host` and -`auth_variable`. It can also set `auth_header` and `auth_prefix`. -The default is a bearer token in `Authorization`. Drukbox does not consult the -catalog for a custom entry. +resolves through the catalog, which names the variable a client reads and +the hosts the service reaches. `github` reaches `api.github.com` and +`uploads.github.com` with a bearer, and `github.com` with Basic and +`x-access-token` as the user, since git's smart HTTP refuses a bearer. A +custom entry names its own `host` and `auth_variable`. It can also set +`auth_header` and `auth_prefix`. The default is a bearer token in +`Authorization`. Drukbox does not consult the catalog for a custom entry. A static entry stores `value`. A refreshable entry stores `issuer`: the URL, the request headers, and the refresh interval. Drukbox never stores a fetched @@ -127,7 +130,10 @@ fingerprint of the random part. The sandbox receives the auth variable with the placeholder in its boot environment, next to the caller's `env`. On every provider but docker-sbx it also receives `HTTPS_PROXY`, `https_proxy`, and `NO_PROXY`, so it sends its HTTPS through the proxy at `SECRETS_PROXY_URL`, -and the proxy's CA in `SECRETS_PROXY_CA`, which it installs at boot. +and the proxy's CA in `SECRETS_PROXY_CA`, which it installs at boot. A box +with a `github` secret also points git at gh for its credential and rewrites +SSH remotes to HTTPS, so git sends the placeholder as a Basic password and +the proxy swaps it. The proxy is the official mitmproxy image with the addon in `deploy/proxy`. It terminates TLS only for the hosts the exchange lists at `/upstreams`, the hosts with a registered secret, and tunnels every other host blind. For a diff --git a/docs/deploy.md b/docs/deploy.md index 1855df9..e757f8b 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -331,7 +331,10 @@ every sandbox with secrets: A sandbox with secrets gets the certificate in `SECRETS_PROXY_CA`, base64, and installs it at boot with `update-ca-certificates`. `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, and `NODE_EXTRA_CA_CERTS` point curl, -Python, and Node at it. Keep `flow_detail` at `1` or below. A +Python, and Node at it. A sandbox with a `github` secret gets the placeholder +in `GH_TOKEN`, git pointed at gh, and SSH remotes rewritten to HTTPS. That +sandbox needs `git` and `gh`. The docker sandbox image has both. Keep +`flow_detail` at `1` or below. A higher level prints request headers, and after the swap those carry the real credential. Real credentials exist in three places only. They are encrypted in Postgres, they pass through the exchange process for one @@ -515,7 +518,7 @@ Docker provider: | Variable | Default | Purpose | | --- | --- | --- | -| `DOCKER_DEFAULT_IMAGE` | `ghcr.io/czpython/drukbox/sandbox:latest` | Sandbox image with sshd; auto-pulled. Build `images/local/Dockerfile` to customize. | +| `DOCKER_DEFAULT_IMAGE` | `ghcr.io/czpython/drukbox/sandbox:latest` | Sandbox image with sshd, git, and gh; auto-pulled. Build `images/local/Dockerfile` to customize. | | `DOCKER_SSH_USERNAME` | `root` | In-container user callers SSH as. | | `DOCKER_BOOTSTRAP_SSH_TIMEOUT_SECONDS` | `30.0` | ssh-keyscan retry budget for a fresh container. | diff --git a/docs/security.md b/docs/security.md index 4a33e71..a14de33 100644 --- a/docs/security.md +++ b/docs/security.md @@ -115,7 +115,9 @@ sandbox cannot reach the exchange or the API through it. Bind the exchange process where only the proxy can reach it, because its answer is the real credential. A sandbox with secrets installs the proxy's CA at boot, so whoever holds the CA key can impersonate any host to that sandbox. The key lives in -the proxy's volume. The API reads the public certificate only. +the proxy's volume. The API reads the public certificate only. A sandbox with +a `github` secret sends git through gh and rewrites SSH remotes to HTTPS, so +the usual clone and push go through the proxy. Two pieces of material reach the VM through its provider's user-data / setup-script mechanism, and that channel is the relevant exposure: diff --git a/images/local/Dockerfile b/images/local/Dockerfile index d321e66..559147c 100644 --- a/images/local/Dockerfile +++ b/images/local/Dockerfile @@ -1,4 +1,4 @@ -# Minimal sandbox image for the local `docker` provider: Ubuntu + sshd. +# Sandbox image for the local `docker` provider: Ubuntu, sshd, git, and gh. # Build once, then point the provider at it: # # docker build -t drukbox/sandbox:latest images/local/ @@ -7,8 +7,17 @@ # drukbox per host) and runs sshd in the foreground. Key-only auth; no password. FROM ubuntu:24.04 +# gh comes from GitHub's own apt repository, as its install guide says. RUN apt-get update \ - && apt-get install -y --no-install-recommends openssh-server ca-certificates \ + && apt-get install -y --no-install-recommends openssh-server ca-certificates curl git \ + && mkdir -p -m 755 /etc/apt/keyrings \ + && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \ + && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends gh \ && rm -rf /var/lib/apt/lists/* \ && mkdir -p /run/sshd diff --git a/images/local/entrypoint.sh b/images/local/entrypoint.sh index 972612c..385565a 100755 --- a/images/local/entrypoint.sh +++ b/images/local/entrypoint.sh @@ -19,6 +19,14 @@ if [ -n "${SECRETS_PROXY_CA:-}" ]; then update-ca-certificates >/dev/null fi +# git takes its credential from gh, and SSH remotes go over HTTPS. +if [ -n "${GH_TOKEN:-}" ]; then + git config --system --replace-all credential.https://github.com.helper '' + git config --system --add credential.https://github.com.helper '!gh auth git-credential' + git config --system --replace-all url.https://github.com/.insteadOf git@github.com: + git config --system --add url.https://github.com/.insteadOf ssh://git@github.com/ +fi + # Generate host keys if the image doesn't ship any. ssh-keygen -A diff --git a/src/host_secrets/catalog.py b/src/host_secrets/catalog.py index 438f560..a4435d0 100644 --- a/src/host_secrets/catalog.py +++ b/src/host_secrets/catalog.py @@ -1,38 +1,48 @@ -from typing import Any, TypedDict +import base64 +from typing import Any, NamedTuple BEARER_HEADER = "Authorization" BEARER_PREFIX = "Bearer " SERVICE_FIELDS = frozenset({"host", "auth_header", "auth_prefix", "auth_variable"}) -class Service(TypedDict): +class Upstream(NamedTuple): host: str - auth_header: str - auth_prefix: str - auth_variable: str + auth_header: str = BEARER_HEADER + auth_prefix: str = BEARER_PREFIX + # Basic carries the credential as this user's password. + basic_user: str = "" + + def credential(self, value: str) -> str: + if self.basic_user: + return "Basic " + base64.b64encode(f"{self.basic_user}:{value}".encode()).decode() + return f"{self.auth_prefix}{value}" -def bearer(host: str, auth_variable: str) -> Service: - return { - "host": host, - "auth_header": BEARER_HEADER, - "auth_prefix": BEARER_PREFIX, - "auth_variable": auth_variable, - } +class Service(NamedTuple): + auth_variable: str + upstreams: tuple[Upstream, ...] CATALOG: dict[str, Service] = { - "anthropic": bearer("api.anthropic.com", "ANTHROPIC_AUTH_TOKEN"), - "github": bearer("api.github.com", "GH_TOKEN"), - "openai": bearer("api.openai.com", "OPENAI_API_KEY"), + "anthropic": Service("ANTHROPIC_AUTH_TOKEN", (Upstream("api.anthropic.com"),)), + # git's smart HTTP refuses a bearer. It takes Basic with x-access-token as the user. + "github": Service( + "GH_TOKEN", + ( + Upstream("api.github.com"), + Upstream("uploads.github.com"), + Upstream("github.com", basic_user="x-access-token"), + ), + ), + "openai": Service("OPENAI_API_KEY", (Upstream("api.openai.com"),)), } def service(name: str, entry: dict[str, Any]) -> Service: - fields = entry if "host" in entry else CATALOG[name] - return { - "host": fields["host"], - "auth_header": fields["auth_header"], - "auth_prefix": fields["auth_prefix"], - "auth_variable": fields["auth_variable"], - } + if "host" in entry: + return Service( + entry["auth_variable"], + (Upstream(entry["host"], entry["auth_header"], entry["auth_prefix"]),), + ) + return CATALOG[name] diff --git a/src/host_secrets/tests/test_catalog.py b/src/host_secrets/tests/test_catalog.py index 280c2e6..43a2c10 100644 --- a/src/host_secrets/tests/test_catalog.py +++ b/src/host_secrets/tests/test_catalog.py @@ -1,12 +1,42 @@ -from host_secrets.catalog import CATALOG +import base64 -SERVICE_KEYS = {"host", "auth_header", "auth_prefix", "auth_variable"} +from host_secrets.catalog import CATALOG, Service, Upstream, service def test_catalog_starts_with_the_three_built_in_services() -> None: assert set(CATALOG) == {"anthropic", "github", "openai"} -def test_every_entry_describes_a_service_in_full() -> None: - for service in CATALOG.values(): - assert set(service) == SERVICE_KEYS +def test_every_entry_names_the_variable_a_client_reads_and_at_least_one_host() -> None: + for entry in CATALOG.values(): + assert entry.auth_variable + assert entry.upstreams + + +def test_github_is_one_service_with_the_api_uploads_and_git_hosts() -> None: + api, uploads, git = CATALOG["github"].upstreams + + assert (api.host, api.credential("ghs_x")) == ("api.github.com", "Bearer ghs_x") + assert (uploads.host, uploads.credential("ghs_x")) == ("uploads.github.com", "Bearer ghs_x") + assert git.host == "github.com" + assert git.credential("ghs_x") == "Basic " + base64.b64encode(b"x-access-token:ghs_x").decode() + assert api.auth_header == uploads.auth_header == git.auth_header == "Authorization" + + +def test_a_custom_entry_names_one_host_with_its_own_header_shape() -> None: + entry = { + "host": "api.acme.test", + "auth_header": "x-api-key", + "auth_prefix": "", + "auth_variable": "ACME_TOKEN", + "value": "ak_live", + } + + assert service("acme", entry) == Service( + "ACME_TOKEN", (Upstream("api.acme.test", "x-api-key", ""),) + ) + assert service("acme", entry).upstreams[0].credential("ak_live") == "ak_live" + + +def test_a_built_in_entry_resolves_through_the_catalog() -> None: + assert service("github", {"value": "ghs_x"}) is CATALOG["github"] diff --git a/src/hosts/tests/test_secrets.py b/src/hosts/tests/test_secrets.py index b1fd7ce..35312bc 100644 --- a/src/hosts/tests/test_secrets.py +++ b/src/hosts/tests/test_secrets.py @@ -42,7 +42,7 @@ async def put_secret( self, *, vm: str, service: Service, placeholder: Placeholder, value: str ) -> dict[str, str]: self.values[placeholder.service] = value - return {service["auth_variable"]: str(placeholder)} + return {service.auth_variable: str(placeholder)} async def delete_secret(self, *, vm: str, placeholder: Placeholder) -> None: return @@ -164,7 +164,7 @@ class OversizedInjection(RecordingInjection): async def put_secret( self, *, vm: str, service: Service, placeholder: Placeholder, value: str ) -> dict[str, str]: - return {service["auth_variable"]: "a" * 9000} + return {service.auth_variable: "a" * 9000} async def test_a_boot_environment_pam_cannot_read_fails_provisioning_before_the_vm( diff --git a/src/providers/capabilities.py b/src/providers/capabilities.py index 4ea864f..4eb5dc4 100644 --- a/src/providers/capabilities.py +++ b/src/providers/capabilities.py @@ -65,7 +65,7 @@ async def put_secret( ) -> dict[str, str]: settings = get_settings() return { - service["auth_variable"]: str(placeholder), + service.auth_variable: str(placeholder), "HTTPS_PROXY": settings.secrets_proxy_url, "https_proxy": settings.secrets_proxy_url, "NO_PROXY": NO_PROXY, diff --git a/src/providers/docker_sbx/api.py b/src/providers/docker_sbx/api.py index fb1205b..b15bd60 100644 --- a/src/providers/docker_sbx/api.py +++ b/src/providers/docker_sbx/api.py @@ -66,18 +66,18 @@ async def set_custom_secret( self, *, sandbox: str, - host: str, + hosts: list[str], env: str, placeholder: str, command: str, ) -> None: + # One secret covers every host of its service. --host repeats. await self._run( "secret", "set-custom", "--sandbox", sandbox, - "--host", - host, + *(part for host in hosts for part in ("--host", host)), "--env", env, "--placeholder", diff --git a/src/providers/docker_sbx/secrets.py b/src/providers/docker_sbx/secrets.py index 00555a2..045e5b8 100644 --- a/src/providers/docker_sbx/secrets.py +++ b/src/providers/docker_sbx/secrets.py @@ -46,14 +46,14 @@ async def put_secret( else: await self.api.set_custom_secret( sandbox=vm, - host=service["host"], - env=service["auth_variable"], + hosts=[upstream.host for upstream in service.upstreams], + env=service.auth_variable, placeholder=str(placeholder), command=command, ) except DockerSbxProviderError as exc: raise ProviderTransportError(str(exc)) from exc - return {service["auth_variable"]: str(placeholder)} + return {service.auth_variable: str(placeholder)} async def delete_secret(self, *, vm: str, placeholder: Placeholder) -> None: try: diff --git a/src/providers/docker_sbx/tests/test_api.py b/src/providers/docker_sbx/tests/test_api.py index a41ac1d..938ab2a 100644 --- a/src/providers/docker_sbx/tests/test_api.py +++ b/src/providers/docker_sbx/tests/test_api.py @@ -69,6 +69,43 @@ async def fake_exec(*args, **kwargs): process.communicate.assert_awaited_once_with(script.encode()) +@pytest.mark.asyncio +async def test_set_custom_secret_names_every_host_of_the_service(monkeypatch): + captured: dict = {} + + async def fake_exec(*args, **kwargs): + captured["args"] = args + return _process() + + monkeypatch.setattr("providers.docker_sbx.api.asyncio.create_subprocess_exec", fake_exec) + + await SbxCLI().set_custom_secret( + sandbox="sb-test", + hosts=["api.github.com", "github.com"], + env="GH_TOKEN", + placeholder="drk.a.github.b", + command="cat /secrets/sb-test/github", + ) + + assert captured["args"] == ( + "sbx", + "secret", + "set-custom", + "--sandbox", + "sb-test", + "--host", + "api.github.com", + "--host", + "github.com", + "--env", + "GH_TOKEN", + "--placeholder", + "drk.a.github.b", + "--command", + "cat /secrets/sb-test/github", + ) + + @pytest.mark.asyncio async def test_sandbox_count_reads_the_listing(monkeypatch): listing = b'{"sandboxes": [{"name": "sb-a"}, {"name": "sb-b"}]}' diff --git a/src/providers/docker_sbx/tests/test_secrets.py b/src/providers/docker_sbx/tests/test_secrets.py index 7997e1a..0c2692a 100644 --- a/src/providers/docker_sbx/tests/test_secrets.py +++ b/src/providers/docker_sbx/tests/test_secrets.py @@ -54,7 +54,7 @@ async def test_any_other_secret_is_a_custom_secret_on_its_host_from_a_file(tmp_p assert stat.S_IMODE(path.parent.stat().st_mode) == 0o700 api.set_custom_secret.assert_awaited_once_with( sandbox="sb-one", - host="api.anthropic.com", + hosts=["api.anthropic.com"], env="ANTHROPIC_AUTH_TOKEN", placeholder=str(placeholder), command=f"cat {path}", diff --git a/src/providers/environment.py b/src/providers/environment.py index db3a827..758d0d8 100644 --- a/src/providers/environment.py +++ b/src/providers/environment.py @@ -3,6 +3,8 @@ import re import shlex +from host_secrets.catalog import CATALOG + _NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") # pam_env reads /etc/environment one entry per line, with its own parser: a @@ -77,16 +79,32 @@ def get_install_ca(env: dict[str, str], *, sudo: bool = False) -> list[str]: return [] +def get_github(env: dict[str, str], *, sudo: bool = False) -> list[str]: + """The lines ``gh auth setup-git`` writes, for every user, and SSH remotes + sent over HTTPS. They can run again on the same box.""" + git = f"{'sudo -n ' if sudo else ''}git config --system" + if CATALOG["github"].auth_variable in env: + return [ + f"{git} --replace-all credential.https://github.com.helper '' || exit 1", + f"{git} --add credential.https://github.com.helper '!gh auth git-credential' || exit 1", + f"{git} --replace-all url.https://github.com/.insteadOf git@github.com: || exit 1", + f"{git} --add url.https://github.com/.insteadOf ssh://git@github.com/ || exit 1", + ] + return [] + + def get_cloud_init(setup_script: str, env: dict[str, str] | None) -> str: """A shebang, ``env`` for the script and every session, the proxy's CA, - then the script.""" + git's setup, then the script.""" script = setup_script if setup_script.startswith("#!") else f"#!/bin/sh\n{setup_script}" shebang, _, body = script.partition("\n") + env = env or {} lines = [ shebang, - *get_export(env or {}), - *get_persist(env or {}), - *get_install_ca(env or {}), + *get_export(env), + *get_persist(env), + *get_install_ca(env), + *get_github(env), body, ] return "\n".join(line for line in lines if line) diff --git a/src/providers/exe/api.py b/src/providers/exe/api.py index c43d537..5da60c6 100644 --- a/src/providers/exe/api.py +++ b/src/providers/exe/api.py @@ -100,6 +100,7 @@ async def create_vm( *environment.get_export(env), environment.get_bashrc(env), *environment.get_install_ca(env, sudo=True), + *environment.get_github(env, sudo=True), body, ] setup_script = "\n".join(part for part in parts if part) diff --git a/src/providers/exe/tests/test_api.py b/src/providers/exe/tests/test_api.py index e26fd07..b5860e5 100644 --- a/src/providers/exe/tests/test_api.py +++ b/src/providers/exe/tests/test_api.py @@ -1,4 +1,5 @@ import json +import re import httpx import pytest @@ -134,6 +135,26 @@ async def test_create_vm_installs_the_proxy_ca_with_sudo(respx_mock): ] +@pytest.mark.asyncio +@respx.mock(base_url="https://exe.dev") +async def test_create_vm_sets_git_up_with_sudo_for_a_github_secret(respx_mock): + route = respx_mock.post("/exec").mock( + return_value=httpx.Response(200, content=b'{"vm_name": "sb-1", "ssh_port": 22}'), + ) + + await _api().create_vm(name="sb-1", image="ubuntu:22.04", env={"GH_TOKEN": "drk.a.github.b"}) + + lines = _setup_script(route.calls.last.request.content.decode()) + assert lines[-4] == ( + "sudo -n git config --system --replace-all credential.https://github.com.helper '' " + "|| exit 1" + ) + assert lines[-1] == ( + "sudo -n git config --system --add url.https://github.com/.insteadOf " + "ssh://git@github.com/ || exit 1" + ) + + @pytest.mark.asyncio @respx.mock(base_url="https://exe.dev", assert_all_called=False) async def test_create_vm_refuses_a_setup_script_over_the_exe_limit(respx_mock): @@ -147,8 +168,9 @@ async def test_create_vm_refuses_a_setup_script_over_the_exe_limit(respx_mock): def _setup_script(body: str) -> list[str]: """The setup script exe receives, as lines.""" - quoted = body.split("--setup-script=", 1)[1].split(" --", 1)[0] - return json.loads(quoted).split("\n") + quoted = re.search(r'--setup-script=("(?:[^"\\]|\\.)*")', body) + assert quoted + return json.loads(quoted.group(1)).split("\n") @pytest.mark.asyncio diff --git a/src/providers/tests/test_environment.py b/src/providers/tests/test_environment.py index 916525b..43cfc43 100644 --- a/src/providers/tests/test_environment.py +++ b/src/providers/tests/test_environment.py @@ -1,4 +1,7 @@ +import os import shlex +import shutil +import subprocess import pytest @@ -96,6 +99,58 @@ def test_a_box_without_secrets_installs_nothing(): assert "ca-certificates" not in environment.get_cloud_init("echo hi", env={"FOO": "bar"}) +GIT_LINES = [ + "git config --system --replace-all credential.https://github.com.helper '' || exit 1", + "git config --system --add credential.https://github.com.helper '!gh auth git-credential' " + "|| exit 1", + "git config --system --replace-all url.https://github.com/.insteadOf git@github.com: || exit 1", + "git config --system --add url.https://github.com/.insteadOf ssh://git@github.com/ || exit 1", +] + + +def test_github_points_git_at_gh_and_sends_ssh_remotes_over_https(): + assert environment.get_github({"GH_TOKEN": "drk.a.github.b"}) == GIT_LINES + + +def test_github_uses_sudo_for_a_script_that_runs_as_a_user(): + assert environment.get_github({"GH_TOKEN": "drk.a.github.b"}, sudo=True) == [ + f"sudo -n {line}" for line in GIT_LINES + ] + + +@pytest.mark.skipif(shutil.which("git") is None, reason="needs git") +def test_the_git_setup_runs_again_on_the_same_box(tmp_path): + # A container restart runs the entrypoint again. git refuses to replace a + # key with several values, so the lines must replace and then add. + system = tmp_path / "gitconfig" + script = "\n".join(environment.get_github({"GH_TOKEN": "drk.a.github.b"})) + for _ in range(2): + subprocess.run( + ["sh", "-c", script], + env={**os.environ, "GIT_CONFIG_SYSTEM": str(system)}, + check=True, + timeout=60, + ) + helpers = subprocess.run( + ["git", "config", "--system", "--get-all", "credential.https://github.com.helper"], + env={**os.environ, "GIT_CONFIG_SYSTEM": str(system)}, + capture_output=True, + text=True, + check=True, + ).stdout + assert helpers == "\n!gh auth git-credential\n" + + +def test_a_box_without_a_github_secret_gets_no_git_setup(): + assert environment.get_github({"ANTHROPIC_AUTH_TOKEN": "drk.a.anthropic.b"}) == [] + assert "git config" not in environment.get_cloud_init("echo hi", env={"FOO": "bar"}) + + +def test_cloud_init_sets_git_up_after_the_ca_and_before_the_script(): + out = environment.get_cloud_init("echo hi", env={"GH_TOKEN": "drk.a.github.b"}) + assert out.splitlines()[-5:] == [*GIT_LINES, "echo hi"] + + def test_cloud_init_installs_the_ca_after_the_env_and_before_the_script(): out = environment.get_cloud_init("echo hi", env={"SECRETS_PROXY_CA": "Q0VSVA=="}) assert out.splitlines() == [ diff --git a/src/secrets_exchange/app.py b/src/secrets_exchange/app.py index c328c0b..f283057 100644 --- a/src/secrets_exchange/app.py +++ b/src/secrets_exchange/app.py @@ -44,9 +44,10 @@ async def upstreams(session: Annotated[AsyncSession, Depends(get_session)]) -> l hosts = (await session.execute(select(Host))).scalars() return sorted( { - catalog.service(name, entry)["host"] + upstream.host for host in hosts for name, entry in host.secrets.items() + for upstream in catalog.service(name, entry).upstreams } ) @@ -72,9 +73,13 @@ async def authorize( if not placeholder.matches(entry["placeholder_fingerprint"]): raise HTTPException(status.HTTP_403_FORBIDDEN) - service = catalog.service(placeholder.service, entry) - if x_forwarded_host != service["host"]: + upstreams = { + upstream.host: upstream + for upstream in catalog.service(placeholder.service, entry).upstreams + } + if x_forwarded_host not in upstreams: raise HTTPException(status.HTTP_403_FORBIDDEN) + upstream = upstreams[x_forwarded_host] try: secret = await secrets.current(host.id, placeholder.service, entry) @@ -86,8 +91,8 @@ async def authorize( return Response( status_code=status.HTTP_200_OK, headers={ - UPSTREAM_HOST: service["host"], - UPSTREAM_HEADER: service["auth_header"], - UPSTREAM_CREDENTIAL: f"{service['auth_prefix']}{secret.value}", + UPSTREAM_HOST: upstream.host, + UPSTREAM_HEADER: upstream.auth_header, + UPSTREAM_CREDENTIAL: upstream.credential(secret.value), }, ) diff --git a/src/secrets_exchange/tests/test_app.py b/src/secrets_exchange/tests/test_app.py index 1ee638d..3ef3014 100644 --- a/src/secrets_exchange/tests/test_app.py +++ b/src/secrets_exchange/tests/test_app.py @@ -1,3 +1,4 @@ +import base64 import uuid from collections.abc import AsyncGenerator from pathlib import Path @@ -41,6 +42,24 @@ async def test_a_placeholder_is_exchanged_for_the_real_secret(edge) -> None: assert response.headers["X-Upstream-Credential"] == "Bearer ghs_real" +async def test_the_git_host_of_github_gets_basic_with_the_token_as_the_password(edge) -> None: + host_id = uuid.uuid4() + minted = Placeholder.mint(host_id, "github") + await _create_host( + host_id, {"github": {"value": "ghs_real", "placeholder_fingerprint": minted.fingerprint}} + ) + + response = await edge.get("/authorize", headers=_headers(str(minted), "github.com")) + + assert response.status_code == 200 + assert response.headers["X-Upstream-Host"] == "github.com" + assert response.headers["X-Upstream-Header"] == "Authorization" + assert ( + response.headers["X-Upstream-Credential"] + == "Basic " + base64.b64encode(b"x-access-token:ghs_real").decode() + ) + + async def test_a_custom_service_gets_its_own_header_shape(edge) -> None: host_id = uuid.uuid4() minted = Placeholder.mint(host_id, "acme") @@ -147,12 +166,19 @@ async def test_the_upstreams_are_the_hosts_with_a_registered_secret(edge) -> Non }, }, ) + await _create_host(uuid.uuid4(), {"github": {"value": "ghs_real"}}) await _create_host(uuid.uuid4(), {}) response = await edge.get("/upstreams") assert response.status_code == 200 - assert response.json() == ["api.acme.test", "api.anthropic.com"] + assert response.json() == [ + "api.acme.test", + "api.anthropic.com", + "api.github.com", + "github.com", + "uploads.github.com", + ] async def test_no_secret_means_no_upstream(edge) -> None: diff --git a/src/secrets_exchange/tests/test_swap.py b/src/secrets_exchange/tests/test_swap.py index 15bf749..9c19cb9 100644 --- a/src/secrets_exchange/tests/test_swap.py +++ b/src/secrets_exchange/tests/test_swap.py @@ -154,11 +154,12 @@ def _basic(user: str, password: str) -> str: [ (f"Bearer {PLACEHOLDER}", PLACEHOLDER), (f"bearer {PLACEHOLDER}", PLACEHOLDER), + (f"token {PLACEHOLDER}", PLACEHOLDER), (PLACEHOLDER, PLACEHOLDER), (_basic("x-access-token", PLACEHOLDER), PLACEHOLDER), ("Bearer sk-ant-oat01-real", ""), (f"Bearer x{PLACEHOLDER}", ""), - (f"token {PLACEHOLDER}", ""), + (f"Digest {PLACEHOLDER}", ""), ("Bearer drk.0123abcd.anthropic", ""), (_basic("user", "ghs_real"), ""), ("Basic not-base64!", ""),