Files
kandev/apps/backend/internal/linear/copy.go
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

77 lines
3.1 KiB
Go

package linear
import (
"context"
"errors"
"fmt"
"time"
)
// ErrSameWorkspace is returned when a copy targets the same workspace it reads
// from.
var ErrSameWorkspace = errors.New("linear: source and target workspaces are the same")
// ErrNothingToCopy is returned when the source workspace has no Linear config.
var ErrNothingToCopy = errors.New("linear: source workspace has no configuration to copy")
// CopyConfigToWorkspace copies the Linear provider config and credential (API
// key) from sourceWorkspaceID to targetWorkspaceID. Watchers are intentionally
// out of scope — only the connection settings and secret are duplicated.
func (s *Service) CopyConfigToWorkspace(ctx context.Context, sourceWorkspaceID, targetWorkspaceID string) (*LinearConfig, error) {
// Authorize BOTH workspaces up front. The integration middleware only
// authorizes the source (workspace_id query param); the target arrives in the
// request body and is otherwise unchecked, so a caller could copy their config
// and credential into another user's workspace by naming its ID here. Guard
// before the target health-row write below, not just before the config write.
sourceWorkspaceID, err := s.resolveWorkspaceID(ctx, sourceWorkspaceID)
if err != nil {
return nil, err
}
targetWorkspaceID, err = s.resolveWorkspaceID(ctx, targetWorkspaceID)
if err != nil {
return nil, err
}
if sourceWorkspaceID == targetWorkspaceID {
return nil, ErrSameWorkspace
}
cfg, err := s.store.GetConfigForWorkspace(ctx, sourceWorkspaceID)
if err != nil {
return nil, fmt.Errorf("read source linear config: %w", err)
}
if cfg == nil {
return nil, ErrNothingToCopy
}
secret, err := s.revealSecret(ctx, sourceWorkspaceID)
if err != nil {
return nil, fmt.Errorf("read source linear secret: %w", err)
}
// An empty Secret means "keep existing" in SetConfigForWorkspace, so copying
// from a workspace without a stored API key would leave the target on its old
// credential paired with the copied settings. Treat that as nothing to copy.
if secret == "" {
return nil, ErrNothingToCopy
}
req := &SetConfigRequest{
AuthMethod: cfg.AuthMethod,
DefaultTeamKey: cfg.DefaultTeamKey,
Secret: secret,
}
// Mark the target unhealthy before the write so the UI doesn't briefly flash
// a stale "connected" state (from a previously connected org) while the async
// probe kicked off by SetConfigForWorkspace validates the copied credential.
if err := s.store.UpdateAuthHealthForWorkspace(ctx, targetWorkspaceID, false, "", "", time.Now().UTC()); err != nil {
return nil, fmt.Errorf("reset target linear health: %w", err)
}
cfgOut, err := s.SetConfigForWorkspace(ctx, targetWorkspaceID, req)
if err != nil {
// The write failed, so the target's config is unchanged but we already
// flipped its health row. Re-probe asynchronously (best effort) so the row
// reflects the target's real state again rather than a spurious
// "unhealthy", without delaying the error response by the probe timeout.
// Detach from the request context, which may already be cancelled.
go s.RecordAuthHealthForWorkspace(context.Background(), targetWorkspaceID)
return nil, err
}
return cfgOut, nil
}