Files
kandev/docs/plans/task-lifecycle-authz-gaps/plan.md
T
2026-08-24 11:09:03 +00:00

146 lines
6.7 KiB
Markdown

---
spec: docs/specs/auth/requirements/auth.md
created: 2026-08-10
status: 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.UpdateTaskState` and `Service.MoveTaskWithOptions` start
at `s.tasks.GetTask` with no authorize call. Their WS actions (`task.state`,
`task.move`) name the task `id`, and the gateway backstop only parses `task_id`,
`session_id` and `task_environment_id`, so it finds no refs and allows the action.
- **Gap 3** — `TaskHandlers.httpDeleteTask` builds its context from
`context.Background()`. The identity the auth middleware put on the request is
dropped, so `authorizeTaskID` sees 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.ArchiveTask` at all. When a
`HandoffService` is wired — which `backendapp` always does — the handlers call
`HandoffService.DeleteTaskTree` / `ArchiveTaskTree`, and `HandoffService` has 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 has
`handoffSvc == 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 of `s.tasks.GetTask`. The destination
workflow needs no separate check: `validateTaskMove` already refuses a move
whose target workflow belongs to a different workspace.
- `UpdateRepositoryBaseBranch` — same, on `req.TaskID`. Its WS payload uses
`task_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:
```go
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:
```go
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's `id` rule) plus two
scenarios.
- `apps/backend/CLAUDE.md` — the scoping-model section gains the `id`-vs-`task_id`
rule 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.
## Tasks
| # | Task | Depends on |
|---|------|-----------|
| 01 | [Service-layer guards for state, move and base-branch](task-01-service-guards.md) | — |
| 02 | [HandoffService cascade authorization](task-02-handoff-cascade-authz.md) | — |
| 03 | [HTTP delete keeps the caller identity](task-03-http-delete-identity.md) | 02 |
| 04 | [WS backstop reads `id` on top-level `task.*` actions](task-04-ws-backstop-id.md) | — |