diff --git a/sdks/typescript/scripts/normalize_generated_sdk.py b/sdks/typescript/scripts/normalize_generated_sdk.py index 3eedfeb2..6090484f 100644 --- a/sdks/typescript/scripts/normalize_generated_sdk.py +++ b/sdks/typescript/scripts/normalize_generated_sdk.py @@ -62,14 +62,11 @@ def _normalize_sdk_metadata_version(file_path: Path) -> None: file_path.write_text(normalized, encoding="utf-8") -def _strip_unstable_type_comments(file_path: Path) -> None: +def _strip_unstable_type_comments(file_path: Path, type_name: str, description: str) -> None: original = file_path.read_text(encoding="utf-8") normalized = original.replace( - "/**\n" - " * Template-backed input payload for control create/update requests.\n" - " */\n" - "export type TemplateControlInput = {", - "export type TemplateControlInput = {", + f"/**\n * {description}\n */\nexport type {type_name} = {{", + f"export type {type_name} = {{", ) if normalized != original: @@ -82,9 +79,19 @@ def normalize_generated_sdk(_schema_path: Path, generated_dir: Path) -> None: if config_file.exists(): _normalize_sdk_metadata_version(config_file) - template_control_input_file = generated_dir / "models" / "template-control-input.ts" - if template_control_input_file.exists(): - _strip_unstable_type_comments(template_control_input_file) + # Speakeasy output varies in whether these type-level descriptions are present. + # Normalize only the known unstable comments; retain field documentation. + for filename, type_name, description in ( + ( + "template-control-input.ts", + "TemplateControlInput", + "Template-backed input payload for control create/update requests.", + ), + ("regex-template-parameter.ts", "RegexTemplateParameter", "RE2 regex template parameter."), + ): + model_file = generated_dir / "models" / filename + if model_file.exists(): + _strip_unstable_type_comments(model_file, type_name, description) validation_error_file = generated_dir / "models" / "validation-error.ts" if not validation_error_file.exists(): diff --git a/sdks/typescript/src/generated/models/regex-template-parameter.ts b/sdks/typescript/src/generated/models/regex-template-parameter.ts index eac4ce3d..597f0098 100644 --- a/sdks/typescript/src/generated/models/regex-template-parameter.ts +++ b/sdks/typescript/src/generated/models/regex-template-parameter.ts @@ -9,9 +9,6 @@ import { Result as SafeParseResult } from "../types/fp.js"; import * as types from "../types/primitives.js"; import { SDKValidationError } from "./errors/sdk-validation-error.js"; -/** - * RE2 regex template parameter. - */ export type RegexTemplateParameter = { /** * Optional default regex pattern diff --git a/sdks/typescript/tests/test_normalize_generated_sdk.py b/sdks/typescript/tests/test_normalize_generated_sdk.py index 1f23f6df..6161cc2b 100644 --- a/sdks/typescript/tests/test_normalize_generated_sdk.py +++ b/sdks/typescript/tests/test_normalize_generated_sdk.py @@ -5,6 +5,7 @@ import sys from pathlib import Path +import pytest SCRIPT_PATH = ( Path(__file__).resolve().parent.parent / "scripts" / "normalize_generated_sdk.py" @@ -262,3 +263,39 @@ def test_normalizer_removes_unstable_template_control_input_type_comment( normalized = template_control_input.read_text(encoding="utf-8") assert normalized == TEMPLATE_CONTROL_INPUT_WITHOUT_UNSTABLE_TYPE_COMMENT + + +@pytest.mark.parametrize("include_type_comment", [True, False]) +def test_normalizer_stabilizes_regex_type_comment( + tmp_path: Path, include_type_comment: bool +) -> None: + schema_path = _write_schema(tmp_path, include_context_fields=False) + generated_dir = tmp_path / "generated" + models_dir = generated_dir / "models" + models_dir.mkdir(parents=True) + model_file = models_dir / "regex-template-parameter.ts" + expected = ( + "export type RegexTemplateParameter = {\n" + " /** Optional default regex pattern */\n" + " default?: string | undefined;\n" + "};\n" + ) + type_comment = "/**\n * RE2 regex template parameter.\n */\n" + model_file.write_text( + (type_comment if include_type_comment else "") + expected, encoding="utf-8" + ) + + # Both generator variants converge, and a second normalization is a no-op. + for _ in range(2): + subprocess.run( + [ + sys.executable, + str(SCRIPT_PATH), + "--schema", + str(schema_path), + "--generated-dir", + str(generated_dir), + ], + check=True, + ) + assert model_file.read_text(encoding="utf-8") == expected diff --git a/server/README.md b/server/README.md index 7c56e446..3c9e97de 100644 --- a/server/README.md +++ b/server/README.md @@ -57,3 +57,21 @@ psycopg's bundled binary package. Server configuration is driven by environment variables (database, auth, observability, evaluators). For the full list and examples, see the docs. Full guide: https://docs.agentcontrol.dev/components/server + +### Binding lists with Orbit authorization + +When `AGENT_CONTROL_AUTH_UPSTREAM_URL` points to Orbit's +`/internal/auth/agent_control/check_management_access` route, binding lists need +both `target_type` and `target_id`: + +```http +GET /api/v1/control-bindings?target_type=log_stream&target_id=&limit=100 +``` + +Add `control_id` to filter bindings within that target. Include both target +parameters on every pagination request. A missing, blank, or incomplete target +returns `422 VALIDATION_ERROR` before calling the authorization service. + +This requirement follows Orbit's target-scoped authorization contract. Local +authentication and generic HTTP upstreams keep their namespace-wide list behavior. +Unexpected upstream rejections still return `502 AUTH_UPSTREAM_REJECTED`. diff --git a/server/src/agent_control_server/auth_framework/config.py b/server/src/agent_control_server/auth_framework/config.py index 1d1260f6..e587f84b 100644 --- a/server/src/agent_control_server/auth_framework/config.py +++ b/server/src/agent_control_server/auth_framework/config.py @@ -252,11 +252,13 @@ def _build_default_provider() -> RequestAuthorizer: # Only the known Orbit management route has a predictable identity route. # Other upstreams keep their existing namespace-wide authorization flow. explicit_identity_url = (os.environ.get(_UPSTREAM_IDENTITY_URL_ENV) or "").strip() - identity_url = explicit_identity_url or _derive_orbit_identity_url(url) + orbit_identity_url = _derive_orbit_identity_url(url) + identity_url = explicit_identity_url or orbit_identity_url try: upstream_config = HttpUpstreamConfig( url=url, identity_url=identity_url, + binding_list_requires_target=orbit_identity_url is not None, timeout_seconds=timeout, service_token=token, service_token_header=token_header, diff --git a/server/src/agent_control_server/auth_framework/providers/http_upstream.py b/server/src/agent_control_server/auth_framework/providers/http_upstream.py index 79dcf483..86b78da8 100644 --- a/server/src/agent_control_server/auth_framework/providers/http_upstream.py +++ b/server/src/agent_control_server/auth_framework/providers/http_upstream.py @@ -63,7 +63,13 @@ model_validator, ) -from ...errors import APIError, AuthenticationError, ForbiddenError, NotFoundError +from ...errors import ( + APIError, + APIValidationError, + AuthenticationError, + ForbiddenError, + NotFoundError, +) from ...logging_utils import get_logger from ..core import Operation, Principal, RequestAuthorizer @@ -289,6 +295,13 @@ class HttpUpstreamConfig: identity_url: str | None = None """Optional URL for credential and namespace resolution before target authorization.""" + binding_list_requires_target: bool = False + """Require a complete target for binding reads when the upstream requires one. + + Generic upstreams retain namespace-wide reads by default. Environment + configuration enables this for Orbit's management authorization route. + """ + def __post_init__(self) -> None: if self.keepalive_expiry_seconds < 0: raise ValueError("keepalive_expiry_seconds must be greater than or equal to 0") @@ -355,6 +368,30 @@ async def authorize( operation: Operation, context: dict[str, Any] | None = None, ) -> Principal: + if ( + self._config.binding_list_requires_target + and operation == Operation.CONTROL_BINDINGS_READ + ): + target_context = context or {} + target_type = target_context.get("target_type") + target_id = target_context.get("target_id") + has_complete_target = ( + isinstance(target_type, str) + and bool(target_type.strip()) + and isinstance(target_id, str) + and bool(target_id.strip()) + ) + if not has_complete_target: + raise APIValidationError( + error_code=ErrorCode.VALIDATION_ERROR, + detail=( + "Binding lists require both target_type and target_id in this deployment." + ), + hint=( + "Pass both target_type and target_id query parameters. " + "Add control_id only as a filter within that target." + ), + ) headers = self._forward_headers(request) payload: dict[str, Any] = {"operation": operation.value} if context: diff --git a/server/tests/test_auth_framework.py b/server/tests/test_auth_framework.py index 9e717b14..8b3d0e6c 100644 --- a/server/tests/test_auth_framework.py +++ b/server/tests/test_auth_framework.py @@ -283,6 +283,49 @@ def factory(request: httpx.Request) -> httpx.Response: assert captured["headers"]["x-api-key"] == "caller-key" +@pytest.mark.asyncio +@pytest.mark.parametrize( + "operation", [Operation.AGENTS_CREATE, Operation.RUNTIME_TOKEN_EXCHANGE, Operation.RUNTIME_USE] +) +async def test_binding_target_requirement_preserves_other_operations(operation: Operation) -> None: + # Given: only binding lists require a target in this upstream deployment. + payloads: list[dict[str, Any]] = [] + + def factory(request: httpx.Request) -> httpx.Response: + payloads.append(json.loads(request.content)) + return httpx.Response(200, json={"namespace_key": "org-7"}) + + provider = _build_upstream(factory, config_overrides={"binding_list_requires_target": True}) + + # When: another operation reaches the provider without binding-list context. + principal = await provider.authorize(_build_request(), operation) + + # Then: the existing upstream flow decides that operation's authorization. + assert principal.namespace_key == "org-7" + assert payloads == [{"operation": operation.value}] + + +@pytest.mark.asyncio +async def test_complete_binding_target_preserves_unexpected_upstream_rejection() -> None: + # Given: a complete target still receives an unexpected upstream 400. + provider = _build_upstream( + lambda request: httpx.Response(400, json={"detail": "Unexpected contract mismatch"}), + config_overrides={"binding_list_requires_target": True}, + ) + + # When: the provider delegates a complete binding-read request. + with pytest.raises(APIError) as exc_info: + await provider.authorize( + _build_request(), + Operation.CONTROL_BINDINGS_READ, + {"target_type": "log_stream", "target_id": "stream-42"}, + ) + + # Then: the upstream contract failure keeps its existing 502 signal. + assert exc_info.value.status_code == 502 + assert exc_info.value.error_code == "AUTH_UPSTREAM_REJECTED" + + @pytest.mark.asyncio async def test_http_upstream_resolves_identity_at_configured_url(): captured: dict[str, Any] = {} @@ -2207,6 +2250,7 @@ async def test_configure_http_upstream_selects_identity_url( assert isinstance(provider, HttpUpstreamAuthProvider) assert provider._config.identity_url == expected_identity_url assert provider.binding_target_authorization is (expected_identity_url is not None) + assert provider._config.binding_list_requires_target is (expected_identity_url is not None) finally: await auth_config.teardown_auth() @@ -2236,6 +2280,9 @@ async def test_configure_http_upstream_identity_url_env(monkeypatch, upstream_ur assert isinstance(provider, HttpUpstreamAuthProvider) assert provider._config.identity_url == "https://identity.example.test/resolve" assert provider.binding_target_authorization is True + assert provider._config.binding_list_requires_target is upstream_url.endswith( + "/internal/auth/agent_control/check_management_access" + ) finally: await auth_config.teardown_auth() diff --git a/server/tests/test_control_bindings_endpoints.py b/server/tests/test_control_bindings_endpoints.py index 8ebb386f..ecce0d0a 100644 --- a/server/tests/test_control_bindings_endpoints.py +++ b/server/tests/test_control_bindings_endpoints.py @@ -2,14 +2,23 @@ from __future__ import annotations +import asyncio import json +import os import uuid +from collections.abc import Iterator +from contextlib import contextmanager from typing import Any +from unittest.mock import AsyncMock, patch import httpx import pytest from agent_control_models.errors import ErrorCode, ErrorReason +from fastapi.testclient import TestClient +from httpx import Response + from agent_control_server.auth_framework import Operation, Principal, set_authorizer +from agent_control_server.auth_framework import config as auth_config from agent_control_server.auth_framework.providers import ( HeaderAuthProvider, HttpUpstreamAuthProvider, @@ -17,14 +26,358 @@ from agent_control_server.auth_framework.providers.http_upstream import HttpUpstreamConfig from agent_control_server.errors import APIError, ForbiddenError, NotFoundError from agent_control_server.models import DEFAULT_NAMESPACE_KEY -from fastapi.testclient import TestClient -from httpx import Response +from agent_control_server.services.control_bindings import ControlBindingsService from .utils import VALID_CONTROL_PAYLOAD _BINDINGS_URL = "/api/v1/control-bindings" +@pytest.fixture( + params=[ + pytest.param( + "http://orbit.example:8088/internal/auth/agent_control/check_management_access", + id="us1-http", + ), + pytest.param( + "https://orbit.example:8088/internal/auth/agent_control/check_management_access", + id="multitenant-https", + ), + ] +) +def orbit_upstream_url(request: pytest.FixtureRequest) -> str: + return str(request.param) + + +@contextmanager +def _configured_orbit_authorizer( + monkeypatch: pytest.MonkeyPatch, url: str, upstream_client: httpx.AsyncClient +) -> Iterator[None]: + """Exercise startup configuration with mocked upstream HTTP, not a manual opt-in.""" + with monkeypatch.context() as env: + for key in list(os.environ): + if key.startswith("AGENT_CONTROL_AUTH_UPSTREAM_"): + env.delenv(key) + env.setenv("AGENT_CONTROL_AUTH_MODE", "http_upstream") + env.setenv("AGENT_CONTROL_AUTH_UPSTREAM_URL", url) + env.setenv("AGENT_CONTROL_RUNTIME_AUTH_MODE", "none") + with patch( + "agent_control_server.auth_framework.providers.http_upstream.httpx.AsyncClient", + return_value=upstream_client, + ): + auth_config.configure_auth_from_env() + try: + yield + finally: + asyncio.run(auth_config.teardown_auth()) + + +@pytest.mark.parametrize( + "params", + [ + pytest.param({}, id="no-query-parameters"), + # These control-only request shapes were observed in the us1 incident. + pytest.param({"control_id": "33"}, id="incident-control-33"), + pytest.param({"control_id": "37"}, id="incident-control-37"), + pytest.param({"control_id": "80"}, id="incident-control-80"), + pytest.param({"target_type": "log_stream"}, id="type-only"), + pytest.param({"target_id": "stream-42"}, id="id-only"), + pytest.param({"target_type": "", "target_id": "stream-42"}, id="empty-type"), + pytest.param({"target_type": " \t", "target_id": "stream-42"}, id="whitespace-type"), + pytest.param({"target_type": "log_stream", "target_id": ""}, id="empty-id"), + pytest.param({"target_type": "log_stream", "target_id": " \t"}, id="whitespace-id"), + pytest.param({"target_type": "", "target_id": ""}, id="both-empty"), + pytest.param({"control_id": "37", "target_type": "log_stream"}, id="control-and-type"), + pytest.param({"control_id": "37", "target_id": "stream-42"}, id="control-and-id"), + pytest.param({"limit": "1", "cursor": "2"}, id="pagination-without-target"), + pytest.param( + {"control_id": "37", "limit": "1", "cursor": "2"}, + id="control-and-pagination-without-target", + ), + pytest.param( + {"targetType": "log_stream", "targetId": "stream-42"}, + id="unsupported-camel-case-parameters", + ), + ], +) +def test_target_required_binding_list_rejects_incomplete_queries_locally( + client: TestClient, + monkeypatch: pytest.MonkeyPatch, + orbit_upstream_url: str, + params: dict[str, str], +) -> None: + # Given: the deployment's startup configuration and Orbit's observed rejection. + upstream_requests: list[httpx.Request] = [] + storage = AsyncMock() + monkeypatch.setattr(ControlBindingsService, "list_bindings", storage) + + def upstream_auth(request: httpx.Request) -> httpx.Response: + upstream_requests.append(request) + return httpx.Response( + 400, + json={"detail": "Operation 'control_bindings.read' requires target context."}, + ) + + upstream_client = httpx.AsyncClient(transport=httpx.MockTransport(upstream_auth)) + with _configured_orbit_authorizer(monkeypatch, orbit_upstream_url, upstream_client): + # When: the caller lists bindings without both non-empty target parameters. + response = client.get(_BINDINGS_URL, params=params) + + # Then: the public response explains the required parameters without a 5xx. + assert response.status_code == 422, response.text + assert response.json()["error_code"] == "VALIDATION_ERROR" + assert "target_type and target_id" in response.json()["detail"] + assert "control_id" in response.json()["hint"] + assert upstream_requests == [] + storage.assert_not_awaited() + + +def test_orbit_binding_list_pagination_keeps_target_filter( + client: TestClient, monkeypatch: pytest.MonkeyPatch, orbit_upstream_url: str +) -> None: + # Given: two controls on one target and a binding on a different target. + control_id = _create_control(client) + other_control_id = _create_control(client) + target_id = str(uuid.uuid4()) + expected_ids = [ + _create_binding( + client, control_id=control_id, target_type="log_stream", target_id=target_id + )["binding_id"] + for control_id in (control_id, other_control_id) + ] + _create_binding( + client, control_id=control_id, target_type="log_stream", target_id=str(uuid.uuid4()) + ) + upstream_payloads: list[dict[str, Any]] = [] + + def upstream_auth(request: httpx.Request) -> httpx.Response: + upstream_payloads.append(json.loads(request.content)) + return httpx.Response( + 200, + json={ + "namespace_key": DEFAULT_NAMESPACE_KEY, + "target_type": "log_stream", + "target_id": target_id, + }, + ) + + upstream_client = httpx.AsyncClient(transport=httpx.MockTransport(upstream_auth)) + params = { + "target_type": "log_stream", + "target_id": target_id, + "limit": "1", + } + with _configured_orbit_authorizer(monkeypatch, orbit_upstream_url, upstream_client): + # When: both page requests retain the target. + first = client.get(_BINDINGS_URL, params=params) + assert first.status_code == 200, first.text + cursor = first.json()["pagination"]["next_cursor"] + assert cursor is not None + second = client.get(_BINDINGS_URL, params={**params, "cursor": cursor}) + + # Then: each page is authorized and neither unrelated binding leaks into it. + assert second.status_code == 200, second.text + ids = [page.json()["bindings"][0]["id"] for page in (first, second)] + assert ids == sorted(expected_ids, reverse=True) + assert first.json()["pagination"]["total"] == 2 + assert second.json()["pagination"]["has_more"] is False + assert upstream_payloads == [ + { + "operation": Operation.CONTROL_BINDINGS_READ.value, + "context": {"target_type": "log_stream", "target_id": target_id}, + } + ] * 2 + + +def test_orbit_binding_list_with_no_matches_is_successful( + client: TestClient, monkeypatch: pytest.MonkeyPatch, orbit_upstream_url: str +) -> None: + # Given: a valid authorized target with no bindings, not missing target context. + target_id = str(uuid.uuid4()) + upstream_requests: list[httpx.Request] = [] + + def upstream_auth(request: httpx.Request) -> httpx.Response: + upstream_requests.append(request) + return httpx.Response( + 200, + json={ + "namespace_key": DEFAULT_NAMESPACE_KEY, + "target_type": "log_stream", + "target_id": target_id, + }, + ) + + upstream_client = httpx.AsyncClient(transport=httpx.MockTransport(upstream_auth)) + with _configured_orbit_authorizer(monkeypatch, orbit_upstream_url, upstream_client): + # When: the complete target is supplied without an optional control filter. + response = client.get( + _BINDINGS_URL, params={"target_type": "log_stream", "target_id": target_id} + ) + + # Then: zero matching bindings is a normal empty page, not a validation failure. + assert response.status_code == 200, response.text + assert response.json()["bindings"] == [] + assert response.json()["pagination"]["total"] == 0 + assert response.json()["pagination"]["has_more"] is False + assert len(upstream_requests) == 1 + + +def test_target_required_binding_list_forwards_target_and_keeps_control_filter( + client: TestClient, monkeypatch: pytest.MonkeyPatch, orbit_upstream_url: str +) -> None: + # Given: two bindings on one target and another target using the same control. + control_id = _create_control(client) + other_control_id = _create_control(client) + target_id = str(uuid.uuid4()) + matching_id = _create_binding( + client, control_id=control_id, target_type="log_stream", target_id=target_id + )["binding_id"] + _create_binding( + client, control_id=other_control_id, target_type="log_stream", target_id=target_id + ) + _create_binding( + client, control_id=control_id, target_type="log_stream", target_id=str(uuid.uuid4()) + ) + upstream_payloads: list[dict[str, Any]] = [] + + def upstream_auth(request: httpx.Request) -> httpx.Response: + upstream_payloads.append(json.loads(request.content)) + return httpx.Response( + 200, + json={ + "namespace_key": DEFAULT_NAMESPACE_KEY, + "target_type": "log_stream", + "target_id": target_id, + }, + ) + + upstream_client = httpx.AsyncClient(transport=httpx.MockTransport(upstream_auth)) + with _configured_orbit_authorizer(monkeypatch, orbit_upstream_url, upstream_client): + # When: the caller lists a control's bindings within an authorized target. + response = client.get( + _BINDINGS_URL, + params={"target_type": "log_stream", "target_id": target_id, "control_id": control_id}, + ) + + # Then: authorization receives the target and storage applies both filters. + assert response.status_code == 200, response.text + assert [binding["id"] for binding in response.json()["bindings"]] == [matching_id] + assert upstream_payloads == [ + { + "operation": Operation.CONTROL_BINDINGS_READ.value, + "context": {"target_type": "log_stream", "target_id": target_id}, + } + ] + + +def test_generic_http_upstream_keeps_namespace_wide_binding_lists(client: TestClient) -> None: + # Given: a generic upstream with independent identity resolution. + upstream_payloads: list[dict[str, Any]] = [] + + def upstream_auth(request: httpx.Request) -> httpx.Response: + upstream_payloads.append(json.loads(request.content)) + return httpx.Response(200, json={"namespace_key": DEFAULT_NAMESPACE_KEY}) + + upstream_client = httpx.AsyncClient(transport=httpx.MockTransport(upstream_auth)) + set_authorizer( + HttpUpstreamAuthProvider( + HttpUpstreamConfig( + url="https://auth.example/check", + identity_url="https://auth.example/identity", + ), + client=upstream_client, + ) + ) + + # When: the caller lists bindings without a target. + response = client.get(_BINDINGS_URL) + + # Then: configuring an identity URL alone does not require target-scoped lists. + assert response.status_code == 200, response.text + assert upstream_payloads == [{"operation": Operation.CONTROL_BINDINGS_READ.value}] + + +@pytest.mark.parametrize("status", [401, 403, 404]) +def test_target_required_binding_list_preserves_upstream_denials( + client: TestClient, monkeypatch: pytest.MonkeyPatch, status: int +) -> None: + # Given: a complete target whose upstream authorization is denied. + target_id = str(uuid.uuid4()) + payloads: list[dict[str, Any]] = [] + storage = AsyncMock() + monkeypatch.setattr(ControlBindingsService, "list_bindings", storage) + + def upstream_auth(request: httpx.Request) -> httpx.Response: + payloads.append(json.loads(request.content)) + return httpx.Response(status) + + upstream_client = httpx.AsyncClient(transport=httpx.MockTransport(upstream_auth)) + set_authorizer( + HttpUpstreamAuthProvider( + HttpUpstreamConfig( + url="https://orbit.example/internal/auth/agent_control/check_management_access", + binding_list_requires_target=True, + ), + client=upstream_client, + ) + ) + + # When: the caller provides both target parameters. + response = client.get( + _BINDINGS_URL, params={"target_type": "log_stream", "target_id": target_id} + ) + + # Then: validation does not replace permission checks or read any bindings. + assert response.status_code == status, response.text + assert payloads == [ + { + "operation": Operation.CONTROL_BINDINGS_READ.value, + "context": {"target_type": "log_stream", "target_id": target_id}, + } + ] + storage.assert_not_awaited() + + +def test_target_required_binding_list_rejects_a_mismatched_grant( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + # Given: a successful upstream response granting a different target. + target_id = str(uuid.uuid4()) + storage = AsyncMock() + monkeypatch.setattr(ControlBindingsService, "list_bindings", storage) + upstream_client = httpx.AsyncClient( + transport=httpx.MockTransport( + lambda request: httpx.Response( + 200, + json={ + "namespace_key": DEFAULT_NAMESPACE_KEY, + "target_type": "log_stream", + "target_id": str(uuid.uuid4()), + }, + ) + ) + ) + set_authorizer( + HttpUpstreamAuthProvider( + HttpUpstreamConfig( + url="https://orbit.example/internal/auth/agent_control/check_management_access", + binding_list_requires_target=True, + ), + client=upstream_client, + ) + ) + + # When: the caller lists bindings for the original target. + response = client.get( + _BINDINGS_URL, params={"target_type": "log_stream", "target_id": target_id} + ) + + # Then: an upstream 200 cannot grant access to another target's bindings. + assert response.status_code == 403, response.text + assert response.json()["error_code"] == "AUTH_INSUFFICIENT_PRIVILEGES" + storage.assert_not_awaited() + + def _create_control(client: TestClient, name: str | None = None) -> int: payload = { "name": name or f"control-{uuid.uuid4().hex[:12]}", @@ -500,7 +853,13 @@ def upstream_auth(request: httpx.Request) -> Response: assert stored.json()["enabled"] is (method != "patch" or expected_status != 200) -def test_binding_id_orbit_http_upstream_authorizes_stored_target(client: TestClient) -> None: +@pytest.mark.parametrize( + ("method", "operation"), + [("GET", Operation.CONTROL_BINDINGS_READ), ("DELETE", Operation.CONTROL_BINDINGS_WRITE)], +) +def test_binding_id_orbit_http_upstream_authorizes_stored_target( + client: TestClient, method: str, operation: Operation +) -> None: # Given: Orbit requires target context for management authorization. control_id = _create_control(client) target_id = str(uuid.uuid4()) @@ -524,13 +883,14 @@ def upstream_auth(request: httpx.Request) -> Response: HttpUpstreamConfig( url="https://orbit.example/internal/auth/agent_control/check_management_access", identity_url="https://orbit.example/internal/auth/resolve_tenant_context", + binding_list_requires_target=True, ), client=upstream_client, ) ) - # When: an ID-based delete is requested without a target in the request. - response = client.delete(f"{_BINDINGS_URL}/{binding_id}") + # When: an ID-based read or delete has no target parameters in the request. + response = client.request(method, f"{_BINDINGS_URL}/{binding_id}") # Then: identity resolution precedes a target-bound management check. assert response.status_code == 200, response.text @@ -539,7 +899,7 @@ def upstream_auth(request: httpx.Request) -> Response: "/internal/auth/agent_control/check_management_access", ] assert json.loads(upstream_requests[1].content) == { - "operation": Operation.CONTROL_BINDINGS_WRITE.value, + "operation": operation.value, "context": {"target_type": "log_stream", "target_id": target_id}, }