Skip to content

feat(AGX1-277): enforce parent-task authz on GET /messages/{message_id}#255

Open
harvhan wants to merge 2 commits into
mainfrom
harvhan/AGX1-277-message-route-fgac
Open

feat(AGX1-277): enforce parent-task authz on GET /messages/{message_id}#255
harvhan wants to merge 2 commits into
mainfrom
harvhan/AGX1-277-message-route-fgac

Conversation

@harvhan
Copy link
Copy Markdown

@harvhan harvhan commented May 28, 2026

Linear Issue

AGX1-277 — Parent epic: AGX1-264.

Summary

Guard GET /messages/{message_id} (previously unauthorized) by resolving the message to its task_id and requiring task.read on the parent task. Wires this through the existing TaskChildResourceType pattern — message joins event and state so the standard DAuthorizedId(...) machinery handles it.

Collapses task-child auth denials to 404 (ItemDoesNotExist) across path-id and query-id surfaces so callers cannot probe cross-tenant existence by comparing 403 vs 404.

Corrects the misleading TaskMessage | None return type on get_message (use case + route) — the underlying service already raised ItemDoesNotExist; the None branch was dead code.

Changes

  • src/api/schemas/authorization_types.py — add message to TaskChildResourceType.
  • src/utils/authorization_shortcuts.py — thread DTaskMessageRepository through _get_parent_task_id and inject message_repository into the _ensure_authorized_id / _ensure_authorized_query deps. Wrap the task-child check in try/except AuthorizationError → ItemDoesNotExist to collapse denial to 404 on both surfaces.
  • src/api/routes/messages.py — add DAuthorizedId(TaskChildResourceType.message, AuthorizedOperationType.read, ...) to get_message; drop the dead else None branch and tighten the return type.
  • src/domain/use_cases/messages_use_case.py — fix get_message return annotation from TaskMessageEntity | None to TaskMessageEntity.
  • tests/integration/api/messages/test_messages_authz_api.py (new) — integration tests covering the auth matrix.

Behavior change beyond the ticket

The 404 collapse extends to event and state child routes as well, since the TaskChildResourceType branch is shared. The cross-tenant-leak argument is the same for those resources. Aligns with the direction AGX1-275 takes more broadly.

Out of scope

  • POST/PUT message routes still surface 403 on deny. They use DAuthorizedBodyId(task, execute) and will be rewritten to task.update + 404-collapse by AGX1-275.
  • RPC message/send similarly handled by AGX1-275.
  • The agent-API-key path (request.state.agent_identity) still bypasses authz via AuthorizationService._bypass(). Tracked separately in AGX1-305.

Coordination

Will conflict with AGX1-275 (#249) on the _ensure_authorized_id / _ensure_authorized_query bodies — that PR introduces a shared _check_task_or_collapse_to_404 helper in src/utils/task_authorization.py that replaces the inline try/except wraps in this PR. Trivial 3-way merge regardless of merge order; the inline wraps in this PR get replaced by the canonical helper call when the two land together.

Test Plan

  • New: tests/integration/api/messages/test_messages_authz_api.py — 4/4 passing (authorized GET → 200, denied GET → 404 via collapse, non-existent message → 404 with explicit assertion that no authz call fires, authorized list happy-path).
  • Regression: tests/integration/api/messages/test_messages_api.py — 19/19 passing.
  • Regression: tests/integration/api/events/ — 3/3 passing (exercises the changed _get_parent_task_id signature on the event path).
  • Regression: tests/integration/api/states/ — 7/7 passing (same, for state).
  • Regression: tests/integration/api/agents/test_agents_auth_api.py — 3/3 passing (exercises the changed _ensure_authorized_id/_ensure_authorized_query factories on a direct-resource path).
  • Ruff lint + format clean across all changed files.

Guard GET /messages/{message_id} (previously unauthorized) by resolving
the message to its parent task_id and checking task.read via the
existing TaskChildResourceType pattern — message joins event and state.

Collapse task-child auth denials to 404 across path-id and query-id
surfaces so callers cannot probe cross-tenant existence by comparing
403 vs 404.

Correct the misleading TaskMessage | None on get_message — the
underlying service already raised ItemDoesNotExist; the None branch was
dead code.

Integration tests cover the auth matrix on /messages/{message_id} plus
an authorized list happy-path. POST/PUT routes and the agent-API-key
bypass are out of scope (handled in AGX1-275 and tracked separately).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@harvhan harvhan requested a review from a team as a code owner May 28, 2026 21:10
Match the naming convention used for state_repository (DTaskStateRepository)
and event_repository (DEventRepository) where the local parameter name
drops the "task_" prefix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@harvhan harvhan changed the title feat(AGX1-277): enforce task.read on message GET via Spark AuthZ feat(AGX1-277): enforce parent-task authz on GET /messages/{message_id} May 29, 2026
dm36 added a commit that referenced this pull request May 29, 2026
…Id/Query

CI flagged my denied-list test: it expected 404 but got 403, because the
direct-resource branch of _ensure_authorized_id / _ensure_authorized_query
didn't have the AuthorizationError -> ItemDoesNotExist collapse that the
child-resource branches got. Inconsistent — the events GET endpoint
returns 404 on denial (via AgentChildResourceType branch) while the
events LIST endpoint returns 403 (via direct-resource branch on agent_id).

Same security argument as for child resources: a 403/404 split on a
direct resource leaks existence of that resource across tenants. Aligns
with Harvey's #255 direction.

Risk audit: only one existing test asserts 403, and it's about a missing
api-key header (authentication, not authorization). All existing 4xx
assertions on direct-resource routes are already 404 (no-row-exists), not
403. So no test regressions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
dm36 added a commit that referenced this pull request May 29, 2026
Per Asher's review: the previous commit (5deda18) wrapped every direct-resource
DAuthorizedId/DAuthorizedQuery check in AuthorizationError -> ItemDoesNotExist,
which flipped 403 to 404 for every direct-resource route service-wide. That
diverges from #249 (tasks) and #255 (task-children) which keep direct-resource
denials as 403.

Restore the else branch to a bare authorization.check() in both helpers.
Only child-resource branches (TaskChildResourceType, AgentChildResourceType)
collapse to 404, which is the intended scope: the parent-resolution path
already loads the child to find its parent, so 404 is the natural shape.

Update the events list test to assert 403 for an unauthorized agent_id
(consistent with the direct-resource convention).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
dm36 added a commit that referenced this pull request May 29, 2026
…ceType

Per Asher's review: the AgentChildResourceType branch in DAuthorizedId/
DAuthorizedQuery added an extra parent-resolution hop and a new resource-
type family compared to #249 (tasks) and #255 (task-children), both of
which only special-case task/task-children.

Mirror #249's shape exactly:

- Add agent_authorization.check_agent_or_collapse_to_404, parallel to
  task_authorization.check_task_or_collapse_to_404 from #249. Same
  docstring rationale (no tenant column on AgentORM, denial reason not
  discriminable, so collapse to 404 to prevent cross-tenant existence
  leak; TODO references AGX1-290).
- Delete AgentChildResourceType enum.
- Delete _get_parent_agent_id and the elif branch in both shortcuts.
- Drop DEventRepository injection from the shortcuts (was only used by
  the deleted branch).
- Rewrite get_event to do event_use_case.get(...) -> agent check inline,
  using the helper. Identical wire behavior (single /v1/authz/check on
  the parent agent, denial -> 404) but no new abstraction.

Net: shortcuts file is now structurally identical to main; events.py
follows the same pattern Harvey/Asher established for direct/child task
routes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
dm36 added a commit that referenced this pull request May 29, 2026
Mirror Harvey's #255 inline pattern exactly in the task-child branch of
DAuthorizedId/DAuthorizedQuery — same imports, same comment, same
try/except shape. Direct-resource else branch stays as plain check (403),
matching both #249 and #255.

Net diff vs main is consistent with #255's shape: collapse for
task-children, plain check for direct resources. Only delta-from-#255
is this PR's own scope (removing event from TaskChildResourceType and
DEventRepository injection, since events delegate to the parent agent
instead).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants