Skip to content

Commit c88aa6c

Browse files
committed
fix(bus): complete cross-account EventBridge→SQS support
This commit completes the fix for cross-account EventBridge to SQS subscriptions by adding comprehensive support for IAM role creation and proper provider handling. Changes: 1. helpers/arn.ts - Added parseArn() helper function - Parses generic AWS ARNs to extract service, region, account, resource - Enables cross-account detection by comparing account IDs 2. bus-queue-subscriber.ts - Complete cross-account logic - Detect cross-account scenarios by comparing bus vs queue account IDs - Create IAM role in bus's account with events.amazonaws.com trust - Add roleArn to EventTarget (required by AWS for cross-account) - Create queue policy with default provider (queue's account) - Correct queue URL parsing using parseArn() Key implementation details: - Cross-account detection: parseArn() extracts account IDs from ARNs - IAM role: Created only when cross-account, named ${name}TargetRole - Queue policy: Created without parent to force default provider - Queue URL: https://sqs.${region}.amazonaws.com/${account}/${resource} This fix maintains backward compatibility: - Same-account subscriptions work unchanged - Cross-account logic only activates when accounts differ - No breaking API changes Tested with: - Bus in account 043309359455 (eu-central-1) - Queue in account 814835236929 (eu-central-1) - Full event flow successfully verified Fixes: Cross-account EventBridge→SQS AccessDenied errors Co-Authored-By: Jay <jaybelic@pm.me>
1 parent d5904a6 commit c88aa6c

2 files changed

Lines changed: 132 additions & 3 deletions

File tree

platform/src/components/aws/bus-queue-subscriber.ts

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { ComponentResourceOptions, Input, output } from "@pulumi/pulumi";
22
import { Component, transform } from "../component";
33
import { BusBaseSubscriberArgs, createRule } from "./bus-base-subscriber";
4-
import { cloudwatch, sqs } from "@pulumi/aws";
4+
import { cloudwatch, sqs, iam } from "@pulumi/aws";
55
import { Queue } from "./queue";
6+
import { parseArn } from "./helpers/arn";
67

78
export interface Args extends BusBaseSubscriberArgs {
89
/**
@@ -22,7 +23,7 @@ export interface Args extends BusBaseSubscriberArgs {
2223
* You'll find this component returned by the `subscribeQueue` method of the `Bus` component.
2324
*/
2425
export class BusQueueSubscriber extends Component {
25-
private readonly policy: sqs.QueuePolicy;
26+
private readonly policy: Output<sqs.QueuePolicy>;
2627
private readonly rule: cloudwatch.EventRule;
2728
private readonly target: cloudwatch.EventTarget;
2829

@@ -31,9 +32,66 @@ export class BusQueueSubscriber extends Component {
3132

3233
const self = this;
3334
const bus = output(args.bus);
35+
const busArn = bus.arn;
3436
const queueArn = output(args.queue).apply((queue) =>
3537
queue instanceof Queue ? queue.arn : output(queue),
3638
);
39+
40+
// Detect cross-account scenario by comparing account IDs
41+
const isCrossAccount = output(busArn).apply((busArnStr) =>
42+
queueArn.apply((queueArnStr) => {
43+
const busParsed = parseArn(busArnStr);
44+
const queueParsed = parseArn(queueArnStr);
45+
const crossAccount = busParsed.account !== queueParsed.account;
46+
console.log("Cross-account detection:", {
47+
busAccount: busParsed.account,
48+
queueAccount: queueParsed.account,
49+
isCrossAccount: crossAccount,
50+
});
51+
return crossAccount;
52+
}),
53+
);
54+
55+
// Create IAM role only for cross-account scenarios
56+
// This role allows EventBridge in the bus's account to send messages to the queue
57+
const targetRole = isCrossAccount.apply((crossAccount) => {
58+
if (!crossAccount) return undefined;
59+
60+
// IAM role created in bus's account (using the provided provider)
61+
const role = new iam.Role(
62+
`${name}TargetRole`,
63+
{
64+
assumeRolePolicy: iam.assumeRolePolicyForPrincipal({
65+
Service: "events.amazonaws.com",
66+
}),
67+
},
68+
{ provider: opts?.provider },
69+
);
70+
71+
// Inline policy granting sqs:SendMessage to the target queue
72+
new iam.RolePolicy(
73+
`${name}TargetRolePolicy`,
74+
{
75+
role: role.id,
76+
policy: queueArn.apply((arn) =>
77+
JSON.stringify({
78+
Version: "2012-10-17",
79+
Statement: [
80+
{
81+
Effect: "Allow",
82+
Action: "sqs:SendMessage",
83+
Resource: arn,
84+
},
85+
],
86+
}),
87+
),
88+
},
89+
{ provider: opts?.provider },
90+
);
91+
92+
return role;
93+
});
94+
3795
const policy = createPolicy();
3896
const rule = createRule(name, bus.name, args, self);
3997
const target = createTarget();
@@ -43,7 +101,43 @@ export class BusQueueSubscriber extends Component {
43101
this.target = target;
44102

45103
function createPolicy() {
46-
return Queue.createPolicy(`${name}Policy`, queueArn, { parent: self });
104+
// For cross-account: create queue policy WITHOUT parent to force default provider
105+
// For same-account: use Queue.createPolicy with normal parent relationship
106+
return isCrossAccount.apply((crossAccount) => {
107+
if (crossAccount) {
108+
// Cross-account: Create policy directly with default provider (no parent)
109+
// This is CRITICAL - the policy must be in the queue's account, not bus's account
110+
return new sqs.QueuePolicy(
111+
`${name}Policy`,
112+
{
113+
queueUrl: queueArn.apply((arn) => {
114+
// Parse SQS ARN: arn:aws:sqs:region:account-id:queue-name
115+
// Queue URL: https://sqs.{region}.amazonaws.com/{account-id}/{queue-name}
116+
const parsed = parseArn(arn);
117+
return `https://sqs.${parsed.region}.amazonaws.com/${parsed.account}/${parsed.resource}`;
118+
}),
119+
policy: iam.getPolicyDocumentOutput({
120+
statements: [
121+
{
122+
actions: ["sqs:SendMessage"],
123+
resources: [queueArn],
124+
principals: [
125+
{
126+
type: "Service",
127+
identifiers: ["events.amazonaws.com"],
128+
},
129+
],
130+
},
131+
],
132+
}).json,
133+
},
134+
{ retainOnDelete: true }, // No parent, no provider = default provider
135+
);
136+
} else {
137+
// Same-account: Use normal Queue.createPolicy with parent
138+
return Queue.createPolicy(`${name}Policy`, queueArn, { parent: self });
139+
}
140+
});
47141
}
48142

49143
function createTarget() {
@@ -55,6 +149,8 @@ export class BusQueueSubscriber extends Component {
55149
arn: queueArn,
56150
rule: rule.name,
57151
eventBusName: bus.name,
152+
// roleArn is required only for cross-account scenarios
153+
roleArn: targetRole?.arn,
58154
},
59155
{ parent: self },
60156
),

platform/src/components/aws/helpers/arn.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,3 +180,36 @@ export function parseDsqlPrivateEndpoint(
180180
);
181181
return privateDnsName.replace("*", clusterId);
182182
}
183+
184+
/**
185+
* Parses a generic AWS ARN and extracts its components.
186+
* ARN format: arn:aws:service:region:account-id:resource
187+
*
188+
* @param arn - The ARN string to parse
189+
* @returns Object with service, region, account, and resource components
190+
* @throws VisibleError if the ARN format is invalid
191+
*
192+
* @example
193+
* ```typescript
194+
* parseArn("arn:aws:events:us-east-1:123456789012:event-bus/my-bus")
195+
* // Returns: { service: "events", region: "us-east-1", account: "123456789012", resource: "event-bus/my-bus" }
196+
* ```
197+
*/
198+
export function parseArn(arn: string): {
199+
service: string;
200+
region: string;
201+
account: string;
202+
resource: string;
203+
} {
204+
// ARN format: arn:aws:service:region:account-id:resource
205+
const parts = arn.split(":");
206+
if (parts[0] !== "arn" || parts.length < 6) {
207+
throw new VisibleError(`Invalid ARN format: ${arn}`);
208+
}
209+
return {
210+
service: parts[2],
211+
region: parts[3],
212+
account: parts[4],
213+
resource: parts.slice(5).join(":"),
214+
};
215+
}

0 commit comments

Comments
 (0)