Skip to content

Commit 8df4b7e

Browse files
authored
feat(codepipeline): allow cross-account CloudFormation actions (#3208)
This adds a property 'account' to all CloudFormation CodePipeline actions, and properly handles passing it in the pipeline construct.
1 parent b0720dd commit 8df4b7e

File tree

7 files changed

+321
-39
lines changed

7 files changed

+321
-39
lines changed

packages/@aws-cdk/aws-codepipeline-actions/README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,43 @@ using a CloudFormation CodePipeline Action. Example:
346346

347347
[Example of deploying a Lambda through CodePipeline](test/integ.lambda-deployed-through-codepipeline.lit.ts)
348348

349+
##### Cross-account actions
350+
351+
If you want to update stacks in a different account,
352+
pass the `account` property when creating the action:
353+
354+
```typescript
355+
new codepipeline_actions.CloudFormationCreateUpdateStackAction({
356+
// ...
357+
account: '123456789012',
358+
});
359+
```
360+
361+
This will create a new stack, called `<PipelineStackName>-support-123456789012`, in your `App`,
362+
that will contain the role that the pipeline will assume in account 123456789012 before executing this action.
363+
This support stack will automatically be deployed before the stack containing the pipeline.
364+
365+
You can also pass a role explicitly when creating the action -
366+
in that case, the `account` property is ignored,
367+
and the action will operate in the same account the role belongs to:
368+
369+
```typescript
370+
import { PhysicalName } from '@aws-cdk/core';
371+
372+
// in stack for account 123456789012...
373+
const actionRole = new iam.Role(otherAccountStack, 'ActionRole', {
374+
assumedBy: new iam.AccountPrincipal(pipelineAccount),
375+
// the role has to have a physical name set
376+
roleName: PhysicalName.GENERATE_IF_NEEDED,
377+
});
378+
379+
// in the pipeline stack...
380+
new codepipeline_actions.CloudFormationCreateUpdateStackAction({
381+
// ...
382+
role: actionRole, // this action will be cross-account as well
383+
});
384+
```
385+
349386
#### AWS CodeDeploy
350387

351388
##### Server deployments

packages/@aws-cdk/aws-codepipeline-actions/lib/cloudformation/pipeline-actions.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,15 @@ interface CloudFormationActionProps extends codepipeline.CommonAwsActionProps {
4747
* @default the Action resides in the same region as the Pipeline
4848
*/
4949
readonly region?: string;
50+
51+
/**
52+
* The AWS account this Action is supposed to operate in.
53+
* **Note**: if you specify the `role` property,
54+
* this is ignored - the action will operate in the same region the passed role does.
55+
*
56+
* @default - action resides in the same account as the pipeline
57+
*/
58+
readonly account?: string;
5059
}
5160

5261
/**
@@ -259,9 +268,21 @@ abstract class CloudFormationDeployAction extends CloudFormationAction {
259268
if (this.props2.deploymentRole) {
260269
this._deploymentRole = this.props2.deploymentRole;
261270
} else {
262-
this._deploymentRole = new iam.Role(scope, 'Role', {
263-
assumedBy: new iam.ServicePrincipal('cloudformation.amazonaws.com')
264-
});
271+
const roleStack = Stack.of(options.role);
272+
const pipelineStack = Stack.of(scope);
273+
if (roleStack.account !== pipelineStack.account) {
274+
// pass role is not allowed for cross-account access - so,
275+
// create the deployment Role in the other account!
276+
this._deploymentRole = new iam.Role(roleStack,
277+
`${stage.pipeline.node.uniqueId}-${stage.stageName}-${this.actionProperties.actionName}-DeploymentRole`, {
278+
assumedBy: new iam.ServicePrincipal('cloudformation.amazonaws.com'),
279+
roleName: cdk.PhysicalName.GENERATE_IF_NEEDED,
280+
});
281+
} else {
282+
this._deploymentRole = new iam.Role(scope, 'Role', {
283+
assumedBy: new iam.ServicePrincipal('cloudformation.amazonaws.com')
284+
});
285+
}
265286

266287
if (this.props2.adminPermissions) {
267288
this._deploymentRole.addToPolicy(new iam.PolicyStatement({

packages/@aws-cdk/aws-codepipeline-actions/test/cloudformation/test.cloudformation-pipeline-actions.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { expect, haveResource, haveResourceLike } from '@aws-cdk/assert';
22
import { CloudFormationCapabilities } from '@aws-cdk/aws-cloudformation';
33
import codebuild = require('@aws-cdk/aws-codebuild');
4+
import codecommit = require('@aws-cdk/aws-codecommit');
45
import { Repository } from '@aws-cdk/aws-codecommit';
56
import codepipeline = require('@aws-cdk/aws-codepipeline');
67
import { Role } from '@aws-cdk/aws-iam';
@@ -544,6 +545,84 @@ export = {
544545

545546
test.done();
546547
},
548+
549+
'cross-account CFN Pipeline': {
550+
'correctly creates the deployment Role in the other account'(test: Test) {
551+
const app = new cdk.App();
552+
553+
const pipelineStack = new cdk.Stack(app, 'PipelineStack', {
554+
env: {
555+
account: '234567890123',
556+
region: 'us-west-2',
557+
},
558+
});
559+
560+
const sourceOutput = new codepipeline.Artifact();
561+
new codepipeline.Pipeline(pipelineStack, 'Pipeline', {
562+
stages: [
563+
{
564+
stageName: 'Source',
565+
actions: [
566+
new cpactions.CodeCommitSourceAction({
567+
actionName: 'CodeCommit',
568+
repository: codecommit.Repository.fromRepositoryName(pipelineStack, 'Repo', 'RepoName'),
569+
output: sourceOutput,
570+
}),
571+
],
572+
},
573+
{
574+
stageName: 'Deploy',
575+
actions: [
576+
new cpactions.CloudFormationCreateUpdateStackAction({
577+
actionName: 'CFN',
578+
stackName: 'MyStack',
579+
adminPermissions: true,
580+
templatePath: sourceOutput.atPath('template.yaml'),
581+
account: '123456789012',
582+
}),
583+
],
584+
},
585+
],
586+
});
587+
588+
expect(pipelineStack).to(haveResourceLike('AWS::CodePipeline::Pipeline', {
589+
"Stages": [
590+
{
591+
"Name": "Source",
592+
},
593+
{
594+
"Name": "Deploy",
595+
"Actions": [
596+
{
597+
"Name": "CFN",
598+
"RoleArn": { "Fn::Join": ["", ["arn:", { "Ref": "AWS::Partition" },
599+
":iam::123456789012:role/pipelinestack-support-123loycfnactionrole56af64af3590f311bc50",
600+
]],
601+
},
602+
"Configuration": {
603+
"RoleArn": {
604+
"Fn::Join": ["", ["arn:", { "Ref": "AWS::Partition" },
605+
":iam::123456789012:role/pipelinestack-support-123fndeploymentrole4668d9b5a30ce3dc4508",
606+
]],
607+
},
608+
},
609+
},
610+
],
611+
},
612+
],
613+
}));
614+
615+
const otherStack = app.node.findChild('cross-account-support-stack-123456789012') as cdk.Stack;
616+
expect(otherStack).to(haveResourceLike('AWS::IAM::Role', {
617+
"RoleName": "pipelinestack-support-123loycfnactionrole56af64af3590f311bc50",
618+
}));
619+
expect(otherStack).to(haveResourceLike('AWS::IAM::Role', {
620+
"RoleName": "pipelinestack-support-123fndeploymentrole4668d9b5a30ce3dc4508",
621+
}));
622+
623+
test.done();
624+
},
625+
},
547626
};
548627

549628
/**

packages/@aws-cdk/aws-codepipeline/lib/action.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,17 @@ export interface ActionProperties {
4242
*/
4343
readonly region?: string;
4444

45+
/**
46+
* The account the Action is supposed to live in.
47+
* For Actions backed by resources,
48+
* this is inferred from the Stack {@link resource} is part of.
49+
* However, some Actions, like the CloudFormation ones,
50+
* are not backed by any resource, and they still might want to be cross-account.
51+
* In general, a concrete Action class should specify either {@link resource},
52+
* or {@link account} - but not both.
53+
*/
54+
readonly account?: string;
55+
4556
/**
4657
* The optional resource that is backing this Action.
4758
* This is used for automatically handling Actions backed by

packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts

Lines changed: 114 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -375,10 +375,7 @@ export class Pipeline extends PipelineBase {
375375
throw new Error("You need to specify an explicit account when using CodePipeline's cross-region support");
376376
}
377377

378-
const app = this.node.root;
379-
if (!app || !App.isApp(app)) {
380-
throw new Error(`Pipeline stack which uses cross region actions must be part of a CDK app`);
381-
}
378+
const app = this.requireApp();
382379
const crossRegionScaffoldStack = new CrossRegionSupportStack(app, `cross-region-stack-${pipelineAccount}:${region}`, {
383380
pipelineStackName: pipelineStack.stackName,
384381
region,
@@ -404,44 +401,16 @@ export class Pipeline extends PipelineBase {
404401

405402
/**
406403
* Gets the role used for this action,
407-
* including handling the case when the action is supposed to be cross-region.
404+
* including handling the case when the action is supposed to be cross-account.
408405
*
409406
* @param stage the stage the action belongs to
410407
* @param action the action to return/create a role for
408+
* @param actionScope the scope, unique to the action, to create new resources in
411409
*/
412410
private getRoleForAction(stage: Stage, action: IAction, actionScope: Construct): iam.IRole | undefined {
413411
const pipelineStack = Stack.of(this);
414412

415-
let actionRole: iam.IRole | undefined;
416-
if (action.actionProperties.role) {
417-
if (!this.isAwsOwned(action)) {
418-
throw new Error("Specifying a Role is not supported for actions with an owner different than 'AWS' - " +
419-
`got '${action.actionProperties.owner}' (Action: '${action.actionProperties.actionName}' in Stage: '${stage.stageName}')`);
420-
}
421-
actionRole = action.actionProperties.role;
422-
} else if (action.actionProperties.resource) {
423-
const resourceStack = Stack.of(action.actionProperties.resource);
424-
// check if resource is from a different account
425-
if (pipelineStack.environment !== resourceStack.environment) {
426-
// if it is, the pipeline's bucket must have a KMS key
427-
if (!this.artifactBucket.encryptionKey) {
428-
throw new Error('The Pipeline is being used in a cross-account manner, ' +
429-
'but its artifact bucket does not have a KMS key defined. ' +
430-
'A KMS key is required for a cross-account Pipeline. ' +
431-
'Make sure to pass a Bucket with a Key when creating the Pipeline');
432-
}
433-
434-
// generate a role in the other stack, that the Pipeline will assume for executing this action
435-
actionRole = new iam.Role(resourceStack,
436-
`${this.node.uniqueId}-${stage.stageName}-${action.actionProperties.actionName}-ActionRole`, {
437-
assumedBy: new iam.AccountPrincipal(pipelineStack.account),
438-
roleName: PhysicalName.GENERATE_IF_NEEDED,
439-
});
440-
441-
// the other stack has to be deployed before the pipeline stack
442-
pipelineStack.addDependency(resourceStack);
443-
}
444-
}
413+
let actionRole = this.getRoleFromActionPropsOrGenerateIfCrossAccount(stage, action);
445414

446415
if (!actionRole && this.isAwsOwned(action)) {
447416
// generate a Role for this specific Action
@@ -461,6 +430,107 @@ export class Pipeline extends PipelineBase {
461430
return actionRole;
462431
}
463432

433+
private getRoleFromActionPropsOrGenerateIfCrossAccount(stage: Stage, action: IAction): iam.IRole | undefined {
434+
const pipelineStack = Stack.of(this);
435+
436+
// if a Role has been passed explicitly, always use it
437+
// (even if the backing resource is from a different account -
438+
// this is how the user can override our default support logic)
439+
if (action.actionProperties.role) {
440+
if (this.isAwsOwned(action)) {
441+
// the role has to be deployed before the pipeline
442+
const roleStack = Stack.of(action.actionProperties.role);
443+
pipelineStack.addDependency(roleStack);
444+
445+
return action.actionProperties.role;
446+
} else {
447+
// ...except if the Action is not owned by 'AWS',
448+
// as that would be rejected by CodePipeline at deploy time
449+
throw new Error("Specifying a Role is not supported for actions with an owner different than 'AWS' - " +
450+
`got '${action.actionProperties.owner}' (Action: '${action.actionProperties.actionName}' in Stage: '${stage.stageName}')`);
451+
}
452+
}
453+
454+
// if we don't have a Role passed,
455+
// and the action is cross-account,
456+
// generate a Role in that other account stack
457+
const otherAccountStack = this.getOtherStackIfActionIsCrossAccount(action);
458+
if (!otherAccountStack) {
459+
return undefined;
460+
}
461+
462+
// if we have a cross-account action, the pipeline's bucket must have a KMS key
463+
if (!this.artifactBucket.encryptionKey) {
464+
throw new Error('The Pipeline is being used in a cross-account manner, ' +
465+
'but its artifact bucket does not have a KMS key defined. ' +
466+
'A KMS key is required for a cross-account Pipeline. ' +
467+
'Make sure to pass a Bucket with a Key when creating the Pipeline');
468+
}
469+
470+
// generate a role in the other stack, that the Pipeline will assume for executing this action
471+
const ret = new iam.Role(otherAccountStack,
472+
`${this.node.uniqueId}-${stage.stageName}-${action.actionProperties.actionName}-ActionRole`, {
473+
assumedBy: new iam.AccountPrincipal(pipelineStack.account),
474+
roleName: PhysicalName.GENERATE_IF_NEEDED,
475+
});
476+
// the other stack with the role has to be deployed before the pipeline stack
477+
// (CodePipeline verifies you can assume the action Role on creation)
478+
pipelineStack.addDependency(otherAccountStack);
479+
480+
return ret;
481+
}
482+
483+
/**
484+
* Returns the Stack this Action belongs to if this is a cross-account Action.
485+
* If this Action is not cross-account (i.e., it lives in the same account as the Pipeline),
486+
* it returns undefined.
487+
*
488+
* @param action the Action to return the Stack for
489+
*/
490+
private getOtherStackIfActionIsCrossAccount(action: IAction): Stack | undefined {
491+
const pipelineStack = Stack.of(this);
492+
493+
if (action.actionProperties.resource) {
494+
const resourceStack = Stack.of(action.actionProperties.resource);
495+
// check if resource is from a different account
496+
return pipelineStack.account === resourceStack.account
497+
? undefined
498+
: resourceStack;
499+
}
500+
501+
if (!action.actionProperties.account) {
502+
return undefined;
503+
}
504+
505+
const targetAccount = action.actionProperties.account;
506+
// check whether the account is a static string
507+
if (Token.isUnresolved(targetAccount)) {
508+
throw new Error(`The 'account' property must be a concrete value (action: '${action.actionProperties.actionName}')`);
509+
}
510+
// check whether the pipeline account is a static string
511+
if (Token.isUnresolved(pipelineStack.account)) {
512+
throw new Error("Pipeline stack which uses cross-environment actions must have an explicitly set account");
513+
}
514+
515+
if (pipelineStack.account === targetAccount) {
516+
return undefined;
517+
}
518+
519+
const stackId = `cross-account-support-stack-${targetAccount}`;
520+
const app = this.requireApp();
521+
let targetAccountStack = app.node.tryFindChild(stackId) as Stack;
522+
if (!targetAccountStack) {
523+
targetAccountStack = new Stack(app, stackId, {
524+
stackName: `${pipelineStack.stackName}-support-${targetAccount}`,
525+
env: {
526+
account: targetAccount,
527+
region: action.actionProperties.region ? action.actionProperties.region : pipelineStack.region,
528+
},
529+
});
530+
}
531+
return targetAccountStack;
532+
}
533+
464534
private isAwsOwned(action: IAction) {
465535
const owner = action.actionProperties.owner;
466536
return !owner || owner === 'AWS';
@@ -626,10 +696,18 @@ export class Pipeline extends PipelineBase {
626696
private requireRegion(): string {
627697
const region = Stack.of(this).region;
628698
if (Token.isUnresolved(region)) {
629-
throw new Error(`You need to specify an explicit region when using CodePipeline's cross-region support`);
699+
throw new Error(`Pipeline stack which uses cross-environment actions must have an explicitly set region`);
630700
}
631701
return region;
632702
}
703+
704+
private requireApp(): App {
705+
const app = this.node.root;
706+
if (!app || !App.isApp(app)) {
707+
throw new Error(`Pipeline stack which uses cross-environment actions must be part of a CDK app`);
708+
}
709+
return app;
710+
}
633711
}
634712

635713
/**

packages/@aws-cdk/aws-codepipeline/test/fake-build-action.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export interface FakeBuildActionProps extends codepipeline.CommonActionProps {
1313
owner?: string;
1414

1515
role?: iam.IRole;
16+
17+
account?: string;
1618
}
1719

1820
export class FakeBuildAction implements codepipeline.IAction {

0 commit comments

Comments
 (0)