Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions services/api/api/iron-proxy.base.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,15 @@ transforms:
- "x-as-user-email"
- "/^x-codex-.*$/"
- "/^x-openai-.*$/"
# AWS SigV4 request headers (x-amz-target, x-amz-date,
# x-amz-content-sha256, x-amz-security-token) for the aws_auth transform.
- "/^x-amz-.*$/"
# AWS SDK headers that the SDK signer folds into the SigV4 signed-headers
# set (amz-sdk-request, amz-sdk-invocation-id, and x-amzn-query-mode for
# CloudWatch's query-JSON protocol). aws_auth signs them, so they must
# survive egress filtering or AWS rejects with InvalidSignatureException.
- "/^amz-sdk-.*$/"
- "/^x-amzn-.*$/"
- "/^x-[a-z0-9-]*(api-key|apikey|secret|token|auth|key)$/"

log:
Expand Down
52 changes: 51 additions & 1 deletion services/api/api/proxy_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
minting OAuth2 access tokens for the declared grant.
- ``hmac_sign`` transforms — one per unique ``HmacSignSecret`` signing scheme,
HMAC-signing each request and injecting the configured headers.
- ``aws_auth`` transforms — one per unique ``AwsAuthSecret`` credential set,
re-signing AWS SigV4 requests (the tool signs with placeholder credentials)
with real keys resolved from the secret source.
- top-level ``postgres:`` — one listener per ``PgDsnSecret`` on sequential ports
starting at 5432, ordered by name.
"""
Expand All @@ -26,6 +29,7 @@
import yaml

from api.tool_manager import (
AwsAuthSecret,
BrokeredTokenSecret,
GcpAuthSecret,
GitHubAppTokenSecret,
Expand Down Expand Up @@ -53,7 +57,7 @@ def load_base_config() -> str:
PG_LISTEN_PORT_BASE = 5432

_MANAGED_TRANSFORMS: frozenset[str] = frozenset(
{"secrets", "gcp_auth", "oauth_token", "hmac_sign"}
{"secrets", "gcp_auth", "oauth_token", "hmac_sign", "aws_auth"}
)

# Iron-proxy ``source`` schema for resolving secret values. ``env`` reads the
Expand Down Expand Up @@ -459,6 +463,51 @@ def _build_hmac_sign_transforms(
return transforms


def _build_aws_auth_transforms(
secrets: list[SecretDef],
) -> list[dict[str, Any]]:
"""``aws_auth`` transforms: one per unique credential set + scope.

Entries that share the same credential refs, session token, and
allowed regions/services are merged — their host rules are unioned so the
same signing config covers every upstream that opted in. iron-proxy re-signs
requests the tool signed with placeholder credentials, drawing the real keys
from the resolved secret sources.
"""
by_scheme: dict[
tuple[str, str, str | None, tuple[str, ...], tuple[str, ...]],
set[str],
] = {}
for secret in secrets:
if not isinstance(secret, AwsAuthSecret):
continue
key = (
secret.access_key_id_ref,
secret.secret_access_key_ref,
secret.session_token_ref,
secret.allowed_regions,
secret.allowed_services,
)
by_scheme.setdefault(key, set()).update(secret.hosts)

transforms: list[dict[str, Any]] = []
for key in sorted(by_scheme, key=lambda k: (k[0], k[1])):
access_key_id_ref, secret_access_key_ref, session_token_ref, regions, services = key
config: dict[str, Any] = {
"access_key_id": _build_source(access_key_id_ref),
"secret_access_key": _build_source(secret_access_key_ref),
}
if session_token_ref:
config["session_token"] = _build_source(session_token_ref)
if regions:
config["allowed_regions"] = list(regions)
if services:
config["allowed_services"] = list(services)
config["rules"] = [{"host": h} for h in sorted(by_scheme[key])]
transforms.append({"name": "aws_auth", "config": config})
return transforms


def _build_postgres_listeners(
secrets: list[SecretDef],
pg_listen_ports: dict[str, int],
Expand Down Expand Up @@ -524,6 +573,7 @@ def render_proxy_yaml(
if oauth_token is not None:
new_transforms.append(oauth_token)
new_transforms.extend(_build_hmac_sign_transforms(secrets))
new_transforms.extend(_build_aws_auth_transforms(secrets))
if new_transforms:
for index, transform in enumerate(transforms):
if (transform or {}).get("name") == "header_allowlist":
Expand Down
18 changes: 18 additions & 0 deletions services/api/api/sandbox/kubernetes.py
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,24 @@ def _build_tool_server_container(
env.append({"name": name, "value": dsn})
_apply_tool_server_extra_env(env, no_proxy)

# AWS region for the cloudwatch tool (non-secret). The tool signs with
# placeholder credentials and iron-proxy re-signs with the real keys, so no
# AWS credentials belong in this process — only the region, which boto3
# needs to pick the endpoint host and signing scope. Optional: the tool
# defaults to us-east-1 when unset.
env.append(
{
"name": "AWS_REGION",
"valueFrom": {
"secretKeyRef": {
"name": secret_name,
"key": _secret_env_key("AWS_REGION"),
"optional": True,
}
},
}
)

volume_mounts: list[dict[str, Any]] = [
{
"name": "firewall-ca",
Expand Down
88 changes: 84 additions & 4 deletions services/api/api/tool_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,31 @@ class HmacSignSecret:
allow_chunked_body: bool = False


@dataclass(frozen=True)
class AwsAuthSecret:
"""AWS SigV4 re-signing handled by iron-proxy's ``aws_auth`` transform.

The tool's AWS SDK signs each request with throwaway *placeholder*
credentials; iron-proxy reads the region and service from the inbound
signature's credential scope, strips that signature, and re-signs with the
real credentials resolved from ``access_key_id``/``secret_access_key`` (and
optional ``session_token``). The real keys never reach the sandbox — this is
the SigV4 analogue of the ``secrets`` transform's placeholder swap.

``allowed_services``/``allowed_regions`` scope which AWS services/regions the
proxy will sign for; ``hosts`` becomes the iron-proxy ``rules``. Credential
refs resolve like every other secret (env var or 1Password item).
"""

name: str
hosts: tuple[str, ...]
access_key_id_ref: str
secret_access_key_ref: str
session_token_ref: str | None = None
allowed_regions: tuple[str, ...] = ()
allowed_services: tuple[str, ...] = ()


SecretDef = (
HttpSecret
| GcpAuthSecret
Expand All @@ -315,6 +340,7 @@ class HmacSignSecret:
| BrokeredTokenSecret
| GitHubAppTokenSecret
| HmacSignSecret
| AwsAuthSecret
)


Expand Down Expand Up @@ -1040,6 +1066,59 @@ def _parse_secret(entry: Any, *, default_hosts: tuple[str, ...] = ()) -> SecretD
timestamp_format=timestamp_format,
allow_chunked_body=allow_chunked_body,
)
if secret_type == "aws_auth":
hosts = entry.get("hosts", [])
if (
not isinstance(hosts, list)
or not hosts
or not all(isinstance(h, str) and h for h in hosts)
):
raise ValueError(
f"aws_auth entry {name!r} 'hosts' must be a non-empty array "
f"of non-empty strings"
)
access_key_id_ref = entry.get("access_key_id")
if not isinstance(access_key_id_ref, str) or not access_key_id_ref:
raise ValueError(
f"aws_auth entry {name!r} requires a non-empty 'access_key_id'"
)
secret_access_key_ref = entry.get("secret_access_key")
if not isinstance(secret_access_key_ref, str) or not secret_access_key_ref:
raise ValueError(
f"aws_auth entry {name!r} requires a non-empty 'secret_access_key'"
)
session_token_ref = entry.get("session_token")
if session_token_ref is not None and (
not isinstance(session_token_ref, str) or not session_token_ref
):
raise ValueError(
f"aws_auth entry {name!r} 'session_token' must be a non-empty string"
)
allowed_regions = entry.get("allowed_regions", [])
if not isinstance(allowed_regions, list) or not all(
isinstance(r, str) and r for r in allowed_regions
):
raise ValueError(
f"aws_auth entry {name!r} 'allowed_regions' must be an array of "
f"non-empty strings"
)
allowed_services = entry.get("allowed_services", [])
if not isinstance(allowed_services, list) or not all(
isinstance(s, str) and s for s in allowed_services
):
raise ValueError(
f"aws_auth entry {name!r} 'allowed_services' must be an array of "
f"non-empty strings"
)
return AwsAuthSecret(
name=name,
hosts=tuple(hosts),
access_key_id_ref=access_key_id_ref,
secret_access_key_ref=secret_access_key_ref,
session_token_ref=session_token_ref,
allowed_regions=tuple(allowed_regions),
allowed_services=tuple(allowed_services),
)
raise ValueError(f"unknown secret type {secret_type!r}")


Expand All @@ -1065,10 +1144,11 @@ async def _resolve_secrets(secrets: list[SecretDef]) -> dict[str, str]:
``ToolContext`` — the tool gets back the ``replacer`` token, which iron-proxy
swaps for the real credential at the network boundary. Inject-mode HTTP
secrets are applied entirely by iron-proxy and never reach the tool.
``GcpAuthSecret``, ``OAuthTokenSecret`` and ``PgDsnSecret`` are likewise not
exposed via context: gcp_auth and oauth_token are minted and injected on the
wire by iron-proxy, and pg_dsn reaches the tool as an environment variable
set on the sandbox by the kubernetes backend.
``GcpAuthSecret``, ``OAuthTokenSecret``, ``AwsAuthSecret`` and ``PgDsnSecret``
are likewise not exposed via context: gcp_auth, oauth_token and aws_auth are
minted/re-signed and injected on the wire by iron-proxy (the tool signs AWS
requests with placeholder credentials), and pg_dsn reaches the tool as an
environment variable set on the sandbox by the kubernetes backend.
"""
return {s.name: s.replacer for s in secrets if _is_replace_secret(s)}

Expand Down
98 changes: 98 additions & 0 deletions services/api/tests/test_proxy_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
)
from api.tool_manager import (
DEFAULT_MATCH_HEADERS,
AwsAuthSecret,
GcpAuthSecret,
GitHubAppTokenSecret,
HmacHeader,
Expand Down Expand Up @@ -1838,3 +1839,100 @@ def test_render_github_app_token_uses_broker_credential_id(
"rules": [{"host": "api.github.com"}, {"host": "github.com"}],
}
]


# ── aws_auth parser ──────────────────────────────────────────────────────────


def _aws_entry(**overrides):
entry = {
"type": "aws_auth",
"name": "cloudwatch",
"access_key_id": "AWS_ACCESS_KEY_ID",
"secret_access_key": "AWS_SECRET_ACCESS_KEY",
"hosts": ["logs.*.amazonaws.com", "monitoring.*.amazonaws.com"],
"allowed_services": ["logs", "monitoring"],
}
entry.update(overrides)
return entry


def test_parser_typed_aws_auth_full_example() -> None:
secret = _parse_secret(_aws_entry())
assert isinstance(secret, AwsAuthSecret)
assert secret.access_key_id_ref == "AWS_ACCESS_KEY_ID"
assert secret.secret_access_key_ref == "AWS_SECRET_ACCESS_KEY"
assert secret.session_token_ref is None
assert secret.hosts == ("logs.*.amazonaws.com", "monitoring.*.amazonaws.com")
assert secret.allowed_services == ("logs", "monitoring")
assert secret.allowed_regions == ()


def test_parser_aws_auth_accepts_session_token_and_regions() -> None:
secret = _parse_secret(
_aws_entry(session_token="AWS_SESSION_TOKEN", allowed_regions=["us-east-1"])
)
assert isinstance(secret, AwsAuthSecret)
assert secret.session_token_ref == "AWS_SESSION_TOKEN"
assert secret.allowed_regions == ("us-east-1",)


def test_parser_aws_auth_requires_access_key_id() -> None:
entry = _aws_entry()
del entry["access_key_id"]
with pytest.raises(ValueError, match="requires a non-empty 'access_key_id'"):
_parse_secret(entry)


def test_parser_aws_auth_requires_secret_access_key() -> None:
entry = _aws_entry()
del entry["secret_access_key"]
with pytest.raises(ValueError, match="requires a non-empty 'secret_access_key'"):
_parse_secret(entry)


def test_parser_aws_auth_requires_hosts() -> None:
with pytest.raises(ValueError, match="'hosts' must be a non-empty array"):
_parse_secret(_aws_entry(hosts=[]))


# ── aws_auth renderer ────────────────────────────────────────────────────────


def test_render_emits_aws_auth_transform() -> None:
secrets = [
AwsAuthSecret(
name="cloudwatch",
hosts=("logs.*.amazonaws.com", "monitoring.*.amazonaws.com"),
access_key_id_ref="AWS_ACCESS_KEY_ID",
secret_access_key_ref="AWS_SECRET_ACCESS_KEY",
allowed_services=("logs", "monitoring"),
)
]
cfg = yaml.safe_load(render_proxy_yaml(secrets))
aws = next(t for t in cfg["transforms"] if t["name"] == "aws_auth")
assert aws["config"]["access_key_id"] == {"type": "env", "var": "AWS_ACCESS_KEY_ID"}
assert aws["config"]["secret_access_key"] == {
"type": "env",
"var": "AWS_SECRET_ACCESS_KEY",
}
assert aws["config"]["allowed_services"] == ["logs", "monitoring"]
assert "session_token" not in aws["config"]
assert {r["host"] for r in aws["config"]["rules"]} == {
"logs.*.amazonaws.com",
"monitoring.*.amazonaws.com",
}


def test_render_merges_aws_auth_hosts_for_shared_credentials() -> None:
secrets = [
AwsAuthSecret("a", ("logs.*.amazonaws.com",), "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"),
AwsAuthSecret("b", ("monitoring.*.amazonaws.com",), "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"),
]
cfg = yaml.safe_load(render_proxy_yaml(secrets))
blocks = [t for t in cfg["transforms"] if t["name"] == "aws_auth"]
assert len(blocks) == 1
assert {r["host"] for r in blocks[0]["config"]["rules"]} == {
"logs.*.amazonaws.com",
"monitoring.*.amazonaws.com",
}
30 changes: 30 additions & 0 deletions services/api/tests/test_sandbox_kubernetes_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -1203,6 +1203,36 @@ def test_proxy_iron_env_injects_broker_when_url_set(
}


def test_tool_server_container_exposes_aws_region_not_credentials(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from api.sandbox.kubernetes import _build_tool_server_container

monkeypatch.setenv("KUBERNETES_TOOL_SERVER_IMAGE", "centaur-api:latest")
monkeypatch.setenv("KUBERNETES_SECRET_ENV_NAME", "centaur-infra-env")

container = _build_tool_server_container(
thread_key="slack:T:C:1.0",
container_name="tool-server",
firewall_host="fw",
api_url="http://api:8000",
overlay_mount=None,
database_url="postgresql://centaur:centaur@127.0.0.1:15432/centaur",
)
by_name = {e["name"]: e for e in container["env"]}

# Region (non-secret) is exposed, optional so sandboxes start without it.
assert by_name["AWS_REGION"]["valueFrom"]["secretKeyRef"] == {
"name": "centaur-infra-env",
"key": "AWS_REGION",
"optional": True,
}
# Credentials must NOT be here — iron-proxy's aws_auth transform holds them
# and re-signs on the wire; they never enter the tool process.
assert "AWS_ACCESS_KEY_ID" not in by_name
assert "AWS_SECRET_ACCESS_KEY" not in by_name


@pytest.mark.asyncio
async def test_per_sandbox_proxy_uses_bootstrap_secret_for_onepassword(
monkeypatch: pytest.MonkeyPatch,
Expand Down
2 changes: 1 addition & 1 deletion services/iron-proxy/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM ironsh/iron-proxy:0.42.0-rc.3@sha256:d62792cbc1121e0277cb6bf76df53bf6d1050748cbe625a9ac3039dff1cee88f
FROM ironsh/iron-proxy:0.42.0-rc.4@sha256:9989c5fde4ba3891e53942f08d2de33e690bf2734e5e451210fd845beb992e6f

USER root
RUN apk add --no-cache curl jq
Expand Down
Loading
Loading