Skip to content

Commit 3009275

Browse files
committed
feat(AGX1-275): per-RPC task permission rewire and 404/403 wrap
Rewires the operation literal sent to agentex-auth on task RPC routes so each RPC checks the permission that actually matches its side effect, instead of using `execute` everywhere: - `MESSAGE_SEND` / `EVENT_SEND` -> `update` - `TASK_CANCEL` -> `cancel` - `TASK_CREATE` stays `create` - Unknown `AgentRPCMethod` values now raise `NotImplementedError` rather than falling through authz-free (defense-in-depth: a new RPC must be explicitly wired before it can dispatch). The same `execute -> update` swap is applied across `messages.py`, `checkpoints.py`, and `states.py` so the editor role can perform routine mutations without needing owner. The task SpiceDB schema defines `permission update = (editor + owner) & internal_tenant_gate`, so leaving these on `execute` (owner-only) would lock editors out of normal flows. Adds `check_task_or_collapse_to_404` in `src/utils/task_authorization.py` and routes every task-resource denial path through it: path id, query id, body id, and the name surface in `authorization_shortcuts.py`. `tasks.name` is globally unique, so a 403/404 split on the name route would let any authenticated caller probe the whole system for task existence — collapsing both denial cases into 404 closes that leak at the cost of an in-tenant UX regression on permission-gap updates (tracked under AGX1-290). The `MESSAGE_SEND` task-name branch is restructured to `try/else`: a denied update on an existing task must surface as 404 and NOT fall through to the create check, which would promote "denied update" into create access. Cross-repo wire dependency: the `update` and `cancel` literals must resolve against the existing OWNER grant in SGP's task permission schema before this deploys. `update` is already in SGP's `AgentexOperation` enum; `cancel` is added by scaleapi/scaleapi sgp-agentex-cancel-enum. Held behind that PR shipping everywhere. Part of the AGX1-264 stack: scaleapi/scaleapi#145000 (FGAC_AGENTEX_AUTH_SPARK flag) -> scaleapi/scaleapi sgp-agentex-cancel-enum (cancel enum on SGP backend) -> scaleapi/agentex#353 (agentex-auth per-account routing + cancel) -> #246 (task FGAC dual-write + audit columns) -> this PR.
1 parent 5d055f4 commit 3009275

7 files changed

Lines changed: 564 additions & 57 deletions

File tree

agentex/src/api/routes/agents.py

Lines changed: 39 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
DAuthorizedResourceIds,
3939
)
4040
from src.utils.logging import make_logger
41+
from src.utils.task_authorization import check_task_or_collapse_to_404
4142

4243
logger = make_logger(__name__)
4344

@@ -333,26 +334,31 @@ async def _authorize_rpc_request(
333334
task_name = request.params.task_name
334335

335336
if task_id is not None:
336-
# Direct task ID provided - check execute permission on that specific task
337-
await authorization_service.check(
338-
resource=AgentexResource.task(task_id),
339-
operation=AuthorizedOperationType.execute,
337+
await check_task_or_collapse_to_404(
338+
authorization_service,
339+
task_id,
340+
AuthorizedOperationType.update,
340341
)
341342
elif task_name is not None:
342-
# Task name provided - check if task exists
343+
# try/else (not try/except wrapping the whole block): a denied
344+
# update on an existing task must surface as 404 to the caller,
345+
# NOT silently fall through to the create check below — that
346+
# would let "I'm denied update" masquerade as "task is absent"
347+
# and grant create access.
343348
try:
344349
existing_task = await task_service.get_task(name=task_name)
345-
# Task exists - require execute permission on the specific task
346-
await authorization_service.check(
347-
resource=AgentexResource.task(existing_task.id),
348-
operation=AuthorizedOperationType.execute,
349-
)
350350
except ItemDoesNotExist:
351351
# Task doesn't exist - will be created, require create permission
352352
await authorization_service.check(
353353
resource=AgentexResource.task("*"),
354354
operation=AuthorizedOperationType.create,
355355
)
356+
else:
357+
await check_task_or_collapse_to_404(
358+
authorization_service,
359+
existing_task.id,
360+
AuthorizedOperationType.update,
361+
)
356362
else:
357363
# No identifier provided - creating new task, require create permission
358364
await authorization_service.check(
@@ -365,20 +371,22 @@ async def _authorize_rpc_request(
365371
task_name = request.params.task_name
366372

367373
if task_id is not None:
368-
# Direct task ID provided - check execute permission on that specific task
369-
await authorization_service.check(
370-
resource=AgentexResource.task(task_id),
371-
operation=AuthorizedOperationType.execute,
374+
await check_task_or_collapse_to_404(
375+
authorization_service,
376+
task_id,
377+
AuthorizedOperationType.update,
372378
)
373379
elif task_name is not None:
374-
# Task name provided - look up task and check execute permission
380+
# `get_task` raises ItemDoesNotExist for absent rows (= 404
381+
# naturally); the wrap collapses present-but-denied to the same
382+
# shape so callers can't distinguish.
375383
existing_task = await task_service.get_task(name=task_name)
376-
await authorization_service.check(
377-
resource=AgentexResource.task(existing_task.id),
378-
operation=AuthorizedOperationType.execute,
384+
await check_task_or_collapse_to_404(
385+
authorization_service,
386+
existing_task.id,
387+
AuthorizedOperationType.update,
379388
)
380389
else:
381-
# No identifier provided - this shouldn't happen but handle gracefully
382390
raise ValueError(
383391
"Either task_id or task_name must be provided for event/send"
384392
)
@@ -388,25 +396,27 @@ async def _authorize_rpc_request(
388396
task_name = request.params.task_name
389397

390398
if task_id is not None:
391-
# Direct task ID provided - check execute permission on that specific task
392-
await authorization_service.check(
393-
resource=AgentexResource.task(task_id),
394-
operation=AuthorizedOperationType.execute,
399+
await check_task_or_collapse_to_404(
400+
authorization_service,
401+
task_id,
402+
AuthorizedOperationType.cancel,
395403
)
396404
elif task_name is not None:
397-
# Task name provided - look up task and check execute permission
398405
existing_task = await task_service.get_task(name=task_name)
399-
await authorization_service.check(
400-
resource=AgentexResource.task(existing_task.id),
401-
operation=AuthorizedOperationType.execute,
406+
await check_task_or_collapse_to_404(
407+
authorization_service,
408+
existing_task.id,
409+
AuthorizedOperationType.cancel,
402410
)
403411
else:
404-
# No identifier provided - this shouldn't happen but handle gracefully
405412
raise ValueError(
406413
"Either task_id or task_name must be provided for task/cancel"
407414
)
408415
case _:
409-
pass
416+
raise NotImplementedError(
417+
f"_authorize_rpc_request has no case for {request.method}; "
418+
"RPC methods must be wired explicitly before they can dispatch."
419+
)
410420

411421

412422
async def _handle_agent_rpc(

agentex/src/api/routes/checkpoints.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
from fastapi import APIRouter, Response
44

5+
from src.api.schemas.authorization_types import (
6+
AgentexResourceType,
7+
AuthorizedOperationType,
8+
)
59
from src.api.schemas.checkpoints import (
610
BlobResponse,
711
CheckpointListItem,
@@ -14,10 +18,6 @@
1418
PutWritesRequest,
1519
WriteResponse,
1620
)
17-
from src.api.schemas.authorization_types import (
18-
AgentexResourceType,
19-
AuthorizedOperationType,
20-
)
2121
from src.domain.use_cases.checkpoints_use_case import DCheckpointsUseCase
2222
from src.utils.authorization_shortcuts import DAuthorizedBodyId
2323
from src.utils.logging import make_logger
@@ -95,7 +95,7 @@ async def put_checkpoint(
9595
request: PutCheckpointRequest,
9696
checkpoints_use_case: DCheckpointsUseCase,
9797
_authorized_task_id: DAuthorizedBodyId(
98-
AgentexResourceType.task, AuthorizedOperationType.execute, field_name="thread_id"
98+
AgentexResourceType.task, AuthorizedOperationType.update, field_name="thread_id"
9999
),
100100
) -> PutCheckpointResponse:
101101
blobs = [
@@ -133,7 +133,7 @@ async def put_writes(
133133
request: PutWritesRequest,
134134
checkpoints_use_case: DCheckpointsUseCase,
135135
_authorized_task_id: DAuthorizedBodyId(
136-
AgentexResourceType.task, AuthorizedOperationType.execute, field_name="thread_id"
136+
AgentexResourceType.task, AuthorizedOperationType.update, field_name="thread_id"
137137
),
138138
) -> Response:
139139
writes = [

agentex/src/api/routes/messages.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ async def batch_create_messages(
8282
request: BatchCreateTaskMessagesRequest,
8383
message_use_case: DMessageUseCase,
8484
_authorized_task_id: DAuthorizedBodyId(
85-
AgentexResourceType.task, AuthorizedOperationType.execute
85+
AgentexResourceType.task, AuthorizedOperationType.update
8686
),
8787
) -> list[TaskMessage]:
8888
# Convert each content from API schema to entity schema
@@ -110,7 +110,7 @@ async def batch_update_messages(
110110
request: BatchUpdateTaskMessagesRequest,
111111
message_use_case: DMessageUseCase,
112112
_authorized_task_id: DAuthorizedBodyId(
113-
AgentexResourceType.task, AuthorizedOperationType.execute
113+
AgentexResourceType.task, AuthorizedOperationType.update
114114
),
115115
) -> list[TaskMessage]:
116116
task_message_entities = await message_use_case.update_batch(
@@ -131,7 +131,7 @@ async def create_message(
131131
request: CreateTaskMessageRequest,
132132
message_use_case: DMessageUseCase,
133133
_authorized_task_id: DAuthorizedBodyId(
134-
AgentexResourceType.task, AuthorizedOperationType.execute
134+
AgentexResourceType.task, AuthorizedOperationType.update
135135
),
136136
) -> TaskMessage:
137137
task_message_entity = await message_use_case.create(
@@ -152,7 +152,7 @@ async def update_message(
152152
message_id: str,
153153
message_use_case: DMessageUseCase,
154154
_authorized_task_id: DAuthorizedBodyId(
155-
AgentexResourceType.task, AuthorizedOperationType.execute
155+
AgentexResourceType.task, AuthorizedOperationType.update
156156
),
157157
) -> TaskMessage:
158158
task_message_entity = await message_use_case.update(

agentex/src/api/schemas/authorization_types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class AuthorizedOperationType(StrEnum):
99
update = "update"
1010
delete = "delete"
1111
execute = "execute"
12+
cancel = "cancel"
1213

1314

1415
class AgentexResourceType(StrEnum):

agentex/src/utils/authorization_shortcuts.py

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from src.domain.repositories.task_repository import DTaskRepository
1414
from src.domain.repositories.task_state_repository import DTaskStateRepository
1515
from src.domain.services.authorization_service import DAuthorizationService
16+
from src.utils.task_authorization import check_task_or_collapse_to_404
1617

1718

1819
async def _get_parent_task_id(
@@ -51,12 +52,10 @@ async def _ensure_authorized_id(
5152
task_id = await _get_parent_task_id(
5253
resource_type, resource_id, event_repository, state_repository
5354
)
54-
await authorization.check(
55-
resource=AgentexResource.task(task_id),
56-
operation=operation,
57-
)
55+
await check_task_or_collapse_to_404(authorization, task_id, operation)
56+
elif resource_type == AgentexResourceType.task:
57+
await check_task_or_collapse_to_404(authorization, resource_id, operation)
5858
else:
59-
# For direct resources, check directly
6059
await authorization.check(
6160
resource=AgentexResource(type=resource_type, selector=resource_id),
6261
operation=operation,
@@ -88,12 +87,10 @@ async def _ensure_authorized_query(
8887
task_id = await _get_parent_task_id(
8988
resource_type, resource_id, event_repository, state_repository
9089
)
91-
await authorization.check(
92-
resource=AgentexResource.task(task_id),
93-
operation=operation,
94-
)
90+
await check_task_or_collapse_to_404(authorization, task_id, operation)
91+
elif resource_type == AgentexResourceType.task:
92+
await check_task_or_collapse_to_404(authorization, resource_id, operation)
9593
else:
96-
# For direct resources, check directly
9794
await authorization.check(
9895
resource=AgentexResource(type=resource_type, selector=resource_id),
9996
operation=operation,
@@ -118,10 +115,13 @@ async def _ensure_authorized_body_field(
118115
body = await request.json()
119116
field_value = body[field_name]
120117

121-
await authorization.check(
122-
resource=AgentexResource(type=resource_type, selector=field_value),
123-
operation=operation,
124-
)
118+
if resource_type == AgentexResourceType.task:
119+
await check_task_or_collapse_to_404(authorization, field_value, operation)
120+
else:
121+
await authorization.check(
122+
resource=AgentexResource(type=resource_type, selector=field_value),
123+
operation=operation,
124+
)
125125
return field_value
126126

127127
return Annotated[str, Depends(_ensure_authorized_body_field)]
@@ -164,12 +164,21 @@ async def _ensure_authorized_name(
164164
resource_id = resource_name
165165
repository = registry[resource_type]
166166

167+
# Lookup-before-authz: if the name isn't present, `repository.get` raises
168+
# ItemDoesNotExist (→ 404), which is what we want for absent resources.
169+
# The present-but-denied case is handled per-resource below.
167170
resource = await repository.get(name=resource_id)
168171

169-
await authorization.check(
170-
resource=AgentexResource(type=resource_type, selector=resource.id),
171-
operation=operation,
172-
)
172+
if resource_type == AgentexResourceType.task:
173+
# Tasks: collapse denial to 404 so name probes can't distinguish
174+
# "present in another tenant" from "absent" (tasks.name is globally
175+
# unique — any 403 leak here probes the whole system, not a tenant).
176+
await check_task_or_collapse_to_404(authorization, resource.id, operation)
177+
else:
178+
await authorization.check(
179+
resource=AgentexResource(type=resource_type, selector=resource.id),
180+
operation=operation,
181+
)
173182
return resource_id
174183

175184
return Annotated[str, Depends(_ensure_authorized_name)]
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from src.adapters.authorization.exceptions import AuthorizationError
2+
from src.adapters.crud_store.exceptions import ItemDoesNotExist
3+
from src.api.schemas.authorization_types import (
4+
AgentexResource,
5+
AuthorizedOperationType,
6+
)
7+
from src.domain.services.authorization_service import AuthorizationService
8+
9+
10+
async def check_task_or_collapse_to_404(
11+
authorization: AuthorizationService,
12+
task_id: str,
13+
operation: AuthorizedOperationType,
14+
) -> None:
15+
"""Issue a check on a task resource. On any denial, surface 404 — even when
16+
the task exists.
17+
18+
The 403/404 split cannot be done safely here: ``TaskORM`` has no tenant
19+
column, ``task_repository.get`` does an unfiltered primary-key lookup, and
20+
``AuthorizationError`` carries no deny-reason discriminator. Returning 403
21+
when the row exists and 404 when it doesn't leaks cross-tenant existence
22+
(caller probes "does task X exist?" and gets distinguishable responses).
23+
24+
Until tasks carry tenant scope (or agentex-auth's deny distinguishes
25+
"cross-tenant" from "in-tenant lacking permission"), the safer default is
26+
to collapse both into 404. Trade-off: a user with ``read`` but not
27+
``update`` permission on an in-tenant task sees 404 on update attempts
28+
instead of 403. UX regression for in-tenant permission granularity, but
29+
eliminates the cross-tenant existence leak.
30+
31+
TODO(AGX1-290): Restore the 403/404 split for same-tenant calls once
32+
tasks carry tenant/account_id scope at the data layer.
33+
"""
34+
try:
35+
await authorization.check(
36+
resource=AgentexResource.task(task_id),
37+
operation=operation,
38+
)
39+
except AuthorizationError:
40+
raise ItemDoesNotExist(f"Item with id '{task_id}' does not exist.") from None

0 commit comments

Comments
 (0)