From 5502436def5a3390ca2faeaa49de014de11ee4ea Mon Sep 17 00:00:00 2001 From: Elad Ben-Israel Date: Wed, 18 Jul 2018 16:50:24 +0300 Subject: [PATCH] s3: bucketUrl and urlForObject(key) (#370) The `bucketUrl` returns the URL of the bucket and `urlForObject(key)` returns the URL of an object within the bucket. Furthermore: `iam.IIdentityResource` was soft-renamed to `iam.IPrincipal` (IIdentityResource is still supported). --- .../core/lib/cloudformation/pseudo.ts | 6 + packages/@aws-cdk/iam/lib/policy.ts | 11 +- packages/@aws-cdk/s3/README.md | 13 +++ packages/@aws-cdk/s3/lib/bucket.ts | 90 +++++++++++---- .../s3/test/integ.bucket.expected.json | 4 +- .../test/integ.bucket.url.lit.expected.json | 61 +++++++++++ .../@aws-cdk/s3/test/integ.bucket.url.lit.ts | 19 ++++ packages/@aws-cdk/s3/test/test.bucket.ts | 103 +++++++++++++++++- 8 files changed, 281 insertions(+), 26 deletions(-) create mode 100644 packages/@aws-cdk/s3/test/integ.bucket.url.lit.expected.json create mode 100644 packages/@aws-cdk/s3/test/integ.bucket.url.lit.ts diff --git a/packages/@aws-cdk/core/lib/cloudformation/pseudo.ts b/packages/@aws-cdk/core/lib/cloudformation/pseudo.ts index 29dcf57c0e7d6..59b6edd4c90ee 100644 --- a/packages/@aws-cdk/core/lib/cloudformation/pseudo.ts +++ b/packages/@aws-cdk/core/lib/cloudformation/pseudo.ts @@ -18,6 +18,12 @@ export class AwsDomainSuffix extends PseudoParameter { } } +export class AwsURLSuffix extends PseudoParameter { + constructor() { + super('AWS::URLSuffix'); + } +} + export class AwsNotificationARNs extends PseudoParameter { constructor() { super('AWS::NotificationARNs'); diff --git a/packages/@aws-cdk/iam/lib/policy.ts b/packages/@aws-cdk/iam/lib/policy.ts index 205cef2d56d51..3e98006376600 100644 --- a/packages/@aws-cdk/iam/lib/policy.ts +++ b/packages/@aws-cdk/iam/lib/policy.ts @@ -5,7 +5,10 @@ import { Role } from './role'; import { User } from './user'; import { generatePolicyName, undefinedIfEmpty } from './util'; -export interface IIdentityResource { +/** + * A construct that represents an IAM principal, such as a user, group or role. + */ +export interface IPrincipal { /** * The IAM principal of this identity (i.e. AWS principal, service principal, etc). */ @@ -31,6 +34,12 @@ export interface IIdentityResource { attachManagedPolicy(arn: any): void; } +/** + * @deprecated Use IPrincipal + */ +// tslint:disable-next-line:no-empty-interface +export interface IIdentityResource extends IPrincipal { } + export interface PolicyProps { /** * The name of the policy. If you specify multiple policies for an entity, diff --git a/packages/@aws-cdk/s3/README.md b/packages/@aws-cdk/s3/README.md index 66017fa1b0afe..2eaa3503204e6 100644 --- a/packages/@aws-cdk/s3/README.md +++ b/packages/@aws-cdk/s3/README.md @@ -6,6 +6,19 @@ Define an unencrypted S3 bucket. new Bucket(this, 'MyFirstBucket'); ``` +`Bucket` constructs expose the following deploy-time attributes: + + * `bucketArn` - the ARN of the bucket (i.e. `arn:aws:s3:::bucket_name`) + * `bucketName` - the name of the bucket (i.e. `bucket_name`) + * `bucketUrl` - the URL of the bucket (i.e. + `https://s3.us-west-1.amazonaws.com/onlybucket`) + * `arnForObjects(...pattern)` - the ARN of an object or objects within the + bucket (i.e. + `arn:aws:s3:::my_corporate_bucket/exampleobject.png` or + `arn:aws:s3:::my_corporate_bucket/Development/*`) + * `urlForObject(key)` - the URL of an object within the bucket (i.e. + `https://s3.cn-north-1.amazonaws.com.cn/china-bucket/mykey`) + ### Encryption Define a KMS-encrypted bucket: diff --git a/packages/@aws-cdk/s3/lib/bucket.ts b/packages/@aws-cdk/s3/lib/bucket.ts index 848c0ad39ae8b..cb9a03f24870a 100644 --- a/packages/@aws-cdk/s3/lib/bucket.ts +++ b/packages/@aws-cdk/s3/lib/bucket.ts @@ -1,6 +1,6 @@ -import { applyRemovalPolicy, Arn, Construct, FnConcat, Output, PolicyStatement, RemovalPolicy, Token } from '@aws-cdk/core'; -import { IIdentityResource } from '@aws-cdk/iam'; -import * as kms from '@aws-cdk/kms'; +import cdk = require('@aws-cdk/core'); +import iam = require('@aws-cdk/iam'); +import kms = require('@aws-cdk/kms'); import { BucketPolicy } from './bucket-policy'; import * as perms from './perms'; import { LifecycleRule } from './rule'; @@ -45,7 +45,7 @@ export interface BucketRefProps { * BucketRef.import(this, 'MyImportedBucket', ref); * */ -export abstract class BucketRef extends Construct { +export abstract class BucketRef extends cdk.Construct { /** * Creates a Bucket construct that represents an external bucket. * @@ -54,7 +54,7 @@ export abstract class BucketRef extends Construct { * @param ref A BucketRefProps object. Can be obtained from a call to * `bucket.export()`. */ - public static import(parent: Construct, name: string, props: BucketRefProps): BucketRef { + public static import(parent: cdk.Construct, name: string, props: BucketRefProps): BucketRef { return new ImportedBucketRef(parent, name, props); } @@ -92,8 +92,8 @@ export abstract class BucketRef extends Construct { */ public export(): BucketRefProps { return { - bucketArn: new Output(this, 'BucketArn', { value: this.bucketArn }).makeImportValue(), - bucketName: new Output(this, 'BucketName', { value: this.bucketName }).makeImportValue(), + bucketArn: new cdk.Output(this, 'BucketArn', { value: this.bucketArn }).makeImportValue(), + bucketName: new cdk.Output(this, 'BucketName', { value: this.bucketName }).makeImportValue(), }; } @@ -103,7 +103,7 @@ export abstract class BucketRef extends Construct { * contents. Use `bucketArn` and `arnForObjects(keys)` to obtain ARNs for * this bucket or objects. */ - public addToResourcePolicy(permission: PolicyStatement) { + public addToResourcePolicy(permission: cdk.PolicyStatement) { if (!this.policy && this.autoCreatePolicy) { this.policy = new BucketPolicy(this, 'Policy', { bucket: this }); } @@ -113,6 +113,38 @@ export abstract class BucketRef extends Construct { } } + /** + * The https:// URL of this bucket. + * @example https://s3.us-west-1.amazonaws.com/onlybucket + * Similar to calling `urlForObject` with no object key. + */ + public get bucketUrl() { + return this.urlForObject(); + } + + /** + * The https URL of an S3 object. For example: + * @example https://s3.us-west-1.amazonaws.com/onlybucket + * @example https://s3.us-west-1.amazonaws.com/bucket/key + * @example https://s3.cn-north-1.amazonaws.com.cn/china-bucket/mykey + * @param key The S3 key of the object. If not specified, the URL of the + * bucket is returned. + * @returns an ObjectS3Url token + */ + public urlForObject(key?: any): S3Url { + const components = [ 'https://', 's3.', new cdk.AwsRegion(), '.', new cdk.AwsURLSuffix(), '/', this.bucketName ]; + if (key) { + // trim prepending '/' + if (typeof key === 'string' && key.startsWith('/')) { + key = key.substr(1); + } + components.push('/'); + components.push(key); + } + + return new cdk.FnConcat(...components); + } + /** * Returns an ARN that represents all objects within the bucket that match * the key pattern specified. To represent all keys, specify ``"*"``. @@ -122,8 +154,8 @@ export abstract class BucketRef extends Construct { * arnForObjects('home/', team, '/', user, '/*') * */ - public arnForObjects(...keyPattern: any[]): Arn { - return new FnConcat(this.bucketArn, '/', ...keyPattern); + public arnForObjects(...keyPattern: any[]): cdk.Arn { + return new cdk.FnConcat(this.bucketArn, '/', ...keyPattern); } /** @@ -133,7 +165,7 @@ export abstract class BucketRef extends Construct { * If an encryption key is used, permission to ues the key to decrypt the * contents of the bucket will also be granted. */ - public grantRead(identity?: IIdentityResource, objectsKeyPattern = '*') { + public grantRead(identity?: iam.IPrincipal, objectsKeyPattern: any = '*') { if (!identity) { return; } @@ -147,7 +179,7 @@ export abstract class BucketRef extends Construct { * If an encryption key is used, permission to use the key for * encrypt/decrypt will also be granted. */ - public grantReadWrite(identity?: IIdentityResource, objectsKeyPattern = '*') { + public grantReadWrite(identity?: iam.IPrincipal, objectsKeyPattern: any = '*') { if (!identity) { return; } @@ -156,24 +188,24 @@ export abstract class BucketRef extends Construct { this.grant(identity, objectsKeyPattern, bucketActions, keyActions); } - private grant(identity: IIdentityResource, objectsKeyPattern: string, bucketActions: string[], keyActions: string[]) { + private grant(identity: iam.IPrincipal, objectsKeyPattern: any, bucketActions: string[], keyActions: string[]) { const resources = [ this.bucketArn, this.arnForObjects(objectsKeyPattern) ]; - identity.addToPolicy(new PolicyStatement() + identity.addToPolicy(new cdk.PolicyStatement() .addResources(...resources) .addActions(...bucketActions)); // grant key permissions if there's an associated key. if (this.encryptionKey) { // KMS permissions need to be granted both directions - identity.addToPolicy(new PolicyStatement() + identity.addToPolicy(new cdk.PolicyStatement() .addResource(this.encryptionKey.keyArn) .addActions(...keyActions)); - this.encryptionKey.addToResourcePolicy(new PolicyStatement() + this.encryptionKey.addToResourcePolicy(new cdk.PolicyStatement() .addResource('*') .addPrincipal(identity.principal) .addActions(...keyActions)); @@ -216,7 +248,7 @@ export interface BucketProps { * * @default By default, the bucket will be destroyed if it is removed from the stack. */ - removalPolicy?: RemovalPolicy; + removalPolicy?: cdk.RemovalPolicy; /** * The bucket policy associated with this bucket. @@ -258,7 +290,7 @@ export class Bucket extends BucketRef { private readonly lifecycleRules: LifecycleRule[] = []; private readonly versioned?: boolean; - constructor(parent: Construct, name: string, props: BucketProps = {}) { + constructor(parent: cdk.Construct, name: string, props: BucketProps = {}) { super(parent, name); validateBucketName(props && props.bucketName); @@ -269,10 +301,10 @@ export class Bucket extends BucketRef { bucketName: props && props.bucketName, bucketEncryption, versioningConfiguration: props.versioned ? { status: 'Enabled' } : undefined, - lifecycleConfiguration: new Token(() => this.parseLifecycleConfiguration()), + lifecycleConfiguration: new cdk.Token(() => this.parseLifecycleConfiguration()), }); - applyRemovalPolicy(resource, props.removalPolicy); + cdk.applyRemovalPolicy(resource, props.removalPolicy); this.versioned = props.versioned; this.policy = props.policy; @@ -435,7 +467,21 @@ export enum BucketEncryption { /** * The name of the bucket. */ -export class BucketName extends Token { +export class BucketName extends cdk.Token { + +} + +/** + * A key to an S3 object. + */ +export class ObjectKey extends cdk.Token { + +} + +/** + * The web URL (https://s3.us-west-1.amazonaws.com/bucket/key) of an S3 object. + */ +export class S3Url extends cdk.Token { } @@ -447,7 +493,7 @@ class ImportedBucketRef extends BucketRef { protected policy?: BucketPolicy; protected autoCreatePolicy: boolean; - constructor(parent: Construct, name: string, props: BucketRefProps) { + constructor(parent: cdk.Construct, name: string, props: BucketRefProps) { super(parent, name); this.bucketArn = parseBucketArn(props); diff --git a/packages/@aws-cdk/s3/test/integ.bucket.expected.json b/packages/@aws-cdk/s3/test/integ.bucket.expected.json index 4d166081eef83..ce11fc9d144e1 100644 --- a/packages/@aws-cdk/s3/test/integ.bucket.expected.json +++ b/packages/@aws-cdk/s3/test/integ.bucket.expected.json @@ -3,7 +3,6 @@ "MyBucketKeyC17130CF": { "Type": "AWS::KMS::Key", "Properties": { - "Description": "Created by aws-cdk-s3/MyBucket", "KeyPolicy": { "Statement": [ { @@ -63,7 +62,8 @@ } ], "Version": "2012-10-17" - } + }, + "Description": "Created by aws-cdk-s3/MyBucket" }, "DeletionPolicy": "Retain" }, diff --git a/packages/@aws-cdk/s3/test/integ.bucket.url.lit.expected.json b/packages/@aws-cdk/s3/test/integ.bucket.url.lit.expected.json new file mode 100644 index 0000000000000..5e673cd26b405 --- /dev/null +++ b/packages/@aws-cdk/s3/test/integ.bucket.url.lit.expected.json @@ -0,0 +1,61 @@ +{ + "Resources": { + "MyBucketF68F3FF0": { + "Type": "AWS::S3::Bucket" + } + }, + "Outputs": { + "BucketURL": { + "Value": { + "Fn::Join": [ + "", + [ + "https://", + "s3.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "MyBucketF68F3FF0" + } + ] + ] + }, + "Export": { + "Name": "aws-cdk-s3-urls:BucketURL" + } + }, + "ObjectURL": { + "Value": { + "Fn::Join": [ + "", + [ + "https://", + "s3.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "MyBucketF68F3FF0" + }, + "/", + "myfolder/myfile.txt" + ] + ] + }, + "Export": { + "Name": "aws-cdk-s3-urls:ObjectURL" + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/s3/test/integ.bucket.url.lit.ts b/packages/@aws-cdk/s3/test/integ.bucket.url.lit.ts new file mode 100644 index 0000000000000..c4438650aa6a8 --- /dev/null +++ b/packages/@aws-cdk/s3/test/integ.bucket.url.lit.ts @@ -0,0 +1,19 @@ +import cdk = require('@aws-cdk/core'); +import s3 = require('../lib'); + +class TestStack extends cdk.Stack { + constructor(parent: cdk.App, id: string) { + super(parent, id); + + /// !show + const bucket = new s3.Bucket(this, 'MyBucket'); + + new cdk.Output(this, 'BucketURL', { value: bucket.bucketUrl }); + new cdk.Output(this, 'ObjectURL', { value: bucket.urlForObject('myfolder/myfile.txt') }); + /// !hide + } +} + +const app = new cdk.App(process.argv); +new TestStack(app, 'aws-cdk-s3-urls'); +process.stdout.write(app.run()); diff --git a/packages/@aws-cdk/s3/test/test.bucket.ts b/packages/@aws-cdk/s3/test/test.bucket.ts index 1671e2af2dff2..989ba7167811f 100644 --- a/packages/@aws-cdk/s3/test/test.bucket.ts +++ b/packages/@aws-cdk/s3/test/test.bucket.ts @@ -1,9 +1,10 @@ import { expect } from '@aws-cdk/assert'; -import { PolicyStatement, RemovalPolicy, resolve, Stack } from '@aws-cdk/core'; +import { Output, PolicyStatement, RemovalPolicy, resolve, Stack } from '@aws-cdk/core'; import { Group, User } from '@aws-cdk/iam'; import { EncryptionKey } from '@aws-cdk/kms'; import { Test } from 'nodeunit'; import * as s3 from '../lib'; +import { Bucket } from '../lib'; // to make it easy to copy & paste from output: // tslint:disable:object-literal-key-quotes @@ -915,4 +916,104 @@ export = { test.done(); }, + + 'urlForObject returns a token with the S3 URL of the token'(test: Test) { + const stack = new Stack(); + const bucket = new Bucket(stack, 'MyBucket'); + + new Output(stack, 'BucketURL', { value: bucket.bucketUrl }); + new Output(stack, 'MyFileURL', { value: bucket.urlForObject('my/file.txt') }); + new Output(stack, 'YourFileURL', { value: bucket.urlForObject('/your/file.txt') }); // "/" is optional + + expect(stack).toMatch({ + "Resources": { + "MyBucketF68F3FF0": { + "Type": "AWS::S3::Bucket" + } + }, + "Outputs": { + "BucketURL": { + "Value": { + "Fn::Join": [ + "", + [ + "https://", + "s3.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "MyBucketF68F3FF0" + } + ] + ] + }, + "Export": { + "Name": "BucketURL" + } + }, + "MyFileURL": { + "Value": { + "Fn::Join": [ + "", + [ + "https://", + "s3.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "MyBucketF68F3FF0" + }, + "/", + "my/file.txt" + ] + ] + }, + "Export": { + "Name": "MyFileURL" + } + }, + "YourFileURL": { + "Value": { + "Fn::Join": [ + "", + [ + "https://", + "s3.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "MyBucketF68F3FF0" + }, + "/", + "your/file.txt" + ] + ] + }, + "Export": { + "Name": "YourFileURL" + } + } + } + }); + + test.done(); + } };