116f7557dd
* fix(auth): scope integration config/watch routes to workspace owner
The third-party integration route group is guarded by its own
query-only middleware (integrationWorkspaceScopeMiddleware), which
authorizes only the workspace_id query param and falls through when it
is absent. Two shapes bypassed it entirely under enforced auth:
- POST /{jira,linear,slack}/config/copy takes the copy TARGET from the
request body (targetWorkspaceId), which no layer checked. A caller
could copy their own connection settings + credential into another
user's workspace by naming its ID, after which that workspace's
identity-less poller would run against attacker-controlled config
(Jira/Linear issue watchers auto-creating tasks from attacker issues;
Slack bot token/webhook). CopyConfigToWorkspace now authorizes BOTH
source and target up front, before the pre-write UpdateAuthHealth
side effect. Mirrors github/copy.go.
- GET /{jira,linear}/watches/issue with workspace_id omitted returned
ListAllIssueWatches across every workspace (JQL/filters, repo, agent
and executor profile IDs, spawn prompt). It now filters to the
caller's workspaces; an identity-less internal caller still sees all,
preserving poller use.
- The config Get/Set/Delete entry points resolved an unowned global
"default" workspace when workspace_id was omitted; they now
resolve+authorize via resolveWorkspaceID.
Each service gains SetWorkspaceAuthorizer(taskSvc.AuthorizeWorkspaceAccess),
wired in backendapp; nil (unit tests, auth disabled) stays unscoped.
Denials surface repoerrors.ErrWorkspaceNotFound, mapped to 404 by the
handlers (no existence leak). Recorded the query-only-middleware rule in
integrations/AGENTS.md, including that Sentry and GitLab's
ListAllIssueWatches still need the same filter.
Regression tests added per package and mutation-verified: dropping only
the copy up-front guard fails on the victim health-flip witness;
dropping both guards fails on the config/secret witness.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(auth): scope integration watch-write and data-plane paths too
Review of the prior commit (Codex + Claude, converging) found the same
body/query workspace-bypass class in surfaces the first pass missed:
- CreateIssueWatch / UpdateIssueWatch (Jira, Linear) never authorized the
workspace. CreateIssueWatch takes WorkspaceID from the request body, so
a caller could plant a persistent watch in another user's workspace that
repeatedly spawns tasks with that user's credentials and an
attacker-supplied filter/prompt. Now authorize req.WorkspaceID (create)
and the loaded row's workspace (update). The by-ID mutation/trigger/reset
handlers were already guarded by assertWatchInWorkspace + the
query-authorizing middleware; the service-layer guard adds defense in
depth and covers non-HTTP callers.
- TestConnectionForWorkspace (Jira, Linear, Slack) resolved the default
workspace with no auth, so an omitted workspace_id + attacker-supplied
siteUrl could exfiltrate that workspace's stored token. Now authorizes
after normalize and returns a hard error (404), not a swallowed
{OK:false}.
- Data-plane reads funnel through clientFor, which now resolves+authorizes
the workspace — scoping search/projects/statuses in one chokepoint.
- filterIssueWatchesByAccess treated ANY authorizer error as a denial,
silently dropping watches on a transient failure and returning a
truncated 200. It now drops only on ErrWorkspaceNotFound and propagates
everything else.
Denials map to 404 via the shared writeClientError / writeIssueWatchError
sinks and the two list/test handlers. Renamed the test helper
denyExcept -> denyOnly (it denies exactly the named workspaces). Added
per-package regression tests for create/update/list denial, authorizer
error propagation, connection-test denial, and data-plane denial;
mutation-verified the CreateIssueWatch guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>