Skip to content
Open
30 changes: 9 additions & 21 deletions cdk/src/constructs/jira-integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,8 @@ const WEBHOOK_PROCESSOR_TIMEOUT_SECONDS = 300;

/**
* Marker key embedded in the auto-generated stack-wide webhook-secret
* placeholder. The CLI (`bgagent jira setup`) recognizes a secret carrying
* this key as "never configured" and seeds the operator's value over it.
*
* MUST stay in sync with `JIRA_WEBHOOK_SECRET_PLACEHOLDER_KEY` in
* `cli/src/commands/jira.ts`. Unlike Linear (whose real secrets always start
* with `lin_wh_`), Atlassian webhook signing secrets are operator-chosen bare
* strings with no fixed shape, so the *placeholder* — not the real value — is
* the thing we make recognizable. See #368.
* placeholder. `bgagent jira setup` replaces the complete placeholder with
* the sole active tenant's admin-console webhook secret.
*/
const JIRA_WEBHOOK_SECRET_PLACEHOLDER_KEY = 'abca_jira_webhook_placeholder';

Expand Down Expand Up @@ -187,24 +181,18 @@ export class JiraIntegration extends Construct {

// --- Webhook signing secret (placeholder, populated by `bgagent jira setup`) ---
// Per-tenant OAuth tokens live in `bgagent-jira-oauth-<cloudId>` secrets
// created by the CLI at runtime — not here. This stack-wide secret is
// a back-compat fallback for single-tenant installs predating per-
// tenant signing.
// created by the CLI at runtime — not here. Jira admin-console webhooks
// omit cloudId, so a single-tenant install verifies them against this
Comment thread
ayushtr-aws marked this conversation as resolved.
// stack-wide copy of the tenant's signing secret.
//
// The initial value is an explicit JSON placeholder carrying
// `JIRA_WEBHOOK_SECRET_PLACEHOLDER_KEY`. Without `generateSecretString`,
// CDK seeds a BARE random string — which the CLI's placeholder heuristic
// mistook for an already-configured secret, so `setup` never seeded the
// operator's value and every admin-UI webhook delivery (whose payload has
// no `cloudId`, forcing stack-wide verification) failed HMAC with 401,
// silently (#368). Making the placeholder explicit lets the CLI reliably
// tell "never configured" from an operator-set value.
// The generated JSON is a non-operational initial value. Setup
// unconditionally replaces the complete value before the webhook is enabled.
this.webhookSecret = new secretsmanager.Secret(this, 'WebhookSecret', {
description: 'Jira webhook signing secret — populate via `bgagent jira setup`',
removalPolicy,
generateSecretString: {
// Yields `{"abca_jira_webhook_placeholder":true,"value":"<random>"}`:
// a JSON object (starts with `{`) with an explicit marker key.
// Yields `{"abca_jira_webhook_placeholder":true,"value":"<random>"}`.
// No runtime code interprets the marker key.
secretStringTemplate: JSON.stringify({ [JIRA_WEBHOOK_SECRET_PLACEHOLDER_KEY]: true }),
generateStringKey: 'value',
},
Expand Down
1 change: 1 addition & 0 deletions cdk/src/handlers/jira-link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayPr
const pending = await ddb.send(new GetCommand({
TableName: USER_MAPPING_TABLE,
Key: { jira_identity: `pending#${code}` },
ConsistentRead: true,
}));

if (!pending.Item || pending.Item.status !== 'pending') {
Expand Down
107 changes: 74 additions & 33 deletions cdk/src/handlers/jira-webhook-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ async function resolveSoleTenantCloudId(): Promise<string | undefined> {
ProjectionExpression: 'jira_cloud_id, #s',
ExpressionAttributeNames: { '#s': 'status' },
ExclusiveStartKey: lastKey,
ConsistentRead: true,
}));
for (const item of page.Items ?? []) {
if (item.status === 'active' && typeof item.jira_cloud_id === 'string') {
Expand Down Expand Up @@ -302,6 +303,16 @@ export async function handler(event: ProcessorEvent): Promise<void> {
});
return;
}
const commentProjectKey = issue.fields?.project?.key;
if (!commentProjectKey) {
logger.info('Jira comment issue has no project.key — skipping (cannot establish onboarding)', {
issue_key: issue.key,
});
return;
}
if (!await getActiveProjectMapping(cloudId, commentProjectKey, issue.key)) {
return;
}
await handleCommentTrigger(payload, issue, cloudId);
return;
}
Expand All @@ -311,11 +322,6 @@ export async function handler(event: ProcessorEvent): Promise<void> {
logger.info('Jira issue has no project.key — skipping (cannot route to a repo)', {
issue_key: issue.key,
});
await safeReportIssueFailure(
issue.key,
cloudId,
"❌ This Jira issue isn't in a project — ABCA needs a Jira project to route the task to a repo. Move the issue into a project and re-apply the trigger label.",
);
return;
}

Expand All @@ -331,25 +337,12 @@ export async function handler(event: ProcessorEvent): Promise<void> {
return;
}

const projectIdentity = `${cloudId}#${projectKey}`;
const mapping = await ddb.send(new GetCommand({
TableName: PROJECT_MAPPING_TABLE,
Key: { jira_project_identity: projectIdentity },
}));
if (!mapping.Item || mapping.Item.status !== 'active') {
logger.info('Jira project is not onboarded or is removed — skipping', {
jira_project_identity: projectIdentity,
issue_key: issue.key,
});
await safeReportIssueFailure(
issue.key,
cloudId,
`❌ This Jira project isn't onboarded to ABCA. An admin can onboard it with \`bgagent jira map ${cloudId} ${projectKey} --repo <owner>/<repo>\` (add \`--label <trigger>\` to change the trigger label).`,
);
const mapping = await getActiveProjectMapping(cloudId, projectKey, issue.key);
if (!mapping) {
return;
}
const repo = mapping.Item.repo as string;
const labelFilter = (mapping.Item.label_filter as string | undefined) ?? DEFAULT_LABEL_FILTER;
const repo = mapping.repo as string;
const labelFilter = (mapping.label_filter as string | undefined) ?? DEFAULT_LABEL_FILTER;

if (!shouldTrigger(payload, labelFilter)) {
logger.info('Jira webhook does not match trigger criteria', {
Expand All @@ -362,13 +355,24 @@ export async function handler(event: ProcessorEvent): Promise<void> {
return;
}

const accountId = payload.user?.accountId
?? issue.fields?.reporter?.accountId
?? issue.fields?.creator?.accountId;
const actorAccountId = payload.user?.accountId;
const reporterAccountId = issue.fields?.reporter?.accountId;
const creatorAccountId = issue.fields?.creator?.accountId;
const accountId = actorAccountId ?? reporterAccountId ?? creatorAccountId;
const accountSource = actorAccountId
? 'webhook_user'
: reporterAccountId
? 'issue_reporter'
: creatorAccountId
? 'issue_creator'
: undefined;
if (!accountId) {
logger.warn('Jira webhook missing user.accountId — cannot attribute task', {
issue_key: issue.key,
jira_cloud_id: cloudId,
jira_actor_account_id: actorAccountId,
jira_reporter_account_id: reporterAccountId,
jira_creator_account_id: creatorAccountId,
});
await safeReportIssueFailure(
issue.key,
Expand All @@ -383,12 +387,18 @@ export async function handler(event: ProcessorEvent): Promise<void> {
logger.warn('Jira account has no linked platform user — skipping task creation', {
jira_cloud_id: cloudId,
jira_account_id: accountId,
jira_account_source: accountSource,
jira_actor_account_id: actorAccountId,
jira_reporter_account_id: reporterAccountId,
jira_creator_account_id: creatorAccountId,
jira_identity_lookup_key: `${cloudId}#${accountId}`,
issue_key: issue.key,
});
await safeReportIssueFailure(
issue.key,
cloudId,
"❌ This Jira user isn't linked to a platform user. Run `bgagent jira link <code>` from a Cognito-authenticated CLI session to complete linking.",
`❌ The Jira user for this trigger isn't linked to a platform user (accountId: \`${accountId}\`). `
+ `Ask an admin to run \`bgagent jira invite-user ${cloudId} ${accountId}\`, then redeem the generated link code.`,
);
return;
}
Expand All @@ -408,8 +418,8 @@ export async function handler(event: ProcessorEvent): Promise<void> {
// admin configured `bgagent jira map ... --status-on-start/--status-on-pr`,
// stamp them so the agent's best-effort transition helpers prefer these
// status names over the built-in statusCategory / "In Review" heuristics.
const statusOnStart = mapping.Item.status_on_start as string | undefined;
const statusOnPr = mapping.Item.status_on_pr as string | undefined;
const statusOnStart = mapping.status_on_start as string | undefined;
const statusOnPr = mapping.status_on_pr as string | undefined;
if (statusOnStart) {
channelMetadata.jira_status_on_start = statusOnStart;
}
Expand Down Expand Up @@ -579,9 +589,9 @@ export async function handler(event: ProcessorEvent): Promise<void> {
/**
* Handle `comment_created` independently of the label-trigger path.
*
* The prior task is the routing source of truth: comments do not require the
* trigger label to still be present or the Jira project mapping to remain
* active. This preserves reviewer follow-ups after the original run.
* The prior task is the routing source of truth after the caller establishes
* that the Jira project mapping is still active. Comments do not require the
* trigger label to remain present.
*/
async function handleCommentTrigger(
payload: JiraIssueEvent,
Expand Down Expand Up @@ -1021,7 +1031,38 @@ async function lookupPlatformUser(cloudId: string, accountId: string): Promise<s
const result = await ddb.send(new GetCommand({
TableName: USER_MAPPING_TABLE,
Key: { jira_identity: key },
ConsistentRead: true,
}));
if (!result.Item || result.Item.status === 'pending') return null;
return (result.Item.platform_user_id as string) ?? null;
const platformUserId = result.Item?.platform_user_id;
if (
result.Item?.status !== 'active'
|| typeof platformUserId !== 'string'
|| !platformUserId
) {
return null;
}
return platformUserId;
}

async function getActiveProjectMapping(
cloudId: string,
projectKey: string,
issueKey: string,
): Promise<Record<string, unknown> | null> {
const projectIdentity = `${cloudId}#${projectKey}`;
const mapping = await ddb.send(new GetCommand({
TableName: PROJECT_MAPPING_TABLE,
Key: { jira_project_identity: projectIdentity },
ConsistentRead: true,
}));
if (!mapping.Item || mapping.Item.status !== 'active') {
// Jira admin-console webhooks fire site-wide. An unmapped project has not
// opted into ABCA, so it must remain a true no-op for every event type.
logger.info('Jira project is not onboarded or is removed — skipping silently', {
jira_project_identity: projectIdentity,
issue_key: issueKey,
});
return null;
}
return mapping.Item;
}
10 changes: 5 additions & 5 deletions cdk/src/handlers/jira-webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,10 @@ export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayPr
return jsonResponse(400, { error: 'Invalid JSON' });
}

// Per-tenant verification first. Falls through to stack-wide if (a) registry
// Per-tenant verification first. Uses the stack-wide verifier if (a) registry
// table not configured, (b) no cloudId in body, (c) tenant not in registry,
// or (d) tenant's stored secret lacks `webhook_signing_secret`.
// Per-tenant MISMATCH and REVOKED are fatal — no fallback.
// Per-tenant MISMATCH and REVOKED are fatal — no alternate verification.
//
// `verifiedViaStackWide` is propagated to the processor: a per-tenant
// signature proves the sender knows *that* tenant's secret (so the
Expand All @@ -150,12 +150,12 @@ export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayPr
});
return jsonResponse(401, { error: 'Invalid signature' });
} else if (result === 'revoked') {
logger.warn('Jira webhook from revoked tenant — rejecting without stack-wide fallback', {
logger.warn('Jira webhook from revoked tenant — rejecting without stack-wide verification', {
jira_cloud_id: payload.cloudId,
});
return jsonResponse(401, { error: 'Tenant not active' });
}
// 'no-per-tenant-secret' falls through to stack-wide.
// 'no-per-tenant-secret' uses the stack-wide verifier.
}

if (!verified) {
Expand All @@ -166,7 +166,7 @@ export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayPr
return jsonResponse(401, { error: 'Invalid signature' });
}
verifiedViaStackWide = true;
logger.info('Jira webhook verified via stack-wide fallback secret', {
logger.info('Jira webhook verified via stack-wide secret', {
jira_cloud_id: payload.cloudId,
per_tenant_registry_configured: Boolean(WORKSPACE_REGISTRY_TABLE),
});
Expand Down
18 changes: 10 additions & 8 deletions cdk/src/handlers/shared/jira-oauth-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,14 @@ export interface StoredOauthToken {
/** Per-tenant Jira webhook signing secret.
*
* Atlassian's "Generic webhooks" support a per-webhook secret that signs
* events with `X-Hub-Signature: sha256=<hex>`. Webhook subscriptions are
* tenant-scoped, so a single stack-wide signing secret cannot verify
* events from multiple tenants. The webhook receiver looks this up by
* `cloudId` at verify time.
* events with `X-Hub-Signature: sha256=<hex>`. The receiver uses this copy
* when the payload carries cloudId. Jira admin-console payloads omit
* cloudId, so the sole active tenant also synchronizes its value to the
* stack-wide verifier.
*
* Optional for back-compat: tokens written before per-tenant signing
* was wired up won't have it, and the receiver falls back to the
* stack-wide `JIRA_WEBHOOK_SECRET_ARN` for those installs. */
* was wired up won't have it, so the receiver uses the stack-wide
* `JIRA_WEBHOOK_SECRET_ARN` verifier for those installs. */
readonly webhook_signing_secret?: string;
}

Expand Down Expand Up @@ -341,8 +341,8 @@ export async function resolveJiraOauthToken(
* Strict variant of {@link getRegistryRow}: throws on infra error
* (DDB throttle, network) instead of returning null. Use this from the
* webhook signature-verification path where a `null` return would let
* a transient throttle silently downgrade per-tenant verification to
* the stack-wide fallback secret.
* a transient throttle silently bypass per-tenant verification by using
* the stack-wide verifier.
*/
export async function getRegistryRowStrict(
ddb: DynamoDBDocumentClient,
Expand All @@ -355,6 +355,7 @@ export async function getRegistryRowStrict(
const result = await ddb.send(new GetCommand({
TableName: tableName,
Key: { jira_cloud_id: cloudId },
ConsistentRead: true,
}));
return parseRegistryRow(result.Item, cloudId);
}
Expand All @@ -372,6 +373,7 @@ export async function getRegistryRow(
result = await ddb.send(new GetCommand({
TableName: tableName,
Key: { jira_cloud_id: cloudId },
ConsistentRead: true,
}));
} catch (err) {
logger.error('Failed to fetch Jira workspace registry row', {
Expand Down
5 changes: 3 additions & 2 deletions cdk/src/handlers/shared/jira-verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,9 @@ export async function verifyJiraRequest(
* - `'revoked'` — registry row exists but status is not `active`.
* Reject; do NOT fall back.
* - `'no-per-tenant-secret'` — no registry row, OR the stored secret
* has no `webhook_signing_secret`. Caller should fall back to the
* stack-wide secret for back-compat with single-tenant installs.
* has no `webhook_signing_secret`. Caller should use the stack-wide
* verifier. This is also the normal path for admin-console payloads, which
* omit cloudId before tenant selection is possible.
*
* Strict lookups (throw on infra errors) are used so a transient DDB or
* SM error doesn't silently downgrade a per-tenant-secured tenant to
Expand Down
12 changes: 5 additions & 7 deletions cdk/test/constructs/jira-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,13 @@ describe('JiraIntegration construct', () => {
});
});

// #368: the webhook secret MUST seed an explicit JSON placeholder so the CLI
// can distinguish "never configured" from an operator-set value. A bare
// generated string (CDK's default with no GenerateSecretString) caused
// `bgagent jira setup` to skip seeding, leaving every admin-UI webhook
// delivery to fail HMAC verification with 401.
test('webhook secret seeds a JSON placeholder carrying the explicit marker key (#368)', () => {
// A structured initial value avoids seeding a bare signing-key-shaped
// secret. Setup unconditionally replaces the complete value; no runtime
// code interprets the marker key.
test('webhook secret seeds a non-signing JSON placeholder', () => {
template.hasResourceProperties('AWS::SecretsManager::Secret', {
GenerateSecretString: Match.objectLike({
// secretStringTemplate is the JSON object carrying the marker key.
// secretStringTemplate pins the non-operational initial JSON shape.
SecretStringTemplate: Match.stringLikeRegexp('abca_jira_webhook_placeholder'),
GenerateStringKey: 'value',
}),
Expand Down
2 changes: 2 additions & 0 deletions cdk/test/handlers/jira-link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ describe('jira-link handler', () => {

const result = await handler(makeEvent({ code: 'link-3f8b4a2c' }, 'cognito-user-1'));
expect(result.statusCode).toBe(200);
const getCall = ddbSend.mock.calls.find(([cmd]) => cmd._type === 'Get');
expect(getCall![0].input.ConsistentRead).toBe(true);
const putCall = ddbSend.mock.calls.find(([cmd]) => cmd._type === 'Put');
expect(putCall).toBeTruthy();
expect(putCall![0].input.Item.jira_identity).toBe('cloud-1#acc-1');
Expand Down
Loading
Loading