diff --git a/packages/@aws-cdk/core/lib/cloudformation/arn.ts b/packages/@aws-cdk/core/lib/cloudformation/arn.ts index 0f8687813efb4..6039b4fea9352 100644 --- a/packages/@aws-cdk/core/lib/cloudformation/arn.ts +++ b/packages/@aws-cdk/core/lib/cloudformation/arn.ts @@ -1,4 +1,6 @@ import { AwsAccountId, AwsPartition, AwsRegion, FnConcat, Token } from '..'; +import { FnSplit } from '../../../iam/node_modules/@aws-cdk/core/lib/cloudformation/fn'; +import { FnSelect } from '../../../iam/node_modules/@aws-cdk/core/lib/cloudformation/fn'; /** * An Amazon Resource Name (ARN). @@ -122,6 +124,64 @@ export class Arn extends Token { return result; } + + /** + * Given a Token evaluating to ARN, parses it and returns components. + * + * The ARN cannot be validated, since we don't have the actual value yet + * at the time of this function call. You will have to know the separator + * and the type of ARN. + * + * The resulting `ArnComponents` object will contain tokens for the + * subexpressions of the ARN, not string literals. + * + * WARNING: this function cannot properly parse the complete final + * resourceName (path) out of ARNs that use '/' to both separate the + * 'resource' from the 'resourceName' AND to subdivide the resourceName + * further. For example, in S3 ARNs: + * + * arn:aws:s3:::my_corporate_bucket/path/to/exampleobject.png + * + * After parsing the resourceName will not contain 'path/to/exampleobject.png' + * but simply 'path'. This is a limitation because there is no slicing + * functionality in CloudFormation templates. + * + * @param arn The input token that contains an ARN + * @param sep The separator used to separate resource from resourceName + * @param hasName Whether there is a name component in the ARN at all. + * @returns an ArnComponents object which allows access to the various + * components of the ARN. + */ + public static parseToken(arn: Token, sep: string = '/', hasName: boolean = true): ArnComponents { + // Arn ARN looks like: + // arn:partition:service:region:account-id:resource + // arn:partition:service:region:account-id:resourcetype/resource + // arn:partition:service:region:account-id:resourcetype:resource + + // We need the 'hasName' argument because {Fn::Select}ing a nonexistent field + // throws an error. + + const components = new FnSplit(':', arn); + + const partition = new FnSelect(1, components); + const service = new FnSelect(2, components); + const region = new FnSelect(3, components); + const account = new FnSelect(4, components); + + if (sep === ':') { + const resource = new FnSelect(5, components); + const resourceName = hasName ? new FnSelect(6, components) : undefined; + + return { partition, service, region, account, resource, resourceName, sep }; + } else { + const lastComponents = new FnSplit(sep, new FnSelect(5, components)); + + const resource = new FnSelect(0, lastComponents); + const resourceName = hasName ? new FnSelect(1, lastComponents) : undefined; + + return { partition, service, region, account, resource, resourceName, sep }; + } + } } export interface ArnComponents { @@ -133,13 +193,13 @@ export interface ArnComponents { * * @default The AWS partition the stack is deployed to. */ - partition?: string; + partition?: any; /** * The service namespace that identifies the AWS product (for example, * 's3', 'iam', 'codepipline'). */ - service: string; + service: any; /** * The region the resource resides in. Note that the ARNs for some resources @@ -147,7 +207,7 @@ export interface ArnComponents { * * @default The region the stack is deployed to. */ - region?: string; + region?: any; /** * The ID of the AWS account that owns the resource, without the hyphens. diff --git a/packages/@aws-cdk/core/test/cloudformation/test.arn.ts b/packages/@aws-cdk/core/test/cloudformation/test.arn.ts index 78dbd05f3def7..965cd0dbdb7ca 100644 --- a/packages/@aws-cdk/core/test/cloudformation/test.arn.ts +++ b/packages/@aws-cdk/core/test/cloudformation/test.arn.ts @@ -1,5 +1,5 @@ import { Test } from 'nodeunit'; -import { Arn, ArnComponents, resolve } from '../../lib'; +import { Arn, ArnComponents, resolve, Token } from '../../lib'; export = { 'create from components with defaults'(test: Test) { @@ -187,7 +187,36 @@ export = { }); test.done(); - } + }, + + 'a Token with : separator'(test: Test) { + const theToken = { Ref: 'SomeParameter' }; + const parsed = Arn.parseToken(new Token(() => theToken), ':'); + + test.deepEqual(resolve(parsed.partition), { 'Fn::Select': [ 1, { 'Fn::Split': [ ':', theToken ]} ]}); + test.deepEqual(resolve(parsed.service), { 'Fn::Select': [ 2, { 'Fn::Split': [ ':', theToken ]} ]}); + test.deepEqual(resolve(parsed.region), { 'Fn::Select': [ 3, { 'Fn::Split': [ ':', theToken ]} ]}); + test.deepEqual(resolve(parsed.account), { 'Fn::Select': [ 4, { 'Fn::Split': [ ':', theToken ]} ]}); + test.deepEqual(resolve(parsed.resource), { 'Fn::Select': [ 5, { 'Fn::Split': [ ':', theToken ]} ]}); + test.deepEqual(resolve(parsed.resourceName), { 'Fn::Select': [ 6, { 'Fn::Split': [ ':', theToken ]} ]}); + test.equal(parsed.sep, ':'); + + test.done(); + }, + 'a Token with / separator'(test: Test) { + const theToken = { Ref: 'SomeParameter' }; + const parsed = Arn.parseToken(new Token(() => theToken)); + + test.equal(parsed.sep, '/'); + + // tslint:disable-next-line:max-line-length + test.deepEqual(resolve(parsed.resource), { 'Fn::Select': [ 0, { 'Fn::Split': [ '/', { 'Fn::Select': [ 5, { 'Fn::Split': [ ':', theToken ]} ]} ]} ]}); + // tslint:disable-next-line:max-line-length + test.deepEqual(resolve(parsed.resourceName), { 'Fn::Select': [ 1, { 'Fn::Split': [ '/', { 'Fn::Select': [ 5, { 'Fn::Split': [ ':', theToken ]} ]} ]} ]}); + + test.done(); + } }, -}; + +}; \ No newline at end of file diff --git a/packages/@aws-cdk/kinesis/lib/stream.ts b/packages/@aws-cdk/kinesis/lib/stream.ts index ceb755c20a27a..3d0629743e05f 100644 --- a/packages/@aws-cdk/kinesis/lib/stream.ts +++ b/packages/@aws-cdk/kinesis/lib/stream.ts @@ -1,4 +1,5 @@ -import { Construct, FnConcat, FnSelect, FnSplit, FnSub, Output, PolicyStatement, ServicePrincipal, Stack, Token } from '@aws-cdk/core'; +import { Arn, AwsRegion, Construct, FnConcat, HashedAddressingScheme, Output, + PolicyStatement, ServicePrincipal, Stack, Token } from '@aws-cdk/core'; import { IIdentityResource, Role } from '@aws-cdk/iam'; import * as kms from '@aws-cdk/kms'; import logs = require('@aws-cdk/logs'); @@ -38,7 +39,7 @@ export interface StreamRefProps { * StreamRef.import(this, 'MyImportedStream', ref); * */ -export abstract class StreamRef extends Construct implements logs.ISubscriptionDestination { +export abstract class StreamRef extends Construct implements logs.ILogSubscriptionDestination { /** * Creates a Stream construct that represents an external stream. * @@ -170,12 +171,12 @@ export abstract class StreamRef extends Construct implements logs.ISubscriptionD ); } - public subscriptionDestination(sourceLogGroup: logs.LogGroup): logs.SubscriptionDestination { + public logSubscriptionDestination(sourceLogGroup: logs.LogGroup): logs.LogSubscriptionDestination { // Following example from https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/SubscriptionFilters.html#DestinationKinesisExample if (!this.cloudWatchLogsRole) { // Create a role to be assumed by CWL that can write to this stream and pass itself. this.cloudWatchLogsRole = new Role(this, 'CloudWatchLogsCanPutRecords', { - assumedBy: new ServicePrincipal(new FnSub('logs.${AWS::Region}.amazonaws.com')), + assumedBy: new ServicePrincipal(new FnConcat('logs.', new AwsRegion(), '.amazonaws.com')), }); this.cloudWatchLogsRole.addToPolicy(new PolicyStatement().addAction('kinesis:PutRecord').addResource(this.streamArn)); this.cloudWatchLogsRole.addToPolicy(new PolicyStatement().addAction('iam:PassRole').addResource(this.cloudWatchLogsRole.roleArn)); @@ -194,19 +195,35 @@ export abstract class StreamRef extends Construct implements logs.ISubscriptionD return { arn: this.streamArn, role: this.cloudWatchLogsRole }; } + if (!sourceStack.env.account || !thisStack.env.account) { + throw new Error('SubscriptionFilter stack and Destination stack must either both have accounts defined, or both not have accounts'); + } + + return this.crossAccountLogSubscriptionDestination(sourceLogGroup); + } + + /** + * Generate a CloudWatch Logs Destination and return the properties in the form o a subscription destination + */ + private crossAccountLogSubscriptionDestination(sourceLogGroup: logs.LogGroup): logs.LogSubscriptionDestination { + const sourceStack = Stack.find(sourceLogGroup); + + // Take some effort to construct a unique ID for the destination that is unique to the + // combination of (stream, loggroup). + const uniqueId = new HashedAddressingScheme().allocateAddress([sourceLogGroup.path.replace('/', ''), sourceStack.env.account!]); + // The destination lives in the target account - const dest = new logs.CrossAccountDestination(this, 'CloudWatchCrossAccountDestination', { - // Unfortunately destinationName is required so we have to invent one that won't conflict. - destinationName: new FnConcat(sourceLogGroup.logGroupName, 'To', this.streamName) as any, + const dest = new logs.CrossAccountDestination(this, `CWLDestination${uniqueId}`, { targetArn: this.streamArn, - role: this.cloudWatchLogsRole + role: this.cloudWatchLogsRole! }); + dest.addToPolicy(new PolicyStatement() .addAction('logs:PutSubscriptionFilter') .addAwsAccountPrincipal(sourceStack.env.account) .addAllResources()); - return dest.subscriptionDestination(sourceLogGroup); + return dest.logSubscriptionDestination(sourceLogGroup); } private grant(identity: IIdentityResource, actions: { streamActions: string[], keyActions: string[] }) { @@ -364,9 +381,8 @@ class ImportedStreamRef extends StreamRef { super(parent, name); this.streamArn = props.streamArn; - // ARN always looks like: arn:aws:kinesis:us-east-2:123456789012:stream/mystream - // so we can get the name from the ARN. - this.streamName = new FnSelect(1, new FnSplit('/', this.streamArn)); + // Get the name from the ARN + this.streamName = Arn.parseToken(props.streamArn).resourceName; if (props.encryptionKey) { this.encryptionKey = kms.EncryptionKeyRef.import(parent, 'Key', props.encryptionKey); diff --git a/packages/@aws-cdk/kinesis/test/test.subscriptiondestination.ts b/packages/@aws-cdk/kinesis/test/test.subscriptiondestination.ts index 4aeb8721fd45b..28a13131af574 100644 --- a/packages/@aws-cdk/kinesis/test/test.subscriptiondestination.ts +++ b/packages/@aws-cdk/kinesis/test/test.subscriptiondestination.ts @@ -29,7 +29,7 @@ export = { AssumeRolePolicyDocument: { Statement: [{ Action: "sts:AssumeRole", - Principal: { Service: { "Fn::Sub": "logs.${AWS::Region}.amazonaws.com" }} + Principal: { Service: { "Fn::Join": ["", ["logs.", {Ref: "AWS::Region"}, ".amazonaws.com"]] }} }], } })); diff --git a/packages/@aws-cdk/lambda/lib/lambda-ref.ts b/packages/@aws-cdk/lambda/lib/lambda-ref.ts index 6ddff1887c013..7224273fa1135 100644 --- a/packages/@aws-cdk/lambda/lib/lambda-ref.ts +++ b/packages/@aws-cdk/lambda/lib/lambda-ref.ts @@ -1,5 +1,5 @@ import { Metric, MetricCustomization } from '@aws-cdk/cloudwatch'; -import { AccountPrincipal, Arn, Construct, FnSelect, FnSplit, FnSub, +import { AccountPrincipal, Arn, AwsRegion, Construct, FnConcat, FnSelect, FnSplit, PolicyPrincipal, PolicyStatement, resolve, ServicePrincipal, Token } from '@aws-cdk/core'; import { EventRuleTarget, IEventRuleTarget } from '@aws-cdk/events'; import { Role } from '@aws-cdk/iam'; @@ -24,7 +24,7 @@ export interface LambdaRefProps { role?: Role; } -export abstract class LambdaRef extends Construct implements IEventRuleTarget, logs.ISubscriptionDestination { +export abstract class LambdaRef extends Construct implements IEventRuleTarget, logs.ILogSubscriptionDestination { /** * Creates a Lambda function object which represents a function not defined * within this stack. @@ -110,12 +110,12 @@ export abstract class LambdaRef extends Construct implements IEventRuleTarget, l /** * Indicates if the resource policy that allows CloudWatch events to publish - * notifications to this topic have been added. + * notifications to this lambda have been added. */ private eventRuleTargetPolicyAdded = false; /** - * Indicates if the policy that allows CloudWatch logs to publish to this topic has been added. + * Indicates if the policy that allows CloudWatch logs to publish to this lambda has been added. */ private logSubscriptionDestinationPolicyAddedFor: logs.LogGroupArn[] = []; @@ -218,7 +218,7 @@ export abstract class LambdaRef extends Construct implements IEventRuleTarget, l return this.metric('Throttles', { statistic: 'sum', ...props }); } - public subscriptionDestination(sourceLogGroup: logs.LogGroup): logs.SubscriptionDestination { + public logSubscriptionDestination(sourceLogGroup: logs.LogGroup): logs.LogSubscriptionDestination { const arn = sourceLogGroup.logGroupArn; if (this.logSubscriptionDestinationPolicyAddedFor.indexOf(arn) === -1) { @@ -227,7 +227,7 @@ export abstract class LambdaRef extends Construct implements IEventRuleTarget, l // // (Wildcards in principals are unfortunately not supported. this.addPermission('InvokedByCloudWatchLogs', { - principal: new ServicePrincipal(new FnSub('logs.${AWS::Region}.amazonaws.com')), + principal: new ServicePrincipal(new FnConcat('logs.', new AwsRegion(), '.amazonaws.com')), sourceArn: arn }); this.logSubscriptionDestinationPolicyAddedFor.push(arn); diff --git a/packages/@aws-cdk/lambda/test/test.subscriptiondestination.ts b/packages/@aws-cdk/lambda/test/test.subscriptiondestination.ts index ea59c99eed455..d986d97722163 100644 --- a/packages/@aws-cdk/lambda/test/test.subscriptiondestination.ts +++ b/packages/@aws-cdk/lambda/test/test.subscriptiondestination.ts @@ -31,7 +31,7 @@ export = { expect(stack).to(haveResource('AWS::Lambda::Permission', { Action: "lambda:InvokeFunction", FunctionName: { Ref: "MyLambdaCCE802FB" }, - Principal: { "Fn::Sub": "logs.${AWS::Region}.amazonaws.com" } + Principal: { "Fn::Join": ["", ["logs.", {Ref: "AWS::Region"}, ".amazonaws.com"]] } })); test.done(); diff --git a/packages/@aws-cdk/logs/README.md b/packages/@aws-cdk/logs/README.md index 585486e1ebabe..65d69e6dd20d2 100644 --- a/packages/@aws-cdk/logs/README.md +++ b/packages/@aws-cdk/logs/README.md @@ -27,11 +27,10 @@ any amount of days, or `Infinity` to keep the data in the log group forever. Log events matching a particular filter can be sent to either a Lambda function or a Kinesis stream. -* If the Kinesis stream lives in a different account, you have to also create a - `Destination` object in the current account which will act as a proxy for the - remote Kinesis stream. -* If the filter destination is either a Lambda or a Kinesis stream in the - current account, they can be subscribed directly. +If the Kinesis stream lives in a different account, a `CrossAccountDestination` +object needs to be added in the destination account which will act as a proxy +for the remote Kinesis stream. This object is automatically created for you +if you use the CDK Kinesis library. Create a `SubscriptionFilter`, initialize it with an appropriate `Pattern` (see below) and supply the intended destination: diff --git a/packages/@aws-cdk/logs/lib/cross-account-destination.ts b/packages/@aws-cdk/logs/lib/cross-account-destination.ts index bb6dba6c5c88d..c375b305d7c78 100644 --- a/packages/@aws-cdk/logs/lib/cross-account-destination.ts +++ b/packages/@aws-cdk/logs/lib/cross-account-destination.ts @@ -2,13 +2,15 @@ import cdk = require('@aws-cdk/core'); import iam = require('@aws-cdk/iam'); import { LogGroup } from './log-group'; import { cloudformation, DestinationArn } from './logs.generated'; -import { ISubscriptionDestination, SubscriptionDestination } from './subscription-filter'; +import { ILogSubscriptionDestination, LogSubscriptionDestination } from './subscription-filter'; -export interface DestinationProps { +export interface CrossAccountDestinationProps { /** * The name of the log destination. + * + * @default Automatically generated */ - destinationName: string; + destinationName?: string; /** * The role to assume that grants permissions to write to 'target'. @@ -24,7 +26,7 @@ export interface DestinationProps { } /** - * Create a new CloudWatch Logs Destination. + * A new CloudWatch Logs Destination for use in cross-account scenarios * * Log destinations can be used to subscribe a Kinesis stream in a different * account to a CloudWatch Subscription. A Kinesis stream in the same account @@ -33,34 +35,62 @@ export interface DestinationProps { * The @aws-cdk/kinesis library takes care of this automatically; you shouldn't * need to bother with this class. */ -export class CrossAccountDestination extends cdk.Construct implements ISubscriptionDestination { +export class CrossAccountDestination extends cdk.Construct implements ILogSubscriptionDestination { + /** + * Policy object of this CrossAccountDestination object + */ public readonly policyDocument: cdk.PolicyDocument = new cdk.PolicyDocument(); + + /** + * The name of this CrossAccountDestination object + */ public readonly destinationName: DestinationName; + + /** + * The ARN of this CrossAccountDestination object + */ public readonly destinationArn: DestinationArn; - constructor(parent: cdk.Construct, id: string, props: DestinationProps) { + /** + * The inner resource + */ + private readonly resource: cloudformation.DestinationResource; + + constructor(parent: cdk.Construct, id: string, props: CrossAccountDestinationProps) { super(parent, id); this.policyDocument = new cdk.PolicyDocument(); - const resource = new cloudformation.DestinationResource(this, 'Resource', { - destinationName: props.destinationName, + // In the underlying model, the name is not optional, but we make it so anyway. + const destinationName = props.destinationName || new cdk.Token(() => this.generateUniqueName()); + + this.resource = new cloudformation.DestinationResource(this, 'Resource', { + destinationName, destinationPolicy: new cdk.Token(() => !this.policyDocument.isEmpty ? JSON.stringify(this.policyDocument.resolve()) : ""), roleArn: props.role.roleArn, targetArn: props.targetArn }); - this.destinationArn = resource.destinationArn; - this.destinationName = resource.ref; + this.destinationArn = this.resource.destinationArn; + this.destinationName = this.resource.ref; } public addToPolicy(statement: cdk.PolicyStatement) { this.policyDocument.addStatement(statement); } - public subscriptionDestination(_sourceLogGroup: LogGroup): SubscriptionDestination { + public logSubscriptionDestination(_sourceLogGroup: LogGroup): LogSubscriptionDestination { return { arn: this.destinationArn }; } + + /** + * Generate a unique Destination name in case the user didn't supply one + */ + private generateUniqueName(): string { + // Combination of stack name and LogicalID, which are guaranteed to be unique. + const stack = cdk.Stack.find(this); + return stack.name + '-' + this.resource.logicalId; + } } /** diff --git a/packages/@aws-cdk/logs/lib/log-group.ts b/packages/@aws-cdk/logs/lib/log-group.ts index c466cd1cdfbff..4bb9360fbee6c 100644 --- a/packages/@aws-cdk/logs/lib/log-group.ts +++ b/packages/@aws-cdk/logs/lib/log-group.ts @@ -3,7 +3,7 @@ import { LogStream } from './log-stream'; import { cloudformation, LogGroupArn } from './logs.generated'; import { MetricFilter } from './metric-filter'; import { IFilterPattern } from './pattern'; -import { ISubscriptionDestination, SubscriptionFilter } from './subscription-filter'; +import { ILogSubscriptionDestination, SubscriptionFilter } from './subscription-filter'; /** * Properties for a new LogGroup @@ -27,7 +27,7 @@ export interface LogGroupProps { } /** - * Create a new CloudWatch Log Group + * A new CloudWatch Log Group */ export class LogGroup extends cdk.Construct { /** @@ -132,7 +132,7 @@ export interface NewSubscriptionFilterProps { * * For example, a Kinesis stream or a Lambda function. */ - destination: ISubscriptionDestination; + destination: ILogSubscriptionDestination; /** * Log events matching this pattern will be sent to the destination. diff --git a/packages/@aws-cdk/logs/lib/log-stream.ts b/packages/@aws-cdk/logs/lib/log-stream.ts index 3f2a33fc261fb..9c7c361b450eb 100644 --- a/packages/@aws-cdk/logs/lib/log-stream.ts +++ b/packages/@aws-cdk/logs/lib/log-stream.ts @@ -21,6 +21,9 @@ export interface LogStreamProps { logStreamName?: string; } +/** + * A new Log Stream in a Log Group + */ export class LogStream extends cdk.Construct { /** * The name of this log stream diff --git a/packages/@aws-cdk/logs/lib/subscription-filter.ts b/packages/@aws-cdk/logs/lib/subscription-filter.ts index 2f34b39f2f754..c1f63119d8a1d 100644 --- a/packages/@aws-cdk/logs/lib/subscription-filter.ts +++ b/packages/@aws-cdk/logs/lib/subscription-filter.ts @@ -7,7 +7,7 @@ import { IFilterPattern } from './pattern'; /** * Interface for classes that can be the destination of a log Subscription */ -export interface ISubscriptionDestination { +export interface ILogSubscriptionDestination { /** * Return the properties required to send subscription events to this destination. * @@ -18,13 +18,13 @@ export interface ISubscriptionDestination { * The destination may reconfigure its own permissions in response to this * function call. */ - subscriptionDestination(sourceLogGroup: LogGroup): SubscriptionDestination; + logSubscriptionDestination(sourceLogGroup: LogGroup): LogSubscriptionDestination; } /** * Properties returned by a Subscription destination */ -export interface SubscriptionDestination { +export interface LogSubscriptionDestination { /** * The ARN of the subscription's destination */ @@ -52,7 +52,7 @@ export interface SubscriptionFilterProps { * * For example, a Kinesis stream or a Lambda function. */ - destination: ISubscriptionDestination; + destination: ILogSubscriptionDestination; /** * Log events matching this pattern will be sent to the destination. @@ -67,7 +67,7 @@ export class SubscriptionFilter extends cdk.Construct { constructor(parent: cdk.Construct, id: string, props: SubscriptionFilterProps) { super(parent, id); - const destProps = props.destination.subscriptionDestination(props.logGroup); + const destProps = props.destination.logSubscriptionDestination(props.logGroup); new cloudformation.SubscriptionFilterResource(this, 'Resource', { logGroupName: props.logGroup.logGroupName, diff --git a/packages/@aws-cdk/logs/test/test.subscriptionfilter.ts b/packages/@aws-cdk/logs/test/test.subscriptionfilter.ts index 2fa2394e1dd60..9ad32a90f0439 100644 --- a/packages/@aws-cdk/logs/test/test.subscriptionfilter.ts +++ b/packages/@aws-cdk/logs/test/test.subscriptionfilter.ts @@ -1,7 +1,7 @@ import { expect, haveResource } from '@aws-cdk/assert'; import { Arn, Stack } from '@aws-cdk/core'; import { Test } from 'nodeunit'; -import { FilterPattern, ISubscriptionDestination, LogGroup, SubscriptionFilter } from '../lib'; +import { FilterPattern, ILogSubscriptionDestination, LogGroup, SubscriptionFilter } from '../lib'; export = { 'trivial instantiation'(test: Test) { @@ -27,8 +27,8 @@ export = { }, }; -class FakeDestination implements ISubscriptionDestination { - public subscriptionDestination(_sourceLogGroup: LogGroup) { +class FakeDestination implements ILogSubscriptionDestination { + public logSubscriptionDestination(_sourceLogGroup: LogGroup) { return { arn: new Arn('arn:bogus'), };