Skip to content

Commit 5fb016b

Browse files
committed
fix(server): reject binding auth namespace mismatches
1 parent fe9b7d3 commit 5fb016b

2 files changed

Lines changed: 57 additions & 4 deletions

File tree

‎server/src/agent_control_server/endpoints/control_bindings.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,15 @@ async def dependency(request: Request, binding_id: int) -> Principal:
102102

103103
context = {"target_type": target_type, "target_id": target_id}
104104
try:
105-
return await authorizer.authorize(request, operation, context)
105+
principal = await authorizer.authorize(request, operation, context)
106106
except (ForbiddenError, NotFoundError) as exc:
107107
# A caller cannot distinguish a binding it cannot access from a
108108
# missing binding in its namespace.
109109
raise ControlBindingsService.binding_not_found(binding_id) from exc
110+
if principal.namespace_key != identity.namespace_key:
111+
# The target grant must apply to the namespace used for the lookup.
112+
raise ControlBindingsService.binding_not_found(binding_id)
113+
return principal
110114

111115
return dependency
112116

‎server/tests/test_control_bindings_endpoints.py‎

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,6 @@
99
import httpx
1010
import pytest
1111
from agent_control_models.errors import ErrorCode, ErrorReason
12-
from fastapi.testclient import TestClient
13-
from httpx import Response
14-
1512
from agent_control_server.auth_framework import Operation, Principal, set_authorizer
1613
from agent_control_server.auth_framework.providers import (
1714
HeaderAuthProvider,
@@ -20,6 +17,8 @@
2017
from agent_control_server.auth_framework.providers.http_upstream import HttpUpstreamConfig
2118
from agent_control_server.errors import APIError, ForbiddenError, NotFoundError
2219
from agent_control_server.models import DEFAULT_NAMESPACE_KEY
20+
from fastapi.testclient import TestClient
21+
from httpx import Response
2322

2423
from .utils import VALID_CONTROL_PAYLOAD
2524

@@ -356,6 +355,56 @@ async def authorize(
356355
)
357356

358357

358+
@pytest.mark.parametrize("method", ["get", "patch", "delete"])
359+
def test_binding_id_rejects_namespace_changed_by_target_authorization(
360+
method: str, client: TestClient
361+
) -> None:
362+
# Given: identity resolves to the binding namespace, but target auth changes it.
363+
control_id = _create_control(client)
364+
binding_id = _create_binding(client, control_id=control_id)["binding_id"]
365+
366+
class MismatchedNamespaceAuthorizer:
367+
binding_target_authorization = True
368+
authorization_namespace = "other-namespace"
369+
370+
async def resolve_identity(self, request: Any, operation: Operation) -> Principal:
371+
del request, operation
372+
return Principal(namespace_key=DEFAULT_NAMESPACE_KEY)
373+
374+
async def authorize(
375+
self,
376+
request: Any,
377+
operation: Operation,
378+
context: dict[str, Any] | None = None,
379+
) -> Principal:
380+
del request, operation, context
381+
return Principal(namespace_key=self.authorization_namespace)
382+
383+
authorizer = MismatchedNamespaceAuthorizer()
384+
set_authorizer(authorizer)
385+
386+
# When: the caller reads or mutates the binding by ID.
387+
url = f"{_BINDINGS_URL}/{binding_id}"
388+
if method == "get":
389+
response = client.get(url)
390+
elif method == "patch":
391+
response = client.patch(url, json={"enabled": False})
392+
else:
393+
response = client.delete(url)
394+
395+
# Then: it looks missing, and PATCH/DELETE leave the row untouched.
396+
missing = client.get(f"{_BINDINGS_URL}/999999")
397+
assert response.status_code == missing.status_code == 404
398+
assert response.json()["error_code"] == missing.json()["error_code"] == (
399+
"CONTROL_BINDING_NOT_FOUND"
400+
)
401+
402+
authorizer.authorization_namespace = DEFAULT_NAMESPACE_KEY
403+
stored = client.get(url)
404+
assert stored.status_code == 200, stored.text
405+
assert stored.json()["enabled"] is True
406+
407+
359408
def test_binding_id_legacy_authorizer_preserves_namespace_wide_check(client: TestClient) -> None:
360409
control_id = _create_control(client)
361410
binding_id = _create_binding(client, control_id=control_id)["binding_id"]

0 commit comments

Comments
 (0)