Skip to content

Commit

Permalink
feat(codepipeline): allow cross-account CloudFormation actions (#3208)
Browse files Browse the repository at this point in the history
This adds a property 'account' to all CloudFormation CodePipeline actions,
and properly handles passing it in the pipeline construct.
  • Loading branch information
skinny85 committed Aug 9, 2019
1 parent b0720dd commit 8df4b7e
Show file tree
Hide file tree
Showing 7 changed files with 321 additions and 39 deletions.
37 changes: 37 additions & 0 deletions packages/@aws-cdk/aws-codepipeline-actions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<PipelineStackName>-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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
},
},
};

/**
Expand Down
11 changes: 11 additions & 0 deletions packages/@aws-cdk/aws-codepipeline/lib/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
150 changes: 114 additions & 36 deletions packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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';
Expand Down Expand Up @@ -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;
}
}

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/@aws-cdk/aws-codepipeline/test/fake-build-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export interface FakeBuildActionProps extends codepipeline.CommonActionProps {
owner?: string;

role?: iam.IRole;

account?: string;
}

export class FakeBuildAction implements codepipeline.IAction {
Expand Down

0 comments on commit 8df4b7e

Please sign in to comment.