6.7 KiB
spec, created, status
| spec | created | status |
|---|---|---|
| docs/specs/auth/requirements/auth.md | 2026-08-10 | building |
Implementation Plan: Task Lifecycle Authorization Gaps
Overview
Four per-user authorization holes let one authenticated user mutate another user's
task by naming its ID. All four share one root cause: the entry point never asks
authorizeTaskID, and the structural backstops that would have caught it do not
see the request.
- Gaps 1 & 2 —
Service.UpdateTaskStateandService.MoveTaskWithOptionsstart ats.tasks.GetTaskwith no authorize call. Their WS actions (task.state,task.move) name the taskid, and the gateway backstop only parsestask_id,session_idandtask_environment_id, so it finds no refs and allows the action. - Gap 3 —
TaskHandlers.httpDeleteTaskbuilds its context fromcontext.Background(). The identity the auth middleware put on the request is dropped, soauthorizeTaskIDsees an identity-less internal caller and permits everything. - Gap 4 (found while verifying gap 3's fix) — the production delete/archive path
does not reach
Service.DeleteTask/Service.ArchiveTaskat all. When aHandoffServiceis wired — whichbackendappalways does — the handlers callHandoffService.DeleteTaskTree/ArchiveTaskTree, andHandoffServicehas no identity awareness whatsoever. Fixing gap 3's context alone would leave the shipped delete route wide open; the fixture in which gap 3 was reproduced hashandoffSvc == nil, which is why the sibling archive route looked correct.
Layering matters here. The service guards are the fix; the gateway backstop is a
structural net that makes the next task.* action safe by default rather than
only when its author remembers. Per apps/backend/CLAUDE.md neither substitutes for
the other, so both land.
Four tasks. 01 and 04 are independent; 02 is meaningless without 03 (the production route bypasses the guarded service), so they land together in that order.
Backend
Service layer (internal/task/service/service_workflow.go)
authorizeTaskID first, before any repo, executor or event-bus use — matching
UpdateTaskMetadata (service_tasks.go:1333) and deleteTaskWithReason
(service_tasks.go:1911):
UpdateTaskState—if err := s.authorizeTaskID(ctx, id); err != nil { return nil, err }MoveTaskWithOptions— same, ahead ofs.tasks.GetTask. The destination workflow needs no separate check:validateTaskMovealready refuses a move whose target workflow belongs to a different workspace.UpdateRepositoryBaseBranch— same, onreq.TaskID. Its WS payload usestask_id, so the gateway backstop does cover it today; the check lands anyway because a backstop is not a substitute for a service-level guard and the method is reachable from HTTP and MCP too.
Denials return the existing repoerrors.ErrTaskNotFound sentinel. No handler
change: wsUpdateTaskState and wsMoveTask already collapse every service error
to a generic ErrorCodeInternalError message, which is exactly what a nonexistent
task produces — the denial is indistinguishable from "no such task".
Cascade layer (internal/task/service/handoff_service.go, handoff_cascade.go)
Mirror the orchestrator's checker pattern (internal/orchestrator/service.go:982-1021)
rather than inventing a second one:
func (s *HandoffService) SetTaskAccessChecker(check func(ctx context.Context, taskID string) error)
func (s *HandoffService) authorizeTask(ctx context.Context, taskID string) error // no-op when unwired
Called first in ArchiveTaskTree, DeleteTaskTree and UnarchiveTaskTree — before
the rootID == "" and s.tasks == nil guards do their repo work. Installed by
TaskHandlers.SetHandoffService, which is the call that makes those routes prefer
the cascade over the guarded Service methods, so the substitution cannot happen
without the guard.
Unwired stays unscoped, so every existing fixture that builds a bare
NewHandoffService(repo, nil, nil, nil, nil, nil) is unaffected.
HTTP layer (internal/task/handlers/task_http_handlers.go)
httpDeleteTask becomes:
deleteCtx, cancel := context.WithTimeout(
context.WithoutCancel(c.Request.Context()), constants.TaskDeleteTimeout)
WithoutCancel keeps the request's values — the identity among them — while
detaching from the request's cancellation, so a client that disconnects mid-delete
cannot abort a half-finished subtree teardown. Swapping in c.Request.Context()
directly would fix the identity and reintroduce that abort.
WS gateway backstop (internal/gateway/websocket/dispatch_scope.go)
authorizeAction gains the action name and one rule: for an action of the form
task.<verb> — exactly two dot-separated segments — a payload id is a task ID.
That covers task.get/update/delete/move/state/archive and any future sibling,
without a hand-maintained list to fall out of date.
Deeper namespaces are deliberately excluded: task.plan.revision.get and
task.review.finding.update also carry id, but it names a revision or a finding,
and treating it as a task ID would deny legitimate reads. task.create and
task.list carry no id and are unaffected.
Docs
docs/specs/auth/requirements/auth.md— the behavioral guarantee (lifecycle mutations are ownership-checked below the transport; the backstop'sidrule) plus two scenarios.apps/backend/CLAUDE.md— the scoping-model section gains theid-vs-task_idrule so the next author knows which payload names are covered.
Testing
TDD throughout: denial test first, watched fail, then the guard.
Every denial test also issues the identical request as the owner and asserts it succeeds. Without that witness a denial test passes for the wrong reason — a broken fixture, a typo'd ID, a missing seed row — and several tests in this area were vacuous for exactly that reason. Every denied mutation additionally asserts the repository recorded no write; a guard that refuses the response after committing the write is still data loss.
New symbols in internal/task/handlers are authz-prefixed so they cannot collide
with the fixtures PR #2500 adds to the same package while it is unmerged.
Mutation check: each guard is reverted one at a time and the new test must fail and name itself.