Skip to content

Commit 259397b

Browse files
feat(server): operator-configurable extra forwarded headers on HttpUpstream
The default forward set (X-API-Key, Authorization, Cookie) only covers credential headers Agent Control itself reads. Deployments whose upstream authenticates against a different header name (e.g., a deployer-specific API-key header) had no way to surface that credential through HttpUpstreamAuthProvider — the inbound header reached AC but never crossed the upstream call. Add an extra_forward_headers config field on HttpUpstreamConfig (defaulting to the empty tuple) that operators populate via the new AGENT_CONTROL_AUTH_UPSTREAM_EXTRA_FORWARD_HEADERS env var (comma- separated). The provider's _forward_headers iterates over the union of the default set and the extras, deduplicating case-insensitively so a duplicate name (cross-set or within extras) does not produce two copies on the wire. Tests: - forwards a configured extra header alongside defaults - default forward set unchanged when extras are empty - extras dedupe against defaults case-insensitively - _parse_extra_forward_headers parametric: None / empty / single / multiple / whitespace / empty-entries / case-folded duplicates - configure_auth_from_env threads the parsed tuple onto the provider Lint clean, typecheck clean, full server suite (747) green.
1 parent 19fa65c commit 259397b

3 files changed

Lines changed: 162 additions & 2 deletions

File tree

server/src/agent_control_server/auth_framework/config.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
_UPSTREAM_TIMEOUT_ENV = "AGENT_CONTROL_AUTH_UPSTREAM_TIMEOUT_SECONDS"
4747
_UPSTREAM_TOKEN_ENV = "AGENT_CONTROL_AUTH_UPSTREAM_SERVICE_TOKEN"
4848
_UPSTREAM_TOKEN_HEADER_ENV = "AGENT_CONTROL_AUTH_UPSTREAM_SERVICE_TOKEN_HEADER"
49+
_UPSTREAM_EXTRA_FORWARD_HEADERS_ENV = "AGENT_CONTROL_AUTH_UPSTREAM_EXTRA_FORWARD_HEADERS"
4950

5051
# Runtime flow.
5152
_RUNTIME_MODE_ENV = "AGENT_CONTROL_RUNTIME_AUTH_MODE"
@@ -196,20 +197,48 @@ def _build_default_provider() -> RequestAuthorizer:
196197
timeout = float(os.environ.get(_UPSTREAM_TIMEOUT_ENV, "5.0"))
197198
token = os.environ.get(_UPSTREAM_TOKEN_ENV)
198199
token_header = os.environ.get(_UPSTREAM_TOKEN_HEADER_ENV, "X-Agent-Control-Service-Token")
200+
extra_forward_headers = _parse_extra_forward_headers(
201+
os.environ.get(_UPSTREAM_EXTRA_FORWARD_HEADERS_ENV)
202+
)
199203
_logger.info("Default auth provider: http_upstream url=%s", url)
200204
return HttpUpstreamAuthProvider(
201205
HttpUpstreamConfig(
202206
url=url,
203207
timeout_seconds=timeout,
204208
service_token=token,
205209
service_token_header=token_header,
210+
extra_forward_headers=extra_forward_headers,
206211
)
207212
)
208213
raise RuntimeError(
209214
f"Unknown {_MODE_ENV}={mode!r}; expected 'none', 'api_key', or 'http_upstream'."
210215
)
211216

212217

218+
def _parse_extra_forward_headers(raw: str | None) -> tuple[str, ...]:
219+
"""Parse a comma-separated header list into a deduplicated tuple.
220+
221+
Empty / unset env var returns an empty tuple. Whitespace around each
222+
name is stripped. Empty entries (e.g. ``"X-A,,X-B"``) are dropped.
223+
Order is preserved; duplicates (case-insensitive) are dropped after
224+
the first occurrence.
225+
"""
226+
if not raw or not raw.strip():
227+
return ()
228+
seen: set[str] = set()
229+
result: list[str] = []
230+
for raw_name in raw.split(","):
231+
name = raw_name.strip()
232+
if not name:
233+
continue
234+
lower = name.lower()
235+
if lower in seen:
236+
continue
237+
seen.add(lower)
238+
result.append(name)
239+
return tuple(result)
240+
241+
213242
def _resolve_runtime_mode() -> str:
214243
raw = os.environ.get(_RUNTIME_MODE_ENV)
215244
if raw is None or not raw.strip():

server/src/agent_control_server/auth_framework/providers/http_upstream.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060

6161
_logger = get_logger(__name__)
6262

63-
_FORWARDED_HEADERS = ("X-API-Key", "Authorization", "Cookie")
63+
_DEFAULT_FORWARDED_HEADERS = ("X-API-Key", "Authorization", "Cookie")
6464

6565

6666
class _UpstreamGrant(BaseModel):
@@ -136,6 +136,17 @@ class HttpUpstreamConfig:
136136

137137
service_token_header: str = "X-Agent-Control-Service-Token"
138138

139+
extra_forward_headers: tuple[str, ...] = ()
140+
"""Additional inbound request headers to forward to the upstream
141+
on top of the default ``(X-API-Key, Authorization, Cookie)`` set.
142+
143+
Use this when the upstream authenticates via a header the provider
144+
does not forward by default (e.g., a deployer-specific API-key
145+
header). Header lookups against the inbound request are
146+
case-insensitive; an empty or absent inbound header is silently
147+
dropped. Names duplicating the default set or each other (after
148+
case-folding) are deduplicated."""
149+
139150

140151
class HttpUpstreamAuthProvider(RequestAuthorizer):
141152
"""Delegates authorization to an upstream HTTP service."""
@@ -190,7 +201,12 @@ async def authorize(
190201

191202
def _forward_headers(self, request: Request) -> dict[str, str]:
192203
headers: dict[str, str] = {}
193-
for name in _FORWARDED_HEADERS:
204+
seen: set[str] = set()
205+
for name in (*_DEFAULT_FORWARDED_HEADERS, *self._config.extra_forward_headers):
206+
lower = name.lower()
207+
if lower in seen:
208+
continue
209+
seen.add(lower)
194210
value = request.headers.get(name)
195211
if value is not None:
196212
headers[name] = value

server/tests/test_auth_framework.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,75 @@ def factory(request: httpx.Request) -> httpx.Response:
261261
assert captured["headers"]["x-custom-token"] == "shh"
262262

263263

264+
@pytest.mark.asyncio
265+
async def test_http_upstream_forwards_extra_headers():
266+
# Given: a provider configured with an extra header in its forward list
267+
captured: dict[str, Any] = {}
268+
269+
def factory(request: httpx.Request) -> httpx.Response:
270+
captured["headers"] = dict(request.headers)
271+
return httpx.Response(200, json={"namespace_key": "ns"})
272+
273+
provider = _build_upstream(
274+
factory,
275+
config_overrides={"extra_forward_headers": ("X-Deployer-Auth",)},
276+
)
277+
278+
# When: the inbound request carries the extra header
279+
inbound = _build_request(headers={"X-Deployer-Auth": "k_abc", "X-API-Key": "k1"})
280+
await provider.authorize(inbound, Operation.CONTROL_BINDINGS_READ)
281+
282+
# Then: both the default and the extra header reach the upstream
283+
assert captured["headers"]["x-deployer-auth"] == "k_abc"
284+
assert captured["headers"]["x-api-key"] == "k1"
285+
286+
287+
@pytest.mark.asyncio
288+
async def test_http_upstream_default_forward_set_unchanged():
289+
# Given: a provider with no extra_forward_headers
290+
captured: dict[str, Any] = {}
291+
292+
def factory(request: httpx.Request) -> httpx.Response:
293+
captured["headers"] = dict(request.headers)
294+
return httpx.Response(200, json={"namespace_key": "ns"})
295+
296+
provider = _build_upstream(factory)
297+
298+
# When: the inbound carries an unlisted header alongside a default one
299+
inbound = _build_request(
300+
headers={"X-API-Key": "k1", "X-Deployer-Auth": "should-not-forward"}
301+
)
302+
await provider.authorize(inbound, Operation.CONTROL_BINDINGS_READ)
303+
304+
# Then: only the default-set header reaches the upstream
305+
assert captured["headers"].get("x-api-key") == "k1"
306+
assert "x-deployer-auth" not in captured["headers"]
307+
308+
309+
@pytest.mark.asyncio
310+
async def test_http_upstream_extra_forward_dedupes_against_defaults():
311+
# Given: extra list duplicates a default header (different case)
312+
captured: dict[str, Any] = {}
313+
314+
def factory(request: httpx.Request) -> httpx.Response:
315+
captured["headers"] = dict(request.headers)
316+
return httpx.Response(200, json={"namespace_key": "ns"})
317+
318+
provider = _build_upstream(
319+
factory,
320+
config_overrides={"extra_forward_headers": ("x-api-key", "Authorization")},
321+
)
322+
323+
# When: inbound has both
324+
inbound = _build_request(headers={"X-API-Key": "k1", "Authorization": "Bearer t"})
325+
await provider.authorize(inbound, Operation.CONTROL_BINDINGS_READ)
326+
327+
# Then: each header appears exactly once on the upstream request
328+
forwarded = captured["headers"]
329+
assert sum(1 for k in forwarded if k.lower() == "x-api-key") == 1
330+
assert sum(1 for k in forwarded if k.lower() == "authorization") == 1
331+
332+
264333
@pytest.mark.asyncio
265334
@pytest.mark.parametrize(
266335
"status, expected",
@@ -1053,6 +1122,52 @@ async def test_configure_http_upstream_management_with_jwt_runtime(monkeypatch):
10531122
await auth_config.teardown_auth()
10541123

10551124

1125+
@pytest.mark.parametrize(
1126+
"raw, expected",
1127+
[
1128+
(None, ()),
1129+
("", ()),
1130+
(" ", ()),
1131+
("X-One", ("X-One",)),
1132+
("X-One,X-Two", ("X-One", "X-Two")),
1133+
(" X-One , X-Two ", ("X-One", "X-Two")),
1134+
("X-One,,X-Two", ("X-One", "X-Two")),
1135+
("X-One,x-one,X-One", ("X-One",)),
1136+
("X-A,X-B,x-a,X-C,X-b", ("X-A", "X-B", "X-C")),
1137+
],
1138+
)
1139+
def test_parse_extra_forward_headers(raw, expected):
1140+
from agent_control_server.auth_framework.config import _parse_extra_forward_headers
1141+
1142+
assert _parse_extra_forward_headers(raw) == expected
1143+
1144+
1145+
@pytest.mark.asyncio
1146+
async def test_configure_http_upstream_extra_forward_headers_env(monkeypatch):
1147+
"""Setting the env var threads extra_forward_headers into the provider."""
1148+
from agent_control_server.auth_framework import config as auth_config
1149+
1150+
clear_authorizers()
1151+
1152+
monkeypatch.setenv("AGENT_CONTROL_AUTH_MODE", "http_upstream")
1153+
monkeypatch.setenv("AGENT_CONTROL_AUTH_UPSTREAM_URL", "https://auth.example.test/check")
1154+
monkeypatch.setenv(
1155+
"AGENT_CONTROL_AUTH_UPSTREAM_EXTRA_FORWARD_HEADERS",
1156+
"X-Deployer-Auth, X-Deployer-Trace",
1157+
)
1158+
1159+
try:
1160+
auth_config.configure_auth_from_env()
1161+
provider = get_authorizer(Operation.CONTROLS_READ)
1162+
assert isinstance(provider, HttpUpstreamAuthProvider)
1163+
assert provider._config.extra_forward_headers == (
1164+
"X-Deployer-Auth",
1165+
"X-Deployer-Trace",
1166+
)
1167+
finally:
1168+
await auth_config.teardown_auth()
1169+
1170+
10561171
def test_configure_runtime_jwt_requires_secret(monkeypatch):
10571172
from agent_control_server.auth_framework import config as auth_config
10581173

0 commit comments

Comments
 (0)