Skip to content

feat(linear): multi-workspace support — add-workspace, per-workspace webhook signing, update-webhook-secret#200

Draft
isadeks wants to merge 21 commits into
aws-samples:mainfrom
isadeks:feat/linear-add-workspace
Draft

feat(linear): multi-workspace support — add-workspace, per-workspace webhook signing, update-webhook-secret#200
isadeks wants to merge 21 commits into
aws-samples:mainfrom
isadeks:feat/linear-add-workspace

Conversation

@isadeks
Copy link
Copy Markdown
Contributor

@isadeks isadeks commented May 27, 2026

Summary

Three CLI commands + a webhook architecture fix that together make ABCA's Linear integration usable against multiple Linear workspaces from one stack.

  • bgagent linear add-workspace <slug> — install OAuth in additional workspaces without re-running the full setup wizard
  • bgagent linear update-webhook-secret <slug> — rotate or fix a workspace's webhook signing secret without re-running OAuth
  • Webhook receiver now picks the signing secret per organizationId (workspace-scoped Linear webhooks were silently dropping signatures from any workspace beyond the first)

Why

Single-workspace deploys work great after #160. The moment you try to onboard a second workspace, two things break:

  1. OAuth Client ID/Secret entrysetup is heavy (full OAuth dance, registry write, user mapping, webhook prompt). add-workspace skips the parts you've already done.
  2. Webhook signature verification — Linear generates a fresh signing secret per webhook subscription, and webhook subscriptions are workspace-scoped. Storing a single stack-wide signing secret means events from any workspace beyond the one whose secret was pasted silently fail HMAC verification.

Both surfaced concretely while onboarding a second Linear workspace this week — add-workspace got an access_denied from Linear when the OAuth app was private to the origin workspace; webhook events 401'd silently when the new workspace was finally authorized.

What's in here (4 logical pieces)

1. bgagent linear add-workspace <slug>

Lighter-weight install for additional workspaces. Reuses an existing workspace's OAuth Client ID as a default (Enter to keep it, or paste a new one for a per-workspace OAuth app). Always interactive — secrets never on the command line. Refuses to silently overwrite an already-onboarded workspace's registry row.

2. Per-workspace webhook signing secret

StoredOauthToken / StoredLinearOauthToken get an optional webhook_signing_secret field. Webhook receiver:

  1. Parses the body to extract `organizationId` (untrusted — used only to select WHICH secret to verify against)
  2. Looks up that workspace's per-workspace secret. If found and signature matches → trusted. If found and mismatches → 401, no fallback (cross-workspace impersonation defense).
  3. If no per-workspace secret → falls back to the stack-wide `LinearWebhookSecret` (back-compat for installs predating per-workspace signing).

Cross-language schema parity test extended to track required vs optional fields so the validator const can't drift from the interface.

3. `bgagent linear update-webhook-secret `

Rotation / recovery primitive. The OAuth dance can't re-run when the app is already installed in a workspace — Linear returns `access_denied`. This command sidesteps it: read the existing OAuth bundle, prompt for the new signing secret, re-upsert the bundle. No DDB writes, no Linear API calls, no stack-wide writes.

This made `setup --rotate-webhook-secret` redundant — the flag is removed in this PR.

4. `list-projects` fixes + setup-guide rewrite

  • `list-projects` now reads from the OAuth secret model (was still pointing at the parked PAK secret)
  • Empty-workspace message clearer: "Workspace 'X' has no projects yet" instead of "No projects visible"
  • Setup guide rewritten end-to-end:
    • Pruned parked Phase 2.0a flow (oauth-register-workspace, AgentCore Identity callbacks)
    • New step-by-step walkthrough for adding a second workspace, with the OAuth-app-public-vs-private decision up front
    • New section: "How webhook signature verification works" with the trust model explained
    • Troubleshooting points at `update-webhook-secret` for rotation cases

Backward compatibility

Designed for zero impact on existing single-workspace deploys:

  • Webhook receiver tries per-workspace path first, falls back to stack-wide. Existing installs keep working without re-onboarding.
  • Re-running `setup` on an existing single-workspace install auto-mirrors the stack-wide signing secret onto the workspace's OAuth bundle (no prompt). After that the workspace works on the per-workspace path.
  • Multi-workspace was structurally impossible before this PR; nobody could be relying on the broken behavior.

Trust model

The `organizationId` in the webhook body is attacker-controlled. But it only selects which secret to verify against — they still need the matching signing secret to forge a valid signature. The no-fallback-on-per-workspace-mismatch rule prevents an attacker from bypassing a per-workspace secret by tricking the receiver into re-checking against the stack-wide one.

The `linear-webhook-multi-workspace.test.ts` suite covers this:

  • workspace A signed with workspace B's secret → 401, no dispatch
  • registry status `revoked` + signature matches the stored per-workspace secret → 401 (revoked workspaces don't trigger tasks)

Tests

  • 6 new tests in `linear-webhook-multi-workspace.test.ts` covering happy multi-workspace, cross-workspace impersonation rejection, registry-miss fallback, missing-field fallback, revoked status
  • 5 new tests in `linear.test.ts` covering `findReusableOauthAppCredentials` (empty registry, happy path, missing fields, corrupted JSON, missing SecretString)
  • Schema parity test extended to allow optional fields and verify required-set matches the validator const
  • Existing 12 webhook tests + 27 resolver tests all pass unchanged (back-compat path verified)

CDK: 1789 / 1789. CLI: 286 / 286 (after rebuild).

Smoke test

Deployed to a personal AWS account against two Linear workspaces with separate OAuth apps. Verified:

  • Setup against workspace A: writes per-workspace secret, mirrors to stack-wide.
  • `add-workspace` against workspace B with a different OAuth app: prompts for new client_id/secret, completes OAuth dance, refuses to silently overwrite registry row.
  • Webhook from workspace A → verified via per-workspace path → dispatched.
  • Webhook from workspace B → verified via per-workspace path → dispatched.
  • (Negative) workspace A signed with workspace B's secret → 401 (manual curl).
  • `update-webhook-secret` after rotating workspace B's signing secret in Linear: bundle updated, next event verified successfully.

Test plan

  • CI green
  • Maintainer review of trust-model section in the setup guide + the no-fallback-on-mismatch invariant in webhook handler
  • Spot-check that re-running `setup` on a single-workspace install auto-migrates without prompting

isadeks and others added 21 commits May 26, 2026 19:08
Lets operators install the Linear OAuth app in additional workspaces
without re-pasting the same client_id/client_secret they already typed
during the initial bgagent linear setup.

The OAuth app's client_id/client_secret are workspace-independent —
Linear scopes consent per-workspace, not per-app. add-workspace scans
the LinearWorkspaceRegistryTable for the first active row and reads
those credentials from its per-workspace SM secret, avoiding the
re-prompt. Override flags (--client-id, --client-secret) cover the
edge case of running the same ABCA stack against two unrelated Linear
orgs that each have their own OAuth app.

Differs from setup in three ways:
- Refuses if no active workspace exists yet (use setup first)
- Skips the webhook-signing-secret prompt (one stack-wide secret
  covers all workspaces against the same OAuth app + receiver URL)
- Refuses to silently overwrite an already-onboarded workspace's
  registry row — a wrong-account login would otherwise produce a
  confusing duplicate

Adds findReusableOauthAppCredentials helper + 5 jest tests covering:
empty registry → null, happy path, missing client_id/secret in old
secrets → null, corrupted JSON → null, missing SecretString → null.
…space OAuth-app option

The previous version described the parked Phase 2.0a flow (AgentCore
Identity credential providers, oauth-register-workspace command, AWS-
hosted callback URL, https://localhost:8443 with self-signed cert).
None of that runs in the shipped 2.0b-O2 codebase — it's all per-
workspace Secrets Manager + plain HTTP localhost:8080 callback.

Changes:
- 'How it works' rewritten against the SM-direct flow, with a callout
  noting Phase 2.0a is parked
- Step 1 (oauth-register-workspace) deleted — not a real command
- Step 2 (Linear OAuth app) updated to point at localhost:8080/oauth/
  callback (the actual callback URL); flagged that app-template's
  printed value is still the parked-flow placeholder
- Step 4 (setup) rewritten to describe the PKCE → localhost:8080 →
  code exchange → SM upsert dance that actually ships
- Step 5 (webhook signing secret) folded into setup's interactive
  prompt as Step 3, matching how the wizard actually works
- Steps 6-8 renumbered to 4-6
- 'Adding additional Linear workspaces' expanded with the public-
  vs-per-workspace OAuth-app trade-off and the Option B path
  (--client-id/--client-secret overrides) for keeping apps private —
  this is the wrinkle that bit during demo-abca install where
  maguireb's private app rejected cross-workspace authorization
- Troubleshooting + quotas sections updated to reference SM secrets
  and the refresh+race-recovery flow rather than AgentCore Identity
- Stale Step 7 cross-references updated to Step 5

Followup task: update bgagent linear app-template to print
http://localhost:8080/oauth/callback as the default callback (today
it prints a placeholder for the parked AWS-hosted-callback flow).
…--client-secret flags

Secrets-on-command-line is a footgun: --client-secret leaks into
~/.zsh_history/.bash_history. The auto-detect-from-existing-workspace
default also wasn't always right — when each workspace runs its own
private OAuth app (the common case in multi-org production setups),
auto-detect silently picks the wrong credentials and fails with a
confusing "Could not find OAuth client" error after the OAuth dance.

New flow:
- Always prompt. Find any existing active workspace, show its
  client_id as the default in [brackets].
- Press Enter to accept the default (single shared OAuth app installed
  in multiple workspaces — the public-app case).
- Type a new client_id to install with a different OAuth app per
  workspace (the private-app case). Then promptSecret for the new
  client_secret.
- If the user typed the same client_id as the default, reuse the
  existing client_secret without prompting (no point asking the user
  to re-paste a secret we already have).

New helper promptLine(label, defaultValue?) for non-secret input with
default-on-empty semantics. promptSecret unchanged — used only for
client_secret.

Removes the --client-id and --client-secret flags entirely. Existing
flags retained: --region, --stack-name, --no-browser, --no-actor-app.
The interactive prompt previously printed 'found <slug>' and named the
source workspace in the explanation hint. The slug still appears as the
default value in [brackets] (structurally necessary), but no longer
leaks into instructional prose where a generic phrasing works just as
well.
… promptSecret

Previous implementation used readline.createInterface + rl.close(), which
leaves stdin in EOF state. Chaining a promptLine call followed by a
promptSecret call (which add-workspace does for client_id then
client_secret) makes the second readline interface fire 'close'
immediately and reject with 'No input provided.'

Switch to the same raw-mode stdin pattern as promptSecret: register a
'data' handler, accumulate characters, echo each one (visibly, since
this is for non-secret input), unwind cleanly on Enter. Both prompts
now manage stdin consistently and chain without state leakage.
The command still pulled from the parked PAK secret
(`LinearApiTokenSecretArn`), which we removed in Phase 2.0b. Symptom:
`Could not find LinearApiTokenSecretArn in stack outputs.`

Rewrite to scan Secrets Manager for `bgagent-linear-oauth-*` secrets
and query each workspace's projects with its own OAuth token. Supports
`--slug <slug>` to scope to one workspace; without it, queries every
installed workspace and labels each project with its source.

Also: switch to the `Bearer <token>` auth header and the
`teams(first: 1) { nodes { name } }` shape (the old `team` field on
Project no longer exists in Linear's GraphQL).

Adds a `LINEAR_OAUTH_SECRET_PREFIX` const in `linear-oauth.ts` to
keep the secret-name contract in one place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
'No Linear projects visible to any installed workspace' read like an
OAuth-scope or IAM problem when the API call succeeded — the workspace
just has zero projects. Differentiate the single-workspace and
multi-workspace cases and tell the user what to do (create a project).
Linear generates a fresh signing secret per webhook subscription, and
webhook subscriptions are workspace-scoped. The previous single
stack-wide LinearWebhookSecret could only verify events from the
workspace that owned its value — events from any other workspace
silently failed signature verification. add-workspace shipped earlier
today made this concrete: demo-abca couldn't trigger tasks because
its events failed verification against maguireb's signing secret.

Schema: add optional `webhook_signing_secret` to StoredOauthToken (TS
Lambda) and StoredLinearOauthToken (TS CLI). Optional preserves
back-compat with installs predating this change. Cross-language
parity test extended to allow optional fields and check that the
required-fields validator const matches the interface's required set.

Webhook receiver: parse body once, peek at organizationId
(untrusted — used only to select WHICH secret to verify against),
call new verifyLinearRequestForWorkspace which returns 'verified',
'mismatch', or 'no-per-workspace-secret'. On 'verified': dispatch.
On 'mismatch': reject 401 (NO fallback — would let an attacker
bypass the per-workspace secret by also matching stack-wide). On
'no-per-workspace-secret': fall through to the existing stack-wide
verifyLinearRequest path for back-compat.

CDK: webhook receiver Lambda gets registry table read + SM
GetSecretValue on the bgagent-linear-oauth-* prefix +
LINEAR_WORKSPACE_REGISTRY_TABLE_NAME env var. Stack-wide secret
left in place (single-workspace fallback path).

CLI setup: now writes the webhook signing secret to BOTH the per-
workspace OAuth bundle AND (on first install only) the stack-wide
secret. Re-running setup on an existing single-workspace install
auto-mirrors the stack-wide value into the per-workspace bundle —
zero-config migration. --rotate-webhook-secret re-prompts.

CLI add-workspace: always prompts for the workspace's signing
secret (no shared secret to reuse). Refuses to overwrite the
stack-wide secret since multi-workspace installs can't
meaningfully share one stack-wide value.

Tests:
- Multi-workspace test file with 6 cases including the critical
  cross-workspace impersonation rejection (workspace A signed with
  workspace B's secret → 401, lambda not invoked).
- Single-workspace back-compat: registry miss → fallback works.
- Migration mid-state: bundle without webhook_signing_secret →
  fallback works.
- Revoked workspace + no fallback match → 401.

Trust model preserved end-to-end: organizationId in body is
attacker-controlled but only selects the secret; signature still
gates everything. Documented in LINEAR_SETUP_GUIDE.md "How webhook
signature verification works".
The OAuth dance can't be re-run when an app is already installed in a
Linear workspace — Linear returns access_denied. That makes
\`setup --rotate-webhook-secret\` and \`add-workspace\` both unusable
for the common case of "this workspace works, but the webhook signing
secret needs to change." Use cases:

1. Rotation (security policy, planned cycle, key compromise)
2. Recovery from misconfig (typed wrong, copied from wrong page)
3. First-time set after Linear regenerated the signing secret on
   webhook recreation

The new command:
- Reads the existing per-workspace OAuth bundle from SM
- Prompts for the new signing secret (validates lin_wh_ prefix)
- Re-upserts the bundle with merged webhook_signing_secret + bumped
  updated_at

What it doesn't do: OAuth dance, DDB writes, stack-wide secret
writes, Linear API calls. Just the SM mutation. Per-workspace only —
mirrors the architectural choice from the multi-workspace fix that
the stack-wide secret is reserved for the FIRST install's
back-compat fallback.

Docs: troubleshooting section now points at this command for
"webhook signature verification fails repeatedly" — the most common
production path. The previous guidance to re-run setup
--rotate-webhook-secret remains the right primitive for
single-workspace deploys that haven't fully migrated.
…hook-secret

The flag's job — re-prompt for the signing secret on an already-installed
workspace — is now done better by \`bgagent linear update-webhook-secret\`,
which skips the OAuth dance entirely. Keeping the flag means two tools
that do nearly the same thing, and forcing the user to redo OAuth just
to type a new signing secret is wasteful.

setup's webhook flow simplifies to: stack-wide already set → mirror
into per-workspace bundle (auto-migration); else prompt + write to
both. No conditional flag-based branching.

Docs updated:
- Step 3 footnote points at update-webhook-secret for rotation
- 'How webhook signature verification works' single-workspace
  migration note: setup auto-mirrors on next run, no flag needed
The 'Adding additional Linear workspaces' section listed operations
as bullets but didn't sequence them in an actionable way. Multi-
workspace onboarding crosses three contexts (CLI, Linear OAuth app
config, Linear webhook config) and bullet-lists left it unclear
which steps to do where, in what order.

New layout:
- Brief 'Decide: shared vs per-workspace OAuth app' table up front
- Numbered walkthrough that interleaves the CLI + Linear browser
  work in the order you actually need to do them, including the
  pause-at-prompt-then-switch-to-browser step for the webhook
- Two FAQs at the end ('what if I skip step 4?' and 'what if I
  typed the signing secret wrong?') for the common gotchas

Also drops the stale --client-id / --client-secret command examples
that referenced the flags removed in ac5ce67. The walkthrough now
points the user at the interactive prompts directly.
The setup guide kept telling users to find the API URL in CFN outputs
or substitute their own region/account into a placeholder. The CLI
already has the URL — make it surface it.

\`bgagent linear webhook-info\` reads config.api_url and prints the
webhook URL plus the values to paste into Linear's webhook UI, plus
the followup command (\`update-webhook-secret\`). Read-only,
no AWS calls beyond what the existing config layer already does.

Setup guide trimmed:
- Step 1: removed the parked-flow footnote (fixed at the source by
  defaulting app-template's callback URL to http://localhost:8080/
  oauth/callback instead of the parked AgentCore Identity placeholder)
- Step 2: collapsed the 6-bullet wizard description into 2 sentences
  that describe what the user actually does, dropping the
  \`--client-id\` / \`--client-secret\` flag mention (those flags
  never existed on setup; they were on add-workspace and got removed
  earlier this branch)
- Step 3: now references webhook-info as the single source for what
  to paste into Linear, dropping the embedded URL template and CFN
  output instructions
- Multi-workspace step 4: same — references webhook-info instead of
  embedding a URL template
- Dropped two long callout blocks that explained internals operators
  don't need at setup time (where the OAuth token lives, where the
  signing secret is mirrored — covered in 'How webhook signature
  verification works' for those who want it)

Net: -51 lines from the guide, one new always-printable command, no
more URL substitution by the user.
…runbook

LINEAR_SETUP_GUIDE.md was 405 lines and described the same flow twice
(once for first-install single-workspace, once for multi-workspace).
The two walkthroughs only differed in `setup` vs `add-workspace`,
which is a single-line CLI difference. The PAK migration block was
46 lines for a release that's already shipped — anyone reading the
guide today is on 2.0b.

Net result: 405 → 215 lines (-47%). One unified walkthrough that
calls out the setup-vs-add-workspace branch at step 3, drops dead
sections (Out-of-scope items, "What's coming next" — neither was
load-bearing), folds the link-your-Linear-account step into a short
section near the end since most users hit the auto-link path
anyway.

Net changes:
- One walkthrough instead of two (single-workspace + multi-workspace
  collapsed; option A/B trade-off comes up at the relevant step).
- PAK migration runbook moved to LINEAR_PAK_MIGRATION_RUNBOOK.md +
  registered in astro sidebar so it's still findable but doesn't
  block-quote the main guide.
- Dropped "Out of scope in v1.x" + "Limits and budgets" tables —
  the rate-limit info is now one paragraph, the rest was noise.
- Fixed "Removing the integration" — it was using the parked
  AgentCore-Identity delete-oauth2-credential-provider call. Now
  uses Secrets Manager + DDB directly.
- Dropped the "Linear actor has no linked platform user" cross-link
  (was self-referential to the previous Step 5).
Default 128 MB OOMs at module init since the attachment-screening
path bundles pdf-parse + URL-resolver libs alongside the existing
AWS SDK + bedrock-agentcore deps. Symptom: every webhook from a
correctly-configured workspace returns 200 from the receiver (which
async-invokes the processor and returns immediately) but the
processor crashes with Runtime.OutOfMemory before reaching the task-
creation path. No task gets created; Linear stops retrying after the
receiver's 200 ack. Silent failure mode.

Caught while smoke-testing per-workspace webhook signing on a
multi-workspace install: signatures verify, receiver dispatches,
processor OOMs at memorySize: 128. Bumping to 512 MB gives ~4×
headroom and lifts CPU enough that p99 cold-start stays under the
30s API Gateway deadline.
The auto-link in setup / add-workspace mapped the wrong UUID. With
actor=app, Linear's `viewer` query returns the OAuth bot user (a
synthetic `<uuid>@oauthapp.linear.app` identity), not the human admin
who ran the wizard. The bot never applies labels, so the auto-link
silently mapped a UUID that the processor would never see in practice
— every human-triggered task got dropped with "Linear actor has no
linked platform user."

This is the bug @Sphias hit installing demo-abca: the OAuth dance
linked bot user `b15f33ef…` to her Cognito sub, but her human Linear
identity in demo-abca is `91999ba0…` and was never mapped.

Fix:
- Drop the auto-link write from `setup` and `add-workspace`. The
  registry row + OAuth secret are still written; only the user-
  mapping row is no longer auto-stamped.
- New `bgagent linear link-user <slug>` command. Prompts (or accepts
  via flags) the Linear user UUID + Cognito sub, validates the
  workspace is in the registry, writes the mapping row with
  link_method='manual_cli'. Defaults Cognito sub to the caller —
  one-flag invocation for the admin's own row, two-flag for each
  teammate.
- End-of-flow output for setup / add-workspace updated: instructs
  the operator to run `link-user` BEFORE applying labels (otherwise
  every triggered task is dropped).

Doc trade-off considered:
  Auto-discovery via Linear's `users(filter: { email })` was
  rejected because the same human can have a different email in
  Linear vs. Cognito (synthetic `@oauthapp.linear.app` for actor=app,
  separate corporate emails for humans). Manual UUID is more
  friction but always correct.

Pre-existing rows in deployed installs (with the wrong bot UUID) are
left untouched. They're harmless — they just don't match any actor
the processor sees. Operators can clean them up by querying the
table; documented as v1.x followup.

Tests: 37/37 linear suite pass. The `link-user` command itself is
straightforward DDB plumbing; the test gap is acceptable given the
end-to-end verification path (run, see warning, run link-user, see
task created) is what proves the contract.
…user for teammates

The previous link-user command (just landed) put the teammate-onboarding
UX on the admin's hot path: every fresh install needed a manual second
command to map the admin's own Linear identity. For the common single-
admin case that's friction with no benefit.

Restructure:
- runSelfLinkPicker() helper — shared logic that lists Linear workspace
  members, prompts the caller to pick themselves, writes the mapping
  row using the caller's Cognito sub.
- setup and add-workspace call it inline at the end of their flow,
  right after the OAuth dance completes (token + workspace_id are
  already in scope). One command, no separate step. If the picker
  fails (network, no humans), setup completes anyway and prints a
  clear "your tasks won't dispatch until you self-link" hint.
- New `bgagent linear invite-user <slug>` for the teammate case. Admin
  picks the teammate from the same member list, CLI writes a
  pending#<code> row, prints a one-time command to hand off
  (Slack/email/etc).
- The existing `bgagent linear link <code>` command (which was 80%
  built but never wired to a code-generator) becomes the teammate's
  redemption command. Now does a dry-run preview first so the
  teammate sees the Linear identity name+email and confirms BEFORE
  the write hits DDB. The /v1/linear/link API handler grew a
  dry_run flag for this preview.
- Old standalone link-user command removed. Self-link is in the
  setup wizard; teammate-link is invite-user + link.

Trust model unchanged from the prior plan:
- Admin's self-link trusts the admin (their CLI session has Cognito
  + DDB write IAM anyway — no new attack surface).
- Teammate's link separates the halves: admin asserts the Linear
  identity (picker), teammate confirms with their own Cognito
  session and can abort if the admin picked the wrong member. No
  PAKs change hands.

Doc: walkthrough drops the "now run link-user" step (folded into
setup). New "Inviting teammates" section explains invite-user +
link with the trust-model rationale.
PR aws-samples#200's invite-user generates lowercase codes (link-3f8b4a2c
shape). The handler dropped its .toUpperCase() to round-trip them
correctly. Two pre-existing tests still asserted pending#ABC123 and
expected normalization to uppercase — updated to use the new code
shape and assert "preserves case + trims whitespace" instead.
CI's Fail build on mutation step caught ESLint reordering
linear-oauth-resolver before linear-verify.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants