From 6daaca8a919606d3b8f392c1f4ad53ab72c6f765 Mon Sep 17 00:00:00 2001 From: Adam Ruka Date: Mon, 10 Jun 2019 18:54:55 -0700 Subject: [PATCH] feat: formalize the concept of physical names, and use them for cross-environment CodePipelines. (#1924) Introduces a new type, `PhysicalName`, that represents the physical ID of resources, and adds it to `IResource`. Adds a `ResourceIdentifiers` class that is supposed to be used inside the Construct Library. Introduces a concept of a late-bound name, that is automatically generated by the framework if a given resource is used in a cross-environment fashion. Implements it in IAM's Role, CodeBuild's Project, and S3's Bucket. Adds logic to the CodePipeline construct that automatically generates a Role with a physical name if an Action backed by a resource from a different account is added to it. BREAKING CHANGE: * iam: `roleName` in `RoleProps` is now of type `PhysicalName` * s3: `bucketName` in `BucketProps` is now of type `PhysicalName` * codebuild: `roleName` in `RoleProps` is now of type `PhysicalName` --- .../lib/dns-validated-certificate.ts | 4 +- .../@aws-cdk/aws-codebuild/lib/project.ts | 24 +- .../lib/lambda/deployment-group.ts | 2 +- .../lib/codebuild/build-action.ts | 1 + .../cloudformation/test.pipeline-actions.ts | 2 +- ...ipeline-cfn-wtih-action-role.expected.json | 194 +++++----- .../integ.pipeline-cfn-wtih-action-role.ts | 6 +- .../test/test.pipeline.ts | 152 +++++++- .../@aws-cdk/aws-codepipeline/lib/action.ts | 71 ++-- .../lib/cross-region-scaffold-stack.ts | 2 +- .../@aws-cdk/aws-codepipeline/lib/pipeline.ts | 76 +++- .../@aws-cdk/aws-cognito/lib/user-pool.ts | 2 +- packages/@aws-cdk/aws-ecs/lib/cluster.ts | 2 +- .../lib/alb/application-load-balancer.ts | 4 +- packages/@aws-cdk/aws-glue/lib/database.ts | 2 +- packages/@aws-cdk/aws-glue/lib/table.ts | 2 +- packages/@aws-cdk/aws-iam/lib/lazy-role.ts | 5 +- packages/@aws-cdk/aws-iam/lib/role.ts | 26 +- packages/@aws-cdk/aws-lambda/lib/layers.ts | 4 +- packages/@aws-cdk/aws-logs/lib/log-stream.ts | 2 +- packages/@aws-cdk/aws-rds/lib/option-group.ts | 2 +- .../@aws-cdk/aws-rds/lib/parameter-group.ts | 2 +- .../@aws-cdk/aws-route53/lib/hosted-zone.ts | 4 +- packages/@aws-cdk/aws-s3/lib/bucket.ts | 87 ++++- packages/@aws-cdk/aws-s3/test/test.bucket.ts | 340 +++++++++++++----- packages/@aws-cdk/aws-ses/lib/receipt-rule.ts | 2 +- .../aws-stepfunctions/lib/activity.ts | 4 +- packages/@aws-cdk/cdk/lib/cfn-reference.ts | 4 +- .../cdk/lib/cross-environment-token.ts | 53 +++ packages/@aws-cdk/cdk/lib/index.ts | 2 + .../cdk/lib/physical-name-generator.ts | 56 +++ packages/@aws-cdk/cdk/lib/physical-name.ts | 122 +++++++ .../@aws-cdk/cdk/lib/resource-identifiers.ts | 45 +++ packages/@aws-cdk/cdk/lib/resource.ts | 25 +- .../cdk/test/test.cross-environment-token.ts | 213 +++++++++++ 35 files changed, 1266 insertions(+), 278 deletions(-) create mode 100644 packages/@aws-cdk/cdk/lib/cross-environment-token.ts create mode 100644 packages/@aws-cdk/cdk/lib/physical-name-generator.ts create mode 100644 packages/@aws-cdk/cdk/lib/physical-name.ts create mode 100644 packages/@aws-cdk/cdk/lib/resource-identifiers.ts create mode 100644 packages/@aws-cdk/cdk/test/test.cross-environment-token.ts diff --git a/packages/@aws-cdk/aws-certificatemanager/lib/dns-validated-certificate.ts b/packages/@aws-cdk/aws-certificatemanager/lib/dns-validated-certificate.ts index 2707f1d8b3b60..e329df9ec4263 100644 --- a/packages/@aws-cdk/aws-certificatemanager/lib/dns-validated-certificate.ts +++ b/packages/@aws-cdk/aws-certificatemanager/lib/dns-validated-certificate.ts @@ -25,8 +25,10 @@ export interface DnsValidatedCertificateProps extends CertificateProps { /** * A certificate managed by AWS Certificate Manager. Will be automatically * validated using DNS validation against the specified Route 53 hosted zone. + * + * @resource AWS::CertificateManager::Certificate */ -export class DnsValidatedCertificate extends cdk.Construct implements ICertificate { +export class DnsValidatedCertificate extends cdk.Resource implements ICertificate { public readonly certificateArn: string; private normalizedZoneName: string; private hostedZoneId: string; diff --git a/packages/@aws-cdk/aws-codebuild/lib/project.ts b/packages/@aws-cdk/aws-codebuild/lib/project.ts index d8c35077938c0..ff011bdc8d973 100644 --- a/packages/@aws-cdk/aws-codebuild/lib/project.ts +++ b/packages/@aws-cdk/aws-codebuild/lib/project.ts @@ -6,7 +6,7 @@ import ecr = require('@aws-cdk/aws-ecr'); import events = require('@aws-cdk/aws-events'); import iam = require('@aws-cdk/aws-iam'); import kms = require('@aws-cdk/aws-kms'); -import { Aws, Construct, IResource, Resource, Stack, Token } from '@aws-cdk/cdk'; +import { Aws, Construct, IResource, PhysicalName, Resource, ResourceIdentifiers, Stack, Token } from '@aws-cdk/cdk'; import { BuildArtifacts, CodePipelineBuildArtifacts, NoBuildArtifacts } from './artifacts'; import { Cache } from './cache'; import { CfnProject } from './codebuild.generated'; @@ -451,7 +451,7 @@ export interface CommonProjectProps { * * @default - Name is automatically generated. */ - readonly projectName?: string; + readonly projectName?: PhysicalName; /** * VPC network to place codebuild network interfaces @@ -616,13 +616,16 @@ export class Project extends ProjectBase { private _securityGroups: ec2.ISecurityGroup[] = []; constructor(scope: Construct, id: string, props: ProjectProps) { - super(scope, id); + super(scope, id, { + physicalName: props.projectName, + }); if (props.buildScriptAssetEntrypoint && !props.buildScriptAsset) { throw new Error('To use buildScriptAssetEntrypoint, supply buildScriptAsset as well.'); } this.role = props.role || new iam.Role(this, 'Role', { + roleName: PhysicalName.auto({ crossEnvironment: true }), assumedBy: new iam.ServicePrincipal('codebuild.amazonaws.com') }); this.grantPrincipal = this.role; @@ -700,7 +703,7 @@ export class Project extends ProjectBase { encryptionKey: props.encryptionKey && props.encryptionKey.keyArn, badgeEnabled: props.badge, cache: cache._toCloudFormation(), - name: props.projectName, + name: this.physicalName.value, timeoutInMinutes: props.timeout, secondarySources: new Token(() => this.renderSecondarySources()), secondaryArtifacts: new Token(() => this.renderSecondaryArtifacts()), @@ -708,8 +711,17 @@ export class Project extends ProjectBase { vpcConfig: this.configureVpc(props), }); - this.projectArn = resource.projectArn; - this.projectName = resource.projectName; + const resourceIdentifiers = new ResourceIdentifiers(this, { + arn: resource.projectArn, + name: resource.projectName, + arnComponents: { + service: 'codebuild', + resource: 'project', + resourceName: this.physicalName.value, + }, + }); + this.projectArn = resourceIdentifiers.arn; + this.projectName = resourceIdentifiers.name; this.addToRolePolicy(this.createLoggingPermission()); diff --git a/packages/@aws-cdk/aws-codedeploy/lib/lambda/deployment-group.ts b/packages/@aws-cdk/aws-codedeploy/lib/lambda/deployment-group.ts index eae6b45745927..047b29aab2e5b 100644 --- a/packages/@aws-cdk/aws-codedeploy/lib/lambda/deployment-group.ts +++ b/packages/@aws-cdk/aws-codedeploy/lib/lambda/deployment-group.ts @@ -257,7 +257,7 @@ export interface LambdaDeploymentGroupAttributes { readonly deploymentGroupName: string; } -class ImportedLambdaDeploymentGroup extends cdk.Construct implements ILambdaDeploymentGroup { +class ImportedLambdaDeploymentGroup extends cdk.Resource implements ILambdaDeploymentGroup { public readonly application: ILambdaApplication; public readonly deploymentGroupName: string; public readonly deploymentGroupArn: string; diff --git a/packages/@aws-cdk/aws-codepipeline-actions/lib/codebuild/build-action.ts b/packages/@aws-cdk/aws-codepipeline-actions/lib/codebuild/build-action.ts index 0738883212a35..f3702d270b4f0 100644 --- a/packages/@aws-cdk/aws-codepipeline-actions/lib/codebuild/build-action.ts +++ b/packages/@aws-cdk/aws-codepipeline-actions/lib/codebuild/build-action.ts @@ -75,6 +75,7 @@ export class CodeBuildAction extends codepipeline.Action { artifactBounds: { minInputs: 1, maxInputs: 5, minOutputs: 0, maxOutputs: 5 }, inputs: [props.input, ...props.extraInputs || []], outputs: getOutputs(props), + resource: props.project, configuration: { ProjectName: props.project.projectName, }, diff --git a/packages/@aws-cdk/aws-codepipeline-actions/test/cloudformation/test.pipeline-actions.ts b/packages/@aws-cdk/aws-codepipeline-actions/test/cloudformation/test.pipeline-actions.ts index 6fc336e6af37f..96ed8f3c19d93 100644 --- a/packages/@aws-cdk/aws-codepipeline-actions/test/cloudformation/test.pipeline-actions.ts +++ b/packages/@aws-cdk/aws-codepipeline-actions/test/cloudformation/test.pipeline-actions.ts @@ -302,7 +302,7 @@ function _stackArn(stackName: string, scope: cdk.IConstruct): string { }); } -class PipelineDouble extends cdk.Construct implements codepipeline.IPipeline { +class PipelineDouble extends cdk.Resource implements codepipeline.IPipeline { public readonly pipelineName: string; public readonly pipelineArn: string; public readonly role: iam.Role; diff --git a/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-cfn-wtih-action-role.expected.json b/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-cfn-wtih-action-role.expected.json index da68e1737ee0e..0f7db21bdaf76 100644 --- a/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-cfn-wtih-action-role.expected.json +++ b/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-cfn-wtih-action-role.expected.json @@ -8,59 +8,6 @@ } } }, - "ActionRole60B0EDF7": { - "Type": "AWS::IAM::Role", - "Properties": { - "AssumeRolePolicyDocument": { - "Statement": [ - { - "Action": "sts:AssumeRole", - "Effect": "Allow", - "Principal": { - "AWS": { - "Fn::Join": [ - "", - [ - "arn:", - { - "Ref": "AWS::Partition" - }, - ":iam::", - { - "Ref": "AWS::AccountId" - }, - ":root" - ] - ] - } - } - } - ], - "Version": "2012-10-17" - } - } - }, - "ActionRoleDefaultPolicyCA33BE56": { - "Type": "AWS::IAM::Policy", - "Properties": { - "PolicyDocument": { - "Statement": [ - { - "Action": "sqs:*", - "Effect": "Allow", - "Resource": "*" - } - ], - "Version": "2012-10-17" - }, - "PolicyName": "ActionRoleDefaultPolicyCA33BE56", - "Roles": [ - { - "Ref": "ActionRole60B0EDF7" - } - ] - } - }, "MyPipelineRoleC0D47CA4": { "Type": "AWS::IAM::Role", "Properties": { @@ -157,54 +104,14 @@ ] }, { - "Action": "iam:PassRole", + "Action": "sts:AssumeRole", "Effect": "Allow", "Resource": { "Fn::GetAtt": [ - "MyPipelineCFNCFNDeployRole9CC99B3F", + "ActionRole60B0EDF7", "Arn" ] } - }, - { - "Action": [ - "cloudformation:CreateStack", - "cloudformation:DescribeStack*", - "cloudformation:GetStackPolicy", - "cloudformation:GetTemplate*", - "cloudformation:SetStackPolicy", - "cloudformation:UpdateStack", - "cloudformation:ValidateTemplate" - ], - "Effect": "Allow", - "Resource": { - "Fn::Join": [ - "", - [ - "arn:", - { - "Ref": "AWS::Partition" - }, - ":cloudformation:", - { - "Ref": "AWS::Region" - }, - ":", - { - "Ref": "AWS::AccountId" - }, - ":stack/aws-cdk-codepipeline-cross-region-deploy-stack/*" - ] - ] - } - }, - { - "Action": [ - "sts:AssumeRole", - "iam:PassRole" - ], - "Effect": "Allow", - "Resource": "*" } ], "Version": "2012-10-17" @@ -305,6 +212,101 @@ "MyPipelineRoleC0D47CA4" ] }, + "ActionRole60B0EDF7": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "AWS": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::", + { + "Ref": "AWS::AccountId" + }, + ":root" + ] + ] + } + } + } + ], + "Version": "2012-10-17" + } + } + }, + "ActionRoleDefaultPolicyCA33BE56": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "sqs:*", + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": "iam:PassRole", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "MyPipelineCFNCFNDeployRole9CC99B3F", + "Arn" + ] + } + }, + { + "Action": [ + "cloudformation:CreateStack", + "cloudformation:DescribeStack*", + "cloudformation:GetStackPolicy", + "cloudformation:GetTemplate*", + "cloudformation:SetStackPolicy", + "cloudformation:UpdateStack", + "cloudformation:ValidateTemplate" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":cloudformation:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":stack/aws-cdk-codepipeline-cross-region-deploy-stack/*" + ] + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ActionRoleDefaultPolicyCA33BE56", + "Roles": [ + { + "Ref": "ActionRole60B0EDF7" + } + ] + } + }, "MyPipelineCFNCFNDeployRole9CC99B3F": { "Type": "AWS::IAM::Role", "Properties": { @@ -333,4 +335,4 @@ } } } -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-cfn-wtih-action-role.ts b/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-cfn-wtih-action-role.ts index 56cdf3ba9130e..d71799cb93f78 100644 --- a/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-cfn-wtih-action-role.ts +++ b/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-cfn-wtih-action-role.ts @@ -45,16 +45,12 @@ const cfnStage = { ], }; -const pipeline = new codepipeline.Pipeline(stack, 'MyPipeline', { +new codepipeline.Pipeline(stack, 'MyPipeline', { artifactBucket: bucket, stages: [ sourceStage, cfnStage, ], }); -pipeline.addToRolePolicy(new iam.PolicyStatement() - .addActions("sts:AssumeRole", "iam:PassRole") - .addAllResources() -); app.synth(); diff --git a/packages/@aws-cdk/aws-codepipeline-actions/test/test.pipeline.ts b/packages/@aws-cdk/aws-codepipeline-actions/test/test.pipeline.ts index 0e67c60edb447..5c37285b3a008 100644 --- a/packages/@aws-cdk/aws-codepipeline-actions/test/test.pipeline.ts +++ b/packages/@aws-cdk/aws-codepipeline-actions/test/test.pipeline.ts @@ -3,10 +3,11 @@ import codebuild = require('@aws-cdk/aws-codebuild'); import codecommit = require('@aws-cdk/aws-codecommit'); import codepipeline = require('@aws-cdk/aws-codepipeline'); import targets = require('@aws-cdk/aws-events-targets'); +import iam = require('@aws-cdk/aws-iam'); import lambda = require('@aws-cdk/aws-lambda'); import s3 = require('@aws-cdk/aws-s3'); import sns = require('@aws-cdk/aws-sns'); -import { App, CfnParameter, ConstructNode, SecretValue, Stack } from '@aws-cdk/cdk'; +import { App, CfnParameter, ConstructNode, PhysicalName, SecretValue, Stack } from '@aws-cdk/cdk'; import { Test } from 'nodeunit'; import cpactions = require('../lib'); @@ -397,7 +398,7 @@ export = { const stack = new Stack(); new codebuild.PipelineProject(stack, 'MyProject', { - projectName: 'MyProject', + projectName: PhysicalName.of('MyProject'), }); expect(stack).to(haveResourceLike('AWS::CodeBuild::Project', { @@ -703,6 +704,153 @@ export = { }, }, + 'cross-account Pipeline': { + 'with a CodeBuild Project in a different account works correctly'(test: Test) { + const app = new App(); + + const buildAccount = '901234567890'; + const buildStack = new Stack(app, 'BuildStack', { + env: { account: buildAccount }, + }); + const rolePhysicalName = 'ProjectRolePhysicalName'; + const projectRole = new iam.Role(buildStack, 'ProjectRole', { + assumedBy: new iam.ServicePrincipal('codebuild.amazonaws.com'), + roleName: PhysicalName.of(rolePhysicalName), + }); + const projectPhysicalName = 'ProjectPhysicalName'; + const project = new codebuild.PipelineProject(buildStack, 'Project', { + projectName: PhysicalName.of(projectPhysicalName), + role: projectRole, + }); + + const pipelineStack = new Stack(app, 'PipelineStack', { + env: { account: '123456789012' }, + }); + const sourceBucket = new s3.Bucket(pipelineStack, 'ArtifactBucket', { + bucketName: PhysicalName.of('source-bucket'), + encryption: s3.BucketEncryption.Kms, + }); + const sourceOutput = new codepipeline.Artifact(); + new codepipeline.Pipeline(pipelineStack, 'Pipeline', { + stages: [ + { + name: 'Source', + actions: [ + new cpactions.S3SourceAction({ + actionName: 'S3', + bucket: sourceBucket, + bucketKey: 'path/to/file.zip', + output: sourceOutput, + }), + ], + }, + { + name: 'Build', + actions: [ + new cpactions.CodeBuildAction({ + actionName: 'CodeBuild', + project, + input: sourceOutput, + output: new codepipeline.Artifact(), + }), + ], + }, + ], + }); + + expect(pipelineStack).to(haveResourceLike('AWS::CodePipeline::Pipeline', { + "Stages": [ + { + "Name": "Source", + }, + { + "Name": "Build", + "Actions": [ + { + "Name": "CodeBuild", + "Configuration": { + "ProjectName": projectPhysicalName, + }, + "RoleArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + `:iam::${buildAccount}:role/buildstackebuildactionrole166c75d145cdaa010350`, + ], + ], + }, + }, + ], + }, + ], + })); + + expect(buildStack).to(haveResourceLike('AWS::IAM::Policy', { + "PolicyDocument": { + "Statement": [ + { + // log permissions from the CodeBuild Project Construct... + }, + { + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*", + "s3:DeleteObject*", + "s3:PutObject*", + "s3:Abort*", + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + `:s3:::pipelinestackeartifactsbucket5409dc8418216ab8debe`, + ], + ], + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + `:s3:::pipelinestackeartifactsbucket5409dc8418216ab8debe/*`, + ], + ], + }, + ], + }, + { + "Action": [ + "kms:Decrypt", + "kms:DescribeKey", + "kms:Encrypt", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + ], + "Effect": "Allow", + "Resource": "*", + }, + ], + }, + })); + + test.done(); + }, + }, + 'Pipeline.fromPipelineArn'(test: Test) { // GIVEN const stack = new Stack(); diff --git a/packages/@aws-cdk/aws-codepipeline/lib/action.ts b/packages/@aws-cdk/aws-codepipeline/lib/action.ts index a63caa198c5a5..7d1916742dbe3 100644 --- a/packages/@aws-cdk/aws-codepipeline/lib/action.ts +++ b/packages/@aws-cdk/aws-codepipeline/lib/action.ts @@ -53,6 +53,10 @@ export interface ActionBind { readonly role: iam.IRole; } +interface ExtendedActionBind extends ActionBind { + readonly actionRole?: iam.IRole; +} + /** * The abstract view of an AWS CodePipeline as required and used by Actions. * It extends {@link events.IRuleTarget}, @@ -168,6 +172,15 @@ export interface ActionProps extends CommonActionProps { readonly configuration?: any; readonly version?: string; readonly owner?: string; + + /** + * The optional resource that is backing this Action. + * This is used for automatically handling Actions backed by + * resources from a different account and/or region. + * + * @default the Action is not backed by any resource + */ + readonly resource?: IResource; } /** @@ -197,6 +210,13 @@ export abstract class Action { */ public readonly region?: string; + /** + * The optional resource that is backing this Action. + * This is used for automatically handling Actions backed by + * resources from a different account and/or region. + */ + public readonly resource?: IResource; + /** * The action's configuration. These are key-value pairs that specify input values for an action. * For more information, see the AWS CodePipeline User Guide. @@ -205,15 +225,6 @@ export abstract class Action { */ public readonly configuration?: any; - /** - * The service role that is assumed during execution of action. - * This role is not mandatory, however more advanced configuration - * may require specifying it. - * - * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-codepipeline-pipeline-stages-actions.html - */ - public readonly role?: iam.IRole; - /** * The order in which AWS CodePipeline runs this action. * For more information, see the AWS CodePipeline User Guide. @@ -230,6 +241,7 @@ export abstract class Action { private readonly _actionOutputArtifacts = new Array(); private readonly artifactBounds: ActionArtifactBounds; + private _role?: iam.IRole; private _pipeline?: IPipeline; private _stage?: IStage; private _scope?: Construct; @@ -246,7 +258,8 @@ export abstract class Action { this.artifactBounds = props.artifactBounds; this.runOrder = props.runOrder === undefined ? 1 : props.runOrder; this.actionName = props.actionName; - this.role = props.role; + this._role = props.role; + this.resource = props.resource; for (const inputArtifact of props.inputs || []) { this.addInputArtifact(inputArtifact); @@ -272,6 +285,14 @@ export abstract class Action { return rule; } + /** + * The service role that is assumed during execution of this action. + * If this is undefined, the Action will execute in the context of the Pipeline Role. + */ + public get role(): iam.IRole | undefined { + return this._role; + } + public get inputs(): Artifact[] { return this._actionInputArtifacts.slice(); } @@ -289,6 +310,22 @@ export abstract class Action { ); } + /** @internal */ + public _actionAttachedToPipeline(info: ExtendedActionBind): void { + if (this._stage) { + throw new Error(`Action '${this.actionName}' has been added to a pipeline twice`); + } + + this._pipeline = info.pipeline; + this._stage = info.stage; + this._scope = info.scope; + if (!this._role) { + this._role = info.actionRole; + } + + this.bind(info); + } + protected addInputArtifact(artifact: Artifact): void { this.addToArtifacts(artifact, this._actionInputArtifacts); } @@ -337,20 +374,6 @@ export abstract class Action { artifacts.push(artifact); } - // ignore unused private method (it's actually used in Pipeline) - // @ts-ignore - private _actionAttachedToPipeline(info: ActionBind): void { - if (this._stage) { - throw new Error(`Action '${this.actionName}' has been added to a pipeline twice`); - } - - this._pipeline = info.pipeline; - this._stage = info.stage; - this._scope = info.scope; - - this.bind(info); - } - private get pipeline(): IPipeline { if (this._pipeline) { return this._pipeline; diff --git a/packages/@aws-cdk/aws-codepipeline/lib/cross-region-scaffold-stack.ts b/packages/@aws-cdk/aws-codepipeline/lib/cross-region-scaffold-stack.ts index a8fcddd49e791..36667a8a1d9a2 100644 --- a/packages/@aws-cdk/aws-codepipeline/lib/cross-region-scaffold-stack.ts +++ b/packages/@aws-cdk/aws-codepipeline/lib/cross-region-scaffold-stack.ts @@ -42,7 +42,7 @@ export class CrossRegionScaffoldStack extends CrossRegionScaffolding { props.region, props.account, false, 12); new s3.Bucket(this, 'CrossRegionCodePipelineReplicationBucket', { - bucketName: replicationBucketName, + bucketName: cdk.PhysicalName.of(replicationBucketName), }); this.replicationBucketName = replicationBucketName; } diff --git a/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts b/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts index 24c8d963035c9..32a39218757e3 100644 --- a/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts +++ b/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts @@ -2,7 +2,7 @@ import events = require('@aws-cdk/aws-events'); import iam = require('@aws-cdk/aws-iam'); import kms = require('@aws-cdk/aws-kms'); import s3 = require('@aws-cdk/aws-s3'); -import { Construct, RemovalPolicy, Resource, Stack, Token } from '@aws-cdk/cdk'; +import { Construct, PhysicalName, RemovalPolicy, Resource, Stack, Token } from '@aws-cdk/cdk'; import { Action, IPipeline, IStage } from "./action"; import { CfnPipeline } from './codepipeline.generated'; import { Stage } from './stage'; @@ -218,9 +218,8 @@ export class Pipeline extends PipelineBase { private readonly artifactStores: { [region: string]: CfnPipeline.ArtifactStoreProperty }; private readonly _crossRegionScaffoldStacks: { [region: string]: CrossRegionScaffoldStack } = {}; - constructor(scope: Construct, id: string, props?: PipelineProps) { + constructor(scope: Construct, id: string, props: PipelineProps = {}) { super(scope, id); - props = props || {}; validateName('Pipeline', props.pipelineName); @@ -229,6 +228,7 @@ export class Pipeline extends PipelineBase { if (!propsBucket) { const encryptionKey = new kms.Key(this, 'ArtifactsBucketEncryptionKey'); propsBucket = new s3.Bucket(this, 'ArtifactsBucket', { + bucketName: PhysicalName.auto({ crossEnvironment: true }), encryptionKey, encryption: s3.BucketEncryption.Kms, removalPolicy: RemovalPolicy.Orphan @@ -348,19 +348,27 @@ export class Pipeline extends PipelineBase { // ignore unused private method (it's actually used in Stage) // @ts-ignore private _attachActionToPipeline(stage: Stage, action: Action, actionScope: cdk.Construct): void { - if (action.region) { - // handle cross-region Actions here - this.ensureReplicationBucketExistsFor(action.region); - } - (action as any)._actionAttachedToPipeline({ + // handle cross-region actions here + this.ensureReplicationBucketExistsFor(action.region); + + // get the role for the given action + const actionRole = this.getRoleForAction(stage, action); + + // call the action callback which eventually calls bind() + action._actionAttachedToPipeline({ pipeline: this, stage, scope: actionScope, - role: this.role, + role: actionRole ? actionRole : this.role, + actionRole, }); } - private ensureReplicationBucketExistsFor(region: string) { + private ensureReplicationBucketExistsFor(region?: string) { + if (!region) { + return; + } + // get the region the Pipeline itself is in const pipelineRegion = Stack.of(this).requireRegion( "You need to specify an explicit region when using CodePipeline's cross-region support"); @@ -397,6 +405,54 @@ export class Pipeline extends PipelineBase { }; } + /** + * Gets the role used for this action, + * including handling the case when the action is supposed to be cross-region. + * + * @param stage the stage the action belongs to + * @param action the action to return/create a role for + */ + private getRoleForAction(stage: Stage, action: Action): iam.IRole | undefined { + let actionRole: iam.IRole | undefined; + + if (action.role) { + actionRole = action.role; + } else if (action.resource) { + const pipelineStack = Stack.of(this); + const resourceStack = Stack.of(action.resource); + // check if resource is from a different account + if (pipelineStack.env.account && resourceStack.env.account && + pipelineStack.env.account !== resourceStack.env.account) { + // if it is, the pipeline's bucket must have a KMS key + if (!this.artifactBucket.encryptionKey) { + throw new Error('The Pipeline is being used in a cross-account manner, ' + + 'but its artifact bucket does not have a KMS key defined. ' + + 'A KMS key is required for a cross-account Pipeline. ' + + 'Make sure to pass a Bucket with a Key when creating the Pipeline'); + } + + // generate a role in the other stack, that the Pipeline will assume for executing this action + actionRole = new iam.Role(resourceStack, + `${this.node.uniqueId}-${stage.stageName}-${action.actionName}-ActionRole`, { + assumedBy: new iam.AccountPrincipal(pipelineStack.env.account), + roleName: PhysicalName.auto({ crossEnvironment: true }), + }); + + // the other stack has to be deployed before the pipeline stack + pipelineStack.addDependency(resourceStack); + } + } + + // the pipeline role needs assumeRole permissions to the action role + if (actionRole) { + this.role.addToPolicy(new iam.PolicyStatement() + .addAction('sts:AssumeRole') + .addResource(actionRole.roleArn)); + } + + return actionRole; + } + private calculateInsertIndexFromPlacement(placement: StagePlacement): number { // check if at most one placement property was provided const providedPlacementProps = ['rightBefore', 'justAfter', 'atIndex'] diff --git a/packages/@aws-cdk/aws-cognito/lib/user-pool.ts b/packages/@aws-cdk/aws-cognito/lib/user-pool.ts index ed478523bfa86..e536c131f1782 100644 --- a/packages/@aws-cdk/aws-cognito/lib/user-pool.ts +++ b/packages/@aws-cdk/aws-cognito/lib/user-pool.ts @@ -302,7 +302,7 @@ export class UserPool extends Resource implements IUserPool { /** * Define a user pool which has been declared in another stack */ - class Import extends Construct implements IUserPool { + class Import extends Resource implements IUserPool { public readonly userPoolId = attrs.userPoolId; public readonly userPoolArn = attrs.userPoolArn; public readonly userPoolProviderName = attrs.userPoolProviderName; diff --git a/packages/@aws-cdk/aws-ecs/lib/cluster.ts b/packages/@aws-cdk/aws-ecs/lib/cluster.ts index db9389fbdbd7c..cf9812bbe887a 100644 --- a/packages/@aws-cdk/aws-ecs/lib/cluster.ts +++ b/packages/@aws-cdk/aws-ecs/lib/cluster.ts @@ -360,7 +360,7 @@ export interface ClusterAttributes { /** * An Cluster that has been imported */ -class ImportedCluster extends Construct implements ICluster { +class ImportedCluster extends Resource implements ICluster { /** * Name of the cluster */ diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-load-balancer.ts b/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-load-balancer.ts index 30510c227ee33..377e495a7e02e 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-load-balancer.ts +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-load-balancer.ts @@ -2,7 +2,7 @@ import cloudwatch = require('@aws-cdk/aws-cloudwatch'); import ec2 = require('@aws-cdk/aws-ec2'); import iam = require('@aws-cdk/aws-iam'); import s3 = require('@aws-cdk/aws-s3'); -import { Construct, Stack, Token } from '@aws-cdk/cdk'; +import { Construct, Resource, Stack, Token } from '@aws-cdk/cdk'; import { BaseLoadBalancer, BaseLoadBalancerProps, ILoadBalancerV2 } from '../shared/base-load-balancer'; import { IpAddressType } from '../shared/enums'; import { ApplicationListener, BaseApplicationListenerProps } from './application-listener'; @@ -540,7 +540,7 @@ const ELBV2_ACCOUNTS: {[region: string]: string } = { /** * An ApplicationLoadBalancer that has been defined elsewhere */ -class ImportedApplicationLoadBalancer extends Construct implements IApplicationLoadBalancer { +class ImportedApplicationLoadBalancer extends Resource implements IApplicationLoadBalancer { /** * Manage connections for this load balancer */ diff --git a/packages/@aws-cdk/aws-glue/lib/database.ts b/packages/@aws-cdk/aws-glue/lib/database.ts index adcaf1186c100..07f38aa422cbc 100644 --- a/packages/@aws-cdk/aws-glue/lib/database.ts +++ b/packages/@aws-cdk/aws-glue/lib/database.ts @@ -50,7 +50,7 @@ export class Database extends Resource implements IDatabase { public static fromDatabaseArn(scope: Construct, id: string, databaseArn: string): IDatabase { const stack = Stack.of(scope); - class Import extends Construct implements IDatabase { + class Import extends Resource implements IDatabase { public databaseArn = databaseArn; public databaseName = stack.parseArn(databaseArn).resourceName!; public catalogArn = stack.formatArn({ service: 'glue', resource: 'catalog' }); diff --git a/packages/@aws-cdk/aws-glue/lib/table.ts b/packages/@aws-cdk/aws-glue/lib/table.ts index 7442143c64763..51d2de06f2fb2 100644 --- a/packages/@aws-cdk/aws-glue/lib/table.ts +++ b/packages/@aws-cdk/aws-glue/lib/table.ts @@ -166,7 +166,7 @@ export class Table extends Resource implements ITable { * @param attrs Import attributes */ public static fromTableAttributes(scope: Construct, id: string, attrs: TableAttributes): ITable { - class Import extends Construct implements ITable { + class Import extends Resource implements ITable { public readonly tableArn = attrs.tableArn; public readonly tableName = attrs.tableName; } diff --git a/packages/@aws-cdk/aws-iam/lib/lazy-role.ts b/packages/@aws-cdk/aws-iam/lib/lazy-role.ts index fc654d8f953bf..43d21130b311c 100644 --- a/packages/@aws-cdk/aws-iam/lib/lazy-role.ts +++ b/packages/@aws-cdk/aws-iam/lib/lazy-role.ts @@ -18,8 +18,10 @@ export interface LazyRoleProps extends RoleProps { * (such as when AutoScaling is configured). The role can be configured in one * place, but if it never gets used it doesn't get instantiated and will * not be synthesized or deployed. + * + * @resource AWS::IAM::Role */ -export class LazyRole extends cdk.Construct implements IRole { +export class LazyRole extends cdk.Resource implements IRole { public readonly grantPrincipal: IPrincipal = this; public readonly assumeRoleAction: string = 'sts:AssumeRole'; private role?: Role; @@ -76,6 +78,7 @@ export class LazyRole extends cdk.Construct implements IRole { return this.instantiate().roleArn; } + /** @attribute */ public get roleId(): string { return this.instantiate().roleId; } diff --git a/packages/@aws-cdk/aws-iam/lib/role.ts b/packages/@aws-cdk/aws-iam/lib/role.ts index 56bcd5de1d2bc..3f59ae06fcc77 100644 --- a/packages/@aws-cdk/aws-iam/lib/role.ts +++ b/packages/@aws-cdk/aws-iam/lib/role.ts @@ -1,4 +1,4 @@ -import { Construct, Resource, Stack } from '@aws-cdk/cdk'; +import { Construct, PhysicalName, Resource, ResourceIdentifiers, Stack } from '@aws-cdk/cdk'; import { Grant } from './grant'; import { CfnRole } from './iam.generated'; import { IIdentity } from './identity-base'; @@ -68,7 +68,7 @@ export interface RoleProps { * @default - AWS CloudFormation generates a unique physical ID and uses that ID * for the group name. */ - readonly roleName?: string; + readonly roleName?: PhysicalName; /** * The maximum session duration (in seconds) that you want to set for the @@ -109,7 +109,7 @@ export class Role extends Resource implements IRole { */ public static fromRoleArn(scope: Construct, id: string, roleArn: string): IRole { - class Import extends Construct implements IRole { + class Import extends Resource implements IRole { public readonly grantPrincipal: IPrincipal = this; public readonly assumeRoleAction: string = 'sts:AssumeRole'; public readonly policyFragment = new ArnPrincipal(roleArn).policyFragment; @@ -190,7 +190,9 @@ export class Role extends Resource implements IRole { private readonly attachedPolicies = new AttachedPolicies(); constructor(scope: Construct, id: string, props: RoleProps) { - super(scope, id); + super(scope, id, { + physicalName: props.roleName, + }); this.assumeRolePolicy = createAssumeRolePolicy(props.assumedBy, props.externalId); this.managedPolicyArns = props.managedPolicyArns || [ ]; @@ -202,13 +204,23 @@ export class Role extends Resource implements IRole { managedPolicyArns: undefinedIfEmpty(() => this.managedPolicyArns), policies: _flatten(props.inlinePolicies), path: props.path, - roleName: props.roleName, + roleName: this.physicalName.value, maxSessionDuration: props.maxSessionDurationSec, }); this.roleId = role.roleId; - this.roleArn = role.roleArn; - this.roleName = role.roleName; + const resourceIdentifiers = new ResourceIdentifiers(this, { + arn: role.roleArn, + name: role.roleName, + arnComponents: { + region: '', // IAM is global in each partition + service: 'iam', + resource: 'role', + resourceName: this.physicalName.value, + }, + }); + this.roleArn = resourceIdentifiers.arn; + this.roleName = resourceIdentifiers.name; this.policyFragment = new ArnPrincipal(this.roleArn).policyFragment; function _flatten(policies?: { [name: string]: PolicyDocument }) { diff --git a/packages/@aws-cdk/aws-lambda/lib/layers.ts b/packages/@aws-cdk/aws-lambda/lib/layers.ts index 92a7ecd1b598c..1f474a3d4c1fe 100644 --- a/packages/@aws-cdk/aws-lambda/lib/layers.ts +++ b/packages/@aws-cdk/aws-lambda/lib/layers.ts @@ -199,8 +199,10 @@ export interface SingletonLayerVersionProps extends LayerVersionProps { * A Singleton Lambda Layer Version. The construct gurantees exactly one LayerVersion will be created in a given Stack * for the provided ``uuid``. It is recommended to use ``uuidgen`` to create a new ``uuid`` each time a new singleton * layer is created. + * + * @resource AWS::Lambda::LayerVersion */ -export class SingletonLayerVersion extends Construct implements ILayerVersion { +export class SingletonLayerVersion extends Resource implements ILayerVersion { private readonly layerVersion: ILayerVersion; constructor(scope: Construct, id: string, props: SingletonLayerVersionProps) { diff --git a/packages/@aws-cdk/aws-logs/lib/log-stream.ts b/packages/@aws-cdk/aws-logs/lib/log-stream.ts index 687acb2787bf5..b807dc2bf814b 100644 --- a/packages/@aws-cdk/aws-logs/lib/log-stream.ts +++ b/packages/@aws-cdk/aws-logs/lib/log-stream.ts @@ -50,7 +50,7 @@ export class LogStream extends Resource implements ILogStream { * Import an existing LogGroup */ public static fromLogStreamName(scope: Construct, id: string, logStreamName: string): ILogStream { - class Import extends Construct implements ILogStream { + class Import extends Resource implements ILogStream { public readonly logStreamName = logStreamName; } diff --git a/packages/@aws-cdk/aws-rds/lib/option-group.ts b/packages/@aws-cdk/aws-rds/lib/option-group.ts index 0180d1f3800fc..578ed11ff1773 100644 --- a/packages/@aws-cdk/aws-rds/lib/option-group.ts +++ b/packages/@aws-cdk/aws-rds/lib/option-group.ts @@ -96,7 +96,7 @@ export class OptionGroup extends Resource implements IOptionGroup { * Import an existing option group. */ public static fromOptionGroupName(scope: Construct, id: string, optionGroupName: string): IOptionGroup { - class Import extends Construct { + class Import extends Resource { public readonly optionGroupName = optionGroupName; } return new Import(scope, id); diff --git a/packages/@aws-cdk/aws-rds/lib/parameter-group.ts b/packages/@aws-cdk/aws-rds/lib/parameter-group.ts index 4344faf63072b..8c86b7c26c008 100644 --- a/packages/@aws-cdk/aws-rds/lib/parameter-group.ts +++ b/packages/@aws-cdk/aws-rds/lib/parameter-group.ts @@ -29,7 +29,7 @@ abstract class ParameterGroupBase extends Resource implements IParameterGroup { * Imports a parameter group */ public static fromParameterGroupName(scope: Construct, id: string, parameterGroupName: string): IParameterGroup { - class Import extends Construct implements IParameterGroup { + class Import extends Resource implements IParameterGroup { public readonly parameterGroupName = parameterGroupName; } return new Import(scope, id); diff --git a/packages/@aws-cdk/aws-route53/lib/hosted-zone.ts b/packages/@aws-cdk/aws-route53/lib/hosted-zone.ts index e89494dc02e70..22f91d6e48488 100644 --- a/packages/@aws-cdk/aws-route53/lib/hosted-zone.ts +++ b/packages/@aws-cdk/aws-route53/lib/hosted-zone.ts @@ -45,7 +45,7 @@ export interface HostedZoneProps extends CommonHostedZoneProps { export class HostedZone extends Resource implements IHostedZone { public static fromHostedZoneId(scope: Construct, id: string, hostedZoneId: string): IHostedZone { - class Import extends Construct implements IHostedZone { + class Import extends Resource implements IHostedZone { public readonly hostedZoneId = hostedZoneId; public get zoneName(): string { throw new Error(`HostedZone.fromHostedZoneId doesn't support "zoneName"`); @@ -59,7 +59,7 @@ export class HostedZone extends Resource implements IHostedZone { * Imports a hosted zone from another stack. */ public static fromHostedZoneAttributes(scope: Construct, id: string, attrs: HostedZoneAttributes): IHostedZone { - class Import extends Construct implements IHostedZone { + class Import extends Resource implements IHostedZone { public readonly hostedZoneId = attrs.hostedZoneId; public readonly zoneName = attrs.zoneName; } diff --git a/packages/@aws-cdk/aws-s3/lib/bucket.ts b/packages/@aws-cdk/aws-s3/lib/bucket.ts index a0cca4a678f3d..a29dfaefb599d 100644 --- a/packages/@aws-cdk/aws-s3/lib/bucket.ts +++ b/packages/@aws-cdk/aws-s3/lib/bucket.ts @@ -1,7 +1,7 @@ import events = require('@aws-cdk/aws-events'); import iam = require('@aws-cdk/aws-iam'); import kms = require('@aws-cdk/aws-kms'); -import { applyRemovalPolicy, Construct, IResource, RemovalPolicy, Resource, Stack, Token } from '@aws-cdk/cdk'; +import { applyRemovalPolicy, Construct, IResource, PhysicalName, RemovalPolicy, Resource, ResourceIdentifiers, Stack, Token } from '@aws-cdk/cdk'; import { EOL } from 'os'; import { BucketPolicy } from './bucket-policy'; import { IBucketNotificationDestination } from './destination'; @@ -508,19 +508,53 @@ abstract class BucketBase extends Resource implements IBucket { resourceArn: string, ...otherResourceArns: string[]) { const resources = [ resourceArn, ...otherResourceArns ]; - const ret = iam.Grant.addToPrincipalOrResource({ - grantee, - actions: bucketActions, - resourceArns: resources, - resource: this, - }); + const crossAccountAccess = this.isGranteeFromAnotherAccount(grantee); + let ret: iam.Grant; + if (crossAccountAccess) { + // if the access is cross-account, we need to trust the accessing principal in the bucket's policy + ret = iam.Grant.addToPrincipalAndResource({ + grantee, + actions: bucketActions, + resourceArns: resources, + resource: this, + }); + } else { + // if not, we don't need to modify the resource policy if the grantee is an identity principal + ret = iam.Grant.addToPrincipalOrResource({ + grantee, + actions: bucketActions, + resourceArns: resources, + resource: this, + }); + } if (this.encryptionKey) { - this.encryptionKey.grant(grantee, ...keyActions); + if (crossAccountAccess) { + // we can't access the Key ARN (they don't have physical names), + // so fall back on using '*'. ToDo we need to make this better... somehow + iam.Grant.addToPrincipalAndResource({ + actions: keyActions, + grantee, + resourceArns: ['*'], + resource: this.encryptionKey, + }); + } else { + this.encryptionKey.grant(grantee, ...keyActions); + } } return ret; } + + private isGranteeFromAnotherAccount(grantee: iam.IGrantable): boolean { + if (!(grantee instanceof Construct)) { + return false; + } + const c = grantee as Construct; + const bucketStack = Stack.of(this); + const identityStack = Stack.of(c); + return bucketStack.env.account !== identityStack.env.account; + } } export interface BlockPublicAccessOptions { @@ -626,7 +660,7 @@ export interface BucketProps { * * @default - Assigned by CloudFormation (recommended). */ - readonly bucketName?: string; + readonly bucketName?: PhysicalName; /** * Policy to apply when the bucket is removed from this stack. @@ -776,15 +810,15 @@ export class Bucket extends BucketBase { private readonly metrics: BucketMetrics[] = []; constructor(scope: Construct, id: string, props: BucketProps = {}) { - super(scope, id); + super(scope, id, { + physicalName: props.bucketName, + }); const { bucketEncryption, encryptionKey } = this.parseEncryption(props); - if (props.bucketName && !Token.isToken(props.bucketName)) { - this.validateBucketName(props.bucketName); - } + this.validateBucketName(this.physicalName); const resource = new CfnBucket(this, 'Resource', { - bucketName: props && props.bucketName, + bucketName: this.physicalName.value, bucketEncryption, versioningConfiguration: props.versioned ? { status: 'Enabled' } : undefined, lifecycleConfiguration: new Token(() => this.parseLifecycleConfiguration()), @@ -798,8 +832,18 @@ export class Bucket extends BucketBase { this.versioned = props.versioned; this.encryptionKey = encryptionKey; - this.bucketArn = resource.bucketArn; - this.bucketName = resource.bucketName; + const resourceIdentifiers = new ResourceIdentifiers(this, { + arn: resource.bucketArn, + name: resource.bucketName, + arnComponents: { + region: '', + account: '', + service: 's3', + resource: this.physicalName.value || '', + }, + }); + this.bucketArn = resourceIdentifiers.arn; + this.bucketName = resourceIdentifiers.name; this.bucketDomainName = resource.bucketDomainName; this.bucketWebsiteUrl = resource.bucketWebsiteUrl; this.bucketDualStackDomainName = resource.bucketDualStackDomainName; @@ -893,7 +937,14 @@ export class Bucket extends BucketBase { return this.addEventNotification(EventType.ObjectRemoved, dest, ...filters); } - private validateBucketName(bucketName: string) { + private validateBucketName(physicalName: PhysicalName): void { + const bucketName = physicalName.value; + if (!bucketName || Token.isToken(bucketName)) { + // the name is a late-bound value, not a defined string, + // so skip validation + return; + } + const errors: string[] = []; // Rules codified from https://docs.aws.amazon.com/AmazonS3/latest/dev/BucketRestrictions.html @@ -1226,4 +1277,4 @@ export interface OnCloudTrailBucketEventOptions extends events.OnEventOptions { * @default - Watch changes to all objects */ readonly paths?: string[]; -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/aws-s3/test/test.bucket.ts b/packages/@aws-cdk/aws-s3/test/test.bucket.ts index b7ca523d4b3e2..aebaa00ed53e7 100644 --- a/packages/@aws-cdk/aws-s3/test/test.bucket.ts +++ b/packages/@aws-cdk/aws-s3/test/test.bucket.ts @@ -1,4 +1,4 @@ -import { expect, haveResource, SynthUtils } from '@aws-cdk/assert'; +import { expect, haveResource, haveResourceLike, SynthUtils } from '@aws-cdk/assert'; import iam = require('@aws-cdk/aws-iam'); import kms = require('@aws-cdk/aws-kms'); import cdk = require('@aws-cdk/cdk'); @@ -32,7 +32,7 @@ export = { 'CFN properties are type-validated during resolution'(test: Test) { const stack = new cdk.Stack(); new s3.Bucket(stack, 'MyBucket', { - bucketName: new cdk.Token(() => 5).toString() // Oh no + bucketName: cdk.PhysicalName.of(new cdk.Token(() => 5).toString()) // Oh no }); test.throws(() => { @@ -92,11 +92,11 @@ export = { const stack = new cdk.Stack(); test.doesNotThrow(() => new s3.Bucket(stack, 'MyBucket1', { - bucketName: 'abc.xyz-34ab' + bucketName: cdk.PhysicalName.of('abc.xyz-34ab'), })); test.doesNotThrow(() => new s3.Bucket(stack, 'MyBucket2', { - bucketName: '124.pp--33' + bucketName: cdk.PhysicalName.of('124.pp--33'), })); test.done(); @@ -106,7 +106,7 @@ export = { const stack = new cdk.Stack(); test.doesNotThrow(() => new s3.Bucket(stack, 'MyBucket', { - bucketName: new cdk.Token(() => '_BUCKET').toString() + bucketName: cdk.PhysicalName.of(new cdk.Token(() => '_BUCKET').toString()), })); test.done(); @@ -125,7 +125,7 @@ export = { ].join(EOL); test.throws(() => new s3.Bucket(stack, 'MyBucket', { - bucketName: bucket + bucketName: cdk.PhysicalName.of(bucket), // tslint:disable-next-line:only-arrow-functions }), function(err: Error) { return expectedErrors === err.message; @@ -138,11 +138,11 @@ export = { const stack = new cdk.Stack(); test.throws(() => new s3.Bucket(stack, 'MyBucket1', { - bucketName: 'a' + bucketName: cdk.PhysicalName.of('a'), }), /at least 3/); test.throws(() => new s3.Bucket(stack, 'MyBucket2', { - bucketName: new Array(65).join('x') + bucketName: cdk.PhysicalName.of(new Array(65).join('x')), }), /no more than 63/); test.done(); @@ -152,15 +152,15 @@ export = { const stack = new cdk.Stack(); test.throws(() => new s3.Bucket(stack, 'MyBucket1', { - bucketName: 'b@cket' + bucketName: cdk.PhysicalName.of('b@cket'), }), /offset: 1/); test.throws(() => new s3.Bucket(stack, 'MyBucket2', { - bucketName: 'bucKet' + bucketName: cdk.PhysicalName.of('bucKet'), }), /offset: 3/); test.throws(() => new s3.Bucket(stack, 'MyBucket3', { - bucketName: 'bučket' + bucketName: cdk.PhysicalName.of('bučket'), }), /offset: 2/); test.done(); @@ -170,11 +170,11 @@ export = { const stack = new cdk.Stack(); test.throws(() => new s3.Bucket(stack, 'MyBucket1', { - bucketName: '-ucket' + bucketName: cdk.PhysicalName.of('-ucket'), }), /offset: 0/); test.throws(() => new s3.Bucket(stack, 'MyBucket2', { - bucketName: 'bucke.' + bucketName: cdk.PhysicalName.of('bucke.'), }), /offset: 5/); test.done(); @@ -184,19 +184,19 @@ export = { const stack = new cdk.Stack(); test.throws(() => new s3.Bucket(stack, 'MyBucket1', { - bucketName: 'buc..ket' + bucketName: cdk.PhysicalName.of('buc..ket'), }), /offset: 3/); test.throws(() => new s3.Bucket(stack, 'MyBucket2', { - bucketName: 'buck.-et' + bucketName: cdk.PhysicalName.of('buck.-et'), }), /offset: 4/); test.throws(() => new s3.Bucket(stack, 'MyBucket3', { - bucketName: 'b-.ucket' + bucketName: cdk.PhysicalName.of('b-.ucket'), }), /offset: 1/); test.doesNotThrow(() => new s3.Bucket(stack, 'MyBucket4', { - bucketName: 'bu--cket' + bucketName: cdk.PhysicalName.of('bu--cket'), })); test.done(); @@ -206,19 +206,19 @@ export = { const stack = new cdk.Stack(); test.throws(() => new s3.Bucket(stack, 'MyBucket1', { - bucketName: '1.2.3.4' + bucketName: cdk.PhysicalName.of('1.2.3.4'), }), /must not resemble an IP address/); test.doesNotThrow(() => new s3.Bucket(stack, 'MyBucket2', { - bucketName: '1.2.3' + bucketName: cdk.PhysicalName.of('1.2.3'), })); test.doesNotThrow(() => new s3.Bucket(stack, 'MyBucket3', { - bucketName: '1.2.3.a' + bucketName: cdk.PhysicalName.of('1.2.3.a'), })); test.doesNotThrow(() => new s3.Bucket(stack, 'MyBucket4', { - bucketName: '1000.2.3.4' + bucketName: cdk.PhysicalName.of('1000.2.3.4'), })); test.done(); @@ -1009,86 +1009,250 @@ export = { test.done(); }, - 'cross-stack permissions'(test: Test) { - const app = new cdk.App(); - const stackA = new cdk.Stack(app, 'stackA'); - const bucketFromStackA = new s3.Bucket(stackA, 'MyBucket'); + 'cross-stack permissions': { + 'in the same account and region'(test: Test) { + const app = new cdk.App(); + const stackA = new cdk.Stack(app, 'stackA'); + const bucketFromStackA = new s3.Bucket(stackA, 'MyBucket'); - const stackB = new cdk.Stack(app, 'stackB'); - const user = new iam.User(stackB, 'UserWhoNeedsAccess'); - bucketFromStackA.grantRead(user); + const stackB = new cdk.Stack(app, 'stackB'); + const user = new iam.User(stackB, 'UserWhoNeedsAccess'); + bucketFromStackA.grantRead(user); - expect(stackA).toMatch({ - "Resources": { - "MyBucketF68F3FF0": { - "Type": "AWS::S3::Bucket", - "DeletionPolicy": "Retain" + expect(stackA).toMatch({ + "Resources": { + "MyBucketF68F3FF0": { + "Type": "AWS::S3::Bucket", + "DeletionPolicy": "Retain" + } + }, + "Outputs": { + "ExportsOutputFnGetAttMyBucketF68F3FF0Arn0F7E8E58": { + "Value": { + "Fn::GetAtt": [ + "MyBucketF68F3FF0", + "Arn" + ] + }, + "Export": { + "Name": "stackA:ExportsOutputFnGetAttMyBucketF68F3FF0Arn0F7E8E58" + } + } } - }, - "Outputs": { - "ExportsOutputFnGetAttMyBucketF68F3FF0Arn0F7E8E58": { - "Value": { - "Fn::GetAtt": [ - "MyBucketF68F3FF0", - "Arn" - ] + }); + + expect(stackB).toMatch({ + "Resources": { + "UserWhoNeedsAccessF8959C3D": { + "Type": "AWS::IAM::User" }, - "Export": { - "Name": "stackA:ExportsOutputFnGetAttMyBucketF68F3FF0Arn0F7E8E58" + "UserWhoNeedsAccessDefaultPolicy6A9EB530": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::ImportValue": "stackA:ExportsOutputFnGetAttMyBucketF68F3FF0Arn0F7E8E58" + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::ImportValue": "stackA:ExportsOutputFnGetAttMyBucketF68F3FF0Arn0F7E8E58" + }, + "/*" + ] + ] + } + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "UserWhoNeedsAccessDefaultPolicy6A9EB530", + "Users": [ + { + "Ref": "UserWhoNeedsAccessF8959C3D" + } + ] + } } } - } - }); + }); - expect(stackB).toMatch({ - "Resources": { - "UserWhoNeedsAccessF8959C3D": { - "Type": "AWS::IAM::User" + test.done(); + }, + + 'in different accounts'(test: Test) { + // given + const stackA = new cdk.Stack(undefined, 'StackA', { env: { account: '123456789012' }}); + const bucketFromStackA = new s3.Bucket(stackA, 'MyBucket', { + bucketName: cdk.PhysicalName.of('my-bucket-physical-name'), + }); + + const stackB = new cdk.Stack(undefined, 'StackB', { env: { account: '234567890123' }}); + const roleFromStackB = new iam.Role(stackB, 'MyRole', { + assumedBy: new iam.AccountPrincipal('234567890123'), + roleName: cdk.PhysicalName.of('MyRolePhysicalName'), + }); + + // when + bucketFromStackA.grantRead(roleFromStackB); + + // then + expect(stackA).to(haveResourceLike('AWS::S3::BucketPolicy', { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*", + ], + "Effect": "Allow", + "Principal": { + "AWS": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":iam::234567890123:role/MyRolePhysicalName", + ], + ], + }, + }, + }, + ], }, - "UserWhoNeedsAccessDefaultPolicy6A9EB530": { - "Type": "AWS::IAM::Policy", - "Properties": { - "PolicyDocument": { - "Statement": [ + })); + + expect(stackB).to(haveResourceLike('AWS::IAM::Policy', { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*", + ], + "Effect": "Allow", + "Resource": [ { - "Action": [ - "s3:GetObject*", - "s3:GetBucket*", - "s3:List*" + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":s3:::my-bucket-physical-name", + ], ], - "Effect": "Allow", - "Resource": [ - { - "Fn::ImportValue": "stackA:ExportsOutputFnGetAttMyBucketF68F3FF0Arn0F7E8E58" - }, - { - "Fn::Join": [ - "", - [ - { - "Fn::ImportValue": "stackA:ExportsOutputFnGetAttMyBucketF68F3FF0Arn0F7E8E58" - }, - "/*" - ] - ] - } - ] - } + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":s3:::my-bucket-physical-name/*", + ], + ], + }, ], - "Version": "2012-10-17" }, - "PolicyName": "UserWhoNeedsAccessDefaultPolicy6A9EB530", - "Users": [ - { - "Ref": "UserWhoNeedsAccessF8959C3D" - } - ] - } - } - } - }); + ], + }, + })); - test.done(); + test.done(); + }, + + 'in different accounts, with a KMS Key'(test: Test) { + // given + const stackA = new cdk.Stack(undefined, 'StackA', { env: { account: '123456789012' }}); + const key = new kms.Key(stackA, 'MyKey'); + const bucketFromStackA = new s3.Bucket(stackA, 'MyBucket', { + bucketName: cdk.PhysicalName.of('my-bucket-physical-name'), + encryptionKey: key, + encryption: s3.BucketEncryption.Kms, + }); + + const stackB = new cdk.Stack(undefined, 'StackB', { env: { account: '234567890123' }}); + const roleFromStackB = new iam.Role(stackB, 'MyRole', { + assumedBy: new iam.AccountPrincipal('234567890123'), + roleName: cdk.PhysicalName.of('MyRolePhysicalName'), + }); + + // when + bucketFromStackA.grantRead(roleFromStackB); + + // then + expect(stackA).to(haveResourceLike('AWS::KMS::Key', { + "KeyPolicy": { + "Statement": [ + { + // grant to the root of the owning account + }, + { + "Action": [ + "kms:Decrypt", + "kms:DescribeKey", + ], + "Effect": "Allow", + "Principal": { + "AWS": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":iam::234567890123:role/MyRolePhysicalName", + ], + ], + }, + }, + }, + ], + }, + })); + + expect(stackB).to(haveResourceLike('AWS::IAM::Policy', { + "PolicyDocument": { + "Statement": [ + { + // Bucket grant + }, + { + "Action": [ + "kms:Decrypt", + "kms:DescribeKey", + ], + "Effect": "Allow", + "Resource": "*", + }, + ], + }, + })); + + test.done(); + }, }, 'urlForObject returns a token with the S3 URL of the token'(test: Test) { diff --git a/packages/@aws-cdk/aws-ses/lib/receipt-rule.ts b/packages/@aws-cdk/aws-ses/lib/receipt-rule.ts index 73b929eafe3c2..13ce9d4d48099 100644 --- a/packages/@aws-cdk/aws-ses/lib/receipt-rule.ts +++ b/packages/@aws-cdk/aws-ses/lib/receipt-rule.ts @@ -103,7 +103,7 @@ export interface ReceiptRuleProps extends ReceiptRuleOptions { export class ReceiptRule extends Resource implements IReceiptRule { public static fromReceiptRuleName(scope: Construct, id: string, receiptRuleName: string): IReceiptRule { - class Import extends Construct implements IReceiptRule { + class Import extends Resource implements IReceiptRule { public readonly receiptRuleName = receiptRuleName; } return new Import(scope, id); diff --git a/packages/@aws-cdk/aws-stepfunctions/lib/activity.ts b/packages/@aws-cdk/aws-stepfunctions/lib/activity.ts index aefb94b000325..6e30ba22ab53f 100644 --- a/packages/@aws-cdk/aws-stepfunctions/lib/activity.ts +++ b/packages/@aws-cdk/aws-stepfunctions/lib/activity.ts @@ -19,7 +19,7 @@ export class Activity extends Resource implements IActivity { * Construct an Activity from an existing Activity ARN */ public static fromActivityArn(scope: Construct, id: string, activityArn: string): IActivity { - class Imported extends Construct implements IActivity { + class Imported extends Resource implements IActivity { public get activityArn() { return activityArn; } public get activityName() { return Stack.of(this).parseArn(activityArn, ':').resourceName || ''; @@ -181,4 +181,4 @@ export interface IActivity extends IResource { * @attribute */ readonly activityName: string; -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/cdk/lib/cfn-reference.ts b/packages/@aws-cdk/cdk/lib/cfn-reference.ts index 18394e3a4f705..2f440bcda7062 100644 --- a/packages/@aws-cdk/cdk/lib/cfn-reference.ts +++ b/packages/@aws-cdk/cdk/lib/cfn-reference.ts @@ -84,6 +84,7 @@ export class CfnReference extends Reference { private readonly replacementTokens: Map; private readonly originalDisplayName: string; + private readonly humanReadableDesc: string; private constructor(value: any, displayName: string, target: Construct) { if (typeof(value) === 'function') { @@ -94,6 +95,7 @@ export class CfnReference extends Reference { super(value, `${target.node.id}.${displayName}`, target); this.originalDisplayName = displayName; this.replacementTokens = new Map(); + this.humanReadableDesc = `target = ${target.node.path}`; this.producingStack = Stack.of(target); Object.defineProperty(this, CFN_REFERENCE_SYMBOL, { value: true }); @@ -137,7 +139,7 @@ export class CfnReference extends Reference { const producingStack = this.producingStack!; if (producingStack.env.account !== consumingStack.env.account || producingStack.env.region !== consumingStack.env.region) { - throw this.newError('Can only reference cross stacks in the same region and account.'); + throw this.newError(`Can only reference cross stacks in the same region and account. ${this.humanReadableDesc}`); } // Ensure a singleton "Exports" scoping Construct diff --git a/packages/@aws-cdk/cdk/lib/cross-environment-token.ts b/packages/@aws-cdk/cdk/lib/cross-environment-token.ts new file mode 100644 index 0000000000000..f32a2ca35df8c --- /dev/null +++ b/packages/@aws-cdk/cdk/lib/cross-environment-token.ts @@ -0,0 +1,53 @@ +import { ArnComponents } from './arn'; +import { IResource } from './resource'; +import { Stack } from './stack'; +import { IResolveContext, Token } from './token'; + +/** + * A Token that represents a reference that spans accounts and/or regions, + * and so requires the resources to have physical names. + * You should never need to interact with these directly, + * instead use the {@link ResourceIdentifiers} class. + * This class is private to the @aws-cdk/cdk package. + */ +export abstract class CrossEnvironmentToken extends Token { + private readonly resource: IResource; + + /** + * @param regularValue the value used when this is referenced NOT from a cross account and/or region Stack + * @param crossEnvironmentValue the value used when this is referenced from a cross account and/or region Stack + * @param resource the scope this reference is mastered in. Used to determine the owning Stack + * @param displayName a short name to be used in Token display + */ + protected constructor(private readonly regularValue: string, private readonly crossEnvironmentValue: any, + resource: IResource, displayName: string) { + super(undefined, displayName); + + this.resource = resource; + } + + public resolve(context: IResolveContext): any { + const consumingStack = Stack.of(context.scope); + const owningStack = Stack.of(this.resource); + + if (consumingStack.env.account !== owningStack.env.account || + consumingStack.env.region !== owningStack.env.region) { + this.resource.physicalName._resolveCrossEnvironment(this.resource); + return this.crossEnvironmentValue; + } else { + return this.regularValue; + } + } +} + +export class CrossEnvironmentPhysicalArnToken extends CrossEnvironmentToken { + constructor(regularValue: string, arnComponents: ArnComponents, resource: IResource, displayName: string = 'Arn') { + super(regularValue, Stack.of(resource).formatArn(arnComponents), resource, displayName); + } +} + +export class CrossEnvironmentPhysicalNameToken extends CrossEnvironmentToken { + constructor(regularValue: string, resource: IResource, displayName: string = 'Ref') { + super(regularValue, resource.physicalName.value, resource, displayName); + } +} diff --git a/packages/@aws-cdk/cdk/lib/index.ts b/packages/@aws-cdk/cdk/lib/index.ts index 5cb2fc282258c..d52d784f1e495 100644 --- a/packages/@aws-cdk/cdk/lib/index.ts +++ b/packages/@aws-cdk/cdk/lib/index.ts @@ -37,3 +37,5 @@ export * from './runtime'; export * from './secret-value'; export * from './resource'; +export * from './physical-name'; +export * from './resource-identifiers'; diff --git a/packages/@aws-cdk/cdk/lib/physical-name-generator.ts b/packages/@aws-cdk/cdk/lib/physical-name-generator.ts new file mode 100644 index 0000000000000..dc0d3fdfffe49 --- /dev/null +++ b/packages/@aws-cdk/cdk/lib/physical-name-generator.ts @@ -0,0 +1,56 @@ +import crypto = require('crypto'); +import { IResource } from './resource'; +import { Stack } from './stack'; + +export function generatePhysicalName(resource: IResource): string { + const stack = Stack.of(resource); + const stackPart = new PrefixNamePart(stack.name, 25); + const idPart = new SuffixNamePart(resource.node.uniqueId, 24); + + const parts = [stackPart, idPart] + .map(part => part.generate()); + + const hashLength = 12; + const sha256 = crypto.createHash('sha256') + .update(stackPart.bareStr) + .update(idPart.bareStr) + .update(stack.env.region || '') + .update(stack.env.account || ''); + const hash = sha256.digest('hex').slice(0, hashLength); + + const ret = [...parts, hash].join(''); + + return ret.toLowerCase(); +} + +abstract class NamePart { + public readonly bareStr: string; + + constructor(bareStr: string) { + this.bareStr = bareStr; + } + + public abstract generate(): string; +} + +class PrefixNamePart extends NamePart { + constructor(bareStr: string, private readonly prefixLength: number) { + super(bareStr); + } + + public generate(): string { + return this.bareStr.slice(0, this.prefixLength); + } +} + +class SuffixNamePart extends NamePart { + constructor(str: string, private readonly suffixLength: number) { + super(str); + } + + public generate(): string { + const strLen = this.bareStr.length; + const startIndex = Math.max(strLen - this.suffixLength, 0); + return this.bareStr.slice(startIndex, strLen); + } +} diff --git a/packages/@aws-cdk/cdk/lib/physical-name.ts b/packages/@aws-cdk/cdk/lib/physical-name.ts new file mode 100644 index 0000000000000..5a77556848eb0 --- /dev/null +++ b/packages/@aws-cdk/cdk/lib/physical-name.ts @@ -0,0 +1,122 @@ +import { generatePhysicalName } from "./physical-name-generator"; +import { IResource } from './resource'; +import { Token } from "./token"; + +/** + * Options allowing customizing the automatically generated physical name of the resource. + * Used in the {@link PhysicalName#auto} method. + */ +export interface AutoPhysicalNameOptions { + /** + * Allow the framework to automatically allocate a physical name during synthesis + * in case this resource is referenced across environments. + * + * When resources are referenced across environments, the physical name of the + * resource must be known during synthesis in order to avoid the need to communicate + * across environments during deployment. This also removes the potential of cyclic + * references. + * + * @default false A physical name will be allocated during deployment by AWS CloudFormation. + */ + readonly crossEnvironment?: boolean; +} + +/** + * Represents the physical (that is, visible in the AWS Console) name of a resource. + * These can be known at code runtime, + * in the case of fixed, customer-provided names, + * at synthesis time, in case of automatically assigned names by the framework, + * or only at deploy time, assigned by CloudFormation - + * which is the default. + * + * @see #auto() + * @see #of() + */ +export abstract class PhysicalName { + /** + * A physical name that will be automatically generated. + * The details of how and when that generation will occur can be customized by passing an instance + * of {@link AutoPhysicalNameOptions}. + * By default, the physical name of the resource will be generated by CloudFormation, + * at deploy time. + */ + public static auto(options: AutoPhysicalNameOptions = {}): PhysicalName { + return new LateBoundPhysicalName(options); + } + + /** + * A fixed physical name (one that is known statically, at synthesis time). + * + * @param name the name to assign + */ + public static of(name: string): PhysicalName { + return new FixedPhysicalName(name); + } + + protected constructor() { + } + + /** + * Returns the physical name, as a String. + * If the customer assigned a physical name to the resource, it will be returned, unchanged; + * can be `undefined`, if a physical name was not provided, + * or an opaque Token, if a generated name should be assigned lazily at synthesis time. + */ + public abstract get value(): string | undefined; + + /** + * A callback method called if the resource this physical name + * belongs to has been used in a cross-environment fashion. + * Allows late-binding of the actual name to only be generated if necessary, + * and kept deploy-time if not. + * + * The default implementation does nothing, + * but can be overridden in subclasses. + * + * @param _resource the resource this physical name belongs to + * @internal + */ + public _resolveCrossEnvironment(_resource: IResource): void { + // does nothing + } +} + +// implementations are private to this module, +// we only surface them through static factory methods + +class FixedPhysicalName extends PhysicalName { + constructor(public readonly value: string) { + super(); + } +} + +class LateBoundPhysicalName extends PhysicalName { + /** + * The value of the physical name - + * either a Token string if crossEnvironment is true, + * or undefined otherwise. + */ + public readonly value: string | undefined; + private name?: string; + + constructor(options: AutoPhysicalNameOptions) { + super(); + + this.value = options.crossEnvironment + ? new Token(() => this.name).toString() + : undefined; + } + + /** @internal */ + public _resolveCrossEnvironment(resource: IResource): void { + if (!this.value) { + // error out - a deploy-time name cannot be used across environments + throw new Error(`Cannot use resource '${resource.node.path}' in a cross-environment fashion, ` + + "as it doesn't have a physical name set. Use PhysicalName.auto({ crossEnvironment: true }) to enable cross env name allocation"); + } + + if (!this.name) { + this.name = generatePhysicalName(resource); + } + } +} diff --git a/packages/@aws-cdk/cdk/lib/resource-identifiers.ts b/packages/@aws-cdk/cdk/lib/resource-identifiers.ts new file mode 100644 index 0000000000000..1f97c30e7d9c3 --- /dev/null +++ b/packages/@aws-cdk/cdk/lib/resource-identifiers.ts @@ -0,0 +1,45 @@ +import { ArnComponents } from './arn'; +import { CrossEnvironmentPhysicalArnToken, CrossEnvironmentPhysicalNameToken } from './cross-environment-token'; +import { IResource } from './resource'; + +/** + * Construction properties for {@link ResourceIdentifiers}. + */ +export interface ResourceIdentifiersProps { + /** + * The ARN of the resource when referenced from the same stack. + */ + readonly arn: string; + + /** + * The name of the resource when referenced from the same stack. + */ + readonly name: string; + + /** + * The recipe for creating an ARN from a name for this resource. + */ + readonly arnComponents: ArnComponents; +} + +/** + * The identifiers (name and ARN) for a given L2. + * These should be only used inside the Construct Library implementation. + */ +export class ResourceIdentifiers { + public readonly arn: string; + public readonly name: string; + + constructor(resource: IResource, props: ResourceIdentifiersProps) { + this.arn = new CrossEnvironmentPhysicalArnToken( + props.arn, + props.arnComponents, + resource, + ).toString(); + + this.name = new CrossEnvironmentPhysicalNameToken( + props.name, + resource, + ).toString(); + } +} diff --git a/packages/@aws-cdk/cdk/lib/resource.ts b/packages/@aws-cdk/cdk/lib/resource.ts index 07c189f52ba9e..32d69f9a8b211 100644 --- a/packages/@aws-cdk/cdk/lib/resource.ts +++ b/packages/@aws-cdk/cdk/lib/resource.ts @@ -1,16 +1,39 @@ import { Construct, IConstruct } from './construct'; +import { PhysicalName } from './physical-name'; /** * Interface for the Resource construct. */ -// tslint:disable-next-line:no-empty-interface export interface IResource extends IConstruct { + /** + * The physical (that is, visible in the AWS Console) name of this resource. + */ + readonly physicalName: PhysicalName; +} +/** + * Construction properties for {@link Resource}. + */ +export interface ResourceProps { + /** + * The physical (that is, visible in the AWS Console) name of this resource. + * By default, the name will be automatically generated by CloudFormation, + * at deploy time. + * + * @default PhysicalName.auto() + */ + readonly physicalName?: PhysicalName; } /** * A construct which represents an AWS resource. */ export abstract class Resource extends Construct implements IResource { + public readonly physicalName: PhysicalName; + + constructor(scope: Construct, id: string, props: ResourceProps = {}) { + super(scope, id); + this.physicalName = props.physicalName || PhysicalName.auto(); + } } diff --git a/packages/@aws-cdk/cdk/test/test.cross-environment-token.ts b/packages/@aws-cdk/cdk/test/test.cross-environment-token.ts new file mode 100644 index 0000000000000..b8e49004ba9be --- /dev/null +++ b/packages/@aws-cdk/cdk/test/test.cross-environment-token.ts @@ -0,0 +1,213 @@ +import { Test } from 'nodeunit'; +import { App, CfnOutput, Construct, PhysicalName, Resource, ResourceIdentifiers, Stack } from '../lib'; +import { toCloudFormation } from './util'; + +// tslint:disable:object-literal-key-quotes + +export = { + 'CrossEnvironmentToken': { + 'can reference an ARN with a fixed physical name directly in a different account'(test: Test) { + // GIVEN + const app = new App(); + const stack1 = new Stack(app, 'Stack1', { + env: { + account: '123456789012', + }, + }); + const myResource = new MyResource(stack1, 'MyResource', PhysicalName.of('PhysicalName')); + + const stack2 = new Stack(app, 'Stack2', { + env: { + account: '234567890123', + }, + }); + + // WHEN + new CfnOutput(stack2, 'Output', { + value: myResource.arn, + }); + + // THEN + test.deepEqual(toCloudFormation(stack2), { + Outputs: { + Output: { + Value: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':myservice:::my-resource/PhysicalName', + ], + ], + }, + }, + }, + }); + + test.done(); + }, + + 'can reference a fixed physical name directly in a different account'(test: Test) { + // GIVEN + const app = new App(); + const stack1 = new Stack(app, 'Stack1', { + env: { + account: '123456789012', + }, + }); + const stack2 = new Stack(app, 'Stack2', { + env: { + account: '234567890123', + }, + }); + + // WHEN + const myResource = new MyResource(stack1, 'MyResource', PhysicalName.of('PhysicalName')); + new CfnOutput(stack2, 'Output', { + value: myResource.name, + }); + + // THEN + test.deepEqual(toCloudFormation(stack2), { + Outputs: { + Output: { + Value: 'PhysicalName', + }, + }, + }); + + test.done(); + }, + + 'can reference an ARN with an assigned physical name directly in a different account'(test: Test) { + // GIVEN + const app = new App(); + const stack1 = new Stack(app, 'Stack1', { + env: { + account: '123456789012', + }, + }); + const myResource = new MyResource(stack1, 'MyResource', PhysicalName.auto({ crossEnvironment: true })); + + const stack2 = new Stack(app, 'Stack2', { + env: { + account: '234567890123', + }, + }); + + // WHEN + new CfnOutput(stack2, 'Output', { + value: myResource.arn, + }); + + // THEN + test.deepEqual(toCloudFormation(stack2), { + Outputs: { + Output: { + Value: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':myservice:::my-resource/stack1stack1myresourcec54ced43dab875fcfa49', + ], + ], + }, + }, + }, + }); + + test.done(); + }, + + 'can reference an assigned physical name directly in a different account'(test: Test) { + // GIVEN + const app = new App(); + const stack1 = new Stack(app, 'Stack1', { + env: { + account: '123456789012', + }, + }); + const stack2 = new Stack(app, 'Stack2', { + env: { + account: '234567890123', + }, + }); + + // WHEN + const myResource = new MyResource(stack1, 'MyResource', PhysicalName.auto({ crossEnvironment: true })); + new CfnOutput(stack2, 'Output', { + value: myResource.name, + }); + + // THEN + test.deepEqual(toCloudFormation(stack2), { + Outputs: { + Output: { + Value: 'stack1stack1myresourcec54ced43dab875fcfa49', + }, + }, + }); + + test.done(); + }, + }, + + 'cannot reference a deploy-time physical name across environments'(test: Test) { + // GIVEN + const app = new App(); + const stack1 = new Stack(app, 'Stack1', { + env: { + account: '123456789012', + }, + }); + const stack2 = new Stack(app, 'Stack2', { + env: { + account: '234567890123', + }, + }); + + // WHEN + const myResource = new MyResource(stack1, 'MyResource', PhysicalName.auto()); + new CfnOutput(stack2, 'Output', { + value: myResource.name, + }); + + // THEN + test.throws(() => toCloudFormation(stack2), + /Cannot use resource 'Stack1\/MyResource' in a cross-environment fashion, as it doesn't have a physical name set/); + + test.done(); + }, +}; + +class MyResource extends Resource { + public readonly arn: string; + public readonly name: string; + + constructor(scope: Construct, id: string, physicalName: PhysicalName) { + super(scope, id, { + physicalName, + }); + + const resourceIdentifiers = new ResourceIdentifiers(this, { + arn: 'simple-arn', + name: 'simple-name', + arnComponents: { + region: '', + account: '', + resource: 'my-resource', + resourceName: this.physicalName.value, + service: 'myservice', + }, + }); + this.arn = resourceIdentifiers.arn; + this.name = resourceIdentifiers.name; + } +}