Files
kandev/apps/backend/internal/integrations/AGENTS.md
T
João Salavisa 116f7557dd fix(auth): scope integration config/watch routes to workspace owner (#1960)
* 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>
2026-07-26 16:29:58 +01:00

7.1 KiB

Adding a new third-party integration

Jira and Linear are the model: per-workspace credentials, a 90s auth-health poller, a settings page with status banner + reconnect CTA, link/import buttons that gate on availability. New integrations should reuse the shared shapes in this folder rather than copying either.

This file covers both halves of the playbook (backend + frontend) so the pattern is readable in one place.

Backend (apps/backend/internal/<name>/)

  • Mirror the package layout: service.go, store.go, client.go, provider.go, handlers.go, models.go, poller.go. Expose Provide(writer, reader *sqlx.DB, secrets SecretStore, eventBus bus.EventBus, log *logger.Logger) (*Service, func() error, error). Pass nil for eventBus when the integration doesn't publish events; both Jira and Linear take and use it for issue-watch publishing.
  • Use internal/integrations/secretadapter instead of writing your own upsert wrapper around secrets.SecretStore. The adapter satisfies any per-integration SecretStore interface shaped as {Reveal, Set, Delete, Exists}.
  • Use internal/integrations/healthpoll for the auth-health loop. Implement the Prober interface (ListConfiguredWorkspaces + RecordAuthHealth) on a small adapter and let healthpoll.New("name", prober, log) own Start/Stop/ticker. Keep integration-specific loops (JQL polling, webhook reconciliation, etc.) separate, like jira's issue-watch loop.
  • Wire the service via a per-domain init<Name>Service(...) helper in cmd/kandev/services.go, not inline in provideServices.
  • Ship a mock_client.go + mock_controller.go next to the real client. Provide branches on KANDEV_MOCK_<NAME>=true and returns the in-memory client; RegisterMockRoutes(router, svc, log) mounts /api/v1/<name>/mock/* only when the service was built with the mock. The e2e backend fixture sets the env var so Playwright tests drive the mock via apiClient.mock<Name>*() helpers — see jira/linear for the layout.
  • If the watcher has a per-watch MaxInflightTasks cap, implement WatchMetadataKey() on the integration's WatcherSource (internal/orchestrator/source_<name>.go) to return the task-metadata watch-id key — the same constant the source writes into BuildTaskRequest's Metadata map (e.g. sentry_issue_watch_id). The throttle gate (acquireWatcherSlot) passes that key to CountOpenWatcherCreatedTasks(metadataKey, watchID), which counts open tasks for the watch. The task repository (internal/task/repository/sqlite/task.go) is intentionally agnostic of integrations — it keys purely on the metadata key, so no repository change is needed per integration. If WatchMetadataKey() returns "" the gate treats the watch as uncapped and the cap silently never applies (Sentry originally shipped with this gap — the cap was stored and validated but never enforced because the repository's old integration switch had no sentry case).

Frontend (apps/web/)

  • Hooks live under hooks/domains/<name>/, not components/<name>/.
  • Use hooks/domains/integrations/use-integration-availability.ts and use-integration-enabled.ts — each integration's useXAvailable / useXEnabled should be a one-line wrapper passing the storage key + sync event + config-fetch function.
  • Settings page reuses <IntegrationAuthStatusBanner> (components/integrations/auth-status-banner.tsx).
  • "Auth required / reconnect" UI reuses <IntegrationAuthErrorMessage> (components/integrations/auth-error-message.tsx) — supply the integration's display name, regex check, and reconnect href.
  • Link / import popovers reuse <ValidatedPopover> (components/integrations/validated-popover.tsx) — supply the icon, label, key regex, fetch function, and success callback.

Per-user scoping (the integration route group is query-only)

The global integrationWorkspaceScopeMiddleware (backendapp/helpers.go) only authorizes the workspace named in the workspace_id query param, and it falls through when that param is absent. So two shapes bypass it and MUST guard themselves at the service layer:

  • A body-supplied workspace — e.g. POST /config/copy's targetWorkspaceId. The middleware authorizes the source (query) but never the target (body), so a copy must call authorizeWorkspaceAccess on both source and target, up front — before any per-workspace side effect (the pre-write UpdateAuthHealth flip counts). Mirror github/copy.go.
  • An omitted workspace_id — normalizeWorkspaceID("") resolves a global default workspace not owned by the caller. User-facing config entry points resolve+authorize in one step via resolveWorkspaceID(ctx, id) instead of bare normalizeWorkspaceID. The unscoped ListAllIssueWatches (reached by omitting workspace_id) filters to the caller's workspaces; an identity-less internal caller (nil authorizer) still sees all, preserving poller use.

Wire the boundary with SetWorkspaceAuthorizer(taskSvc.AuthorizeWorkspaceAccess) in backendapp/helpers.go; nil (unit tests, auth disabled) means unscoped. Denials surface repoerrors.ErrWorkspaceNotFound, which handlers map to 404 (no existence leak). Jira/Linear/Slack follow this; Sentry and GitLab's ListAllIssueWatches still need the same filter — the WS gateway backstop does not read workspace_id, so GitLab's workspace_id-keyed WS list is unscoped too.

Where Jira and Linear deliberately diverge

  • Issue model: Jira uses transitions + JQL; Linear uses state IDs + structured filters. Don't merge these schemas — the upstream APIs are genuinely different.
  • Watch filter persistence: Jira stores the JQL string verbatim; Linear stores the structured SearchFilter as JSON in filter_json (Linear has no JQL equivalent). The orchestrator emits NewJiraIssueEvent / NewLinearIssueEvent respectively and dedups by issue key (Jira) vs identifier (Linear).
  • Health column extras: Linear's linear_configs row carries an org_slug captured from successful probes; Jira's row does not.
  • Sentry — multiple instances per workspace: unlike the one-config-per-workspace integrations, sentry_configs is keyed by an instance id (UUID) with a workspace_id column + UNIQUE(workspace_id, name), so a workspace holds several named instances. Secrets are keyed sentry:instance:<id>:token. Issue watches carry a nullable sentry_instance_id FK (ON DELETE RESTRICT); the bound instance is immutable. The HTTP surface is instance CRUD under /api/v1/sentry/instances?workspace_id= (no install-wide /config); deleting an in-use instance is 409 SENTRY_INSTANCE_IN_USE. See ADR-0030.
  • Azure DevOps — direct read-only REST and task PR persistence: internal/azuredevops accepts only canonical Azure DevOps Services organization URLs, stores one encrypted PAT per workspace through secretadapter, and uses REST API 7.1 without gh or az. It reuses healthpoll and the standard mock-provider gate, but intentionally has no watch loop. Azure pull-request summaries are persisted against tasks in the integration package; provider-native feedback remains transient and Azure-specific.