Skip to content

Commit

Permalink
feat(servicecatalog): support local launch role name in launch role c…
Browse files Browse the repository at this point in the history
…onstraint (#17371)

Service Catalog Launch Role Constraints support the ability to reference a role by name. Many customers use Service Catalog in a hub and spoke model, sharing portfolios to many accounts. This feature allows the launch role to be account agnostic, as the arn is tied to a single account. As a result, a launch role constraint can be created once with the role name and shared rather than sharing the portfolio and creating a launch role constraint in each spoke account.


----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*

Co-authored-by: Aidan Crank <arcrank@gmail.com>
  • Loading branch information
dponzo and arcrank committed Nov 10, 2021
1 parent 9c77e94 commit b307b69
Show file tree
Hide file tree
Showing 5 changed files with 234 additions and 23 deletions.
30 changes: 29 additions & 1 deletion packages/@aws-cdk/aws-servicecatalog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,8 +307,36 @@ const launchRole = new iam.Role(this, 'LaunchRole', {
portfolio.setLaunchRole(product, launchRole);
```

You can also set the launch role using just the name of a role which is locally deployed in end user accounts.
This is useful for when roles and users are separately managed outside of the CDK.
The given role must exist in both the account that creates the launch role constraint,
as well as in any end user accounts that wish to provision a product with the launch role.

You can do this by passing in the role with an explicitly set name:

```ts fixture=portfolio-product
import * as iam from '@aws-cdk/aws-iam';

const launchRole = new iam.Role(this, 'LaunchRole', {
roleName: 'MyRole',
assumedBy: new iam.ServicePrincipal('servicecatalog.amazonaws.com'),
});

portfolio.setLocalLaunchRole(product, launchRole);
```

Or you can simply pass in a role name and CDK will create a role with that name that trusts service catalog in the account:

```ts fixture=portfolio-product
import * as iam from '@aws-cdk/aws-iam';

const roleName = 'MyRole';

const launchRole: iam.IRole = portfolio.setLocalLaunchRoleName(product, roleName);
```

See [Launch Constraint](https://docs.aws.amazon.com/servicecatalog/latest/adminguide/constraints-launch.html) documentation
to understand permissions roles need.
to understand the permissions roles need.

### Deploy with StackSets

Expand Down
42 changes: 41 additions & 1 deletion packages/@aws-cdk/aws-servicecatalog/lib/portfolio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ export interface IPortfolio extends cdk.IResource {

/**
* Force users to assume a certain role when launching a product.
* This sets the launch role using the role arn which is tied to the account this role exists in.
* This is useful if you will be provisioning products from the account where this role exists.
* If you intend to share the portfolio across accounts, use a local launch role.
*
* @param product A service catalog product.
* @param launchRole The IAM role a user must assume when provisioning the product.
Expand All @@ -120,7 +123,30 @@ export interface IPortfolio extends cdk.IResource {
setLaunchRole(product: IProduct, launchRole: iam.IRole, options?: CommonConstraintOptions): void;

/**
* Configure deployment options using AWS Cloudformaiton StackSets
* Force users to assume a certain role when launching a product.
* The role will be referenced by name in the local account instead of a static role arn.
* A role with this name will automatically be created and assumable by Service Catalog in this account.
* This is useful when sharing the portfolio with multiple accounts.
*
* @param product A service catalog product.
* @param launchRoleName The name of the IAM role a user must assume when provisioning the product. A role with this name must exist in the account where the portolio is created and the accounts it is shared with.
* @param options options for the constraint.
*/
setLocalLaunchRoleName(product: IProduct, launchRoleName: string, options?: CommonConstraintOptions): iam.IRole;

/**
* Force users to assume a certain role when launching a product.
* The role name will be referenced by in the local account and must be set explicitly.
* This is useful when sharing the portfolio with multiple accounts.
*
* @param product A service catalog product.
* @param launchRole The IAM role a user must assume when provisioning the product. A role with this name must exist in the account where the portolio is created and the accounts it is shared with. The role name must be set explicitly.
* @param options options for the constraint.
*/
setLocalLaunchRole(product: IProduct, launchRole: iam.IRole, options?: CommonConstraintOptions): void;

/**
* Configure deployment options using AWS Cloudformation StackSets
*
* @param product A service catalog product.
* @param options Configuration options for the constraint.
Expand Down Expand Up @@ -179,6 +205,20 @@ abstract class PortfolioBase extends cdk.Resource implements IPortfolio {
AssociationManager.setLaunchRole(this, product, launchRole, options);
}

public setLocalLaunchRoleName(product: IProduct, launchRoleName: string, options: CommonConstraintOptions = {}): iam.IRole {
const launchRole: iam.IRole = new iam.Role(this, `LaunchRole${launchRoleName}`, {
roleName: launchRoleName,
assumedBy: new iam.ServicePrincipal('servicecatalog.amazonaws.com'),
});
AssociationManager.setLocalLaunchRoleName(this, product, launchRole.roleName, options);
return launchRole;
}

public setLocalLaunchRole(product: IProduct, launchRole: iam.IRole, options: CommonConstraintOptions = {}): void {
InputValidator.validateRoleNameSetForLocalLaunchRole(launchRole);
AssociationManager.setLocalLaunchRoleName(this, product, launchRole.roleName, options);
}

public deployWithStackSets(product: IProduct, options: StackSetsConstraintOptions) {
AssociationManager.deployWithStackSets(this, product, options);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,27 +100,15 @@ export class AssociationManager {
}

public static setLaunchRole(portfolio: IPortfolio, product: IProduct, launchRole: iam.IRole, options: CommonConstraintOptions): void {
const association = this.associateProductWithPortfolio(portfolio, product, options);
// Check if a stackset deployment constraint has already been configured.
if (portfolio.node.tryFindChild(this.stackSetConstraintLogicalId(association.associationKey))) {
throw new Error(`Cannot set launch role when a StackSet rule is already defined for association ${this.prettyPrintAssociation(portfolio, product)}`);
}

const constructId = this.launchRoleConstraintLogicalId(association.associationKey);
if (!portfolio.node.tryFindChild(constructId)) {
const constraint = new CfnLaunchRoleConstraint(portfolio as unknown as cdk.Resource, constructId, {
acceptLanguage: options.messageLanguage,
description: options.description,
portfolioId: portfolio.portfolioId,
productId: product.productId,
roleArn: launchRole.roleArn,
});
this.setLaunchRoleConstraint(portfolio, product, options, {
roleArn: launchRole.roleArn,
});
}

// Add dependsOn to force proper order in deployment.
constraint.addDependsOn(association.cfnPortfolioProductAssociation);
} else {
throw new Error(`Cannot set multiple launch roles for association ${this.prettyPrintAssociation(portfolio, product)}`);
}
public static setLocalLaunchRoleName(portfolio: IPortfolio, product: IProduct, launchRoleName: string, options: CommonConstraintOptions): void {
this.setLaunchRoleConstraint(portfolio, product, options, {
localRoleName: launchRoleName,
});
}

public static deployWithStackSets(portfolio: IPortfolio, product: IProduct, options: StackSetsConstraintOptions) {
Expand Down Expand Up @@ -179,6 +167,34 @@ export class AssociationManager {
};
}

private static setLaunchRoleConstraint(
portfolio: IPortfolio, product: IProduct, options: CommonConstraintOptions,
roleOptions: LaunchRoleConstraintRoleOptions,
): void {
const association = this.associateProductWithPortfolio(portfolio, product, options);
// Check if a stackset deployment constraint has already been configured.
if (portfolio.node.tryFindChild(this.stackSetConstraintLogicalId(association.associationKey))) {
throw new Error(`Cannot set launch role when a StackSet rule is already defined for association ${this.prettyPrintAssociation(portfolio, product)}`);
}

const constructId = this.launchRoleConstraintLogicalId(association.associationKey);
if (!portfolio.node.tryFindChild(constructId)) {
const constraint = new CfnLaunchRoleConstraint(portfolio as unknown as cdk.Resource, constructId, {
acceptLanguage: options.messageLanguage,
description: options.description,
portfolioId: portfolio.portfolioId,
productId: product.productId,
roleArn: roleOptions.roleArn,
localRoleName: roleOptions.localRoleName,
});

// Add dependsOn to force proper order in deployment.
constraint.addDependsOn(association.cfnPortfolioProductAssociation);
} else {
throw new Error(`Cannot set multiple launch roles for association ${this.prettyPrintAssociation(portfolio, product)}`);
}
}

private static stackSetConstraintLogicalId(associationKey: string): string {
return `StackSetConstraint${associationKey}`;
}
Expand Down Expand Up @@ -213,3 +229,14 @@ export class AssociationManager {
};
}

interface LaunchRoleArnOption {
readonly roleArn: string,
readonly localRoleName?: never,
}

interface LaunchRoleNameOption {
readonly localRoleName: string,
readonly roleArn?: never,
}

type LaunchRoleConstraintRoleOptions = LaunchRoleArnOption | LaunchRoleNameOption;
12 changes: 12 additions & 0 deletions packages/@aws-cdk/aws-servicecatalog/lib/private/validation.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as iam from '@aws-cdk/aws-iam';
import * as cdk from '@aws-cdk/core';

/**
Expand Down Expand Up @@ -36,6 +37,17 @@ export class InputValidator {
this.validateRegex(resourceName, inputName, /^[\w\d.%+\-]+@[a-z\d.\-]+\.[a-z]{2,4}$/i, inputString);
}

/**
* Validates that a role being used as a local launch role has the role name set
*/
public static validateRoleNameSetForLocalLaunchRole(role: iam.IRole): void {
if (role.node.defaultChild) {
if (cdk.Token.isUnresolved((role.node.defaultChild as iam.CfnRole).roleName)) {
throw new Error(`Role ${role.node.id} used for Local Launch Role must have roleName explicitly set`);
}
}
}

private static truncateString(string: string, maxLength: number): string {
if (string.length > maxLength) {
return string.substring(0, maxLength) + '[truncated]';
Expand Down
106 changes: 105 additions & 1 deletion packages/@aws-cdk/aws-servicecatalog/test/portfolio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,7 @@ describe('portfolio associations and product constraints', () => {
assumedBy: new iam.AccountRootPrincipal(),
});
launchRole = new iam.Role(stack, 'LaunchRole', {
roleName: 'LaunchRole',
assumedBy: new iam.ServicePrincipal('servicecatalog.amazonaws.com'),
});
}),
Expand All @@ -591,6 +592,59 @@ describe('portfolio associations and product constraints', () => {
});
}),

test('set a launch role constraint using local role name', () => {
portfolio.addProduct(product);

portfolio.setLocalLaunchRoleName(product, 'LocalLaunchRole', {
description: 'set launch role description',
messageLanguage: servicecatalog.MessageLanguage.EN,
});

Template.fromStack(stack).hasResourceProperties('AWS::ServiceCatalog::LaunchRoleConstraint', {
PortfolioId: { Ref: 'MyPortfolio59CCA9C9' },
ProductId: { Ref: 'MyProduct49A3C587' },
Description: 'set launch role description',
AcceptLanguage: 'en',
LocalRoleName: { Ref: 'MyPortfolioLaunchRoleLocalLaunchRoleB2E6E22A' },
});
}),

test('set a launch role constraint using local role', () => {
portfolio.addProduct(product);

portfolio.setLocalLaunchRole(product, launchRole, {
description: 'set launch role description',
messageLanguage: servicecatalog.MessageLanguage.EN,
});

Template.fromStack(stack).hasResourceProperties('AWS::ServiceCatalog::LaunchRoleConstraint', {
PortfolioId: { Ref: 'MyPortfolio59CCA9C9' },
ProductId: { Ref: 'MyProduct49A3C587' },
Description: 'set launch role description',
AcceptLanguage: 'en',
LocalRoleName: { Ref: 'LaunchRole2CFB2E44' },
});
}),

test('set a launch role constraint using imported local role', () => {
portfolio.addProduct(product);

const importedLaunchRole = iam.Role.fromRoleArn(portfolio.stack, 'ImportedLaunchRole', 'arn:aws:iam::123456789012:role/ImportedLaunchRole');

portfolio.setLocalLaunchRole(product, importedLaunchRole, {
description: 'set launch role description',
messageLanguage: servicecatalog.MessageLanguage.EN,
});

Template.fromStack(stack).hasResourceProperties('AWS::ServiceCatalog::LaunchRoleConstraint', {
PortfolioId: { Ref: 'MyPortfolio59CCA9C9' },
ProductId: { Ref: 'MyProduct49A3C587' },
Description: 'set launch role description',
AcceptLanguage: 'en',
LocalRoleName: 'ImportedLaunchRole',
});
}),

test('set launch role constraint still adds without explicit association', () => {
portfolio.setLaunchRole(product, launchRole);

Expand All @@ -606,7 +660,57 @@ describe('portfolio associations and product constraints', () => {

expect(() => {
portfolio.setLaunchRole(product, otherLaunchRole);
}).toThrowError(/Cannot set multiple launch roles for association/);
}).toThrow(/Cannot set multiple launch roles for association/);
}),

test('local launch role must have roleName explicitly set', () => {
const otherLaunchRole = new iam.Role(stack, 'otherLaunchRole', {
assumedBy: new iam.ServicePrincipal('servicecatalog.amazonaws.com'),
});

expect(() => {
portfolio.setLocalLaunchRole(product, otherLaunchRole);
}).toThrow(/Role otherLaunchRole used for Local Launch Role must have roleName explicitly set/);
}),

test('fails to add multiple set launch roles - local launch role first', () => {
portfolio.setLocalLaunchRoleName(product, 'LaunchRole');

expect(() => {
portfolio.setLaunchRole(product, launchRole);
}).toThrow(/Cannot set multiple launch roles for association/);
}),

test('fails to add multiple set local launch roles - local launch role first', () => {
portfolio.setLocalLaunchRoleName(product, 'LaunchRole');

expect(() => {
portfolio.setLocalLaunchRole(product, launchRole);
}).toThrow(/Cannot set multiple launch roles for association/);
}),

test('fails to add multiple set local launch roles - local launch role name first', () => {
portfolio.setLocalLaunchRole(product, launchRole);

expect(() => {
portfolio.setLocalLaunchRoleName(product, 'LaunchRole');
}).toThrow(/Cannot set multiple launch roles for association/);
}),

test('fails to add multiple set launch roles - local launch role second', () => {
portfolio.setLaunchRole(product, launchRole);

expect(() => {
portfolio.setLocalLaunchRole(product, launchRole);
}).toThrow(/Cannot set multiple launch roles for association/);
}),

test('fails to add multiple set launch roles - local launch role second', () => {
portfolio.setLaunchRole(product, launchRole);

expect(() => {
portfolio.setLocalLaunchRoleName(product, 'LaunchRole');
}).toThrow(/Cannot set multiple launch roles for association/);
}),

test('fails to set launch role if stackset rule is already defined', () => {
Expand Down

0 comments on commit b307b69

Please sign in to comment.