diff --git a/packages/@aws-cdk/aws-events-targets/lib/codebuild.ts b/packages/@aws-cdk/aws-events-targets/lib/codebuild.ts index 46ffab1dab155..2742e5039b7a7 100644 --- a/packages/@aws-cdk/aws-events-targets/lib/codebuild.ts +++ b/packages/@aws-cdk/aws-events-targets/lib/codebuild.ts @@ -21,6 +21,7 @@ export class CodeBuildProject implements events.IRuleTarget { actions: ['codebuild:StartBuild'], resources: [this.project.projectArn], })]), + targetResource: this.project, }; } } diff --git a/packages/@aws-cdk/aws-events-targets/lib/codepipeline.ts b/packages/@aws-cdk/aws-events-targets/lib/codepipeline.ts index ba4549261de8b..d5e6204c57020 100644 --- a/packages/@aws-cdk/aws-events-targets/lib/codepipeline.ts +++ b/packages/@aws-cdk/aws-events-targets/lib/codepipeline.ts @@ -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, }; } } diff --git a/packages/@aws-cdk/aws-events-targets/lib/ecs-task.ts b/packages/@aws-cdk/aws-events-targets/lib/ecs-task.ts index c5524e2bf49a8..67aff8edd376b 100644 --- a/packages/@aws-cdk/aws-events-targets/lib/ecs-task.ts +++ b/packages/@aws-cdk/aws-events-targets/lib/ecs-task.ts @@ -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, }; } } diff --git a/packages/@aws-cdk/aws-events-targets/lib/lambda.ts b/packages/@aws-cdk/aws-events-targets/lib/lambda.ts index 9b86107a64394..6081f4f9492d6 100644 --- a/packages/@aws-cdk/aws-events-targets/lib/lambda.ts +++ b/packages/@aws-cdk/aws-events-targets/lib/lambda.ts @@ -42,6 +42,7 @@ export class LambdaFunction implements events.IRuleTarget { id: '', arn: this.handler.functionArn, input: this.props.event, + targetResource: this.handler, }; } } diff --git a/packages/@aws-cdk/aws-events-targets/lib/sns.ts b/packages/@aws-cdk/aws-events-targets/lib/sns.ts index cff76c9f44334..80d4fd9d55619 100644 --- a/packages/@aws-cdk/aws-events-targets/lib/sns.ts +++ b/packages/@aws-cdk/aws-events-targets/lib/sns.ts @@ -42,6 +42,7 @@ export class SnsTopic implements events.IRuleTarget { id: '', arn: this.topic.topicArn, input: this.props.message, + targetResource: this.topic, }; } } diff --git a/packages/@aws-cdk/aws-events-targets/lib/sqs.ts b/packages/@aws-cdk/aws-events-targets/lib/sqs.ts index e6ebd28d1737d..42f39f9f06cce 100644 --- a/packages/@aws-cdk/aws-events-targets/lib/sqs.ts +++ b/packages/@aws-cdk/aws-events-targets/lib/sqs.ts @@ -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 } }); diff --git a/packages/@aws-cdk/aws-events-targets/lib/state-machine.ts b/packages/@aws-cdk/aws-events-targets/lib/state-machine.ts index 666f1340b0e49..d46ea488e65b5 100644 --- a/packages/@aws-cdk/aws-events-targets/lib/state-machine.ts +++ b/packages/@aws-cdk/aws-events-targets/lib/state-machine.ts @@ -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, }; } } diff --git a/packages/@aws-cdk/aws-events/README.md b/packages/@aws-cdk/aws-events/README.md index 3280d46c5336a..6675f9647bdf5 100644 --- a/packages/@aws-cdk/aws-events/README.md +++ b/packages/@aws-cdk/aws-events/README.md @@ -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). diff --git a/packages/@aws-cdk/aws-events/lib/rule.ts b/packages/@aws-cdk/aws-events/lib/rule.ts index cf6cac697e309..032d01ed78072 100644 --- a/packages/@aws-cdk/aws-events/lib/rule.ts +++ b/packages/@aws-cdk/aws-events/lib/rule.ts @@ -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'; @@ -88,16 +88,19 @@ export class Rule extends Resource implements IRule { private readonly targets = new Array(); 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() }), @@ -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, diff --git a/packages/@aws-cdk/aws-events/lib/target.ts b/packages/@aws-cdk/aws-events/lib/target.ts index 678fc3a8fb341..c4f7cfb89f2c7 100644 --- a/packages/@aws-cdk/aws-events/lib/target.ts +++ b/packages/@aws-cdk/aws-events/lib/target.ts @@ -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'; @@ -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; } diff --git a/packages/@aws-cdk/aws-events/test/test.rule.ts b/packages/@aws-cdk/aws-events/test/test.rule.ts index 0875d65120c0c..28eb212f31c38 100644 --- a/packages/@aws-cdk/aws-events/test/test.rule.ts +++ b/packages/@aws-cdk/aws-events/test/test.rule.ts @@ -4,7 +4,7 @@ import { ServicePrincipal } from '@aws-cdk/aws-iam'; import cdk = require('@aws-cdk/core'); import { Stack } from '@aws-cdk/core'; import { Test } from 'nodeunit'; -import { EventField, IRule, IRuleTarget, RuleTargetInput, Schedule } from '../lib'; +import { EventField, IRule, IRuleTarget, RuleTargetConfig, RuleTargetInput, Schedule } from '../lib'; import { Rule } from '../lib/rule'; // tslint:disable:object-literal-key-quotes @@ -422,14 +422,234 @@ export = { })); test.done(); - } + }, + + 'rule and target must be in the same region'(test: Test) { + const app = new cdk.App(); + + const sourceStack = new cdk.Stack(app, 'SourceStack'); + const rule = new Rule(sourceStack, 'Rule'); + + const targetStack = new cdk.Stack(app, 'TargetStack', { env: { region: 'us-west-2' } }); + const resource = new cdk.Construct(targetStack, 'Resource'); + + test.throws(() => { + rule.addTarget(new SomeTarget('T', resource)); + }, /Rule and target must be in the same region/); + + test.done(); + }, + + 'for cross-account targets': { + 'requires that the source stack specify a concrete account'(test: Test) { + const app = new cdk.App(); + + const sourceStack = new cdk.Stack(app, 'SourceStack'); + const rule = new Rule(sourceStack, 'Rule'); + + const targetAccount = '234567890123'; + const targetStack = new cdk.Stack(app, 'TargetStack', { env: { account: targetAccount } }); + const resource = new cdk.Construct(targetStack, 'Resource'); + + test.throws(() => { + rule.addTarget(new SomeTarget('T', resource)); + }, /You need to provide a concrete account for the source stack when using cross-account events/); + + test.done(); + }, + + 'requires that the target stack specify a concrete account'(test: Test) { + const app = new cdk.App(); + + const sourceAccount = '123456789012'; + const sourceStack = new cdk.Stack(app, 'SourceStack', { env: { account: sourceAccount } }); + const rule = new Rule(sourceStack, 'Rule'); + + const targetStack = new cdk.Stack(app, 'TargetStack'); + const resource = new cdk.Construct(targetStack, 'Resource'); + + test.throws(() => { + rule.addTarget(new SomeTarget('T', resource)); + }, /You need to provide a concrete account for the target stack when using cross-account events/); + + test.done(); + }, + + 'requires that the target stack specify a concrete region'(test: Test) { + const app = new cdk.App(); + + const sourceAccount = '123456789012'; + const sourceStack = new cdk.Stack(app, 'SourceStack', { env: { account: sourceAccount } }); + const rule = new Rule(sourceStack, 'Rule'); + + const targetAccount = '234567890123'; + const targetStack = new cdk.Stack(app, 'TargetStack', { env: { account: targetAccount } }); + const resource = new cdk.Construct(targetStack, 'Resource'); + + test.throws(() => { + rule.addTarget(new SomeTarget('T', resource)); + }, /You need to provide a concrete region for the target stack when using cross-account events/); + + test.done(); + }, + + 'requires that the source stack be part of an App'(test: Test) { + const app = new cdk.App(); + + const sourceAccount = '123456789012'; + const sourceStack = new cdk.Stack(undefined, 'SourceStack', { env: { account: sourceAccount, region: 'us-west-2' } }); + const rule = new Rule(sourceStack, 'Rule'); + + const targetAccount = '234567890123'; + const targetStack = new cdk.Stack(app, 'TargetStack', { env: { account: targetAccount, region: 'us-west-2' } }); + const resource = new cdk.Construct(targetStack, 'Resource'); + + test.throws(() => { + rule.addTarget(new SomeTarget('T', resource)); + }, /Event stack which uses cross-account targets must be part of a CDK app/); + + test.done(); + }, + + 'requires that the target stack be part of an App'(test: Test) { + const app = new cdk.App(); + + const sourceAccount = '123456789012'; + const sourceStack = new cdk.Stack(app, 'SourceStack', { env: { account: sourceAccount, region: 'us-west-2' } }); + const rule = new Rule(sourceStack, 'Rule'); + + const targetAccount = '234567890123'; + const targetStack = new cdk.Stack(undefined, 'TargetStack', { env: { account: targetAccount, region: 'us-west-2' } }); + const resource = new cdk.Construct(targetStack, 'Resource'); + + test.throws(() => { + rule.addTarget(new SomeTarget('T', resource)); + }, /Target stack which uses cross-account event targets must be part of a CDK app/); + + test.done(); + }, + + 'requires that the source and target stacks be part of the same App'(test: Test) { + const sourceApp = new cdk.App(); + const sourceAccount = '123456789012'; + const sourceStack = new cdk.Stack(sourceApp, 'SourceStack', { env: { account: sourceAccount, region: 'us-west-2' } }); + const rule = new Rule(sourceStack, 'Rule'); + + const targetApp = new cdk.App(); + const targetAccount = '234567890123'; + const targetStack = new cdk.Stack(targetApp, 'TargetStack', { env: { account: targetAccount, region: 'us-west-2' } }); + const resource = new cdk.Construct(targetStack, 'Resource'); + + test.throws(() => { + rule.addTarget(new SomeTarget('T', resource)); + }, /Event stack and target stack must belong to the same CDK app/); + + test.done(); + }, + + 'generates an event bus target in the source rule, and a separate rule with an identical target in the target stack'(test: Test) { + const app = new cdk.App(); + + const sourceAccount = '123456789012'; + const sourceStack = new cdk.Stack(app, 'SourceStack', { + env: { + account: sourceAccount, + region: 'us-west-2', + }, + }); + const rule = new Rule(sourceStack, 'Rule', { + eventPattern: { + source: ['some-event'], + }, + }); + + const targetAccount = '234567890123'; + const targetStack = new cdk.Stack(app, 'TargetStack', { + env: { + account: targetAccount, + region: 'us-west-2', + }, + }); + const resource1 = new cdk.Construct(targetStack, 'Resource1'); + const resource2 = new cdk.Construct(targetStack, 'Resource2'); + + rule.addTarget(new SomeTarget('T1', resource1)); + rule.addTarget(new SomeTarget('T2', resource2)); + + expect(sourceStack).to(haveResourceLike('AWS::Events::Rule', { + "EventPattern": { + "source": [ + "some-event", + ], + }, + "State": "ENABLED", + "Targets": [ + { + "Id": "T1", + "Arn": { + "Fn::Join": [ + "", + [ + "arn:", + { "Ref": "AWS::Partition" }, + `:events:us-west-2:${targetAccount}:event-bus/default`, + ], + ], + }, + }, + ], + })); + + expect(targetStack).to(haveResourceLike('AWS::Events::Rule', { + "EventPattern": { + "source": [ + "some-event", + ], + }, + "State": "ENABLED", + "Targets": [ + { + "Id": "T1", + "Arn": "ARN1", + }, + ], + })); + expect(targetStack).to(haveResourceLike('AWS::Events::Rule', { + "EventPattern": { + "source": [ + "some-event", + ], + }, + "State": "ENABLED", + "Targets": [ + { + "Id": "T2", + "Arn": "ARN1", + }, + ], + })); + + const eventBusPolicyStack = app.node.findChild(`EventBusPolicy-${sourceAccount}-us-west-2-${targetAccount}`) as cdk.Stack; + expect(eventBusPolicyStack).to(haveResourceLike('AWS::Events::EventBusPolicy', { + "Action": "events:PutEvents", + "StatementId": "MySid", + "Principal": sourceAccount, + })); + + test.done(); + }, + }, }; class SomeTarget implements IRuleTarget { - public bind() { + public constructor(private readonly id?: string, private readonly resource?: cdk.IConstruct) { + } + + public bind(): RuleTargetConfig { return { - id: '', - arn: 'ARN1', kinesisParameters: { partitionKeyPath: 'partitionKeyPath' } + id: this.id || '', + arn: 'ARN1', kinesisParameters: { partitionKeyPath: 'partitionKeyPath' }, + targetResource: this.resource, }; } }