|
9 | 9 | import httpx |
10 | 10 | import pytest |
11 | 11 | from agent_control_models.errors import ErrorCode, ErrorReason |
12 | | -from fastapi.testclient import TestClient |
13 | | -from httpx import Response |
14 | | - |
15 | 12 | from agent_control_server.auth_framework import Operation, Principal, set_authorizer |
16 | 13 | from agent_control_server.auth_framework.providers import ( |
17 | 14 | HeaderAuthProvider, |
|
20 | 17 | from agent_control_server.auth_framework.providers.http_upstream import HttpUpstreamConfig |
21 | 18 | from agent_control_server.errors import APIError, ForbiddenError, NotFoundError |
22 | 19 | from agent_control_server.models import DEFAULT_NAMESPACE_KEY |
| 20 | +from fastapi.testclient import TestClient |
| 21 | +from httpx import Response |
23 | 22 |
|
24 | 23 | from .utils import VALID_CONTROL_PAYLOAD |
25 | 24 |
|
@@ -356,6 +355,56 @@ async def authorize( |
356 | 355 | ) |
357 | 356 |
|
358 | 357 |
|
| 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 | + |
359 | 408 | def test_binding_id_legacy_authorizer_preserves_namespace_wide_check(client: TestClient) -> None: |
360 | 409 | control_id = _create_control(client) |
361 | 410 | binding_id = _create_binding(client, control_id=control_id)["binding_id"] |
|
0 commit comments