From 471008ea5f1b3bcdf4e1eeaa90814943e3be1eff Mon Sep 17 00:00:00 2001 From: Mike Cowgill Date: Mon, 12 Nov 2018 22:18:29 -0800 Subject: [PATCH] feat(app-delivery) IAM policy for deploy stack * The changeset and apply changeset can now apply role IAM permissions, and CloudFormation Capabilities * Document updates for proper build stage configuration * Fixes #1151 --- packages/@aws-cdk/app-delivery/README.md | 32 +++- .../lib/pipeline-deploy-stack-action.ts | 61 ++++++- packages/@aws-cdk/app-delivery/package.json | 10 +- .../test/test.pipeline-deploy-stack-action.ts | 171 +++++++++++++++++- 4 files changed, 263 insertions(+), 11 deletions(-) diff --git a/packages/@aws-cdk/app-delivery/README.md b/packages/@aws-cdk/app-delivery/README.md index f6790ea3e4302..f34b86cc4276b 100644 --- a/packages/@aws-cdk/app-delivery/README.md +++ b/packages/@aws-cdk/app-delivery/README.md @@ -52,9 +52,17 @@ const source = new codepipeline.GitHubSourceAction(pipelineStack, 'GitHub', { /* ... */ }); const project = new codebuild.PipelineProject(pipelineStack, 'CodeBuild', { - /* ... */ + /** + * Choose an environment configuration that meets your use case. For NodeJS + * this might be + * environment: { + * buildImage: codebuild.LinuxBuildImage.UBUNTU_14_04_NODEJS_10_1_0, + * }, + */ }); -const synthesizedApp = project.outputArtifact; +const buildStage = pipeline.addStage('build'); +const buildAction = project.addBuildToPipeline(buildStage, 'CodeBuild'); +const synthesizedApp = buildAction.outputArtifact; // Optionally, self-update the pipeline stack const selfUpdateStage = pipeline.addStage('SelfUpdate'); @@ -69,26 +77,42 @@ const deployStage = pipeline.addStage('Deploy'); const serviceStackA = new MyServiceStackA(app, 'ServiceStackA', { /* ... */ }); const serviceStackB = new MyServiceStackB(app, 'ServiceStackB', { /* ... */ }); // Add actions to deploy the stacks in the deploy stage: -new cicd.PipelineDeployStackAction(pipelineStack, 'DeployServiceStackA', { +const deployServiceAAction = new cicd.PipelineDeployStackAction(pipelineStack, 'DeployServiceStackA', { stage: deployStage, stack: serviceStackA, inputArtifact: synthesizedApp, }); -new cicd.PipelineDeployStackAction(pipelineStack, 'DeployServiceStackB', { + +deployServiceAAction.role.addToPolicy( + // new iam.PolicyStatement(). + // ... addAction('actions that you need'). + // add resource +); + +const deployServiceBAction = new cicd.PipelineDeployStackAction(pipelineStack, 'DeployServiceStackB', { stage: deployStage, stack: serviceStackB, inputArtifact: synthesizedApp, createChangeSetRunOrder: 998, }); +deployServiceBAction.role.addToPolicy( + // new iam.PolicyStatement(). + // ... addAction('actions that you need'). + // add resource +); ``` #### `buildspec.yml` +The repository can contain a file at the root level named `buildspec.yml`, or +you can in-line the buildspec. Note that `buildspec.yaml` is not compatible. + The `PipelineDeployStackAction` expects it's `inputArtifact` to contain the result of synthesizing a CDK App using the `cdk synth -o ` command. For example, a *TypeScript* or *Javascript* CDK App can add the following `buildspec.yml` at the root of the repository configured in the `Source` stage: +Example contents of `buildspec.yml`. ```yml version: 0.2 phases: diff --git a/packages/@aws-cdk/app-delivery/lib/pipeline-deploy-stack-action.ts b/packages/@aws-cdk/app-delivery/lib/pipeline-deploy-stack-action.ts index 8105d354771a8..903799939768e 100644 --- a/packages/@aws-cdk/app-delivery/lib/pipeline-deploy-stack-action.ts +++ b/packages/@aws-cdk/app-delivery/lib/pipeline-deploy-stack-action.ts @@ -1,6 +1,6 @@ - import cfn = require('@aws-cdk/aws-cloudformation'); import codepipeline = require('@aws-cdk/aws-codepipeline-api'); +import iam = require('@aws-cdk/aws-iam'); import cdk = require('@aws-cdk/cdk'); import cxapi = require('@aws-cdk/cx-api'); @@ -41,6 +41,50 @@ export interface PipelineDeployStackActionProps { * @default ``createChangeSetRunOrder + 1`` */ executeChangeSetRunOrder?: number; + + /** + * IAM role to assume when deploying changes. + * + * If not specified, a fresh role is created. The role is created with zero + * permissions unless `fullPermissions` is true, in which case the role will have + * full permissions. + * + * @default A fresh role with full or no permissions (depending on the value of `fullPermissions`). + */ + role?: iam.Role; + + // tslint:disable:max-line-length Because of long URLs in documentation + /** + * Acknowledge certain changes made as part of deployment + * + * For stacks that contain certain resources, explicit acknowledgement that AWS CloudFormation + * might create or update those resources. For example, you must specify CAPABILITY_IAM if your + * stack template contains AWS Identity and Access Management (IAM) resources. For more + * information, see [Acknowledging IAM Resources in AWS CloudFormation Templates] (https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-iam-template.html#using-iam-capabilities). + * + * @default No capabitilities passed, unless `fullPermissions` is true + */ + capabilities?: cfn.CloudFormationCapabilities[]; + // tslint:enable:max-line-length + + /** + * Whether to grant full permissions to CloudFormation while deploying this template. + * + * Setting this to `true` affects the defaults for `role` and `capabilities`, if you + * don't specify any alternatives. + * + * The default role that will be created for you will have full (i.e., `*`) + * permissions on all resources, and the deployment will have named IAM + * capabilities (i.e., able to create all IAM resources). + * + * This is a shorthand that you can use if you fully trust the templates that + * are deployed in this pipeline. If you want more fine-grained permissions, + * use `addToRolePolicy` and `capabilities` to control what the CloudFormation + * deployment is allowed to do. + * + * @default false + */ + fullPermissions?: boolean; } /** @@ -52,6 +96,12 @@ export interface PipelineDeployStackActionProps { * CodePipeline is hosted. */ export class PipelineDeployStackAction extends cdk.Construct { + + /** + * The role used by CloudFormation for the deploy action + */ + public readonly role: iam.Role; + private readonly stack: cdk.Stack; constructor(parent: cdk.Construct, id: string, props: PipelineDeployStackActionProps) { @@ -69,16 +119,21 @@ export class PipelineDeployStackAction extends cdk.Construct { throw new Error(`createChangeSetRunOrder (${createChangeSetRunOrder}) must be < executeChangeSetRunOrder (${executeChangeSetRunOrder})`); } - this.stack = props.stack; const changeSetName = props.changeSetName || 'CDK-CodePipeline-ChangeSet'; - new cfn.PipelineCreateReplaceChangeSetAction(this, 'ChangeSet', { + this.stack = props.stack; + + const changeSetAction = new cfn.PipelineCreateReplaceChangeSetAction(this, 'ChangeSet', { changeSetName, runOrder: createChangeSetRunOrder, stackName: props.stack.name, stage: props.stage, templatePath: props.inputArtifact.atPath(`${props.stack.name}.template.yaml`), + fullPermissions: props.fullPermissions, + role: props.role, + capabilities: props.capabilities, }); + this.role = changeSetAction.role; new cfn.PipelineExecuteChangeSetAction(this, 'Execute', { changeSetName, diff --git a/packages/@aws-cdk/app-delivery/package.json b/packages/@aws-cdk/app-delivery/package.json index 366d64bee41e1..f50dbff9ed09d 100644 --- a/packages/@aws-cdk/app-delivery/package.json +++ b/packages/@aws-cdk/app-delivery/package.json @@ -35,14 +35,16 @@ "@aws-cdk/aws-cloudformation": "^0.17.0", "@aws-cdk/aws-codebuild": "^0.17.0", "@aws-cdk/aws-codepipeline-api": "^0.17.0", - "@aws-cdk/cdk": "^0.17.0", - "@aws-cdk/cx-api": "^0.17.0" + "@aws-cdk/aws-iam": "^0.17.0", + "@aws-cdk/cx-api": "^0.17.0", + "@aws-cdk/cdk": "^0.17.0" }, "devDependencies": { "@aws-cdk/aws-codepipeline": "^0.17.0", "@aws-cdk/aws-s3": "^0.17.0", "cdk-build-tools": "^0.17.0", "cdk-integ-tools": "^0.17.0", + "@aws-cdk/assert": "^0.17.0", "fast-check": "^1.7.0", "pkglint": "^0.17.0" }, @@ -63,6 +65,8 @@ ], "peerDependencies": { "@aws-cdk/aws-codepipeline-api": "^0.17.0", + "@aws-cdk/aws-iam": "^0.17.0", + "@aws-cdk/aws-cloudformation": "^0.17.0", "@aws-cdk/cdk": "^0.17.0" } -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/app-delivery/test/test.pipeline-deploy-stack-action.ts b/packages/@aws-cdk/app-delivery/test/test.pipeline-deploy-stack-action.ts index 5357783f50052..6cae56e2d4853 100644 --- a/packages/@aws-cdk/app-delivery/test/test.pipeline-deploy-stack-action.ts +++ b/packages/@aws-cdk/app-delivery/test/test.pipeline-deploy-stack-action.ts @@ -1,11 +1,21 @@ +import cfn = require('@aws-cdk/aws-cloudformation'); +import codebuild = require('@aws-cdk/aws-codebuild'); import code = require('@aws-cdk/aws-codepipeline'); import api = require('@aws-cdk/aws-codepipeline-api'); +import iam = require('@aws-cdk/aws-iam'); +import s3 = require('@aws-cdk/aws-s3'); import cdk = require('@aws-cdk/cdk'); import cxapi = require('@aws-cdk/cx-api'); import fc = require('fast-check'); import nodeunit = require('nodeunit'); + +import { countResources, expect, haveResource, isSuperObject } from '@aws-cdk/assert'; import { PipelineDeployStackAction } from '../lib/pipeline-deploy-stack-action'; +interface SelfUpdatingPipeline { + synthesizedApp: api.Artifact; + pipeline: code.Pipeline; +} const accountId = fc.array(fc.integer(0, 9), 12, 12).map(arr => arr.join()); export = nodeunit.testCase({ @@ -58,7 +68,129 @@ export = nodeunit.testCase({ ); test.done(); }, + 'users can supply CloudFormation capabilities'(test: nodeunit.Test) { + const pipelineStack = getTestStack(); + const selfUpdatingStack = createSelfUpdatingStack(pipelineStack); + + const pipeline = selfUpdatingStack.pipeline; + const selfUpdateStage = pipeline.addStage('SelfUpdate'); + new PipelineDeployStackAction(pipelineStack, 'SelfUpdatePipeline', { + stage: selfUpdateStage, + stack: pipelineStack, + inputArtifact: selfUpdatingStack.synthesizedApp, + capabilities: [cfn.CloudFormationCapabilities.IAM], + }); + expect(pipelineStack).to(haveResource('AWS::CodePipeline::Pipeline', hasPipelineAction({ + Configuration: { + StackName: "TestStack", + ActionMode: "CHANGE_SET_REPLACE", + Capabilities: "CAPABILITY_IAM", + } + }))); + test.done(); + }, + 'users can supply enable full permissions'(test: nodeunit.Test) { + const pipelineStack = getTestStack(); + const selfUpdatingStack = createSelfUpdatingStack(pipelineStack); + + const pipeline = selfUpdatingStack.pipeline; + const selfUpdateStage = pipeline.addStage('SelfUpdate'); + new PipelineDeployStackAction(pipelineStack, 'SelfUpdatePipeline', { + stage: selfUpdateStage, + stack: pipelineStack, + inputArtifact: selfUpdatingStack.synthesizedApp, + fullPermissions: true, + }); + expect(pipelineStack).to(haveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: '*', + Effect: 'Allow', + Resource: '*', + } + ], + } + })); + test.done(); + }, + 'users can supply a role for deploy action'(test: nodeunit.Test) { + const pipelineStack = getTestStack(); + const selfUpdatingStack = createSelfUpdatingStack(pipelineStack); + + const pipeline = selfUpdatingStack.pipeline; + const role = new iam.Role(pipelineStack, 'MyRole', { + assumedBy: new iam.ServicePrincipal('cloudformation.amazonaws.com'), + }); + const selfUpdateStage = pipeline.addStage('SelfUpdate'); + const deployAction = new PipelineDeployStackAction(pipelineStack, 'SelfUpdatePipeline', { + stage: selfUpdateStage, + stack: pipelineStack, + inputArtifact: selfUpdatingStack.synthesizedApp, + role + }); + test.deepEqual(role.id, deployAction.role.id); + test.done(); + }, + 'users can specify IAM permissions for the deploy action'(test: nodeunit.Test) { + // GIVEN // + const pipelineStack = getTestStack(); + // the fake stack to deploy + const emptyStack = getTestStack(); + + const selfUpdatingStack = createSelfUpdatingStack(pipelineStack); + const pipeline = selfUpdatingStack.pipeline; + + // WHEN // + // this our app/service/infra to deploy + const deployStage = pipeline.addStage('Deploy'); + const deployAction = new PipelineDeployStackAction(pipelineStack, 'DeployServiceStackA', { + stage: deployStage, + stack: emptyStack, + inputArtifact: selfUpdatingStack.synthesizedApp, + }); + // we might need to add permissions + deployAction.role.addToPolicy( new iam.PolicyStatement(). + addAction('ec2:AuthorizeSecurityGroupEgress'). + addAction('ec2:AuthorizeSecurityGroupIngress'). + addAction('ec2:DeleteSecurityGroup'). + addAction('ec2:DescribeSecurityGroups'). + addAction('ec2:CreateSecurityGroup'). + addAction('ec2:RevokeSecurityGroupEgress'). + addAction('ec2:RevokeSecurityGroupIngress'). + addAllResources()); + + // THEN // + // there should be 3 policies 1. CodePipeline, 2. Codebuild, 3. + // ChangeSetDeploy Action + expect(pipelineStack).to(countResources('AWS::IAM::Policy', 3)); + expect(pipelineStack).to(haveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: [ + 'ec2:AuthorizeSecurityGroupEgress', + 'ec2:AuthorizeSecurityGroupIngress', + 'ec2:DeleteSecurityGroup', + 'ec2:DescribeSecurityGroups', + 'ec2:CreateSecurityGroup', + 'ec2:RevokeSecurityGroupEgress', + 'ec2:RevokeSecurityGroupIngress' + ], + Effect: 'Allow', + Resource: '*', + }, + ], + }, + Roles: [ + { + Ref: 'DeployServiceStackAChangeSetRoleA1245536', + }, + ], + })); + test.done(); + }, 'rejects stacks with assets'(test: nodeunit.Test) { fc.assert( fc.property( @@ -79,7 +211,7 @@ export = nodeunit.testCase({ deployedStack.addMetadata(cxapi.ASSET_METADATA, {}); } test.deepEqual(action.validate(), - [`Cannot deploy the stack DeployedStack because it references ${assetCount} asset(s)`]); + [`Cannot deploy the stack DeployedStack because it references ${assetCount} asset(s)`]); } ) ); @@ -101,3 +233,40 @@ class FakeAction extends api.Action { this.outputArtifact = new api.Artifact(this, 'OutputArtifact'); } } + +function getTestStack(): cdk.Stack { + return new cdk.Stack(undefined, 'TestStack', { env: { account: '123456789012', region: 'us-east-1' } }); +} + +function createSelfUpdatingStack(pipelineStack: cdk.Stack): SelfUpdatingPipeline { + const pipeline = new code.Pipeline(pipelineStack, 'CodePipeline', { + restartExecutionOnUpdate: true, + }); + + // simple source + const bucket = s3.Bucket.import( pipeline, 'PatternBucket', { bucketArn: 'arn:aws:s3:::totally-fake-bucket' }); + new s3.PipelineSourceAction(pipeline, 'S3Source', { + bucket, + bucketKey: 'the-great-key', + stage: pipeline.addStage('source'), + }); + + const project = new codebuild.PipelineProject(pipelineStack, 'CodeBuild'); + const buildStage = pipeline.addStage('build'); + const buildAction = project.addBuildToPipeline(buildStage, 'CodeBuild'); + const synthesizedApp = buildAction.outputArtifact; + return {synthesizedApp, pipeline}; +} + +function hasPipelineAction(expectedAction: any): (props: any) => boolean { + return (props: any) => { + for (const stage of props.Stages) { + for (const action of stage.Actions) { + if (isSuperObject(action, expectedAction)) { + return true; + } + } + } + return false; + }; +}