Skip to content

Commit

Permalink
Merge branch 'master' into cloudfront/addedOptionToCustomizeSecurityP…
Browse files Browse the repository at this point in the history
…rotocol
  • Loading branch information
hassanazharkhan committed Dec 27, 2020
2 parents 7d7129c + 32e9c23 commit 07a8584
Show file tree
Hide file tree
Showing 9 changed files with 780 additions and 8 deletions.
8 changes: 4 additions & 4 deletions packages/@aws-cdk/aws-cloudfront/lib/distribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export interface DistributionAttributes {
/**
* Represents a certificate in AWS Certificate Manager
*/
export interface DistributionCertificate extends acm.ICertificate {
export interface IDistributionCertificate extends acm.ICertificate {
/**
* The minimum version of the SSL protocol that you want CloudFront to use for HTTPS connections.
*
Expand Down Expand Up @@ -101,7 +101,7 @@ export interface DistributionProps {
*
* @default - the CloudFront wildcard certificate (*.cloudfront.net) will be used.
*/
readonly certificate?: DistributionCertificate;
readonly certificate?: IDistributionCertificate;

/**
* Any comments you want to include about the distribution.
Expand Down Expand Up @@ -256,7 +256,7 @@ export class Distribution extends Resource implements IDistribution {
private readonly originGroups: CfnDistribution.OriginGroupProperty[] = [];

private readonly errorResponses: ErrorResponse[];
private readonly certificate?: DistributionCertificate;
private readonly certificate?: IDistributionCertificate;

constructor(scope: Construct, id: string, props: DistributionProps) {
super(scope, id);
Expand Down Expand Up @@ -442,7 +442,7 @@ export class Distribution extends Resource implements IDistribution {
} : undefined;
}

private renderViewerCertificate(certificate: DistributionCertificate): CfnDistribution.ViewerCertificateProperty {
private renderViewerCertificate(certificate: IDistributionCertificate): CfnDistribution.ViewerCertificateProperty {
return {
acmCertificateArn: certificate.certificateArn,
sslSupportMethod: SSLMethod.SNI,
Expand Down
22 changes: 20 additions & 2 deletions packages/@aws-cdk/aws-s3/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ bucket.virtualHostedUrlForObject('objectname', { regional: false }); // Virtual

### Object Ownership

You can use the two following properties to specify the bucket [object Ownership].
You can use the two following properties to specify the bucket [object Ownership].

[object Ownership]: https://docs.aws.amazon.com/AmazonS3/latest/dev/about-object-ownership.html

Expand All @@ -365,10 +365,28 @@ new s3.Bucket(this, 'MyBucket', {

#### Bucket owner preferred

The bucket owner will own the object if the object is uploaded with the bucket-owner-full-control canned ACL. Without this setting and canned ACL, the object is uploaded and remains owned by the uploading account.
The bucket owner will own the object if the object is uploaded with the bucket-owner-full-control canned ACL. Without this setting and canned ACL, the object is uploaded and remains owned by the uploading account.

```ts
new s3.Bucket(this, 'MyBucket', {
objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_PREFERRED,
});
```

### Bucket deletion

When a bucket is removed from a stack (or the stack is deleted), the S3
bucket will be removed according to its removal policy (which by default will
simply orphan the bucket and leave it in your AWS account). If the removal
policy is set to `RemovalPolicy.DESTROY`, the bucket will be deleted as long
as it does not contain any objects.

To override this and force all objects to get deleted during bucket deletion,
enable the`autoDeleteObjects` option.

```ts
const bucket = new Bucket(this, 'MyTempFileBucket', {
removalPolicy: RemovalPolicy.DESTROY,
autoDeleteObjects: true,
});
```
42 changes: 42 additions & 0 deletions packages/@aws-cdk/aws-s3/lib/auto-delete-objects-handler/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import { S3 } from 'aws-sdk';

const s3 = new S3();

export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent) {
switch (event.RequestType) {
case 'Create':
case 'Update':
return;
case 'Delete':
return onDelete(event);
}
}

/**
* Recursively delete all items in the bucket
*
* @param bucketName the bucket name
*/
async function emptyBucket(bucketName: string) {
const listedObjects = await s3.listObjectVersions({ Bucket: bucketName }).promise();
const contents = [...listedObjects.Versions ?? [], ...listedObjects.DeleteMarkers ?? []];
if (contents.length === 0) {
return;
};

const records = contents.map((record: any) => ({ Key: record.Key, VersionId: record.VersionId }));
await s3.deleteObjects({ Bucket: bucketName, Delete: { Objects: records } }).promise();

if (listedObjects?.IsTruncated) {
await emptyBucket(bucketName);
}
}

async function onDelete(deleteEvent: AWSLambda.CloudFormationCustomResourceDeleteEvent) {
const bucketName = deleteEvent.ResourceProperties?.BucketName;
if (!bucketName) {
throw new Error('No BucketName was provided.');
}
await emptyBucket(bucketName);
}
62 changes: 61 additions & 1 deletion packages/@aws-cdk/aws-s3/lib/bucket.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { EOL } from 'os';
import * as path from 'path';
import * as events from '@aws-cdk/aws-events';
import * as iam from '@aws-cdk/aws-iam';
import * as kms from '@aws-cdk/aws-kms';
import { Fn, IResource, Lazy, RemovalPolicy, Resource, Stack, Token } from '@aws-cdk/core';
import {
Fn, IResource, Lazy, RemovalPolicy, Resource, Stack, Token,
CustomResource, CustomResourceProvider, CustomResourceProviderRuntime,
} from '@aws-cdk/core';
import { Construct } from 'constructs';
import { BucketPolicy } from './bucket-policy';
import { IBucketNotificationDestination } from './destination';
Expand All @@ -12,6 +16,8 @@ import { LifecycleRule } from './rule';
import { CfnBucket } from './s3.generated';
import { parseBucketArn, parseBucketName } from './util';

const AUTO_DELETE_OBJECTS_RESOURCE_TYPE = 'Custom::S3AutoDeleteObjects';

export interface IBucket extends IResource {
/**
* The ARN of the bucket.
Expand Down Expand Up @@ -1041,6 +1047,16 @@ export interface BucketProps {
*/
readonly removalPolicy?: RemovalPolicy;

/**
* Whether all objects should be automatically deleted when the bucket is
* removed from the stack or when the stack is deleted.
*
* Requires the `removalPolicy` to be set to `RemovalPolicy.DESTROY`.
*
* @default false
*/
readonly autoDeleteObjects?: boolean;

/**
* Whether this bucket should have versioning turned on or not.
*
Expand Down Expand Up @@ -1326,6 +1342,14 @@ export class Bucket extends BucketBase {
if (props.publicReadAccess) {
this.grantPublicAccess();
}

if (props.autoDeleteObjects) {
if (props.removalPolicy !== RemovalPolicy.DESTROY) {
throw new Error('Cannot use \'autoDeleteObjects\' property on a bucket without setting removal policy to \'DESTROY\'.');
}

this.enableAutoDeleteObjects();
}
}

/**
Expand Down Expand Up @@ -1728,6 +1752,42 @@ export class Bucket extends BucketBase {
};
});
}

private enableAutoDeleteObjects() {
const provider = CustomResourceProvider.getOrCreateProvider(this, AUTO_DELETE_OBJECTS_RESOURCE_TYPE, {
codeDirectory: path.join(__dirname, 'auto-delete-objects-handler'),
runtime: CustomResourceProviderRuntime.NODEJS_12,
});

// Use a bucket policy to allow the custom resource to delete
// objects in the bucket
this.addToResourcePolicy(new iam.PolicyStatement({
actions: [
...perms.BUCKET_READ_ACTIONS, // list objects
...perms.BUCKET_DELETE_ACTIONS, // and then delete them
],
resources: [
this.bucketArn,
this.arnForObjects('*'),
],
principals: [new iam.ArnPrincipal(provider.roleArn)],
}));

const customResource = new CustomResource(this, 'AutoDeleteObjectsCustomResource', {
resourceType: AUTO_DELETE_OBJECTS_RESOURCE_TYPE,
serviceToken: provider.serviceToken,
properties: {
BucketName: this.bucketName,
},
});

// Ensure bucket policy is deleted AFTER the custom resource otherwise
// we don't have permissions to list and delete in the bucket.
// (add a `if` to make TS happy)
if (this.policy) {
customResource.node.addDependency(this.policy);
}
}
}

/**
Expand Down
168 changes: 168 additions & 0 deletions packages/@aws-cdk/aws-s3/test/auto-delete-objects-handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
const mockS3Client = {
listObjectVersions: jest.fn().mockReturnThis(),
deleteObjects: jest.fn().mockReturnThis(),
promise: jest.fn(),
};

import { handler } from '../lib/auto-delete-objects-handler';

jest.mock('aws-sdk', () => {
return { S3: jest.fn(() => mockS3Client) };
});

beforeEach(() => {
mockS3Client.listObjectVersions.mockReturnThis();
mockS3Client.deleteObjects.mockReturnThis();
});

afterEach(() => {
jest.resetAllMocks();
});

test('does nothing on create event', async () => {
// GIVEN
const event: Partial<AWSLambda.CloudFormationCustomResourceCreateEvent> = {
RequestType: 'Create',
ResourceProperties: {
ServiceToken: 'Foo',
BucketName: 'MyBucket',
},
};

// WHEN
await invokeHandler(event);

// THEN
expect(mockS3Client.listObjectVersions).toHaveBeenCalledTimes(0);
expect(mockS3Client.deleteObjects).toHaveBeenCalledTimes(0);
});

test('does nothing on update event', async () => {
// GIVEN
const event: Partial<AWSLambda.CloudFormationCustomResourceUpdateEvent> = {
RequestType: 'Update',
ResourceProperties: {
ServiceToken: 'Foo',
BucketName: 'MyBucket',
},
};

// WHEN
await invokeHandler(event);

// THEN
expect(mockS3Client.listObjectVersions).toHaveBeenCalledTimes(0);
expect(mockS3Client.deleteObjects).toHaveBeenCalledTimes(0);
});

test('deletes no objects on delete event when bucket has no objects', async () => {
// GIVEN
mockS3Client.promise.mockResolvedValue({ Versions: [] }); // listObjectVersions() call

// WHEN
const event: Partial<AWSLambda.CloudFormationCustomResourceDeleteEvent> = {
RequestType: 'Delete',
ResourceProperties: {
ServiceToken: 'Foo',
BucketName: 'MyBucket',
},
};
await invokeHandler(event);

// THEN
expect(mockS3Client.listObjectVersions).toHaveBeenCalledTimes(1);
expect(mockS3Client.listObjectVersions).toHaveBeenCalledWith({ Bucket: 'MyBucket' });
expect(mockS3Client.deleteObjects).toHaveBeenCalledTimes(0);
});

test('deletes all objects on delete event', async () => {
// GIVEN
mockS3Client.promise.mockResolvedValue({ // listObjectVersions() call
Versions: [
{ Key: 'Key1', VersionId: 'VersionId1' },
{ Key: 'Key2', VersionId: 'VersionId2' },
],
});

// WHEN
const event: Partial<AWSLambda.CloudFormationCustomResourceDeleteEvent> = {
RequestType: 'Delete',
ResourceProperties: {
ServiceToken: 'Foo',
BucketName: 'MyBucket',
},
};
await invokeHandler(event);

// THEN
expect(mockS3Client.listObjectVersions).toHaveBeenCalledTimes(1);
expect(mockS3Client.listObjectVersions).toHaveBeenCalledWith({ Bucket: 'MyBucket' });
expect(mockS3Client.deleteObjects).toHaveBeenCalledTimes(1);
expect(mockS3Client.deleteObjects).toHaveBeenCalledWith({
Bucket: 'MyBucket',
Delete: {
Objects: [
{ Key: 'Key1', VersionId: 'VersionId1' },
{ Key: 'Key2', VersionId: 'VersionId2' },
],
},
});
});

test('delete event where bucket has many objects does recurse appropriately', async () => {
// GIVEN
mockS3Client.promise // listObjectVersions() call
.mockResolvedValueOnce({
Versions: [
{ Key: 'Key1', VersionId: 'VersionId1' },
{ Key: 'Key2', VersionId: 'VersionId2' },
],
IsTruncated: true,
})
.mockResolvedValueOnce(undefined) // deleteObjects() call
.mockResolvedValueOnce({ // listObjectVersions() call
Versions: [
{ Key: 'Key3', VersionId: 'VersionId3' },
{ Key: 'Key4', VersionId: 'VersionId4' },
],
});

// WHEN
const event: Partial<AWSLambda.CloudFormationCustomResourceDeleteEvent> = {
RequestType: 'Delete',
ResourceProperties: {
ServiceToken: 'Foo',
BucketName: 'MyBucket',
},
};
await invokeHandler(event);

// THEN
expect(mockS3Client.listObjectVersions).toHaveBeenCalledTimes(2);
expect(mockS3Client.listObjectVersions).toHaveBeenCalledWith({ Bucket: 'MyBucket' });
expect(mockS3Client.deleteObjects).toHaveBeenCalledTimes(2);
expect(mockS3Client.deleteObjects).toHaveBeenNthCalledWith(1, {
Bucket: 'MyBucket',
Delete: {
Objects: [
{ Key: 'Key1', VersionId: 'VersionId1' },
{ Key: 'Key2', VersionId: 'VersionId2' },
],
},
});
expect(mockS3Client.deleteObjects).toHaveBeenNthCalledWith(2, {
Bucket: 'MyBucket',
Delete: {
Objects: [
{ Key: 'Key3', VersionId: 'VersionId3' },
{ Key: 'Key4', VersionId: 'VersionId4' },
],
},
});
});

// helper function to get around TypeScript expecting a complete event object,
// even though our tests only need some of the fields
async function invokeHandler(event: Partial<AWSLambda.CloudFormationCustomResourceEvent>) {
return handler(event as AWSLambda.CloudFormationCustomResourceEvent);
}
Loading

0 comments on commit 07a8584

Please sign in to comment.