Files
kandev/docs/specs/workflow-sync-workspace-authz/spec.md
T

97 lines
6.4 KiB
Markdown

---
status: building
created: 2026-08-06
owner: nova28
---
# Workflow Sync — Per-User Workspace Authorization
## 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 a `workspace_id` the caller does not own.
- The workflow-sync config write endpoint (`POST /api/v1/workflow-sync/config`) SHALL deny writing a
config for a `workspace_id` the caller does not own.
- The workflow-sync config delete endpoint (`DELETE /api/v1/workflow-sync/config`) SHALL deny
deleting a config for a `workspace_id` the caller does not own.
- The force-sync endpoint (`POST /api/v1/workflow-sync/sync`) SHALL deny triggering a sync for a
`workspace_id` the 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_id` values
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_id` is empty or matches the caller.
- Denial uses the `ErrWorkspaceNotFound` sentinel — 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.Service` directly
without calling `SetWorkspaceAuthorizer`): 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 user `bob`) each with their own
workflow-sync config, **WHEN** `bob` calls `GET /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** `bob` calls
`POST /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** `bob` calls `DELETE /api/v1/workflow-sync/config?workspace_id=<A>`
or `POST /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`, **WHEN** `alice` calls any of the four endpoints against her
own workspace, **THEN** the request succeeds exactly as before this fix.
- **GIVEN** `features.auth` is 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`/`SyncWorkspace` invoked internally) to carry or
require an identity — it is a correct internal caller today and stays unscoped by the existing
`callerScope` rule.
- Any change to GitHub or GitLab repository-level authorization (App installation scope, PAT scope)
— this spec is strictly about the `workspace_id` ownership 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.