7.1 KiB
status, system, created, owners
| status | system | created | owners | |
|---|---|---|---|---|
| active | tasks | 2026-08-06 |
|
Workflow Sync — Per-User Workspace Authorization Requirements
Overview
This document is the migrated task-system source for the capability. The source detail below remains authoritative while the system is migrated into separate requirement and design records.
Requirements
REQ-TASKS-WORKFLOW-SYNC-WORKSPACE-AUTHZ-001: Workflow Sync — Per-User Workspace Authorization
Intent: Preserve the observable task or workflow behavior recorded by the legacy specification.
Acceptance criteria
- AC-TASKS-WORKFLOW-SYNC-WORKSPACE-AUTHZ-001.1: When a consumer uses this capability, the system shall provide the observable behavior and exclusions documented below.
Migrated source detail
Why
Every other per-workspace integration service (GitHub, Jira, Linear, Slack, Azure DevOps,
Automation) enforces that a caller-supplied workspace_id belongs to the requesting user inside
the service itself — SetWorkspaceAuthorizer + a check at the top of every user-facing method —
in addition to the global integrationWorkspaceScopeMiddleware
(internal/backendapp/main.go/helpers.go) that already gates their HTTP routes by path prefix.
internal/workflowsync is the one integration service that never adopted the service-layer half of
that pattern: its Service methods take a caller-supplied workspace_id straight from the caller
with no ownership check of their own.
Correction (found during review): an earlier draft of this spec claimed the HTTP endpoints
(/api/v1/workflow-sync/config, /api/v1/workflow-sync/sync) had no ownership check at all and were
directly exploitable by an authenticated member. That was wrong — those paths are already listed in
integrationWorkspacePrefixes and covered by the global middleware, which authorizes before any
workflow-sync handler runs, exactly like /api/v1/jira/, /api/v1/github/, etc. The actual gap this
spec closes is narrower: workflowsync.Service has no authorization boundary of its own, unlike
every sibling integration service, so (a) it is the one integration whose safety depends entirely on
the route-level middleware never being bypassed, misconfigured, or missing this path prefix, with no
independent second layer, and (b) any future non-HTTP caller of the service (a WS handler, an MCP
tool, a plugin, a different mount point) would inherit no protection at all, unlike every sibling
service where the constructor-level check still applies regardless of how the call arrived. This is
a defense-in-depth and consistency fix, not a closure of a currently open HTTP exploit.
What
- The workflow-sync config read endpoint (
GET /api/v1/workflow-sync/config) SHALL deny a request for aworkspace_idthe caller does not own. - The workflow-sync config write endpoint (
POST /api/v1/workflow-sync/config) SHALL deny writing a config for aworkspace_idthe caller does not own. - The workflow-sync config delete endpoint (
DELETE /api/v1/workflow-sync/config) SHALL deny deleting a config for aworkspace_idthe caller does not own. - The force-sync endpoint (
POST /api/v1/workflow-sync/sync) SHALL deny triggering a sync for aworkspace_idthe caller does not own. - A denied request SHALL be indistinguishable from a request against a nonexistent workspace (404, no existence leak) — consistent with every other per-user-scoped integration in this codebase.
- Requests from an unscoped caller — no identity in context (internal callers: the periodic sync
poller, the event bus) or a synthetic identity (auth disabled) — SHALL continue to work exactly as
before this fix; this feature only closes the gap for caller-supplied
workspace_idvalues reaching these endpoints from an authenticated HTTP request. - Workspaces with no owner (
owner_id = '', pre-auth rows not yet claimed by the setup wizard) SHALL remain visible to any authenticated member, matching the existing workspace-visibility rule used everywhere else.
Permissions
Reuses the existing per-user workspace scoping rule documented in
apps/backend/AGENTS.md ("Opt-in authentication & per-user scoping"):
- No identity in context, or a synthetic identity → unscoped (today's pre-auth behavior).
- Real identity → the workspace is visible only if its
owner_idis empty or matches the caller. - Denial uses the
ErrWorkspaceNotFoundsentinel — the same value the task service, GitHub, Jira, Linear, Slack, and Azure DevOps services already return for this class of denial — so a foreign workspace and a missing workspace produce the same observable response.
This spec does not introduce a new permission concept; it applies the existing one to a service that missed it.
Failure modes
- Caller lacks access to the target workspace: the request is denied with the sanitized not-found response before the config store (or the sync/apply pipeline) is touched. No config contents, repo identity, or sync status for the foreign workspace are ever included in the denial.
- Workspace authorizer not wired (e.g. a unit test constructing
workflowsync.Servicedirectly without callingSetWorkspaceAuthorizer): the service behaves as unscoped, matching every other integration service's nil-safe default — this preserves existing tests and call sites that don't go through the full app-boot wiring.
Scenarios
- GIVEN two workspaces A (owned by user
alice) and B (owned by userbob) each with their own workflow-sync config, WHENbobcallsGET /api/v1/workflow-sync/config?workspace_id=<A>, THEN the response is 404 and contains no data about workspace A's config. - GIVEN the same setup, WHEN
bobcallsPOST /api/v1/workflow-sync/config?workspace_id=<A>with an attacker-controlled repo payload, THEN the request is denied (404) and workspace A's stored config is unchanged. - GIVEN the same setup, WHEN
bobcallsDELETE /api/v1/workflow-sync/config?workspace_id=<A>orPOST /api/v1/workflow-sync/sync?workspace_id=<A>, THEN both are denied (404) and workspace A's config/sync state is unaffected. - GIVEN workspace A owned by
alice, WHENalicecalls any of the four endpoints against her own workspace, THEN the request succeeds exactly as before this fix. - GIVEN
features.authis disabled (synthetic identity) or the caller is an internal caller (no identity in context, e.g. the periodic sync poller), WHEN any workflow-sync entry point runs, THEN it is unaffected — no denial, same behavior as before this fix.
Out of scope
- Changing the periodic poller (
SyncDueConfigs/SyncWorkspaceinvoked internally) to carry or require an identity — it is a correct internal caller today and stays unscoped by the existingcallerScoperule. - Any change to GitHub or GitLab repository-level authorization (App installation scope, PAT scope)
— this spec is strictly about the
workspace_idownership boundary on the workflow-sync HTTP surface. - Adding workspace authorization to any other integration package — all of them already have it.
- New data model or schema changes — no persistent state changes.