From d418f9416dffa73193ea3e62cb35ad239e6a3e06 Mon Sep 17 00:00:00 2001 From: Thibault RICHARD Date: Mon, 27 Sep 2021 00:49:27 +0200 Subject: [PATCH] Add Upload construct --- docs/upload.md | 179 ++++++++++++++++++++++++++++++++++ package.json | 1 + src/constructs/aws/Storage.ts | 2 - src/constructs/aws/Upload.ts | 179 ++++++++++++++++++++++++++++++++++ src/providers/AwsProvider.ts | 22 ++++- src/types/serverless.ts | 1 + test/unit/upload.test.ts | 161 ++++++++++++++++++++++++++++++ 7 files changed, 542 insertions(+), 3 deletions(-) create mode 100644 docs/upload.md create mode 100644 src/constructs/aws/Upload.ts create mode 100644 test/unit/upload.test.ts diff --git a/docs/upload.md b/docs/upload.md new file mode 100644 index 00000000..61db2d7b --- /dev/null +++ b/docs/upload.md @@ -0,0 +1,179 @@ +# Upload + +The `upload` construct deploys a S3 bucket where you can upload files from the frontend. + +It also creates a Lambda function that will generate a temporary URL to upload to the S3 bucket. + +## Quick start + +```bash +serverless plugin install -n serverless-lift +``` + +```yaml +service: my-app +provider: + name: aws + +functions: + myFunction: + handler: src/index.handler + events: + - httpApi: '*' + +constructs: + upload: + type: upload + +plugins: + - serverless-lift +``` + +On `serverless deploy`, a S3 bucket will be created and the Lambda function will be attached to your API Gateway. + +## How it works + +The `upload` construct creates and configures the S3 bucket for the upload: + +- Files stored in the bucket are automatically encrypted (S3 takes care of encrypting and decrypting data on the fly, without change to our applications). +- Files are stored in a `tmp` folder and files are automatically deleted after 24 hours. +- Cross-Origin Resource Sharing (CORS) is configured to be reachable from a web browser. + +It also creates a Lambda function : + +- It is automatically attached to your API Gateway under the path `/upload-url` +- It requires to be called via a **POST** request containing a JSON body with the fields `fileName` and `contentType` +- It will generate the pre-signed URL that will be valid for 5 minutes +- It will return a JSON containing the `uploadUrl` and the `fileName` which is the path in the S3 bucket where the file will be stored + +**Warning:** because files are deleted from the bucket after 24 hours, your backend code +should move it if it needs to be stored permanently. This is done this way to avoid uploaded files that are never used, +such as a user that uploads a file but never submits the form. + +## How to use it in the browser + +Here is an example of how to use this construct with `fetch` + +```html + +... + +``` + +## Variables + +All upload constructs expose the following variables: + +- `bucketName`: the name of the deployed S3 bucket +- `bucketArn`: the ARN of the deployed S3 bucket + +This can be used to reference the bucket from Lambda functions, for example: + +```yaml +constructs: + upload: + type: upload + +functions: + myFunction: + handler: src/index.handler + environment: + UPLOAD_BUCKET_NAME: ${construct:upload.bucketName} +``` + +_How it works: the `${construct:upload.bucketName}` variable will automatically be replaced with a CloudFormation reference to the S3 bucket._ + +This is useful to process the uploaded files. Remember that the files will be automatically deleted after 24 hours. + +## Permissions + +By default, all the Lambda functions deployed in the same `serverless.yml` file **will be allowed to read/write into the upload bucket**. + +In the example below, there are no IAM permissions to set up: `myFunction` will be allowed to read and write into the `upload` bucket. + +```yaml +constructs: + upload: + type: upload + +functions: + myFunction: + handler: src/index.handler + environment: + UPLOAD_BUCKET_NAME: ${construct:avatars.bucketName} +``` + +Automatic permissions can be disabled: [read more about IAM permissions](permissions.md). + +## Configuration reference + +### API Gateway + +API Gateway provides 2 versions of APIs: + +- v1: REST API +- v2: HTTP API, the fastest and cheapest + +By default, the `upload` construct supports v2 HTTP APIs. + +If your Lambda functions uses `http` events (v1 REST API) instead of `httpApi` events (v2 HTTP API), use the `apiGateway: "rest"` option: + +```yaml +constructs: + upload: + type: upload + apiGateway: 'rest' # either "rest" (v1) or "http" (v2, the default) + +functions: + v1: + handler: foo.handler + events: + - http: 'GET /' # REST API (v1) + v2: + handler: bar.handler + events: + - httpApi: 'GET /' # HTTP API (v2) +``` + +### Encryption + +By default, files are encrypted using [the default S3 encryption mechanism](https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingServerSideEncryption.html) (free). + +Alternatively, for example to comply with certain policies, it is possible to [use KMS](https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingKMSEncryption.html): + +```yaml +constructs: + upload: + # ... + encryption: kms +``` + +### More options + +Looking for more options in the construct configuration? [Open a GitHub issue](https://github.com/getlift/lift/issues/new). diff --git a/package.json b/package.json index 778660d0..735d374d 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "repository": "https://github.com/getlift/lift", "description": "Lift", "dependencies": { + "@aws-cdk/aws-apigateway": "^1.110.1", "@aws-cdk/aws-apigatewayv2": "^1.110.1", "@aws-cdk/aws-apigatewayv2-integrations": "^1.110.1", "@aws-cdk/aws-certificatemanager": "^1.110.1", diff --git a/src/constructs/aws/Storage.ts b/src/constructs/aws/Storage.ts index 276c2ed5..45ae0737 100644 --- a/src/constructs/aws/Storage.ts +++ b/src/constructs/aws/Storage.ts @@ -10,7 +10,6 @@ const STORAGE_DEFINITION = { type: "object", properties: { type: { const: "storage" }, - archive: { type: "number", minimum: 30 }, encryption: { anyOf: [{ const: "s3" }, { const: "kms" }], }, @@ -19,7 +18,6 @@ const STORAGE_DEFINITION = { } as const; const STORAGE_DEFAULTS: Required> = { type: "storage", - archive: 45, encryption: "s3", }; diff --git a/src/constructs/aws/Upload.ts b/src/constructs/aws/Upload.ts new file mode 100644 index 00000000..17e56f09 --- /dev/null +++ b/src/constructs/aws/Upload.ts @@ -0,0 +1,179 @@ +import { BlockPublicAccess, Bucket, BucketEncryption, HttpMethods } from "@aws-cdk/aws-s3"; +import type { Construct as CdkConstruct } from "@aws-cdk/core"; +import { CfnOutput, Duration, Fn, Stack } from "@aws-cdk/core"; +import { Code, Function as LambdaFunction, Runtime } from "@aws-cdk/aws-lambda"; +import type { FromSchema } from "json-schema-to-ts"; +import type { AwsProvider } from "@lift/providers"; +import { AwsConstruct } from "@lift/constructs/abstracts"; +import type { IHttpApi } from "@aws-cdk/aws-apigatewayv2"; +import { HttpApi, HttpMethod, HttpRoute, HttpRouteKey } from "@aws-cdk/aws-apigatewayv2"; +import type { IRestApi, Resource } from "@aws-cdk/aws-apigateway"; +import { LambdaIntegration, RestApi } from "@aws-cdk/aws-apigateway"; +import { LambdaProxyIntegration } from "@aws-cdk/aws-apigatewayv2-integrations"; +import { Role } from "@aws-cdk/aws-iam"; +import { PolicyStatement } from "../../CloudFormation"; + +const UPLOAD_DEFINITION = { + type: "object", + properties: { + type: { const: "upload" }, + apiGateway: { enum: ["http", "rest"] }, + encryption: { + anyOf: [{ const: "s3" }, { const: "kms" }], + }, + }, + additionalProperties: false, +} as const; +const UPLOAD_DEFAULTS: Required> = { + type: "upload", + encryption: "s3", + apiGateway: "http", +}; + +type Configuration = FromSchema; + +export class Upload extends AwsConstruct { + public static type = "upload"; + public static schema = UPLOAD_DEFINITION; + + private readonly bucket: Bucket; + private readonly bucketNameOutput: CfnOutput; + private function: LambdaFunction; + private httpApi: IHttpApi | undefined; + private route: HttpRoute | undefined; + private restApi: RestApi | undefined; + + constructor(scope: CdkConstruct, id: string, configuration: Configuration, private provider: AwsProvider) { + super(scope, id); + + const resolvedConfiguration = Object.assign({}, UPLOAD_DEFAULTS, configuration); + + const encryptionOptions = { + s3: BucketEncryption.S3_MANAGED, + kms: BucketEncryption.KMS_MANAGED, + }; + + this.bucket = new Bucket(this, "Bucket", { + encryption: encryptionOptions[resolvedConfiguration.encryption], + blockPublicAccess: BlockPublicAccess.BLOCK_ALL, + enforceSSL: true, + cors: [ + { + allowedMethods: [HttpMethods.PUT], + allowedOrigins: ["*"], + allowedHeaders: ["*"], + }, + ], + lifecycleRules: [ + { + expiration: Duration.days(1), + }, + ], + }); + + this.bucketNameOutput = new CfnOutput(this, "BucketName", { + value: this.bucket.bucketName, + }); + + this.function = new LambdaFunction(this, "Function", { + code: Code.fromInline(this.createFunctionCode()), + handler: "index.handler", + runtime: Runtime.NODEJS_12_X, + environment: { + LIFT_UPLOAD_BUCKET_NAME: this.bucket.bucketName, + }, + role: Role.fromRoleArn( + this, + "LambdaRole", + Fn.getAtt(this.provider.naming.getRoleLogicalId(), "Arn").toString() + ), + }); + + if (resolvedConfiguration.apiGateway === "http") { + this.provider.enableHttpApiCors(); + this.httpApi = HttpApi.fromHttpApiAttributes(this, "HttpApi", { + httpApiId: Fn.ref(this.provider.naming.getHttpApiLogicalId()), + }); + + this.route = new HttpRoute(this, "Route", { + httpApi: this.httpApi, + integration: new LambdaProxyIntegration({ + handler: this.function, + }), + routeKey: HttpRouteKey.with("/upload-url", HttpMethod.POST), + }); + } + + if (resolvedConfiguration.apiGateway === "rest") { + this.restApi = RestApi.fromRestApiAttributes(this, "RestApi", { + restApiId: Fn.ref(this.provider.naming.getRestApiLogicalId()), + rootResourceId: Fn.getAtt(this.provider.naming.getRestApiLogicalId(), "RootResourceId").toString(), + }) as RestApi; + + const resource: Resource = this.restApi.root.addResource("upload-url"); + resource.addCorsPreflight({ + allowHeaders: ["*"], + allowMethods: ["POST"], + allowOrigins: ["*"], + }); + resource.addMethod("POST", new LambdaIntegration(this.function)); + } + } + + variables(): Record { + return { + bucketArn: this.bucket.bucketArn, + bucketName: this.bucket.bucketName, + }; + } + + permissions(): PolicyStatement[] { + return [ + new PolicyStatement( + ["s3:PutObject", "s3:GetObject", "s3:DeleteObject", "s3:ListBucket"], + [this.bucket.bucketArn, Stack.of(this).resolve(Fn.join("/", [this.bucket.bucketArn, "*"]))] + ), + ]; + } + + outputs(): Record Promise> { + return { + bucketName: () => this.getBucketName(), + }; + } + + async getBucketName(): Promise { + return this.provider.getStackOutput(this.bucketNameOutput); + } + + private createFunctionCode(): string { + return ` +const AWS = require('aws-sdk'); +const crypto = require("crypto"); +const s3 = new AWS.S3(); + +exports.handler = async (event) => { + const body = JSON.parse(event.body); + const fileName = \`tmp/\${crypto.randomBytes(5).toString('hex')}-\${body.fileName}\`; + + const url = s3.getSignedUrl('putObject', { + Bucket: process.env.LIFT_UPLOAD_BUCKET_NAME, + Key: fileName, + ContentType: body.contentType, + Expires: 60 * 5, + }); + + return { + body: JSON.stringify({ + fileName: fileName, + uploadUrl: url, + }), + headers: { + "Access-Control-Allow-Origin": event.headers.origin, + }, + statusCode: 200 + }; +} + `; + } +} diff --git a/src/providers/AwsProvider.ts b/src/providers/AwsProvider.ts index bc02d444..a11566a6 100644 --- a/src/providers/AwsProvider.ts +++ b/src/providers/AwsProvider.ts @@ -13,10 +13,12 @@ import { Vpc, Webhook, } from "@lift/constructs/aws"; +import { Upload } from "@lift/constructs/aws/Upload"; import { getStackOutput } from "../CloudFormation"; import type { CloudformationTemplate, Provider as LegacyAwsProvider, Serverless } from "../types/serverless"; import { awsRequest } from "../classes/aws"; import ServerlessError from "../utils/error"; +import { HttpApiCorsConfig } from "../types/serverless"; const AWS_DEFINITION = { type: "object", @@ -63,6 +65,7 @@ export class AwsProvider implements ProviderInterface { getLambdaLogicalId: (functionName: string) => string; getRestApiLogicalId: () => string; getHttpApiLogicalId: () => string; + getRoleLogicalId: () => string; }; constructor(private readonly serverless: Serverless) { @@ -136,6 +139,22 @@ export class AwsProvider implements ProviderInterface { return this.serverless.service.provider.vpc ?? null; } + /** + * This enables HTTP API CORS preflight requests if the user + * didn't do it explicitly. + */ + enableHttpApiCors(): void { + if (this.serverless.service.provider.httpApi === undefined) { + this.serverless.service.provider.httpApi = { + cors: true, + }; + } + + if (this.serverless.service.provider.httpApi.cors === undefined) { + this.serverless.service.provider.httpApi.cors = true; + } + } + /** * Resolves the value of a CloudFormation stack output. */ @@ -172,5 +191,6 @@ AwsProvider.registerConstructs( StaticWebsite, Vpc, DatabaseDynamoDBSingleTable, - ServerSideWebsite + ServerSideWebsite, + Upload ); diff --git a/src/types/serverless.ts b/src/types/serverless.ts index ae6a7a38..86e05d04 100644 --- a/src/types/serverless.ts +++ b/src/types/serverless.ts @@ -27,6 +27,7 @@ export type Provider = { getRestApiLogicalId: () => string; getHttpApiLogicalId: () => string; getCompiledTemplateFileName: () => string; + getRoleLogicalId: () => string; }; getRegion: () => string; /** diff --git a/test/unit/upload.test.ts b/test/unit/upload.test.ts new file mode 100644 index 00000000..19075e30 --- /dev/null +++ b/test/unit/upload.test.ts @@ -0,0 +1,161 @@ +import { get } from "lodash"; +import { baseConfig, runServerless } from "../utils/runServerless"; + +describe("upload", () => { + it("should create all required resources with HTTP API", async () => { + const { cfTemplate, computeLogicalId } = await runServerless({ + command: "package", + config: Object.assign(baseConfig, { + constructs: { + upload: { + type: "upload", + }, + }, + }), + }); + + const bucket = computeLogicalId("upload", "Bucket"); + const bucketPolicy = computeLogicalId("upload", "Bucket", "Policy"); + const uploadFunction = computeLogicalId("upload", "Function"); + const httpApiRoute = computeLogicalId("upload", "Route"); + + expect(Object.keys(cfTemplate.Resources)).toStrictEqual([ + "ServerlessDeploymentBucket", + "ServerlessDeploymentBucketPolicy", + bucket, + bucketPolicy, + uploadFunction, + "uploadRouteuploadRoute2545F0B8PermissionCB079AC2", + "uploadRouteHttpIntegration02104492e88c1940a1c8d0dbac532c8091C83E5A", + httpApiRoute, + ]); + }); + it("should create all required resources with REST API", async () => { + const { cfTemplate, computeLogicalId } = await runServerless({ + command: "package", + config: Object.assign(baseConfig, { + constructs: { + upload: { + type: "upload", + apiGateway: "rest", + }, + }, + }), + }); + + const bucket = computeLogicalId("upload", "Bucket"); + const bucketPolicy = computeLogicalId("upload", "Bucket", "Policy"); + const uploadFunction = computeLogicalId("upload", "Function"); + + expect(Object.keys(cfTemplate.Resources)).toStrictEqual([ + "ServerlessDeploymentBucket", + "ServerlessDeploymentBucketPolicy", + bucket, + bucketPolicy, + uploadFunction, + "uploadRestApiuploadurl2A547A06", + "uploadRestApiuploadurlOPTIONS1BD5E4F2", + "uploadRestApiuploadurlPOSTApiPermissionuploadRestApiC195B6D4POSTuploadurlE1E5BEF5", + "uploadRestApiuploadurlPOSTApiPermissionTestuploadRestApiC195B6D4POSTuploadurl7144EDCF", + "uploadRestApiuploadurlPOST347E9EEB", + ]); + }); + + it("should delete files after 1 day", async () => { + const { cfTemplate, computeLogicalId } = await runServerless({ + command: "package", + config: Object.assign(baseConfig, { + constructs: { + upload: { + type: "upload", + }, + }, + }), + }); + + expect( + get(cfTemplate.Resources[computeLogicalId("upload", "Bucket")].Properties, "LifecycleConfiguration") + ).toStrictEqual({ + Rules: [ + { + ExpirationInDays: 1, + Status: "Enabled", + }, + ], + }); + }); + + it("should enable CORS on the bucket", async () => { + const { cfTemplate, computeLogicalId } = await runServerless({ + command: "package", + config: Object.assign(baseConfig, { + constructs: { + upload: { + type: "upload", + }, + }, + }), + }); + + expect( + get(cfTemplate.Resources[computeLogicalId("upload", "Bucket")].Properties, "CorsConfiguration") + ).toStrictEqual({ + CorsRules: [ + { + AllowedHeaders: ["*"], + AllowedMethods: ["PUT"], + AllowedOrigins: ["*"], + }, + ], + }); + }); + + it("should enable block public access on the bucket", async () => { + const { cfTemplate, computeLogicalId } = await runServerless({ + command: "package", + config: Object.assign(baseConfig, { + constructs: { + upload: { + type: "upload", + }, + }, + }), + }); + + expect( + get(cfTemplate.Resources[computeLogicalId("upload", "Bucket")].Properties, "PublicAccessBlockConfiguration") + ).toStrictEqual({ + BlockPublicAcls: true, + BlockPublicPolicy: true, + IgnorePublicAcls: true, + RestrictPublicBuckets: true, + }); + }); + + test.each([ + ["s3", "AES256"], + ["kms", "aws:kms"], + ])("should allow %p encryption", async (encryption, expectedSSEAlgorithm) => { + const { cfTemplate, computeLogicalId } = await runServerless({ + command: "package", + config: Object.assign(baseConfig, { + constructs: { + upload: { + type: "upload", + encryption: encryption, + }, + }, + }), + }); + + expect(cfTemplate.Resources[computeLogicalId("upload", "Bucket")].Properties).toMatchObject({ + BucketEncryption: { + ServerSideEncryptionConfiguration: [ + { + ServerSideEncryptionByDefault: { SSEAlgorithm: expectedSSEAlgorithm }, + }, + ], + }, + }); + }); +});