Skip to content

Commit

Permalink
feat(events): ability to add cross-account targets (#3323)
Browse files Browse the repository at this point in the history
This adds the capability of adding a target to an event rule
that belongs to a different account than the rule itself.
Required for things like cross-account CodePipelines with source actions triggered by events.
  • Loading branch information
skinny85 authored Aug 1, 2019
1 parent c7e9dfb commit 3b794ea
Show file tree
Hide file tree
Showing 11 changed files with 400 additions and 17 deletions.
1 change: 1 addition & 0 deletions packages/@aws-cdk/aws-events-targets/lib/codebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export class CodeBuildProject implements events.IRuleTarget {
actions: ['codebuild:StartBuild'],
resources: [this.project.projectArn],
})]),
targetResource: this.project,
};
}
}
3 changes: 2 additions & 1 deletion packages/@aws-cdk/aws-events-targets/lib/codepipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ export class CodePipeline implements events.IRuleTarget {
role: singletonEventRole(this.pipeline, [new iam.PolicyStatement({
resources: [this.pipeline.pipelineArn],
actions: ['codepipeline:StartPipelineExecution'],
})])
})]),
targetResource: this.pipeline,
};
}
}
3 changes: 2 additions & 1 deletion packages/@aws-cdk/aws-events-targets/lib/ecs-task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,8 @@ export class EcsTask implements events.IRuleTarget {
taskCount,
taskDefinitionArn
},
input: events.RuleTargetInput.fromObject(input)
input: events.RuleTargetInput.fromObject(input),
targetResource: this.taskDefinition,
};
}
}
1 change: 1 addition & 0 deletions packages/@aws-cdk/aws-events-targets/lib/lambda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export class LambdaFunction implements events.IRuleTarget {
id: '',
arn: this.handler.functionArn,
input: this.props.event,
targetResource: this.handler,
};
}
}
1 change: 1 addition & 0 deletions packages/@aws-cdk/aws-events-targets/lib/sns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export class SnsTopic implements events.IRuleTarget {
id: '',
arn: this.topic.topicArn,
input: this.props.message,
targetResource: this.topic,
};
}
}
3 changes: 2 additions & 1 deletion packages/@aws-cdk/aws-events-targets/lib/sqs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,11 @@ export class SqsQueue implements events.IRuleTarget {
})
);

const result = {
const result: events.RuleTargetConfig = {
id: '',
arn: this.queue.queueArn,
input: this.props.message,
targetResource: this.queue,
};
if (!!this.props.messageGroupId) {
Object.assign(result, { sqsParameters: { messageGroupId: this.props.messageGroupId } });
Expand Down
3 changes: 2 additions & 1 deletion packages/@aws-cdk/aws-events-targets/lib/state-machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ export class SfnStateMachine implements events.IRuleTarget {
actions: ['states:StartExecution'],
resources: [this.machine.stateMachineArn]
})]),
input: this.props.input
input: this.props.input,
targetResource: this.machine,
};
}
}
41 changes: 41 additions & 0 deletions packages/@aws-cdk/aws-events/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,44 @@ The following targets are supported:
* `targets.SnsTopic`: Publish into an SNS topic
* `targets.SqsQueue`: Send a message to an Amazon SQS Queue
* `targets.SfnStateMachine`: Trigger an AWS Step Functions state machine

### Cross-account targets

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

```typescript
import { App, Stack } from '@aws-cdk/core';
import codebuild = require('@aws-cdk/aws-codebuild');
import codecommit = require('@aws-cdk/aws-codecommit');
import targets = require('@aws-cdk/aws-events-targets');

const app = new App();

const stack1 = new Stack(app, 'Stack1', { env: { account: account1, region: 'us-east-1' } });
const repo = new codecommit.Repository(stack1, 'Repository', {
// ...
});

const stack2 = new Stack(app, 'Stack2', { env: { account: account2, region: 'us-east-1' } });
const project = new codebuild.Project(stack2, 'Project', {
// ...
});

repo.onCommit('OnCommit', {
target: new targets.CodeBuildProject(project),
});
```

In this situation, the CDK will wire the 2 accounts together:

* It will generate a rule in the source stack with the event bus of the target account as the target
* It will generate a rule in the target stack, with the provided target
* It will generate a separate stack that gives the source account permissions to publish events
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 a CloudWatch, not CDK, limitation).

For more information, see the
[AWS documentation on cross-account events](https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/CloudWatchEvents-CrossAccountEventDelivery.html).
117 changes: 109 additions & 8 deletions packages/@aws-cdk/aws-events/lib/rule.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Construct, Lazy, Resource } from '@aws-cdk/core';
import { App, Construct, Lazy, Resource, Stack, Token } from '@aws-cdk/core';
import { EventPattern } from './event-pattern';
import { CfnRule } from './events.generated';
import { CfnEventBusPolicy, CfnRule } from './events.generated';
import { IRule } from './rule-ref';
import { Schedule } from './schedule';
import { IRuleTarget } from './target';
Expand Down Expand Up @@ -88,16 +88,19 @@ export class Rule extends Resource implements IRule {

private readonly targets = new Array<CfnRule.TargetProperty>();
private readonly eventPattern: EventPattern = { };
private scheduleExpression?: string;
private readonly scheduleExpression?: string;
private readonly description?: string;
private readonly accountEventBusTargets: { [account: string]: boolean } = {};

constructor(scope: Construct, id: string, props: RuleProps = { }) {
super(scope, id, {
physicalName: props.ruleName,
});
this.description = props.description;

const resource = new CfnRule(this, 'Resource', {
name: this.physicalName,
description: props.description,
description: this.description,
state: props.enabled == null ? 'ENABLED' : (props.enabled ? 'ENABLED' : 'DISABLED'),
scheduleExpression: Lazy.stringValue({ produce: () => this.scheduleExpression }),
eventPattern: Lazy.anyValue({ produce: () => this.renderEventPattern() }),
Expand All @@ -124,19 +127,117 @@ export class Rule extends Resource implements IRule {
*
* No-op if target is undefined.
*/
public addTarget(target?: IRuleTarget) {
public addTarget(target?: IRuleTarget): void {
if (!target) { return; }

// Simply increment id for each `addTarget` call. This is guaranteed to be unique.
const id = `Target${this.targets.length}`;
const autoGeneratedId = `Target${this.targets.length}`;

const targetProps = target.bind(this, id);
const targetProps = target.bind(this, autoGeneratedId);
const inputProps = targetProps.input && targetProps.input.bind(this);

const roleArn = targetProps.role ? targetProps.role.roleArn : undefined;
const id = targetProps.id || autoGeneratedId;

if (targetProps.targetResource) {
const targetStack = Stack.of(targetProps.targetResource);
const targetAccount = targetStack.account;
const targetRegion = 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!
// based on:
// https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/CloudWatchEvents-CrossAccountEventDelivery.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');
}
if (Token.isUnresolved(sourceAccount)) {
throw new Error('You need to provide a concrete account for the source stack when using cross-account 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');
}

// the _actual_ target is just the event bus of the target's account
// make sure we only add it once per region
const key = `${targetAccount}-${targetRegion}`;
const exists = this.accountEventBusTargets[key];
if (!exists) {
this.accountEventBusTargets[key] = true;
this.targets.push({
id,
arn: targetStack.formatArn({
service: 'events',
resource: 'event-bus',
resourceName: 'default',
region: targetRegion,
account: targetAccount,
}),
});
}

// Grant the source account permissions to publish events to the event bus of the target account.
// 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
// (CloudWatch 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');
}
const targetApp = targetProps.targetResource.node.root;
if (!targetApp || !App.isApp(targetApp)) {
throw new Error('Target stack which uses cross-account 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: 'MySid',
principal: sourceAccount,
});
}
// 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
new Rule(targetStack, `${this.node.uniqueId}-${id}`, {
targets: [target],
eventPattern: this.eventPattern,
schedule: this.scheduleExpression ? Schedule.expression(this.scheduleExpression) : undefined,
description: this.description,
});

return;
}
}

this.targets.push({
id: targetProps.id || id,
id,
arn: targetProps.arn,
roleArn,
ecsParameters: targetProps.ecsParameters,
Expand Down
14 changes: 14 additions & 0 deletions packages/@aws-cdk/aws-events/lib/target.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import iam = require('@aws-cdk/aws-iam');
import { IConstruct } from '@aws-cdk/core';
import { CfnRule } from './events.generated';
import { RuleTargetInput } from './input';
import { IRule } from './rule-ref';
Expand Down Expand Up @@ -65,4 +66,17 @@ export interface RuleTargetConfig {
* @default the entire event
*/
readonly input?: RuleTargetInput;

/**
* The resource that is backing this target.
* This is the resource that will actually have some action performed on it when used as a target
* (for example, start a build for a CodeBuild project).
* We need it to determine whether the rule belongs to a different account than the target -
* if so, we generate a more complex setup,
* including an additional stack containing the EventBusPolicy.
*
* @see https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/CloudWatchEvents-CrossAccountEventDelivery.html
* @default the target is not backed by any resource
*/
readonly targetResource?: IConstruct;
}
Loading

0 comments on commit 3b794ea

Please sign in to comment.