Skip to content

Commit fe78d23

Browse files
committed
feat: s3 encryption with customer managed keys
1 parent a61875e commit fe78d23

7 files changed

Lines changed: 159 additions & 5 deletions

File tree

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,21 @@ export default () => ({
394394
});
395395
```
396396

397+
### S3 bucket Enryption
398+
399+
```js
400+
export default () => ({
401+
hostedZoneName: `example.com`,
402+
s3: {
403+
// <==
404+
encryption: `KMS_MANAGED`, // default: S3_MANAGED
405+
encryptionKeyArn: `arn:aws:kms:${aws_region}:${aws_accound}:key/${id}`, // only for type KMS, if missing AWS will generate a key
406+
bucketKeyEnabled: true, // bucket key reduces encryption costs by lowering calls to AWS KMS
407+
},
408+
routes: [{ type: `file`, publicPath: `/`, path: `dist/index.html` }],
409+
});
410+
```
411+
397412
### Source maps
398413

399414
#### Enabling source maps for a Lambda function on AWS
@@ -622,6 +637,12 @@ Note: The `onStart` hook is called before the routes are registered.
622637
"Effect": "Allow",
623638
"Action": ["iam:ListAttachedRolePolicies", "iam:DetachRolePolicy", "iam:DeleteRole"],
624639
"Resource": "arn:aws:iam::*:role/aws-simple-*"
640+
},
641+
{
642+
"Sid": "AwsSimple10",
643+
"Effect": "Allow",
644+
"Action": ["kms:GenerateDataKey", "kms:Decrypt"],
645+
"Resource": "arn:aws:kms:::key/"
625646
}
626647
]
627648
}

src/cdk/add-s3-resource.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { aws_iam, aws_s3 } from 'aws-cdk-lib';
33

44
import { addCorsPreflight } from './add-cors-preflight.js';
55
import { aws_apigateway } from 'aws-cdk-lib';
6-
import { join } from 'path';
6+
import { join } from 'node:path';
77

88
export interface S3ResourceConstructDependencies {
99
readonly bucket: aws_s3.IBucket;

src/cdk/create-bucket.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import type { Stack } from 'aws-cdk-lib';
2-
32
import { CfnOutput, RemovalPolicy, aws_s3 } from 'aws-cdk-lib';
43

5-
export function createBucket(stack: Stack): aws_s3.IBucket {
4+
import type { StackConfig } from '../parse-stack-config.js';
5+
import { mapS3Encryption } from '../utils/map-s3-encryption.js';
6+
7+
export function createBucket(stackConfig: StackConfig, stack: Stack): aws_s3.IBucket {
8+
const s3BucketEncryption = mapS3Encryption(stackConfig.s3, stack);
9+
610
const bucket = new aws_s3.Bucket(stack, `Bucket`, {
11+
...s3BucketEncryption,
712
blockPublicAccess: aws_s3.BlockPublicAccess.BLOCK_ALL,
8-
encryption: aws_s3.BucketEncryption.S3_MANAGED,
913
enforceSSL: true,
1014
removalPolicy: RemovalPolicy.DESTROY,
1115
autoDeleteObjects: true,

src/parse-stack-config.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,22 @@ export type S3Route = z.TypeOf<typeof S3RouteSchema>;
3434
const LambdaRuntimeSchema = z.enum([`20.x`, `22.x`, `24.x`, `LATEST`]).optional();
3535
export type LambdaRuntime = z.TypeOf<typeof LambdaRuntimeSchema>;
3636

37+
const S3BucketEncryptionSchema = z
38+
.discriminatedUnion(`encryption`, [
39+
z.object({ encryption: z.literal('S3_MANAGED') }),
40+
z.object({
41+
encryption: z.literal('KMS_MANAGED'),
42+
bucketKeyEnabled: z.boolean().default(true).optional(),
43+
}),
44+
z.object({
45+
encryption: z.literal('KMS'),
46+
bucketKeyEnabled: z.boolean().default(true).optional(),
47+
encryptionKeyArn: z.string().optional(),
48+
}),
49+
])
50+
.optional();
51+
export type S3BucketEncryption = z.TypeOf<typeof S3BucketEncryptionSchema>;
52+
3753
const LambdaRouteSchema = z.object({
3854
type: z.literal(`function`),
3955
httpMethod: z.enum([`DELETE`, `GET`, `HEAD`, `PATCH`, `POST`, `PUT`]),
@@ -105,6 +121,7 @@ const StackConfigSchema = DomainNamePartsSchema.extend({
105121
.optional(),
106122
tags: z.record(z.string()).optional(),
107123
routes: z.array(z.union([LambdaRouteSchema, S3RouteSchema])).min(1),
124+
s3: S3BucketEncryptionSchema,
108125
onSynthesize: z.function().optional(),
109126
onStart: z.function().optional(),
110127
});

src/synthesize-command.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export const synthesizeCommand: CommandModule<{}, {}> = {
2929
const stackConfig = parseStackConfig(await readStackConfig());
3030
const stack = createStack(stackConfig);
3131
const restApi = createRestApi(stackConfig, stack);
32-
const bucket = createBucket(stack);
32+
const bucket = createBucket(stackConfig, stack);
3333
const bucketReadRole = createBucketReadRole(stack, bucket);
3434
const requestAuthorizer = createRequestAuthorizer(stackConfig, stack);
3535
const lambdaServiceRole = createLambdaServiceRole(stack);
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { describe, expect, test } from '@jest/globals';
2+
3+
import { mapS3Encryption } from './map-s3-encryption.js';
4+
import { aws_s3, Stack } from 'aws-cdk-lib';
5+
6+
describe(`mapS3Encryption()`, () => {
7+
test(`returns S3_MANAGED encryption when no encryption is provided`, () => {
8+
const result = mapS3Encryption(undefined as any, {} as Stack);
9+
expect(result).toEqual({ encryption: aws_s3.BucketEncryption.S3_MANAGED });
10+
});
11+
12+
test(`returns S3_MANAGED encryption when S3_MANAGED is provided`, () => {
13+
const result = mapS3Encryption({ encryption: 'S3_MANAGED' }, {} as Stack);
14+
expect(result).toEqual({ encryption: aws_s3.BucketEncryption.S3_MANAGED });
15+
});
16+
17+
test(`returns KMS_MANAGED encryption when KMS_MANAGED is provided`, () => {
18+
const result = mapS3Encryption({ encryption: 'KMS_MANAGED' }, {} as Stack);
19+
expect(result).toEqual({
20+
encryption: aws_s3.BucketEncryption.KMS_MANAGED,
21+
bucketKeyEnabled: true,
22+
});
23+
});
24+
25+
test(`returns KMS_MANAGED encryption with bucketKeyEnabled when KMS_MANAGED and bucketKeyEnabled are provided`, () => {
26+
const result = mapS3Encryption(
27+
{ encryption: 'KMS_MANAGED', bucketKeyEnabled: false },
28+
{} as Stack,
29+
);
30+
expect(result).toEqual({
31+
encryption: aws_s3.BucketEncryption.KMS_MANAGED,
32+
bucketKeyEnabled: false,
33+
});
34+
});
35+
36+
test(`returns KMS encryption with key when KMS and encryptionKeyArn are provided`, () => {
37+
const stack = new Stack();
38+
expect(Stack.isStack(stack)).toBeTruthy();
39+
40+
const keyArn = 'arn:aws:kms:region:account-id:key/key-id';
41+
const result = mapS3Encryption({ encryption: 'KMS', encryptionKeyArn: keyArn }, stack);
42+
expect(result.encryption).toEqual(aws_s3.BucketEncryption.KMS);
43+
expect(result.encryptionKey).toBeDefined();
44+
});
45+
46+
test(`returns KMS encryption without key when KMS is provided without encryptionKeyArn`, () => {
47+
const result = mapS3Encryption({ encryption: 'KMS' }, {} as Stack);
48+
expect(result).toEqual({
49+
encryption: aws_s3.BucketEncryption.KMS,
50+
encryptionKey: undefined,
51+
bucketKeyEnabled: true,
52+
});
53+
});
54+
55+
test(`returns KMS encryption with bucketKeyEnabled when KMS, bucketKeyEnabled and encryptionKeyArn are provided`, () => {
56+
const stack = new Stack();
57+
expect(Stack.isStack(stack)).toBeTruthy();
58+
59+
const keyArn = 'arn:aws:kms:region:account-id:key/key-id';
60+
const result = mapS3Encryption(
61+
{ encryption: 'KMS', encryptionKeyArn: keyArn, bucketKeyEnabled: false },
62+
stack,
63+
);
64+
expect(result).toEqual({
65+
encryption: aws_s3.BucketEncryption.KMS,
66+
encryptionKey: expect.any(Object),
67+
bucketKeyEnabled: false,
68+
});
69+
});
70+
71+
test(`throws an error for unsupported encryption types`, () => {
72+
expect(() => mapS3Encryption({ encryption: 'UNSUPPORTED' } as any, {} as Stack)).toThrow(
73+
'Unsupported encryption type',
74+
);
75+
});
76+
});

src/utils/map-s3-encryption.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { S3BucketEncryption } from '../parse-stack-config.js';
2+
3+
import { aws_s3, aws_kms, type Stack } from 'aws-cdk-lib';
4+
5+
export type BucketEncryption = {
6+
encryption: aws_s3.BucketEncryption;
7+
encryptionKey?: aws_kms.IKey;
8+
bucketKeyEnabled?: boolean;
9+
};
10+
11+
export function mapS3Encryption(s3encryption: S3BucketEncryption, stack: Stack): BucketEncryption {
12+
if (!s3encryption || s3encryption.encryption === `S3_MANAGED`) {
13+
return { encryption: aws_s3.BucketEncryption.S3_MANAGED };
14+
}
15+
16+
const bucketKeyEnabled = s3encryption.bucketKeyEnabled ?? true;
17+
18+
if (s3encryption.encryption === `KMS_MANAGED`) {
19+
return { encryption: aws_s3.BucketEncryption.KMS_MANAGED, bucketKeyEnabled };
20+
}
21+
22+
if (s3encryption.encryption === `KMS`) {
23+
let encryptionKey: aws_kms.IKey | undefined = undefined;
24+
25+
if (s3encryption.encryptionKeyArn) {
26+
encryptionKey = aws_kms.Key.fromKeyArn(
27+
stack,
28+
`S3EncryptionKey`,
29+
s3encryption.encryptionKeyArn,
30+
);
31+
}
32+
return { encryptionKey, encryption: aws_s3.BucketEncryption.KMS, bucketKeyEnabled };
33+
}
34+
35+
throw new Error(`Unsupported encryption type`);
36+
}

0 commit comments

Comments
 (0)