From 784169651eb12a8aa33e0babf5fcf1bd32b437d0 Mon Sep 17 00:00:00 2001 From: Asher Fink Date: Tue, 26 May 2026 13:28:35 -0400 Subject: [PATCH] feat(AGX1-275): per-RPC task permission rewire and 404/403 wrap --- agentex/src/api/routes/agents.py | 63 +-- agentex/src/api/routes/checkpoints.py | 12 +- agentex/src/api/routes/messages.py | 8 +- .../src/api/schemas/authorization_types.py | 1 + agentex/src/utils/authorization_shortcuts.py | 45 ++- agentex/src/utils/task_authorization.py | 39 ++ agentex/tests/unit/api/test_tasks_authz.py | 367 ++++++++++++++++++ 7 files changed, 479 insertions(+), 56 deletions(-) create mode 100644 agentex/src/utils/task_authorization.py create mode 100644 agentex/tests/unit/api/test_tasks_authz.py diff --git a/agentex/src/api/routes/agents.py b/agentex/src/api/routes/agents.py index bfdc53b9..e802f98a 100644 --- a/agentex/src/api/routes/agents.py +++ b/agentex/src/api/routes/agents.py @@ -38,6 +38,7 @@ DAuthorizedResourceIds, ) from src.utils.logging import make_logger +from src.utils.task_authorization import _check_task_or_collapse_to_404 logger = make_logger(__name__) @@ -333,26 +334,31 @@ async def _authorize_rpc_request( task_name = request.params.task_name if task_id is not None: - # Direct task ID provided - check execute permission on that specific task - await authorization_service.check( - resource=AgentexResource.task(task_id), - operation=AuthorizedOperationType.execute, + await _check_task_or_collapse_to_404( + authorization_service, + task_id, + AuthorizedOperationType.update, ) elif task_name is not None: - # Task name provided - check if task exists + # try/else (not try/except wrapping the whole block): a denied + # update on an existing task must surface as 404 to the caller, + # NOT silently fall through to the create check below — that + # would let "I'm denied update" masquerade as "task is absent" + # and grant create access. try: existing_task = await task_service.get_task(name=task_name) - # Task exists - require execute permission on the specific task - await authorization_service.check( - resource=AgentexResource.task(existing_task.id), - operation=AuthorizedOperationType.execute, - ) except ItemDoesNotExist: # Task doesn't exist - will be created, require create permission await authorization_service.check( resource=AgentexResource.task("*"), operation=AuthorizedOperationType.create, ) + else: + await _check_task_or_collapse_to_404( + authorization_service, + existing_task.id, + AuthorizedOperationType.update, + ) else: # No identifier provided - creating new task, require create permission await authorization_service.check( @@ -365,20 +371,22 @@ async def _authorize_rpc_request( task_name = request.params.task_name if task_id is not None: - # Direct task ID provided - check execute permission on that specific task - await authorization_service.check( - resource=AgentexResource.task(task_id), - operation=AuthorizedOperationType.execute, + await _check_task_or_collapse_to_404( + authorization_service, + task_id, + AuthorizedOperationType.update, ) elif task_name is not None: - # Task name provided - look up task and check execute permission + # `get_task` raises ItemDoesNotExist for absent rows (= 404 + # naturally); the wrap collapses present-but-denied to the same + # shape so callers can't distinguish. existing_task = await task_service.get_task(name=task_name) - await authorization_service.check( - resource=AgentexResource.task(existing_task.id), - operation=AuthorizedOperationType.execute, + await _check_task_or_collapse_to_404( + authorization_service, + existing_task.id, + AuthorizedOperationType.update, ) else: - # No identifier provided - this shouldn't happen but handle gracefully raise ValueError( "Either task_id or task_name must be provided for event/send" ) @@ -388,20 +396,19 @@ async def _authorize_rpc_request( task_name = request.params.task_name if task_id is not None: - # Direct task ID provided - check execute permission on that specific task - await authorization_service.check( - resource=AgentexResource.task(task_id), - operation=AuthorizedOperationType.execute, + await _check_task_or_collapse_to_404( + authorization_service, + task_id, + AuthorizedOperationType.cancel, ) elif task_name is not None: - # Task name provided - look up task and check execute permission existing_task = await task_service.get_task(name=task_name) - await authorization_service.check( - resource=AgentexResource.task(existing_task.id), - operation=AuthorizedOperationType.execute, + await _check_task_or_collapse_to_404( + authorization_service, + existing_task.id, + AuthorizedOperationType.cancel, ) else: - # No identifier provided - this shouldn't happen but handle gracefully raise ValueError( "Either task_id or task_name must be provided for task/cancel" ) diff --git a/agentex/src/api/routes/checkpoints.py b/agentex/src/api/routes/checkpoints.py index 81f3a831..69b64f33 100644 --- a/agentex/src/api/routes/checkpoints.py +++ b/agentex/src/api/routes/checkpoints.py @@ -2,6 +2,10 @@ from fastapi import APIRouter, Response +from src.api.schemas.authorization_types import ( + AgentexResourceType, + AuthorizedOperationType, +) from src.api.schemas.checkpoints import ( BlobResponse, CheckpointListItem, @@ -14,10 +18,6 @@ PutWritesRequest, WriteResponse, ) -from src.api.schemas.authorization_types import ( - AgentexResourceType, - AuthorizedOperationType, -) from src.domain.use_cases.checkpoints_use_case import DCheckpointsUseCase from src.utils.authorization_shortcuts import DAuthorizedBodyId from src.utils.logging import make_logger @@ -95,7 +95,7 @@ async def put_checkpoint( request: PutCheckpointRequest, checkpoints_use_case: DCheckpointsUseCase, _authorized_task_id: DAuthorizedBodyId( - AgentexResourceType.task, AuthorizedOperationType.execute, field_name="thread_id" + AgentexResourceType.task, AuthorizedOperationType.update, field_name="thread_id" ), ) -> PutCheckpointResponse: blobs = [ @@ -133,7 +133,7 @@ async def put_writes( request: PutWritesRequest, checkpoints_use_case: DCheckpointsUseCase, _authorized_task_id: DAuthorizedBodyId( - AgentexResourceType.task, AuthorizedOperationType.execute, field_name="thread_id" + AgentexResourceType.task, AuthorizedOperationType.update, field_name="thread_id" ), ) -> Response: writes = [ diff --git a/agentex/src/api/routes/messages.py b/agentex/src/api/routes/messages.py index 2f842758..29bef4e3 100644 --- a/agentex/src/api/routes/messages.py +++ b/agentex/src/api/routes/messages.py @@ -82,7 +82,7 @@ async def batch_create_messages( request: BatchCreateTaskMessagesRequest, message_use_case: DMessageUseCase, _authorized_task_id: DAuthorizedBodyId( - AgentexResourceType.task, AuthorizedOperationType.execute + AgentexResourceType.task, AuthorizedOperationType.update ), ) -> list[TaskMessage]: # Convert each content from API schema to entity schema @@ -110,7 +110,7 @@ async def batch_update_messages( request: BatchUpdateTaskMessagesRequest, message_use_case: DMessageUseCase, _authorized_task_id: DAuthorizedBodyId( - AgentexResourceType.task, AuthorizedOperationType.execute + AgentexResourceType.task, AuthorizedOperationType.update ), ) -> list[TaskMessage]: task_message_entities = await message_use_case.update_batch( @@ -131,7 +131,7 @@ async def create_message( request: CreateTaskMessageRequest, message_use_case: DMessageUseCase, _authorized_task_id: DAuthorizedBodyId( - AgentexResourceType.task, AuthorizedOperationType.execute + AgentexResourceType.task, AuthorizedOperationType.update ), ) -> TaskMessage: task_message_entity = await message_use_case.create( @@ -152,7 +152,7 @@ async def update_message( message_id: str, message_use_case: DMessageUseCase, _authorized_task_id: DAuthorizedBodyId( - AgentexResourceType.task, AuthorizedOperationType.execute + AgentexResourceType.task, AuthorizedOperationType.update ), ) -> TaskMessage: task_message_entity = await message_use_case.update( diff --git a/agentex/src/api/schemas/authorization_types.py b/agentex/src/api/schemas/authorization_types.py index 585fa3a3..446a69a4 100644 --- a/agentex/src/api/schemas/authorization_types.py +++ b/agentex/src/api/schemas/authorization_types.py @@ -9,6 +9,7 @@ class AuthorizedOperationType(StrEnum): update = "update" delete = "delete" execute = "execute" + cancel = "cancel" class AgentexResourceType(StrEnum): diff --git a/agentex/src/utils/authorization_shortcuts.py b/agentex/src/utils/authorization_shortcuts.py index 2f8296d6..5df27fd0 100644 --- a/agentex/src/utils/authorization_shortcuts.py +++ b/agentex/src/utils/authorization_shortcuts.py @@ -13,6 +13,7 @@ from src.domain.repositories.task_repository import DTaskRepository from src.domain.repositories.task_state_repository import DTaskStateRepository from src.domain.services.authorization_service import DAuthorizationService +from src.utils.task_authorization import _check_task_or_collapse_to_404 async def _get_parent_task_id( @@ -51,12 +52,10 @@ async def _ensure_authorized_id( task_id = await _get_parent_task_id( resource_type, resource_id, event_repository, state_repository ) - await authorization.check( - resource=AgentexResource.task(task_id), - operation=operation, - ) + await _check_task_or_collapse_to_404(authorization, task_id, operation) + elif resource_type == AgentexResourceType.task: + await _check_task_or_collapse_to_404(authorization, resource_id, operation) else: - # For direct resources, check directly await authorization.check( resource=AgentexResource(type=resource_type, selector=resource_id), operation=operation, @@ -88,12 +87,10 @@ async def _ensure_authorized_query( task_id = await _get_parent_task_id( resource_type, resource_id, event_repository, state_repository ) - await authorization.check( - resource=AgentexResource.task(task_id), - operation=operation, - ) + await _check_task_or_collapse_to_404(authorization, task_id, operation) + elif resource_type == AgentexResourceType.task: + await _check_task_or_collapse_to_404(authorization, resource_id, operation) else: - # For direct resources, check directly await authorization.check( resource=AgentexResource(type=resource_type, selector=resource_id), operation=operation, @@ -118,10 +115,13 @@ async def _ensure_authorized_body_field( body = await request.json() field_value = body[field_name] - await authorization.check( - resource=AgentexResource(type=resource_type, selector=field_value), - operation=operation, - ) + if resource_type == AgentexResourceType.task: + await _check_task_or_collapse_to_404(authorization, field_value, operation) + else: + await authorization.check( + resource=AgentexResource(type=resource_type, selector=field_value), + operation=operation, + ) return field_value return Annotated[str, Depends(_ensure_authorized_body_field)] @@ -164,12 +164,21 @@ async def _ensure_authorized_name( resource_id = resource_name repository = registry[resource_type] + # Lookup-before-authz: if the name isn't present, `repository.get` raises + # ItemDoesNotExist (→ 404), which is what we want for absent resources. + # The present-but-denied case is handled per-resource below. resource = await repository.get(name=resource_id) - await authorization.check( - resource=AgentexResource(type=resource_type, selector=resource.id), - operation=operation, - ) + if resource_type == AgentexResourceType.task: + # Tasks: collapse denial to 404 so name probes can't distinguish + # "present in another tenant" from "absent" (tasks.name is globally + # unique — any 403 leak here probes the whole system, not a tenant). + await _check_task_or_collapse_to_404(authorization, resource.id, operation) + else: + await authorization.check( + resource=AgentexResource(type=resource_type, selector=resource.id), + operation=operation, + ) return resource_id return Annotated[str, Depends(_ensure_authorized_name)] diff --git a/agentex/src/utils/task_authorization.py b/agentex/src/utils/task_authorization.py new file mode 100644 index 00000000..20817c68 --- /dev/null +++ b/agentex/src/utils/task_authorization.py @@ -0,0 +1,39 @@ +from src.adapters.authorization.exceptions import AuthorizationError +from src.adapters.crud_store.exceptions import ItemDoesNotExist +from src.api.schemas.authorization_types import ( + AgentexResource, + AuthorizedOperationType, +) + + +async def _check_task_or_collapse_to_404( + authorization, + task_id: str, + operation: AuthorizedOperationType, +) -> None: + """Issue a check on a task resource. On any denial, surface 404 — even when + the task exists. + + The 403/404 split cannot be done safely here: ``TaskORM`` has no tenant + column, ``task_repository.get`` does an unfiltered primary-key lookup, and + ``AuthorizationError`` carries no deny-reason discriminator. Returning 403 + when the row exists and 404 when it doesn't leaks cross-tenant existence + (caller probes "does task X exist?" and gets distinguishable responses). + + Until tasks carry tenant scope (or agentex-auth's deny distinguishes + "cross-tenant" from "in-tenant lacking permission"), the safer default is + to collapse both into 404. Trade-off: a user with ``read`` but not + ``update`` permission on an in-tenant task sees 404 on update attempts + instead of 403. UX regression for in-tenant permission granularity, but + eliminates the cross-tenant existence leak. + + TODO(AGX1-290): Restore the 403/404 split for same-tenant calls once + tasks carry tenant/account_id scope at the data layer. + """ + try: + await authorization.check( + resource=AgentexResource.task(task_id), + operation=operation, + ) + except AuthorizationError: + raise ItemDoesNotExist(f"Item with id '{task_id}' does not exist.") from None diff --git a/agentex/tests/unit/api/test_tasks_authz.py b/agentex/tests/unit/api/test_tasks_authz.py new file mode 100644 index 00000000..a063a37b --- /dev/null +++ b/agentex/tests/unit/api/test_tasks_authz.py @@ -0,0 +1,367 @@ +"""Tests for Stream 3 (AGX1-275) — per-RPC operation routing and 404/403 wrap. + +The first two tests mock the authorization gateway directly and run entirely +in scale-agentex. They verify: + 1. Per-RPC operation routing: MESSAGE_SEND/EVENT_SEND map to ``update``, + TASK_CANCEL maps to ``cancel``, TASK_CREATE stays ``create``. + 2. 404 vs 403 split when authorization denies a task-resource check. + +Tests that require a real spark-authz cluster (cross-tenant isolation, +cancel-owner-only, no tenant-role fanout, list filtering) belong in an +end-to-end suite gated on dev-sgp availability; see the §5.6 / Stage B+ +playbook in the AGX1-264 plan. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from src.adapters.authorization.exceptions import AuthorizationError +from src.adapters.crud_store.exceptions import ItemDoesNotExist +from src.api.schemas.authorization_types import ( + AgentexResource, + AgentexResourceType, + AuthorizedOperationType, +) +from src.utils.authorization_shortcuts import ( + DAuthorizedBodyId, + DAuthorizedName, +) +from src.utils.task_authorization import _check_task_or_collapse_to_404 + + +def _dep_callable(annotation): + """Pull the inner FastAPI dependency function out of an ``Annotated[str, Depends(...)]``.""" + return annotation.__metadata__[0].dependency + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestPerRpcOperationRouting: + """Verify that per-RPC checks issue the right ``AuthorizedOperationType``.""" + + @staticmethod + def _capture_check(authorization: MagicMock) -> list[tuple]: + calls: list[tuple] = [] + + async def _check(resource: AgentexResource, operation: AuthorizedOperationType): + calls.append((resource.type, resource.selector, operation)) + + authorization.check = AsyncMock(side_effect=_check) + return calls + + async def test_message_send_with_task_id_uses_update(self): + from src.api.routes.agents import _authorize_rpc_request + from src.api.schemas.agents import AgentRPCMethod + + authorization = MagicMock() + calls = self._capture_check(authorization) + task_service = MagicMock() + request = MagicMock() + request.method = AgentRPCMethod.MESSAGE_SEND + request.params = MagicMock(task_id="task-1", task_name=None) + + await _authorize_rpc_request(request, authorization, task_service) + + assert calls == [ + (AgentexResourceType.task, "task-1", AuthorizedOperationType.update) + ] + + async def test_event_send_with_task_id_uses_update(self): + from src.api.routes.agents import _authorize_rpc_request + from src.api.schemas.agents import AgentRPCMethod + + authorization = MagicMock() + calls = self._capture_check(authorization) + task_service = MagicMock() + request = MagicMock() + request.method = AgentRPCMethod.EVENT_SEND + request.params = MagicMock(task_id="task-2", task_name=None) + + await _authorize_rpc_request(request, authorization, task_service) + + assert calls == [ + (AgentexResourceType.task, "task-2", AuthorizedOperationType.update) + ] + + async def test_task_cancel_with_task_id_uses_cancel(self): + from src.api.routes.agents import _authorize_rpc_request + from src.api.schemas.agents import AgentRPCMethod + + authorization = MagicMock() + calls = self._capture_check(authorization) + task_service = MagicMock() + request = MagicMock() + request.method = AgentRPCMethod.TASK_CANCEL + request.params = MagicMock(task_id="task-3", task_name=None) + + await _authorize_rpc_request(request, authorization, task_service) + + assert calls == [ + (AgentexResourceType.task, "task-3", AuthorizedOperationType.cancel) + ] + + async def test_task_create_remains_create(self): + from src.api.routes.agents import _authorize_rpc_request + from src.api.schemas.agents import AgentRPCMethod + + authorization = MagicMock() + calls = self._capture_check(authorization) + task_service = MagicMock() + request = MagicMock() + request.method = AgentRPCMethod.TASK_CREATE + + await _authorize_rpc_request(request, authorization, task_service) + + assert calls == [ + (AgentexResourceType.task, "*", AuthorizedOperationType.create) + ] + + async def test_message_send_with_task_name_uses_update_on_existing_task(self): + from src.api.routes.agents import _authorize_rpc_request + from src.api.schemas.agents import AgentRPCMethod + + authorization = MagicMock() + calls = self._capture_check(authorization) + task_service = MagicMock() + task_service.get_task = AsyncMock(return_value=MagicMock(id="task-resolved")) + request = MagicMock() + request.method = AgentRPCMethod.MESSAGE_SEND + request.params = MagicMock(task_id=None, task_name="my-task") + + await _authorize_rpc_request(request, authorization, task_service) + + task_service.get_task.assert_awaited_once_with(name="my-task") + assert calls == [ + (AgentexResourceType.task, "task-resolved", AuthorizedOperationType.update) + ] + + async def test_message_send_with_missing_task_name_falls_back_to_create(self): + from src.api.routes.agents import _authorize_rpc_request + from src.api.schemas.agents import AgentRPCMethod + + authorization = MagicMock() + calls = self._capture_check(authorization) + task_service = MagicMock() + task_service.get_task = AsyncMock(side_effect=ItemDoesNotExist("not-found")) + request = MagicMock() + request.method = AgentRPCMethod.MESSAGE_SEND + request.params = MagicMock(task_id=None, task_name="ghost-task") + + await _authorize_rpc_request(request, authorization, task_service) + + assert calls == [ + (AgentexResourceType.task, "*", AuthorizedOperationType.create) + ] + + async def test_event_send_with_task_name_uses_update(self): + from src.api.routes.agents import _authorize_rpc_request + from src.api.schemas.agents import AgentRPCMethod + + authorization = MagicMock() + calls = self._capture_check(authorization) + task_service = MagicMock() + task_service.get_task = AsyncMock(return_value=MagicMock(id="task-evt")) + request = MagicMock() + request.method = AgentRPCMethod.EVENT_SEND + request.params = MagicMock(task_id=None, task_name="evt-task") + + await _authorize_rpc_request(request, authorization, task_service) + + task_service.get_task.assert_awaited_once_with(name="evt-task") + assert calls == [ + (AgentexResourceType.task, "task-evt", AuthorizedOperationType.update) + ] + + async def test_task_cancel_with_task_name_uses_cancel(self): + from src.api.routes.agents import _authorize_rpc_request + from src.api.schemas.agents import AgentRPCMethod + + authorization = MagicMock() + calls = self._capture_check(authorization) + task_service = MagicMock() + task_service.get_task = AsyncMock(return_value=MagicMock(id="task-cancel")) + request = MagicMock() + request.method = AgentRPCMethod.TASK_CANCEL + request.params = MagicMock(task_id=None, task_name="cancel-task") + + await _authorize_rpc_request(request, authorization, task_service) + + task_service.get_task.assert_awaited_once_with(name="cancel-task") + assert calls == [ + (AgentexResourceType.task, "task-cancel", AuthorizedOperationType.cancel) + ] + + +def test_cancel_operation_wire_format_matches_agentex_auth_contract(): + """Cross-repo enum contract: the literal string ``"cancel"`` is the wire + format shared with agentex-auth's mirror enum. Diverging strings would + only show up at runtime, so lock it in a test. Mirrors the test of the + same shape in + ``~/agentex/agentex-auth/tests/domain/services/test_authorization_service_routing.py``.""" + assert AuthorizedOperationType.cancel.value == "cancel" + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestCheckTaskOrCollapseTo404: + """The task-resource authz wrap collapses every denial to 404 so callers + can't distinguish "present in another tenant" from "absent".""" + + async def test_allowed_check_returns_normally(self): + authorization = MagicMock() + authorization.check = AsyncMock(return_value=True) + + await _check_task_or_collapse_to_404( + authorization, + "task-1", + AuthorizedOperationType.update, + ) + + authorization.check.assert_awaited_once() + + async def test_denied_collapses_to_not_found_regardless_of_existence(self): + """Both "denied + task exists" and "denied + task missing" surface as + ``ItemDoesNotExist`` (→ 404). The wrap doesn't consult any repository + — collapsing avoids the cross-tenant existence leak.""" + authorization = MagicMock() + authorization.check = AsyncMock(side_effect=AuthorizationError("denied")) + + with pytest.raises(ItemDoesNotExist): + await _check_task_or_collapse_to_404( + authorization, + "task-1", + AuthorizedOperationType.update, + ) + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestDAuthorizedBodyIdTaskWrap: + """``DAuthorizedBodyId`` must route task-resource body-id checks through + the task-collapse wrap so denied body-id calls return 404 instead of 403 — + matching the path/query variants.""" + + @staticmethod + def _make_request(task_id: str) -> MagicMock: + request = MagicMock() + request.json = AsyncMock(return_value={"task_id": task_id}) + return request + + async def test_task_body_id_routes_through_wrap_on_denial(self): + """The body-id wrap goes through the task-collapse helper, which + collapses any denial to 404 — including for tasks that exist.""" + annotation = DAuthorizedBodyId( + AgentexResourceType.task, AuthorizedOperationType.update + ) + dep = _dep_callable(annotation) + + authorization = MagicMock() + authorization.check = AsyncMock(side_effect=AuthorizationError("denied")) + + with pytest.raises(ItemDoesNotExist): + await dep(self._make_request("task-7"), authorization) + + async def test_task_body_id_returns_field_value_when_allowed(self): + annotation = DAuthorizedBodyId( + AgentexResourceType.task, AuthorizedOperationType.update + ) + dep = _dep_callable(annotation) + + authorization = MagicMock() + authorization.check = AsyncMock(return_value=True) + + result = await dep(self._make_request("task-9"), authorization) + + assert result == "task-9" + + async def test_non_task_body_id_skips_wrap(self): + annotation = DAuthorizedBodyId( + AgentexResourceType.agent, AuthorizedOperationType.read + ) + dep = _dep_callable(annotation) + + authorization = MagicMock() + authorization.check = AsyncMock(return_value=True) + request = MagicMock() + request.json = AsyncMock(return_value={"agent_id": "agent-1"}) + + result = await dep(request, authorization) + + assert result == "agent-1" + # Non-task resources go straight to authorization.check, never to the wrap. + authorization.check.assert_awaited_once() + called_kwargs = authorization.check.await_args.kwargs + assert called_kwargs["resource"] == AgentexResource( + type=AgentexResourceType.agent, selector="agent-1" + ) + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestDAuthorizedNameTaskWrap: + """``DAuthorizedName`` must route task-resource name-path checks through + the task-collapse wrap so denied name-path calls return 404 instead of 403. + Without this, ``tasks.name`` (globally unique) lets callers probe whether + a name exists in any tenant by observing 404 vs 403 — exactly the + cross-tenant existence leak the other surfaces eliminate.""" + + async def test_task_name_routes_through_wrap_on_denial(self): + """Present-but-denied tasks (looked up by name) surface as + ``ItemDoesNotExist`` (→ 404), same as absent. The lookup itself + succeeds — the wrap intercepts the subsequent authz check.""" + annotation = DAuthorizedName( + AgentexResourceType.task, AuthorizedOperationType.update + ) + dep = _dep_callable(annotation) + + authorization = MagicMock() + authorization.check = AsyncMock(side_effect=AuthorizationError("denied")) + agent_repository = MagicMock() + task_repository = MagicMock() + task_repository.get = AsyncMock(return_value=MagicMock(id="task-resolved")) + + with pytest.raises(ItemDoesNotExist): + await dep(authorization, agent_repository, task_repository, "task-name-x") + + # The wrap fires AFTER the lookup — the repo is consulted to resolve + # name → id; the leak is only on the authz response, not the lookup. + task_repository.get.assert_awaited_once_with(name="task-name-x") + authorization.check.assert_awaited_once() + + async def test_task_name_returns_resource_name_when_allowed(self): + annotation = DAuthorizedName( + AgentexResourceType.task, AuthorizedOperationType.read + ) + dep = _dep_callable(annotation) + + authorization = MagicMock() + authorization.check = AsyncMock(return_value=True) + agent_repository = MagicMock() + task_repository = MagicMock() + task_repository.get = AsyncMock(return_value=MagicMock(id="task-allow")) + + result = await dep(authorization, agent_repository, task_repository, "ok-name") + + assert result == "ok-name" + called_kwargs = authorization.check.await_args.kwargs + # The selector is the resolved row id, not the user-provided name. + assert called_kwargs["resource"] == AgentexResource.task("task-allow") + + async def test_non_task_name_skips_wrap(self): + """Agent FGAC is out of scope for AGX1-264 — the wrap must NOT extend + to agent name-routes, which keep the existing 403-on-deny behavior.""" + annotation = DAuthorizedName( + AgentexResourceType.agent, AuthorizedOperationType.read + ) + dep = _dep_callable(annotation) + + authorization = MagicMock() + authorization.check = AsyncMock(side_effect=AuthorizationError("denied")) + agent_repository = MagicMock() + agent_repository.get = AsyncMock(return_value=MagicMock(id="agent-1")) + task_repository = MagicMock() + + with pytest.raises(AuthorizationError): + await dep(authorization, agent_repository, task_repository, "agent-name")