diff --git a/packages/@aws-cdk/aws-codepipeline-actions/README.md b/packages/@aws-cdk/aws-codepipeline-actions/README.md index d3056ad2e0e35..b69d2d45369b4 100644 --- a/packages/@aws-cdk/aws-codepipeline-actions/README.md +++ b/packages/@aws-cdk/aws-codepipeline-actions/README.md @@ -346,6 +346,43 @@ using a CloudFormation CodePipeline Action. Example: [Example of deploying a Lambda through CodePipeline](test/integ.lambda-deployed-through-codepipeline.lit.ts) +##### Cross-account actions + +If you want to update stacks in a different account, +pass the `account` property when creating the action: + +```typescript +new codepipeline_actions.CloudFormationCreateUpdateStackAction({ + // ... + account: '123456789012', +}); +``` + +This will create a new stack, called `-support-123456789012`, in your `App`, +that will contain the role that the pipeline will assume in account 123456789012 before executing this action. +This support stack will automatically be deployed before the stack containing the pipeline. + +You can also pass a role explicitly when creating the action - +in that case, the `account` property is ignored, +and the action will operate in the same account the role belongs to: + +```typescript +import { PhysicalName } from '@aws-cdk/core'; + +// in stack for account 123456789012... +const actionRole = new iam.Role(otherAccountStack, 'ActionRole', { + assumedBy: new iam.AccountPrincipal(pipelineAccount), + // the role has to have a physical name set + roleName: PhysicalName.GENERATE_IF_NEEDED, +}); + +// in the pipeline stack... +new codepipeline_actions.CloudFormationCreateUpdateStackAction({ + // ... + role: actionRole, // this action will be cross-account as well +}); +``` + #### AWS CodeDeploy ##### Server deployments diff --git a/packages/@aws-cdk/aws-codepipeline-actions/lib/cloudformation/pipeline-actions.ts b/packages/@aws-cdk/aws-codepipeline-actions/lib/cloudformation/pipeline-actions.ts index 721a91f465963..a31bf1b364bca 100644 --- a/packages/@aws-cdk/aws-codepipeline-actions/lib/cloudformation/pipeline-actions.ts +++ b/packages/@aws-cdk/aws-codepipeline-actions/lib/cloudformation/pipeline-actions.ts @@ -47,6 +47,15 @@ interface CloudFormationActionProps extends codepipeline.CommonAwsActionProps { * @default the Action resides in the same region as the Pipeline */ readonly region?: string; + + /** + * The AWS account this Action is supposed to operate in. + * **Note**: if you specify the `role` property, + * this is ignored - the action will operate in the same region the passed role does. + * + * @default - action resides in the same account as the pipeline + */ + readonly account?: string; } /** @@ -259,9 +268,21 @@ abstract class CloudFormationDeployAction extends CloudFormationAction { if (this.props2.deploymentRole) { this._deploymentRole = this.props2.deploymentRole; } else { - this._deploymentRole = new iam.Role(scope, 'Role', { - assumedBy: new iam.ServicePrincipal('cloudformation.amazonaws.com') - }); + const roleStack = Stack.of(options.role); + const pipelineStack = Stack.of(scope); + if (roleStack.account !== pipelineStack.account) { + // pass role is not allowed for cross-account access - so, + // create the deployment Role in the other account! + this._deploymentRole = new iam.Role(roleStack, + `${stage.pipeline.node.uniqueId}-${stage.stageName}-${this.actionProperties.actionName}-DeploymentRole`, { + assumedBy: new iam.ServicePrincipal('cloudformation.amazonaws.com'), + roleName: cdk.PhysicalName.GENERATE_IF_NEEDED, + }); + } else { + this._deploymentRole = new iam.Role(scope, 'Role', { + assumedBy: new iam.ServicePrincipal('cloudformation.amazonaws.com') + }); + } if (this.props2.adminPermissions) { this._deploymentRole.addToPolicy(new iam.PolicyStatement({ diff --git a/packages/@aws-cdk/aws-codepipeline-actions/test/cloudformation/test.cloudformation-pipeline-actions.ts b/packages/@aws-cdk/aws-codepipeline-actions/test/cloudformation/test.cloudformation-pipeline-actions.ts index 574afba1bce43..02cc583f78cae 100644 --- a/packages/@aws-cdk/aws-codepipeline-actions/test/cloudformation/test.cloudformation-pipeline-actions.ts +++ b/packages/@aws-cdk/aws-codepipeline-actions/test/cloudformation/test.cloudformation-pipeline-actions.ts @@ -1,6 +1,7 @@ import { expect, haveResource, haveResourceLike } from '@aws-cdk/assert'; import { CloudFormationCapabilities } from '@aws-cdk/aws-cloudformation'; import codebuild = require('@aws-cdk/aws-codebuild'); +import codecommit = require('@aws-cdk/aws-codecommit'); import { Repository } from '@aws-cdk/aws-codecommit'; import codepipeline = require('@aws-cdk/aws-codepipeline'); import { Role } from '@aws-cdk/aws-iam'; @@ -544,6 +545,84 @@ export = { test.done(); }, + + 'cross-account CFN Pipeline': { + 'correctly creates the deployment Role in the other account'(test: Test) { + const app = new cdk.App(); + + const pipelineStack = new cdk.Stack(app, 'PipelineStack', { + env: { + account: '234567890123', + region: 'us-west-2', + }, + }); + + const sourceOutput = new codepipeline.Artifact(); + new codepipeline.Pipeline(pipelineStack, 'Pipeline', { + stages: [ + { + stageName: 'Source', + actions: [ + new cpactions.CodeCommitSourceAction({ + actionName: 'CodeCommit', + repository: codecommit.Repository.fromRepositoryName(pipelineStack, 'Repo', 'RepoName'), + output: sourceOutput, + }), + ], + }, + { + stageName: 'Deploy', + actions: [ + new cpactions.CloudFormationCreateUpdateStackAction({ + actionName: 'CFN', + stackName: 'MyStack', + adminPermissions: true, + templatePath: sourceOutput.atPath('template.yaml'), + account: '123456789012', + }), + ], + }, + ], + }); + + expect(pipelineStack).to(haveResourceLike('AWS::CodePipeline::Pipeline', { + "Stages": [ + { + "Name": "Source", + }, + { + "Name": "Deploy", + "Actions": [ + { + "Name": "CFN", + "RoleArn": { "Fn::Join": ["", ["arn:", { "Ref": "AWS::Partition" }, + ":iam::123456789012:role/pipelinestack-support-123loycfnactionrole56af64af3590f311bc50", + ]], + }, + "Configuration": { + "RoleArn": { + "Fn::Join": ["", ["arn:", { "Ref": "AWS::Partition" }, + ":iam::123456789012:role/pipelinestack-support-123fndeploymentrole4668d9b5a30ce3dc4508", + ]], + }, + }, + }, + ], + }, + ], + })); + + const otherStack = app.node.findChild('cross-account-support-stack-123456789012') as cdk.Stack; + expect(otherStack).to(haveResourceLike('AWS::IAM::Role', { + "RoleName": "pipelinestack-support-123loycfnactionrole56af64af3590f311bc50", + })); + expect(otherStack).to(haveResourceLike('AWS::IAM::Role', { + "RoleName": "pipelinestack-support-123fndeploymentrole4668d9b5a30ce3dc4508", + })); + + test.done(); + }, + }, }; /** diff --git a/packages/@aws-cdk/aws-codepipeline/lib/action.ts b/packages/@aws-cdk/aws-codepipeline/lib/action.ts index 8ab5c79d88042..67c4439210921 100644 --- a/packages/@aws-cdk/aws-codepipeline/lib/action.ts +++ b/packages/@aws-cdk/aws-codepipeline/lib/action.ts @@ -42,6 +42,17 @@ export interface ActionProperties { */ readonly region?: string; + /** + * The account the Action is supposed to live in. + * For Actions backed by resources, + * this is inferred from the Stack {@link resource} is part of. + * However, some Actions, like the CloudFormation ones, + * are not backed by any resource, and they still might want to be cross-account. + * In general, a concrete Action class should specify either {@link resource}, + * or {@link account} - but not both. + */ + readonly account?: string; + /** * The optional resource that is backing this Action. * This is used for automatically handling Actions backed by diff --git a/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts b/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts index 653a3868fe86f..3cdc89d84c075 100644 --- a/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts +++ b/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts @@ -375,10 +375,7 @@ export class Pipeline extends PipelineBase { throw new Error("You need to specify an explicit account when using CodePipeline's cross-region support"); } - const app = this.node.root; - if (!app || !App.isApp(app)) { - throw new Error(`Pipeline stack which uses cross region actions must be part of a CDK app`); - } + const app = this.requireApp(); const crossRegionScaffoldStack = new CrossRegionSupportStack(app, `cross-region-stack-${pipelineAccount}:${region}`, { pipelineStackName: pipelineStack.stackName, region, @@ -404,44 +401,16 @@ export class Pipeline extends PipelineBase { /** * Gets the role used for this action, - * including handling the case when the action is supposed to be cross-region. + * including handling the case when the action is supposed to be cross-account. * * @param stage the stage the action belongs to * @param action the action to return/create a role for + * @param actionScope the scope, unique to the action, to create new resources in */ private getRoleForAction(stage: Stage, action: IAction, actionScope: Construct): iam.IRole | undefined { const pipelineStack = Stack.of(this); - let actionRole: iam.IRole | undefined; - if (action.actionProperties.role) { - if (!this.isAwsOwned(action)) { - throw new Error("Specifying a Role is not supported for actions with an owner different than 'AWS' - " + - `got '${action.actionProperties.owner}' (Action: '${action.actionProperties.actionName}' in Stage: '${stage.stageName}')`); - } - actionRole = action.actionProperties.role; - } else if (action.actionProperties.resource) { - const resourceStack = Stack.of(action.actionProperties.resource); - // check if resource is from a different account - if (pipelineStack.environment !== resourceStack.environment) { - // if it is, the pipeline's bucket must have a KMS key - if (!this.artifactBucket.encryptionKey) { - throw new Error('The Pipeline is being used in a cross-account manner, ' + - 'but its artifact bucket does not have a KMS key defined. ' + - 'A KMS key is required for a cross-account Pipeline. ' + - 'Make sure to pass a Bucket with a Key when creating the Pipeline'); - } - - // generate a role in the other stack, that the Pipeline will assume for executing this action - actionRole = new iam.Role(resourceStack, - `${this.node.uniqueId}-${stage.stageName}-${action.actionProperties.actionName}-ActionRole`, { - assumedBy: new iam.AccountPrincipal(pipelineStack.account), - roleName: PhysicalName.GENERATE_IF_NEEDED, - }); - - // the other stack has to be deployed before the pipeline stack - pipelineStack.addDependency(resourceStack); - } - } + let actionRole = this.getRoleFromActionPropsOrGenerateIfCrossAccount(stage, action); if (!actionRole && this.isAwsOwned(action)) { // generate a Role for this specific Action @@ -461,6 +430,107 @@ export class Pipeline extends PipelineBase { return actionRole; } + private getRoleFromActionPropsOrGenerateIfCrossAccount(stage: Stage, action: IAction): iam.IRole | undefined { + const pipelineStack = Stack.of(this); + + // if a Role has been passed explicitly, always use it + // (even if the backing resource is from a different account - + // this is how the user can override our default support logic) + if (action.actionProperties.role) { + if (this.isAwsOwned(action)) { + // the role has to be deployed before the pipeline + const roleStack = Stack.of(action.actionProperties.role); + pipelineStack.addDependency(roleStack); + + return action.actionProperties.role; + } else { + // ...except if the Action is not owned by 'AWS', + // as that would be rejected by CodePipeline at deploy time + throw new Error("Specifying a Role is not supported for actions with an owner different than 'AWS' - " + + `got '${action.actionProperties.owner}' (Action: '${action.actionProperties.actionName}' in Stage: '${stage.stageName}')`); + } + } + + // if we don't have a Role passed, + // and the action is cross-account, + // generate a Role in that other account stack + const otherAccountStack = this.getOtherStackIfActionIsCrossAccount(action); + if (!otherAccountStack) { + return undefined; + } + + // if we have a cross-account action, the pipeline's bucket must have a KMS key + if (!this.artifactBucket.encryptionKey) { + throw new Error('The Pipeline is being used in a cross-account manner, ' + + 'but its artifact bucket does not have a KMS key defined. ' + + 'A KMS key is required for a cross-account Pipeline. ' + + 'Make sure to pass a Bucket with a Key when creating the Pipeline'); + } + + // generate a role in the other stack, that the Pipeline will assume for executing this action + const ret = new iam.Role(otherAccountStack, + `${this.node.uniqueId}-${stage.stageName}-${action.actionProperties.actionName}-ActionRole`, { + assumedBy: new iam.AccountPrincipal(pipelineStack.account), + roleName: PhysicalName.GENERATE_IF_NEEDED, + }); + // the other stack with the role has to be deployed before the pipeline stack + // (CodePipeline verifies you can assume the action Role on creation) + pipelineStack.addDependency(otherAccountStack); + + return ret; + } + + /** + * Returns the Stack this Action belongs to if this is a cross-account Action. + * If this Action is not cross-account (i.e., it lives in the same account as the Pipeline), + * it returns undefined. + * + * @param action the Action to return the Stack for + */ + private getOtherStackIfActionIsCrossAccount(action: IAction): Stack | undefined { + const pipelineStack = Stack.of(this); + + if (action.actionProperties.resource) { + const resourceStack = Stack.of(action.actionProperties.resource); + // check if resource is from a different account + return pipelineStack.account === resourceStack.account + ? undefined + : resourceStack; + } + + if (!action.actionProperties.account) { + return undefined; + } + + const targetAccount = action.actionProperties.account; + // check whether the account is a static string + if (Token.isUnresolved(targetAccount)) { + throw new Error(`The 'account' property must be a concrete value (action: '${action.actionProperties.actionName}')`); + } + // check whether the pipeline account is a static string + if (Token.isUnresolved(pipelineStack.account)) { + throw new Error("Pipeline stack which uses cross-environment actions must have an explicitly set account"); + } + + if (pipelineStack.account === targetAccount) { + return undefined; + } + + const stackId = `cross-account-support-stack-${targetAccount}`; + const app = this.requireApp(); + let targetAccountStack = app.node.tryFindChild(stackId) as Stack; + if (!targetAccountStack) { + targetAccountStack = new Stack(app, stackId, { + stackName: `${pipelineStack.stackName}-support-${targetAccount}`, + env: { + account: targetAccount, + region: action.actionProperties.region ? action.actionProperties.region : pipelineStack.region, + }, + }); + } + return targetAccountStack; + } + private isAwsOwned(action: IAction) { const owner = action.actionProperties.owner; return !owner || owner === 'AWS'; @@ -626,10 +696,18 @@ export class Pipeline extends PipelineBase { private requireRegion(): string { const region = Stack.of(this).region; if (Token.isUnresolved(region)) { - throw new Error(`You need to specify an explicit region when using CodePipeline's cross-region support`); + throw new Error(`Pipeline stack which uses cross-environment actions must have an explicitly set region`); } return region; } + + private requireApp(): App { + const app = this.node.root; + if (!app || !App.isApp(app)) { + throw new Error(`Pipeline stack which uses cross-environment actions must be part of a CDK app`); + } + return app; + } } /** diff --git a/packages/@aws-cdk/aws-codepipeline/test/fake-build-action.ts b/packages/@aws-cdk/aws-codepipeline/test/fake-build-action.ts index b22fa90cc70fa..5e29ffc05ef0d 100644 --- a/packages/@aws-cdk/aws-codepipeline/test/fake-build-action.ts +++ b/packages/@aws-cdk/aws-codepipeline/test/fake-build-action.ts @@ -13,6 +13,8 @@ export interface FakeBuildActionProps extends codepipeline.CommonActionProps { owner?: string; role?: iam.IRole; + + account?: string; } export class FakeBuildAction implements codepipeline.IAction { diff --git a/packages/@aws-cdk/aws-codepipeline/test/test.pipeline.ts b/packages/@aws-cdk/aws-codepipeline/test/test.pipeline.ts index 3a3df493c79dc..275596de6c826 100644 --- a/packages/@aws-cdk/aws-codepipeline/test/test.pipeline.ts +++ b/packages/@aws-cdk/aws-codepipeline/test/test.pipeline.ts @@ -3,6 +3,8 @@ import iam = require('@aws-cdk/aws-iam'); import cdk = require('@aws-cdk/core'); import { Test } from 'nodeunit'; import codepipeline = require('../lib'); +import { FakeBuildAction } from './fake-build-action'; +import { FakeSourceAction } from './fake-source-action'; // tslint:disable:object-literal-key-quotes @@ -40,5 +42,57 @@ export = { test.done(); }, + + 'that is cross-account': { + 'does not allow passing a dynamic value in the Action account property'(test: Test) { + const app = new cdk.App(); + const stack = new cdk.Stack(app, 'PipelineStack', { env: { account: '123456789012' }}); + const sourceOutput = new codepipeline.Artifact(); + const pipeline = new codepipeline.Pipeline(stack, 'Pipeline', { + stages: [ + { + stageName: 'Source', + actions: [new FakeSourceAction({ actionName: 'Source', output: sourceOutput })], + }, + ], + }); + const buildStage = pipeline.addStage({ stageName: 'Build' }); + + test.throws(() => { + buildStage.addAction(new FakeBuildAction({ + actionName: 'FakeBuild', + input: sourceOutput, + account: cdk.Aws.ACCOUNT_ID, + })); + }, /The 'account' property must be a concrete value \(action: 'FakeBuild'\)/); + + test.done(); + }, + + 'does not allow an env-agnostic Pipeline Stack if an Action account has been provided'(test: Test) { + const app = new cdk.App(); + const stack = new cdk.Stack(app, 'PipelineStack'); + const sourceOutput = new codepipeline.Artifact(); + const pipeline = new codepipeline.Pipeline(stack, 'Pipeline', { + stages: [ + { + stageName: 'Source', + actions: [new FakeSourceAction({ actionName: 'Source', output: sourceOutput })], + }, + ], + }); + const buildStage = pipeline.addStage({ stageName: 'Build' }); + + test.throws(() => { + buildStage.addAction(new FakeBuildAction({ + actionName: 'FakeBuild', + input: sourceOutput, + account: '123456789012', + })); + }, /Pipeline stack which uses cross-environment actions must have an explicitly set account/); + + test.done(); + }, + }, }, };