Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions agentex/docs/docs/development_guides/auth_provider_contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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`

Expand Down Expand Up @@ -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` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down
8 changes: 6 additions & 2 deletions agentex/src/adapters/authorization/port.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Empty file.
Original file line number Diff line number Diff line change
@@ -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 == []
Comment thread
smoreinis marked this conversation as resolved.

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,
)
Loading