From 0e4c58ef53477bfc02f59d62fb49d4f81d774dcb Mon Sep 17 00:00:00 2001 From: Josh Kellendonk Date: Fri, 17 Dec 2021 03:12:03 -0700 Subject: [PATCH] feat(apigatewayv2): http api - IAM authorizer support (#17519) Fixes #15123 See also: [@nija-at's comments on `grantInvoke`](https://github.com/aws/aws-cdk/pull/14853#discussion_r648952691), #10534 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .../aws-apigatewayv2-authorizers/README.md | 25 ++ .../lib/http/iam.ts | 17 ++ .../lib/http/index.ts | 3 +- .../rosetta/default.ts-fixture | 1 + .../test/http/integ.iam.expected.json | 204 +++++++++++++ .../test/http/integ.iam.ts | 69 +++++ .../aws-apigatewayv2/lib/http/authorizer.ts | 4 + .../aws-apigatewayv2/lib/http/index.ts | 2 +- .../aws-apigatewayv2/lib/http/route.ts | 97 ++++++- .../aws-apigatewayv2/test/http/route.test.ts | 267 +++++++++++++++++- 10 files changed, 673 insertions(+), 16 deletions(-) create mode 100644 packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/iam.ts create mode 100644 packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/integ.iam.expected.json create mode 100644 packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/integ.iam.ts diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/README.md b/packages/@aws-cdk/aws-apigatewayv2-authorizers/README.md index e2d9f13198711..16a7b3c591b27 100644 --- a/packages/@aws-cdk/aws-apigatewayv2-authorizers/README.md +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/README.md @@ -25,6 +25,7 @@ - [JWT Authorizers](#jwt-authorizers) - [User Pool Authorizer](#user-pool-authorizer) - [Lambda Authorizers](#lambda-authorizers) + - [IAM Authorizers](#iam-authorizers) - [WebSocket APIs](#websocket-apis) - [Lambda Authorizer](#lambda-authorizer) @@ -199,6 +200,30 @@ api.addRoutes({ }); ``` +### IAM Authorizers + +API Gateway supports IAM via the included `HttpIamAuthorizer` and grant syntax: + +```ts +import { HttpIamAuthorizer } from '@aws-cdk/aws-apigatewayv2-authorizers'; +import { HttpUrlIntegration } from '@aws-cdk/aws-apigatewayv2-integrations'; + +declare const principal: iam.AnyPrincipal; + +const authorizer = new HttpIamAuthorizer(); + +const httpApi = new apigwv2.HttpApi(this, 'HttpApi', { + defaultAuthorizer: authorizer, +}); + +const routes = httpApi.addRoutes({ + integration: new HttpUrlIntegration('BooksIntegration', 'https://get-books-proxy.myproxy.internal'), + path: '/books/{book}', +}); + +routes[0].grantInvoke(principal); +``` + ## WebSocket APIs You can set an authorizer to your WebSocket API's `$connect` route to control access to your API. diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/iam.ts b/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/iam.ts new file mode 100644 index 0000000000000..682aa4c24356a --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/iam.ts @@ -0,0 +1,17 @@ +import { + HttpAuthorizerType, + HttpRouteAuthorizerBindOptions, + HttpRouteAuthorizerConfig, + IHttpRouteAuthorizer, +} from '@aws-cdk/aws-apigatewayv2'; + +/** + * Authorize HTTP API Routes with IAM + */ +export class HttpIamAuthorizer implements IHttpRouteAuthorizer { + public bind(_options: HttpRouteAuthorizerBindOptions): HttpRouteAuthorizerConfig { + return { + authorizationType: HttpAuthorizerType.IAM, + }; + } +} \ No newline at end of file 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 410cc8aa09f2e..ff394e018a14b 100644 --- a/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/index.ts +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/lib/http/index.ts @@ -1,3 +1,4 @@ export * from './user-pool'; export * from './jwt'; -export * from './lambda'; \ No newline at end of file +export * from './lambda'; +export * from './iam'; \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/rosetta/default.ts-fixture b/packages/@aws-cdk/aws-apigatewayv2-authorizers/rosetta/default.ts-fixture index 3d0f887754334..12918827cf034 100644 --- a/packages/@aws-cdk/aws-apigatewayv2-authorizers/rosetta/default.ts-fixture +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/rosetta/default.ts-fixture @@ -2,6 +2,7 @@ import { Construct } from 'constructs'; import { Duration, Stack } from '@aws-cdk/core'; import * as apigwv2 from '@aws-cdk/aws-apigatewayv2'; +import * as iam from '@aws-cdk/aws-iam'; import * as lambda from '@aws-cdk/aws-lambda'; class Fixture extends Stack { diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/integ.iam.expected.json b/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/integ.iam.expected.json new file mode 100644 index 0000000000000..ab33666949d9b --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/integ.iam.expected.json @@ -0,0 +1,204 @@ +{ + "Resources": { + "User00B015A1": { + "Type": "AWS::IAM::User" + }, + "UserDefaultPolicy1F97781E": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "execute-api:Invoke", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:aws:execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "HttpApiF5A9A8A7" + }, + "/*/*/foo" + ] + ] + } + }, + { + "Action": "execute-api:Invoke", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:aws:execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "HttpApiF5A9A8A7" + }, + "/*/*/books/*" + ] + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "UserDefaultPolicy1F97781E", + "Users": [ + { + "Ref": "User00B015A1" + } + ] + } + }, + "UserAccess": { + "Type": "AWS::IAM::AccessKey", + "Properties": { + "UserName": { + "Ref": "User00B015A1" + } + } + }, + "HttpApiF5A9A8A7": { + "Type": "AWS::ApiGatewayV2::Api", + "Properties": { + "Name": "HttpApi", + "ProtocolType": "HTTP" + } + }, + "HttpApiDefaultStage3EEB07D6": { + "Type": "AWS::ApiGatewayV2::Stage", + "Properties": { + "ApiId": { + "Ref": "HttpApiF5A9A8A7" + }, + "StageName": "$default", + "AutoDeploy": true + } + }, + "HttpApiANYfooexamplecom903F7A9F": { + "Type": "AWS::ApiGatewayV2::Integration", + "Properties": { + "ApiId": { + "Ref": "HttpApiF5A9A8A7" + }, + "IntegrationType": "HTTP_PROXY", + "IntegrationMethod": "GET", + "IntegrationUri": "https://www.example.com/", + "PayloadFormatVersion": "1.0" + } + }, + "HttpApiANYfooD178456F": { + "Type": "AWS::ApiGatewayV2::Route", + "Properties": { + "ApiId": { + "Ref": "HttpApiF5A9A8A7" + }, + "RouteKey": "ANY /foo", + "AuthorizationType": "AWS_IAM", + "Target": { + "Fn::Join": [ + "", + [ + "integrations/", + { + "Ref": "HttpApiANYfooexamplecom903F7A9F" + } + ] + ] + } + } + }, + "HttpApiANYbooksbookexamplecom5C333C98": { + "Type": "AWS::ApiGatewayV2::Integration", + "Properties": { + "ApiId": { + "Ref": "HttpApiF5A9A8A7" + }, + "IntegrationType": "HTTP_PROXY", + "IntegrationMethod": "GET", + "IntegrationUri": "https://www.example.com/", + "PayloadFormatVersion": "1.0" + } + }, + "HttpApiANYbooksbook2F78361C": { + "Type": "AWS::ApiGatewayV2::Route", + "Properties": { + "ApiId": { + "Ref": "HttpApiF5A9A8A7" + }, + "RouteKey": "ANY /books/{book}", + "AuthorizationType": "AWS_IAM", + "Target": { + "Fn::Join": [ + "", + [ + "integrations/", + { + "Ref": "HttpApiANYbooksbookexamplecom5C333C98" + } + ] + ] + } + } + } + }, + "Outputs": { + "API": { + "Value": { + "Fn::Join": [ + "", + [ + "https://", + { + "Ref": "HttpApiF5A9A8A7" + }, + ".execute-api.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/" + ] + ] + } + }, + "TESTACCESSKEYID": { + "Value": { + "Ref": "UserAccess" + } + }, + "TESTSECRETACCESSKEY": { + "Value": { + "Fn::GetAtt": [ + "UserAccess", + "SecretAccessKey" + ] + } + }, + "TESTREGION": { + "Value": { + "Ref": "AWS::Region" + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/integ.iam.ts b/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/integ.iam.ts new file mode 100644 index 0000000000000..a010e6c0b990e --- /dev/null +++ b/packages/@aws-cdk/aws-apigatewayv2-authorizers/test/http/integ.iam.ts @@ -0,0 +1,69 @@ +import * as apigatewayv2 from '@aws-cdk/aws-apigatewayv2'; +import * as iam from '@aws-cdk/aws-iam'; +import * as cdk from '@aws-cdk/core'; +import { HttpIamAuthorizer } from '../../lib'; + +class ExampleComIntegration extends apigatewayv2.HttpRouteIntegration { + public bind(): apigatewayv2.HttpRouteIntegrationConfig { + return { + type: apigatewayv2.HttpIntegrationType.HTTP_PROXY, + payloadFormatVersion: apigatewayv2.PayloadFormatVersion.VERSION_1_0, + method: apigatewayv2.HttpMethod.GET, + uri: 'https://www.example.com/', + }; + } +} + +const app = new cdk.App(); +const stack = new cdk.Stack(app, 'IntegApiGatewayV2Iam'); +const user = new iam.User(stack, 'User'); +const userAccessKey = new iam.CfnAccessKey(stack, 'UserAccess', { + userName: user.userName, +}); + +const httpApi = new apigatewayv2.HttpApi(stack, 'HttpApi', { + defaultAuthorizer: new HttpIamAuthorizer(), +}); + +const [fooRoute] = httpApi.addRoutes({ + integration: new ExampleComIntegration('examplecom'), + path: '/foo', +}); + +fooRoute.grantInvoke(user); + +const [booksRoute] = httpApi.addRoutes({ + integration: new ExampleComIntegration('examplecom'), + path: '/books/{book}', +}); + +booksRoute.grantInvoke(user); + +new cdk.CfnOutput(stack, 'API', { + value: httpApi.url!, +}); + +new cdk.CfnOutput(stack, 'TESTACCESSKEYID', { + value: userAccessKey.ref, +}); + +new cdk.CfnOutput(stack, 'TESTSECRETACCESSKEY', { + value: userAccessKey.attrSecretAccessKey, +}); + +new cdk.CfnOutput(stack, 'TESTREGION', { + value: stack.region, +}); + +/* + * Stack verification steps: + * * Get cURL version 7.75.0 or later so you can use the --aws-sigv4 option + * * Curl /foo without sigv4 and expect a 403 + * * Curl /books/something without sigv4 and expect a 403 + * * Curl /foo with sigv4 from the authorized user and expect 200 + * * Curl /books/something with sigv4 from the authorized user and expect 200 + * + * Reference: + * * Using cURL 7.75.0 or later via the official docker image: docker run --rm curlimages/curl -s -o/dev/null -w"%{http_code}" + * * Args to enable sigv4 with authorized credentials: --user "$TESTACCESSKEYID:$TESTSECRETACCESSKEY" --aws-sigv4 "aws:amz:$TESTREGION:execute-api" + */ diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/http/authorizer.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/http/authorizer.ts index d4a7cf21b4ac4..7475ad1d73dbe 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/http/authorizer.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/http/authorizer.ts @@ -10,6 +10,9 @@ import { IHttpRoute } from './route'; * Supported Authorizer types */ export enum HttpAuthorizerType { + /** IAM Authorizer */ + IAM = 'AWS_IAM', + /** JSON Web Tokens */ JWT = 'JWT', @@ -221,6 +224,7 @@ export interface HttpRouteAuthorizerConfig { * The type of authorization * * Possible values are: + * - AWS_IAM - IAM Authorizer * - JWT - JSON Web Token Authorizer * - CUSTOM - Lambda Authorizer * - NONE - No Authorization diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/http/index.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/http/index.ts index 81ddfec695bc3..06c4a791b8bd9 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/http/index.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/http/index.ts @@ -3,4 +3,4 @@ export * from './route'; export * from './integration'; export * from './stage'; export * from './vpc-link'; -export * from './authorizer'; +export * from './authorizer'; \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigatewayv2/lib/http/route.ts b/packages/@aws-cdk/aws-apigatewayv2/lib/http/route.ts index 7894defce6077..a7f2e13b99cd4 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/lib/http/route.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/lib/http/route.ts @@ -1,9 +1,10 @@ +import * as iam from '@aws-cdk/aws-iam'; import { Resource } from '@aws-cdk/core'; import { Construct } from 'constructs'; import { CfnRoute, CfnRouteProps } from '../apigatewayv2.generated'; import { IRoute } from '../common'; import { IHttpApi } from './api'; -import { IHttpRouteAuthorizer } from './authorizer'; +import { HttpRouteAuthorizerConfig, IHttpRouteAuthorizer } from './authorizer'; import { HttpRouteIntegration } from './integration'; /** @@ -19,6 +20,30 @@ export interface IHttpRoute extends IRoute { * Returns the path component of this HTTP route, `undefined` if the path is the catch-all route. */ readonly path?: string; + + /** + * Returns the arn of the route. + * @attribute + */ + readonly routeArn: string; + + /** + * Grant access to invoke the route. + * This method requires that the authorizer of the route is undefined or is + * an `HttpIamAuthorizer`. + */ + grantInvoke(grantee: iam.IGrantable, options?: GrantInvokeOptions): iam.Grant; +} + +/** + * Options for granting invoke access. + */ +export interface GrantInvokeOptions { + /** + * The HTTP methods to allow. + * @default - the HttpMethod of the route + */ + readonly httpMethods?: HttpMethod[]; } /** @@ -51,7 +76,7 @@ export class HttpRouteKey { /** * The catch-all route of the API, i.e., when no other routes match */ - public static readonly DEFAULT = new HttpRouteKey('$default'); + public static readonly DEFAULT = new HttpRouteKey(); /** * Create a route key with the combination of the path and the method. @@ -61,9 +86,13 @@ export class HttpRouteKey { if (path !== '/' && (!path.startsWith('/') || path.endsWith('/'))) { throw new Error('A route path must always start with a "/" and not end with a "/"'); } - return new HttpRouteKey(`${method ?? HttpMethod.ANY} ${path}`, path); + return new HttpRouteKey(method, path); } + /** + * The method of the route + */ + public readonly method: HttpMethod; /** * The key to the RouteKey as recognized by APIGateway */ @@ -74,9 +103,10 @@ export class HttpRouteKey { */ public readonly path?: string; - private constructor(key: string, path?: string) { - this.key = key; + private constructor(method?: HttpMethod, path?: string) { + this.method = method ?? HttpMethod.ANY; this.path = path; + this.key = path ? `${method} ${path}` : '$default'; } } @@ -124,6 +154,9 @@ export interface HttpRouteProps extends BatchHttpRouteOptions { * Supported Route Authorizer types */ enum HttpRouteAuthorizationType { + /** AWS IAM */ + AWS_IAM = 'AWS_IAM', + /** JSON Web Tokens */ JWT = 'JWT', @@ -142,30 +175,36 @@ export class HttpRoute extends Resource implements IHttpRoute { public readonly routeId: string; public readonly httpApi: IHttpApi; public readonly path?: string; + public readonly routeArn: string; + + private readonly method: HttpMethod; + private readonly authBindResult?: HttpRouteAuthorizerConfig; constructor(scope: Construct, id: string, props: HttpRouteProps) { super(scope, id); this.httpApi = props.httpApi; this.path = props.routeKey.path; + this.method = props.routeKey.method; + this.routeArn = this.produceRouteArn(props.routeKey.method); const config = props.integration._bindToRoute({ route: this, scope: this, }); - const authBindResult = props.authorizer ? props.authorizer.bind({ + this.authBindResult = props.authorizer?.bind({ route: this, 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'); + if (this.authBindResult && !(this.authBindResult.authorizationType in HttpRouteAuthorizationType)) { + throw new Error(`authorizationType should either be AWS_IAM, JWT, CUSTOM, or NONE but was '${this.authBindResult.authorizationType}'`); } - let authorizationScopes = authBindResult?.authorizationScopes; + let authorizationScopes = this.authBindResult?.authorizationScopes; - if (authBindResult && props.authorizationScopes) { + if (this.authBindResult && props.authorizationScopes) { authorizationScopes = Array.from(new Set([ ...authorizationScopes ?? [], ...props.authorizationScopes, @@ -180,12 +219,44 @@ export class HttpRoute extends Resource implements IHttpRoute { apiId: props.httpApi.apiId, routeKey: props.routeKey.key, target: `integrations/${config.integrationId}`, - authorizerId: authBindResult?.authorizerId, - authorizationType: authBindResult?.authorizationType ?? 'NONE', // must be explicitly NONE (not undefined) for stack updates to work correctly + authorizerId: this.authBindResult?.authorizerId, + authorizationType: this.authBindResult?.authorizationType ?? 'NONE', authorizationScopes, }; const route = new CfnRoute(this, 'Resource', routeProps); this.routeId = route.ref; } + + private produceRouteArn(httpMethod: HttpMethod): string { + const stage = '*'; + const iamHttpMethod = httpMethod === HttpMethod.ANY ? '*' : httpMethod; + const path = this.path ?? '/'; + // When the user has provided a path with path variables, we replace the + // path variable and all that follows with a wildcard. + const iamPath = path.replace(/\{.*?\}.*/, '*'); + + return `arn:aws:execute-api:${this.stack.region}:${this.stack.account}:${this.httpApi.apiId}/${stage}/${iamHttpMethod}${iamPath}`; + } + + public grantInvoke(grantee: iam.IGrantable, options: GrantInvokeOptions = {}): iam.Grant { + if (!this.authBindResult || this.authBindResult.authorizationType !== HttpRouteAuthorizationType.AWS_IAM) { + throw new Error('To use grantInvoke, you must use IAM authorization'); + } + + const httpMethods = Array.from(new Set(options.httpMethods ?? [this.method])); + if (this.method !== HttpMethod.ANY && httpMethods.some(method => method !== this.method)) { + throw new Error('This route does not support granting invoke for all requested http methods'); + } + + const resourceArns = httpMethods.map(httpMethod => { + return this.produceRouteArn(httpMethod); + }); + + return iam.Grant.addToPrincipal({ + grantee, + actions: ['execute-api:Invoke'], + resourceArns: resourceArns, + }); + } } 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 df917fffe0ac8..0f0d4d01fd1c5 100644 --- a/packages/@aws-cdk/aws-apigatewayv2/test/http/route.test.ts +++ b/packages/@aws-cdk/aws-apigatewayv2/test/http/route.test.ts @@ -1,4 +1,5 @@ import { Template } from '@aws-cdk/assertions'; +import { AccountPrincipal, Role } from '@aws-cdk/aws-iam'; import { Stack, App } from '@aws-cdk/core'; import { HttpApi, HttpAuthorizer, HttpAuthorizerType, HttpConnectionType, HttpIntegrationType, HttpMethod, HttpRoute, @@ -306,7 +307,261 @@ describe('HttpRoute', () => { integration: new DummyIntegration(), routeKey: HttpRouteKey.with('/books', HttpMethod.GET), authorizer, - })).toThrowError('authorizationType should either be JWT, CUSTOM, or NONE'); + })).toThrowError('authorizationType should either be AWS_IAM, JWT, CUSTOM, or NONE'); + }); + + test('granting invoke', () => { + const stack = new Stack(); + const httpApi = new HttpApi(stack, 'HttpApi'); + const role = new Role(stack, 'Role', { + assumedBy: new AccountPrincipal('111111111111'), + }); + + const route = new HttpRoute(stack, 'HttpRoute', { + httpApi, + integration: new DummyIntegration(), + routeKey: HttpRouteKey.with('/books', HttpMethod.GET), + authorizer: new SomeAuthorizerType('AWS_IAM'), + }); + + // WHEN + route.grantInvoke(role); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::ApiGatewayV2::Route', { + AuthorizationType: 'AWS_IAM', + }); + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: 'execute-api:Invoke', + Effect: 'Allow', + Resource: { + 'Fn::Join': [ + '', + [ + 'arn:aws:execute-api:', + { Ref: 'AWS::Region' }, + ':', + { Ref: 'AWS::AccountId' }, + ':', + { Ref: 'HttpApiF5A9A8A7' }, + '/*/GET/books', + ], + ], + }, + }, + ], + Version: '2012-10-17', + }, + }); + }); + + test('granting invoke with httpMethod GET and PUT', () => { + const stack = new Stack(); + const httpApi = new HttpApi(stack, 'HttpApi'); + const role = new Role(stack, 'Role', { + assumedBy: new AccountPrincipal('111111111111'), + }); + + const route = new HttpRoute(stack, 'HttpRoute', { + httpApi, + integration: new DummyIntegration(), + routeKey: HttpRouteKey.with('/books'), + authorizer: new SomeAuthorizerType('AWS_IAM'), + }); + + // WHEN + route.grantInvoke(role, { + httpMethods: [HttpMethod.GET, HttpMethod.PUT], + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::ApiGatewayV2::Route', { + AuthorizationType: 'AWS_IAM', + }); + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: 'execute-api:Invoke', + Effect: 'Allow', + Resource: [ + { + 'Fn::Join': [ + '', + [ + 'arn:aws:execute-api:', + { Ref: 'AWS::Region' }, + ':', + { Ref: 'AWS::AccountId' }, + ':', + { Ref: 'HttpApiF5A9A8A7' }, + '/*/GET/books', + ], + ], + }, + { + 'Fn::Join': [ + '', + [ + 'arn:aws:execute-api:', + { Ref: 'AWS::Region' }, + ':', + { Ref: 'AWS::AccountId' }, + ':', + { Ref: 'HttpApiF5A9A8A7' }, + '/*/PUT/books', + ], + ], + }, + ], + }, + ], + Version: '2012-10-17', + }, + }); + }); + + test('granting invoke with path variables', () => { + const stack = new Stack(); + const httpApi = new HttpApi(stack, 'HttpApi'); + const role = new Role(stack, 'Role', { + assumedBy: new AccountPrincipal('111111111111'), + }); + + const route = new HttpRoute(stack, 'HttpRoute', { + httpApi, + integration: new DummyIntegration(), + routeKey: HttpRouteKey.with('/books/{book}/something'), + authorizer: new SomeAuthorizerType('AWS_IAM'), + }); + + // WHEN + route.grantInvoke(role); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::ApiGatewayV2::Route', { + AuthorizationType: 'AWS_IAM', + }); + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: 'execute-api:Invoke', + Effect: 'Allow', + Resource: { + 'Fn::Join': [ + '', + [ + 'arn:aws:execute-api:', + { Ref: 'AWS::Region' }, + ':', + { Ref: 'AWS::AccountId' }, + ':', + { Ref: 'HttpApiF5A9A8A7' }, + '/*/*/books/*', + ], + ], + }, + }, + ], + Version: '2012-10-17', + }, + }); + }); + + test('throws when granting invoke with httpMethods not supported by the route', () => { + const stack = new Stack(); + const httpApi = new HttpApi(stack, 'HttpApi'); + const role = new Role(stack, 'Role', { + assumedBy: new AccountPrincipal('111111111111'), + }); + + const route = new HttpRoute(stack, 'HttpRoute', { + httpApi, + integration: new DummyIntegration(), + routeKey: HttpRouteKey.with('/books/{book}/something', HttpMethod.GET), + authorizer: new SomeAuthorizerType('AWS_IAM'), + }); + + expect(() => + route.grantInvoke(role, { + httpMethods: [HttpMethod.DELETE], + }), + ).toThrowError(/This route does not support granting invoke for all requested http methods/i); + }); + + test('throws when granting invoke with the wrong authorizer type', () => { + const stack = new Stack(); + const httpApi = new HttpApi(stack, 'HttpApi'); + const role = new Role(stack, 'Role', { + assumedBy: new AccountPrincipal('111111111111'), + }); + + const route = new HttpRoute(stack, 'HttpRoute', { + httpApi, + integration: new DummyIntegration(), + routeKey: HttpRouteKey.with('/books/{book}/something', HttpMethod.GET), + authorizer: new SomeAuthorizerType('JWT'), + }); + + expect(() => + route.grantInvoke(role, { + httpMethods: [HttpMethod.DELETE], + }), + ).toThrowError(/To use grantInvoke, you must use IAM authorization/i); + }); + + test('accessing an ANY route arn', () => { + const stack = new Stack(); + const httpApi = new HttpApi(stack, 'HttpApi'); + + // WHEN + const route = new HttpRoute(stack, 'HttpRoute', { + httpApi, + integration: new DummyIntegration(), + routeKey: HttpRouteKey.with('/books/{book}/something'), + }); + + // THEN + expect(stack.resolve(route.routeArn)).toEqual({ + 'Fn::Join': ['', [ + 'arn:aws:execute-api:', + { Ref: 'AWS::Region' }, + ':', + { Ref: 'AWS::AccountId' }, + ':', + { Ref: 'HttpApiF5A9A8A7' }, + '/*/*/books/*', + ]], + }); + }); + + test('accessing a GET route arn', () => { + const stack = new Stack(); + const httpApi = new HttpApi(stack, 'HttpApi'); + + // WHEN + const route = new HttpRoute(stack, 'HttpRoute', { + httpApi, + integration: new DummyIntegration(), + routeKey: HttpRouteKey.with('/books/{book}/something', HttpMethod.GET), + }); + + // THEN + expect(stack.resolve(route.routeArn)).toEqual({ + 'Fn::Join': ['', [ + 'arn:aws:execute-api:', + { Ref: 'AWS::Region' }, + ':', + { Ref: 'AWS::AccountId' }, + ':', + { Ref: 'HttpApiF5A9A8A7' }, + '/*/GET/books/*', + ]], + }); }); }); @@ -367,4 +622,14 @@ class InvalidTypeAuthorizer implements IHttpRouteAuthorizer { authorizationType: 'Random', }; } +} + +class SomeAuthorizerType implements IHttpRouteAuthorizer { + constructor(private readonly authorizationType: string) {} + + bind(_: HttpRouteAuthorizerBindOptions): HttpRouteAuthorizerConfig { + return { + authorizationType: this.authorizationType, + }; + } } \ No newline at end of file