diff --git a/packages/@aws-cdk/aws-cloudformation/lib/pipeline-actions.ts b/packages/@aws-cdk/aws-cloudformation/lib/pipeline-actions.ts index 372aab93934ce..cf271353ea47a 100644 --- a/packages/@aws-cdk/aws-cloudformation/lib/pipeline-actions.ts +++ b/packages/@aws-cdk/aws-cloudformation/lib/pipeline-actions.ts @@ -88,6 +88,11 @@ export class PipelineExecuteChangeSetAction extends PipelineCloudFormationAction ActionMode: 'CHANGE_SET_EXECUTE', ChangeSetName: props.changeSetName, }); + + props.stage.pipelineRole.addToPolicy(new cdk.PolicyStatement() + .addAction('cloudformation:ExecuteChangeSet') + .addResource(stackArnFromName(props.stackName)) + .addCondition('StringEquals', { 'cloudformation:ChangeSetName': props.changeSetName })); } } @@ -243,6 +248,24 @@ export class PipelineCreateReplaceChangeSetAction extends PipelineCloudFormation }); this.addInputArtifact(props.templatePath.artifact); + if (props.templateConfiguration && props.templateConfiguration.artifact.name !== props.templatePath.artifact.name) { + this.addInputArtifact(props.templateConfiguration.artifact); + } + + const stackArn = stackArnFromName(props.stackName); + // Allow the pipeline to check for Stack & ChangeSet existence + props.stage.pipelineRole.addToPolicy(new cdk.PolicyStatement() + .addAction('cloudformation:DescribeStacks') + .addResource(stackArn)); + // Allow the pipeline to create & delete the specified ChangeSet + props.stage.pipelineRole.addToPolicy(new cdk.PolicyStatement() + .addActions('cloudformation:CreateChangeSet', 'cloudformation:DeleteChangeSet', 'cloudformation:DescribeChangeSet') + .addResource(stackArn) + .addCondition('StringEquals', { 'cloudformation:ChangeSetName': props.changeSetName })); + // Allow the pipeline to pass this actions' role to CloudFormation + props.stage.pipelineRole.addToPolicy(new cdk.PolicyStatement() + .addAction('iam:PassRole') + .addResource(this.role.roleArn)); } } @@ -337,3 +360,11 @@ export enum CloudFormationCapabilities { */ NamedIAM = 'CAPABILITY_NAMED_IAM' } + +function stackArnFromName(stackName: string): string { + return cdk.ArnUtils.fromComponents({ + service: 'cloudformation', + resource: 'stack', + resourceName: `${stackName}/*` + }); +} diff --git a/packages/@aws-cdk/aws-cloudformation/package-lock.json b/packages/@aws-cdk/aws-cloudformation/package-lock.json index e06fd2d030fd6..bdd10f16c02bd 100644 --- a/packages/@aws-cdk/aws-cloudformation/package-lock.json +++ b/packages/@aws-cdk/aws-cloudformation/package-lock.json @@ -1,5 +1,16 @@ { - "name": "@aws-cdk/aws-cloudformation", - "version": "0.9.0", - "lockfileVersion": 1 + "requires": true, + "lockfileVersion": 1, + "dependencies": { + "@types/lodash": { + "version": "4.14.116", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.116.tgz", + "integrity": "sha512-lRnAtKnxMXcYYXqOiotTmJd74uawNWuPnsnPrrO7HiFuE3npE2iQhfABatbYDyxTNqZNuXzcKGhw37R7RjBFLg==" + }, + "lodash": { + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", + "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==" + } + } } diff --git a/packages/@aws-cdk/aws-cloudformation/package.json b/packages/@aws-cdk/aws-cloudformation/package.json index 82acb6edfdf06..f2355394ad050 100644 --- a/packages/@aws-cdk/aws-cloudformation/package.json +++ b/packages/@aws-cdk/aws-cloudformation/package.json @@ -57,9 +57,11 @@ "license": "Apache-2.0", "devDependencies": { "@aws-cdk/assert": "^0.10.0", + "@types/lodash": "^4.14.116", "cdk-build-tools": "^0.10.0", "cdk-integ-tools": "^0.10.0", "cfn2ts": "^0.10.0", + "lodash": "^4.17.11", "pkglint": "^0.10.0" }, "dependencies": { diff --git a/packages/@aws-cdk/aws-cloudformation/test/test.cloudformation.ts b/packages/@aws-cdk/aws-cloudformation/test/test.cloudformation.ts deleted file mode 100644 index 820f6b467f38f..0000000000000 --- a/packages/@aws-cdk/aws-cloudformation/test/test.cloudformation.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Test, testCase } from 'nodeunit'; - -export = testCase({ - notTested(test: Test) { - test.ok(true, 'No tests are specified for this package.'); - test.done(); - } -}); diff --git a/packages/@aws-cdk/aws-cloudformation/test/test.pipeline-actions.ts b/packages/@aws-cdk/aws-cloudformation/test/test.pipeline-actions.ts new file mode 100644 index 0000000000000..c251c63c78bf8 --- /dev/null +++ b/packages/@aws-cdk/aws-cloudformation/test/test.pipeline-actions.ts @@ -0,0 +1,181 @@ +import cpapi = require('@aws-cdk/aws-codepipeline-api'); +import iam = require('@aws-cdk/aws-iam'); +import cdk = require('@aws-cdk/cdk'); +import _ = require('lodash'); +import nodeunit = require('nodeunit'); +import cloudformation = require('../lib'); + +export = nodeunit.testCase({ + CreateReplaceChangeSet: { + works(test: nodeunit.Test) { + const stack = new cdk.Stack(); + const pipelineRole = new RoleDouble(stack, 'PipelineRole'); + const stage = new StageDouble({ pipelineRole }); + const artifact = new cpapi.Artifact(stack as any, 'TestArtifact'); + const action = new cloudformation.PipelineCreateReplaceChangeSetAction(stack, 'Action', { + stage, + changeSetName: 'MyChangeSet', + stackName: 'MyStack', + templatePath: artifact.atPath('path/to/file') + }); + + _assertPermissionGranted(test, pipelineRole.statements, 'iam:PassRole', action.role.roleArn); + + const stackArn = cdk.ArnUtils.fromComponents({ + service: 'cloudformation', + resource: 'stack', + resourceName: 'MyStack/*' + }); + const changeSetCondition = { StringEquals: { 'cloudformation:ChangeSetName': 'MyChangeSet' } }; + _assertPermissionGranted(test, pipelineRole.statements, 'cloudformation:DescribeStacks', stackArn); + _assertPermissionGranted(test, pipelineRole.statements, 'cloudformation:DescribeChangeSet', stackArn, changeSetCondition); + _assertPermissionGranted(test, pipelineRole.statements, 'cloudformation:CreateChangeSet', stackArn, changeSetCondition); + _assertPermissionGranted(test, pipelineRole.statements, 'cloudformation:DeleteChangeSet', stackArn, changeSetCondition); + + test.deepEqual(action.inputArtifacts, [artifact], + 'The inputArtifact was correctly registered'); + + _assertActionMatches(test, stage.actions, 'AWS', 'CloudFormation', 'Deploy', { + ActionMode: 'CHANGE_SET_CREATE_REPLACE', + StackName: 'MyStack', + ChangeSetName: 'MyChangeSet' + }); + + test.done(); + } + }, + ExecuteChangeSet: { + works(test: nodeunit.Test) { + const stack = new cdk.Stack(); + const pipelineRole = new RoleDouble(stack, 'PipelineRole'); + const stage = new StageDouble({ pipelineRole }); + new cloudformation.PipelineExecuteChangeSetAction(stack, 'Action', { + stage, + changeSetName: 'MyChangeSet', + stackName: 'MyStack', + }); + + const stackArn = cdk.ArnUtils.fromComponents({ + service: 'cloudformation', + resource: 'stack', + resourceName: 'MyStack/*' + }); + _assertPermissionGranted(test, pipelineRole.statements, 'cloudformation:ExecuteChangeSet', stackArn, + { StringEquals: { 'cloudformation:ChangeSetName': 'MyChangeSet' } }); + + _assertActionMatches(test, stage.actions, 'AWS', 'CloudFormation', 'Deploy', { + ActionMode: 'CHANGE_SET_EXECUTE', + StackName: 'MyStack', + ChangeSetName: 'MyChangeSet' + }); + + test.done(); + } + } +}); + +interface PolicyStatementJson { + Effect: 'Allow' | 'Deny'; + Action: string | string[]; + Resource: string | string[]; + Condition: any; +} + +function _assertActionMatches(test: nodeunit.Test, + actions: cpapi.Action[], + owner: string, + provider: string, + category: string, + configuration?: { [key: string]: any }) { + const configurationStr = configuration + ? `configuration including ${JSON.stringify(cdk.resolve(configuration), null, 2)}` + : ''; + const actionsStr = JSON.stringify(actions.map(a => + ({ owner: a.owner, provider: a.provider, category: a.category, configuration: cdk.resolve(a.configuration) }) + ), null, 2); + test.ok(_hasAction(actions, owner, provider, category, configuration), + `Expected to find an action with owner ${owner}, provider ${provider}, category ${category}${configurationStr}, but found ${actionsStr}`); +} + +function _hasAction(actions: cpapi.Action[], owner: string, provider: string, category: string, configuration?: { [key: string]: any}) { + for (const action of actions) { + if (action.owner !== owner) { continue; } + if (action.provider !== provider) { continue; } + if (action.category !== category) { continue; } + if (configuration && !action.configuration) { continue; } + if (configuration) { + for (const key of Object.keys(configuration)) { + if (!_.isEqual(cdk.resolve(action.configuration[key]), cdk.resolve(configuration[key]))) { + continue; + } + } + } + return true; + } + return false; +} + +function _assertPermissionGranted(test: nodeunit.Test, statements: PolicyStatementJson[], action: string, resource: string, conditions?: any) { + const conditionStr = conditions + ? ` with condition(s) ${JSON.stringify(cdk.resolve(conditions))}` + : ''; + const statementsStr = JSON.stringify(cdk.resolve(statements), null, 2); + test.ok(_grantsPermission(statements, action, resource, conditions), + `Expected to find a statement granting ${action} on ${cdk.resolve(resource)}${conditionStr}, found:\n${statementsStr}`); +} + +function _grantsPermission(statements: PolicyStatementJson[], action: string, resource: string, conditions?: any) { + for (const statement of statements.filter(s => s.Effect === 'Allow')) { + if (!_isOrContains(statement.Action, action)) { continue; } + if (!_isOrContains(statement.Resource, resource)) { continue; } + if (conditions && !_isOrContains(statement.Condition, conditions)) { continue; } + return true; + } + return false; +} + +function _isOrContains(entity: string | string[], value: string): boolean { + const resolvedValue = cdk.resolve(value); + const resolvedEntity = cdk.resolve(entity); + if (_.isEqual(resolvedEntity, resolvedValue)) { return true; } + if (!Array.isArray(resolvedEntity)) { return false; } + for (const tested of entity) { + if (_.isEqual(tested, resolvedValue)) { return true; } + } + return false; +} + +class StageDouble implements cpapi.IStage { + public readonly name: string; + public readonly pipelineArn: string; + public readonly pipelineRole: iam.Role; + + public readonly actions = new Array(); + + constructor({ name, pipelineName, pipelineRole }: { name?: string, pipelineName?: string, pipelineRole: iam.Role }) { + this.name = name || 'TestStage'; + this.pipelineArn = cdk.ArnUtils.fromComponents({ service: 'codepipeline', resource: 'pipeline', resourceName: pipelineName || 'TestPipeline' }); + this.pipelineRole = pipelineRole; + } + + public grantPipelineBucketReadWrite() { + throw new Error('Unsupported'); + } + + public _attachAction(action: cpapi.Action) { + this.actions.push(action); + } +} + +class RoleDouble extends iam.Role { + public readonly statements = new Array(); + + constructor(parent: cdk.Construct, id: string, props: iam.RoleProps = { assumedBy: new cdk.ServicePrincipal('test') }) { + super(parent, id, props); + } + + public addToPolicy(statement: cdk.PolicyStatement) { + super.addToPolicy(statement); + this.statements.push(statement.toJson()); + } +} diff --git a/packages/@aws-cdk/aws-codepipeline-api/lib/artifact.ts b/packages/@aws-cdk/aws-codepipeline-api/lib/artifact.ts index d5e3bc7ad90ec..b31da4dc4884a 100644 --- a/packages/@aws-cdk/aws-codepipeline-api/lib/artifact.ts +++ b/packages/@aws-cdk/aws-codepipeline-api/lib/artifact.ts @@ -14,7 +14,7 @@ export class Artifact extends Construct { * Output is in the form "::" * @param fileName The name of the file */ - public subartifact(fileName: string) { + public atPath(fileName: string) { return new ArtifactPath(this, fileName); } diff --git a/packages/@aws-cdk/aws-codepipeline/test/integ.cfn-template-from-repo.lit.expected.json b/packages/@aws-cdk/aws-codepipeline/test/integ.cfn-template-from-repo.lit.expected.json index cac1870ff16d0..a825f06928a3d 100644 --- a/packages/@aws-cdk/aws-codepipeline/test/integ.cfn-template-from-repo.lit.expected.json +++ b/packages/@aws-cdk/aws-codepipeline/test/integ.cfn-template-from-repo.lit.expected.json @@ -75,6 +75,120 @@ "Arn" ] } + }, + { + "Action": "cloudformation:DescribeStacks", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn", + ":", + { + "Ref": "AWS::Partition" + }, + ":", + "cloudformation", + ":", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + "stack", + "/", + "OurStack/*" + ] + ] + } + }, + { + "Action": [ + "cloudformation:CreateChangeSet", + "cloudformation:DeleteChangeSet", + "cloudformation:DescribeChangeSet" + ], + "Condition": { + "StringEquals": { + "cloudformation:ChangeSetName": "StagedChangeSet" + } + }, + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn", + ":", + { + "Ref": "AWS::Partition" + }, + ":", + "cloudformation", + ":", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + "stack", + "/", + "OurStack/*" + ] + ] + } + }, + { + "Action": "iam:PassRole", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "PipelineDeployPrepareChangesRoleD28C853C", + "Arn" + ] + } + }, + { + "Action": "cloudformation:ExecuteChangeSet", + "Condition": { + "StringEquals": { + "cloudformation:ChangeSetName": "StagedChangeSet" + } + }, + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn", + ":", + { + "Ref": "AWS::Partition" + }, + ":", + "cloudformation", + ":", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + "stack", + "/", + "OurStack/*" + ] + ] + } } ], "Version": "2012-10-17" diff --git a/packages/@aws-cdk/aws-codepipeline/test/integ.cfn-template-from-repo.lit.ts b/packages/@aws-cdk/aws-codepipeline/test/integ.cfn-template-from-repo.lit.ts index e6c1bdf7dede0..7167d3bbb9afb 100644 --- a/packages/@aws-cdk/aws-codepipeline/test/integ.cfn-template-from-repo.lit.ts +++ b/packages/@aws-cdk/aws-codepipeline/test/integ.cfn-template-from-repo.lit.ts @@ -30,7 +30,7 @@ new cfn.PipelineCreateReplaceChangeSetAction(prodStage, 'PrepareChanges', { stackName, changeSetName, fullPermissions: true, - templatePath: source.artifact.subartifact('template.yaml'), + templatePath: source.artifact.atPath('template.yaml'), }); new codepipeline.ManualApprovalAction(stack, 'ApproveChanges', { diff --git a/packages/@aws-cdk/aws-codepipeline/test/integ.pipeline-cfn.expected.json b/packages/@aws-cdk/aws-codepipeline/test/integ.pipeline-cfn.expected.json index 8067191fc54e1..b2a70c45f0322 100644 --- a/packages/@aws-cdk/aws-codepipeline/test/integ.pipeline-cfn.expected.json +++ b/packages/@aws-cdk/aws-codepipeline/test/integ.pipeline-cfn.expected.json @@ -90,6 +90,85 @@ ] } ] + }, + { + "Action": "cloudformation:DescribeStacks", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn", + ":", + { + "Ref": "AWS::Partition" + }, + ":", + "cloudformation", + ":", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + "stack", + "/", + "IntegTest-TestActionStack/*" + ] + ] + } + }, + { + "Action": [ + "cloudformation:CreateChangeSet", + "cloudformation:DeleteChangeSet", + "cloudformation:DescribeChangeSet" + ], + "Condition": { + "StringEquals": { + "cloudformation:ChangeSetName": "ChangeSetIntegTest" + } + }, + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn", + ":", + { + "Ref": "AWS::Partition" + }, + ":", + "cloudformation", + ":", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + "stack", + "/", + "IntegTest-TestActionStack/*" + ] + ] + } + }, + { + "Action": "iam:PassRole", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "CfnChangeSetRole6F05F6FC", + "Arn" + ] + } } ], "Version": "2012-10-17" diff --git a/packages/@aws-cdk/aws-codepipeline/test/test.cloudformation-pipeline-actions.ts b/packages/@aws-cdk/aws-codepipeline/test/test.cloudformation-pipeline-actions.ts index 5274184ce2daf..4994ee2243047 100644 --- a/packages/@aws-cdk/aws-codepipeline/test/test.cloudformation-pipeline-actions.ts +++ b/packages/@aws-cdk/aws-codepipeline/test/test.cloudformation-pipeline-actions.ts @@ -203,7 +203,7 @@ export = { new PipelineCreateUpdateStackAction(stack.deployStage, 'CreateUpdate', { stage: stack.deployStage, stackName: 'MyStack', - templatePath: stack.source.artifact.subartifact('template.yaml'), + templatePath: stack.source.artifact.atPath('template.yaml'), fullPermissions: true, }); @@ -256,7 +256,7 @@ export = { new PipelineCreateUpdateStackAction(stack, 'CreateUpdate', { stage: stack.deployStage, stackName: 'MyStack', - templatePath: stack.source.artifact.subartifact('template.yaml'), + templatePath: stack.source.artifact.atPath('template.yaml'), outputFileName: 'CreateResponse.json', }); @@ -287,7 +287,7 @@ export = { new PipelineCreateUpdateStackAction(stack, 'CreateUpdate', { stage: stack.deployStage, stackName: 'MyStack', - templatePath: stack.source.artifact.subartifact('template.yaml'), + templatePath: stack.source.artifact.atPath('template.yaml'), replaceOnFailure: true, }); @@ -320,7 +320,7 @@ export = { new PipelineCreateUpdateStackAction(stack, 'CreateUpdate', { stage: stack.deployStage, stackName: 'MyStack', - templatePath: stack.source.artifact.subartifact('template.yaml'), + templatePath: stack.source.artifact.atPath('template.yaml'), parameterOverrides: { RepoName: stack.repo.repositoryName }