Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(servicecatalog): Add Product Stack Asset Support #22857

Merged
merged 15 commits into from
Dec 28, 2022
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions packages/@aws-cdk/aws-servicecatalog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ enables organizations to create and manage catalogs of products for their end us
- [Product](#product)
- [Creating a product from a local asset](#creating-a-product-from-local-asset)
- [Creating a product from a stack](#creating-a-product-from-a-stack)
- [Using Assets in your Product Stack](#using-aseets-in-your-product-stack)
- [Creating a Product from a stack with a history of previous versions](#creating-a-product-from-a-stack-with-a-history-of-all-previous-versions)
- [Adding a product to a portfolio](#adding-a-product-to-a-portfolio)
- [TagOptions](#tag-options)
Expand Down Expand Up @@ -185,6 +186,108 @@ const product = new servicecatalog.CloudFormationProduct(this, 'Product', {
});
```

### Using Assets in your Product Stack

You can reference assets in a Product Stack. For example, we can add a handler to a Lambda function or a S3 Asset directly from a local asset file.
In this case, you must provide a S3 Bucket with a bucketName to store your assets.

```ts
import * as lambda from '@aws-cdk/aws-lambda';
import * as cdk from '@aws-cdk/core';
import { Bucket } from "@aws-cdk/aws-s3";

class LambdaProduct extends servicecatalog.ProductStack {
constructor(scope: Construct, id: string) {
super(scope, id);

new lambda.Function(this, 'LambdaProduct', {
runtime: lambda.Runtime.PYTHON_3_9,
code: lambda.Code.fromAsset("./assets"),
handler: 'index.handler'
});
}
}

const userDefinedBucket = new Bucket(this, `UserDefinedBucket`, {
bucketName: 'user-defined-bucket-for-product-stack-assets',
});

const product = new servicecatalog.CloudFormationProduct(this, 'Product', {
productName: "My Product",
owner: "Product Owner",
productVersions: [
{
productVersionName: "v1",
cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromProductStack(new LambdaProduct(this, 'LambdaFunctionProduct', {
assetBucket: userDefinedBucket,
})),
},
],
});
```

When a product containing an asset is shared with a spoke account, the corresponding asset bucket
will automatically grant read permissions to the spoke account.
Note, it is not recommended using a referenced bucket as permissions cannot be added from CDK.
In this case, it will be your responsibility to grant read permissions for the asset bucket to
the spoke account.
If you want to provide your own bucket policy or scope down your bucket policy further to only allow
reads from a specific launch role, refer to the following example policy:

```ts
new iam.PolicyStatement({
actions: [
's3:GetObject*',
's3:GetBucket*',
's3:List*', ],
effect: iam.Effect.ALLOW,
resources: [
bucket.bucketArn,
bucket.arnForObjects('*'),
],
principals: [
new iam.ArnPrincipal(cdk.Stack.of(this).formatArn({
service: 'iam',
region: '',
sharedAccount,
resource: 'role',
resourceName: launchRoleName,
}))
],
conditions: {
'ForAnyValue:StringEquals': {
'aws:CalledVia': ['cloudformation.amazonaws.com'],
},
'Bool': {
'aws:ViaAWSService': true,
},
},
});
```

Furthermore, in order for a spoke account to provision a product with an asset, the role launching
the product needs permissions to read from the asset bucket.
We recommend you utilize a launch role with permissions to read from the asset bucket.
For example your launch role would need to include at least the following policy:
Comment on lines +268 to +271
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we make the permissions more specific if they are using a launch role?
For example if they provide a launch role via portfolio.setLocalLaunchRoleName() then we could specify that as part of the bucket policy. From my testing it looks like the most specific we can get is something like this:

new iam.PolicyStatement({
	actions: [
		's3:GetObject*',
		's3:GetBucket*',
		's3:List*', ],
	effect: iam.Effect.ALLOW,
	resources: [
		bucket.bucketArn,
		bucket.arnForObjects('*'),
	],
	principals: [
		...this.sharedAccounts.map(account => new iam.ArnPrincipal(cdk.Stack.of(this).formatArn({
			service: 'iam',
			region: '',
			account,
			resource: 'role',
			resourceName: launchRoleName,
		}))),
	],
	conditions: {
		'ForAnyValue:StringEquals': {
			'aws:CalledVia': ['cloudformation.amazonaws.com'],
		},
		'Bool': {
			'aws:ViaAWSService': true,
		},
	},
});

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean actually adding that PolicyStatement to their launch role. We would have to save the LaunchRole in portfolio when it is set and add it our aspect, so we can propagate the sharedAccounts regardless of the order it is called. There can also be multiple bucket and multiple accounts and other possible complications trying to generate that PolicyStatement. I'm not if it worth the effort or if the customer wants us adding permissions to their launchRole for them.

Or do you mean adding this to the documentation as a recommended policy statement to use for launch role when deploying assets? I think this a good alternative as they can craft a PolicyStatement using that more specific template you provided and easily add it to their LaunchRole if needed.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No not to the launch role. This would be the policy that we add to the bucket. Rather than allowing read from any principal in the shared accounts, this only allows read from the launch role (if one is specified).

If this isn't going to cover all use cases though then we can leave the bucket policy as is and just add this as an example policy to the docs if users want to scope down the access more.


```json
{
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject"
],
"Resource": "*"
}
]
}
```

Please refer to [Set launch role](#set-launch-role) for additional details about launch roles.
See [Launch Constraint](https://docs.aws.amazon.com/servicecatalog/latest/adminguide/constraints-launch.html) documentation
to understand the permissions that launch roles need.

### Creating a Product from a stack with a history of previous versions

The default behavior of Service Catalog is to overwrite each product version upon deployment.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { IBucket } from '@aws-cdk/aws-s3';
import * as s3_assets from '@aws-cdk/aws-s3-assets';
import { Construct } from 'constructs';
import { hashValues } from './private/util';
Expand Down Expand Up @@ -46,9 +47,16 @@ export abstract class CloudFormationTemplate {
*/
export interface CloudFormationTemplateConfig {
/**
* The http url of the template in S3.
*/
* The http url of the template in S3.
*/
readonly httpUrl: string;

/**
* The S3 bucket containing product stack assets.
* @default - None - no assets are used in this product
*/
readonly assetBucket?: IBucket;

}

/**
Expand Down Expand Up @@ -108,6 +116,7 @@ class CloudFormationProductStackTemplate extends CloudFormationTemplate {
public bind(_scope: Construct): CloudFormationTemplateConfig {
return {
httpUrl: this.productStack._getTemplateUrl(),
assetBucket: this.productStack._getAssetBucket(),
};
}
}
35 changes: 33 additions & 2 deletions packages/@aws-cdk/aws-servicecatalog/lib/portfolio.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import * as iam from '@aws-cdk/aws-iam';
import { IBucket } from '@aws-cdk/aws-s3';
import * as sns from '@aws-cdk/aws-sns';
import * as cdk from '@aws-cdk/core';
import { Construct } from 'constructs';
import { Construct, IConstruct } from 'constructs';
import { MessageLanguage } from './common';
import {
CloudFormationRuleConstraintOptions, CommonConstraintOptions,
Expand Down Expand Up @@ -105,7 +106,7 @@ export interface IPortfolio extends cdk.IResource {
* @param product A service catalog product.
* @param options options for the constraint.
*/
constrainCloudFormationParameters(product:IProduct, options: CloudFormationRuleConstraintOptions): void;
constrainCloudFormationParameters(product: IProduct, options: CloudFormationRuleConstraintOptions): void;

/**
* Force users to assume a certain role when launching a product.
Expand Down Expand Up @@ -155,6 +156,8 @@ abstract class PortfolioBase extends cdk.Resource implements IPortfolio {
public abstract readonly portfolioArn: string;
public abstract readonly portfolioId: string;
private readonly associatedPrincipals: Set<string> = new Set();
private readonly assetBuckets: Set<IBucket> = new Set<IBucket>();
private readonly sharedAccounts: string[] = [];

public giveAccessToRole(role: iam.IRole): void {
this.associatePrincipal(role.roleArn, role.node.addr);
Expand All @@ -169,11 +172,17 @@ abstract class PortfolioBase extends cdk.Resource implements IPortfolio {
}

public addProduct(product: IProduct): void {
if (product.assetBuckets) {
for (const bucket of product.assetBuckets) {
this.assetBuckets.add(bucket);
}
}
AssociationManager.associateProductWithPortfolio(this, product, undefined);
}

public shareWithAccount(accountId: string, options: PortfolioShareOptions = {}): void {
const hashId = this.generateUniqueHash(accountId);
this.sharedAccounts.push(accountId);
new CfnPortfolioShare(this, `PortfolioShare${hashId}`, {
portfolioId: this.portfolioId,
accountId: accountId,
Expand Down Expand Up @@ -236,6 +245,19 @@ abstract class PortfolioBase extends cdk.Resource implements IPortfolio {
}
}

/**
* Gives access to Asset Buckets to Shared Accounts.
*
*/
protected addBucketPermissionsToSharedAccounts() {
if (this.sharedAccounts.length > 0) {
for (const bucket of this.assetBuckets) {
bucket.grantRead(new iam.CompositePrincipal(...this.sharedAccounts.map(account => new iam.AccountPrincipal(account))),
);
}
}
}

/**
* Create a unique id based off the L1 CfnPortfolio or the arn of an imported portfolio.
*/
Expand Down Expand Up @@ -336,6 +358,15 @@ export class Portfolio extends PortfolioBase {
if (props.tagOptions !== undefined) {
this.associateTagOptions(props.tagOptions);
}

const portfolioNodeId = this.node.id;
cdk.Aspects.of(this).add({
visit(c: IConstruct) {
if (c.node.id === portfolioNodeId) {
(c as Portfolio).addBucketPermissionsToSharedAccounts();
};
},
});
}

protected generateUniqueHash(value: string): string {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,66 @@
import { CfnBucket, IBucket } from '@aws-cdk/aws-s3';
import { BucketDeployment, Source } from '@aws-cdk/aws-s3-deployment';
import * as cdk from '@aws-cdk/core';
import { ProductStack } from '../product-stack';

/**
* Deployment environment for an AWS Service Catalog product stack.
*
* Interoperates with the StackSynthesizer of the parent stack.
*/
export class ProductStackSynthesizer extends cdk.StackSynthesizer {
public addFileAsset(_asset: cdk.FileAssetSource): cdk.FileAssetLocation {
throw new Error('Service Catalog Product Stacks cannot use Assets');
private readonly assetBucket?: IBucket;
private bucketDeployment?: BucketDeployment;

constructor(assetBucket?: IBucket) {
super();
this.assetBucket = assetBucket;
}

public addFileAsset(asset: cdk.FileAssetSource): cdk.FileAssetLocation {
if (!this.assetBucket) {
throw new Error('An Asset Bucket must be provided to use Assets');
}
const outdir = cdk.App.of(this.boundStack)?.outdir ?? 'cdk.out';
const assetPath = `./${outdir}/${asset.fileName}`;
if (!this.bucketDeployment) {
const parentStack = (this.boundStack as ProductStack)._getParentStack();
if (!cdk.Resource.isOwnedResource(this.assetBucket)) {
cdk.Annotations.of(parentStack).addWarning('[WARNING] Bucket Policy Permissions cannot be added to' +
' referenced Bucket. Please make sure your bucket has the correct permissions');
}
this.bucketDeployment = new BucketDeployment(parentStack, 'AssetsBucketDeployment', {
sources: [Source.asset(assetPath)],
destinationBucket: this.assetBucket,
extract: false,
prune: false,
});
} else {
this.bucketDeployment.addSource(Source.asset(assetPath));
}

const physicalName = this.physicalNameOfBucket(this.assetBucket);

const bucketName = physicalName;
const s3Filename = asset.fileName?.split('.')[1] + '.zip';
const objectKey = `${s3Filename}`;
const s3ObjectUrl = `s3://${bucketName}/${objectKey}`;
const httpUrl = `https://s3.${bucketName}/${objectKey}`;

return { bucketName, objectKey, httpUrl, s3ObjectUrl, s3Url: httpUrl };
}

private physicalNameOfBucket(bucket: IBucket) {
let resolvedName;
if (cdk.Resource.isOwnedResource(bucket)) {
resolvedName = cdk.Stack.of(bucket).resolve((bucket.node.defaultChild as CfnBucket).bucketName);
} else {
resolvedName = bucket.bucketName;
}
if (resolvedName === undefined) {
throw new Error('A bucketName must be provided to use Assets');
}
return resolvedName;
}

public addDockerImageAsset(_asset: cdk.DockerImageAssetSource): cdk.DockerImageAssetLocation {
Expand Down
38 changes: 36 additions & 2 deletions packages/@aws-cdk/aws-servicecatalog/lib/product-stack.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import { IBucket } from '@aws-cdk/aws-s3';
import * as cdk from '@aws-cdk/core';
import { Construct } from 'constructs';
import { ProductStackSynthesizer } from './private/product-stack-synthesizer';
import { ProductStackHistory } from './product-stack-history';

/**
* Product stack props.
*/
export interface ProductStackProps {
/**
* A Bucket can be passed to store assets, enabling ProductStack Asset support
* @default No Bucket provided and Assets will not be supported.
*/
readonly assetBucket?: IBucket;
}

/**
* A Service Catalog product stack, which is similar in form to a Cloudformation nested stack.
* You can add the resources to this stack that you want to define for your service catalog product.
Expand All @@ -21,15 +33,19 @@ export class ProductStack extends cdk.Stack {
private _templateUrl?: string;
private _parentStack: cdk.Stack;

constructor(scope: Construct, id: string) {
private assetBucket?: IBucket;

constructor(scope: Construct, id: string, props: ProductStackProps = {}) {
super(scope, id, {
synthesizer: new ProductStackSynthesizer(),
synthesizer: new ProductStackSynthesizer(props.assetBucket),
});

this._parentStack = findParentStack(scope);

// this is the file name of the synthesized template file within the cloud assembly
this.templateFile = `${cdk.Names.uniqueId(this)}.product.template.json`;

this.assetBucket = props.assetBucket;
}

/**
Expand All @@ -50,6 +66,24 @@ export class ProductStack extends cdk.Stack {
return cdk.Lazy.uncachedString({ produce: () => this._templateUrl });
}

/**
* Fetch the asset bucket.
*
* @internal
*/
public _getAssetBucket(): IBucket | undefined {
return this.assetBucket;
}

/**
* Fetch the parent Stack.
*
* @internal
*/
public _getParentStack(): cdk.Stack {
return this._parentStack;
}

/**
* Synthesize the product stack template, overrides the `super` class method.
*
Expand Down
Loading