Skip to content

Commit ce523fa

Browse files
Merge branch 'main' into feat/366-synth-reuse
2 parents 95ebf79 + 0e2806a commit ce523fa

5 files changed

Lines changed: 970 additions & 12 deletions

File tree

cdk/src/constructs/jira-integration.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,19 @@ const DEFAULT_TASK_RETENTION_DAYS = 90;
3838
/** Webhook-processor Lambda timeout (seconds). */
3939
const WEBHOOK_PROCESSOR_TIMEOUT_SECONDS = 30;
4040

41+
/**
42+
* Marker key embedded in the auto-generated stack-wide webhook-secret
43+
* placeholder. The CLI (`bgagent jira setup`) recognizes a secret carrying
44+
* this key as "never configured" and seeds the operator's value over it.
45+
*
46+
* MUST stay in sync with `JIRA_WEBHOOK_SECRET_PLACEHOLDER_KEY` in
47+
* `cli/src/commands/jira.ts`. Unlike Linear (whose real secrets always start
48+
* with `lin_wh_`), Atlassian webhook signing secrets are operator-chosen bare
49+
* strings with no fixed shape, so the *placeholder* — not the real value — is
50+
* the thing we make recognizable. See #368.
51+
*/
52+
const JIRA_WEBHOOK_SECRET_PLACEHOLDER_KEY = 'abca_jira_webhook_placeholder';
53+
4154
/**
4255
* Webhook-processor Lambda memory (MB). Matches the Linear processor — the
4356
* same attachment-screening path bundles pdf-parse + the URL resolver, and
@@ -151,9 +164,24 @@ export class JiraIntegration extends Construct {
151164
// created by the CLI at runtime — not here. This stack-wide secret is
152165
// a back-compat fallback for single-tenant installs predating per-
153166
// tenant signing.
167+
//
168+
// The initial value is an explicit JSON placeholder carrying
169+
// `JIRA_WEBHOOK_SECRET_PLACEHOLDER_KEY`. Without `generateSecretString`,
170+
// CDK seeds a BARE random string — which the CLI's placeholder heuristic
171+
// mistook for an already-configured secret, so `setup` never seeded the
172+
// operator's value and every admin-UI webhook delivery (whose payload has
173+
// no `cloudId`, forcing stack-wide verification) failed HMAC with 401,
174+
// silently (#368). Making the placeholder explicit lets the CLI reliably
175+
// tell "never configured" from an operator-set value.
154176
this.webhookSecret = new secretsmanager.Secret(this, 'WebhookSecret', {
155177
description: 'Jira webhook signing secret — populate via `bgagent jira setup`',
156178
removalPolicy,
179+
generateSecretString: {
180+
// Yields `{"abca_jira_webhook_placeholder":true,"value":"<random>"}`:
181+
// a JSON object (starts with `{`) with an explicit marker key.
182+
secretStringTemplate: JSON.stringify({ [JIRA_WEBHOOK_SECRET_PLACEHOLDER_KEY]: true }),
183+
generateStringKey: 'value',
184+
},
157185
});
158186

159187
// --- Shared Lambda configuration ---
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* MIT No Attribution
3+
*
4+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy of
7+
* the Software without restriction, including without limitation the rights to
8+
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9+
* the Software, and to permit persons to whom the Software is furnished to do so.
10+
*
11+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
12+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
13+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
14+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
15+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
16+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
17+
* SOFTWARE.
18+
*/
19+
20+
import { App, Stack } from 'aws-cdk-lib';
21+
import { Template, Match } from 'aws-cdk-lib/assertions';
22+
import * as apigw from 'aws-cdk-lib/aws-apigateway';
23+
import * as cognito from 'aws-cdk-lib/aws-cognito';
24+
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
25+
import { JiraIntegration } from '../../src/constructs/jira-integration';
26+
27+
describe('JiraIntegration construct', () => {
28+
let template: Template;
29+
30+
beforeAll(() => {
31+
const app = new App();
32+
const stack = new Stack(app, 'TestStack');
33+
34+
const api = new apigw.RestApi(stack, 'TestApi');
35+
const userPool = new cognito.UserPool(stack, 'TestUserPool');
36+
const taskTable = new dynamodb.Table(stack, 'TaskTable', {
37+
partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING },
38+
});
39+
const taskEventsTable = new dynamodb.Table(stack, 'TaskEventsTable', {
40+
partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING },
41+
sortKey: { name: 'event_id', type: dynamodb.AttributeType.STRING },
42+
});
43+
44+
new JiraIntegration(stack, 'JiraIntegration', {
45+
api,
46+
userPool,
47+
taskTable,
48+
taskEventsTable,
49+
});
50+
51+
template = Template.fromStack(stack);
52+
});
53+
54+
test('creates one Secrets Manager secret (webhook signing) — OAuth tokens are CLI-created at runtime', () => {
55+
template.resourceCountIs('AWS::SecretsManager::Secret', 1);
56+
template.hasResourceProperties('AWS::SecretsManager::Secret', {
57+
Description: Match.stringLikeRegexp('Jira webhook signing secret'),
58+
});
59+
});
60+
61+
// #368: the webhook secret MUST seed an explicit JSON placeholder so the CLI
62+
// can distinguish "never configured" from an operator-set value. A bare
63+
// generated string (CDK's default with no GenerateSecretString) caused
64+
// `bgagent jira setup` to skip seeding, leaving every admin-UI webhook
65+
// delivery to fail HMAC verification with 401.
66+
test('webhook secret seeds a JSON placeholder carrying the explicit marker key (#368)', () => {
67+
template.hasResourceProperties('AWS::SecretsManager::Secret', {
68+
GenerateSecretString: Match.objectLike({
69+
// secretStringTemplate is the JSON object carrying the marker key.
70+
SecretStringTemplate: Match.stringLikeRegexp('abca_jira_webhook_placeholder'),
71+
GenerateStringKey: 'value',
72+
}),
73+
});
74+
});
75+
});

cli/src/commands/jira.ts

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -184,27 +184,42 @@ export async function upsertOauthSecret(
184184
}
185185

186186
/**
187-
* Check whether the JiraWebhookSecret already holds a real signing secret
188-
* (vs CDK's autogenerated placeholder). Used to decide whether to prompt
189-
* for the webhook secret on subsequent setup runs.
187+
* Marker key embedded in the CDK-generated stack-wide webhook-secret
188+
* placeholder. A secret whose JSON carries this key has never been
189+
* configured by an operator, so `setup` is free to seed the real value.
190190
*
191-
* Atlassian's generic-webhook signing secrets are operator-chosen — they
192-
* have no fixed prefix like Linear's `lin_wh_`. We treat the placeholder
193-
* as a JSON-encoded value (CDK's default for autogenerated secrets) and
194-
* everything else as a real value.
191+
* MUST stay in sync with `JIRA_WEBHOOK_SECRET_PLACEHOLDER_KEY` in
192+
* `cdk/src/constructs/jira-integration.ts`. See #368.
195193
*/
196-
async function isWebhookSecretConfigured(
194+
export const JIRA_WEBHOOK_SECRET_PLACEHOLDER_KEY = 'abca_jira_webhook_placeholder';
195+
196+
/**
197+
* Check whether the JiraWebhookSecret already holds a real, operator-set
198+
* signing secret (vs the CDK-generated placeholder). Used to decide whether
199+
* to seed the stack-wide secret on a `setup` run.
200+
*
201+
* Atlassian's generic-webhook signing secrets are operator-chosen — they have
202+
* no fixed prefix like Linear's `lin_wh_`, so we cannot positively recognize a
203+
* *real* value by shape. Instead we recognize the *placeholder*: the CDK
204+
* construct seeds an explicit JSON object carrying
205+
* `JIRA_WEBHOOK_SECRET_PLACEHOLDER_KEY`. Anything that is not that placeholder
206+
* is treated as an operator value.
207+
*
208+
* NOTE (#368 migration): stacks deployed before the explicit-placeholder fix
209+
* seeded a *bare random string* placeholder, which is indistinguishable from
210+
* an operator value and so is (conservatively) reported as configured. Such
211+
* installs must redeploy the CDK stack — which regenerates the secret with the
212+
* JSON placeholder — before `setup` will seed it.
213+
*/
214+
export async function isWebhookSecretConfigured(
197215
client: SecretsManagerClient,
198216
secretArn: string,
199217
): Promise<boolean> {
200218
try {
201219
const result = await client.send(new GetSecretValueCommand({ SecretId: secretArn }));
202220
const value = result.SecretString;
203221
if (typeof value !== 'string' || value.length === 0) return false;
204-
// CDK's auto-generated secret is a JSON object string starting with `{`
205-
// — operator-set secrets (the Atlassian-side configured value) are bare
206-
// strings. Anything that doesn't look like the placeholder JSON is real.
207-
return !value.trim().startsWith('{');
222+
return !isWebhookSecretPlaceholder(value);
208223
} catch (err) {
209224
const errorName = (err as { name?: string }).name;
210225
if (errorName === 'ResourceNotFoundException') {
@@ -219,6 +234,29 @@ async function isWebhookSecretConfigured(
219234
}
220235
}
221236

237+
/**
238+
* True when `value` is the CDK-generated placeholder — a JSON object carrying
239+
* the {@link JIRA_WEBHOOK_SECRET_PLACEHOLDER_KEY} marker. A non-JSON value, or
240+
* JSON without the marker, is an operator-set secret.
241+
*/
242+
function isWebhookSecretPlaceholder(value: string): boolean {
243+
const trimmed = value.trim();
244+
// Fast reject: real Atlassian signing secrets are bare strings.
245+
if (!trimmed.startsWith('{')) return false;
246+
try {
247+
const parsed: unknown = JSON.parse(trimmed);
248+
return (
249+
typeof parsed === 'object'
250+
&& parsed !== null
251+
&& JIRA_WEBHOOK_SECRET_PLACEHOLDER_KEY in (parsed as Record<string, unknown>)
252+
);
253+
} catch {
254+
// Starts with `{` but isn't valid JSON — not our placeholder. Treat as a
255+
// (malformed) operator value rather than silently re-seeding over it.
256+
return false; // nosemgrep: ts-silent-success-masking -- unparseable secret is conservatively treated as operator-set (not the placeholder), so setup never overwrites it
257+
}
258+
}
259+
222260
function promptSecret(label: string): Promise<string> {
223261
return new Promise((resolve) => {
224262
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });

0 commit comments

Comments
 (0)