From b307b6996ed13b1f2dedeb41d29409183becb969 Mon Sep 17 00:00:00 2001 From: Dillon Date: Tue, 9 Nov 2021 21:28:14 -0500 Subject: [PATCH] feat(servicecatalog): support local launch role name in launch role constraint (#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 --- .../@aws-cdk/aws-servicecatalog/README.md | 30 ++++- .../aws-servicecatalog/lib/portfolio.ts | 42 ++++++- .../lib/private/association-manager.ts | 67 +++++++---- .../lib/private/validation.ts | 12 ++ .../aws-servicecatalog/test/portfolio.test.ts | 106 +++++++++++++++++- 5 files changed, 234 insertions(+), 23 deletions(-) diff --git a/packages/@aws-cdk/aws-servicecatalog/README.md b/packages/@aws-cdk/aws-servicecatalog/README.md index 2d7694d3e84b6..bbc82e13e2d7b 100644 --- a/packages/@aws-cdk/aws-servicecatalog/README.md +++ b/packages/@aws-cdk/aws-servicecatalog/README.md @@ -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 diff --git a/packages/@aws-cdk/aws-servicecatalog/lib/portfolio.ts b/packages/@aws-cdk/aws-servicecatalog/lib/portfolio.ts index 3056a48e19777..36d267d022519 100644 --- a/packages/@aws-cdk/aws-servicecatalog/lib/portfolio.ts +++ b/packages/@aws-cdk/aws-servicecatalog/lib/portfolio.ts @@ -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. @@ -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. @@ -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); } diff --git a/packages/@aws-cdk/aws-servicecatalog/lib/private/association-manager.ts b/packages/@aws-cdk/aws-servicecatalog/lib/private/association-manager.ts index bf5a68a8e70d3..b92fb2483ad54 100644 --- a/packages/@aws-cdk/aws-servicecatalog/lib/private/association-manager.ts +++ b/packages/@aws-cdk/aws-servicecatalog/lib/private/association-manager.ts @@ -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) { @@ -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}`; } @@ -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; diff --git a/packages/@aws-cdk/aws-servicecatalog/lib/private/validation.ts b/packages/@aws-cdk/aws-servicecatalog/lib/private/validation.ts index 3beaa42552eff..cd70006fbe373 100644 --- a/packages/@aws-cdk/aws-servicecatalog/lib/private/validation.ts +++ b/packages/@aws-cdk/aws-servicecatalog/lib/private/validation.ts @@ -1,3 +1,4 @@ +import * as iam from '@aws-cdk/aws-iam'; import * as cdk from '@aws-cdk/core'; /** @@ -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]'; diff --git a/packages/@aws-cdk/aws-servicecatalog/test/portfolio.test.ts b/packages/@aws-cdk/aws-servicecatalog/test/portfolio.test.ts index 8f9a27a96a940..43a283c157f80 100644 --- a/packages/@aws-cdk/aws-servicecatalog/test/portfolio.test.ts +++ b/packages/@aws-cdk/aws-servicecatalog/test/portfolio.test.ts @@ -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'), }); }), @@ -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); @@ -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', () => {