Skip to content

Commit

Permalink
feat(events): cross-region event rules (#14731)
Browse files Browse the repository at this point in the history
This pull request aims to extend the current support for cross-account event targets to also support limited cross-region event targets. Currently, the initial list of supported destination regions is: US East (N. Virginia – us-east-1), US West (Oregon – us-west-2), and Europe (Ireland – eu-west-1). The event can originate in any AWS region. 

The original feature request is described here: #14635 and the blog post describing this feature launch is here: https://aws.amazon.com/blogs/compute/introducing-cross-region-event-routing-with-amazon-eventbridge/


----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
stephenhibbert authored Jul 12, 2021
1 parent 76f06fc commit c62afe9
Show file tree
Hide file tree
Showing 5 changed files with 361 additions and 77 deletions.
3 changes: 2 additions & 1 deletion packages/@aws-cdk/aws-events-targets/lib/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as events from '@aws-cdk/aws-events';
import * as iam from '@aws-cdk/aws-iam';
import * as lambda from '@aws-cdk/aws-lambda';
import * as sqs from '@aws-cdk/aws-sqs';
import { Annotations, ConstructNode, IConstruct, Names, Token, TokenComparison, Duration } from '@aws-cdk/core';
import { Annotations, ConstructNode, IConstruct, Names, Token, TokenComparison, Duration, PhysicalName } from '@aws-cdk/core';

// keep this import separate from other imports to reduce chance for merge conflicts with v2-main
// eslint-disable-next-line no-duplicate-imports, import/order
Expand Down Expand Up @@ -77,6 +77,7 @@ export function singletonEventRole(scope: IConstruct, policyStatements: iam.Poli
if (existing) { return existing; }

const role = new iam.Role(scope as Construct, id, {
roleName: PhysicalName.GENERATE_IF_NEEDED,
assumedBy: new iam.ServicePrincipal('events.amazonaws.com'),
});

Expand Down
9 changes: 3 additions & 6 deletions packages/@aws-cdk/aws-events/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,9 @@ The following targets are supported:
* `targets.BatchJob`: Queue an AWS Batch Job
* `targets.AwsApi`: Make an AWS API call

### Cross-account targets
### Cross-account and cross-region targets

It's possible to have the source of the event and a target in separate AWS accounts:
It's possible to have the source of the event and a target in separate AWS accounts and regions:

```ts
import { App, Stack } from '@aws-cdk/core';
Expand All @@ -148,7 +148,7 @@ import * as targets from '@aws-cdk/aws-events-targets';

const app = new App();

const stack1 = new Stack(app, 'Stack1', { env: { account: account1, region: 'us-east-1' } });
const stack1 = new Stack(app, 'Stack1', { env: { account: account1, region: 'us-west-1' } });
const repo = new codecommit.Repository(stack1, 'Repository', {
// ...
});
Expand All @@ -171,9 +171,6 @@ In this situation, the CDK will wire the 2 accounts together:
to the event bus of the target account in the given region,
and make sure its deployed before the source stack

**Note**: while events can span multiple accounts, they _cannot_ span different regions
(that is an EventBridge, not CDK, limitation).

For more information, see the
[AWS documentation on cross-account events](https://docs.aws.amazon.com/eventbridge/latest/userguide/eventbridge-cross-account-event-delivery.html).

Expand Down
169 changes: 119 additions & 50 deletions packages/@aws-cdk/aws-events/lib/rule.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { App, Lazy, Names, Resource, Stack, Token } from '@aws-cdk/core';
import { Construct, Node } from 'constructs';
import { IRole, PolicyStatement, Role, ServicePrincipal } from '@aws-cdk/aws-iam';
import { App, IConstruct, IResource, Lazy, Names, Resource, Stack, Token, PhysicalName } from '@aws-cdk/core';
import { Node, Construct } from 'constructs';
import { IEventBus } from './event-bus';
import { EventPattern } from './event-pattern';
import { CfnEventBusPolicy, CfnRule } from './events.generated';
import { IRule } from './rule-ref';
import { Schedule } from './schedule';
import { IRuleTarget } from './target';
import { mergeEventPattern, renderEventPattern } from './util';
import { mergeEventPattern, renderEventPattern, sameEnvDimension } from './util';

// keep this import separate from other imports to reduce chance for merge conflicts with v2-main
// eslint-disable-next-line no-duplicate-imports, import/order
import { Construct as CoreConstruct } from '@aws-cdk/core';

/**
* Properties for defining an EventBridge Rule
Expand Down Expand Up @@ -113,7 +118,7 @@ export class Rule extends Resource implements IRule {
private readonly eventPattern: EventPattern = { };
private readonly scheduleExpression?: string;
private readonly description?: string;
private readonly accountEventBusTargets: { [account: string]: boolean } = {};
private readonly targetAccounts: {[key: string]: Set<string>} = {};

constructor(scope: Construct, id: string, props: RuleProps = { }) {
super(scope, id, {
Expand Down Expand Up @@ -171,93 +176,122 @@ export class Rule extends Resource implements IRule {

if (targetProps.targetResource) {
const targetStack = Stack.of(targetProps.targetResource);
const targetAccount = targetStack.account;
const targetRegion = targetStack.region;

const targetAccount = (targetProps.targetResource as IResource).env?.account || targetStack.account;
const targetRegion = (targetProps.targetResource as IResource).env?.region || targetStack.region;

const sourceStack = Stack.of(this);
const sourceAccount = sourceStack.account;
const sourceRegion = sourceStack.region;

if (targetRegion !== sourceRegion) {
throw new Error('Rule and target must be in the same region');
}

if (targetAccount !== sourceAccount) {
// cross-account event - strap in, this works differently than regular events!
// if the target is in a different account or region and is defined in this CDK App
// we can generate all the needed components:
// - forwarding rule in the source stack (target: default event bus of the receiver region)
// - eventbus permissions policy (creating an extra stack)
// - receiver rule in the target stack (target: the actual target)
if (!sameEnvDimension(sourceAccount, targetAccount) || !sameEnvDimension(sourceRegion, targetRegion)) {
// cross-account and/or cross-region event - strap in, this works differently than regular events!
// based on:
// https://docs.aws.amazon.com/eventbridge/latest/userguide/eventbridge-cross-account-event-delivery.html
// https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-cross-account.html

// for cross-account events, we require concrete accounts
if (Token.isUnresolved(targetAccount)) {
throw new Error('You need to provide a concrete account for the target stack when using cross-account events');
// for cross-account or cross-region events, we cannot create new components for an imported resource
// because we don't have the target stack
const isImportedResource = !sameEnvDimension(targetStack.account, targetAccount) || !sameEnvDimension(targetStack.region, targetRegion); //(targetAccount !== targetStack.account) || (targetRegion !== targetStack.region);
if (isImportedResource) {
throw new Error('Cannot create a cross-account or cross-region rule with an imported resource');
}

// for cross-account or cross-region events, we require concrete accounts
if (!targetAccount || Token.isUnresolved(targetAccount)) {
throw new Error('You need to provide a concrete account for the target stack when using cross-account or cross-region events');
}
if (Token.isUnresolved(sourceAccount)) {
throw new Error('You need to provide a concrete account for the source stack when using cross-account events');
throw new Error('You need to provide a concrete account for the source stack when using cross-account or cross-region events');
}
// and the target region has to be concrete as well
if (Token.isUnresolved(targetRegion)) {
throw new Error('You need to provide a concrete region for the target stack when using cross-account events');
if (!targetRegion || Token.isUnresolved(targetRegion)) {
throw new Error('You need to provide a concrete region for the target stack when using cross-account or cross-region events');
}

// the _actual_ target is just the event bus of the target's account
// make sure we only add it once per account
const exists = this.accountEventBusTargets[targetAccount];
if (!exists) {
this.accountEventBusTargets[targetAccount] = true;
// make sure we only add it once per account per region
let targetAccountExists = false;
const accountKey = Object.keys(this.targetAccounts).find(account => account === targetAccount);
if (accountKey) {
targetAccountExists = this.targetAccounts[accountKey].has(targetRegion);
}

if (!targetAccountExists) {
// add the current account-region pair to tracking structure
const regionsSet = this.targetAccounts[targetAccount];
if (!regionsSet) {
this.targetAccounts[targetAccount] = new Set<string>();
}
this.targetAccounts[targetAccount].add(targetRegion);

const eventBusArn = targetStack.formatArn({
service: 'events',
resource: 'event-bus',
resourceName: 'default',
region: targetRegion,
account: targetAccount,
});

this.targets.push({
id,
arn: targetStack.formatArn({
service: 'events',
resource: 'event-bus',
resourceName: 'default',
region: targetRegion,
account: targetAccount,
}),
arn: eventBusArn,
// for cross-region we now require a role with PutEvents permissions
roleArn: roleArn ?? this.singletonEventRole(this, [this.putEventStatement(eventBusArn)]).roleArn,
});
}

// Grant the source account permissions to publish events to the event bus of the target account.
// Grant the source account in the source region permissions to publish events to the event bus of the target account in the target region.
// Do it in a separate stack instead of the target stack (which seems like the obvious place to put it),
// because it needs to be deployed before the rule containing the above event-bus target in the source stack
// (EventBridge verifies whether you have permissions to the targets on rule creation),
// but it's common for the target stack to depend on the source stack
// (that's the case with CodePipeline, for example)
const sourceApp = this.node.root;
if (!sourceApp || !App.isApp(sourceApp)) {
throw new Error('Event stack which uses cross-account targets must be part of a CDK app');
throw new Error('Event stack which uses cross-account or cross-region targets must be part of a CDK app');
}
const targetApp = Node.of(targetProps.targetResource).root;
if (!targetApp || !App.isApp(targetApp)) {
throw new Error('Target stack which uses cross-account event targets must be part of a CDK app');
throw new Error('Target stack which uses cross-account or cross-region event targets must be part of a CDK app');
}
if (sourceApp !== targetApp) {
throw new Error('Event stack and target stack must belong to the same CDK app');
}
const stackId = `EventBusPolicy-${sourceAccount}-${targetRegion}-${targetAccount}`;
let eventBusPolicyStack: Stack = sourceApp.node.tryFindChild(stackId) as Stack;
if (!eventBusPolicyStack) {
eventBusPolicyStack = new Stack(sourceApp, stackId, {
env: {
account: targetAccount,
region: targetRegion,
},
stackName: `${targetStack.stackName}-EventBusPolicy-support-${targetRegion}-${sourceAccount}`,
});
new CfnEventBusPolicy(eventBusPolicyStack, 'GivePermToOtherAccount', {
action: 'events:PutEvents',
statementId: `Allow-account-${sourceAccount}`,
principal: sourceAccount,
});

// if different accounts, we need to add the permissions to the target eventbus
if (!sameEnvDimension(sourceAccount, targetAccount)) {
const stackId = `EventBusPolicy-${sourceAccount}-${targetRegion}-${targetAccount}`;
let eventBusPolicyStack: Stack = sourceApp.node.tryFindChild(stackId) as Stack;
if (!eventBusPolicyStack) {
eventBusPolicyStack = new Stack(sourceApp, stackId, {
env: {
account: targetAccount,
region: targetRegion,
},
stackName: `${targetStack.stackName}-EventBusPolicy-support-${targetRegion}-${sourceAccount}`,
});
new CfnEventBusPolicy(eventBusPolicyStack, 'GivePermToOtherAccount', {
action: 'events:PutEvents',
statementId: `Allow-account-${sourceAccount}`,
principal: sourceAccount,
});
}
// deploy the event bus permissions before the source stack
sourceStack.addDependency(eventBusPolicyStack);
}
// deploy the event bus permissions before the source stack
sourceStack.addDependency(eventBusPolicyStack);

// The actual rule lives in the target stack.
// Other than the account, it's identical to this one

// eventPattern is mutable through addEventPattern(), so we need to lazy evaluate it
// but only Tokens can be lazy in the framework, so make a subclass instead
const self = this;

class CopyRule extends Rule {
public _renderEventPattern(): any {
return self._renderEventPattern();
Expand All @@ -274,6 +308,7 @@ export class Rule extends Resource implements IRule {
protected validate(): string[] {
return [];
}

}

new CopyRule(targetStack, `${Names.uniqueId(this)}-${id}`, {
Expand All @@ -287,6 +322,10 @@ export class Rule extends Resource implements IRule {
}
}

// Here only if the target does not have a targetResource defined.
// In such case we don't have to generate any extra component.
// Note that this can also be an imported resource (i.e: EventBus target)

this.targets.push({
id,
arn: targetProps.arn,
Expand Down Expand Up @@ -372,4 +411,34 @@ export class Rule extends Resource implements IRule {

return this.targets;
}

/**
* Obtain the Role for the EventBridge event
*
* If a role already exists, it will be returned. This ensures that if multiple
* events have the same target, they will share a role.
* @internal
*/
private singletonEventRole(scope: IConstruct, policyStatements: PolicyStatement[]): IRole {
const id = 'EventsRole';
const existing = scope.node.tryFindChild(id) as IRole;
if (existing) { return existing; }

const role = new Role(scope as CoreConstruct, id, {
roleName: PhysicalName.GENERATE_IF_NEEDED,
assumedBy: new ServicePrincipal('events.amazonaws.com'),
});

policyStatements.forEach(role.addToPolicy.bind(role));

return role;
}

private putEventStatement(eventBusArn: string) {
return new PolicyStatement({
actions: ['events:PutEvents'],
resources: [eventBusArn],
});
}
}

12 changes: 12 additions & 0 deletions packages/@aws-cdk/aws-events/lib/util.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Token, TokenComparison } from '@aws-cdk/core';
import { EventPattern } from './event-pattern';

/**
Expand Down Expand Up @@ -54,6 +55,17 @@ export function mergeEventPattern(dest: any, src: any) {
}
}

/**
* Whether two string probably contain the same environment dimension (region or account)
*
* Used to compare either accounts or regions, and also returns true if both
* are unresolved (in which case both are expted to be "current region" or "current account").
* @internal
*/
export function sameEnvDimension(dim1: string, dim2: string) {
return [TokenComparison.SAME, TokenComparison.BOTH_UNRESOLVED].includes(Token.compareStrings(dim1, dim2));
}

/**
* Transform an eventPattern object into a valid Event Rule Pattern
* by changing detailType into detail-type when present.
Expand Down
Loading

0 comments on commit c62afe9

Please sign in to comment.