diff --git a/agentex/docs/docs/development_guides/auth_provider_contract.md b/agentex/docs/docs/development_guides/auth_provider_contract.md index 6526f6dc..008ea85a 100644 --- a/agentex/docs/docs/development_guides/auth_provider_contract.md +++ b/agentex/docs/docs/development_guides/auth_provider_contract.md @@ -70,7 +70,7 @@ decide what happened. The body matters only where noted (`/v1/authn` principal, | Status | Meaning to Agentex | Resulting behavior | | --- | --- | --- | -| `200` | Success | Proceed. For `check`, the principal is authorized. For `search`, read `items`. | +| `200` | Success | Proceed. For `check`, the principal is authorized. For `search`, read `items` or the `unscoped` sentinel. | | `401` | Unauthenticated — missing/invalid credentials | Request rejected as `401 Unauthorized` | | `403` | Authenticated but not permitted | Treated as a permission denial (e.g. `check` failed) | | `502` | Provider acted as a bad gateway | Surfaced as a gateway error | @@ -211,14 +211,25 @@ the returned `items` to **scope list endpoints** — effectively a { "items": ["task_1", "task_2", "task_3"], "success": true } ``` +To grant access to **every** resource of the type without enumerating ids, return +the wildcard sentinel instead: + +```json +{ "unscoped": true } +``` + | Field | Type | Notes | | --- | --- | --- | -| `items` | `string[]` | Resource ids the principal may access. **Required.** | +| `items` | `string[]` | Resource ids the principal may access. Required unless `unscoped` is `true`. | +| `unscoped` | `boolean` | Optional. `true` signals **all resources of this type** — no filter. When set, `items` is ignored. | > ⚠️ **`items` is an inclusion filter, not a hint.** Returning `[]` hides *every* -> resource of that type from the principal. There is no "all resources" sentinel -> in the base contract, so a provider that intends to grant broad access must -> return the actual set of accessible ids. +> resource of that type from the principal. To grant **broad** access without +> enumerating ids, return the wildcard sentinel `{ "unscoped": true }` — Agentex +> maps it to *no filter at all* (the same path used when authorization is +> disabled), so a permissive single-account provider can answer `search` +> statelessly. `unscoped` is an explicit opt-in: omit it (or set it `false`) and +> `items` is treated as the exact, exhaustive set of accessible ids. ### `POST /v1/authz/grant` @@ -278,7 +289,7 @@ its relationships. | --- | --- | --- | --- | | `POST /v1/authn` | Authenticate, return principal | `200` + principal | `401` | | `POST /v1/authz/check` | Read gate | `200` | `403` | -| `POST /v1/authz/search` | List accessible ids | `200` + `{items}` | `200` + `{items: []}` | +| `POST /v1/authz/search` | List accessible ids | `200` + `{items}` or `{unscoped: true}` | `200` + `{items: []}` | | `POST /v1/authz/grant` | Share a resource | `200` | `403` | | `POST /v1/authz/revoke` | Un-share a resource | `200` | `403` | | `POST /v1/authz/register` | Register created resource | `200` | `403` | diff --git a/agentex/src/adapters/authorization/adapter_agentex_authz_proxy.py b/agentex/src/adapters/authorization/adapter_agentex_authz_proxy.py index c8ba4f3a..23f02ebe 100644 --- a/agentex/src/adapters/authorization/adapter_agentex_authz_proxy.py +++ b/agentex/src/adapters/authorization/adapter_agentex_authz_proxy.py @@ -74,7 +74,7 @@ async def list_resources( principal: AgentexAuthPrincipalContext, filter_resource: AgentexResourceType, filter_operation: AuthorizedOperationType = AuthorizedOperationType.read, - ) -> Iterable[str]: + ) -> Iterable[str] | None: payload = { "principal": principal, "filter_resource": filter_resource, @@ -83,6 +83,14 @@ async def list_resources( response = await HttpRequestHandler.post_with_error_handling( self.agentex_auth_url, "/v1/authz/search", json=payload ) + # Wildcard sentinel: a provider signals "all resources of this type" with + # {"unscoped": true} rather than enumerating ids. Map only the exact JSON + # boolean sentinel onto the existing unscoped path (None == no id filter) + # so malformed truthy values do not broaden access. We index + # response["items"] in the normal case (rather than .get) so a malformed + # response missing both fields raises instead of silently failing open. + if response.get("unscoped") is True: + return None return response["items"] async def register_resource( diff --git a/agentex/src/adapters/authorization/port.py b/agentex/src/adapters/authorization/port.py index babe899b..88af965f 100644 --- a/agentex/src/adapters/authorization/port.py +++ b/agentex/src/adapters/authorization/port.py @@ -47,8 +47,12 @@ async def list_resources( principal: PrincipalT, filter_resource: AgentexResourceType, filter_operation: AuthorizedOperationType = AuthorizedOperationType.read, - ) -> Iterable[str]: - """List resource_ids for a given principal""" + ) -> Iterable[str] | None: + """List resource_ids for a given principal. + + Return ``None`` to signal *unscoped* access (all resources of the type, + i.e. no id filter) rather than an enumerated id list. + """ @abstractmethod async def register_resource( diff --git a/agentex/tests/unit/adapters/authorization/__init__.py b/agentex/tests/unit/adapters/authorization/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/agentex/tests/unit/adapters/authorization/test_adapter_agentex_authz_proxy.py b/agentex/tests/unit/adapters/authorization/test_adapter_agentex_authz_proxy.py new file mode 100644 index 00000000..533c0bf7 --- /dev/null +++ b/agentex/tests/unit/adapters/authorization/test_adapter_agentex_authz_proxy.py @@ -0,0 +1,94 @@ +"""Unit tests for AgentexAuthorizationProxy.list_resources sentinel handling. + +The proxy maps a provider's wildcard sentinel ({"unscoped": true}) onto the +existing unscoped path (None == no id filter), while ordinary {"items": [...]} +responses pass through as an inclusion filter. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest +from src.adapters.authorization.adapter_agentex_authz_proxy import ( + AgentexAuthorizationProxy, +) +from src.api.schemas.authorization_types import ( + AgentexResourceType, + AuthorizedOperationType, +) + +PROXY_TARGET = ( + "src.adapters.authorization.adapter_agentex_authz_proxy." + "HttpRequestHandler.post_with_error_handling" +) + + +def _proxy() -> AgentexAuthorizationProxy: + return AgentexAuthorizationProxy(agentex_auth_url="http://auth.test") + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestListResourcesSentinel: + async def test_items_list_passes_through(self): + with patch(PROXY_TARGET, new=AsyncMock(return_value={"items": ["a", "b"]})): + result = await _proxy().list_resources( + principal={"user_id": "u"}, + filter_resource=AgentexResourceType.task, + filter_operation=AuthorizedOperationType.read, + ) + assert result == ["a", "b"] + + async def test_unscoped_true_returns_none(self): + with patch(PROXY_TARGET, new=AsyncMock(return_value={"unscoped": True})): + result = await _proxy().list_resources( + principal={"user_id": "u"}, + filter_resource=AgentexResourceType.task, + ) + assert result is None + + async def test_unscoped_wins_over_items(self): + with patch( + PROXY_TARGET, + new=AsyncMock(return_value={"unscoped": True, "items": ["x"]}), + ): + result = await _proxy().list_resources( + principal={"user_id": "u"}, + filter_resource=AgentexResourceType.task, + ) + assert result is None + + async def test_empty_items_is_not_a_sentinel(self): + with patch(PROXY_TARGET, new=AsyncMock(return_value={"items": []})): + result = await _proxy().list_resources( + principal={"user_id": "u"}, + filter_resource=AgentexResourceType.task, + ) + assert result == [] + + async def test_truthy_non_boolean_unscoped_is_not_a_sentinel(self): + # 1 is truthy but `1 is True` is False, so the strict identity check must + # not treat it as the sentinel; only the JSON boolean `true` qualifies. + with patch( + PROXY_TARGET, + new=AsyncMock(return_value={"unscoped": 1, "items": []}), + ): + result = await _proxy().list_resources( + principal={"user_id": "u"}, + filter_resource=AgentexResourceType.task, + ) + assert result == [] + + async def test_missing_unscoped_and_items_fails_closed(self): + # A malformed response with neither field must raise rather than silently + # return [] or None — the guard against a provider accidentally failing open. + with patch( + PROXY_TARGET, + new=AsyncMock(return_value={"unexpected_key": "val"}), + ): + with pytest.raises(KeyError): + await _proxy().list_resources( + principal={"user_id": "u"}, + filter_resource=AgentexResourceType.task, + )