Skip to content

Commit c2bdb64

Browse files
fix(schedules): hide resolved ids for name aliases
Keep name-addressed schedule authorization failures on a name-based not-found response so denied requests do not reveal the resolved schedule id. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 34dc76f commit c2bdb64

3 files changed

Lines changed: 37 additions & 4 deletions

File tree

agentex/src/api/routes/agent_run_schedules.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,13 +129,16 @@ async def _resolve_name_alias_and_check(
129129
# and then authorize/operate on the immutable schedule id. Acting on the id,
130130
# not the name, keeps it the stable auth and mutation target even if the
131131
# label changes, and closes the rename/recreate race where a check on the old
132-
# row could precede a write that lands on a new one. Absent names 404; denied
133-
# resources collapse to 404.
132+
# row could precede a write that lands on a new one. Both the absent-name and
133+
# the denied-resource paths raise this same name-based 404 so an unauthorized
134+
# caller can neither distinguish the two nor read back the resolved id.
135+
not_found_message = f"Run schedule '{name}' for agent '{agent_id}' does not exist."
134136
schedule_id = await run_schedules_use_case.get_schedule_id_by_name(agent_id, name)
135137
await _check_schedule_or_collapse_to_404(
136138
authorization,
137139
build_run_schedule_authz_selector(agent_id, schedule_id),
138140
operation,
141+
not_found_message=not_found_message,
139142
)
140143
return schedule_id
141144

agentex/src/utils/schedule_authorization.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,18 @@ async def _check_schedule_or_collapse_to_404(
1212
authorization: AuthorizationService,
1313
schedule_id: str,
1414
operation: AuthorizedOperationType,
15+
not_found_message: str | None = None,
1516
) -> None:
16-
"""Check an agent_schedule resource while hiding unreadable schedules."""
17+
"""Check an agent_schedule resource while hiding unreadable schedules.
18+
19+
``not_found_message`` overrides the collapsed-404 body. Name-addressed routes
20+
pass a name-based message so a denied resource is indistinguishable from an
21+
absent one and the resolved id is never echoed back to an unauthorized caller.
22+
"""
1723
await check_resource_or_collapse_unreadable_to_404(
1824
authorization=authorization,
1925
resource=AgentexResource.schedule(schedule_id),
2026
operation=operation,
21-
not_found_message=f"Item with id '{schedule_id}' does not exist.",
27+
not_found_message=not_found_message
28+
or f"Item with id '{schedule_id}' does not exist.",
2229
)

agentex/tests/unit/api/test_agent_run_schedules_authz.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,29 @@ async def test_get_by_name_resolves_id_then_checks_read(self):
150150
assert called["operation"] == AuthorizedOperationType.read
151151
use_case.get_schedule.assert_awaited_once_with("agent-1", "schedule-1")
152152

153+
async def test_get_by_name_denied_404_hides_existence_and_resolved_id(self):
154+
# A denied name-alias request must 404 with a name-based body identical to
155+
# the absent-name case, and must never echo the resolved schedule id — else
156+
# an unauthorized caller could probe existence / read back the id.
157+
authorization = MagicMock()
158+
authorization.check = AsyncMock(side_effect=AuthorizationError("denied"))
159+
use_case = MagicMock()
160+
use_case.get_schedule_id_by_name = AsyncMock(return_value="secret-id")
161+
use_case.get_schedule = AsyncMock()
162+
163+
with pytest.raises(ItemDoesNotExist) as exc_info:
164+
await get_run_schedule_by_name(
165+
agent_id="agent-1",
166+
name="nightly",
167+
run_schedules_use_case=use_case,
168+
authorization=authorization,
169+
)
170+
171+
message = str(exc_info.value)
172+
assert message == "Run schedule 'nightly' for agent 'agent-1' does not exist."
173+
assert "secret-id" not in message
174+
use_case.get_schedule.assert_not_called()
175+
153176
async def test_get_denied_collapses_to_404_and_skips_use_case(self):
154177
authorization = MagicMock()
155178
authorization.check = AsyncMock(side_effect=AuthorizationError("denied"))

0 commit comments

Comments
 (0)