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.
- **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.
- **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.
- **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
// full reload.
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 {
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
// 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.
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",
zap.String("action", msg.Action),
zap.String("user_id", c.identity.UserID),
@@ -3,6 +3,7 @@ package websocket
import (
"context"
"encoding/json"
"strings"
"github.com/kandev/kandev/internal/auth/authn"
)
@@ -26,16 +27,22 @@ import (
// 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
// 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
// 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.
//
// 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 {
TaskID string `json:"task_id"`
SessionID string `json:"session_id"`
TaskEnvironmentID string `json:"task_environment_id"`
ID string `json:"id"`
}
// 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
// clients and the synthetic identity injected while auth is disabled keep the
// 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 {
return nil
}
@@ -52,14 +59,18 @@ func (c *Client) authorizeAction(ctx context.Context, payload json.RawMessage) e
if !ok {
return nil
}
taskID := refs.TaskID
if taskID == "" && isTopLevelTaskAction(action) {
taskID = refs.ID
}
policy := c.hub.authPolicy
if refs.SessionID != "" && policy.Subscriptions.Session != nil {
if err := policy.Subscriptions.Session(ctx, refs.SessionID); err != nil {
return err
}
}
if refs.TaskID != "" && policy.Subscriptions.Task != nil {
if err := policy.Subscriptions.Task(ctx, refs.TaskID); err != nil {
if taskID != "" && policy.Subscriptions.Task != nil {
if err := policy.Subscriptions.Task(ctx, taskID); err != nil {
return err
}
}
@@ -83,7 +94,25 @@ func parseScopedActionRefs(payload json.RawMessage) (scopedActionRefs, bool) {
// Non-object payloads (arrays, bare values) name no resource.
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
@@ -18,8 +18,9 @@ import (
// dispatchScopeFixture wires a hub whose auth policy grants only user-a's
// task-a / sess-a and denies everything else.
type dispatchScopeFixture struct {
client *Client
handled *bool
client *Client
handled *bool
dispatcher *ws.Dispatcher
}
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.hub = h
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) {
@@ -198,25 +214,30 @@ func TestParseScopedActionRefs(t *testing.T) {
wantTask string
wantSess string
wantEnv string
wantID string
}{
{`{"task_id":"t","session_id":"s"}`, true, "t", "s", ""},
{`{"task_id":"t"}`, true, "t", "", ""},
{`{"session_id":"s"}`, true, "", "s", ""},
{`{"task_environment_id":"e"}`, true, "", "", "e"},
{`{"task_id":"","task_environment_id":"e"}`, true, "", "", "e"},
{`{}`, false, "", "", ""},
{`{"task_id":""}`, false, "", "", ""},
{`[1,2]`, false, "", "", ""},
{``, false, "", "", ""},
{`{"task_id":"t","session_id":"s"}`, true, "t", "s", "", ""},
{`{"task_id":"t"}`, true, "t", "", "", ""},
{`{"session_id":"s"}`, true, "", "s", "", ""},
{`{"task_environment_id":"e"}`, true, "", "", "e", ""},
{`{"task_id":"","task_environment_id":"e"}`, true, "", "", "e", ""},
{`{"id":"t"}`, true, "", "", "", "t"},
{`{"id":"t","state":"COMPLETED"}`, true, "", "", "", "t"},
{`{}`, false, "", "", "", ""},
{`{"task_id":""}`, false, "", "", "", ""},
{`{"id":""}`, false, "", "", "", ""},
{`[1,2]`, false, "", "", "", ""},
{``, false, "", "", "", ""},
}
for _, tc := range cases {
refs, ok := parseScopedActionRefs(json.RawMessage(tc.payload))
if 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 {
t.Errorf("payload %q refs = %+v, want task=%q session=%q env=%q",
tc.payload, refs, tc.wantTask, tc.wantSess, tc.wantEnv)
if refs.TaskID != tc.wantTask || refs.SessionID != tc.wantSess ||
refs.TaskEnvironmentID != tc.wantEnv || refs.ID != tc.wantID {
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")
}
}
// 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
// sequential blocker chain (handoffs phase 5). Optional — nil disables
// 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) {
h.handoffSvc = svc
if svc != nil && h.service != nil {
svc.SetTaskAccessChecker(h.service.AuthorizeTaskAccess)
}
}
func (h *TaskHandlers) SetWorkspaceQuarantineRestorer(restorer WorkspaceQuarantineRestorer) {
@@ -1652,7 +1652,14 @@ func (h *TaskHandlers) httpMoveTask(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()
taskID := c.Param("id")
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.
// 5. Evaluate cleanup once per affected group.
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 == "" {
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
// for symmetry with archive.
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 == "" {
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
// left archived — fixing the resurrection bug from the original review.
func (s *HandoffService) UnarchiveTaskTree(ctx context.Context, rootID string) (*CascadeOutcome, error) {
if err := s.authorizeTask(ctx, rootID); err != nil {
return nil, err
}
if rootID == "" {
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
eventPublisher TaskEventPublisher
resourceCleaner TaskResourceCleaner
taskAccessCheck func(ctx context.Context, taskID string) error
logger *logger.Logger
parentLock parentMutex
workspaceGroupLock parentMutex
@@ -278,6 +279,28 @@ func (s *HandoffService) SetTaskResourceCleaner(c TaskResourceCleaner) {
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
// nil — when nil the corresponding methods return empty results /
// 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 {
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)
if err != nil {
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,
// and publishes a task.state_changed event
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)
if err != nil {
return nil, err
@@ -430,6 +433,9 @@ func (s *Service) MoveTaskWithOptions(
position int,
opts MoveTaskOptions,
) (*MoveTaskResult, error) {
if err := s.authorizeTaskID(ctx, id); err != nil {
return nil, err
}
task, err := s.tasks.GetTask(ctx, id)
if err != nil {
return nil, err
@@ -684,6 +690,19 @@ func (s *Service) promoteFeederQueuedTask(ctx context.Context, candidate *models
s.publishTaskMovedEvent(ctx, candidate, oldWorkflowID, fromStepID, targetStep.ID, "")
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 {
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))
@@ -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
users' workspaces (hard privacy; admin is a management role, not a
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
profiles, environment definitions, agent profiles/agents, editors, prompts,
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
- **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
request receives a Kandev session challenge, **THEN** the browser clears the
stale Kandev identity and navigates to `/login`.