diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml new file mode 100644 index 0000000..9239202 --- /dev/null +++ b/.github/workflows/pre-release.yml @@ -0,0 +1,32 @@ +name: Pre-Release + +on: + workflow_dispatch: + +jobs: + pre-release: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + registry-url: 'https://npm.pkg.github.com' + scope: '@${{ github.repository_owner }}' + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Compile and test + run: npm run ci + + - name: Prepare pre release + run: | + git_sha=$(git rev-parse --short HEAD) + mv package.json /tmp/package.json + jq --arg git_sha ${git_sha} 'del(.repository) | .version = .version + "-" + $git_sha' /tmp/package.json > package.json + + - run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index de55d4a..cd9d933 100644 --- a/README.md +++ b/README.md @@ -394,6 +394,21 @@ export default () => ({ }); ``` +### S3 bucket Enryption + +```js +export default () => ({ + hostedZoneName: `example.com`, + s3: { + // <== + encryption: `KMS_MANAGED`, // default: S3_MANAGED + encryptionKeyArn: `arn:aws:kms:${aws_region}:${aws_accound}:key/${id}`, // only for type KMS, if missing AWS will generate a key + bucketKeyEnabled: true, // bucket key reduces encryption costs by lowering calls to AWS KMS + }, + routes: [{ type: `file`, publicPath: `/`, path: `dist/index.html` }], +}); +``` + ### Source maps #### Enabling source maps for a Lambda function on AWS @@ -622,6 +637,12 @@ Note: The `onStart` hook is called before the routes are registered. "Effect": "Allow", "Action": ["iam:ListAttachedRolePolicies", "iam:DetachRolePolicy", "iam:DeleteRole"], "Resource": "arn:aws:iam::*:role/aws-simple-*" + }, + { + "Sid": "AwsSimple10", + "Effect": "Allow", + "Action": ["kms:GenerateDataKey", "kms:Decrypt"], + "Resource": "arn:aws:kms:::key/" } ] } diff --git a/src/cdk/add-s3-resource.ts b/src/cdk/add-s3-resource.ts index 50dc839..22e9a24 100644 --- a/src/cdk/add-s3-resource.ts +++ b/src/cdk/add-s3-resource.ts @@ -3,7 +3,7 @@ import type { aws_iam, aws_s3 } from 'aws-cdk-lib'; import { addCorsPreflight } from './add-cors-preflight.js'; import { aws_apigateway } from 'aws-cdk-lib'; -import { join } from 'path'; +import { join } from 'node:path'; export interface S3ResourceConstructDependencies { readonly bucket: aws_s3.IBucket; diff --git a/src/cdk/create-bucket.ts b/src/cdk/create-bucket.ts index 21d1157..881dfc7 100644 --- a/src/cdk/create-bucket.ts +++ b/src/cdk/create-bucket.ts @@ -1,11 +1,15 @@ import type { Stack } from 'aws-cdk-lib'; - import { CfnOutput, RemovalPolicy, aws_s3 } from 'aws-cdk-lib'; -export function createBucket(stack: Stack): aws_s3.IBucket { +import type { StackConfig } from '../parse-stack-config.js'; +import { mapS3Encryption } from '../utils/map-s3-encryption.js'; + +export function createBucket(stackConfig: StackConfig, stack: Stack): aws_s3.IBucket { + const s3BucketEncryption = mapS3Encryption(stackConfig.s3, stack); + const bucket = new aws_s3.Bucket(stack, `Bucket`, { + ...s3BucketEncryption, blockPublicAccess: aws_s3.BlockPublicAccess.BLOCK_ALL, - encryption: aws_s3.BucketEncryption.S3_MANAGED, enforceSSL: true, removalPolicy: RemovalPolicy.DESTROY, autoDeleteObjects: true, diff --git a/src/parse-stack-config.ts b/src/parse-stack-config.ts index 74bef4d..8227bc9 100644 --- a/src/parse-stack-config.ts +++ b/src/parse-stack-config.ts @@ -34,6 +34,22 @@ export type S3Route = z.TypeOf; const LambdaRuntimeSchema = z.enum([`20.x`, `22.x`, `24.x`, `LATEST`]).optional(); export type LambdaRuntime = z.TypeOf; +const S3BucketEncryptionSchema = z + .discriminatedUnion(`encryption`, [ + z.object({ encryption: z.literal('S3_MANAGED') }), + z.object({ + encryption: z.literal('KMS_MANAGED'), + bucketKeyEnabled: z.boolean().default(true).optional(), + }), + z.object({ + encryption: z.literal('KMS'), + bucketKeyEnabled: z.boolean().default(true).optional(), + encryptionKeyArn: z.string().optional(), + }), + ]) + .optional(); +export type S3BucketEncryption = z.TypeOf; + const LambdaRouteSchema = z.object({ type: z.literal(`function`), httpMethod: z.enum([`DELETE`, `GET`, `HEAD`, `PATCH`, `POST`, `PUT`]), @@ -105,6 +121,7 @@ const StackConfigSchema = DomainNamePartsSchema.extend({ .optional(), tags: z.record(z.string()).optional(), routes: z.array(z.union([LambdaRouteSchema, S3RouteSchema])).min(1), + s3: S3BucketEncryptionSchema, onSynthesize: z.function().optional(), onStart: z.function().optional(), }); diff --git a/src/synthesize-command.ts b/src/synthesize-command.ts index 6bfde25..55d4cd9 100644 --- a/src/synthesize-command.ts +++ b/src/synthesize-command.ts @@ -29,7 +29,7 @@ export const synthesizeCommand: CommandModule<{}, {}> = { const stackConfig = parseStackConfig(await readStackConfig()); const stack = createStack(stackConfig); const restApi = createRestApi(stackConfig, stack); - const bucket = createBucket(stack); + const bucket = createBucket(stackConfig, stack); const bucketReadRole = createBucketReadRole(stack, bucket); const requestAuthorizer = createRequestAuthorizer(stackConfig, stack); const lambdaServiceRole = createLambdaServiceRole(stack); diff --git a/src/utils/map-s3-encryption.test.ts b/src/utils/map-s3-encryption.test.ts new file mode 100644 index 0000000..243d488 --- /dev/null +++ b/src/utils/map-s3-encryption.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from '@jest/globals'; + +import { mapS3Encryption } from './map-s3-encryption.js'; +import { aws_s3, Stack } from 'aws-cdk-lib'; + +describe(`mapS3Encryption()`, () => { + test(`returns S3_MANAGED encryption when no encryption is provided`, () => { + const result = mapS3Encryption(undefined as any, {} as Stack); + expect(result).toEqual({ encryption: aws_s3.BucketEncryption.S3_MANAGED }); + }); + + test(`returns S3_MANAGED encryption when S3_MANAGED is provided`, () => { + const result = mapS3Encryption({ encryption: 'S3_MANAGED' }, {} as Stack); + expect(result).toEqual({ encryption: aws_s3.BucketEncryption.S3_MANAGED }); + }); + + test(`returns KMS_MANAGED encryption when KMS_MANAGED is provided`, () => { + const result = mapS3Encryption({ encryption: 'KMS_MANAGED' }, {} as Stack); + expect(result).toEqual({ + encryption: aws_s3.BucketEncryption.KMS_MANAGED, + bucketKeyEnabled: true, + }); + }); + + test(`returns KMS_MANAGED encryption with bucketKeyEnabled when KMS_MANAGED and bucketKeyEnabled are provided`, () => { + const result = mapS3Encryption( + { encryption: 'KMS_MANAGED', bucketKeyEnabled: false }, + {} as Stack, + ); + expect(result).toEqual({ + encryption: aws_s3.BucketEncryption.KMS_MANAGED, + bucketKeyEnabled: false, + }); + }); + + test(`returns KMS encryption with key when KMS and encryptionKeyArn are provided`, () => { + const stack = new Stack(); + expect(Stack.isStack(stack)).toBeTruthy(); + + const keyArn = 'arn:aws:kms:region:account-id:key/key-id'; + const result = mapS3Encryption({ encryption: 'KMS', encryptionKeyArn: keyArn }, stack); + expect(result.encryption).toEqual(aws_s3.BucketEncryption.KMS); + expect(result.encryptionKey).toBeDefined(); + }); + + test(`returns KMS encryption without key when KMS is provided without encryptionKeyArn`, () => { + const result = mapS3Encryption({ encryption: 'KMS' }, {} as Stack); + expect(result).toEqual({ + encryption: aws_s3.BucketEncryption.KMS, + encryptionKey: undefined, + bucketKeyEnabled: true, + }); + }); + + test(`returns KMS encryption with bucketKeyEnabled when KMS, bucketKeyEnabled and encryptionKeyArn are provided`, () => { + const stack = new Stack(); + expect(Stack.isStack(stack)).toBeTruthy(); + + const keyArn = 'arn:aws:kms:region:account-id:key/key-id'; + const result = mapS3Encryption( + { encryption: 'KMS', encryptionKeyArn: keyArn, bucketKeyEnabled: false }, + stack, + ); + expect(result).toEqual({ + encryption: aws_s3.BucketEncryption.KMS, + encryptionKey: expect.any(Object), + bucketKeyEnabled: false, + }); + }); + + test(`throws an error for unsupported encryption types`, () => { + expect(() => mapS3Encryption({ encryption: 'UNSUPPORTED' } as any, {} as Stack)).toThrow( + 'Unsupported encryption type', + ); + }); +}); diff --git a/src/utils/map-s3-encryption.ts b/src/utils/map-s3-encryption.ts new file mode 100644 index 0000000..2e5137e --- /dev/null +++ b/src/utils/map-s3-encryption.ts @@ -0,0 +1,36 @@ +import type { S3BucketEncryption } from '../parse-stack-config.js'; + +import { aws_s3, aws_kms, type Stack } from 'aws-cdk-lib'; + +export type BucketEncryption = { + encryption: aws_s3.BucketEncryption; + encryptionKey?: aws_kms.IKey; + bucketKeyEnabled?: boolean; +}; + +export function mapS3Encryption(s3encryption: S3BucketEncryption, stack: Stack): BucketEncryption { + if (!s3encryption || s3encryption.encryption === `S3_MANAGED`) { + return { encryption: aws_s3.BucketEncryption.S3_MANAGED }; + } + + const bucketKeyEnabled = s3encryption.bucketKeyEnabled ?? true; + + if (s3encryption.encryption === `KMS_MANAGED`) { + return { encryption: aws_s3.BucketEncryption.KMS_MANAGED, bucketKeyEnabled }; + } + + if (s3encryption.encryption === `KMS`) { + let encryptionKey: aws_kms.IKey | undefined = undefined; + + if (s3encryption.encryptionKeyArn) { + encryptionKey = aws_kms.Key.fromKeyArn( + stack, + `S3EncryptionKey`, + s3encryption.encryptionKeyArn, + ); + } + return { encryptionKey, encryption: aws_s3.BucketEncryption.KMS, bucketKeyEnabled }; + } + + throw new Error(`Unsupported encryption type`); +}