From a9656c772831e2a67385055e63a4c9deae7f062e Mon Sep 17 00:00:00 2001 From: Stas Moreinis Date: Thu, 11 Jun 2026 13:35:53 -0700 Subject: [PATCH 1/4] feat(authz): map search 'unscoped' sentinel to no-filter path --- .../adapter_agentex_authz_proxy.py | 10 ++- agentex/src/adapters/authorization/port.py | 8 ++- .../unit/adapters/authorization/__init__.py | 0 .../test_adapter_agentex_authz_proxy.py | 68 +++++++++++++++++++ 4 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 agentex/tests/unit/adapters/authorization/__init__.py create mode 100644 agentex/tests/unit/adapters/authorization/test_adapter_agentex_authz_proxy.py diff --git a/agentex/src/adapters/authorization/adapter_agentex_authz_proxy.py b/agentex/src/adapters/authorization/adapter_agentex_authz_proxy.py index c8ba4f3a..51ed577a 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 it onto the existing + # unscoped path (None == no id filter) so permissive providers stay + # stateless. 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"): + 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..d860dcd5 --- /dev/null +++ b/agentex/tests/unit/adapters/authorization/test_adapter_agentex_authz_proxy.py @@ -0,0 +1,68 @@ +"""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 == [] From e323efea83f633ded385a2d557a897469634cd64 Mon Sep 17 00:00:00 2001 From: Stas Moreinis Date: Thu, 11 Jun 2026 13:36:40 -0700 Subject: [PATCH 2/4] docs(authz): formalize search 'unscoped' wildcard sentinel --- .../auth_provider_contract.md | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/agentex/docs/docs/development_guides/auth_provider_contract.md b/agentex/docs/docs/development_guides/auth_provider_contract.md index 6526f6dc..4b8075ed 100644 --- a/agentex/docs/docs/development_guides/auth_provider_contract.md +++ b/agentex/docs/docs/development_guides/auth_provider_contract.md @@ -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` | From bd53109c5456724915418d3370e77d9541302434 Mon Sep 17 00:00:00 2001 From: Stas Moreinis Date: Thu, 11 Jun 2026 13:50:40 -0700 Subject: [PATCH 3/4] fix(authz): require boolean unscoped sentinel --- .../development_guides/auth_provider_contract.md | 2 +- .../authorization/adapter_agentex_authz_proxy.py | 12 ++++++------ .../test_adapter_agentex_authz_proxy.py | 11 +++++++++++ 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/agentex/docs/docs/development_guides/auth_provider_contract.md b/agentex/docs/docs/development_guides/auth_provider_contract.md index 4b8075ed..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 | diff --git a/agentex/src/adapters/authorization/adapter_agentex_authz_proxy.py b/agentex/src/adapters/authorization/adapter_agentex_authz_proxy.py index 51ed577a..23f02ebe 100644 --- a/agentex/src/adapters/authorization/adapter_agentex_authz_proxy.py +++ b/agentex/src/adapters/authorization/adapter_agentex_authz_proxy.py @@ -84,12 +84,12 @@ async def list_resources( 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 it onto the existing - # unscoped path (None == no id filter) so permissive providers stay - # stateless. 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"): + # {"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"] 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 index d860dcd5..b125506e 100644 --- a/agentex/tests/unit/adapters/authorization/test_adapter_agentex_authz_proxy.py +++ b/agentex/tests/unit/adapters/authorization/test_adapter_agentex_authz_proxy.py @@ -66,3 +66,14 @@ async def test_empty_items_is_not_a_sentinel(self): filter_resource=AgentexResourceType.task, ) assert result == [] + + async def test_truthy_non_boolean_unscoped_is_not_a_sentinel(self): + with patch( + PROXY_TARGET, + new=AsyncMock(return_value={"unscoped": "false", "items": []}), + ): + result = await _proxy().list_resources( + principal={"user_id": "u"}, + filter_resource=AgentexResourceType.task, + ) + assert result == [] From 8dc47ed9701990c3fee5ab56927e2b7aca30baf5 Mon Sep 17 00:00:00 2001 From: Stas Moreinis Date: Thu, 11 Jun 2026 14:12:00 -0700 Subject: [PATCH 4/4] test(authz): address greptile review findings - Add fail-closed test: response missing both 'unscoped' and 'items' raises KeyError - Use an unambiguous truthy-non-boolean value (1) in the strict-sentinel test --- .../test_adapter_agentex_authz_proxy.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) 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 index b125506e..533c0bf7 100644 --- a/agentex/tests/unit/adapters/authorization/test_adapter_agentex_authz_proxy.py +++ b/agentex/tests/unit/adapters/authorization/test_adapter_agentex_authz_proxy.py @@ -68,12 +68,27 @@ async def test_empty_items_is_not_a_sentinel(self): 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": "false", "items": []}), + 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, + )