Skip to content

Commit 420c042

Browse files
isadeksclaudekrokoko
authored
feat(linear): multi-workspace support — add-workspace, per-workspace webhook signing, update-webhook-secret (#200)
* feat(linear): add bgagent linear add-workspace command 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. * docs(linear): rewrite setup guide for shipped 2.0b-O2 flow + per-workspace 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). * fix(linear): make add-workspace fully interactive — drop --client-id/--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. * fix(linear): drop source workspace name from add-workspace prompt prose 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. * fix(linear): rewrite promptLine in raw stdin mode so it composes with 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. * fix(cli): bgagent linear list-projects on the OAuth secret model 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> * fix(linear): clearer empty-result message in list-projects '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). * feat(linear): per-workspace webhook signing secret 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". * feat(linear): bgagent linear update-webhook-secret for rotation/recovery 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. * refactor(linear): drop --rotate-webhook-secret in favor of update-webhook-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 * docs(linear): step-by-step walkthrough for adding a second workspace 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. * feat(linear): bgagent linear webhook-info + setup-guide trim 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. * docs(linear): aggressive trim of setup guide; PAK migration moved to 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). * fix(linear): bump webhook processor memory to 512 MB 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. * feat(linear): bgagent linear link-user; drop broken actor=app auto-link 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. * feat(linear): inline self-link picker in setup/add-workspace; invite-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. * fix(linear): update linear-link tests for case-sensitive codes PR #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. * fix: sort imports in linear-webhook-multi-workspace test CI's Fail build on mutation step caught ESLint reordering linear-oauth-resolver before linear-verify. * fix(linear): close fail-open + revoked-bypass on webhook verify path PR-200 review-1 blockers from krokoko: 1. Strict-lookup variants of getRegistryRow / getOauthSecret that throw on infra error (DDB throttle, SM 5xx) instead of returning null. The webhook verify path was calling the lenient null-on-error helpers, which silently downgraded a per-workspace-secured workspace to stack-wide verification under load. Lenient helpers are kept for resolveLinearOauthToken whose graceful no-op contract is intentional. 2. Distinct 'revoked' outcome from verifyLinearRequestForWorkspace. Previously a registry row with status != 'active' collapsed to 'no-per-workspace-secret', which fell through to the stack-wide secret. Since setup mirrors the first workspace's secret into the stack-wide one and revocation never clears it, a revoked workspace's events still verified. Now revoked → 401, no fallback. New test pins the bypass scenario (stack-wide secret matches AND workspace is revoked). New test also pins that DDB infra errors surface as 500 rather than silently falling through. 3. Processor early-returns when registry resolution fails. Previously only logged a warning then called createTaskCore anyway, leaving downstream user-mapping lookups to incidentally block. Now the drop is explicit so a workspace ABCA doesn't recognize doesn't burn agent quota with no observable result. Also adds a positive log on stack-wide-fallback success so operators diagnosing per-workspace verification regressions have a breadcrumb. * fix(linear): drop stale link-user-self refs, distinct corrupt-secret error, paginate list-projects PR-200 review-1 cleanup batch: - linear.ts:1665 told operators to run a non-existent 'bgagent linear link-user-self <slug>' command. Self-link is now inline in setup/add-workspace; point there instead. - Stale 'bgagent linear link-user' references in code comments (linear.ts:628/976/1605) — link-user was folded into setup/add-workspace, not extracted as its own command. Comments now describe the inline picker. - linear-link.ts:63 + multi-workspace test :278 mentioned the removed --rotate-webhook-secret flag; redirect to update-webhook-secret. - findReusableOauthAppCredentials now throws CliError on corrupt-secret states (bad JSON, no SecretString, missing client_id/client_secret) rather than collapsing to 'no active workspace, run setup' which nudged toward duplicate installs. - bgagent linear link in --output json mode skips the dry-run preview call (no interactive prompt to feed it; it's wasted work). - bgagent linear list-projects paginates ListSecretsCommand. Previous single-page call silently capped at 100 workspaces. - cdk-nag IAM5 suppression text now mentions the Secrets Manager bgagent-linear-oauth-* prefix grant the wildcard covers (not just the DDB index). * test(linear): cover dry_run preview branch + generateInviteCode format Closes the test gaps krokoko flagged on PR-200 review-1: - linear-link dry_run path now has explicit coverage that the preview returns the linear identity payload AND does not write the confirmed mapping or delete the pending row. Pins the safety rail behind bgagent linear link's confirmation prompt. - dry_run on a non-existent code still 404s — preview must not leak existence to a teammate probing for valid codes. - generateInviteCode is now exported from the CLI so its format can be asserted (link-<8-char-hex>, alphabet excludes ambiguous glyphs, distinct codes across runs). - Updated existing findReusableOauthAppCredentials tests to expect the new throw-on-corrupt behaviour (was returning null and surfacing 'no active workspace'). runSelfLinkPicker behavioural coverage deferred — needs the helper extracted from the giant setup/add-workspace action first; that's a larger refactor than fits this PR. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Alain Krok <alkrok@amazon.com>
1 parent 6b5295c commit 420c042

21 files changed

Lines changed: 2376 additions & 569 deletions

cdk/src/constructs/linear-integration.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,12 @@ export class LinearIntegration extends Construct {
183183
runtime: Runtime.NODEJS_24_X,
184184
architecture: Architecture.ARM_64,
185185
timeout: Duration.seconds(30),
186+
// Default 128 MB OOMs at module init since the attachment-screening
187+
// path (#176) bundles pdf-parse + URL-resolver libs alongside the
188+
// existing AWS SDK + bedrock-agentcore deps. 512 MB gives ~4× headroom
189+
// and lifts CPU enough that p99 startup stays under the API Gateway
190+
// 30s deadline on cold starts.
191+
memorySize: 512,
186192
environment: {
187193
...createTaskEnv,
188194
LINEAR_PROJECT_MAPPING_TABLE_NAME: this.projectMappingTable.tableName,
@@ -245,11 +251,32 @@ export class LinearIntegration extends Construct {
245251
LINEAR_WEBHOOK_SECRET_ARN: this.webhookSecret.secretArn,
246252
LINEAR_WEBHOOK_DEDUP_TABLE_NAME: this.webhookDedupTable.tableName,
247253
LINEAR_WEBHOOK_PROCESSOR_FUNCTION_NAME: webhookProcessorFn.functionName,
254+
// Per-workspace signing-secret lookup — selects the right
255+
// workspace's `webhook_signing_secret` from the OAuth secret
256+
// bundle so multi-workspace installs verify correctly. Receiver
257+
// falls back to LINEAR_WEBHOOK_SECRET_ARN when this lookup
258+
// misses (back-compat for single-workspace installs).
259+
LINEAR_WORKSPACE_REGISTRY_TABLE_NAME: this.workspaceRegistryTable.tableName,
248260
},
249261
bundling: commonBundling,
250262
});
251263
this.webhookSecret.grantRead(webhookFn);
252264
this.webhookDedupTable.grantReadWriteData(webhookFn);
265+
this.workspaceRegistryTable.grantReadData(webhookFn);
266+
// Read-only on the per-workspace OAuth secret prefix — we extract
267+
// `webhook_signing_secret` for verification but never mutate; the
268+
// CLI owns the lifecycle of these secrets.
269+
webhookFn.addToRolePolicy(new iam.PolicyStatement({
270+
actions: ['secretsmanager:GetSecretValue'],
271+
resources: [
272+
Stack.of(this).formatArn({
273+
service: 'secretsmanager',
274+
resource: 'secret',
275+
arnFormat: ArnFormat.COLON_RESOURCE_NAME,
276+
resourceName: 'bgagent-linear-oauth-*',
277+
}),
278+
],
279+
}));
253280
webhookProcessorFn.grantInvoke(webhookFn);
254281

255282
// --- Account linking (Cognito-authenticated) ---
@@ -319,7 +346,11 @@ export class LinearIntegration extends Construct {
319346
},
320347
{
321348
id: 'AwsSolutions-IAM5',
322-
reason: 'Wildcard permissions are scoped by DynamoDB index ARN patterns',
349+
reason:
350+
'Wildcards cover (a) DynamoDB index ARN patterns from CDK grant helpers, '
351+
+ 'and (b) the Secrets Manager `bgagent-linear-oauth-*` prefix grant — '
352+
+ 'the per-workspace OAuth secret name is not known at synth time '
353+
+ '(operators add workspaces by slug at runtime via `bgagent linear add-workspace`).',
323354
},
324355
], true);
325356
}

cdk/src/handlers/linear-link.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,19 @@ const USER_MAPPING_TABLE = process.env.LINEAR_USER_MAPPING_TABLE_NAME!;
3232

3333
interface LinkRequest {
3434
readonly code: string;
35+
/** Preview-only: return what would be linked without writing. */
36+
readonly dry_run?: boolean;
3537
}
3638

3739
/**
38-
* POST /v1/linear/link — Complete Linear account linking.
40+
* POST /v1/linear/link — Complete Linear account linking, or preview it.
3941
*
4042
* Called from the CLI (`bgagent linear link <code>`) with a Cognito JWT.
41-
* Looks up the pending link record, maps the Linear identity to the
42-
* authenticated platform user, and cleans up the pending record.
43+
* Looks up the pending link record. With `dry_run: true`, returns the
44+
* Linear identity attached to the code without writing — the CLI uses
45+
* this to render a "you're about to link X (email: Y)" preview before
46+
* the teammate confirms. Without `dry_run`, writes the mapping and
47+
* deletes the pending record.
4348
*/
4449
export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> {
4550
const requestId = ulid();
@@ -55,7 +60,11 @@ export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayPr
5560
return errorResponse(400, ErrorCode.VALIDATION_ERROR, 'Request body must include a "code" field.', requestId);
5661
}
5762

58-
const code = body.code.trim().toUpperCase();
63+
// Codes from `bgagent linear invite-user` are case-sensitive
64+
// (see generateInviteCode in the CLI — `link-<8-char-hex>` shape,
65+
// hex is lowercase). Don't uppercase the incoming value — that
66+
// would break codes generated post-2.0b.
67+
const code = body.code.trim();
5968

6069
const pending = await ddb.send(new GetCommand({
6170
TableName: USER_MAPPING_TABLE,
@@ -67,7 +76,23 @@ export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayPr
6776
}
6877

6978
const workspaceId = pending.Item.linear_workspace_id as string;
79+
const workspaceSlug = (pending.Item.linear_workspace_slug as string | undefined) ?? '';
7080
const linearUserId = pending.Item.linear_user_id as string;
81+
const linearUserName = (pending.Item.linear_user_name as string | undefined) ?? '';
82+
const linearUserEmail = (pending.Item.linear_user_email as string | undefined) ?? '';
83+
84+
// Dry-run preview: return identity without writing.
85+
if (body.dry_run === true) {
86+
return successResponse(200, {
87+
dry_run: true,
88+
linear_workspace_id: workspaceId,
89+
linear_workspace_slug: workspaceSlug,
90+
linear_user_id: linearUserId,
91+
linear_user_name: linearUserName,
92+
linear_user_email: linearUserEmail,
93+
}, requestId);
94+
}
95+
7196
const now = new Date().toISOString();
7297

7398
await ddb.send(new PutCommand({
@@ -97,7 +122,10 @@ export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayPr
97122
return successResponse(200, {
98123
message: 'Linear account linked successfully.',
99124
linear_workspace_id: workspaceId,
125+
linear_workspace_slug: workspaceSlug,
100126
linear_user_id: linearUserId,
127+
linear_user_name: linearUserName,
128+
linear_user_email: linearUserEmail,
101129
linked_at: now,
102130
}, requestId);
103131
} catch (err) {

cdk/src/handlers/linear-webhook-processor.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -248,19 +248,27 @@ export async function handler(event: ProcessorEvent): Promise<void> {
248248

249249
// Phase 2.0b-O2: resolve the workspace's OAuth secret ARN ONCE here
250250
// and stash it on the task record. The agent runtime reads it directly
251-
// (no registry lookup at task-execution time). If the workspace isn't
252-
// onboarded the agent's outbound Linear MCP simply skips.
251+
// (no registry lookup at task-execution time).
252+
//
253+
// When the registry table IS configured but resolution returns null —
254+
// workspace not in registry, status not active, or token unreadable —
255+
// the receiver only let this through because the stack-wide fallback
256+
// verified. Creating a task against a workspace ABCA doesn't recognize
257+
// is the wrong behaviour: outbound Linear comments would silently
258+
// skip, the user mapping lookup would fail, and we'd burn agent
259+
// quota for no observable result. Drop the event explicitly here
260+
// rather than rely on downstream lookups to incidentally block it.
253261
if (WORKSPACE_REGISTRY_TABLE) {
254262
const resolved = await resolveLinearOauthToken(workspaceId, WORKSPACE_REGISTRY_TABLE);
255-
if (resolved) {
256-
channelMetadata.linear_oauth_secret_arn = resolved.oauthSecretArn;
257-
channelMetadata.linear_workspace_slug = resolved.workspaceSlug;
258-
} else {
259-
logger.warn('Linear workspace not in registry — agent will run without Linear MCP', {
263+
if (!resolved) {
264+
logger.warn('Linear workspace not resolvable from registry — dropping event', {
260265
linear_workspace_id: workspaceId,
261266
issue_id: issue.id,
262267
});
268+
return;
263269
}
270+
channelMetadata.linear_oauth_secret_arn = resolved.oauthSecretArn;
271+
channelMetadata.linear_workspace_slug = resolved.workspaceSlug;
264272
}
265273

266274
// Extract embedded image URLs from the issue description markdown.

cdk/src/handlers/linear-webhook.ts

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ import { ConditionalCheckFailedException, DynamoDBClient } from '@aws-sdk/client
2121
import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda';
2222
import { DeleteCommand, DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';
2323
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
24-
import { isWebhookTimestampFresh, verifyLinearRequest } from './shared/linear-verify';
24+
import {
25+
isWebhookTimestampFresh,
26+
verifyLinearRequest,
27+
verifyLinearRequestForWorkspace,
28+
} from './shared/linear-verify';
2529
import { logger } from './shared/logger';
2630

2731
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
@@ -30,6 +34,10 @@ const lambdaClient = new LambdaClient({});
3034
const WEBHOOK_SECRET_ARN = process.env.LINEAR_WEBHOOK_SECRET_ARN!;
3135
const DEDUP_TABLE_NAME = process.env.LINEAR_WEBHOOK_DEDUP_TABLE_NAME!;
3236
const PROCESSOR_FUNCTION_NAME = process.env.LINEAR_WEBHOOK_PROCESSOR_FUNCTION_NAME!;
37+
/** Optional. When unset, the per-workspace signing-secret path is skipped
38+
* and only the stack-wide secret is consulted (back-compat for installs
39+
* predating per-workspace secrets). */
40+
const WORKSPACE_REGISTRY_TABLE = process.env.LINEAR_WORKSPACE_REGISTRY_TABLE_NAME;
3341

3442
/**
3543
* Dedup window (seconds). Must exceed Linear's full retry horizon: first
@@ -79,11 +87,11 @@ export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayPr
7987
return jsonResponse(401, { error: 'Missing signature' });
8088
}
8189

82-
if (!await verifyLinearRequest(WEBHOOK_SECRET_ARN, signature, event.body)) {
83-
logger.warn('Invalid Linear webhook signature');
84-
return jsonResponse(401, { error: 'Invalid signature' });
85-
}
86-
90+
// Parse body ONCE — we peek at the orgId before signature verification
91+
// so we can pick the right per-workspace signing secret. The orgId is
92+
// untrusted at this point; it only selects WHICH secret to verify
93+
// against. An attacker can claim any orgId but still needs the
94+
// matching signing secret to forge a valid signature.
8795
let payload: LinearWebhookEnvelope;
8896
try {
8997
payload = JSON.parse(event.body) as LinearWebhookEnvelope;
@@ -94,6 +102,57 @@ export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayPr
94102
return jsonResponse(400, { error: 'Invalid JSON' });
95103
}
96104

105+
// Try the per-workspace secret first. Falls through to the stack-wide
106+
// path if (a) registry table not configured, (b) no orgId in body,
107+
// (c) workspace not in registry, or (d) workspace's stored secret
108+
// lacks `webhook_signing_secret`. Per-workspace MISMATCH is fatal —
109+
// do NOT fall back, that would let an attacker bypass per-workspace
110+
// signatures by also matching the stack-wide one. REVOKED is also
111+
// fatal: a workspace that has been deactivated must not be able to
112+
// ride the stack-wide fallback (which `setup` mirrored from the
113+
// first workspace's signing secret) back into a verified state.
114+
let verified = false;
115+
if (WORKSPACE_REGISTRY_TABLE && payload.organizationId) {
116+
const result = await verifyLinearRequestForWorkspace(
117+
WORKSPACE_REGISTRY_TABLE,
118+
payload.organizationId,
119+
signature,
120+
event.body,
121+
);
122+
if (result === 'verified') {
123+
verified = true;
124+
} else if (result === 'mismatch') {
125+
logger.warn('Linear webhook signature mismatch against per-workspace secret', {
126+
linear_workspace_id: payload.organizationId,
127+
});
128+
return jsonResponse(401, { error: 'Invalid signature' });
129+
} else if (result === 'revoked') {
130+
logger.warn('Linear webhook from revoked workspace — rejecting without stack-wide fallback', {
131+
linear_workspace_id: payload.organizationId,
132+
});
133+
return jsonResponse(401, { error: 'Workspace not active' });
134+
}
135+
// 'no-per-workspace-secret' falls through to the stack-wide path
136+
// below — back-compat for installs predating per-workspace secrets.
137+
}
138+
139+
if (!verified) {
140+
if (!await verifyLinearRequest(WEBHOOK_SECRET_ARN, signature, event.body)) {
141+
logger.warn('Invalid Linear webhook signature', {
142+
linear_workspace_id: payload.organizationId,
143+
});
144+
return jsonResponse(401, { error: 'Invalid signature' });
145+
}
146+
// Stack-wide fallback succeeded. Log positively so operators
147+
// diagnosing a per-workspace verification regression have a
148+
// breadcrumb that says "this workspace is verifying via the
149+
// back-compat path" rather than its own per-workspace secret.
150+
logger.info('Linear webhook verified via stack-wide fallback secret', {
151+
linear_workspace_id: payload.organizationId,
152+
per_workspace_registry_configured: Boolean(WORKSPACE_REGISTRY_TABLE),
153+
});
154+
}
155+
97156
if (!isWebhookTimestampFresh(payload.webhookTimestamp)) {
98157
logger.warn('Linear webhook timestamp outside replay window', {
99158
webhook_timestamp: payload.webhookTimestamp,

0 commit comments

Comments
 (0)