Skip to content

Commit e26085d

Browse files
committed
fix(cli): address #612 review — fail-closed webhook pre-read + tests + nits
B1 (fail-open re-armed the clobber): the pre-read catch swallowed EVERY GetSecretValue error and treated it as 'first install — nothing to preserve'. That only holds for ResourceNotFoundException; for AccessDenied / KMSAccessDenied / Throttling / network / a corrupt bundle — all on a secret that EXISTS — existingWebhookSecret stayed undefined → resolveWebhookSecretAction mirrors the stack-wide (workspace #1's) secret over this workspace's real one, re-arming the #611 401 clobber silently behind a green 'Setup complete'. Extracted the pre-read into readExistingWebhookSecret() which FAILS CLOSED: only ResourceNotFound → 'none to preserve'; a corrupt bundle (JSON.parse) or any other SM error propagates, and setup re-throws an actionable CliError with an IAM/KMS hint (mirroring the sibling isWebhookSecretConfigured). B2 (fix was untested end-to-end): the 5 prior tests only exercised the pure resolveWebhookSecretAction. Added 7 tests for readExistingWebhookSecret covering the load-bearing seam — preserve-not-clobber, ResourceNotFound→undefined, malformed/absent secret, and the fail-closed THROW on AccessDenied / KMS / Throttling / corrupt JSON (the exact B1 path). N1 (stale comment): the Step-6 header still described the REMOVED behavior ('the stored value is this workspace's secret — lift it in'). Rewrote it to describe the preserve → mirror-stackwide → prompt decision so a maintainer can't revert the fix. N2 (double-write + failure window): fold the preserved secret into the INITIAL bundle so the OAuth-secret write lands it in ONE PutSecretValue; the preserve path skips the later re-write (guarded by !== stored.webhook_signing_secret). Closes the window where a throw between the two writes left the bundle persisted without the secret (401 until update-webhook-secret). Full cli build green (616 tests).
1 parent 45a770b commit e26085d

3 files changed

Lines changed: 149 additions & 20 deletions

File tree

cli/src/commands/linear.ts

Lines changed: 55 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
generatePkce,
4444
LINEAR_OAUTH_SECRET_PREFIX,
4545
linearOauthSecretName,
46+
readExistingWebhookSecret,
4647
resolveWebhookSecretAction,
4748
StoredLinearOauthToken,
4849
} from '../linear-oauth';
@@ -609,17 +610,34 @@ export function makeLinearCommand(): Command {
609610
// first) — that silently breaks signature verification (401 "Invalid
610611
// signature") for every workspace after the first in a multi-workspace
611612
// deployment. Rotation stays the job of `update-webhook-secret`.
613+
// Fail CLOSED (#612 review B1): read this workspace's existing signing
614+
// secret before the overwrite. Only ResourceNotFoundException is a clean
615+
// first-install; any other SM error (AccessDenied, KMSAccessDenied,
616+
// Throttling, network) — or a corrupt bundle — must surface, NOT default
617+
// to undefined (which would mirror the stack-wide secret over a working
618+
// per-workspace one → the #611 401 clobber, silently, behind a green
619+
// "Setup complete"). Extracted to readExistingWebhookSecret + unit-tested.
612620
let existingWebhookSecret: string | undefined;
613621
try {
614-
const prior = await sm.send(new GetSecretValueCommand({ SecretId: linearOauthSecretName(slug) }));
615-
if (prior.SecretString) {
616-
const priorBundle = JSON.parse(prior.SecretString) as Partial<StoredLinearOauthToken>;
617-
if (priorBundle.webhook_signing_secret?.startsWith('lin_wh_')) {
618-
existingWebhookSecret = priorBundle.webhook_signing_secret;
619-
}
620-
}
621-
} catch {
622-
// No prior bundle (first install) — nothing to preserve.
622+
existingWebhookSecret = await readExistingWebhookSecret(
623+
async () => {
624+
const prior = await sm.send(new GetSecretValueCommand({ SecretId: linearOauthSecretName(slug) }));
625+
return prior.SecretString;
626+
},
627+
(err) => (err as { name?: string }).name === 'ResourceNotFoundException',
628+
);
629+
} catch (err) {
630+
const errorName = (err as { name?: string }).name;
631+
const message = err instanceof Error ? err.message : String(err);
632+
throw new CliError(
633+
`Failed to read the existing Linear webhook secret for '${slug}' before re-write: `
634+
+ `${errorName ?? 'Error'}: ${message}. Refusing to proceed — continuing could clobber `
635+
+ 'this workspace\'s webhook signing secret with the stack-wide value (a 401 on every '
636+
+ 'delivery). Likely an IAM/KMS gap: confirm your CLI principal has '
637+
+ '`secretsmanager:GetSecretValue` (and kms:Decrypt if a CMK) on '
638+
+ `${linearOauthSecretName(slug)}, or run \`bgagent linear update-webhook-secret ${slug}\` `
639+
+ 'after fixing access.',
640+
);
623641
}
624642
const stored: StoredLinearOauthToken = {
625643
access_token: tokenResponse.access_token,
@@ -635,6 +653,14 @@ export function makeLinearCommand(): Command {
635653
installed_at: now,
636654
updated_at: now,
637655
installed_by_platform_user_id: cognitoSub,
656+
// Fold the preserved secret into the INITIAL bundle (#612 review N2):
657+
// the OAuth-secret write below then lands the correct bundle in ONE
658+
// PutSecretValue, and the preserve path skips the later re-write. This
659+
// also closes a narrow window — if any fallible step between the two
660+
// writes threw, the bundle was left persisted WITHOUT the secret (401
661+
// until update-webhook-secret). Only set for a real `lin_wh_` value;
662+
// mirror-stackwide / prompt cases add it in the mirror-back below.
663+
...(existingWebhookSecret ? { webhook_signing_secret: existingWebhookSecret } : {}),
638664
};
639665
if (!stored.refresh_token) {
640666
throw new CliError(
@@ -696,14 +722,21 @@ export function makeLinearCommand(): Command {
696722
// without re-onboarding. Multi-workspace installs need each
697723
// workspace to own its own per-workspace signing secret — only
698724
// the FIRST install can populate the stack-wide one usefully.
699-
// If stack-wide is already populated, this is either a re-run
700-
// of setup on the SAME workspace or the FIRST workspace of a
701-
// future multi-workspace install. Either way the stored value
702-
// is this workspace's signing secret — lift it into the
703-
// per-workspace bundle without prompting (auto-migration to
704-
// the new shape). Rotation is not setup's job: use
705-
// `bgagent linear update-webhook-secret <slug>` to rotate the
706-
// signing secret without re-running OAuth.
725+
//
726+
// resolveWebhookSecretAction picks one of three outcomes from
727+
// (existingWebhookSecret, stackWideAlreadyConfigured):
728+
// • preserve — this workspace ALREADY has its own `lin_wh_`
729+
// secret (a re-run). Keep it; do NOT overwrite from
730+
// the stack-wide fallback, which is a DIFFERENT
731+
// workspace's secret once >1 is installed. This is
732+
// the #611 clobber fix — the stack-wide value is NOT
733+
// necessarily this workspace's.
734+
// • mirror-stackwide — no per-workspace secret yet but stack-wide is
735+
// set: mirror it (correct for the first/only
736+
// workspace; warn for an additional one).
737+
// • prompt — nothing to go on: ask for the signing secret.
738+
// Rotation is not setup's job: use
739+
// `bgagent linear update-webhook-secret <slug>` to rotate.
707740
const stackWideAlreadyConfigured = await isWebhookSecretConfigured(sm, webhookSecretArn!);
708741
let webhookSigningSecret: string | undefined;
709742

@@ -759,9 +792,11 @@ export function makeLinearCommand(): Command {
759792
webhookSigningSecret = webhookSecret;
760793
}
761794

762-
// Mirror into the per-workspace OAuth secret so the receiver can
763-
// look it up by orgId. Re-upsert with the merged payload.
764-
if (webhookSigningSecret) {
795+
// Mirror into the per-workspace OAuth secret so the receiver can look it
796+
// up by orgId. In the PRESERVE case the secret is already in `stored`
797+
// (folded above) and was written by the OAuth-secret upsert — no re-write
798+
// needed. Only mirror-stackwide / prompt produce a NEW secret to persist.
799+
if (webhookSigningSecret && webhookSigningSecret !== stored.webhook_signing_secret) {
765800
const merged: StoredLinearOauthToken = {
766801
...stored,
767802
webhook_signing_secret: webhookSigningSecret,

cli/src/linear-oauth.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,3 +335,46 @@ export function resolveWebhookSecretAction(
335335
}
336336
return { kind: 'prompt' };
337337
}
338+
339+
/**
340+
* Read this workspace's EXISTING `webhook_signing_secret` before `setup`
341+
* overwrites the OAuth bundle — FAIL CLOSED (#612 review B1).
342+
*
343+
* `fetchSecretString` fetches the prior per-workspace OAuth `SecretString`
344+
* (typically a `SecretsManagerClient.send(GetSecretValueCommand)` wrapper). The
345+
* value fed to {@link resolveWebhookSecretAction} decides whether the existing
346+
* secret is preserved or the stack-wide one is mirrored over it — so a wrong
347+
* `undefined` here re-arms the #611 clobber. Therefore:
348+
*
349+
* • secret missing (`ResourceNotFoundException`) → genuine first install,
350+
* return undefined (nothing to preserve).
351+
* • secret present with a valid `lin_wh_…` `webhook_signing_secret` → return it.
352+
* • secret present but no/malformed secret → return undefined (nothing valid
353+
* to preserve; the caller mirrors/prompts).
354+
* • ANY OTHER error (AccessDenied, KMSAccessDenied, Throttling, network, or a
355+
* corrupt-JSON bundle) → THROW. Swallowing it would leave undefined and
356+
* silently clobber a working secret behind a green "Setup complete".
357+
*
358+
* `isNotFound` classifies the fetch error; callers pass an
359+
* `name === 'ResourceNotFoundException'` check. On a throw the caller re-raises
360+
* a CliError with an IAM/KMS hint (mirroring `isWebhookSecretConfigured`).
361+
*/
362+
export async function readExistingWebhookSecret(
363+
fetchSecretString: () => Promise<string | undefined>,
364+
isNotFound: (err: unknown) => boolean,
365+
): Promise<string | undefined> {
366+
let raw: string | undefined;
367+
try {
368+
raw = await fetchSecretString();
369+
} catch (err) {
370+
if (isNotFound(err)) return undefined; // genuine first install
371+
throw err; // fail closed — caller wraps with an actionable CliError
372+
}
373+
if (!raw) return undefined;
374+
// A corrupt-but-present bundle must surface (JSON.parse throws → propagates),
375+
// NOT silently become "nothing to preserve" → mirror-stackwide.
376+
const bundle = JSON.parse(raw) as Partial<StoredLinearOauthToken>;
377+
return bundle.webhook_signing_secret?.startsWith('lin_wh_')
378+
? bundle.webhook_signing_secret
379+
: undefined;
380+
}

cli/test/linear-oauth.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
LINEAR_OAUTH_SCOPES,
2929
LINEAR_TOKEN_ENDPOINT,
3030
linearOauthSecretName,
31+
readExistingWebhookSecret,
3132
refreshAccessToken,
3233
resolveWebhookSecretAction,
3334
} from '../src/linear-oauth';
@@ -314,3 +315,53 @@ describe('resolveWebhookSecretAction', () => {
314315
expect(resolveWebhookSecretAction('', false)).toEqual({ kind: 'prompt' });
315316
});
316317
});
318+
319+
describe('readExistingWebhookSecret — fail-closed pre-read (#612 review B1/B2)', () => {
320+
const notFound = (err: unknown) => (err as { name?: string }).name === 'ResourceNotFoundException';
321+
const bundle = (secret?: string) =>
322+
JSON.stringify({ access_token: 'a', webhook_signing_secret: secret });
323+
324+
it('returns the existing lin_wh_ secret when the bundle has one (→ preserve, not clobber)', async () => {
325+
const got = await readExistingWebhookSecret(async () => bundle('lin_wh_thisWorkspace'), notFound);
326+
// This is the value that makes resolveWebhookSecretAction PRESERVE — the fix.
327+
expect(got).toBe('lin_wh_thisWorkspace');
328+
expect(resolveWebhookSecretAction(got, true)).toEqual({ kind: 'preserve', secret: 'lin_wh_thisWorkspace' });
329+
});
330+
331+
it('returns undefined on ResourceNotFoundException (genuine first install)', async () => {
332+
const got = await readExistingWebhookSecret(async () => {
333+
throw Object.assign(new Error('no'), { name: 'ResourceNotFoundException' });
334+
}, notFound);
335+
expect(got).toBeUndefined();
336+
});
337+
338+
it('returns undefined when the bundle exists but has no/malformed secret', async () => {
339+
expect(await readExistingWebhookSecret(async () => bundle(undefined), notFound)).toBeUndefined();
340+
expect(await readExistingWebhookSecret(async () => bundle('not-a-wh'), notFound)).toBeUndefined();
341+
expect(await readExistingWebhookSecret(async () => undefined, notFound)).toBeUndefined();
342+
});
343+
344+
it('THROWS (fails closed) on AccessDenied — must NOT default to undefined and clobber', async () => {
345+
// The B1 bug: a bare catch here would return undefined → mirror-stackwide →
346+
// the #611 clobber, silently. The pre-read must surface the error instead.
347+
const accessDenied = Object.assign(new Error('denied'), { name: 'AccessDeniedException' });
348+
await expect(
349+
readExistingWebhookSecret(async () => { throw accessDenied; }, notFound),
350+
).rejects.toBe(accessDenied);
351+
});
352+
353+
it('THROWS on KMSAccessDeniedException / Throttling / network (any non-not-found)', async () => {
354+
for (const name of ['KMSAccessDeniedException', 'ThrottlingException', 'InternalServiceError']) {
355+
const err = Object.assign(new Error(name), { name });
356+
await expect(
357+
readExistingWebhookSecret(async () => { throw err; }, notFound),
358+
).rejects.toBe(err);
359+
}
360+
});
361+
362+
it('THROWS on a corrupt-but-present bundle (JSON.parse) — not silently "nothing to preserve"', async () => {
363+
await expect(
364+
readExistingWebhookSecret(async () => '{not valid json', notFound),
365+
).rejects.toThrow();
366+
});
367+
});

0 commit comments

Comments
 (0)