diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/README.md b/packages/@aws-cdk/aws-apigatewayv2-authorizers/README.md index ae44e5e5d97fc..3e888e57c9a9c 100644 --- a/packages/@aws-cdk/aws-apigatewayv2-authorizers/README.md +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/README.md @@ -24,6 +24,7 @@ - [Route Authorization](#route-authorization) - [JWT Authorizers](#jwt-authorizers) - [User Pool Authorizer](#user-pool-authorizer) +- [Lambda Authorizers](#lambda-authorizers) ## Introduction @@ -162,3 +163,32 @@ api.addRoutes({ authorizer, }); ``` + +## Lambda Authorizers + +Lambda authorizers use a Lambda function to control access to your HTTP API. When a client calls your API, API Gateway invokes your Lambda function and uses the response to determine whether the client can access your API. + +Lambda authorizers depending on their response, fall into either two types - Simple or IAM. You can learn about differences [here](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-lambda-authorizer.html#http-api-lambda-authorizer.payload-format-response). + + +```ts +// This function handles your auth logic +const authHandler = new Function(this, 'auth-function', { + //... +}); + +const authorizer = new HttpLambdaAuthorizer({ + responseTypes: [HttpLambdaAuthorizerType.SIMPLE] // Define if returns simple and/or iam response + handler: authHandler, +}); + +const api = new HttpApi(stack, 'HttpApi'); + +api.addRoutes({ + integration: new HttpProxyIntegration({ + url: 'https://get-books-proxy.myproxy.internal', + }), + path: '/books', + authorizer, +}); +``` diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/index.ts b/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/index.ts index 9f9ad94c6a4b7..410cc8aa09f2e 100644 --- a/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/index.ts +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/index.ts @@ -1,2 +1,3 @@ export * from './user-pool'; -export * from './jwt'; \ No newline at end of file +export * from './jwt'; +export * from './lambda'; \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/jwt.ts b/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/jwt.ts index afb5f10ac07f8..184d02f3382b6 100644 --- a/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/jwt.ts +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/jwt.ts @@ -64,7 +64,7 @@ export class HttpJwtAuthorizer implements IHttpRouteAuthorizer { return { authorizerId: this.authorizer.authorizerId, - authorizationType: HttpAuthorizerType.JWT, + authorizationType: 'JWT', }; } } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/lambda.ts b/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/lambda.ts new file mode 100644 index 0000000000000..fcb4f327c08a2 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/lambda.ts @@ -0,0 +1,130 @@ +import { + HttpAuthorizer, + HttpAuthorizerType, + HttpRouteAuthorizerBindOptions, + HttpRouteAuthorizerConfig, + IHttpRouteAuthorizer, + AuthorizerPayloadVersion, + IHttpApi, +} from '@aws-cdk/aws-apigatewayv2'; +import { ServicePrincipal } from '@aws-cdk/aws-iam'; +import { IFunction } from '@aws-cdk/aws-lambda'; +import { Stack, Duration, Names } from '@aws-cdk/core'; + +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct as CoreConstruct } from '@aws-cdk/core'; + +/** + * Specifies the type responses the lambda returns + */ +export enum HttpLambdaResponseType { + /** Returns simple boolean response */ + SIMPLE, + + /** Returns an IAM Policy */ + IAM, +} + +/** + * Properties to initialize HttpTokenAuthorizer. + */ +export interface HttpLambdaAuthorizerProps { + + /** + * The name of the authorizer + */ + readonly authorizerName: string; + + /** + * The identity source for which authorization is requested. + * + * @default ['$request.header.Authorization'] + */ + readonly identitySource?: string[]; + + /** + * The lambda function used for authorization + */ + readonly handler: IFunction; + + /** + * How long APIGateway should cache the results. Max 1 hour. + * Disable caching by setting this to `Duration.seconds(0)`. + * + * @default Duration.minutes(5) + */ + readonly resultsCacheTtl?: Duration; + + /** + * The types of responses the lambda can return + * + * If HttpLambdaResponseType.SIMPLE is included then + * response format 2.0 will be used. + * + * @see https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-lambda-authorizer.html#http-api-lambda-authorizer.payload-format-response + * + * @default [HttpLambdaResponseType.IAM] + */ + readonly responseTypes?: HttpLambdaResponseType[]; +} + +/** + * Authorize Http Api routes via a lambda function + */ +export class HttpLambdaAuthorizer implements IHttpRouteAuthorizer { + private authorizer?: HttpAuthorizer; + private httpApi?: IHttpApi; + + constructor(private readonly props: HttpLambdaAuthorizerProps) { + } + + public bind(options: HttpRouteAuthorizerBindOptions): HttpRouteAuthorizerConfig { + if (this.httpApi && (this.httpApi.apiId !== options.route.httpApi.apiId)) { + throw new Error('Cannot attach the same authorizer to multiple Apis'); + } + + if (!this.authorizer) { + const id = this.props.authorizerName; + + const responseTypes = this.props.responseTypes ?? [HttpLambdaResponseType.IAM]; + const enableSimpleResponses = responseTypes.includes(HttpLambdaResponseType.SIMPLE) || undefined; + + this.httpApi = options.route.httpApi; + this.authorizer = new HttpAuthorizer(options.scope, id, { + httpApi: options.route.httpApi, + identitySource: this.props.identitySource ?? [ + '$request.header.Authorization', + ], + type: HttpAuthorizerType.LAMBDA, + authorizerName: this.props.authorizerName, + enableSimpleResponses, + payloadFormatVersion: enableSimpleResponses ? AuthorizerPayloadVersion.VERSION_2_0 : AuthorizerPayloadVersion.VERSION_1_0, + authorizerUri: lambdaAuthorizerArn(this.props.handler), + resultsCacheTtl: this.props.resultsCacheTtl ?? Duration.minutes(5), + }); + + this.props.handler.addPermission(`${Names.nodeUniqueId(this.authorizer.node)}-Permission`, { + scope: options.scope as CoreConstruct, + principal: new ServicePrincipal('apigateway.amazonaws.com'), + sourceArn: Stack.of(options.route).formatArn({ + service: 'execute-api', + resource: options.route.httpApi.apiId, + resourceName: `authorizers/${this.authorizer.authorizerId}`, + }), + }); + } + + return { + authorizerId: this.authorizer.authorizerId, + authorizationType: 'CUSTOM', + }; + } +} + +/** + * constructs the authorizerURIArn. + */ +function lambdaAuthorizerArn(handler: IFunction) { + return `arn:${Stack.of(handler).partition}:apigateway:${Stack.of(handler).region}:lambda:path/2015-03-31/functions/${handler.functionArn}/invocations`; +} diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/user-pool.ts b/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/user-pool.ts index 4a251b8eb7406..702a3a05576ec 100644 --- a/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/user-pool.ts +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/user-pool.ts @@ -63,7 +63,7 @@ export class HttpUserPoolAuthorizer implements IHttpRouteAuthorizer { return { authorizerId: this.authorizer.authorizerId, - authorizationType: HttpAuthorizerType.JWT, + authorizationType: 'JWT', }; } } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/package.json b/packages/@aws-cdk/aws-apigatewayv2-authorizers/package.json index c70e7a5dd8115..58de08da038e4 100644 --- a/packages/@aws-cdk/aws-apigatewayv2-authorizers/package.json +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/package.json @@ -82,12 +82,16 @@ "dependencies": { "@aws-cdk/aws-apigatewayv2": "0.0.0", "@aws-cdk/aws-cognito": "0.0.0", + "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-lambda": "0.0.0", "@aws-cdk/core": "0.0.0", "constructs": "^3.3.69" }, "peerDependencies": { "@aws-cdk/aws-apigatewayv2": "0.0.0", "@aws-cdk/aws-cognito": "0.0.0", + "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-lambda": "0.0.0", "@aws-cdk/core": "0.0.0", "constructs": "^3.3.69" }, diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/auth-handler/index.ts b/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/auth-handler/index.ts new file mode 100644 index 0000000000000..f08c1bdb1b42a --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/auth-handler/index.ts @@ -0,0 +1,9 @@ +/* eslint-disable no-console */ + +export const handler = async (event: AWSLambda.APIGatewayProxyEventV2) => { + const key = event.headers['x-api-key']; + + return { + isAuthorized: key === '123', + }; +}; \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/integ.lambda.expected.json b/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/integ.lambda.expected.json new file mode 100644 index 0000000000000..69c407f274908 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/integ.lambda.expected.json @@ -0,0 +1,397 @@ +{ + "Resources": { + "MyHttpApi8AEAAC21": { + "Type": "AWS::ApiGatewayV2::Api", + "Properties": { + "Name": "MyHttpApi", + "ProtocolType": "HTTP" + } + }, + "MyHttpApiDefaultStageDCB9BC49": { + "Type": "AWS::ApiGatewayV2::Stage", + "Properties": { + "ApiId": { + "Ref": "MyHttpApi8AEAAC21" + }, + "StageName": "$default", + "AutoDeploy": true + } + }, + "MyHttpApiGETAuthorizerIntegMyHttpApiGET16D02385PermissionBB02EBFE": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "lambda8B5974B5", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "MyHttpApi8AEAAC21" + }, + "/*/*/" + ] + ] + } + } + }, + "MyHttpApiGETHttpIntegration6f095b8469365f72e33fa33d9711b140516EBE31": { + "Type": "AWS::ApiGatewayV2::Integration", + "Properties": { + "ApiId": { + "Ref": "MyHttpApi8AEAAC21" + }, + "IntegrationType": "AWS_PROXY", + "IntegrationUri": { + "Fn::GetAtt": [ + "lambda8B5974B5", + "Arn" + ] + }, + "PayloadFormatVersion": "2.0" + } + }, + "MyHttpApiGETE0EFC6F8": { + "Type": "AWS::ApiGatewayV2::Route", + "Properties": { + "ApiId": { + "Ref": "MyHttpApi8AEAAC21" + }, + "RouteKey": "GET /", + "AuthorizationType": "CUSTOM", + "AuthorizerId": { + "Ref": "MyHttpApimysimpleauthorizer98398C16" + }, + "Target": { + "Fn::Join": [ + "", + [ + "integrations/", + { + "Ref": "MyHttpApiGETHttpIntegration6f095b8469365f72e33fa33d9711b140516EBE31" + } + ] + ] + } + } + }, + "MyHttpApimysimpleauthorizer98398C16": { + "Type": "AWS::ApiGatewayV2::Authorizer", + "Properties": { + "ApiId": { + "Ref": "MyHttpApi8AEAAC21" + }, + "AuthorizerType": "REQUEST", + "IdentitySource": [ + "$request.header.X-API-Key" + ], + "Name": "my-simple-authorizer", + "AuthorizerPayloadFormatVersion": "2.0", + "AuthorizerResultTtlInSeconds": 300, + "AuthorizerUri": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":apigateway:", + { + "Ref": "AWS::Region" + }, + ":lambda:path/2015-03-31/functions/", + { + "Fn::GetAtt": [ + "authfunction96361832", + "Arn" + ] + }, + "/invocations" + ] + ] + }, + "EnableSimpleResponses": true + } + }, + "MyHttpApiAuthorizerIntegMyHttpApimysimpleauthorizer0F14A472PermissionF37EF5C8": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "authfunction96361832", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "MyHttpApi8AEAAC21" + }, + "/authorizers/", + { + "Ref": "MyHttpApimysimpleauthorizer98398C16" + } + ] + ] + } + } + }, + "authfunctionServiceRoleFCB72198": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "authfunction96361832": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "AssetParameters7f2fe4e4fa40a84f0f773203f5c5fdaac31c80ce42c5185ed2659a049db03043S3BucketC7E46972" + }, + "S3Key": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters7f2fe4e4fa40a84f0f773203f5c5fdaac31c80ce42c5185ed2659a049db03043S3VersionKeyA8ECA032" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters7f2fe4e4fa40a84f0f773203f5c5fdaac31c80ce42c5185ed2659a049db03043S3VersionKeyA8ECA032" + } + ] + } + ] + } + ] + ] + } + }, + "Role": { + "Fn::GetAtt": [ + "authfunctionServiceRoleFCB72198", + "Arn" + ] + }, + "Handler": "index.handler", + "Runtime": "nodejs14.x" + }, + "DependsOn": [ + "authfunctionServiceRoleFCB72198" + ] + }, + "lambdaServiceRole494E4CA6": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "lambda8B5974B5": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "AssetParameters1fd1c15cb7d5e2e36a11745fd10b4b7c3ca8eb30642b41954630413d2b913cdaS3Bucket2E6D85D3" + }, + "S3Key": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters1fd1c15cb7d5e2e36a11745fd10b4b7c3ca8eb30642b41954630413d2b913cdaS3VersionKey22B8E7C6" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters1fd1c15cb7d5e2e36a11745fd10b4b7c3ca8eb30642b41954630413d2b913cdaS3VersionKey22B8E7C6" + } + ] + } + ] + } + ] + ] + } + }, + "Role": { + "Fn::GetAtt": [ + "lambdaServiceRole494E4CA6", + "Arn" + ] + }, + "Handler": "index.handler", + "Runtime": "nodejs12.x" + }, + "DependsOn": [ + "lambdaServiceRole494E4CA6" + ] + } + }, + "Parameters": { + "AssetParameters7f2fe4e4fa40a84f0f773203f5c5fdaac31c80ce42c5185ed2659a049db03043S3BucketC7E46972": { + "Type": "String", + "Description": "S3 bucket for asset \"7f2fe4e4fa40a84f0f773203f5c5fdaac31c80ce42c5185ed2659a049db03043\"" + }, + "AssetParameters7f2fe4e4fa40a84f0f773203f5c5fdaac31c80ce42c5185ed2659a049db03043S3VersionKeyA8ECA032": { + "Type": "String", + "Description": "S3 key for asset version \"7f2fe4e4fa40a84f0f773203f5c5fdaac31c80ce42c5185ed2659a049db03043\"" + }, + "AssetParameters7f2fe4e4fa40a84f0f773203f5c5fdaac31c80ce42c5185ed2659a049db03043ArtifactHashE679D99A": { + "Type": "String", + "Description": "Artifact hash for asset \"7f2fe4e4fa40a84f0f773203f5c5fdaac31c80ce42c5185ed2659a049db03043\"" + }, + "AssetParameters1fd1c15cb7d5e2e36a11745fd10b4b7c3ca8eb30642b41954630413d2b913cdaS3Bucket2E6D85D3": { + "Type": "String", + "Description": "S3 bucket for asset \"1fd1c15cb7d5e2e36a11745fd10b4b7c3ca8eb30642b41954630413d2b913cda\"" + }, + "AssetParameters1fd1c15cb7d5e2e36a11745fd10b4b7c3ca8eb30642b41954630413d2b913cdaS3VersionKey22B8E7C6": { + "Type": "String", + "Description": "S3 key for asset version \"1fd1c15cb7d5e2e36a11745fd10b4b7c3ca8eb30642b41954630413d2b913cda\"" + }, + "AssetParameters1fd1c15cb7d5e2e36a11745fd10b4b7c3ca8eb30642b41954630413d2b913cdaArtifactHash82A279EA": { + "Type": "String", + "Description": "Artifact hash for asset \"1fd1c15cb7d5e2e36a11745fd10b4b7c3ca8eb30642b41954630413d2b913cda\"" + } + }, + "Outputs": { + "URL": { + "Value": { + "Fn::Join": [ + "", + [ + "https://", + { + "Ref": "MyHttpApi8AEAAC21" + }, + ".execute-api.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/" + ] + ] + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/integ.lambda.ts b/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/integ.lambda.ts new file mode 100644 index 0000000000000..264da5f4bf510 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/integ.lambda.ts @@ -0,0 +1,49 @@ +import * as path from 'path'; +import { HttpApi, HttpMethod } from '@aws-cdk/aws-apigatewayv2'; +import { LambdaProxyIntegration } from '@aws-cdk/aws-apigatewayv2-integrations'; +import * as lambda from '@aws-cdk/aws-lambda'; +import { App, Stack, CfnOutput } from '@aws-cdk/core'; +import { HttpLambdaAuthorizer, HttpLambdaResponseType } from '../../lib'; + +/* + * Stack verification steps: + * * `curl -H 'X-API-Key: 123' ` should return 200 + * * `curl ` should return 401 + * * `curl -H 'X-API-Key: 1234' ` should return 403 + */ + +const app = new App(); +const stack = new Stack(app, 'AuthorizerInteg'); + +const httpApi = new HttpApi(stack, 'MyHttpApi'); + +const authHandler = new lambda.Function(stack, 'auth-function', { + runtime: lambda.Runtime.NODEJS_14_X, + handler: 'index.handler', + code: lambda.Code.fromAsset(path.join(__dirname, '../auth-handler')), +}); + + +const authorizer = new HttpLambdaAuthorizer({ + authorizerName: 'my-simple-authorizer', + identitySource: ['$request.header.X-API-Key'], + handler: authHandler, + responseTypes: [HttpLambdaResponseType.SIMPLE], +}); + +const handler = new lambda.Function(stack, 'lambda', { + runtime: lambda.Runtime.NODEJS_12_X, + handler: 'index.handler', + code: lambda.AssetCode.fromAsset(path.join(__dirname, '../integ.lambda.handler')), +}); + +httpApi.addRoutes({ + path: '/', + methods: [HttpMethod.GET], + integration: new LambdaProxyIntegration({ handler }), + authorizer, +}); + +new CfnOutput(stack, 'URL', { + value: httpApi.url!, +}); diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/lambda.test.ts b/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/lambda.test.ts new file mode 100644 index 0000000000000..a9efd500e6bf1 --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/lambda.test.ts @@ -0,0 +1,182 @@ +import '@aws-cdk/assert-internal/jest'; +import { ABSENT } from '@aws-cdk/assert-internal'; +import { HttpApi, HttpIntegrationType, HttpRouteIntegrationBindOptions, IHttpRouteIntegration, PayloadFormatVersion } from '@aws-cdk/aws-apigatewayv2'; +import { Code, Function, Runtime } from '@aws-cdk/aws-lambda'; +import { Duration, Stack } from '@aws-cdk/core'; +import { HttpLambdaAuthorizer, HttpLambdaResponseType } from '../../lib'; + +describe('HttpLambdaAuthorizer', () => { + + test('default', () => { + // GIVEN + const stack = new Stack(); + const api = new HttpApi(stack, 'HttpApi'); + + const handler = new Function(stack, 'auth-function', { + runtime: Runtime.NODEJS_12_X, + code: Code.fromInline('exports.handler = () => {return true}'), + handler: 'index.handler', + }); + + const authorizer = new HttpLambdaAuthorizer({ + authorizerName: 'default-authorizer', + handler, + }); + + // WHEN + api.addRoutes({ + integration: new DummyRouteIntegration(), + path: '/books', + authorizer, + }); + + // THEN + expect(stack).toHaveResource('AWS::ApiGatewayV2::Authorizer', { + Name: 'default-authorizer', + AuthorizerType: 'REQUEST', + AuthorizerResultTtlInSeconds: 300, + AuthorizerPayloadFormatVersion: '1.0', + IdentitySource: [ + '$request.header.Authorization', + ], + }); + + expect(stack).toHaveResource('AWS::ApiGatewayV2::Route', { + AuthorizationType: 'CUSTOM', + }); + }); + + test('should use format 2.0 and simple responses when simple response type is requested', () => { + // GIVEN + const stack = new Stack(); + const api = new HttpApi(stack, 'HttpApi'); + + const handler = new Function(stack, 'auth-function', { + runtime: Runtime.NODEJS_12_X, + code: Code.fromInline('exports.handler = () => {return true}'), + handler: 'index.handler', + }); + + const authorizer = new HttpLambdaAuthorizer({ + authorizerName: 'my-simple-authorizer', + responseTypes: [HttpLambdaResponseType.SIMPLE], + handler, + }); + + // WHEN + api.addRoutes({ + integration: new DummyRouteIntegration(), + path: '/books', + authorizer, + }); + + // THEN + expect(stack).toHaveResource('AWS::ApiGatewayV2::Authorizer', { + AuthorizerPayloadFormatVersion: '2.0', + EnableSimpleResponses: true, + }); + }); + + test('should use format 1.0 when only IAM response type is requested', () => { + // GIVEN + const stack = new Stack(); + const api = new HttpApi(stack, 'HttpApi'); + + const handler = new Function(stack, 'auth-function', { + runtime: Runtime.NODEJS_12_X, + code: Code.fromInline('exports.handler = () => {return true}'), + handler: 'index.handler', + }); + + const authorizer = new HttpLambdaAuthorizer({ + authorizerName: 'my-iam-authorizer', + responseTypes: [HttpLambdaResponseType.IAM], + handler, + }); + + // WHEN + api.addRoutes({ + integration: new DummyRouteIntegration(), + path: '/books', + authorizer, + }); + + // THEN + expect(stack).toHaveResource('AWS::ApiGatewayV2::Authorizer', { + AuthorizerPayloadFormatVersion: '1.0', + EnableSimpleResponses: ABSENT, + }); + }); + + test('should use format 2.0 and simple responses when both response types are requested', () => { + // GIVEN + const stack = new Stack(); + const api = new HttpApi(stack, 'HttpApi'); + + const handler = new Function(stack, 'auth-function', { + runtime: Runtime.NODEJS_12_X, + code: Code.fromInline('exports.handler = () => {return true}'), + handler: 'index.handler', + }); + + const authorizer = new HttpLambdaAuthorizer({ + authorizerName: 'my-simple-iam-authorizer', + responseTypes: [HttpLambdaResponseType.IAM, HttpLambdaResponseType.SIMPLE], + handler, + }); + + // WHEN + api.addRoutes({ + integration: new DummyRouteIntegration(), + path: '/books', + authorizer, + }); + + // THEN + expect(stack).toHaveResource('AWS::ApiGatewayV2::Authorizer', { + AuthorizerPayloadFormatVersion: '2.0', + EnableSimpleResponses: true, + }); + }); + + test('can override cache ttl', () => { + // GIVEN + const stack = new Stack(); + const api = new HttpApi(stack, 'HttpApi'); + + const handler = new Function(stack, 'auth-functon', { + runtime: Runtime.NODEJS_12_X, + code: Code.fromInline('exports.handler = () => {return true}'), + handler: 'index.handler', + }); + + const authorizer = new HttpLambdaAuthorizer({ + authorizerName: 'my-simple-authorizer', + responseTypes: [HttpLambdaResponseType.SIMPLE], + handler, + resultsCacheTtl: Duration.minutes(10), + }); + + // WHEN + api.addRoutes({ + integration: new DummyRouteIntegration(), + path: '/books', + authorizer, + }); + + // THEN + expect(stack).toHaveResource('AWS::ApiGatewayV2::Authorizer', { + AuthorizerResultTtlInSeconds: 600, + }); + }); +}); + +class DummyRouteIntegration implements IHttpRouteIntegration { + public bind(_: HttpRouteIntegrationBindOptions) { + return { + payloadFormatVersion: PayloadFormatVersion.VERSION_2_0, + type: HttpIntegrationType.HTTP_PROXY, + uri: 'some-uri', + }; + } +} diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/integ.lambda.handler/index.ts b/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/integ.lambda.handler/index.ts new file mode 100644 index 0000000000000..def194e303e1e --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/integ.lambda.handler/index.ts @@ -0,0 +1,9 @@ +export const handler = async () => { + return { + statusCode: 200, + body: JSON.stringify({ message: 'Hello from authenticated lambda' }), + headers: { + 'Content-Type': 'application/json', + }, + }; +}; \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/http/authorizer.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/http/authorizer.ts index 297abf12e78e2..08936ecf36d8f 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/http/authorizer.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/http/authorizer.ts @@ -1,4 +1,4 @@ -import { Resource } from '@aws-cdk/core'; +import { Duration, Resource } from '@aws-cdk/core'; import { Construct } from 'constructs'; import { CfnAuthorizer } from '../apigatewayv2.generated'; @@ -15,9 +15,18 @@ export enum HttpAuthorizerType { /** Lambda Authorizer */ LAMBDA = 'REQUEST', +} + +/** + * Payload format version for lambda authorizers + * @see https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-lambda-authorizer.html + */ +export enum AuthorizerPayloadVersion { + /** Version 1.0 */ + VERSION_1_0 = '1.0', - /** No authorizer */ - NONE = 'NONE' + /** Version 2.0 */ + VERSION_2_0 = '2.0' } /** @@ -58,6 +67,38 @@ export interface HttpAuthorizerProps { * @default - required for JWT authorizer types. */ readonly jwtIssuer?: string; + + /** + * Specifies whether a Lambda authorizer returns a response in a simple format. + * + * If enabled, the Lambda authorizer can return a boolean value instead of an IAM policy. + * + * @default - The lambda authorizer must return an IAM policy as its response + */ + readonly enableSimpleResponses?: boolean; + + /** + * Specifies the format of the payload sent to an HTTP API Lambda authorizer. + * + * @default AuthorizerPayloadVersion.VERSION_2_0 if the authorizer type is HttpAuthorizerType.LAMBDA + */ + readonly payloadFormatVersion?: AuthorizerPayloadVersion; + + /** + * The authorizer's Uniform Resource Identifier (URI). + * + * For REQUEST authorizers, this must be a well-formed Lambda function URI. + * + * @default - required for Request authorizer types + */ + readonly authorizerUri?: string; + + /** + * How long APIGateway should cache the results. Max 1 hour. + * + * @default - API Gateway will not cache authorizer responses + */ + readonly resultsCacheTtl?: Duration; } /** @@ -77,8 +118,13 @@ export interface HttpAuthorizerAttributes { /** * Type of authorizer + * + * Possible values are: + * - JWT - JSON Web Token Authorizer + * - CUSTOM - Lambda Authorizer + * - NONE - No Authorization */ - readonly authorizerType: HttpAuthorizerType + readonly authorizerType: string } /** @@ -109,10 +155,24 @@ export class HttpAuthorizer extends Resource implements IHttpAuthorizer { constructor(scope: Construct, id: string, props: HttpAuthorizerProps) { super(scope, id); + let authorizerPayloadFormatVersion = props.payloadFormatVersion; + if (props.type === HttpAuthorizerType.JWT && (!props.jwtAudience || props.jwtAudience.length === 0 || !props.jwtIssuer)) { throw new Error('jwtAudience and jwtIssuer are mandatory for JWT authorizers'); } + if (props.type === HttpAuthorizerType.LAMBDA && !props.authorizerUri) { + throw new Error('authorizerUri is mandatory for Lambda authorizers'); + } + + /** + * This check is required because Cloudformation will fail stack creation is this property + * is set for the JWT authorizer. AuthorizerPayloadFormatVersion can only be set for REQUEST authorizer + */ + if (props.type === HttpAuthorizerType.LAMBDA && typeof authorizerPayloadFormatVersion === 'undefined') { + authorizerPayloadFormatVersion = AuthorizerPayloadVersion.VERSION_2_0; + } + const resource = new CfnAuthorizer(this, 'Resource', { name: props.authorizerName ?? id, apiId: props.httpApi.apiId, @@ -122,6 +182,10 @@ export class HttpAuthorizer extends Resource implements IHttpAuthorizer { audience: props.jwtAudience, issuer: props.jwtIssuer, }), + enableSimpleResponses: props.enableSimpleResponses, + authorizerPayloadFormatVersion, + authorizerUri: props.authorizerUri, + authorizerResultTtlInSeconds: props.resultsCacheTtl?.toSeconds(), }); this.authorizerId = resource.ref; @@ -152,10 +216,17 @@ export interface HttpRouteAuthorizerConfig { * @default - No authorizer id (useful for AWS_IAM route authorizer) */ readonly authorizerId?: string; + /** * The type of authorization + * + * Possible values are: + * - JWT - JSON Web Token Authorizer + * - CUSTOM - Lambda Authorizer + * - NONE - No Authorization */ - readonly authorizationType: HttpAuthorizerType; + readonly authorizationType: string; + /** * The list of OIDC scopes to include in the authorization. * @default - no authorization scopes @@ -184,7 +255,7 @@ function undefinedIfNoKeys(obj: A): A | undefined { export class HttpNoneAuthorizer implements IHttpRouteAuthorizer { public bind(_: HttpRouteAuthorizerBindOptions): HttpRouteAuthorizerConfig { return { - authorizationType: HttpAuthorizerType.NONE, + authorizationType: 'NONE', }; } } diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/http/route.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/http/route.ts index 5178281d08953..a88aaae0b3416 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/http/route.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/http/route.ts @@ -3,7 +3,7 @@ import { Construct } from 'constructs'; import { CfnRoute, CfnRouteProps } from '../apigatewayv2.generated'; import { IRoute } from '../common'; import { IHttpApi } from './api'; -import { HttpAuthorizerType, IHttpRouteAuthorizer } from './authorizer'; +import { IHttpRouteAuthorizer } from './authorizer'; import { IHttpRouteIntegration } from './integration'; /** @@ -120,6 +120,20 @@ export interface HttpRouteProps extends BatchHttpRouteOptions { readonly authorizationScopes?: string[]; } +/** + * Supported Route Authorizer types + */ +enum HttpRouteAuthorizationType { + /** JSON Web Tokens */ + JWT = 'JWT', + + /** Lambda Authorizer */ + CUSTOM = 'CUSTOM', + + /** No authorizer */ + NONE = 'NONE' +} + /** * Route class that creates the Route for API Gateway HTTP API * @resource AWS::ApiGatewayV2::Route @@ -147,6 +161,10 @@ export class HttpRoute extends Resource implements IHttpRoute { scope: this.httpApi instanceof Construct ? this.httpApi : this, // scope under the API if it's not imported }) : undefined; + if (authBindResult && !(authBindResult.authorizationType in HttpRouteAuthorizationType)) { + throw new Error('authorizationType should either be JWT, CUSTOM, or NONE'); + } + let authorizationScopes = authBindResult?.authorizationScopes; if (authBindResult && props.authorizationScopes) { @@ -165,7 +183,7 @@ export class HttpRoute extends Resource implements IHttpRoute { routeKey: props.routeKey.key, target: `integrations/${integration.integrationId}`, authorizerId: authBindResult?.authorizerId, - authorizationType: authBindResult?.authorizationType ?? HttpAuthorizerType.NONE, // must be explicitly NONE (not undefined) for stack updates to work correctly + authorizationType: authBindResult?.authorizationType ?? 'NONE', // must be explicitly NONE (not undefined) for stack updates to work correctly authorizationScopes, }; diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/http/api.test.ts b/packages/@aws-cdk/aws-apigatewayv2/test/http/api.test.ts index 12d2c68aa0ecb..3b07593676c11 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/test/http/api.test.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/test/http/api.test.ts @@ -5,7 +5,7 @@ import * as ec2 from '@aws-cdk/aws-ec2'; import { Duration, Stack } from '@aws-cdk/core'; import { CorsHttpMethod, - HttpApi, HttpAuthorizer, HttpAuthorizerType, HttpIntegrationType, HttpMethod, HttpRouteAuthorizerBindOptions, HttpRouteAuthorizerConfig, + HttpApi, HttpAuthorizer, HttpIntegrationType, HttpMethod, HttpRouteAuthorizerBindOptions, HttpRouteAuthorizerConfig, HttpRouteIntegrationBindOptions, HttpRouteIntegrationConfig, IHttpRouteAuthorizer, IHttpRouteIntegration, HttpNoneAuthorizer, PayloadFormatVersion, } from '../../lib'; @@ -310,7 +310,7 @@ describe('HttpApi', () => { const authorizer = HttpAuthorizer.fromHttpAuthorizerAttributes(stack, 'auth', { authorizerId: '12345', - authorizerType: HttpAuthorizerType.JWT, + authorizerType: 'JWT', }); // WHEN @@ -506,7 +506,7 @@ class DummyAuthorizer implements IHttpRouteAuthorizer { public bind(_: HttpRouteAuthorizerBindOptions): HttpRouteAuthorizerConfig { return { authorizerId: 'auth-1234', - authorizationType: HttpAuthorizerType.JWT, + authorizationType: 'JWT', }; } } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/http/authorizer.test.ts b/packages/@aws-cdk/aws-apigatewayv2/test/http/authorizer.test.ts index 9f99ccb9f7691..92c0ee0422c17 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/test/http/authorizer.test.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/test/http/authorizer.test.ts @@ -64,4 +64,24 @@ describe('HttpAuthorizer', () => { }); }); }); + + describe('lambda', () => { + it('default', () => { + const stack = new Stack(); + const httpApi = new HttpApi(stack, 'HttpApi'); + + new HttpAuthorizer(stack, 'HttpAuthorizer', { + httpApi, + identitySource: ['identitysource.1', 'identitysource.2'], + type: HttpAuthorizerType.LAMBDA, + authorizerUri: 'arn:cool-lambda-arn', + }); + + expect(stack).toHaveResource('AWS::ApiGatewayV2::Authorizer', { + AuthorizerType: 'REQUEST', + AuthorizerPayloadFormatVersion: '2.0', + AuthorizerUri: 'arn:cool-lambda-arn', + }); + }); + }); }); diff --git a/packages/@aws-cdk/aws-apigatewayv2/test/http/route.test.ts b/packages/@aws-cdk/aws-apigatewayv2/test/http/route.test.ts index 8de7d2ae7f1d6..f30bdaba9205e 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/test/http/route.test.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/test/http/route.test.ts @@ -242,6 +242,20 @@ describe('HttpRoute', () => { AuthorizationScopes: ['read:books'], }); }); + + test('should fail when unsupported authorization type is used', () => { + const stack = new Stack(); + const httpApi = new HttpApi(stack, 'HttpApi'); + + const authorizer = new InvalidTypeAuthorizer(); + + expect(() => new HttpRoute(stack, 'HttpRoute', { + httpApi, + integration: new DummyIntegration(), + routeKey: HttpRouteKey.with('/books', HttpMethod.GET), + authorizer, + })).toThrowError('authorizationType should either be JWT, CUSTOM, or NONE'); + }); }); class DummyIntegration implements IHttpRouteIntegration { @@ -272,7 +286,29 @@ class DummyAuthorizer implements IHttpRouteAuthorizer { return { authorizerId: this.authorizer.authorizerId, - authorizationType: HttpAuthorizerType.JWT, + authorizationType: 'JWT', }; } } + +class InvalidTypeAuthorizer implements IHttpRouteAuthorizer { + private authorizer?: HttpAuthorizer; + + public bind(options: HttpRouteAuthorizerBindOptions): HttpRouteAuthorizerConfig { + if (!this.authorizer) { + + this.authorizer = new HttpAuthorizer(options.scope, 'auth-1234', { + httpApi: options.route.httpApi, + identitySource: ['identitysource.1', 'identitysource.2'], + type: HttpAuthorizerType.JWT, + jwtAudience: ['audience.1', 'audience.2'], + jwtIssuer: 'issuer', + }); + } + + return { + authorizerId: this.authorizer.authorizerId, + authorizationType: 'Random', + }; + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-kinesis/lib/stream.ts b/packages/@aws-cdk/aws-kinesis/lib/stream.ts index 0a73c6b9062f8..b2fed1eb10329 100644 --- a/packages/@aws-cdk/aws-kinesis/lib/stream.ts +++ b/packages/@aws-cdk/aws-kinesis/lib/stream.ts @@ -326,7 +326,7 @@ abstract class StreamBase extends Resource implements IStream { public abstract readonly encryptionKey?: kms.IKey; /** - * Grant write permissions for this stream and its contents to an IAM + * Grant read permissions for this stream and its contents to an IAM * principal (Role/Group/User). * * If an encryption key is used, permission to ues the key to decrypt the @@ -343,10 +343,10 @@ abstract class StreamBase extends Resource implements IStream { } /** - * Grant read permissions for this stream and its contents to an IAM + * Grant write permissions for this stream and its contents to an IAM * principal (Role/Group/User). * - * If an encryption key is used, permission to ues the key to decrypt the + * If an encryption key is used, permission to ues the key to encrypt the * contents of the stream will also be granted. */ public grantWrite(grantee: iam.IGrantable) { diff --git a/packages/@aws-cdk/aws-lambda-go/lib/Dockerfile b/packages/@aws-cdk/aws-lambda-go/lib/Dockerfile index 149d117cffdb9..ffa102c84803c 100644 --- a/packages/@aws-cdk/aws-lambda-go/lib/Dockerfile +++ b/packages/@aws-cdk/aws-lambda-go/lib/Dockerfile @@ -5,6 +5,7 @@ FROM $IMAGE # set the GOCACHE ENV GOCACHE=$GOPATH/.cache/go-build +ENV GOPROXY=direct # Ensure all users can write to GOPATH RUN chmod -R 777 $GOPATH diff --git a/packages/@aws-cdk/aws-lambda/lib/singleton-lambda.ts b/packages/@aws-cdk/aws-lambda/lib/singleton-lambda.ts index 170f1f3e37573..431b9bf6a71d9 100644 --- a/packages/@aws-cdk/aws-lambda/lib/singleton-lambda.ts +++ b/packages/@aws-cdk/aws-lambda/lib/singleton-lambda.ts @@ -1,3 +1,4 @@ +import * as ec2 from '@aws-cdk/aws-ec2'; import * as iam from '@aws-cdk/aws-iam'; import * as cdk from '@aws-cdk/core'; import { Construct } from 'constructs'; @@ -63,6 +64,20 @@ export class SingletonFunction extends FunctionBase { this.canCreatePermissions = true; // Doesn't matter, addPermission is overriden anyway } + /** + * @inheritdoc + */ + public get isBoundToVpc(): boolean { + return this.lambdaFunction.isBoundToVpc; + } + + /** + * @inheritdoc + */ + public get connections(): ec2.Connections { + return this.lambdaFunction.connections; + } + /** * Returns a `lambda.Version` which represents the current version of this * singleton Lambda function. A new version will be created every time the diff --git a/packages/@aws-cdk/aws-lambda/test/singleton-lambda.test.ts b/packages/@aws-cdk/aws-lambda/test/singleton-lambda.test.ts index ebe9a5c5253cd..3bf78253d5ed6 100644 --- a/packages/@aws-cdk/aws-lambda/test/singleton-lambda.test.ts +++ b/packages/@aws-cdk/aws-lambda/test/singleton-lambda.test.ts @@ -1,5 +1,6 @@ import '@aws-cdk/assert-internal/jest'; import { ResourcePart } from '@aws-cdk/assert-internal'; +import * as ec2 from '@aws-cdk/aws-ec2'; import * as iam from '@aws-cdk/aws-iam'; import * as cdk from '@aws-cdk/core'; import * as lambda from '../lib'; @@ -174,4 +175,27 @@ describe('singleton lambda', () => { }, }); }); + + test('bind to vpc and access connections', () => { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'VPC', { maxAzs: 2 }); + const securityGroup = new ec2.SecurityGroup(stack, 'SecurityGroup', { + vpc: vpc, + }); + + // WHEN + const singleton = new lambda.SingletonFunction(stack, 'Singleton', { + uuid: '84c0de93-353f-4217-9b0b-45b6c993251a', + code: new lambda.InlineCode('foo'), + runtime: lambda.Runtime.NODEJS_12_X, + handler: 'index.handler', + securityGroups: [securityGroup], + vpc: vpc, + }); + + // THEN + expect(singleton.isBoundToVpc).toBeTruthy(); + expect(singleton.connections).toEqual(new ec2.Connections({ securityGroups: [securityGroup] })); + }); }); diff --git a/packages/@aws-cdk/aws-msk/test/integ.cluster.ts b/packages/@aws-cdk/aws-msk/test/integ.cluster.ts index c422a26b5cc32..c05fa496d7210 100644 --- a/packages/@aws-cdk/aws-msk/test/integ.cluster.ts +++ b/packages/@aws-cdk/aws-msk/test/integ.cluster.ts @@ -1,3 +1,4 @@ +/// !cdk-integ pragma:ignore-assets import * as ec2 from '@aws-cdk/aws-ec2'; import * as cdk from '@aws-cdk/core'; import * as msk from '../lib'; diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/README.md b/packages/@aws-cdk/aws-stepfunctions-tasks/README.md index 0888563679d47..74134d35ba3e3 100644 --- a/packages/@aws-cdk/aws-stepfunctions-tasks/README.md +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/README.md @@ -133,6 +133,32 @@ const submitJob = new tasks.LambdaInvoke(this, 'Invoke Handler', { }); ``` +### ResultSelector + +You can use [`ResultSelector`](https://docs.aws.amazon.com/step-functions/latest/dg/input-output-inputpath-params.html#input-output-resultselector) +to manipulate the raw result of a Task, Map or Parallel state before it is +passed to [`ResultPath`](###ResultPath). For service integrations, the raw +result contains metadata in addition to the response payload. You can use +ResultSelector to construct a JSON payload that becomes the effective result +using static values or references to the raw result or context object. + +The following example extracts the output payload of a Lambda function Task and combines +it with some static values and the state name from the context object. + +```ts +new tasks.LambdaInvoke(this, 'Invoke Handler', { + lambdaFunction: fn, + resultSelector: { + lambdaOutput: sfn.JsonPath.stringAt('$.Payload'), + invokeRequestId: sfn.JsonPath.stringAt('$.SdkResponseMetadata.RequestId'), + staticValue: { + foo: 'bar', + }, + stateName: sfn.JsonPath.stringAt('$$.State.Name'), + }, +}) +``` + ### ResultPath The output of a state can be a copy of its input, the result it produces (for @@ -226,8 +252,8 @@ of the Node.js family are supported. Step Functions supports [API Gateway](https://docs.aws.amazon.com/step-functions/latest/dg/connect-api-gateway.html) through the service integration pattern. -HTTP APIs are designed for low-latency, cost-effective integrations with AWS services, including AWS Lambda, and HTTP endpoints. -HTTP APIs support OIDC and OAuth 2.0 authorization, and come with built-in support for CORS and automatic deployments. +HTTP APIs are designed for low-latency, cost-effective integrations with AWS services, including AWS Lambda, and HTTP endpoints. +HTTP APIs support OIDC and OAuth 2.0 authorization, and come with built-in support for CORS and automatic deployments. Previous-generation REST APIs currently offer more features. More details can be found [here](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-vs-rest.html). ### Call REST API Endpoint @@ -507,8 +533,8 @@ isolation by design. Learn more about [Fargate](https://aws.amazon.com/fargate/) The Fargate launch type allows you to run your containerized applications without the need to provision and manage the backend infrastructure. Just register your task definition and -Fargate launches the container for you. The latest ACTIVE revision of the passed -task definition is used for running the task. Learn more about +Fargate launches the container for you. The latest ACTIVE revision of the passed +task definition is used for running the task. Learn more about [Fargate Versioning](https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_DescribeTaskDefinition.html) The following example runs a job from a task definition on Fargate @@ -718,7 +744,7 @@ You can call the [`StartJobRun`](https://docs.aws.amazon.com/glue/latest/dg/aws- new tasks.GlueStartJobRun(this, 'Task', { glueJobName: 'my-glue-job', arguments: sfn.TaskInput.fromObject({ - key: 'value', + key: 'value', }), timeout: cdk.Duration.minutes(30), notifyDelayAfter: cdk.Duration.minutes(5), @@ -1020,7 +1046,7 @@ a specific task in a state machine. When Step Functions reaches an activity task state, the workflow waits for an activity worker to poll for a task. An activity worker polls Step Functions by -using GetActivityTask, and sending the ARN for the related activity. +using GetActivityTask, and sending the ARN for the related activity. After the activity worker completes its work, it can provide a report of its success or failure by using `SendTaskSuccess` or `SendTaskFailure`. These two diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/lib/stepfunctions/start-execution.ts b/packages/@aws-cdk/aws-stepfunctions-tasks/lib/stepfunctions/start-execution.ts index 16e64fcc9cd54..bb8cf601f3bf0 100644 --- a/packages/@aws-cdk/aws-stepfunctions-tasks/lib/stepfunctions/start-execution.ts +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/lib/stepfunctions/start-execution.ts @@ -35,7 +35,7 @@ export interface StepFunctionsStartExecutionProps extends sfn.TaskStateBaseProps /** * A Step Functions Task to call StartExecution on another state machine. * - * It supports three service integration patterns: FIRE_AND_FORGET, SYNC and WAIT_FOR_TASK_TOKEN. + * It supports three service integration patterns: REQUEST_RESPONSE, RUN_JOB, and WAIT_FOR_TASK_TOKEN. */ export class StepFunctionsStartExecution extends sfn.TaskStateBase { private static readonly SUPPORTED_INTEGRATION_PATTERNS = [ diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/test/lambda/integ.invoke.expected.json b/packages/@aws-cdk/aws-stepfunctions-tasks/test/lambda/integ.invoke.expected.json index 1d0e7fcdc6f51..fe1262610ffd2 100644 --- a/packages/@aws-cdk/aws-stepfunctions-tasks/test/lambda/integ.invoke.expected.json +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/test/lambda/integ.invoke.expected.json @@ -188,7 +188,7 @@ "Arn" ] }, - "\",\"Payload.$\":\"$\"}},\"Check the job state\":{\"Next\":\"Job Complete?\",\"Retry\":[{\"ErrorEquals\":[\"Lambda.ServiceException\",\"Lambda.AWSLambdaException\",\"Lambda.SdkClientException\"],\"IntervalSeconds\":2,\"MaxAttempts\":6,\"BackoffRate\":2}],\"Type\":\"Task\",\"OutputPath\":\"$.Payload\",\"Resource\":\"arn:", + "\",\"Payload.$\":\"$\"}},\"Check the job state\":{\"Next\":\"Job Complete?\",\"Retry\":[{\"ErrorEquals\":[\"Lambda.ServiceException\",\"Lambda.AWSLambdaException\",\"Lambda.SdkClientException\"],\"IntervalSeconds\":2,\"MaxAttempts\":6,\"BackoffRate\":2}],\"Type\":\"Task\",\"ResultSelector\":{\"status.$\":\"$.Payload.status\"},\"Resource\":\"arn:", { "Ref": "AWS::Partition" }, diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/test/lambda/integ.invoke.ts b/packages/@aws-cdk/aws-stepfunctions-tasks/test/lambda/integ.invoke.ts index 870589ff72b3e..b7006b2ad33c4 100644 --- a/packages/@aws-cdk/aws-stepfunctions-tasks/test/lambda/integ.invoke.ts +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/test/lambda/integ.invoke.ts @@ -46,7 +46,9 @@ const checkJobStateLambda = new Function(stack, 'checkJobStateLambda', { const checkJobState = new LambdaInvoke(stack, 'Check the job state', { lambdaFunction: checkJobStateLambda, - outputPath: '$.Payload', + resultSelector: { + status: sfn.JsonPath.stringAt('$.Payload.status'), + }, }); const isComplete = new sfn.Choice(stack, 'Job Complete?'); diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/test/lambda/invoke.test.ts b/packages/@aws-cdk/aws-stepfunctions-tasks/test/lambda/invoke.test.ts index e0166427b502c..05bc1245d903e 100644 --- a/packages/@aws-cdk/aws-stepfunctions-tasks/test/lambda/invoke.test.ts +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/test/lambda/invoke.test.ts @@ -112,6 +112,58 @@ describe('LambdaInvoke', () => { })); }); + test('resultSelector', () => { + // WHEN + const task = new LambdaInvoke(stack, 'Task', { + lambdaFunction, + resultSelector: { + Result: sfn.JsonPath.stringAt('$.output.Payload'), + }, + }); + + // THEN + expect(stack.resolve(task.toStateJson())).toEqual(expect.objectContaining({ + Type: 'Task', + Resource: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':states:::lambda:invoke', + ], + ], + }, + End: true, + Parameters: { + FunctionName: { + 'Fn::GetAtt': [ + 'Fn9270CBC0', + 'Arn', + ], + }, + 'Payload.$': '$', + }, + ResultSelector: { + 'Result.$': '$.output.Payload', + }, + Retry: [ + { + ErrorEquals: [ + 'Lambda.ServiceException', + 'Lambda.AWSLambdaException', + 'Lambda.SdkClientException', + ], + IntervalSeconds: 2, + MaxAttempts: 6, + BackoffRate: 2, + }, + ], + })); + }); + test('invoke Lambda function and wait for task token', () => { // GIVEN const task = new LambdaInvoke(stack, 'Task', { diff --git a/packages/@aws-cdk/aws-stepfunctions/lib/states/map.ts b/packages/@aws-cdk/aws-stepfunctions/lib/states/map.ts index e3f6e4800991f..431886e59e0b2 100644 --- a/packages/@aws-cdk/aws-stepfunctions/lib/states/map.ts +++ b/packages/@aws-cdk/aws-stepfunctions/lib/states/map.ts @@ -61,6 +61,20 @@ export interface MapProps { */ readonly parameters?: { [key: string]: any }; + /** + * The JSON that will replace the state's raw result and become the effective + * result before ResultPath is applied. + * + * You can use ResultSelector to create a payload with values that are static + * or selected from the state's raw result. + * + * @see + * https://docs.aws.amazon.com/step-functions/latest/dg/input-output-inputpath-params.html#input-output-resultselector + * + * @default - None + */ + readonly resultSelector?: { [key: string]: any }; + /** * MaxConcurrency * @@ -158,6 +172,7 @@ export class Map extends State implements INextable { ...this.renderNextEnd(), ...this.renderInputOutput(), ...this.renderParameters(), + ...this.renderResultSelector(), ...this.renderRetryCatch(), ...this.renderIterator(), ...this.renderItemsPath(), diff --git a/packages/@aws-cdk/aws-stepfunctions/lib/states/parallel.ts b/packages/@aws-cdk/aws-stepfunctions/lib/states/parallel.ts index 70ac5819a6888..9167c7d0d0355 100644 --- a/packages/@aws-cdk/aws-stepfunctions/lib/states/parallel.ts +++ b/packages/@aws-cdk/aws-stepfunctions/lib/states/parallel.ts @@ -45,6 +45,20 @@ export interface ParallelProps { * @default $ */ readonly resultPath?: string; + + /** + * The JSON that will replace the state's raw result and become the effective + * result before ResultPath is applied. + * + * You can use ResultSelector to create a payload with values that are static + * or selected from the state's raw result. + * + * @see + * https://docs.aws.amazon.com/step-functions/latest/dg/input-output-inputpath-params.html#input-output-resultselector + * + * @default - None + */ + readonly resultSelector?: { [key: string]: any }; } /** @@ -117,6 +131,7 @@ export class Parallel extends State implements INextable { ...this.renderInputOutput(), ...this.renderRetryCatch(), ...this.renderBranches(), + ...this.renderResultSelector(), }; } diff --git a/packages/@aws-cdk/aws-stepfunctions/lib/states/state.ts b/packages/@aws-cdk/aws-stepfunctions/lib/states/state.ts index 42a4c8e9bf1b5..5dee29cf978ac 100644 --- a/packages/@aws-cdk/aws-stepfunctions/lib/states/state.ts +++ b/packages/@aws-cdk/aws-stepfunctions/lib/states/state.ts @@ -1,6 +1,6 @@ import { IConstruct, Construct, Node } from 'constructs'; import { Condition } from '../condition'; -import { JsonPath } from '../fields'; +import { FieldUtils, JsonPath } from '../fields'; import { StateGraph } from '../state-graph'; import { CatchProps, Errors, IChainable, INextable, RetryProps } from '../types'; @@ -58,6 +58,20 @@ export interface StateProps { * @default $ */ readonly resultPath?: string; + + /** + * The JSON that will replace the state's raw result and become the effective + * result before ResultPath is applied. + * + * You can use ResultSelector to create a payload with values that are static + * or selected from the state's raw result. + * + * @see + * https://docs.aws.amazon.com/step-functions/latest/dg/input-output-inputpath-params.html#input-output-resultselector + * + * @default - None + */ + readonly resultSelector?: { [key: string]: any }; } /** @@ -149,6 +163,7 @@ export abstract class State extends CoreConstruct implements IChainable { protected readonly parameters?: object; protected readonly outputPath?: string; protected readonly resultPath?: string; + protected readonly resultSelector?: object; protected readonly branches: StateGraph[] = []; protected iteration?: StateGraph; protected defaultChoice?: State; @@ -187,6 +202,7 @@ export abstract class State extends CoreConstruct implements IChainable { this.parameters = props.parameters; this.outputPath = props.outputPath; this.resultPath = props.resultPath; + this.resultSelector = props.resultSelector; } public get id() { @@ -398,6 +414,15 @@ export abstract class State extends CoreConstruct implements IChainable { }; } + /** + * Render ResultSelector in ASL JSON format + */ + protected renderResultSelector(): any { + return FieldUtils.renderObject({ + ResultSelector: this.resultSelector, + }); + } + /** * Called whenever this state is bound to a graph * diff --git a/packages/@aws-cdk/aws-stepfunctions/lib/states/task-base.ts b/packages/@aws-cdk/aws-stepfunctions/lib/states/task-base.ts index f530113de2202..5743c0171d188 100644 --- a/packages/@aws-cdk/aws-stepfunctions/lib/states/task-base.ts +++ b/packages/@aws-cdk/aws-stepfunctions/lib/states/task-base.ts @@ -51,6 +51,20 @@ export interface TaskStateBaseProps { */ readonly resultPath?: string; + /** + * The JSON that will replace the state's raw result and become the effective + * result before ResultPath is applied. + * + * You can use ResultSelector to create a payload with values that are static + * or selected from the state's raw result. + * + * @see + * https://docs.aws.amazon.com/step-functions/latest/dg/input-output-inputpath-params.html#input-output-resultselector + * + * @default - None + */ + readonly resultSelector?: { [key: string]: any }; + /** * Timeout for the state machine * @@ -269,6 +283,7 @@ export abstract class TaskStateBase extends State implements INextable { InputPath: renderJsonPath(this.inputPath), OutputPath: renderJsonPath(this.outputPath), ResultPath: renderJsonPath(this.resultPath), + ...this.renderResultSelector(), }; } } diff --git a/packages/@aws-cdk/aws-stepfunctions/test/map.test.ts b/packages/@aws-cdk/aws-stepfunctions/test/map.test.ts index 1f9ea12651d4a..e5e379f578800 100644 --- a/packages/@aws-cdk/aws-stepfunctions/test/map.test.ts +++ b/packages/@aws-cdk/aws-stepfunctions/test/map.test.ts @@ -44,6 +44,47 @@ describe('Map State', () => { }, }); }), + test('State Machine With Map State and ResultSelector', () => { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + const map = new stepfunctions.Map(stack, 'Map State', { + maxConcurrency: 1, + itemsPath: stepfunctions.JsonPath.stringAt('$.inputForMap'), + resultSelector: { + buz: 'buz', + baz: stepfunctions.JsonPath.stringAt('$.baz'), + }, + }); + map.iterator(new stepfunctions.Pass(stack, 'Pass State')); + + // THEN + expect(render(map)).toStrictEqual({ + StartAt: 'Map State', + States: { + 'Map State': { + Type: 'Map', + End: true, + Iterator: { + StartAt: 'Pass State', + States: { + 'Pass State': { + Type: 'Pass', + End: true, + }, + }, + }, + ItemsPath: '$.inputForMap', + MaxConcurrency: 1, + ResultSelector: { + 'buz': 'buz', + 'baz.$': '$.baz', + }, + }, + }, + }); + }), test('synth is successful', () => { const app = createAppWithMap((stack) => { const map = new stepfunctions.Map(stack, 'Map State', { diff --git a/packages/@aws-cdk/aws-stepfunctions/test/parallel.test.ts b/packages/@aws-cdk/aws-stepfunctions/test/parallel.test.ts index cae6bdf543e99..b89a567b22826 100644 --- a/packages/@aws-cdk/aws-stepfunctions/test/parallel.test.ts +++ b/packages/@aws-cdk/aws-stepfunctions/test/parallel.test.ts @@ -27,6 +27,38 @@ describe('Parallel State', () => { }, }); }); + + test('State Machine With Parallel State and ResultSelector', () => { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + const parallel = new stepfunctions.Parallel(stack, 'Parallel State', { + resultSelector: { + buz: 'buz', + baz: stepfunctions.JsonPath.stringAt('$.baz'), + }, + }); + parallel.branch(new stepfunctions.Pass(stack, 'Branch 1')); + + // THEN + expect(render(parallel)).toStrictEqual({ + StartAt: 'Parallel State', + States: { + 'Parallel State': { + Type: 'Parallel', + End: true, + Branches: [ + { StartAt: 'Branch 1', States: { 'Branch 1': { Type: 'Pass', End: true } } }, + ], + ResultSelector: { + 'buz': 'buz', + 'baz.$': '$.baz', + }, + }, + }, + }); + }); }); function render(sm: stepfunctions.IChainable) { diff --git a/packages/@aws-cdk/aws-stepfunctions/test/task-base.test.ts b/packages/@aws-cdk/aws-stepfunctions/test/task-base.test.ts index 3a8132a1f5cb8..a57c489134efd 100644 --- a/packages/@aws-cdk/aws-stepfunctions/test/task-base.test.ts +++ b/packages/@aws-cdk/aws-stepfunctions/test/task-base.test.ts @@ -44,6 +44,33 @@ describe('Task base', () => { }); }); + test('instantiate a concrete implementation with resultSelector', () => { + // WHEN + task = new FakeTask(stack, 'my-exciting-task', { + resultSelector: { + buz: 'buz', + baz: sfn.JsonPath.stringAt('$.baz'), + }, + }); + + // THEN + expect(render(task)).toEqual({ + StartAt: 'my-exciting-task', + States: { + 'my-exciting-task': { + End: true, + Type: 'Task', + Resource: 'my-resource', + Parameters: { MyParameter: 'myParameter' }, + ResultSelector: { + 'buz': 'buz', + 'baz.$': '$.baz', + }, + }, + }, + }); + }); + test('add catch configuration', () => { // GIVEN const failure = new sfn.Fail(stack, 'failed', {