fix(backend): scope task state, move and delete to the calling user (#2503)

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-08-10 22:23:03 +02:00
committed by GitHub
parent 58b18bcb70
commit fbb04aed92
21 changed files with 1462 additions and 24 deletions
+1 -1
View File
@@ -180,7 +180,7 @@ and can leak ACP subprocesses.
- **Workspaces are per-user** (`workspaces.owner_id`); the task service filters/denies at the service layer with `*NotFound` sentinels (no existence leak). Unowned rows (`owner_id=''`) stay visible until the setup wizard claims them for the admin. - **Workspaces are per-user** (`workspaces.owner_id`); the task service filters/denies at the service layer with `*NotFound` sentinels (no existence leak). Unowned rows (`owner_id=''`) stay visible until the setup wizard claims them for the admin.
- **New user-facing service entry points must apply scoping** — call the `authorize*` helpers in `task/service/service_access.go` (or the same pattern) when adding routes that read or mutate workspace-scoped data. This includes **session-keyed** entry points: any service method taking a caller-supplied `sessionID` (or a `taskID` used to reach sessions) authorizes via `authorizeTaskID(session.TaskID)`. Authorize off the row you already read rather than calling `AuthorizeSessionAccess`, which re-reads the session. Batch helpers that take ID *lists* (`BatchGetSessionsForTasks`, `GetPrimarySessionInfoForTasks`, …) are exempt by convention: their callers derive the IDs from an already-authorized task list, so per-ID checks would add N queries to list views for no gain — keep it that way, and don't hand them caller-supplied IDs. - **New user-facing service entry points must apply scoping** — call the `authorize*` helpers in `task/service/service_access.go` (or the same pattern) when adding routes that read or mutate workspace-scoped data. This includes **session-keyed** entry points: any service method taking a caller-supplied `sessionID` (or a `taskID` used to reach sessions) authorizes via `authorizeTaskID(session.TaskID)`. Authorize off the row you already read rather than calling `AuthorizeSessionAccess`, which re-reads the session. Batch helpers that take ID *lists* (`BatchGetSessionsForTasks`, `GetPrimarySessionInfoForTasks`, …) are exempt by convention: their callers derive the IDs from an already-authorized task list, so per-ID checks would add N queries to list views for no gain — keep it that way, and don't hand them caller-supplied IDs.
- **Two session paths bypass the task service and must guard themselves.** (1) Handlers that resolve an execution by a bare in-memory lookup (`GetExecutionBySessionID`, `*BySessionID`) skip the `GetOrEnsure*` chokepoint where the lifecycle check runs — call `service.AuthorizeSessionAccess` (see `ProcessHandlers.denySessionAccess`) or `lifecycleMgr.CheckSessionAccess` first. (2) The orchestrator reads sessions through its own repo handle, so it inherits nothing from the task service; session-keyed entry points there call `authorizeSession` / `authorizeTask`, wired by `SetSessionAccessChecker` / `SetTaskAccessChecker`. An entry point that accepts **both** a task and a session ID must use `authorizeTaskSessionPair`, never `authorizeSession` alone: the caller can satisfy a session-only check with one of their own sessions while pointing `taskID` at someone else's task, which the method then uses for its task-scoped work. Put the guard **first** in the method, before any repo/executor/agent-manager use — `TestSessionKeyedEntryPointsGuardBeforeDependencies` runs each entry point with nil dependencies and fails on a panic if a guard is placed too late. - **Two session paths bypass the task service and must guard themselves.** (1) Handlers that resolve an execution by a bare in-memory lookup (`GetExecutionBySessionID`, `*BySessionID`) skip the `GetOrEnsure*` chokepoint where the lifecycle check runs — call `service.AuthorizeSessionAccess` (see `ProcessHandlers.denySessionAccess`) or `lifecycleMgr.CheckSessionAccess` first. (2) The orchestrator reads sessions through its own repo handle, so it inherits nothing from the task service; session-keyed entry points there call `authorizeSession` / `authorizeTask`, wired by `SetSessionAccessChecker` / `SetTaskAccessChecker`. An entry point that accepts **both** a task and a session ID must use `authorizeTaskSessionPair`, never `authorizeSession` alone: the caller can satisfy a session-only check with one of their own sessions while pointing `taskID` at someone else's task, which the method then uses for its task-scoped work. Put the guard **first** in the method, before any repo/executor/agent-manager use — `TestSessionKeyedEntryPointsGuardBeforeDependencies` runs each entry point with nil dependencies and fails on a panic if a guard is placed too late.
- **Dispatched WS actions have a gateway backstop.** `Client.authorizeAction` (`internal/gateway/websocket/dispatch_scope.go`) checks any payload carrying `task_id` or `session_id` against the client's identity *before* dispatch, so a newly added action is scoped by default rather than only when its author remembers. Handler-level scoping is still required (defense in depth, and it covers non-WS callers) — do not delete a service-layer check because the backstop exists. The backstop reads `task_id`, `session_id` and `task_environment_id` — keep using those names for task-scoped actions, because an action that invents a new name for the same kind of resource silently opts out. (`user_shell.stop` was exactly that: it keys off `task_environment_id` with `task_id` optional, so a `task_id`-only backstop missed it.) Also avoid permissive types (`any`, `json.RawMessage`) for those fields: a payload that fails to unmarshal into the ref struct is treated as naming nothing. - **Dispatched WS actions have a gateway backstop.** `Client.authorizeAction` (`internal/gateway/websocket/dispatch_scope.go`) checks any payload carrying `task_id` or `session_id` against the client's identity *before* dispatch, so a newly added action is scoped by default rather than only when its author remembers. Handler-level scoping is still required (defense in depth, and it covers non-WS callers) — do not delete a service-layer check because the backstop exists. The backstop reads `task_id`, `session_id` and `task_environment_id`, plus `id` on **top-level `task.<verb>` actions only** (`task.state`, `task.move`, `task.get`, … — `task.` and exactly one more segment). Keep using those names for task-scoped actions, because an action that invents a new name for the same kind of resource silently opts out. (`user_shell.stop` was exactly that: it keys off `task_environment_id` with `task_id` optional, so a `task_id`-only backstop missed it. `task.state` and `task.move` were the second instance: they name the task `id`, so the backstop parsed no refs and let one user mutate any other user's task — hence the `id` rule.) Deeper `task.*` namespaces are deliberately excluded: in `task.plan.revision.get` and `task.review.finding.update` the `id` names a revision or a finding, so those actions must carry `task_id` when they are task-scoped. Also avoid permissive types (`any`, `json.RawMessage`) for those fields: a payload that fails to unmarshal into the ref struct is treated as naming nothing.
- **WS**: clients carry their identity; dispatched actions and subscriptions are scoped; workspace-carrying events route via `Hub.BroadcastToWorkspace`. A new `hub.Broadcast` (global) call site needs a `//ws:global` justification comment. - **WS**: clients carry their identity; dispatched actions and subscriptions are scoped; workspace-carrying events route via `Hub.BroadcastToWorkspace`. A new `hub.Broadcast` (global) call site needs a `//ws:global` justification comment.
- **Self-authenticating webhooks** (automation, office channels, plugin webhooks) and `/health`, `/api/v1/features`, `/api/v1/app-state` stay public — the allowlist lives in `auth/httpmw/middleware.go` with a pinning test. - **Self-authenticating webhooks** (automation, office channels, plugin webhooks) and `/health`, `/api/v1/features`, `/api/v1/app-state` stay public — the allowlist lives in `auth/httpmw/middleware.go` with a pinning test.
@@ -599,6 +599,9 @@ func registerRoutes(p routeParams) {
// the kanban board doesn't react to subtree archive/delete until a // the kanban board doesn't react to subtree archive/delete until a
// full reload. // full reload.
handoffSvc.SetTaskEventPublisher(p.taskSvc) handoffSvc.SetTaskEventPublisher(p.taskSvc)
// Per-user scoping for the cascade is installed by
// TaskHandlers.SetHandoffService, which is the call that makes the archive /
// delete routes prefer the cascade over the guarded Service methods.
if p.services.Office != nil { if p.services.Office != nil {
p.services.Office.SetWorkspaceGroupCleaner(handoffSvc) p.services.Office.SetWorkspaceGroupCleaner(handoffSvc)
} }
@@ -237,7 +237,7 @@ func (c *Client) handleMessage(msg *ws.Message) {
// Per-user scoping backstop: refuse before the handler runs if the payload // Per-user scoping backstop: refuse before the handler runs if the payload
// names a task or session this client may not touch. See dispatch_scope.go // names a task or session this client may not touch. See dispatch_scope.go
// for why this lives here and not only in each handler. // for why this lives here and not only in each handler.
if err := c.authorizeAction(dispatchCtx, msg.Payload); err != nil { if err := c.authorizeAction(dispatchCtx, msg.Action, msg.Payload); err != nil {
c.logger.Debug("denied out-of-scope action", c.logger.Debug("denied out-of-scope action",
zap.String("action", msg.Action), zap.String("action", msg.Action),
zap.String("user_id", c.identity.UserID), zap.String("user_id", c.identity.UserID),
@@ -3,6 +3,7 @@ package websocket
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"strings"
"github.com/kandev/kandev/internal/auth/authn" "github.com/kandev/kandev/internal/auth/authn"
) )
@@ -26,16 +27,22 @@ import (
// scopedActionRefs are the payload fields that name a per-user resource. // scopedActionRefs are the payload fields that name a per-user resource.
// //
// All three names must stay in sync with what handlers actually send. The // All four names must stay in sync with what handlers actually send. The
// user-shell actions key off task_environment_id and treat task_id as // user-shell actions key off task_environment_id and treat task_id as
// optional, so checking only task_id let `user_shell.stop` tear down another // optional, so checking only task_id let `user_shell.stop` tear down another
// user's terminal by naming their environment with task_id empty. An action // user's terminal by naming their environment with task_id empty. An action
// that invents a fourth name for the same kind of resource silently opts out // that invents a fifth name for the same kind of resource silently opts out
// of this backstop — see AGENTS.md. // of this backstop — see AGENTS.md.
//
// ID is the same escape hatch found a second time: task.state and task.move
// name their task `id`, so a task_id-only backstop parsed no refs and let one
// user mutate any other user's task. It is read only for top-level task.<verb>
// actions — see isTopLevelTaskAction.
type scopedActionRefs struct { type scopedActionRefs struct {
TaskID string `json:"task_id"` TaskID string `json:"task_id"`
SessionID string `json:"session_id"` SessionID string `json:"session_id"`
TaskEnvironmentID string `json:"task_environment_id"` TaskEnvironmentID string `json:"task_environment_id"`
ID string `json:"id"`
} }
// authorizeAction reports why a dispatched action must be refused, or nil when // authorizeAction reports why a dispatched action must be refused, or nil when
@@ -44,7 +51,7 @@ type scopedActionRefs struct {
// No-op for connections without a real identity: identity-less internal // No-op for connections without a real identity: identity-less internal
// clients and the synthetic identity injected while auth is disabled keep the // clients and the synthetic identity injected while auth is disabled keep the
// pre-auth see-everything behavior, exactly as callerScope does server-side. // pre-auth see-everything behavior, exactly as callerScope does server-side.
func (c *Client) authorizeAction(ctx context.Context, payload json.RawMessage) error { func (c *Client) authorizeAction(ctx context.Context, action string, payload json.RawMessage) error {
if c.identity.UserID == "" || c.identity.Synthetic { if c.identity.UserID == "" || c.identity.Synthetic {
return nil return nil
} }
@@ -52,14 +59,18 @@ func (c *Client) authorizeAction(ctx context.Context, payload json.RawMessage) e
if !ok { if !ok {
return nil return nil
} }
taskID := refs.TaskID
if taskID == "" && isTopLevelTaskAction(action) {
taskID = refs.ID
}
policy := c.hub.authPolicy policy := c.hub.authPolicy
if refs.SessionID != "" && policy.Subscriptions.Session != nil { if refs.SessionID != "" && policy.Subscriptions.Session != nil {
if err := policy.Subscriptions.Session(ctx, refs.SessionID); err != nil { if err := policy.Subscriptions.Session(ctx, refs.SessionID); err != nil {
return err return err
} }
} }
if refs.TaskID != "" && policy.Subscriptions.Task != nil { if taskID != "" && policy.Subscriptions.Task != nil {
if err := policy.Subscriptions.Task(ctx, refs.TaskID); err != nil { if err := policy.Subscriptions.Task(ctx, taskID); err != nil {
return err return err
} }
} }
@@ -83,7 +94,25 @@ func parseScopedActionRefs(payload json.RawMessage) (scopedActionRefs, bool) {
// Non-object payloads (arrays, bare values) name no resource. // Non-object payloads (arrays, bare values) name no resource.
return scopedActionRefs{}, false return scopedActionRefs{}, false
} }
return refs, refs.TaskID != "" || refs.SessionID != "" || refs.TaskEnvironmentID != "" return refs, refs.TaskID != "" || refs.SessionID != "" || refs.TaskEnvironmentID != "" || refs.ID != ""
}
// isTopLevelTaskAction reports whether an action's payload `id` names a task.
//
// True for "task." plus exactly one more segment — task.get, task.update,
// task.delete, task.move, task.state, task.archive, and any future sibling.
// Deliberately a rule about namespace depth rather than a list of action names:
// a list has the same "someone forgot to add it" failure mode this backstop
// exists to remove.
//
// Deeper namespaces are excluded because their `id` names something else:
// task.plan.revision.get carries a revision ID and task.review.finding.update a
// finding ID, so checking either as a task ID would deny legitimate calls.
// Those actions carry task_id when they are task-scoped.
func isTopLevelTaskAction(action string) bool {
const prefix = "task."
rest, found := strings.CutPrefix(action, prefix)
return found && !strings.Contains(rest, ".")
} }
// identityIsScoped reports whether this client's identity participates in // identityIsScoped reports whether this client's identity participates in
@@ -18,8 +18,9 @@ import (
// dispatchScopeFixture wires a hub whose auth policy grants only user-a's // dispatchScopeFixture wires a hub whose auth policy grants only user-a's
// task-a / sess-a and denies everything else. // task-a / sess-a and denies everything else.
type dispatchScopeFixture struct { type dispatchScopeFixture struct {
client *Client client *Client
handled *bool handled *bool
dispatcher *ws.Dispatcher
} }
func newDispatchScopeFixture(t *testing.T, identity authn.Identity) *dispatchScopeFixture { func newDispatchScopeFixture(t *testing.T, identity authn.Identity) *dispatchScopeFixture {
@@ -65,7 +66,22 @@ func newDispatchScopeFixture(t *testing.T, identity authn.Identity) *dispatchSco
c := newTestClient("c-scope") c := newTestClient("c-scope")
c.hub = h c.hub = h
c.identity = identity c.identity = identity
return &dispatchScopeFixture{client: c, handled: &handled} return &dispatchScopeFixture{client: c, handled: &handled, dispatcher: dispatcher}
}
// dispatchAs sends payload under a real action name, so tests can exercise the
// rules that key off the action rather than only the payload.
func (f *dispatchScopeFixture) dispatchAs(t *testing.T, action, payload string) {
t.Helper()
f.dispatcher.RegisterFunc(action, func(context.Context, *ws.Message) (*ws.Message, error) {
*f.handled = true
return nil, nil
})
f.client.handleMessage(&ws.Message{
Action: action,
Type: ws.MessageTypeRequest,
Payload: json.RawMessage(payload),
})
} }
func (f *dispatchScopeFixture) dispatch(t *testing.T, payload string) { func (f *dispatchScopeFixture) dispatch(t *testing.T, payload string) {
@@ -198,25 +214,30 @@ func TestParseScopedActionRefs(t *testing.T) {
wantTask string wantTask string
wantSess string wantSess string
wantEnv string wantEnv string
wantID string
}{ }{
{`{"task_id":"t","session_id":"s"}`, true, "t", "s", ""}, {`{"task_id":"t","session_id":"s"}`, true, "t", "s", "", ""},
{`{"task_id":"t"}`, true, "t", "", ""}, {`{"task_id":"t"}`, true, "t", "", "", ""},
{`{"session_id":"s"}`, true, "", "s", ""}, {`{"session_id":"s"}`, true, "", "s", "", ""},
{`{"task_environment_id":"e"}`, true, "", "", "e"}, {`{"task_environment_id":"e"}`, true, "", "", "e", ""},
{`{"task_id":"","task_environment_id":"e"}`, true, "", "", "e"}, {`{"task_id":"","task_environment_id":"e"}`, true, "", "", "e", ""},
{`{}`, false, "", "", ""}, {`{"id":"t"}`, true, "", "", "", "t"},
{`{"task_id":""}`, false, "", "", ""}, {`{"id":"t","state":"COMPLETED"}`, true, "", "", "", "t"},
{`[1,2]`, false, "", "", ""}, {`{}`, false, "", "", "", ""},
{``, false, "", "", ""}, {`{"task_id":""}`, false, "", "", "", ""},
{`{"id":""}`, false, "", "", "", ""},
{`[1,2]`, false, "", "", "", ""},
{``, false, "", "", "", ""},
} }
for _, tc := range cases { for _, tc := range cases {
refs, ok := parseScopedActionRefs(json.RawMessage(tc.payload)) refs, ok := parseScopedActionRefs(json.RawMessage(tc.payload))
if ok != tc.wantOK { if ok != tc.wantOK {
t.Errorf("payload %q ok = %v, want %v", tc.payload, ok, tc.wantOK) t.Errorf("payload %q ok = %v, want %v", tc.payload, ok, tc.wantOK)
} }
if refs.TaskID != tc.wantTask || refs.SessionID != tc.wantSess || refs.TaskEnvironmentID != tc.wantEnv { if refs.TaskID != tc.wantTask || refs.SessionID != tc.wantSess ||
t.Errorf("payload %q refs = %+v, want task=%q session=%q env=%q", refs.TaskEnvironmentID != tc.wantEnv || refs.ID != tc.wantID {
tc.payload, refs, tc.wantTask, tc.wantSess, tc.wantEnv) t.Errorf("payload %q refs = %+v, want task=%q session=%q env=%q id=%q",
tc.payload, refs, tc.wantTask, tc.wantSess, tc.wantEnv, tc.wantID)
} }
} }
} }
@@ -267,3 +288,107 @@ func TestDispatchScopeAllowsOwnEnvironment(t *testing.T) {
t.Error("handler did not run for the caller's own task environment") t.Error("handler did not run for the caller's own task environment")
} }
} }
// A top-level task.<verb> action names its task `id`, not `task_id` — which is
// how task.state and task.move mutated any user's task with the backstop finding
// no refs to check and allowing the dispatch.
//
// The rule keys off namespace depth rather than a list of action names, so a
// future task.<verb> is covered without an edit. Deeper namespaces are excluded
// on purpose: their `id` names a plan, revision or finding, and checking it as a
// task ID would deny legitimate reads — see the allow cases below.
func TestDispatchScopeDeniesForeignTaskByIDOnTopLevelTaskActions(t *testing.T) {
for _, action := range []string{ws.ActionTaskState, ws.ActionTaskMove, ws.ActionTaskArchive, ws.ActionTaskGet} {
t.Run(action, func(t *testing.T) {
f := newDispatchScopeFixture(t, realIdentity())
f.dispatchAs(t, action, `{"id":"task-b","state":"COMPLETED"}`)
if *f.handled {
t.Errorf("handler ran for %s naming a foreign task by id", action)
}
})
}
}
func TestDispatchScopeAllowsOwnTaskByIDOnTopLevelTaskActions(t *testing.T) {
f := newDispatchScopeFixture(t, realIdentity())
f.dispatchAs(t, ws.ActionTaskState, `{"id":"task-a","state":"COMPLETED"}`)
if !*f.handled {
t.Error("the owner's own task.state was denied; the rule must not be a blanket deny")
}
}
// TestDispatchScopeIgnoresIDOnNestedTaskActions is the regression this rule
// could plausibly cause: in task.plan.* and task.review.* the `id` is a plan,
// revision or finding ID, and treating it as a task ID would refuse every read.
func TestDispatchScopeIgnoresIDOnNestedTaskActions(t *testing.T) {
nested := map[string]string{
ws.ActionTaskPlanRevisionGet: `{"id":"revision-7"}`,
ws.ActionTaskReviewFindingUpdate: `{"id":"finding-3","status":"resolved"}`,
ws.ActionTaskPlanGet: `{"id":"plan-1"}`,
}
for action, payload := range nested {
t.Run(action, func(t *testing.T) {
f := newDispatchScopeFixture(t, realIdentity())
f.dispatchAs(t, action, payload)
if !*f.handled {
t.Errorf("%s was denied: its id names a nested resource, not a task", action)
}
})
}
}
// TestDispatchScopeIgnoresIDOnNonTaskActions keeps every other namespace out of
// the rule — `id` there is a workflow, executor or agent profile.
func TestDispatchScopeIgnoresIDOnNonTaskActions(t *testing.T) {
for _, action := range []string{ws.ActionWorkflowGet, "executor.get", "tasks.something"} {
t.Run(action, func(t *testing.T) {
f := newDispatchScopeFixture(t, realIdentity())
f.dispatchAs(t, action, `{"id":"task-b"}`)
if !*f.handled {
t.Errorf("%s was denied; its id does not name a task", action)
}
})
}
}
// task.create / task.list carry no id, so the rule is a no-op for them.
func TestDispatchScopeAllowsTopLevelTaskActionsWithoutID(t *testing.T) {
f := newDispatchScopeFixture(t, realIdentity())
f.dispatchAs(t, ws.ActionTaskCreate, `{"workspace_id":"ws-a","title":"New"}`)
if !*f.handled {
t.Error("task.create names no task id and must dispatch")
}
}
func TestIsTopLevelTaskAction(t *testing.T) {
cases := map[string]bool{
ws.ActionTaskState: true,
ws.ActionTaskMove: true,
ws.ActionTaskGet: true,
ws.ActionTaskArchive: true,
ws.ActionTaskPlanGet: false,
ws.ActionTaskPlanRevisionGet: false,
ws.ActionTaskReviewFindingUpdate: false,
ws.ActionTaskSessionList: false,
ws.ActionWorkflowGet: false,
"task": false,
"task.": true,
"tasks.get": false,
"": false,
}
for action, want := range cases {
if got := isTopLevelTaskAction(action); got != want {
t.Errorf("isTopLevelTaskAction(%q) = %v, want %v", action, got, want)
}
}
}
@@ -0,0 +1,256 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/auth/authn"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/service"
ws "github.com/kandev/kandev/pkg/websocket"
)
// DELETE /api/v1/tasks/:id built its context with context.WithTimeout(
// context.Background(), …). context.Background() carries no identity, so the
// service's authorizeTaskID saw an identity-less internal caller and permitted
// deleting anyone's task. The sibling archive route uses c.Request.Context() and
// answers 404, which is the shape of the fix — but the request context is
// cancelled when the client disconnects, and this route detaches from that on
// purpose, so the fix is context.WithoutCancel: keep the values, drop the cancel.
//
// Symbols here are authz-prefixed so they cannot collide with the fixtures PR
// #2500 adds to this package while it is unmerged.
// authzDeleteRepo serves one task owned by user-b and records every delete the
// handler managed to issue.
type authzDeleteRepo struct {
mockRepository
mu sync.Mutex
// deleted records each delete alongside the liveness of the context it
// arrived with. A cancelled context means the caller handed us one that a
// client disconnect had already killed.
deleted []string
deleteErr []error
archived []string
}
func (r *authzDeleteRepo) GetTask(_ context.Context, id string) (*models.Task, error) {
return &models.Task{ID: id, WorkspaceID: "ws-b", Title: "Victim"}, nil
}
func (r *authzDeleteRepo) GetWorkspace(_ context.Context, id string) (*models.Workspace, error) {
return &models.Workspace{ID: id, Name: "B's", OwnerID: "user-b"}, nil
}
func (r *authzDeleteRepo) ArchiveTaskIfActive(_ context.Context, id, cascadeID string) (bool, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.archived = append(r.archived, id+"/"+cascadeID)
return true, nil
}
func (r *authzDeleteRepo) archives() []string {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string(nil), r.archived...)
}
func (r *authzDeleteRepo) DeleteTask(ctx context.Context, id string) error {
r.mu.Lock()
defer r.mu.Unlock()
r.deleted = append(r.deleted, id)
r.deleteErr = append(r.deleteErr, ctx.Err())
return nil
}
func (r *authzDeleteRepo) ListChildren(context.Context, string) ([]*models.Task, error) {
return nil, nil
}
func (r *authzDeleteRepo) ListChildrenIncludingArchived(context.Context, string) ([]*models.Task, error) {
return nil, nil
}
func (r *authzDeleteRepo) CountToolCallMessagesBySession(context.Context, []string) (map[string]int, error) {
return nil, nil
}
func (r *authzDeleteRepo) deletes() []string {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string(nil), r.deleted...)
}
// deliveredLive reports whether every recorded delete arrived on a context that
// was still alive. A real repository would abort on a cancelled one; the fake
// cannot, so the liveness is asserted directly.
func (r *authzDeleteRepo) deliveredLive() bool {
r.mu.Lock()
defer r.mu.Unlock()
for _, err := range r.deleteErr {
if err != nil {
return false
}
}
return len(r.deleteErr) > 0
}
func newAuthzTaskHandlers(t *testing.T, repo *authzDeleteRepo) *TaskHandlers {
t.Helper()
log := newTestLogger(t)
svc := service.NewService(service.Repos{
Workspaces: repo, Tasks: repo, TaskRepos: repo,
Workflows: repo, Messages: repo, Turns: repo,
Sessions: repo, GitSnapshots: repo, RepoEntities: repo,
Executors: repo, Environments: repo, TaskEnvironments: repo,
Reviews: repo,
}, nil, log, service.RepositoryDiscoveryConfig{})
return &TaskHandlers{service: svc, repo: repo, logger: log}
}
// authzDeleteRequest builds a DELETE for taskID carrying userID's identity.
func authzDeleteRequest(t *testing.T, userID, taskID string) (*gin.Context, *httptest.ResponseRecorder) {
t.Helper()
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/tasks/"+taskID, nil)
req = req.WithContext(authn.WithIdentity(
req.Context(), authn.Identity{UserID: userID, Role: authn.RoleMember}))
c.Request = req
c.Params = gin.Params{{Key: "id", Value: taskID}}
return c, rec
}
func TestHTTPDeleteTaskDeniesForeignTask(t *testing.T) {
repo := &authzDeleteRepo{}
h := newAuthzTaskHandlers(t, repo)
c, rec := authzDeleteRequest(t, "user-a", "task-b")
h.httpDeleteTask(c)
if rec.Code != http.StatusNotFound {
t.Fatalf("foreign delete: status = %d body = %s, want 404", rec.Code, rec.Body.String())
}
if got := repo.deletes(); len(got) != 0 {
t.Fatalf("a denied delete reached the repository: %v", got)
}
// The owner must still be able to delete, otherwise a 404 for everyone
// would pass this test just as well.
ownerCtx, ownerRec := authzDeleteRequest(t, "user-b", "task-b")
h.httpDeleteTask(ownerCtx)
if ownerRec.Code != http.StatusOK {
t.Fatalf("owner delete: status = %d body = %s, want 200", ownerRec.Code, ownerRec.Body.String())
}
if got := repo.deletes(); len(got) != 1 || got[0] != "task-b" {
t.Fatalf("owner delete did not reach the repository: %v", got)
}
}
// TestHTTPDeleteTaskDeniesForeignTaskThroughCascade covers the shape backendapp
// actually ships: with a HandoffService wired the handler never calls
// Service.DeleteTask, so the guard that carries this route lives on the cascade.
//
// SetHandoffService is called exactly as production calls it — nothing here
// installs the access checker by hand — so removing that wiring fails this test.
func TestHTTPDeleteTaskDeniesForeignTaskThroughCascade(t *testing.T) {
repo := &authzDeleteRepo{}
h := newAuthzTaskHandlers(t, repo)
h.SetHandoffService(service.NewHandoffService(repo, nil, nil, nil, nil, nil))
c, rec := authzDeleteRequest(t, "user-a", "task-b")
h.httpDeleteTask(c)
if rec.Code != http.StatusNotFound {
t.Fatalf("foreign cascade delete: status = %d body = %s, want 404", rec.Code, rec.Body.String())
}
if got := repo.deletes(); len(got) != 0 {
t.Fatalf("a denied cascade delete reached the repository: %v", got)
}
ownerCtx, ownerRec := authzDeleteRequest(t, "user-b", "task-b")
h.httpDeleteTask(ownerCtx)
if ownerRec.Code != http.StatusOK {
t.Fatalf("owner cascade delete: status = %d body = %s, want 200", ownerRec.Code, ownerRec.Body.String())
}
if got := repo.deletes(); len(got) != 1 || got[0] != "task-b" {
t.Fatalf("owner cascade delete did not reach the repository: %v", got)
}
}
// TestWSArchiveTaskDeniesForeignTaskThroughCascade covers the third route the
// cascade substitution unscoped: wsArchiveTask also prefers HandoffService when
// one is wired, so it never reaches Service.ArchiveTask's guard either.
func TestWSArchiveTaskDeniesForeignTaskThroughCascade(t *testing.T) {
repo := &authzDeleteRepo{}
h := newAuthzTaskHandlers(t, repo)
h.SetHandoffService(service.NewHandoffService(repo, nil, nil, nil, nil, nil))
msg := &ws.Message{
ID: "msg-1",
Action: ws.ActionTaskArchive,
Payload: json.RawMessage(`{"id":"task-b"}`),
}
denied, err := h.wsArchiveTask(authzWSContext("user-a"), msg)
if err != nil {
t.Fatalf("wsArchiveTask: %v", err)
}
if denied.Type != ws.MessageTypeError {
t.Fatalf("foreign archive: type = %s payload = %s, want error", denied.Type, denied.Payload)
}
if got := repo.archives(); len(got) != 0 {
t.Fatalf("a denied archive reached the repository: %v", got)
}
owner, err := h.wsArchiveTask(authzWSContext("user-b"), msg)
if err != nil {
t.Fatalf("owner archive: %v", err)
}
if owner.Type == ws.MessageTypeError {
t.Fatalf("owner archive was refused: %s", owner.Payload)
}
if got := repo.archives(); len(got) != 1 {
t.Fatalf("owner archive did not reach the repository: %v", got)
}
}
// TestHTTPDeleteTaskSurvivesClientDisconnect is why the fix is
// context.WithoutCancel rather than a plain c.Request.Context() swap: a delete
// tears down worktrees, containers and subtree rows, and a browser that
// navigates away mid-request must not abort it half-done.
func TestHTTPDeleteTaskSurvivesClientDisconnect(t *testing.T) {
repo := &authzDeleteRepo{}
h := newAuthzTaskHandlers(t, repo)
c, rec := authzDeleteRequest(t, "user-b", "task-b")
gone, disconnect := context.WithCancel(c.Request.Context())
c.Request = c.Request.WithContext(gone)
disconnect()
h.httpDeleteTask(c)
if rec.Code != http.StatusOK {
t.Fatalf("disconnected delete: status = %d body = %s, want 200", rec.Code, rec.Body.String())
}
if got := repo.deletes(); len(got) != 1 || got[0] != "task-b" {
t.Fatalf("a client disconnect aborted the delete: %v", got)
}
// The status code alone proves nothing here — this fake repository ignores
// its context, so a cancelled one would still "succeed". Assert the context
// the delete was actually handed is live, which is what a real repository,
// worktree teardown or container removal depends on.
if !repo.deliveredLive() {
t.Fatal("the delete was handed a cancelled context; a client disconnect can abort it half-done")
}
}
@@ -66,8 +66,20 @@ type taskCreateLastUsedRecorder interface {
// Kanban subtask path to attach workspace-group membership and the // Kanban subtask path to attach workspace-group membership and the
// sequential blocker chain (handoffs phase 5). Optional — nil disables // sequential blocker chain (handoffs phase 5). Optional — nil disables
// post-create attachment, matching the pre-handoffs behaviour. // post-create attachment, matching the pre-handoffs behaviour.
//
// Wiring a HandoffService also re-installs the per-user task guard on it. That
// is not a convenience: this setter is what makes the archive / delete / unarchive
// routes call the cascade *instead of* Service.ArchiveTask / DeleteTask, and the
// cascade walks the task repository directly, so it inherits none of their
// authorizeTaskID checks. Installing the checker here means the substitution
// cannot silently unscope those routes. The checker is a no-op for identity-less
// internal callers (the integration watch-reset paths), exactly as it is
// everywhere else.
func (h *TaskHandlers) SetHandoffService(svc *service.HandoffService) { func (h *TaskHandlers) SetHandoffService(svc *service.HandoffService) {
h.handoffSvc = svc h.handoffSvc = svc
if svc != nil && h.service != nil {
svc.SetTaskAccessChecker(h.service.AuthorizeTaskAccess)
}
} }
func (h *TaskHandlers) SetWorkspaceQuarantineRestorer(restorer WorkspaceQuarantineRestorer) { func (h *TaskHandlers) SetWorkspaceQuarantineRestorer(restorer WorkspaceQuarantineRestorer) {
@@ -1652,7 +1652,14 @@ func (h *TaskHandlers) httpMoveTask(c *gin.Context) {
} }
func (h *TaskHandlers) httpDeleteTask(c *gin.Context) { func (h *TaskHandlers) httpDeleteTask(c *gin.Context) {
deleteCtx, cancel := context.WithTimeout(context.Background(), constants.TaskDeleteTimeout) // WithoutCancel, not Background: a delete tears down worktrees, containers
// and subtree rows, so it must survive the client navigating away — but
// context.Background() also dropped the caller identity the auth middleware
// attached, and an identity-free context reads as an internal caller, which
// authorizes everything. WithoutCancel keeps the request's values and drops
// only its cancellation.
deleteCtx, cancel := context.WithTimeout(
context.WithoutCancel(c.Request.Context()), constants.TaskDeleteTimeout)
defer cancel() defer cancel()
taskID := c.Param("id") taskID := c.Param("id")
cascade := cascadeQueryParam(c) cascade := cascadeQueryParam(c)
@@ -0,0 +1,180 @@
package handlers
import (
"context"
"encoding/json"
"strings"
"sync"
"testing"
"github.com/kandev/kandev/internal/auth/authn"
"github.com/kandev/kandev/internal/task/models"
taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite"
"github.com/kandev/kandev/internal/task/service"
v1 "github.com/kandev/kandev/pkg/api/v1"
ws "github.com/kandev/kandev/pkg/websocket"
)
// The WS half of the same gaps. task.state and task.move name their task `id`,
// which is what let them past the gateway backstop; these assert the handlers
// refuse regardless, since the service guard is the actual fix and the handler
// is also what a non-gateway caller reaches.
//
// The denial assertion is deliberately a *comparison* rather than a fixed error
// code: what matters is that a foreign task is indistinguishable from one that
// does not exist, which is what these handlers already emit for any service
// error.
// authzWSRepo serves exactly one task — user-b's — and records every mutating
// write. Any other ID is genuinely absent, so "foreign" and "missing" can be
// compared against each other.
type authzWSRepo struct {
mockRepository
mu sync.Mutex
states []string
positions []string
}
func (r *authzWSRepo) GetTask(_ context.Context, id string) (*models.Task, error) {
if id != "task-b" {
return nil, taskrepo.ErrTaskNotFound
}
return &models.Task{
ID: "task-b", WorkspaceID: "ws-b", Title: "Victim",
WorkflowID: "wf-b", WorkflowStepID: "step-b-1", State: v1.TaskStateTODO,
}, nil
}
func (r *authzWSRepo) GetWorkspace(_ context.Context, id string) (*models.Workspace, error) {
return &models.Workspace{ID: id, Name: "B's", OwnerID: "user-b"}, nil
}
func (r *authzWSRepo) UpdateTaskState(_ context.Context, id string, state v1.TaskState) error {
r.mu.Lock()
defer r.mu.Unlock()
r.states = append(r.states, id+"="+string(state))
return nil
}
func (r *authzWSRepo) UpdateTask(_ context.Context, task *models.Task) error {
r.mu.Lock()
defer r.mu.Unlock()
r.positions = append(r.positions, task.ID+"@"+task.WorkflowStepID)
return nil
}
func (r *authzWSRepo) writes() int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.states) + len(r.positions)
}
func (r *authzWSRepo) CountToolCallMessagesBySession(context.Context, []string) (map[string]int, error) {
return nil, nil
}
func (r *authzWSRepo) ListChildren(context.Context, string) ([]*models.Task, error) {
return nil, nil
}
func newAuthzWSHandlers(t *testing.T, repo *authzWSRepo) *TaskHandlers {
t.Helper()
log := newTestLogger(t)
svc := service.NewService(service.Repos{
Workspaces: repo, Tasks: repo, TaskRepos: repo,
Workflows: repo, Messages: repo, Turns: repo,
Sessions: repo, GitSnapshots: repo, RepoEntities: repo,
Executors: repo, Environments: repo, TaskEnvironments: repo,
Reviews: repo,
}, nil, log, service.RepositoryDiscoveryConfig{})
return &TaskHandlers{service: svc, repo: repo, logger: log}
}
func authzWSContext(userID string) context.Context {
return authn.WithIdentity(context.Background(),
authn.Identity{UserID: userID, Role: authn.RoleMember})
}
func authzWSMessage(t *testing.T, action string, payload map[string]any) *ws.Message {
t.Helper()
raw, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
return &ws.Message{ID: "msg-1", Action: action, Payload: raw}
}
// authzWSCall names one entry point and how to invoke it against a task ID.
type authzWSCall struct {
action string
invoke func(*TaskHandlers, context.Context, string) (*ws.Message, error)
}
func authzWSCalls(t *testing.T) []authzWSCall {
t.Helper()
return []authzWSCall{
{ws.ActionTaskState, func(h *TaskHandlers, ctx context.Context, id string) (*ws.Message, error) {
return h.wsUpdateTaskState(ctx, authzWSMessage(t, ws.ActionTaskState,
map[string]any{"id": id, "state": string(v1.TaskStateCompleted)}))
}},
{ws.ActionTaskMove, func(h *TaskHandlers, ctx context.Context, id string) (*ws.Message, error) {
return h.wsMoveTask(ctx, authzWSMessage(t, ws.ActionTaskMove, map[string]any{
"id": id, "workflow_id": "wf-b", "workflow_step_id": "step-b-2", "position": 3,
}))
}},
}
}
func TestWSTaskStateAndMoveDenyForeignTask(t *testing.T) {
for _, tc := range authzWSCalls(t) {
t.Run(tc.action, func(t *testing.T) {
repo := &authzWSRepo{}
h := newAuthzWSHandlers(t, repo)
denied, err := tc.invoke(h, authzWSContext("user-a"), "task-b")
if err != nil {
t.Fatalf("%s: %v", tc.action, err)
}
if denied.Type != ws.MessageTypeError {
t.Fatalf("foreign %s: type = %s payload = %s, want error",
tc.action, denied.Type, denied.Payload)
}
if strings.Contains(string(denied.Payload), "Victim") {
t.Fatalf("the foreign task leaked into the response: %s", denied.Payload)
}
if n := repo.writes(); n != 0 {
t.Fatalf("a denied %s reached the repository (%d writes)", tc.action, n)
}
// A denial must read exactly like a task that does not exist.
missing, err := tc.invoke(h, authzWSContext("user-a"), "task-nonexistent")
if err != nil {
t.Fatalf("%s (unknown task): %v", tc.action, err)
}
if string(missing.Payload) != string(denied.Payload) {
t.Fatalf("denial is distinguishable from a missing task:\n denied = %s\n missing = %s",
denied.Payload, missing.Payload)
}
})
}
}
// TestWSTaskStateAllowsOwner is the witness for the denial above: without it the
// test would still pass if the guard refused everyone.
func TestWSTaskStateAllowsOwner(t *testing.T) {
repo := &authzWSRepo{}
h := newAuthzWSHandlers(t, repo)
resp, err := h.wsUpdateTaskState(authzWSContext("user-b"), authzWSMessage(t, ws.ActionTaskState,
map[string]any{"id": "task-b", "state": string(v1.TaskStateCompleted)}))
if err != nil {
t.Fatalf("owner task.state: %v", err)
}
if resp.Type == ws.MessageTypeError {
t.Fatalf("owner task.state was refused: %s", resp.Payload)
}
if repo.writes() == 0 {
t.Fatal("owner task.state did not reach the repository")
}
}
@@ -176,6 +176,9 @@ type CascadeOutcome struct {
// archived, stamping the cascade ID on the released row. // archived, stamping the cascade ID on the released row.
// 5. Evaluate cleanup once per affected group. // 5. Evaluate cleanup once per affected group.
func (s *HandoffService) ArchiveTaskTree(ctx context.Context, rootID string, cascade bool) (*CascadeOutcome, error) { func (s *HandoffService) ArchiveTaskTree(ctx context.Context, rootID string, cascade bool) (*CascadeOutcome, error) {
if err := s.authorizeTask(ctx, rootID); err != nil {
return nil, err
}
if rootID == "" { if rootID == "" {
return nil, errors.New("rootID is required") return nil, errors.New("rootID is required")
} }
@@ -278,6 +281,9 @@ func (s *HandoffService) ArchiveTaskTree(ctx context.Context, rootID string, cas
// because deletion is unconditional; the cascade ID is stamped only // because deletion is unconditional; the cascade ID is stamped only
// for symmetry with archive. // for symmetry with archive.
func (s *HandoffService) DeleteTaskTree(ctx context.Context, rootID string, cascade bool) (*CascadeOutcome, error) { func (s *HandoffService) DeleteTaskTree(ctx context.Context, rootID string, cascade bool) (*CascadeOutcome, error) {
if err := s.authorizeTask(ctx, rootID); err != nil {
return nil, err
}
if rootID == "" { if rootID == "" {
return nil, errors.New("rootID is required") return nil, errors.New("rootID is required")
} }
@@ -628,6 +634,9 @@ func (s *HandoffService) cancelActiveRuns(ctx context.Context, taskIDs []string,
// archived (empty cascade id) or that belong to an earlier cascade are // archived (empty cascade id) or that belong to an earlier cascade are
// left archived — fixing the resurrection bug from the original review. // left archived — fixing the resurrection bug from the original review.
func (s *HandoffService) UnarchiveTaskTree(ctx context.Context, rootID string) (*CascadeOutcome, error) { func (s *HandoffService) UnarchiveTaskTree(ctx context.Context, rootID string) (*CascadeOutcome, error) {
if err := s.authorizeTask(ctx, rootID); err != nil {
return nil, err
}
if rootID == "" { if rootID == "" {
return nil, errors.New("rootID is required") return nil, errors.New("rootID is required")
} }
@@ -0,0 +1,124 @@
package service
import (
"context"
"errors"
"testing"
)
// Per-user scoping for the cascade entry points.
//
// backendapp always wires a HandoffService into TaskHandlers, so in a shipped
// instance DELETE /tasks/:id, POST /tasks/:id/archive and the task.archive WS
// action never reach the guarded Service.DeleteTask / Service.ArchiveTask — they
// land here instead, and this type had no identity awareness at all. Restoring
// the caller identity on the HTTP delete handler is inert without these guards.
var errCascadeDenied = errors.New("task not found")
// cascadeAuthzCase invokes one cascade entry point against a task ID.
type cascadeAuthzCase struct {
name string
invoke func(*HandoffService, string) error
}
func cascadeAuthzCases() []cascadeAuthzCase {
return []cascadeAuthzCase{
{"ArchiveTaskTree", func(s *HandoffService, id string) error {
_, err := s.ArchiveTaskTree(context.Background(), id, true)
return err
}},
{"DeleteTaskTree", func(s *HandoffService, id string) error {
_, err := s.DeleteTaskTree(context.Background(), id, true)
return err
}},
{"UnarchiveTaskTree", func(s *HandoffService, id string) error {
_, err := s.UnarchiveTaskTree(context.Background(), id)
return err
}},
}
}
// cascadeAuthzChecker admits task-mine and refuses everything else, which is
// what Service.AuthorizeTaskAccess does for a caller who owns one workspace.
func cascadeAuthzChecker(_ context.Context, taskID string) error {
if taskID == "task-mine" {
return nil
}
return errCascadeDenied
}
func TestCascadeEntryPointsDenyForeignTask(t *testing.T) {
for _, tc := range cascadeAuthzCases() {
t.Run(tc.name, func(t *testing.T) {
tasks := newFakeTaskRepo()
tasks.addTask("task-mine", "", "ws-a")
tasks.addArchivedTask("task-b", "", "ws-b", "")
repo := &fakeDeleteRepo{fakeCascadeRepo: newCascadeRepo(tasks)}
svc := NewHandoffService(repo, nil, nil, nil, newCascadeWSGroupRepo(), nil)
svc.SetTaskAccessChecker(cascadeAuthzChecker)
if err := tc.invoke(svc, "task-b"); !errors.Is(err, errCascadeDenied) {
t.Fatalf("foreign cascade: err = %v, want denial", err)
}
tasks.mu.Lock()
victim, present := tasks.tasks["task-b"]
stillArchived := present && victim.ArchivedAt != nil
tasks.mu.Unlock()
if !present {
t.Fatal("a denied cascade deleted the foreign task")
}
if !stillArchived {
t.Fatal("a denied cascade unarchived the foreign task")
}
// The owner must get past the guard, otherwise the denial above
// would also pass with the checker refusing everything.
if err := tc.invoke(svc, "task-mine"); errors.Is(err, errCascadeDenied) {
t.Fatalf("owner cascade was denied: %v", err)
}
})
}
}
// TestCascadeEntryPointsGuardBeforeDependencies pins guard placement: with the
// task repo nil, a guard placed after the first repo read panics instead of
// returning the denial.
func TestCascadeEntryPointsGuardBeforeDependencies(t *testing.T) {
for _, tc := range cascadeAuthzCases() {
t.Run(tc.name, func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("guard runs too late — panicked on a nil dependency: %v", r)
}
}()
svc := NewHandoffService(nil, nil, nil, nil, nil, nil)
svc.SetTaskAccessChecker(cascadeAuthzChecker)
if err := tc.invoke(svc, "task-b"); !errors.Is(err, errCascadeDenied) {
t.Fatalf("err = %v, want denial", err)
}
})
}
}
// TestCascadeEntryPointsUnscopedWhenUnwired pins the compatibility contract: no
// checker installed means nothing is denied by scoping, so identity-less
// internal callers and every existing bare-NewHandoffService fixture are
// unaffected. The calls may still fail on their own dependencies — this asserts
// only that the denial sentinel does not appear.
func TestCascadeEntryPointsUnscopedWhenUnwired(t *testing.T) {
for _, tc := range cascadeAuthzCases() {
t.Run(tc.name, func(t *testing.T) {
tasks := newFakeTaskRepo()
tasks.addArchivedTask("task-b", "", "ws-b", "")
repo := &fakeDeleteRepo{fakeCascadeRepo: newCascadeRepo(tasks)}
svc := NewHandoffService(repo, nil, nil, nil, newCascadeWSGroupRepo(), nil)
if err := tc.invoke(svc, "task-b"); errors.Is(err, errCascadeDenied) {
t.Fatal("unwired checker denied the call; pre-auth behavior broken")
}
})
}
}
@@ -200,6 +200,7 @@ type HandoffService struct {
runCanceller RunCanceller runCanceller RunCanceller
eventPublisher TaskEventPublisher eventPublisher TaskEventPublisher
resourceCleaner TaskResourceCleaner resourceCleaner TaskResourceCleaner
taskAccessCheck func(ctx context.Context, taskID string) error
logger *logger.Logger logger *logger.Logger
parentLock parentMutex parentLock parentMutex
workspaceGroupLock parentMutex workspaceGroupLock parentMutex
@@ -278,6 +279,28 @@ func (s *HandoffService) SetTaskResourceCleaner(c TaskResourceCleaner) {
s.resourceCleaner = c s.resourceCleaner = c
} }
// SetTaskAccessChecker installs the per-user task check applied by the cascade
// entry points. Same contract as orchestrator.Service.SetTaskAccessChecker: the
// checker returns nil for contexts without a request identity, so internal
// callers (event bus, schedulers, workflow engine) stay unscoped, and leaving it
// unwired keeps the pre-auth see-everything behavior.
//
// The cascade methods walk the task repository directly and never reach
// Service.DeleteTask / Service.ArchiveTask, so they inherit nothing from the
// task service's authorize* helpers — without this they are the unguarded half
// of every archive and delete route.
func (s *HandoffService) SetTaskAccessChecker(check func(ctx context.Context, taskID string) error) {
s.taskAccessCheck = check
}
// authorizeTask applies the configured per-user task check. No-op when unwired.
func (s *HandoffService) authorizeTask(ctx context.Context, taskID string) error {
if s.taskAccessCheck == nil || taskID == "" {
return nil
}
return s.taskAccessCheck(ctx, taskID)
}
// NewHandoffService creates a HandoffService. blockers and wsGroups may be // NewHandoffService creates a HandoffService. blockers and wsGroups may be
// nil — when nil the corresponding methods return empty results / // nil — when nil the corresponding methods return empty results /
// no-ops, mirroring the optional wiring used by Service.SetBlockerRepository. // no-ops, mirroring the optional wiring used by Service.SetBlockerRepository.
@@ -50,6 +50,12 @@ func (s *Service) UpdateRepositoryBaseBranch(ctx context.Context, req UpdateRepo
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Ahead of every repository read. The WS action names task_id so the gateway
// backstop already covers that transport, but this method is also reachable
// over HTTP and MCP, and a backstop is not a substitute for a service guard.
if err := s.authorizeTaskID(ctx, req.TaskID); err != nil {
return nil, err
}
taskRepo, err := s.loadTaskRepositoryForUpdate(ctx, req.TaskID, req.TaskRepositoryID) taskRepo, err := s.loadTaskRepositoryForUpdate(ctx, req.TaskID, req.TaskRepositoryID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -161,6 +161,9 @@ func (s *Service) resolveApprovalNextStep(ctx context.Context, step *wfmodels.Wo
// UpdateTaskState updates the state of a task, moves it to the matching column, // UpdateTaskState updates the state of a task, moves it to the matching column,
// and publishes a task.state_changed event // and publishes a task.state_changed event
func (s *Service) UpdateTaskState(ctx context.Context, id string, state v1.TaskState) (*models.Task, error) { func (s *Service) UpdateTaskState(ctx context.Context, id string, state v1.TaskState) (*models.Task, error) {
if err := s.authorizeTaskID(ctx, id); err != nil {
return nil, err
}
task, err := s.tasks.GetTask(ctx, id) task, err := s.tasks.GetTask(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -430,6 +433,9 @@ func (s *Service) MoveTaskWithOptions(
position int, position int,
opts MoveTaskOptions, opts MoveTaskOptions,
) (*MoveTaskResult, error) { ) (*MoveTaskResult, error) {
if err := s.authorizeTaskID(ctx, id); err != nil {
return nil, err
}
task, err := s.tasks.GetTask(ctx, id) task, err := s.tasks.GetTask(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -684,6 +690,19 @@ func (s *Service) promoteFeederQueuedTask(ctx context.Context, candidate *models
s.publishTaskMovedEvent(ctx, candidate, oldWorkflowID, fromStepID, targetStep.ID, "") s.publishTaskMovedEvent(ctx, candidate, oldWorkflowID, fromStepID, targetStep.ID, "")
return true return true
} }
// ctx here still carries the identity of whoever triggered the move that
// freed the slot, so MoveTaskWithOptions' authorizeTaskID applies to the
// promoted candidate too. That is safe by construction: a step's
// PullFromStepID resolves within the same workflow, a workflow belongs to
// one workspace, and a workspace has one owner — so the candidate always
// belongs to the caller who just passed the same check.
//
// If a future configuration ever allowed cross-workspace feeder pulls, this
// would refuse and log below rather than promote. Leave it that way: do not
// strip the identity to "fix" it. Promoting another user's task into a step
// they cannot see is the worse outcome, and the atomic promoter above (which
// the SQLite repository implements, so it is the only path production takes)
// does not go through a guarded method at all.
if _, err := s.MoveTaskWithOptions(ctx, candidate.ID, targetStep.WorkflowID, targetStep.ID, position, MoveTaskOptions{PreserveDeferredLaunch: true}); err != nil { if _, err := s.MoveTaskWithOptions(ctx, candidate.ID, targetStep.WorkflowID, targetStep.ID, position, MoveTaskOptions{PreserveDeferredLaunch: true}); err != nil {
skipped[candidate.ID] = struct{}{} skipped[candidate.ID] = struct{}{}
s.logger.Warn("skipping queued task that could not be promoted", zap.String("task_id", candidate.ID), zap.String("to_step_id", targetStep.ID), zap.Error(err)) s.logger.Warn("skipping queued task that could not be promoted", zap.String("task_id", candidate.ID), zap.String("to_step_id", targetStep.ID), zap.Error(err))
@@ -0,0 +1,247 @@
package service
import (
"context"
"errors"
"testing"
sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/repository/repoerrors"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// Per-user scoping for the task lifecycle mutations that used to start at
// s.tasks.GetTask with no authorization step: state changes, workflow moves and
// base-branch edits. Their WS actions name the task `id` rather than `task_id`,
// so the gateway backstop parsed no refs and let them through — these guards are
// what actually refuses the call.
//
// Every case runs the identical request as the OWNER afterwards and asserts it
// succeeds. Without that witness a denial test still passes when the fixture is
// broken (wrong ID, unseeded row) rather than when the guard works.
// seedAuthzWorkflowFixture builds one workspace owned by user-b holding a
// workflow with two steps and a task parked on the first, plus a repository the
// task checks out. Returns the task-repository row ID for the base-branch case.
func seedAuthzWorkflowFixture(t *testing.T, svc *Service, repo *sqliterepo.Repository) string {
t.Helper()
ctx := context.Background()
svc.SetWorkflowStepGetter(&fakeWorkflowStepGetter{steps: map[string]*wfmodels.WorkflowStep{
"step-b-1": {ID: "step-b-1", WorkflowID: "wf-b", Name: "First", Position: 0},
"step-b-2": {ID: "step-b-2", WorkflowID: "wf-b", Name: "Second", Position: 1},
}})
must(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-b", Name: "B's", OwnerID: "user-b"}))
must(t, repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-b", WorkspaceID: "ws-b", Name: "B flow"}))
must(t, repo.CreateRepository(ctx, &models.Repository{
ID: "repo-b", WorkspaceID: "ws-b", Name: "backend", DefaultBranch: "main",
}))
must(t, repo.CreateTask(ctx, &models.Task{
ID: "task-b", WorkspaceID: "ws-b", WorkflowID: "wf-b", WorkflowStepID: "step-b-1",
Title: "Victim", State: v1.TaskStateTODO, Priority: priorityMedium,
}))
must(t, repo.CreateTaskRepository(ctx, &models.TaskRepository{
ID: "task-repo-b", TaskID: "task-b", RepositoryID: "repo-b",
BaseBranch: "main", CheckoutBranch: "feature/x",
}))
return "task-repo-b"
}
func TestUpdateTaskStateDeniesForeignTask(t *testing.T) {
svc, _, repo := createTestService(t)
seedAuthzWorkflowFixture(t, svc, repo)
task, err := svc.UpdateTaskState(ctxAs("user-a"), "task-b", v1.TaskStateInProgress)
if !errors.Is(err, repoerrors.ErrTaskNotFound) {
t.Fatalf("foreign state change: err = %v, want ErrTaskNotFound", err)
}
if task != nil {
t.Fatalf("denial leaked the task: %+v", task)
}
stored, err := repo.GetTask(context.Background(), "task-b")
if err != nil {
t.Fatalf("GetTask: %v", err)
}
if stored.State != v1.TaskStateTODO {
t.Fatalf("a denied state change reached the repository: state = %s", stored.State)
}
// The owner must still get through, otherwise the denial above proves nothing.
owned, err := svc.UpdateTaskState(ctxAs("user-b"), "task-b", v1.TaskStateInProgress)
if err != nil {
t.Fatalf("owner state change: %v", err)
}
if owned.State != v1.TaskStateInProgress {
t.Fatalf("owner state change did not apply: state = %s", owned.State)
}
}
func TestMoveTaskDeniesForeignTask(t *testing.T) {
svc, _, repo := createTestService(t)
seedAuthzWorkflowFixture(t, svc, repo)
result, err := svc.MoveTask(ctxAs("user-a"), "task-b", "wf-b", "step-b-2", 3)
if !errors.Is(err, repoerrors.ErrTaskNotFound) {
t.Fatalf("foreign move: err = %v, want ErrTaskNotFound", err)
}
if result != nil {
t.Fatalf("denial leaked the move result: %+v", result)
}
stored, err := repo.GetTask(context.Background(), "task-b")
if err != nil {
t.Fatalf("GetTask: %v", err)
}
if stored.WorkflowStepID != "step-b-1" || stored.Position != 0 {
t.Fatalf("a denied move reached the repository: step = %s position = %d",
stored.WorkflowStepID, stored.Position)
}
owned, err := svc.MoveTask(ctxAs("user-b"), "task-b", "wf-b", "step-b-2", 3)
if err != nil {
t.Fatalf("owner move: %v", err)
}
if owned.Task.WorkflowStepID != "step-b-2" {
t.Fatalf("owner move did not apply: step = %s", owned.Task.WorkflowStepID)
}
}
func TestUpdateRepositoryBaseBranchDeniesForeignTask(t *testing.T) {
svc, _, repo := createTestService(t)
taskRepoID := seedAuthzWorkflowFixture(t, svc, repo)
svc.SetAgentBaseBranchPusher(&fakeBaseBranchPusher{})
req := UpdateRepositoryBaseBranchRequest{
TaskID: "task-b", TaskRepositoryID: taskRepoID, BaseBranch: "staging",
}
updated, err := svc.UpdateRepositoryBaseBranch(ctxAs("user-a"), req)
if !errors.Is(err, repoerrors.ErrTaskNotFound) {
t.Fatalf("foreign base-branch update: err = %v, want ErrTaskNotFound", err)
}
if updated != nil {
t.Fatalf("denial leaked the task repository: %+v", updated)
}
rows, err := repo.ListTaskRepositories(context.Background(), "task-b")
if err != nil {
t.Fatalf("ListTaskRepositories: %v", err)
}
if rows[0].BaseBranch != "main" {
t.Fatalf("a denied base-branch update reached the repository: %s", rows[0].BaseBranch)
}
owned, err := svc.UpdateRepositoryBaseBranch(ctxAs("user-b"), req)
if err != nil {
t.Fatalf("owner base-branch update: %v", err)
}
if owned.BaseBranch != "staging" {
t.Fatalf("owner base-branch update did not apply: %s", owned.BaseBranch)
}
}
// TestScopedMoveStillPromotesFeederQueuedTask covers the follow-on work flagged
// in review: the move guards run on a ctx that internal continuations inherit,
// and feeder queue promotion is the one that can reach a guarded method. Freeing
// a slot in a WIP-limited step as the owner must still pull the task queued in
// the feeder step behind it.
//
// What this pins, precisely: promotion still happens end to end under a real
// identity. It does NOT exercise the guard, because the SQLite repository
// implements workflowQueuedTaskPromoter, so promotion takes the atomic branch and
// never calls MoveTaskWithOptions at all — the guarded fallback in
// promoteFeederQueuedTask is unreachable in production and only runs for repos
// without that method. Verified by mutation: breaking the feeder pull fails this
// test, while forcing a denying identity into the promotion path does not.
//
// The invariant that keeps the fallback safe is documented at that call site: a
// step's PullFromStepID resolves within the same workflow, a workflow belongs to
// one workspace, and a workspace has one owner.
func TestScopedMoveStillPromotesFeederQueuedTask(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
svc.SetWorkflowStepGetter(&fakeWorkflowStepGetter{steps: map[string]*wfmodels.WorkflowStep{
"step-b-feed": {ID: "step-b-feed", WorkflowID: "wf-b", Name: "Backlog", Position: 0},
"step-b-wip": {
ID: "step-b-wip", WorkflowID: "wf-b", Name: "Doing", Position: 1,
WIPLimit: 1, PullFromStepID: "step-b-feed",
},
"step-b-done": {ID: "step-b-done", WorkflowID: "wf-b", Name: "Done", Position: 2},
}})
must(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-b", Name: "B's", OwnerID: "user-b"}))
must(t, repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-b", WorkspaceID: "ws-b", Name: "B flow"}))
// occupant fills the WIP-limited step; candidate waits in the feeder step.
must(t, repo.CreateTask(ctx, &models.Task{
ID: "task-occupant", WorkspaceID: "ws-b", WorkflowID: "wf-b", WorkflowStepID: "step-b-wip",
Title: "Occupant", State: v1.TaskStateTODO, Priority: priorityMedium, WIPAdmitted: true,
}))
must(t, repo.CreateTask(ctx, &models.Task{
ID: "task-queued", WorkspaceID: "ws-b", WorkflowID: "wf-b", WorkflowStepID: "step-b-feed",
Title: "Queued", State: v1.TaskStateTODO, Priority: priorityMedium,
QueuedForStepID: "step-b-wip",
}))
// The owner moves the occupant out, freeing the slot — under their identity,
// so every guard on the promotion path is live.
if _, err := svc.MoveTask(ctxAs("user-b"), "task-occupant", "wf-b", "step-b-done", 0); err != nil {
t.Fatalf("owner move out of the WIP step: %v", err)
}
promoted, err := repo.GetTask(ctx, "task-queued")
if err != nil {
t.Fatalf("GetTask: %v", err)
}
if promoted.WorkflowStepID != "step-b-wip" || !promoted.WIPAdmitted {
t.Fatalf("a scoped move did not pull the feeder-queued task: step=%s admitted=%v",
promoted.WorkflowStepID, promoted.WIPAdmitted)
}
}
// TestTaskWorkflowGuardsPrecedeRepositoryUse pins guard placement. The service
// carries only the two repositories the guard itself reads; a denial that ran
// after the first unrelated repo call would nil-panic instead of returning.
func TestTaskWorkflowGuardsPrecedeRepositoryUse(t *testing.T) {
svc, _, repo := createTestService(t)
seedAuthzWorkflowFixture(t, svc, repo)
guarded := &Service{
tasks: svc.tasks,
workspaces: svc.workspaces,
logger: svc.logger,
}
cases := map[string]func() error{
"UpdateTaskState": func() error {
_, err := guarded.UpdateTaskState(ctxAs("user-a"), "task-b", v1.TaskStateInProgress)
return err
},
"MoveTaskWithOptions": func() error {
_, err := guarded.MoveTaskWithOptions(
ctxAs("user-a"), "task-b", "wf-b", "step-b-2", 0, MoveTaskOptions{})
return err
},
"UpdateRepositoryBaseBranch": func() error {
_, err := guarded.UpdateRepositoryBaseBranch(ctxAs("user-a"), UpdateRepositoryBaseBranchRequest{
TaskID: "task-b", TaskRepositoryID: "task-repo-b", BaseBranch: "staging",
})
return err
},
}
for name, invoke := range cases {
t.Run(name, func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("guard runs too late — panicked on a nil dependency: %v", r)
}
}()
if err := invoke(); !errors.Is(err, repoerrors.ErrTaskNotFound) {
t.Fatalf("err = %v, want ErrTaskNotFound", err)
}
})
}
}
@@ -0,0 +1,145 @@
---
spec: docs/specs/auth/spec.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/spec.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) | — |
@@ -0,0 +1,54 @@
# Task 01 — Service-layer guards for state, move and base-branch
**Status:** done
**Depends on:** —
## Goal
`Service.UpdateTaskState`, `Service.MoveTaskWithOptions` and
`Service.UpdateRepositoryBaseBranch` refuse a task the caller cannot see, before
they read or write anything.
## Changes
`internal/task/service/service_workflow.go`
- `UpdateTaskState` — `authorizeTaskID(ctx, id)` as the first statement, ahead of
`s.tasks.GetTask`.
- `MoveTaskWithOptions` — same. `MoveTask` delegates here, so both entry points are
covered by one guard.
- `UpdateRepositoryBaseBranch` — `authorizeTaskID(ctx, req.TaskID)` first. Defense in
depth: the WS action names `task_id` so the gateway backstop already covers that
transport, but the method is also reachable from HTTP and MCP, and
`apps/backend/CLAUDE.md` requires the service-level check regardless.
No handler changes. `wsUpdateTaskState` and `wsMoveTask` map every service error to
a generic `ErrorCodeInternalError` message, which is byte-identical to what a
nonexistent task already produces — so the denial leaks no existence.
## Tests
`internal/task/service/service_workflow_authz_test.go` (new), using the
`ctxAs` / `seedScopedWorkspaces` / `createTestService` fixtures already in
`service_access_test.go`:
- `TestUpdateTaskStateDeniesForeignTask` — user-a is refused with
`repoerrors.ErrTaskNotFound`, the returned task is nil, and the repository state
is unchanged; **then user-b runs the identical call and it succeeds** and the
state actually changes.
- `TestMoveTaskDeniesForeignTask` — same shape; asserts the task's
workflow/step/position are untouched after the denial, and that the owner's move
lands.
- `TestUpdateRepositoryBaseBranchDeniesForeignTask` — same shape.
- `TestTaskWorkflowGuardsPrecedeRepositoryUse` — table over the three entry points
with a `Service` whose repos are nil except the two the guard itself needs; a
panic means the guard is placed after the first repo use.
## Verification
```
cd apps/backend && CGO_ENABLED=1 go test -tags fts5 -race -count=1 ./internal/task/service/...
```
Mutation: remove each `authorizeTaskID` line in turn; the matching test must fail
and name itself.
@@ -0,0 +1,67 @@
# Task 02 — HandoffService cascade authorization
**Status:** done
**Depends on:** —
## Why
`backendapp` always wires a `HandoffService` into `TaskHandlers`, so in a shipped
instance `DELETE /tasks/:id`, `POST /tasks/:id/archive` and the `task.archive` WS
action never reach the guarded `Service.DeleteTask` / `Service.ArchiveTask` — they
go to `HandoffService.DeleteTaskTree` / `ArchiveTaskTree`, which have no identity
awareness at all. Task 03's identity fix is inert without this.
This is why the sibling archive route looked correctly scoped: it was reproduced
against a fixture with `handoffSvc == nil`.
## Changes
`internal/task/service/handoff_service.go`
- `taskAccessCheck func(ctx context.Context, taskID string) error` field.
- `SetTaskAccessChecker(...)` — same contract as
`orchestrator.Service.SetTaskAccessChecker`: the checker returns nil for
identity-less contexts, and leaving it unwired keeps the pre-auth behavior.
- `authorizeTask(ctx, taskID)` — no-op when unwired or when taskID is empty.
`internal/task/service/handoff_cascade.go`
- `authorizeTask` as the first statement of `ArchiveTaskTree`, `DeleteTaskTree` and
`UnarchiveTaskTree`.
`internal/task/handlers/task_handlers.go`
- `TaskHandlers.SetHandoffService` installs the checker
(`svc.SetTaskAccessChecker(h.service.AuthorizeTaskAccess)`).
The wiring lives there rather than in `backendapp` on purpose: `SetHandoffService`
is the call that makes archive/delete/unarchive use the cascade *instead of* the
guarded `Service` methods, and every user-facing cascade call site is in this
package. Installing the guard as part of that substitution means it cannot be
wired without it, and it is reachable from a package test — a separate
`backendapp` line would have had no pin short of booting the whole route table.
The integration watch-reset callers share the same instance and are identity-free,
so the checker no-ops for them.
## Tests
`internal/task/service/handoff_cascade_authz_test.go` (new):
- `TestCascadeEntryPointsDenyForeignTask` — table over the three entry points with a
checker that admits `task-mine` and refuses `task-b`. Each asserts the denial, that
the outcome is nil, and that the recording repo saw **no** archive/delete/unarchive
call; **then re-runs the same entry point on the owned task** and asserts it
proceeds past the guard.
- `TestCascadeEntryPointsGuardBeforeDependencies` — the same table with `s.tasks`
nil; a panic means the guard runs too late.
- `TestCascadeEntryPointsUnscopedWhenUnwired` — no checker installed, the denial
sentinel must not appear, so every existing fixture that builds a bare
`NewHandoffService(...)` keeps working.
## Verification
```
cd apps/backend && CGO_ENABLED=1 go test -tags fts5 -race -count=1 ./internal/task/... ./internal/backendapp/...
```
Mutation: remove each `authorizeTask` call in turn.
@@ -0,0 +1,52 @@
# Task 03 — HTTP delete keeps the caller identity
**Status:** done
**Depends on:** 02
## Goal
`DELETE /api/v1/tasks/:id` authorizes the caller, without reintroducing the
client-disconnect abort that `context.Background()` was there to prevent.
## Changes
`internal/task/handlers/task_http_handlers.go` — `httpDeleteTask`:
```go
deleteCtx, cancel := context.WithTimeout(
context.WithoutCancel(c.Request.Context()), constants.TaskDeleteTimeout)
```
`context.WithoutCancel` keeps the request context's *values* — the identity the auth
middleware attached is one of them — while dropping its cancellation and deadline.
A browser that navigates away mid-delete no longer aborts a half-finished subtree
teardown, which is what plain `c.Request.Context()` would have reintroduced.
## Tests
`internal/task/handlers/task_delete_authz_test.go` (new). Symbols are `authz`-prefixed
so they cannot collide with the fixtures PR #2500 adds to this package while it is
unmerged.
- `TestHTTPDeleteTaskDeniesForeignTask` — user-a gets 404 with the sanitized body,
the recording repo saw no delete; **then user-b issues the identical request and
gets 200**, with the delete recorded.
- `TestHTTPDeleteTaskDeniesForeignTaskThroughCascade` — the same pair with a
`HandoffService` wired (the production shape), proving task 02's guard is what
carries this route.
- `TestWSArchiveTaskDeniesForeignTaskThroughCascade` — the third route the cascade
substitution unscoped.
- `TestHTTPDeleteTaskSurvivesClientDisconnect` — the request context is cancelled
before the handler runs; the delete must still reach the repository **on a live
context**. Asserting the status code alone was vacuous (the fake repository
ignores its context, so a cancelled one still "succeeds") — the mutation run
caught that, and the test now records `ctx.Err()` at the repository boundary.
## Verification
```
cd apps/backend && CGO_ENABLED=1 go test -tags fts5 -race -count=1 ./internal/task/handlers/...
```
Mutation: restore `context.Background()`; the denial tests must fail. Replace with a
bare `c.Request.Context()`; the disconnect test must fail.
@@ -0,0 +1,58 @@
# Task 04 — WS backstop reads `id` on top-level `task.*` actions
**Status:** done
**Depends on:** —
## Why
`dispatch_scope.go` exists so a *newly added* WS action is scoped by default rather
than only when its author remembers. It reads `task_id`, `session_id` and
`task_environment_id`. `task.state` and `task.move` name their task `id`, so the
backstop parsed no refs and allowed them — the file's own comment predicted this
("an action that invents a fourth name … silently opts out").
The service guards in task 01 are the fix. This closes the class.
## Changes
`internal/gateway/websocket/dispatch_scope.go`
- `authorizeAction(ctx, action, payload)` — takes the action name.
- `scopedActionRefs` gains `ID string \`json:"id"\``, used **only** when
`isTopLevelTaskAction(action)`: the action is `task.` + exactly one more segment.
- Deeper namespaces stay excluded on purpose. `task.plan.revision.get` and
`task.review.finding.update` also carry `id`, but it names a revision or a finding;
treating it as a task ID would deny legitimate reads. Rather than a hand-maintained
action list — which has the same "someone forgot" failure mode the backstop exists
to remove — the rule keys off the namespace depth, so a future `task.<verb>` is
covered without an edit.
- `parseScopedActionRefs` returns ok when any of the four names is present.
`internal/gateway/websocket/client.go` — pass `msg.Action` through.
## Tests
`internal/gateway/websocket/dispatch_scope_test.go`:
- `task.state` / `task.move` / `task.archive` with `{"id": "task-b"}` are refused,
and the task checker is called with `task-b`.
- The owner's identical action is allowed (the checker admits it), so the rule is not
a blanket deny.
- `task.plan.revision.get` and `task.review.finding.update` with `{"id": …}` are
**allowed** — the regression this rule could plausibly cause.
- `task.create` / `task.list` (no `id`) are unaffected.
- A synthetic or identity-less client is unaffected.
## Docs
`apps/backend/CLAUDE.md` — the scoping-model section records which payload names the
backstop reads, including the `task.<verb>` + `id` rule, so the next author knows
what is and is not covered.
## Verification
```
cd apps/backend && CGO_ENABLED=1 go test -tags fts5 -race -count=1 ./internal/gateway/...
```
Mutation: drop the `isTopLevelTaskAction` branch; the `task.state` case must fail.
+22
View File
@@ -51,6 +51,19 @@ the local single-user install with a login screen it never asked for.
from another user's workspace does not grant access. Admins do NOT see other from another user's workspace does not grant access. Admins do NOT see other
users' workspaces (hard privacy; admin is a management role, not a users' workspaces (hard privacy; admin is a management role, not a
visibility role). Pre-auth data is claimed by the admin at setup. visibility role). Pre-auth data is claimed by the admin at setup.
- **Task lifecycle mutations are ownership-checked at the service layer.**
Changing a task's state, moving it between workflows/steps, updating a task
repository's base branch, and archiving or deleting a task (including the
cascade that walks its subtree) all refuse a foreign task before any read or
write reaches the repository. This holds on every transport — HTTP, WebSocket
and MCP — because the check lives below them, and it holds for long-running
deletes, which detach from request cancellation but keep the caller identity.
- **The WebSocket backstop reads `id` on top-level `task.*` actions.** In
addition to `task_id` / `session_id` / `task_environment_id`, an action named
`task.<verb>` (exactly two segments — `task.state`, `task.move`, `task.get`, …)
has its payload `id` treated as a task ID. Deeper namespaces (`task.plan.*`,
`task.review.*`) are untouched: their `id` names a plan, revision or finding,
not a task.
- **Shared surfaces (instance-global by design).** Executors, executor - **Shared surfaces (instance-global by design).** Executors, executor
profiles, environment definitions, agent profiles/agents, editors, prompts, profiles, environment definitions, agent profiles/agents, editors, prompts,
sprites, voice, notification providers, and system pages remain shared sprites, voice, notification providers, and system pages remain shared
@@ -99,6 +112,15 @@ the local single-user install with a login screen it never asked for.
## Scenarios ## Scenarios
- **GIVEN** two users A and B and a task owned by B, **WHEN** A sends
`task.state` or `task.move` over the WebSocket naming that task by `id`,
**THEN** the call is refused, B's task DTO is not serialized into the
response, and no state, position or workflow write reaches the repository.
- **GIVEN** the same pair, **WHEN** A sends `DELETE /api/v1/tasks/{B's task}`
or `POST /api/v1/tasks/{B's task}/archive`, **THEN** the response is 404 and
no task row, subtree row, or workspace-group membership is touched — while
the owner's own delete still runs to completion if the owner's browser
disconnects mid-request.
- **GIVEN** an authenticated Kandev browser session, **WHEN** a protected API - **GIVEN** an authenticated Kandev browser session, **WHEN** a protected API
request receives a Kandev session challenge, **THEN** the browser clears the request receives a Kandev session challenge, **THEN** the browser clears the
stale Kandev identity and navigates to `/login`. stale Kandev identity and navigates to `/login`.