diff --git a/packages/@aws-cdk/aws-apigateway/lib/restapi.ts b/packages/@aws-cdk/aws-apigateway/lib/restapi.ts index ec5eaa8bb4dbb..471981550c1dc 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/restapi.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/restapi.ts @@ -377,7 +377,7 @@ export enum EndpointType { Private = 'PRIVATE' } -class ImportedRestApi extends Construct implements IRestApi { +class ImportedRestApi extends Resource implements IRestApi { public restApiId: string; constructor(scope: Construct, id: string, private readonly props: RestApiImportProps) { diff --git a/packages/@aws-cdk/aws-certificatemanager/lib/certificate.ts b/packages/@aws-cdk/aws-certificatemanager/lib/certificate.ts index 0ea1ba629cd74..d2c048fc9275e 100644 --- a/packages/@aws-cdk/aws-certificatemanager/lib/certificate.ts +++ b/packages/@aws-cdk/aws-certificatemanager/lib/certificate.ts @@ -122,7 +122,7 @@ export class Certificate extends Resource implements ICertificate { /** * A Certificate that has been imported from another stack */ -class ImportedCertificate extends Construct implements ICertificate { +class ImportedCertificate extends Resource implements ICertificate { public readonly certificateArn: string; constructor(scope: Construct, id: string, private readonly props: CertificateImportProps) { 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 e303441480945..902b5c759bacc 100644 --- a/packages/@aws-cdk/aws-certificatemanager/lib/dns-validated-certificate.ts +++ b/packages/@aws-cdk/aws-certificatemanager/lib/dns-validated-certificate.ts @@ -18,7 +18,7 @@ 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. */ -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 e21a3d07637ab..7ee818608fe78 100644 --- a/packages/@aws-cdk/aws-codebuild/lib/project.ts +++ b/packages/@aws-cdk/aws-codebuild/lib/project.ts @@ -7,7 +7,15 @@ 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 { Aws, CfnOutput, Construct, Fn, IResource, Resource, Token } from '@aws-cdk/cdk'; +import { + Aws, + CfnOutput, + Construct, + Fn, + IResource, PhysicalName, + Resource, ResourceIdentifiers, + Token +} from '@aws-cdk/cdk'; import { BuildArtifacts, CodePipelineBuildArtifacts, NoBuildArtifacts } from './artifacts'; import { CfnProject } from './codebuild.generated'; import { BuildSource, NoSource, SourceType } from './source'; @@ -455,6 +463,8 @@ export interface CommonProjectProps { */ readonly projectName?: string; + readonly physicalName?: PhysicalName; + /** * VPC network to place codebuild network interfaces * @@ -583,6 +593,7 @@ export class Project extends ProjectBase { } this.role = props.role || new iam.Role(this, 'Role', { + physicalName: PhysicalName.deployTimeOrAssigned(), assumedBy: new iam.ServicePrincipal('codebuild.amazonaws.com') }); this.grantPrincipal = this.role; @@ -657,6 +668,10 @@ export class Project extends ProjectBase { this.validateCodePipelineSettings(artifacts); + const physicalName = props.physicalName + ? props.physicalName + : (props.projectName ? PhysicalName.fixed(props.projectName) : PhysicalName.deployTime()); + const resource = new CfnProject(this, 'Resource', { description: props.description, source: renderSource(), @@ -666,7 +681,7 @@ export class Project extends ProjectBase { encryptionKey: props.encryptionKey && props.encryptionKey.keyArn, badgeEnabled: props.badge, cache, - name: props.projectName, + name: physicalName.asString(), timeoutInMinutes: props.timeout, secondarySources: new Token(() => this.renderSecondarySources()), secondaryArtifacts: new Token(() => this.renderSecondaryArtifacts()), @@ -674,8 +689,19 @@ export class Project extends ProjectBase { vpcConfig: this.configureVpc(props), }); - this.projectArn = resource.projectArn; - this.projectName = resource.projectName; + const resourceIdentifiers = new ResourceIdentifiers({ + resource: this, + resourceSimpleArn: resource.projectArn, + resourceSimpleName: resource.projectName, + physicalName, + arnComponents: { + service: 'codebuild', + resource: 'project', + resourceName: physicalName.asString(), + }, + }); + this.projectArn = resourceIdentifiers.arn; + this.projectName = resourceIdentifiers.name; this.addToRolePolicy(this.createLoggingPermission()); } 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 8a4596d6a2e49..b948a438ec04c 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 @@ -301,7 +301,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 04cf136975672..9d3808a63f32d 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": { @@ -156,48 +103,6 @@ } ] }, - { - "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/*" - ] - ] - } - }, { "Action": [ "sts:AssumeRole", @@ -305,6 +210,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": { 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 2055c76e01e34..32c529a4dcf71 100644 --- a/packages/@aws-cdk/aws-codepipeline-actions/test/test.pipeline.ts +++ b/packages/@aws-cdk/aws-codepipeline-actions/test/test.pipeline.ts @@ -3,6 +3,7 @@ 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'); @@ -585,6 +586,149 @@ export = { test.done(); }, }, + + 'cross-account Pipeline': { + 'with a CodeBuild Project in a different account works correctly'(test: Test) { + const buildAccount = '901234567890'; + const buildStack = new Stack(undefined, 'PipelineStack', { + env: { account: buildAccount }, + }); + const rolePhysicalName = 'ProjectRolePhysicalName'; + const projectRole = new iam.Role(buildStack, 'ProjectRole', { + assumedBy: new iam.ServicePrincipal('codebuild.amazonaws.com'), + roleName: rolePhysicalName, + }); + const projectPhysicalName = 'ProjectPhysicalName'; + const project = new codebuild.PipelineProject(buildStack, 'Project', { + projectName: projectPhysicalName, + role: projectRole, + }); + + const pipelineStack = new Stack(undefined, 'PipelineStack', { + env: { account: '123456789012' }, + }); + const bucket = new s3.Bucket(pipelineStack, 'ArtifactBucket', { + bucketName: 'artifact-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, + 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/PipelineStack-PipelineActionRole-e1866daedfc6`, + ], + ], + }, + }, + ], + }, + ], + })); + + 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:::pipelinestack-artifactsbucket-5a14952fd11d`, + ], + ], + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + `:s3:::pipelinestack-artifactsbucket-5a14952fd11d/*`, + ], + ], + }, + ], + }, + { + "Action": [ + "kms:Decrypt", + "kms:DescribeKey", + "kms:Encrypt", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + ], + "Effect": "Allow", + "Resource": "*", + }, + ], + }, + })); + + test.done(); + }, + }, }; function stageForTesting(stack: Stack): codepipeline.IStage { diff --git a/packages/@aws-cdk/aws-codepipeline/lib/action.ts b/packages/@aws-cdk/aws-codepipeline/lib/action.ts index 84f71a4cc8a1f..d2172959dce48 100644 --- a/packages/@aws-cdk/aws-codepipeline/lib/action.ts +++ b/packages/@aws-cdk/aws-codepipeline/lib/action.ts @@ -1,6 +1,6 @@ import events = require('@aws-cdk/aws-events'); import iam = require('@aws-cdk/aws-iam'); -import { Construct, IResource } from '@aws-cdk/cdk'; +import { Construct, IConstruct, IResource } from '@aws-cdk/cdk'; import { Artifact } from './artifact'; import validation = require('./validation'); @@ -51,6 +51,8 @@ export interface ActionBind { * The IAM Role to add the necessary permissions to. */ readonly role: iam.IRole; + + readonly actionRole?: iam.IRole; } /** @@ -116,6 +118,8 @@ export interface CommonActionProps { * @see https://docs.aws.amazon.com/codepipeline/latest/userguide/reference-pipeline-structure.html */ readonly runOrder?: number; + + readonly roleArn?: string; } /** @@ -147,6 +151,8 @@ export interface ActionProps extends CommonActionProps { readonly configuration?: any; readonly version?: string; readonly owner?: string; + + readonly resource?: IConstruct; } /** @@ -176,6 +182,8 @@ export abstract class Action { */ public readonly region?: string; + public readonly resource?: IConstruct; + /** * 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. @@ -184,15 +192,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. @@ -209,6 +208,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; @@ -225,7 +225,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); @@ -251,6 +252,17 @@ export abstract class Action { return rule; } + /** + * 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 get role(): iam.IRole | undefined { + return this._role; + } + public get inputs(): Artifact[] { return this._actionInputArtifacts.slice(); } @@ -326,6 +338,9 @@ export abstract class Action { this._pipeline = info.pipeline; this._stage = info.stage; this._scope = info.scope; + if (!this._role) { + this._role = info.actionRole; + } this.bind(info); } diff --git a/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts b/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts index 8a46c51fbde88..4b23ca0a51880 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, 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 { CrossRegionScaffoldStack } from './cross-region-scaffold-stack'; @@ -96,6 +96,13 @@ export interface PipelineProps { * You can always add more Stages later by calling {@link Pipeline#addStage}. */ readonly stages?: StageProps[]; + + /** + * The Role to use for this Pipeline. + * + * @default a new Role will be created + */ + readonly role?: iam.IRole; } /** @@ -122,7 +129,7 @@ export class Pipeline extends Resource implements IPipeline { * The IAM role AWS CodePipeline will use to perform actions or assume roles for actions with * a more specific IAM role. */ - public readonly role: iam.Role; + public readonly role: iam.IRole; /** * ARN of this pipeline @@ -147,13 +154,13 @@ export class Pipeline extends Resource implements IPipeline { private readonly stages = new Array(); private eventsRole?: iam.Role; private readonly pipelineResource: CfnPipeline; + private readonly crossAccountActionRoles: Map; private readonly crossRegionReplicationBuckets: { [region: string]: string }; private readonly artifactStores: { [region: string]: any }; 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); @@ -162,6 +169,7 @@ export class Pipeline extends Resource implements IPipeline { if (!propsBucket) { const encryptionKey = new kms.EncryptionKey(this, 'ArtifactsBucketEncryptionKey'); propsBucket = new s3.Bucket(this, 'ArtifactsBucket', { + physicalName: PhysicalName.deployTimeOrAssigned(), encryptionKey, encryption: s3.BucketEncryption.Kms, removalPolicy: RemovalPolicy.Orphan @@ -169,7 +177,7 @@ export class Pipeline extends Resource implements IPipeline { } this.artifactBucket = propsBucket; - this.role = new iam.Role(this, 'Role', { + this.role = props.role || new iam.Role(this, 'Role', { assumedBy: new iam.ServicePrincipal('codepipeline.amazonaws.com') }); @@ -189,6 +197,7 @@ export class Pipeline extends Resource implements IPipeline { this.pipelineName = codePipeline.ref; this.pipelineVersion = codePipeline.pipelineVersion; this.pipelineResource = codePipeline; + this.crossAccountActionRoles = new Map(); this.crossRegionReplicationBuckets = props.crossRegionReplicationBuckets || {}; this.artifactStores = {}; @@ -337,19 +346,27 @@ export class Pipeline extends Resource implements IPipeline { // 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); - } + // handle cross-region Actions here + this.ensureReplicationBucketExistsFor(action.region); + + // handle cross-account Actions here + const actionRole = this.attachActionForCrossAccount(action); + + // call the Action callback which eventually calls bind() (action as any)._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 = this.node.stack.requireRegion( "You need to specify an explicit region when using CodePipeline's cross-region support"); @@ -386,6 +403,47 @@ export class Pipeline extends Resource implements IPipeline { }; } + private attachActionForCrossAccount(action: Action): iam.IRole | undefined { + if (!action.resource) { + return action.role; + } + + const pipelineStack = this.node.stack; + const resourceStack = action.resource.node.stack; + if (pipelineStack.env.account !== resourceStack.env.account) { + 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'); + } + + if (action.role) { + return action.role; + } + + let actionRole = this.crossAccountActionRoles.get(resourceStack); + if (!actionRole) { + actionRole = new iam.Role(resourceStack, this.node.id + 'ActionRole', { + assumedBy: new iam.AccountPrincipal(pipelineStack.env.account), + physicalName: PhysicalName.deployTimeOrAssigned(), + }); + + this.role.addToPolicy(new iam.PolicyStatement() + .addAction('sts:AssumeRole') + .addResource(actionRole.roleArn)); + + pipelineStack.addDependency(resourceStack); + + this.crossAccountActionRoles.set(resourceStack, actionRole); + } + + return actionRole; + } + + return undefined; + } + 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 3df23242c7775..9c005ef007459 100644 --- a/packages/@aws-cdk/aws-cognito/lib/user-pool.ts +++ b/packages/@aws-cdk/aws-cognito/lib/user-pool.ts @@ -496,7 +496,7 @@ export class UserPool extends Resource implements IUserPool { /** * Define a user pool which has been declared in another stack */ -class ImportedUserPool extends Construct implements IUserPool { +class ImportedUserPool extends Resource implements IUserPool { /** * The ID of an existing user pool */ diff --git a/packages/@aws-cdk/aws-ecs/lib/cluster.ts b/packages/@aws-cdk/aws-ecs/lib/cluster.ts index 4266dde63e6ca..a71aa55a6d5ce 100644 --- a/packages/@aws-cdk/aws-ecs/lib/cluster.ts +++ b/packages/@aws-cdk/aws-ecs/lib/cluster.ts @@ -349,7 +349,7 @@ export interface ClusterImportProps { /** * 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-glue/lib/database.ts b/packages/@aws-cdk/aws-glue/lib/database.ts index 31c1953baf26f..c001021d2db40 100644 --- a/packages/@aws-cdk/aws-glue/lib/database.ts +++ b/packages/@aws-cdk/aws-glue/lib/database.ts @@ -140,7 +140,7 @@ export class Database extends Resource { } } -class ImportedDatabase extends Construct implements IDatabase { +class ImportedDatabase extends Resource implements IDatabase { public readonly catalogArn: string; public readonly catalogId: string; public readonly databaseArn: string; diff --git a/packages/@aws-cdk/aws-glue/lib/table.ts b/packages/@aws-cdk/aws-glue/lib/table.ts index e7146df8f1967..b9f92e9fe100d 100644 --- a/packages/@aws-cdk/aws-glue/lib/table.ts +++ b/packages/@aws-cdk/aws-glue/lib/table.ts @@ -398,7 +398,7 @@ function renderColumns(columns?: Array) { }); } -class ImportedTable extends Construct implements ITable { +class ImportedTable extends Resource implements ITable { public readonly tableArn: string; public readonly tableName: string; diff --git a/packages/@aws-cdk/aws-iam/lib/lazy-role.ts b/packages/@aws-cdk/aws-iam/lib/lazy-role.ts index 2af11a3acb1f0..ac0f9abd1ab79 100644 --- a/packages/@aws-cdk/aws-iam/lib/lazy-role.ts +++ b/packages/@aws-cdk/aws-iam/lib/lazy-role.ts @@ -19,7 +19,7 @@ export interface LazyRoleProps extends RoleProps { * place, but if it never gets used it doesn't get instantiated and will * not be synthesized or deployed. */ -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; diff --git a/packages/@aws-cdk/aws-iam/lib/role.ts b/packages/@aws-cdk/aws-iam/lib/role.ts index cfbbc8f10877d..2095c8e1c8dd6 100644 --- a/packages/@aws-cdk/aws-iam/lib/role.ts +++ b/packages/@aws-cdk/aws-iam/lib/role.ts @@ -1,4 +1,9 @@ -import { CfnOutput, Construct, Resource } from '@aws-cdk/cdk'; +import { + CfnOutput, + Construct, + PhysicalName, + Resource, ResourceIdentifiers +} from '@aws-cdk/cdk'; import { Grant } from './grant'; import { CfnRole } from './iam.generated'; import { IIdentity } from './identity-base'; @@ -65,6 +70,8 @@ export interface RoleProps { */ readonly roleName?: string; + readonly physicalName?: PhysicalName; + /** * The maximum session duration (in seconds) that you want to set for the * specified role. If you do not specify a value for this setting, the @@ -143,18 +150,34 @@ export class Role extends Resource implements IRole { validateMaxSessionDuration(props.maxSessionDurationSec); + const physicalName = props.physicalName + ? props.physicalName + : (props.roleName ? PhysicalName.fixed(props.roleName) : PhysicalName.deployTime()); + const role = new CfnRole(this, 'Resource', { assumeRolePolicyDocument: this.assumeRolePolicy as any, managedPolicyArns: undefinedIfEmpty(() => this.managedPolicyArns), policies: _flatten(props.inlinePolicies), path: props.path, - roleName: props.roleName, + roleName: physicalName.asString(), maxSessionDuration: props.maxSessionDurationSec, }); this.roleId = role.roleId; - this.roleArn = role.roleArn; - this.roleName = role.roleName; + const resourceIdentifiers = new ResourceIdentifiers({ + resource: this, + resourceSimpleArn: role.roleArn, + resourceSimpleName: role.roleName, + physicalName, + arnComponents: { + region: '', // IAM is global for each partition + service: 'iam', + resource: 'role', + resourceName: physicalName.asString(), + }, + }); + this.roleArn = resourceIdentifiers.arn; + this.roleName = resourceIdentifiers.name; this.policyFragment = new ArnPrincipal(this.roleArn).policyFragment; function _flatten(policies?: { [name: string]: PolicyDocument }) { @@ -309,7 +332,7 @@ export interface RoleImportProps { /** * A role that already exists */ -class ImportedRole extends Construct implements IRole { +class ImportedRole extends Resource implements IRole { public readonly grantPrincipal: IPrincipal = this; public readonly assumeRoleAction: string = 'sts:AssumeRole'; public readonly policyFragment: PrincipalPolicyFragment; diff --git a/packages/@aws-cdk/aws-lambda/lib/layers.ts b/packages/@aws-cdk/aws-lambda/lib/layers.ts index c506668adf572..c318f31e38eed 100644 --- a/packages/@aws-cdk/aws-lambda/lib/layers.ts +++ b/packages/@aws-cdk/aws-lambda/lib/layers.ts @@ -202,7 +202,7 @@ export interface SingletonLayerVersionProps extends LayerVersionProps { * for the provided ``uuid``. It is recommended to use ``uuidgen`` to create a new ``uuid`` each time a new singleton * layer is created. */ -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 aecf66b7fbc2c..66af8ed6c3681 100644 --- a/packages/@aws-cdk/aws-logs/lib/log-stream.ts +++ b/packages/@aws-cdk/aws-logs/lib/log-stream.ts @@ -97,7 +97,7 @@ export class LogStream extends Resource implements ILogStream { /** * An imported LogStream */ -class ImportedLogStream extends Construct implements ILogStream { +class ImportedLogStream extends Resource implements ILogStream { /** * The name of this log stream */ diff --git a/packages/@aws-cdk/aws-route53/lib/hosted-zone.ts b/packages/@aws-cdk/aws-route53/lib/hosted-zone.ts index 58990e3b912cf..9200c47feccf3 100644 --- a/packages/@aws-cdk/aws-route53/lib/hosted-zone.ts +++ b/packages/@aws-cdk/aws-route53/lib/hosted-zone.ts @@ -177,7 +177,7 @@ export class PrivateHostedZone extends HostedZone { /** * Imported hosted zone */ -class ImportedHostedZone extends Construct implements IHostedZone { +class ImportedHostedZone extends Resource implements IHostedZone { public readonly hostedZoneId: string; public readonly zoneName: string; diff --git a/packages/@aws-cdk/aws-s3/lib/bucket.ts b/packages/@aws-cdk/aws-s3/lib/bucket.ts index a671d0ee43a26..6fbb54caea7ad 100644 --- a/packages/@aws-cdk/aws-s3/lib/bucket.ts +++ b/packages/@aws-cdk/aws-s3/lib/bucket.ts @@ -2,7 +2,15 @@ import events = require('@aws-cdk/aws-events'); import iam = require('@aws-cdk/aws-iam'); import kms = require('@aws-cdk/aws-kms'); import { IBucketNotificationDestination } from '@aws-cdk/aws-s3-notifications'; -import { applyRemovalPolicy, CfnOutput, Construct, IResource, RemovalPolicy, Resource, Token } from '@aws-cdk/cdk'; +import { + applyRemovalPolicy, + CfnOutput, + Construct, + IResource, PhysicalName, PhysicalNameGenerator, + RemovalPolicy, + Resource, ResourceIdentifiers, + Token +} from '@aws-cdk/cdk'; import { EOL } from 'os'; import { BucketPolicy } from './bucket-policy'; import { BucketNotifications } from './notifications-resource'; @@ -486,18 +494,87 @@ export 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) { + ret = iam.Grant.addToPrincipalAndResource({ + grantee, + actions: bucketActions, + resourceArns: resources, + resource: this, + }); + } else { + 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; + + // this is the old code... + /*const bucketStack = this.node.stack; + const identityStack = identity.node.stack; + const crossAccountAccess = bucketStack.env.account !== identityStack.env.account; + + identity.addToPolicy(new iam.PolicyStatement() + .addResources(...resources) + .addActions(...bucketActions)); + + if (crossAccountAccess) { + this.addToResourcePolicy(new iam.PolicyStatement() + .addResources(...resources) + .addActions(...bucketActions) + .addPrincipal(identity.principal)); + } + + // grant key permissions if there's an associated key. + if (this.encryptionKey) { + // KMS permissions need to be granted both directions + this.encryptionKey.addToResourcePolicy(new iam.PolicyStatement() + .addAllResources() + .addPrincipal(identity.principal) + .addActions(...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 + identity.addToPolicy(new iam.PolicyStatement() + .addAllResources() + .addActions(...keyActions)); + } else { + identity.addToPolicy(new iam.PolicyStatement() + .addResource(this.encryptionKey.keyArn) + .addActions(...keyActions)); + } + }*/ + } + + private isGranteeFromAnotherAccount(grantee: iam.IGrantable): boolean { + if (!(grantee instanceof Construct)) { + return false; + } + const c = grantee as Construct; + const bucketStack = this.node.stack; + const identityStack = c.node.stack; + return bucketStack.env.account !== identityStack.env.account; } } @@ -587,6 +664,8 @@ export interface BucketProps { */ readonly bucketName?: string; + readonly physicalName?: PhysicalName; + /** * Policy to apply when the bucket is removed from this stack. * @@ -674,8 +753,12 @@ export class Bucket extends BucketBase { this.validateBucketName(props.bucketName); } + const physicalName = props.physicalName + ? props.physicalName + : (props.bucketName ? PhysicalName.fixed(props.bucketName) : PhysicalName.deployTime()); + const resource = new CfnBucket(this, 'Resource', { - bucketName: props && props.bucketName, + bucketName: physicalName.asString(), bucketEncryption, versioningConfiguration: props.versioned ? { status: 'Enabled' } : undefined, lifecycleConfiguration: new Token(() => this.parseLifecycleConfiguration()), @@ -687,8 +770,21 @@ export class Bucket extends BucketBase { this.versioned = props.versioned; this.encryptionKey = encryptionKey; - this.bucketArn = resource.bucketArn; - this.bucketName = resource.bucketName; + const resourceIdentifiers = new ResourceIdentifiers({ + resource: this, + resourceSimpleArn: resource.bucketArn, + resourceSimpleName: resource.bucketName, + physicalName, + arnComponents: { + region: '', + account: '', + service: 's3', + resource: physicalName.asString() || '', + }, + }); + this.bucketArn = resourceIdentifiers.arn; + this.bucketName = resourceIdentifiers.name; + this.domainName = resource.bucketDomainName; this.bucketWebsiteUrl = resource.bucketWebsiteUrl; this.dualstackDomainName = resource.bucketDualStackDomainName; @@ -780,6 +876,10 @@ export class Bucket extends BucketBase { return this.onEvent(EventType.ObjectRemoved, dest, ...filters); } + public physicalNameGenerator(): PhysicalNameGenerator { + return super.physicalNameGenerator().toLowerCase(); + } + private validateBucketName(bucketName: string) { const errors: string[] = []; diff --git a/packages/@aws-cdk/aws-s3/test/test.bucket.ts b/packages/@aws-cdk/aws-s3/test/test.bucket.ts index eb23be105fc90..144c0213ab5ac 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'); @@ -1130,115 +1130,279 @@ export = { test.done(); }, - 'cross-stack permissions'(test: Test) { - const stackA = new cdk.Stack(); - const bucketFromStackA = new s3.Bucket(stackA, 'MyBucket'); - const refToBucketFromStackA = bucketFromStackA.export(); + 'cross-stack permissions': { + 'in the same account and region'(test: Test) { + const stackA = new cdk.Stack(); + const bucketFromStackA = new s3.Bucket(stackA, 'MyBucket'); + const refToBucketFromStackA = bucketFromStackA.export(); - const stackB = new cdk.Stack(); - const user = new iam.User(stackB, 'UserWhoNeedsAccess'); - const theBucketFromStackAAsARefInStackB = s3.Bucket.import(stackB, 'RefToBucketFromStackA', refToBucketFromStackA); - theBucketFromStackAAsARefInStackB.grantRead(user); + const stackB = new cdk.Stack(); + const user = new iam.User(stackB, 'UserWhoNeedsAccess'); + const theBucketFromStackAAsARefInStackB = s3.Bucket.import(stackB, 'RefToBucketFromStackA', refToBucketFromStackA); + theBucketFromStackAAsARefInStackB.grantRead(user); - expect(stackA).toMatch({ - "Resources": { - "MyBucketF68F3FF0": { - "Type": "AWS::S3::Bucket", - "DeletionPolicy": "Retain", - } - }, - "Outputs": { - "MyBucketBucketArnE260558C": { - "Value": { - "Fn::GetAtt": [ - "MyBucketF68F3FF0", - "Arn" - ] - }, - "Export": { - "Name": "Stack:MyBucketBucketArnE260558C" - } - }, - "MyBucketBucketName8A027014": { - "Value": { - "Ref": "MyBucketF68F3FF0" + expect(stackA).toMatch({ + "Resources": { + "MyBucketF68F3FF0": { + "Type": "AWS::S3::Bucket", + "DeletionPolicy": "Retain", + } }, - "Export": { - "Name": "Stack:MyBucketBucketName8A027014" + "Outputs": { + "MyBucketBucketArnE260558C": { + "Value": { + "Fn::GetAtt": [ + "MyBucketF68F3FF0", + "Arn" + ] + }, + "Export": { + "Name": "Stack:MyBucketBucketArnE260558C" + } + }, + "MyBucketBucketName8A027014": { + "Value": { + "Ref": "MyBucketF68F3FF0" + }, + "Export": { + "Name": "Stack:MyBucketBucketName8A027014" + } + }, + "MyBucketDomainNameF76B9A7A": { + "Value": { + "Fn::GetAtt": [ + "MyBucketF68F3FF0", + "DomainName" + ] + }, + "Export": { + "Name": "Stack:MyBucketDomainNameF76B9A7A" + } + }, + "MyBucketWebsiteURL9C222788": { + "Value": { + "Fn::GetAtt": [ + "MyBucketF68F3FF0", + "WebsiteURL" + ] + }, + "Export": {"Name": "Stack:MyBucketWebsiteURL9C222788"} + } } - }, - "MyBucketDomainNameF76B9A7A": { - "Value": { - "Fn::GetAtt": [ - "MyBucketF68F3FF0", - "DomainName" - ] - }, - "Export": { - "Name": "Stack:MyBucketDomainNameF76B9A7A" + }); + + expect(stackB).toMatch({ + "Resources": { + "UserWhoNeedsAccessF8959C3D": { + "Type": "AWS::IAM::User" + }, + "UserWhoNeedsAccessDefaultPolicy6A9EB530": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::ImportValue": "Stack:MyBucketBucketArnE260558C" + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::ImportValue": "Stack:MyBucketBucketArnE260558C" + }, + "/*" + ] + ] + } + ] + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "UserWhoNeedsAccessDefaultPolicy6A9EB530", + "Users": [ + { + "Ref": "UserWhoNeedsAccessF8959C3D" + } + ] + } + } } - }, - "MyBucketWebsiteURL9C222788": { - "Value": { - "Fn::GetAtt": [ - "MyBucketF68F3FF0", - "WebsiteURL" - ] - }, - "Export": {"Name": "Stack:MyBucketWebsiteURL9C222788"} - } - } - }); + }); - expect(stackB).toMatch({ - "Resources": { - "UserWhoNeedsAccessF8959C3D": { - "Type": "AWS::IAM::User" - }, - "UserWhoNeedsAccessDefaultPolicy6A9EB530": { - "Type": "AWS::IAM::Policy", - "Properties": { + 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', { + physicalName: cdk.PhysicalName.fixed('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: 'MyRolePhysicalName', + }); + + // when + bucketFromStackA.grantRead(roleFromStackB); + + // then + expect(stackA).to(haveResourceLike('AWS::S3::BucketPolicy', { "PolicyDocument": { "Statement": [ - { - "Action": [ - "s3:GetObject*", - "s3:GetBucket*", - "s3:List*" - ], - "Effect": "Allow", - "Resource": [ { - "Fn::ImportValue": "Stack:MyBucketBucketArnE260558C" + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*", + ], + "Effect": "Allow", + "Principal": { + "AWS": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":iam::234567890123:role/MyRolePhysicalName", + ], + ], + }, + }, }, + ], + }, + })); + + expect(stackB).to(haveResourceLike('AWS::IAM::Policy', { + "PolicyDocument": { + "Statement": [ { - "Fn::Join": [ - "", - [ + "Action": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*", + ], + "Effect": "Allow", + "Resource": [ { - "Fn::ImportValue": "Stack:MyBucketBucketArnE260558C" + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":s3:::my-bucket-physical-name", + ], + ], }, - "/*" - ] - ] - } - ] - } + { + "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.EncryptionKey(stackA, 'MyKey'); + const bucketFromStackA = new s3.Bucket(stackA, 'MyBucket', { + bucketName: '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: '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 64afd187d9b9c..8039245204fdf 100644 --- a/packages/@aws-cdk/aws-ses/lib/receipt-rule.ts +++ b/packages/@aws-cdk/aws-ses/lib/receipt-rule.ts @@ -172,7 +172,7 @@ export interface ReceiptRuleImportProps { /** * An imported receipt rule. */ -class ImportedReceiptRule extends Construct implements IReceiptRule { +class ImportedReceiptRule extends Resource implements IReceiptRule { public readonly name: string; constructor(scope: Construct, id: string, private readonly props: ReceiptRuleImportProps) { diff --git a/packages/@aws-cdk/cdk/lib/cfn-reference.ts b/packages/@aws-cdk/cdk/lib/cfn-reference.ts index 24cb71b37aba9..2aba71bf96fd1 100644 --- a/packages/@aws-cdk/cdk/lib/cfn-reference.ts +++ b/packages/@aws-cdk/cdk/lib/cfn-reference.ts @@ -35,6 +35,7 @@ export class CfnReference extends Reference { private readonly replacementTokens: Map; private readonly originalDisplayName: string; + private readonly humanReadableDesc: string; constructor(value: any, displayName: string, target: Construct) { if (typeof(value) === 'function') { @@ -44,6 +45,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 = target.node.stack; Object.defineProperty(this, CFN_REFERENCE_SYMBOL, { value: true }); @@ -80,7 +82,7 @@ export class CfnReference extends Reference { const producingStack = this.producingStack!; if (producingStack.env.account !== consumingStack.env.account || producingStack.env.region !== consumingStack.env.region) { - throw new Error('Can only reference cross stacks in the same region and account.'); + throw new Error(`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..37f8fbe7174a8 --- /dev/null +++ b/packages/@aws-cdk/cdk/lib/cross-environment-token.ts @@ -0,0 +1,66 @@ +import { ArnComponents } from './arn'; +import { DeployTimeOrAssignedPhysicalName } from "./deploy-time-or-assigned-physical-name"; +import { PhysicalName } from './physical-name'; +import { IResource } from './resource'; +import { ResolveContext, 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; + private readonly physicalName: PhysicalName; + + /** + * @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 physicalName + * @param displayName a short name to be used in Token display + */ + protected constructor(private readonly regularValue: string, private readonly crossEnvironmentValue: any, + resource: IResource, physicalName: PhysicalName, displayName: string) { + super(undefined, displayName); + + this.resource = resource; + this.physicalName = physicalName; + } + + public resolve(context: ResolveContext): any { + const consumingStack = context.scope.node.stack; + const owningStack = this.resource.node.stack; + + if (consumingStack.env.account !== owningStack.env.account || + consumingStack.env.region !== owningStack.env.region) { + // check whether the resource has a physical name set + if (!this.physicalName.availableAtSynthesisTime) { + if (this.physicalName instanceof DeployTimeOrAssignedPhysicalName) { + this.physicalName._setName(this.resource.physicalNameGenerator().generate()); + } else { + throw new Error(`Cannot use resource '${this.resource.node.path}' in a cross-environment fashion, ` + + "as it doesn't have a physical name set"); + } + } + return this.crossEnvironmentValue; + } else { + return this.regularValue; + } + } +} + +export class CrossEnvironmentPhysicalArnToken extends CrossEnvironmentToken { + constructor(regularValue: string, arnComponents: ArnComponents, resource: IResource, physicalName: PhysicalName, + displayName: string = 'Arn') { + super(regularValue, resource.node.stack.formatArn(arnComponents), resource, physicalName, displayName); + } +} + +export class CrossEnvironmentPhysicalNameToken extends CrossEnvironmentToken { + constructor(regularValue: string, resource: IResource, physicalName: PhysicalName, displayName: string = 'Ref') { + super(regularValue, physicalName.asString(), resource, physicalName, displayName); + } +} diff --git a/packages/@aws-cdk/cdk/lib/deploy-time-or-assigned-physical-name.ts b/packages/@aws-cdk/cdk/lib/deploy-time-or-assigned-physical-name.ts new file mode 100644 index 0000000000000..ab07415f35bfc --- /dev/null +++ b/packages/@aws-cdk/cdk/lib/deploy-time-or-assigned-physical-name.ts @@ -0,0 +1,28 @@ +import { PhysicalName } from './physical-name'; +import { Token } from './token'; + +/** + * A class that allows assigning a physical name if the given + * resource is used in a cross-environment manner. + * Separated into its own file so that it can be exported, + * as it's used in {@link CrossEnvironmentToken}, + * but still kept private to the @aws-cdk/cdk package. + */ +export class DeployTimeOrAssignedPhysicalName extends PhysicalName { + private name?: string; + + /** @internal */ + public _setName(name: string): void { + this.name = name; + } + + public get availableAtSynthesisTime() { + return this.name !== undefined; + } + + public asString(): string | undefined { + return new Token(() => { + return this.name; + }).toString(); + } +} diff --git a/packages/@aws-cdk/cdk/lib/index.ts b/packages/@aws-cdk/cdk/lib/index.ts index 5c7a51fb302c7..000e999d52224 100644 --- a/packages/@aws-cdk/cdk/lib/index.ts +++ b/packages/@aws-cdk/cdk/lib/index.ts @@ -37,3 +37,6 @@ export * from './secret-value'; export * from './synthesis'; export * from './resource'; +export * from './physical-name'; +export * from './resource-identifiers'; +export * from './util/physical-name-generator'; 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..5f076fa1bdf3d --- /dev/null +++ b/packages/@aws-cdk/cdk/lib/physical-name.ts @@ -0,0 +1,80 @@ +/** + * Represents the human-readable, physical 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 #deployTime() + * @see #deployTimeOrAssigned() + * @see #fixed() + */ +export abstract class PhysicalName { + /** + * A physical name that will be automatically assigned at deploy time. + * This is the default you don't specify a physical name. + */ + public static deployTime(): PhysicalName { + return new DeployTimePhysicalName(); + } + + /** + * A physical name that will be assigned at synthesis time if the resource + * is used in a cross-environment manner, + * and at deploy time otherwise. + * This is the default name implicitly created sub-resources + * (like IAM Roles) are created with in the Construct Library. + * If you're using a Construct in a cross-environment manner, + * you need to use either this name, + * or a {@link #fixed() fixed name}. + */ + public static deployTimeOrAssigned(): PhysicalName { + return new DeployTimeOrAssignedPhysicalName(); + } + + /** + * A fixed physical name (one that is known statically, at synthesis time). + * + * @param name the name to assign + */ + public static fixed(name: string): PhysicalName { + return new FixedPhysicalName(name); + } + + public abstract availableAtSynthesisTime: boolean; + + protected constructor() { + } + + public abstract asString(): string | undefined; +} + +// implementations are private to this module, +// we only surface them through static factory methods + +class DeployTimePhysicalName extends PhysicalName { + public readonly availableAtSynthesisTime = false; + + public asString(): string | undefined { + return undefined; + } +} + +class FixedPhysicalName extends PhysicalName { + public readonly availableAtSynthesisTime = true; + private readonly name: string; + + constructor(name: string) { + super(); + + this.name = name; + } + + public asString(): string | undefined { + return this.name; + } +} + +// this import needs to be on the bottom because of dependency cycles +import { DeployTimeOrAssignedPhysicalName } from './deploy-time-or-assigned-physical-name'; 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..b1978bf8a9cdb --- /dev/null +++ b/packages/@aws-cdk/cdk/lib/resource-identifiers.ts @@ -0,0 +1,58 @@ +import { ArnComponents } from './arn'; +import { CrossEnvironmentPhysicalArnToken, CrossEnvironmentPhysicalNameToken } from './cross-environment-token'; +import { PhysicalName } from './physical-name'; +import { Resource } from './resource'; + +/** + * Construction properties for {@link ResourceIdentifiers}. + */ +export interface ResourceIdentifiersProps { + /** + * The {@link Resource} instance we're defining identifiers for. + */ + readonly resource: Resource; + + /** + * The ARN of the resource when referenced from the same CloudFormation template. + */ + readonly resourceSimpleArn: string; + + /** + * The name of the resource when referenced from the same CloudFormation template. + */ + readonly resourceSimpleName: string; + + /** + * The physical name, as provided by the customer when creating the resource. + */ + readonly physicalName: PhysicalName; + + /** + * 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(props: ResourceIdentifiersProps) { + this.arn = new CrossEnvironmentPhysicalArnToken( + props.resourceSimpleArn, + props.arnComponents, + props.resource, + props.physicalName, + ).toString(); + + this.name = new CrossEnvironmentPhysicalNameToken( + props.resourceSimpleName, + props.resource, + props.physicalName, + ).toString(); + } +} diff --git a/packages/@aws-cdk/cdk/lib/resource.ts b/packages/@aws-cdk/cdk/lib/resource.ts index 07c189f52ba9e..fe69adea7a5dd 100644 --- a/packages/@aws-cdk/cdk/lib/resource.ts +++ b/packages/@aws-cdk/cdk/lib/resource.ts @@ -1,16 +1,27 @@ import { Construct, IConstruct } from './construct'; +import { PhysicalNameGenerator } from "./util/physical-name-generator"; /** * Interface for the Resource construct. */ -// tslint:disable-next-line:no-empty-interface export interface IResource extends IConstruct { - + physicalNameGenerator(): PhysicalNameGenerator; } /** * A construct which represents an AWS resource. */ export abstract class Resource extends Construct implements IResource { - + /** + * Returns the {@link PhysicalNameGenerator} for this Construct, + * which will be used in {@link #assignPhysicalNameIfNotSet()} + * if the name parameter was not provided. + * The implementation in {@link Construct} returns a default generator; + * subclasses with particular requirements for physical names are expected to override this method, + * possibly by calling `super` and changing the resulting generator, + * to fit their needs. + */ + public physicalNameGenerator(): PhysicalNameGenerator { + return PhysicalNameGenerator.from(this); + } } diff --git a/packages/@aws-cdk/cdk/lib/util/physical-name-generator.ts b/packages/@aws-cdk/cdk/lib/util/physical-name-generator.ts new file mode 100644 index 0000000000000..421a94b7ae870 --- /dev/null +++ b/packages/@aws-cdk/cdk/lib/util/physical-name-generator.ts @@ -0,0 +1,58 @@ +import crypto = require('crypto'); +import { IResource } from "../resource"; +import { Stack } from '../stack'; + +export class PhysicalNameGenerator { + public static from(resource: IResource): PhysicalNameGenerator { + return new PhysicalNameGenerator( + new NamePart(resource.node.stack.name, 25), + new NamePart(resource.node.id, 24), + resource.node.stack); + } + + private readonly stackPart: NamePart; + private readonly idPart: NamePart; + private readonly stack: Stack; + private readonly hashLength: number; + private changeToLowerCase = false; + + private constructor(stackPart: NamePart, idPart: NamePart, stack: Stack) { + this.stackPart = stackPart; + this.idPart = idPart; + this.stack = stack; + this.hashLength = 12; + } + + public generate(): string { + const sha256 = crypto.createHash('sha256') + .update(this.stackPart.str) + .update(this.idPart.str) + .update(this.stack.env.region || '') + .update(this.stack.env.account || ''); + + const hash = sha256.digest('hex').slice(0, this.hashLength); + + const parts = [this.stackPart, this.idPart] + .map(part => part.generate()); + + const ret = [...parts, hash] + .filter(part => part.length > 0) + .join('-'); + + return this.changeToLowerCase ? ret.toLowerCase() : ret; + } + + public toLowerCase(): PhysicalNameGenerator { + this.changeToLowerCase = true; + return this; + } +} + +class NamePart { + constructor(public readonly str: string, public readonly maxLength: number) { + } + + public generate(): string { + return this.str.slice(0, this.maxLength); + } +} 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..ae328067c58f6 --- /dev/null +++ b/packages/@aws-cdk/cdk/test/test.cross-environment-token.ts @@ -0,0 +1,188 @@ +import { Test } from 'nodeunit'; +import { App, CfnOutput, Construct, PhysicalName, Resource, ResourceIdentifiers, Stack } from '../lib'; + +// 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.fixed('PhysicalName')); + + const stack2 = new Stack(app, 'Stack2', { + env: { + account: '234567890123', + }, + }); + + // WHEN + new CfnOutput(stack2, 'Output', { + value: myResource.arn, + }); + + // THEN + app.node.prepareTree(); + test.deepEqual(stack2._toCloudFormation(), { + Outputs: { + Output: { + Value: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':myservice:::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.fixed('PhysicalName')); + new CfnOutput(stack2, 'Output', { + value: myResource.name, + }); + + // THEN + app.node.prepareTree(); + test.deepEqual(stack2._toCloudFormation(), { + 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.deployTimeOrAssigned()); + + const stack2 = new Stack(app, 'Stack2', { + env: { + account: '234567890123', + }, + }); + + // WHEN + new CfnOutput(stack2, 'Output', { + value: myResource.arn, + }); + + // THEN + app.node.prepareTree(); + test.deepEqual(stack2._toCloudFormation(), { + Outputs: { + Output: { + Value: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':myservice:::Stack1-MyResource-ba494bcf759e', + ], + ], + }, + }, + }, + }); + + 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.deployTimeOrAssigned()); + new CfnOutput(stack2, 'Output', { + value: myResource.name, + }); + + // THEN + app.node.prepareTree(); + test.deepEqual(stack2._toCloudFormation(), { + Outputs: { + Output: { + Value: 'Stack1-MyResource-ba494bcf759e', + }, + }, + }); + + test.done(); + }, + }, +}; + +class MyResource extends Resource { + public readonly arn: string; + public readonly name: string; + + constructor(scope: Construct, id: string, physicalName: PhysicalName) { + super(scope, id); + + const resourceIdentifiers = new ResourceIdentifiers({ + resource: this, + resourceSimpleArn: 'simple-arn', + resourceSimpleName: 'simple-name', + physicalName, + arnComponents: { + region: '', + account: '', + resource: physicalName.asString() || 'physical-name', + service: 'myservice', + }, + }); + this.arn = resourceIdentifiers.arn; + this.name = resourceIdentifiers.name; + } +}