Skip to content

Commit 94e7452

Browse files
authored
feat(agents): collapse denied agent route access to 404 instead of 403 (#271)
## Related work Parent ticket: AGX1-242 - AgentEx authorization dual-write. This change is part of a 4-PR stack across 3 repos. | Repo | PR | Purpose | |---|---|---| | scaleapi/scaleapi | [scaleapi/scaleapi#146335](scaleapi/scaleapi#146335) | merged flag registry update for the shared AgentEx resources rollout flags | | scaleapi/agentex | [scaleapi/agentex#364](scaleapi/agentex#364) | agentex-auth routes each auth verb to one backend target | | scaleapi/scale-agentex | [#270](#270) | registers agentex resources in the authorization graph and grants agent/task ownership | | **scaleapi/scale-agentex** | **this PR** | **denied direct agent access collapses to 404** | **Merge/deploy order:** [scaleapi/scaleapi#146335](scaleapi/scaleapi#146335) is merged; deploy [scaleapi/agentex#364](scaleapi/agentex#364), then merge/deploy [#270](#270), then this PR. Rollout per account is: enable `fgac-agentex-resources-dual-write`, backfill existing resources into Spark, then enable `fgac-agentex-resources` reads. ## What Collapses denied direct agent access from 403 to 404 for agent lookups by id, name, and query. Allowed checks pass through unchanged, and list behavior is unchanged (the list route already filters to authorized ids). ## Why Returning 403 for a specific agent id or name reveals that the agent exists; returning 404 makes "not found" and "exists but not visible to this caller" indistinguishable. This is read-side behavior only. ## Testing - Unit tests for the agent and task authorization route behavior. <!-- greptile_comment --> <h3>Greptile Summary</h3> This PR extends the authorization shortcut helpers to collapse denied agent checks from 403 to 404, preventing cross-tenant existence probing via error-code differentiation. - Adds `_check_agent_or_collapse_to_404` and wires it into all three shortcuts (`DAuthorizedId`, `DAuthorizedQuery`, `DAuthorizedName`) for `AgentexResourceType.agent`; the earlier review concern about `DAuthorizedQuery` (`GET /events` list endpoint) is addressed in this diff. - Introduces 263-line unit test suite (`test_agents_authz.py`) covering the helper directly plus each shortcut's agent path, and updates `test_schedules_authz.py` / `test_tasks_authz.py` to reflect the new expected behavior. <details><summary><h3>Confidence Score: 5/5</h3></summary> Safe to merge; all three agent-lookup shortcuts collapse denied checks to 404 and the earlier concern about DAuthorizedQuery on the events list endpoint is resolved in this diff. The change is narrow and consistent: one new helper function wired into three existing shortcuts, each path exercised by dedicated unit tests. DAuthorizedBodyId is only used for task resources so it requires no changes. The updated schedule and task tests correctly reflect the new collapse behavior. No files require special attention. </details> <h3>Important Files Changed</h3> | Filename | Overview | |----------|----------| | agentex/src/utils/authorization_shortcuts.py | Adds _check_agent_or_collapse_to_404 and applies it in DAuthorizedId, DAuthorizedQuery, and DAuthorizedName; all three agent-lookup paths now collapse AuthorizationError to 404. DAuthorizedBodyId is unaffected and only used with task resources. | | agentex/tests/unit/api/test_agents_authz.py | New test file covering the helper, DAuthorizedId, DAuthorizedQuery, DAuthorizedName, and list-filtering for agent resources; denied/absent/allowed cases are all represented. | | agentex/tests/unit/api/test_schedules_authz.py | Updates TestCreateParentAgentCheck to expect ItemDoesNotExist (404) instead of AuthorizationError (403) on a denied parent-agent check, consistent with the new behavior in DAuthorizedId for agents. | | agentex/tests/unit/api/test_tasks_authz.py | Renames test_non_task_name_skips_wrap to test_agent_name_collapses_to_404 and flips the expected exception from AuthorizationError to ItemDoesNotExist, aligning with the agent-name collapse now in DAuthorizedName. | </details> <details><summary><h3>Flowchart</h3></summary> ```mermaid %%{init: {'theme': 'neutral'}}%% flowchart TD A[Caller Request] --> B{Authorization Shortcut} B -->|DAuthorizedId agent| C[_check_agent_or_collapse_to_404] B -->|DAuthorizedName agent| D[repo.get name to id then _check_agent_or_collapse_to_404] B -->|DAuthorizedQuery agent| E[_check_agent_or_collapse_to_404] C --> F{authorization.check} D --> F E --> F F -->|Allowed| G[Pass through - return resource id] F -->|AuthorizationError denied| H[raise ItemDoesNotExist - HTTP 404] style H fill:#f96,color:#000 style G fill:#6f6,color:#000 ``` </details> <sub>Reviews (10): Last reviewed commit: ["feat(agents): collapse denied agent rout..."](99106f8) | [Re-trigger Greptile](https://app.greptile.com/api/retrigger?id=35397402)</sub> <!-- /greptile_comment -->
1 parent 7c0700a commit 94e7452

4 files changed

Lines changed: 317 additions & 10 deletions

File tree

agentex/src/utils/authorization_shortcuts.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from fastapi import Depends, Path, Query, Request
44

5+
from src.adapters.authorization.exceptions import AuthorizationError
6+
from src.adapters.crud_store.exceptions import ItemDoesNotExist
57
from src.api.schemas.authorization_types import (
68
AgentexResource,
79
AgentexResourceType,
@@ -15,7 +17,10 @@
1517
from src.domain.repositories.task_message_repository import DTaskMessageRepository
1618
from src.domain.repositories.task_repository import DTaskRepository
1719
from src.domain.repositories.task_state_repository import DTaskStateRepository
18-
from src.domain.services.authorization_service import DAuthorizationService
20+
from src.domain.services.authorization_service import (
21+
AuthorizationService,
22+
DAuthorizationService,
23+
)
1924
from src.utils.agent_api_key_authorization import _check_api_key_or_collapse_to_404
2025
from src.utils.task_authorization import check_task_or_collapse_to_404
2126

@@ -39,6 +44,29 @@ async def _get_parent_task_id(
3944
return resource.task_id
4045

4146

47+
async def _check_agent_or_collapse_to_404(
48+
authorization: AuthorizationService,
49+
agent_id: str,
50+
operation: AuthorizedOperationType,
51+
) -> None:
52+
"""Check an agent resource; collapse any denial to 404.
53+
54+
Collapsing a denied check into 404 stops callers from distinguishing 403
55+
(exists, no access) from 404 (absent) and thereby probing whether an agent
56+
exists in another tenant. Mirrors the task/api_key collapse helpers.
57+
58+
TODO: fold this into a single generic collapse helper, and restore the
59+
403/404 split once agents carry tenant scope at the data layer.
60+
"""
61+
try:
62+
await authorization.check(
63+
resource=AgentexResource.agent(agent_id),
64+
operation=operation,
65+
)
66+
except AuthorizationError:
67+
raise ItemDoesNotExist(f"Item with id '{agent_id}' does not exist.") from None
68+
69+
4270
def DAuthorizedId(
4371
resource_type: AgentexResourceType | TaskChildResourceType,
4472
operation: AuthorizedOperationType = AuthorizedOperationType.read,
@@ -74,6 +102,9 @@ async def _ensure_authorized_id(
74102
await _check_api_key_or_collapse_to_404(
75103
authorization, resource_id, operation
76104
)
105+
elif resource_type == AgentexResourceType.agent:
106+
# Collapse agent denials to 404 for the same discoverability reason.
107+
await _check_agent_or_collapse_to_404(authorization, resource_id, operation)
77108
else:
78109
await authorization.check(
79110
resource=AgentexResource(type=resource_type, selector=resource_id),
@@ -116,6 +147,8 @@ async def _ensure_authorized_query(
116147
await check_task_or_collapse_to_404(authorization, task_id, operation)
117148
elif resource_type == AgentexResourceType.task:
118149
await check_task_or_collapse_to_404(authorization, resource_id, operation)
150+
elif resource_type == AgentexResourceType.agent:
151+
await _check_agent_or_collapse_to_404(authorization, resource_id, operation)
119152
else:
120153
await authorization.check(
121154
resource=AgentexResource(type=resource_type, selector=resource_id),
@@ -200,6 +233,9 @@ async def _ensure_authorized_name(
200233
# "present in another tenant" from "absent" (tasks.name is globally
201234
# unique — any 403 leak here probes the whole system, not a tenant).
202235
await check_task_or_collapse_to_404(authorization, resource.id, operation)
236+
elif resource_type == AgentexResourceType.agent:
237+
# Agents: same collapse, on the id resolved from the name above.
238+
await _check_agent_or_collapse_to_404(authorization, resource.id, operation)
203239
else:
204240
await authorization.check(
205241
resource=AgentexResource(type=resource_type, selector=resource.id),
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
"""Agent direct-route authorization: denied reads/deletes collapse to 404.
2+
3+
Asserts the route layer issues the right ``check`` calls and collapses a denial
4+
to 404 (so callers can't probe cross-tenant existence), and that list filtering
5+
forwards the authorized-id set to the use case.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from unittest.mock import AsyncMock, MagicMock
11+
12+
import pytest
13+
from src.adapters.authorization.exceptions import AuthorizationError
14+
from src.adapters.crud_store.exceptions import ItemDoesNotExist
15+
from src.api.schemas.authorization_types import (
16+
AgentexResource,
17+
AgentexResourceType,
18+
AuthorizedOperationType,
19+
)
20+
from src.utils.authorization_shortcuts import (
21+
DAuthorizedId,
22+
DAuthorizedName,
23+
DAuthorizedQuery,
24+
_check_agent_or_collapse_to_404,
25+
)
26+
27+
28+
def _dep_callable(annotation):
29+
"""Pull the inner FastAPI dependency function out of an ``Annotated[str, Depends(...)]``."""
30+
return annotation.__metadata__[0].dependency
31+
32+
33+
@pytest.mark.unit
34+
@pytest.mark.asyncio
35+
class TestCheckAgentOrCollapseTo404:
36+
"""Helper collapses every denial to 404 (no cross-tenant existence leak)."""
37+
38+
async def test_allowed_check_returns_normally(self):
39+
authorization = MagicMock()
40+
authorization.check = AsyncMock(return_value=True)
41+
42+
await _check_agent_or_collapse_to_404(
43+
authorization,
44+
"agent-1",
45+
AuthorizedOperationType.read,
46+
)
47+
48+
authorization.check.assert_awaited_once()
49+
called_kwargs = authorization.check.await_args.kwargs
50+
assert called_kwargs["resource"] == AgentexResource.agent("agent-1")
51+
assert called_kwargs["operation"] == AuthorizedOperationType.read
52+
53+
async def test_denied_collapses_to_not_found(self):
54+
authorization = MagicMock()
55+
authorization.check = AsyncMock(side_effect=AuthorizationError("denied"))
56+
57+
with pytest.raises(ItemDoesNotExist):
58+
await _check_agent_or_collapse_to_404(
59+
authorization,
60+
"agent-1",
61+
AuthorizedOperationType.delete,
62+
)
63+
64+
async def test_operation_forwarded_verbatim(self):
65+
authorization = MagicMock()
66+
authorization.check = AsyncMock(return_value=True)
67+
68+
await _check_agent_or_collapse_to_404(
69+
authorization,
70+
"agent-2",
71+
AuthorizedOperationType.delete,
72+
)
73+
74+
called_kwargs = authorization.check.await_args.kwargs
75+
assert called_kwargs["operation"] == AuthorizedOperationType.delete
76+
77+
78+
@pytest.mark.unit
79+
@pytest.mark.asyncio
80+
class TestDAuthorizedIdAgentWrap:
81+
"""``DAuthorizedId(agent, ...)`` routes through the collapse wrap → 404 on denial."""
82+
83+
async def test_agent_id_returns_resource_id_when_allowed(self):
84+
annotation = DAuthorizedId(
85+
AgentexResourceType.agent, AuthorizedOperationType.read
86+
)
87+
dep = _dep_callable(annotation)
88+
89+
authorization = MagicMock()
90+
authorization.check = AsyncMock(return_value=True)
91+
92+
result = await dep(
93+
authorization, MagicMock(), MagicMock(), MagicMock(), "agent-9"
94+
)
95+
96+
assert result == "agent-9"
97+
called_kwargs = authorization.check.await_args.kwargs
98+
assert called_kwargs["resource"] == AgentexResource.agent("agent-9")
99+
assert called_kwargs["operation"] == AuthorizedOperationType.read
100+
101+
async def test_agent_id_routes_through_wrap_on_denial(self):
102+
annotation = DAuthorizedId(
103+
AgentexResourceType.agent, AuthorizedOperationType.read
104+
)
105+
dep = _dep_callable(annotation)
106+
107+
authorization = MagicMock()
108+
authorization.check = AsyncMock(side_effect=AuthorizationError("denied"))
109+
110+
with pytest.raises(ItemDoesNotExist):
111+
await dep(authorization, MagicMock(), MagicMock(), MagicMock(), "agent-7")
112+
113+
async def test_agent_delete_op_propagated_to_check(self):
114+
annotation = DAuthorizedId(
115+
AgentexResourceType.agent, AuthorizedOperationType.delete
116+
)
117+
dep = _dep_callable(annotation)
118+
119+
authorization = MagicMock()
120+
authorization.check = AsyncMock(return_value=True)
121+
122+
await dep(authorization, MagicMock(), MagicMock(), MagicMock(), "agent-del")
123+
124+
called_kwargs = authorization.check.await_args.kwargs
125+
assert called_kwargs["operation"] == AuthorizedOperationType.delete
126+
127+
128+
@pytest.mark.unit
129+
@pytest.mark.asyncio
130+
class TestDAuthorizedNameAgentWrap:
131+
"""``DAuthorizedName(agent, ...)``: lookup-then-collapse on the resolved id."""
132+
133+
async def test_present_but_denied_collapses_to_404(self):
134+
annotation = DAuthorizedName(
135+
AgentexResourceType.agent, AuthorizedOperationType.read
136+
)
137+
dep = _dep_callable(annotation)
138+
139+
agent_repository = MagicMock()
140+
agent_repository.get = AsyncMock(return_value=MagicMock(id="agent-resolved"))
141+
task_repository = MagicMock()
142+
authorization = MagicMock()
143+
authorization.check = AsyncMock(side_effect=AuthorizationError("denied"))
144+
145+
with pytest.raises(ItemDoesNotExist):
146+
await dep(authorization, agent_repository, task_repository, "prod-agent")
147+
148+
# Name was resolved to an id BEFORE the authz check intercepted the denial.
149+
agent_repository.get.assert_awaited_once_with(name="prod-agent")
150+
called_kwargs = authorization.check.await_args.kwargs
151+
assert called_kwargs["resource"] == AgentexResource.agent("agent-resolved")
152+
153+
async def test_absent_name_surfaces_native_404_without_checking(self):
154+
annotation = DAuthorizedName(
155+
AgentexResourceType.agent, AuthorizedOperationType.read
156+
)
157+
dep = _dep_callable(annotation)
158+
159+
agent_repository = MagicMock()
160+
agent_repository.get = AsyncMock(side_effect=ItemDoesNotExist("absent"))
161+
task_repository = MagicMock()
162+
authorization = MagicMock()
163+
authorization.check = AsyncMock(return_value=True)
164+
165+
with pytest.raises(ItemDoesNotExist):
166+
await dep(authorization, agent_repository, task_repository, "missing-agent")
167+
168+
# Truly-absent name → repo's native 404, and no authz check is attempted.
169+
authorization.check.assert_not_awaited()
170+
171+
172+
@pytest.mark.unit
173+
@pytest.mark.asyncio
174+
class TestDAuthorizedQueryAgentWrap:
175+
"""``DAuthorizedQuery(agent, ...)`` also collapses denied checks to 404."""
176+
177+
async def test_agent_query_returns_resource_id_when_allowed(self):
178+
annotation = DAuthorizedQuery(
179+
AgentexResourceType.agent, AuthorizedOperationType.read
180+
)
181+
dep = _dep_callable(annotation)
182+
183+
authorization = MagicMock()
184+
authorization.check = AsyncMock(return_value=True)
185+
186+
result = await dep(
187+
authorization, MagicMock(), MagicMock(), MagicMock(), "agent-query"
188+
)
189+
190+
assert result == "agent-query"
191+
called_kwargs = authorization.check.await_args.kwargs
192+
assert called_kwargs["resource"] == AgentexResource.agent("agent-query")
193+
assert called_kwargs["operation"] == AuthorizedOperationType.read
194+
195+
async def test_agent_query_routes_through_wrap_on_denial(self):
196+
annotation = DAuthorizedQuery(
197+
AgentexResourceType.agent, AuthorizedOperationType.read
198+
)
199+
dep = _dep_callable(annotation)
200+
201+
authorization = MagicMock()
202+
authorization.check = AsyncMock(side_effect=AuthorizationError("denied"))
203+
204+
with pytest.raises(ItemDoesNotExist):
205+
await dep(
206+
authorization, MagicMock(), MagicMock(), MagicMock(), "agent-query"
207+
)
208+
209+
210+
@pytest.mark.unit
211+
@pytest.mark.asyncio
212+
class TestListFiltering:
213+
"""List forwards the authorized-id set to the use case (SQL-layer filtering)."""
214+
215+
async def test_authorized_ids_pushed_into_use_case(self):
216+
from src.api.routes.agents import list_agents
217+
218+
agents_use_case = MagicMock()
219+
agents_use_case.list = AsyncMock(return_value=[])
220+
221+
await list_agents(
222+
agents_use_case=agents_use_case,
223+
_authorized_ids=["agent-a", "agent-c"],
224+
task_id=None,
225+
limit=50,
226+
page_number=1,
227+
order_by=None,
228+
order_direction="desc",
229+
)
230+
231+
agents_use_case.list.assert_awaited_once_with(
232+
task_id=None,
233+
limit=50,
234+
page_number=1,
235+
order_by=None,
236+
order_direction="desc",
237+
id=["agent-a", "agent-c"],
238+
)
239+
240+
async def test_none_authorized_ids_passes_through_unfiltered(self):
241+
from src.api.routes.agents import list_agents
242+
243+
agents_use_case = MagicMock()
244+
agents_use_case.list = AsyncMock(return_value=[])
245+
246+
await list_agents(
247+
agents_use_case=agents_use_case,
248+
_authorized_ids=None,
249+
task_id=None,
250+
limit=50,
251+
page_number=1,
252+
order_by=None,
253+
order_direction="desc",
254+
)
255+
256+
# ``None`` (authz bypass / disabled) means no id filter is applied.
257+
agents_use_case.list.assert_awaited_once_with(
258+
task_id=None,
259+
limit=50,
260+
page_number=1,
261+
order_by=None,
262+
order_direction="desc",
263+
)

agentex/tests/unit/api/test_schedules_authz.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,8 @@ class TestCreateParentAgentCheck:
190190
"""``create_schedule`` is the only route where no schedule resource exists
191191
yet, so the authorization service cannot transitively gate on it. The
192192
route's ``agent_id`` guard MUST check ``agent.update`` on the parent, and a
193-
denial propagates as 403 (no schedule whose existence could leak)."""
193+
denial collapses to 404 — like every other agent check — so the schedules
194+
endpoint can't be used to probe cross-tenant agent existence."""
194195

195196
@staticmethod
196197
def _agent_id_dep():
@@ -215,16 +216,21 @@ async def test_create_checks_parent_agent_update(self):
215216
assert called_kwargs["resource"] == AgentexResource.agent("agent-1")
216217
assert called_kwargs["operation"] == AuthorizedOperationType.update
217218

218-
async def test_create_denied_propagates_403_not_collapsed(self):
219+
async def test_create_denied_collapses_to_404(self):
219220
dep = self._agent_id_dep()
220221

221222
authorization = MagicMock()
222223
authorization.check = AsyncMock(side_effect=AuthorizationError("denied"))
223224

224-
# Direct agent denial bubbles out as AuthorizationError (→ 403), NOT 404.
225-
with pytest.raises(AuthorizationError):
225+
# Parent-agent denial collapses to 404 so creating a schedule under an
226+
# agent in another tenant can't reveal that the agent exists.
227+
with pytest.raises(ItemDoesNotExist):
226228
await dep(
227-
authorization, MagicMock(), MagicMock(), MagicMock(), resource_id="agent-1"
229+
authorization,
230+
MagicMock(),
231+
MagicMock(),
232+
MagicMock(),
233+
resource_id="agent-1",
228234
)
229235

230236

0 commit comments

Comments
 (0)