Skip to content

Commit bdcd6c8

Browse files
authored
feat(s3): allow configuring S3 Object Lock (#23744)
S3 Object Lock allows configuring various retention holds, for legal and compliance purposes, on an S3 bucket. This enables a write-once-read-many model. Object Lock can only be enabled on new buckets via the CloudFormation (and therefore via the CDK). Updates to an existing bucket will result in a CloudFormation update failure. This behavior is possible today using Escape Hatches to modify the L1 construct (with the same limitations): ```ts cfnBucket.addPropertyOverride("ObjectLockEnabled", true); ``` Providing L2 wrappers around this configuration can aleviate some common and easy-to-make mistakes, such as providing `ObjectLockConfiguration` without providing `ObjectLockEnabled` or specifying `"Governance"` instead of `"GOVERNANCE"` for the compliance mode. It is possible to enable Object Lock without specifying a default duration. Therefore, there needs to be a means to set `ObjectLockEnabled`. This is done with the `ObjectLoc.enabled` property. Since this is a boolean, it can theoretically be set to `false`. If `false` and a `defaultRetention` is provided, an error is thrown. CloudFormation allows specifying `Days` or `Years` for retention; for simplicity, this implementation always converts to `Days`. Because CloudFormation requires that to be a positive integer, this implementation also proactively performs that validation at synthesis time. Further, CloudFormation does not allow omitting `ObjectLockEnabled` within `ObjectLockConfiguration`. The following template would result in a validation error that the input does not match the schema: ```yaml Bucket: Type: AWS::S3::Bucket Properties: ObjectLockEnabled: true ObjectLockConfiguration: Rule: DefaultRetention: Days: 1 Mode: GOVERNANCE ``` Therefore, this implementation also always sets `ObjectLockConfiguration.ObjectLockEnabled` to `"Enabled"`. Additionally, it seems that the behavior of doing ```yaml Bucket: Type: AWS::S3::Bucket Properties: ObjectLockEnabled: true ObjectLockConfiguration: ObjectLockEnabled: 'Enabled' ``` causes CloudFormation to create the buckets with Object Lock enabled and then just wait and wait and wait. Frankly I didn't wait for the operation to time out so I don't know whether that would succeed or fail, but in any case, that would be a duplicate of specifying only `ObjectLockEnabled: true` (without nested in `ObjectLockConfiguration`) so this implementation prefers the shorter variant, which CloudFormation/S3 also seem to prefer, when Object Lock is enabled without default retention. Unfortunately, there isn't a way to check during synthesis whether the bucket already exists, so there's not really a way to detect that pitfall. Users will just get the typical CloudFormation error for this situation and a stack rollback. More variants of Object Lock configuration in S3 and descriptions of what CloudFormation does with them can be found at: https://gist.github.com/788df029f121af14645f31152ff54e32 This _partially_ addresses #5247 (nothing here handles MFA delete). This follows up on #21738 which has been marked as abandoned. ---- ### All Submissions: * [X] Have you followed the guidelines in our [Contributing guide?](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) ### Adding new Construct Runtime Dependencies: * [ ] This PR adds new construct runtime dependencies following the process described [here](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md/#adding-construct-runtime-dependencies) ### New Features * [X] Have you added the new feature to an [integration test](https://github.com/aws/aws-cdk/blob/main/INTEGRATION_TESTS.md)? * [X] Did you use `yarn integ` to deploy the infrastructure and generate the snapshot (i.e. `yarn integ` without `--dry-run`)? *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 360d61c commit bdcd6c8

12 files changed

+721
-2
lines changed

packages/@aws-cdk/aws-s3/README.md

+33
Original file line numberDiff line numberDiff line change
@@ -616,3 +616,36 @@ const bucket = new s3.Bucket(this, 'MyBucket', {
616616
}]
617617
});
618618
```
619+
620+
## Object Lock Configuration
621+
622+
[Object Lock](https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock-overview.html)
623+
can be configured to enable a write-once-read-many model for an S3 bucket. Object Lock must be
624+
configured when a bucket is created; if a bucket is created without Object Lock, it cannot be
625+
enabled later via the CDK.
626+
627+
Object Lock can be enabled on an S3 bucket by specifying:
628+
629+
```ts
630+
const bucket = new s3.Bucket(this, 'MyBucket', {
631+
objectLockEnabled: true
632+
});
633+
```
634+
635+
Usually, it is desired to not just enable Object Lock for a bucket but to also configure a
636+
[retention mode](https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock-overview.html#object-lock-retention-modes)
637+
and a [retention period](https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock-overview.html#object-lock-retention-periods).
638+
These can be specified by providing `objectLockDefaultRetention`:
639+
640+
```ts
641+
// Configure for governance mode with a duration of 7 years
642+
new s3.Bucket(this, 'Bucket1', {
643+
objectLockDefaultRetention: s3.ObjectLockRetention.governance(cdk.Duration.days(7 * 365)),
644+
});
645+
646+
// Configure for compliance mode with a duration of 1 year
647+
new s3.Bucket(this, 'Bucket2', {
648+
objectLockDefaultRetention: s3.ObjectLockRetention.compliance(cdk.Duration.days(365)),
649+
});
650+
651+
```

packages/@aws-cdk/aws-s3/lib/bucket.ts

+138-2
Original file line numberDiff line numberDiff line change
@@ -1401,10 +1401,34 @@ export interface BucketProps {
14011401
/**
14021402
* Whether this bucket should have versioning turned on or not.
14031403
*
1404-
* @default false
1404+
* @default false (unless object lock is enabled, then true)
14051405
*/
14061406
readonly versioned?: boolean;
14071407

1408+
/**
1409+
* Enable object lock on the bucket.
1410+
*
1411+
* Enabling object lock for existing buckets is not supported. Object lock must be
1412+
* enabled when the bucket is created.
1413+
*
1414+
* @see https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock-overview.html#object-lock-bucket-config-enable
1415+
*
1416+
* @default false, unless objectLockDefaultRetention is set (then, true)
1417+
*/
1418+
readonly objectLockEnabled?: boolean;
1419+
1420+
/**
1421+
* The default retention mode and rules for S3 Object Lock.
1422+
*
1423+
* Default retention can be configured after a bucket is created if the bucket already
1424+
* has object lock enabled. Enabling object lock for existing buckets is not supported.
1425+
*
1426+
* @see https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock-overview.html#object-lock-bucket-config-enable
1427+
*
1428+
* @default no default retention period
1429+
*/
1430+
readonly objectLockDefaultRetention?: ObjectLockRetention;
1431+
14081432
/**
14091433
* Whether this bucket should send notifications to Amazon EventBridge or not.
14101434
*
@@ -1787,6 +1811,8 @@ export class Bucket extends BucketBase {
17871811
const websiteConfiguration = this.renderWebsiteConfiguration(props);
17881812
this.isWebsite = (websiteConfiguration !== undefined);
17891813

1814+
const objectLockConfiguration = this.parseObjectLockConfig(props);
1815+
17901816
const resource = new CfnBucket(this, 'Resource', {
17911817
bucketName: this.physicalName,
17921818
bucketEncryption,
@@ -1802,6 +1828,8 @@ export class Bucket extends BucketBase {
18021828
ownershipControls: this.parseOwnershipControls(props),
18031829
accelerateConfiguration: props.transferAcceleration ? { accelerationStatus: 'Enabled' } : undefined,
18041830
intelligentTieringConfigurations: this.parseTieringConfig(props),
1831+
objectLockEnabled: objectLockConfiguration ? true : props.objectLockEnabled,
1832+
objectLockConfiguration: objectLockConfiguration,
18051833
});
18061834
this._resource = resource;
18071835

@@ -2164,6 +2192,27 @@ export class Bucket extends BucketBase {
21642192
});
21652193
}
21662194

2195+
private parseObjectLockConfig(props: BucketProps): CfnBucket.ObjectLockConfigurationProperty | undefined {
2196+
const { objectLockEnabled, objectLockDefaultRetention } = props;
2197+
2198+
if (!objectLockDefaultRetention) {
2199+
return undefined;
2200+
}
2201+
if (objectLockEnabled === false && objectLockDefaultRetention) {
2202+
throw new Error('Object Lock must be enabled to configure default retention settings');
2203+
}
2204+
2205+
return {
2206+
objectLockEnabled: 'Enabled',
2207+
rule: {
2208+
defaultRetention: {
2209+
days: objectLockDefaultRetention.duration.toDays(),
2210+
mode: objectLockDefaultRetention.mode,
2211+
},
2212+
},
2213+
};
2214+
}
2215+
21672216
private renderWebsiteConfiguration(props: BucketProps): CfnBucket.WebsiteConfigurationProperty | undefined {
21682217
if (!props.websiteErrorDocument && !props.websiteIndexDocument && !props.websiteRedirect && !props.websiteRoutingRules) {
21692218
return undefined;
@@ -2231,7 +2280,7 @@ export class Bucket extends BucketBase {
22312280
effect: iam.Effect.ALLOW,
22322281
principals: [new iam.ServicePrincipal('logging.s3.amazonaws.com')],
22332282
actions: ['s3:PutObject'],
2234-
resources: [this.arnForObjects(prefix ? `${prefix}*`: '*')],
2283+
resources: [this.arnForObjects(prefix ? `${prefix}*` : '*')],
22352284
conditions: conditions,
22362285
}));
22372286
} else if (this.accessControl && this.accessControl !== BucketAccessControl.LOG_DELIVERY_WRITE) {
@@ -2742,6 +2791,93 @@ export interface RoutingRule {
27422791
readonly condition?: RoutingRuleCondition;
27432792
}
27442793

2794+
/**
2795+
* Modes in which S3 Object Lock retention can be configured.
2796+
*
2797+
* @see https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock-overview.html#object-lock-retention-modes
2798+
*/
2799+
export enum ObjectLockMode {
2800+
/**
2801+
* The Governance retention mode.
2802+
*
2803+
* With governance mode, you protect objects against being deleted by most users, but you can
2804+
* still grant some users permission to alter the retention settings or delete the object if
2805+
* necessary. You can also use governance mode to test retention-period settings before
2806+
* creating a compliance-mode retention period.
2807+
*/
2808+
GOVERNANCE = 'GOVERNANCE',
2809+
2810+
/**
2811+
* The Compliance retention mode.
2812+
*
2813+
* When an object is locked in compliance mode, its retention mode can't be changed, and
2814+
* its retention period can't be shortened. Compliance mode helps ensure that an object
2815+
* version can't be overwritten or deleted for the duration of the retention period.
2816+
*/
2817+
COMPLIANCE = 'COMPLIANCE',
2818+
}
2819+
2820+
/**
2821+
* The default retention settings for an S3 Object Lock configuration.
2822+
*
2823+
* @see https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock-overview.html
2824+
*/
2825+
export class ObjectLockRetention {
2826+
/**
2827+
* Configure for Governance retention for a specified duration.
2828+
*
2829+
* With governance mode, you protect objects against being deleted by most users, but you can
2830+
* still grant some users permission to alter the retention settings or delete the object if
2831+
* necessary. You can also use governance mode to test retention-period settings before
2832+
* creating a compliance-mode retention period.
2833+
*
2834+
* @param duration the length of time for which objects should retained
2835+
* @returns the ObjectLockRetention configuration
2836+
*/
2837+
public static governance(duration: Duration): ObjectLockRetention {
2838+
return new ObjectLockRetention(ObjectLockMode.GOVERNANCE, duration);
2839+
}
2840+
2841+
/**
2842+
* Configure for Compliance retention for a specified duration.
2843+
*
2844+
* When an object is locked in compliance mode, its retention mode can't be changed, and
2845+
* its retention period can't be shortened. Compliance mode helps ensure that an object
2846+
* version can't be overwritten or deleted for the duration of the retention period.
2847+
*
2848+
* @param duration the length of time for which objects should be retained
2849+
* @returns the ObjectLockRetention configuration
2850+
*/
2851+
public static compliance(duration: Duration): ObjectLockRetention {
2852+
return new ObjectLockRetention(ObjectLockMode.COMPLIANCE, duration);
2853+
}
2854+
2855+
/**
2856+
* The default period for which objects should be retained.
2857+
*/
2858+
public readonly duration: Duration;
2859+
2860+
/**
2861+
* The retention mode to use for the object lock configuration.
2862+
*
2863+
* @see https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock-overview.html#object-lock-retention-modes
2864+
*/
2865+
public readonly mode: ObjectLockMode;
2866+
2867+
private constructor(mode: ObjectLockMode, duration: Duration) {
2868+
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock-managing.html#object-lock-managing-retention-limits
2869+
if (duration.toDays() > 365 * 100) {
2870+
throw new Error('Object Lock retention duration must be less than 100 years');
2871+
}
2872+
if (duration.toDays() < 1) {
2873+
throw new Error('Object Lock retention duration must be at least 1 day');
2874+
}
2875+
2876+
this.mode = mode;
2877+
this.duration = duration;
2878+
}
2879+
}
2880+
27452881
/**
27462882
* Options for creating Virtual-Hosted style URL.
27472883
*/

packages/@aws-cdk/aws-s3/test/bucket.test.ts

+104
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,110 @@ describe('bucket', () => {
381381
});
382382
});
383383

384+
test('bucket with object lock enabled but no retention', () => {
385+
const stack = new cdk.Stack();
386+
new s3.Bucket(stack, 'Bucket', {
387+
objectLockEnabled: true,
388+
});
389+
Template.fromStack(stack).hasResourceProperties('AWS::S3::Bucket', {
390+
ObjectLockEnabled: true,
391+
ObjectLockConfiguration: Match.absent(),
392+
});
393+
});
394+
395+
test('object lock defaults to disabled', () => {
396+
const stack = new cdk.Stack();
397+
new s3.Bucket(stack, 'Bucket');
398+
Template.fromStack(stack).hasResourceProperties('AWS::S3::Bucket', {
399+
ObjectLockEnabled: Match.absent(),
400+
});
401+
});
402+
403+
test('object lock defaults to enabled when default retention is specified', () => {
404+
const stack = new cdk.Stack();
405+
new s3.Bucket(stack, 'Bucket', {
406+
objectLockDefaultRetention: s3.ObjectLockRetention.governance(cdk.Duration.days(7 * 365)),
407+
});
408+
Template.fromStack(stack).hasResourceProperties('AWS::S3::Bucket', {
409+
ObjectLockEnabled: true,
410+
ObjectLockConfiguration: {
411+
ObjectLockEnabled: 'Enabled',
412+
Rule: {
413+
DefaultRetention: {
414+
Mode: 'GOVERNANCE',
415+
Days: 7 * 365,
416+
},
417+
},
418+
},
419+
});
420+
});
421+
422+
test('bucket with object lock enabled with governance retention', () => {
423+
const stack = new cdk.Stack();
424+
new s3.Bucket(stack, 'Bucket', {
425+
objectLockEnabled: true,
426+
objectLockDefaultRetention: s3.ObjectLockRetention.governance(cdk.Duration.days(1)),
427+
});
428+
429+
Template.fromStack(stack).hasResourceProperties('AWS::S3::Bucket', {
430+
ObjectLockEnabled: true,
431+
ObjectLockConfiguration: {
432+
ObjectLockEnabled: 'Enabled',
433+
Rule: {
434+
DefaultRetention: {
435+
Mode: 'GOVERNANCE',
436+
Days: 1,
437+
},
438+
},
439+
},
440+
});
441+
});
442+
443+
test('bucket with object lock enabled with compliance retention', () => {
444+
const stack = new cdk.Stack();
445+
new s3.Bucket(stack, 'Bucket', {
446+
objectLockEnabled: true,
447+
objectLockDefaultRetention: s3.ObjectLockRetention.compliance(cdk.Duration.days(1)),
448+
});
449+
Template.fromStack(stack).hasResourceProperties('AWS::S3::Bucket', {
450+
ObjectLockEnabled: true,
451+
ObjectLockConfiguration: {
452+
ObjectLockEnabled: 'Enabled',
453+
Rule: {
454+
DefaultRetention: {
455+
Mode: 'COMPLIANCE',
456+
Days: 1,
457+
},
458+
},
459+
},
460+
});
461+
});
462+
463+
test('bucket with object lock disabled throws error with retention set', () => {
464+
const stack = new cdk.Stack();
465+
expect(() => new s3.Bucket(stack, 'Bucket', {
466+
objectLockEnabled: false,
467+
objectLockDefaultRetention: s3.ObjectLockRetention.governance(cdk.Duration.days(1)),
468+
})).toThrow('Object Lock must be enabled to configure default retention settings');
469+
});
470+
471+
test('bucket with object lock requires duration than one day', () => {
472+
const stack = new cdk.Stack();
473+
expect(() => new s3.Bucket(stack, 'Bucket', {
474+
objectLockEnabled: true,
475+
objectLockDefaultRetention: s3.ObjectLockRetention.governance(cdk.Duration.days(0)),
476+
})).toThrow('Object Lock retention duration must be at least 1 day');
477+
});
478+
479+
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock-managing.html#object-lock-managing-retention-limits
480+
test('bucket with object lock requires duration less than 100 years', () => {
481+
const stack = new cdk.Stack();
482+
expect(() => new s3.Bucket(stack, 'Bucket', {
483+
objectLockEnabled: true,
484+
objectLockDefaultRetention: s3.ObjectLockRetention.governance(cdk.Duration.days(365 * 101)),
485+
})).toThrow('Object Lock retention duration must be less than 100 years');
486+
});
487+
384488
test('bucket with block public access set to BlockAll', () => {
385489
const stack = new cdk.Stack();
386490
new s3.Bucket(stack, 'MyBucket', {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"version": "29.0.0",
3+
"files": {
4+
"21fbb51d7b23f6a6c262b46a9caee79d744a3ac019fd45422d988b96d44b2a22": {
5+
"source": {
6+
"path": "ServerAccessLogsImportTestDefaultTestDeployAssert076DA7F5.template.json",
7+
"packaging": "file"
8+
},
9+
"destinations": {
10+
"current_account-current_region": {
11+
"bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}",
12+
"objectKey": "21fbb51d7b23f6a6c262b46a9caee79d744a3ac019fd45422d988b96d44b2a22.json",
13+
"assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}"
14+
}
15+
}
16+
}
17+
},
18+
"dockerImages": {}
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"Parameters": {
3+
"BootstrapVersion": {
4+
"Type": "AWS::SSM::Parameter::Value<String>",
5+
"Default": "/cdk-bootstrap/hnb659fds/version",
6+
"Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]"
7+
}
8+
},
9+
"Rules": {
10+
"CheckBootstrapVersion": {
11+
"Assertions": [
12+
{
13+
"Assert": {
14+
"Fn::Not": [
15+
{
16+
"Fn::Contains": [
17+
[
18+
"1",
19+
"2",
20+
"3",
21+
"4",
22+
"5"
23+
],
24+
{
25+
"Ref": "BootstrapVersion"
26+
}
27+
]
28+
}
29+
]
30+
},
31+
"AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI."
32+
}
33+
]
34+
}
35+
}
36+
}

0 commit comments

Comments
 (0)