Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(events): ability to add cross-account targets #3323

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;

skinny85 marked this conversation as resolved.
Show resolved Hide resolved
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');
}

skinny85 marked this conversation as resolved.
Show resolved Hide resolved
// 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');
}
skinny85 marked this conversation as resolved.
Show resolved Hide resolved
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