From 031932d79511c3750f3f4177d74ead4609cab541 Mon Sep 17 00:00:00 2001 From: CaerusKaru Date: Mon, 24 Feb 2020 06:00:27 -0500 Subject: [PATCH 1/4] feat(apigateway): lambda request authorizer (#5642) This creates a common LambdaAuthorizer base class so that the token and request authorizers can share common functionality. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .gitignore | 1 + packages/@aws-cdk/aws-apigateway/README.md | 48 ++- .../lib/authorizers/identity-source.ts | 55 +++ .../aws-apigateway/lib/authorizers/index.ts | 3 +- .../aws-apigateway/lib/authorizers/lambda.ts | 209 ++++++++---- packages/@aws-cdk/aws-apigateway/package.json | 4 +- .../integ.request-authorizer.expected.json | 312 ++++++++++++++++++ .../integ.request-authorizer.handler/index.ts | 25 ++ .../authorizers/integ.request-authorizer.ts | 42 +++ ...eg.token-authorizer-iam-role.expected.json | 22 +- .../integ.token-authorizer.expected.json | 102 +++--- .../integ.token-authorizer.handler/index.ts | 2 +- .../test/authorizers/test.identity-source.ts | 33 ++ .../test/authorizers/test.lambda.ts | 143 +++++++- .../test/integ.restapi.books.expected.json | 2 +- .../integ.restapi.multistack.expected.json | 2 +- .../test/integ.restapi.multiuse.expected.json | 2 +- .../integ.usage-plan.multikey.expected.json | 3 +- 18 files changed, 878 insertions(+), 132 deletions(-) create mode 100644 packages/@aws-cdk/aws-apigateway/lib/authorizers/identity-source.ts create mode 100644 packages/@aws-cdk/aws-apigateway/test/authorizers/integ.request-authorizer.expected.json create mode 100644 packages/@aws-cdk/aws-apigateway/test/authorizers/integ.request-authorizer.handler/index.ts create mode 100644 packages/@aws-cdk/aws-apigateway/test/authorizers/integ.request-authorizer.ts create mode 100644 packages/@aws-cdk/aws-apigateway/test/authorizers/test.identity-source.ts diff --git a/.gitignore b/.gitignore index 1fc6393de0acd..17ba70d7ac568 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ pack/ .tools/ coverage/ .nyc_output +.nycrc .LAST_BUILD *.sw[a-z] *~ diff --git a/packages/@aws-cdk/aws-apigateway/README.md b/packages/@aws-cdk/aws-apigateway/README.md index e190d03b8ee2e..b7537d82c63fb 100644 --- a/packages/@aws-cdk/aws-apigateway/README.md +++ b/packages/@aws-cdk/aws-apigateway/README.md @@ -370,8 +370,8 @@ API Gateway interacts with the authorizer Lambda function handler by passing inp The event object that the handler is called with contains the `authorizationToken` and the `methodArn` from the request to the API Gateway endpoint. The handler is expected to return the `principalId` (i.e. the client identifier) and a `policyDocument` stating what the client is authorizer to perform. -See https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html for a detailed specification on -inputs and outputs of the lambda handler. +See [here](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html) for a detailed specification on +inputs and outputs of the Lambda handler. The following code attaches a token-based Lambda authorizer to the 'GET' Method of the Book resource: @@ -382,7 +382,7 @@ const authFn = new lambda.Function(this, 'booksAuthorizerLambda', { }); const auth = new apigateway.TokenAuthorizer(this, 'booksAuthorizer', { - function: authFn + handler: authFn }); books.addMethod('GET', new apigateway.HttpIntegration('http://amazon.com'), { @@ -397,6 +397,45 @@ Authorizers can also be passed via the `defaultMethodOptions` property within th explicitly overridden, the specified defaults will be applied across all `Method`s across the `RestApi` or across all `Resource`s, depending on where the defaults were specified. +#### Lambda-based request authorizer + +This module provides support for request-based Lambda authorizers. When a client makes a request to an API's methods configured with such +an authorizer, API Gateway calls the Lambda authorizer, which takes specified parts of the request, known as identity sources, +as input and returns an IAM policy as output. A request-based Lambda authorizer (also called a request authorizer) receives +the identity sources in a series of values pulled from the request, from the headers, stage variables, query strings, and the context. + +API Gateway interacts with the authorizer Lambda function handler by passing input and expecting the output in a specific format. +The event object that the handler is called with contains the body of the request and the `methodArn` from the request to the +API Gateway endpoint. The handler is expected to return the `principalId` (i.e. the client identifier) and a `policyDocument` stating +what the client is authorizer to perform. +See [here](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html) for a detailed specification on +inputs and outputs of the Lambda handler. + +The following code attaches a request-based Lambda authorizer to the 'GET' Method of the Book resource: + +```ts +const authFn = new lambda.Function(this, 'booksAuthorizerLambda', { + // ... + // ... +}); + +const auth = new apigateway.RequestAuthorizer(this, 'booksAuthorizer', { + handler: authFn, + identitySources: [IdentitySource.header('Authorization')] +}); + +books.addMethod('GET', new apigateway.HttpIntegration('http://amazon.com'), { + authorizer: auth +}); +``` + +By default, the `RequestAuthorizer` does not pass any kind of information from the request. This can, +however, be modified by changing the `identitySource` property, and is required when specifying a value for caching. + +Authorizers can also be passed via the `defaultMethodOptions` property within the `RestApi` construct or the `Method` construct. Unless +explicitly overridden, the specified defaults will be applied across all `Method`s across the `RestApi` or across all `Resource`s, +depending on where the defaults were specified. + ### Deployments By default, the `RestApi` construct will automatically create an API Gateway @@ -539,7 +578,8 @@ running at one origin, access to selected resources from a different origin. A web application executes a cross-origin HTTP request when it requests a resource that has a different origin (domain, protocol, or port) from its own. -You can add the CORS [preflight](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#Preflighted_requests) OPTIONS HTTP method to any API resource via the `defaultCorsPreflightOptions` option or by calling the `addCorsPreflight` on a specific resource. +You can add the CORS [preflight](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#Preflighted_requests) OPTIONS +HTTP method to any API resource via the `defaultCorsPreflightOptions` option or by calling the `addCorsPreflight` on a specific resource. The following example will enable CORS for all methods and all origins on all resources of the API: diff --git a/packages/@aws-cdk/aws-apigateway/lib/authorizers/identity-source.ts b/packages/@aws-cdk/aws-apigateway/lib/authorizers/identity-source.ts new file mode 100644 index 0000000000000..d4ecc739da38d --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/lib/authorizers/identity-source.ts @@ -0,0 +1,55 @@ +/** + * Represents an identity source. + * + * The source can be specified either as a literal value (e.g: `Auth`) which + * cannot be blank, or as an unresolved string token. + */ +export class IdentitySource { + /** + * Provides a properly formatted header identity source. + * @param headerName the name of the header the `IdentitySource` will represent. + * + * @returns a header identity source. + */ + public static header(headerName: string): string { + return IdentitySource.toString(headerName, 'method.request.header'); + } + + /** + * Provides a properly formatted query string identity source. + * @param queryString the name of the query string the `IdentitySource` will represent. + * + * @returns a query string identity source. + */ + public static queryString(queryString: string): string { + return IdentitySource.toString(queryString, 'method.request.querystring'); + } + + /** + * Provides a properly formatted API Gateway stage variable identity source. + * @param stageVariable the name of the stage variable the `IdentitySource` will represent. + * + * @returns an API Gateway stage variable identity source. + */ + public static stageVariable(stageVariable: string): string { + return IdentitySource.toString(stageVariable, 'stageVariables'); + } + + /** + * Provides a properly formatted request context identity source. + * @param context the name of the context variable the `IdentitySource` will represent. + * + * @returns a request context identity source. + */ + public static context(context: string): string { + return IdentitySource.toString(context, 'context'); + } + + private static toString(source: string, type: string) { + if (!source.trim()) { + throw new Error(`IdentitySources must be a non-empty string.`); + } + + return `${type}.${source}`; + } +} diff --git a/packages/@aws-cdk/aws-apigateway/lib/authorizers/index.ts b/packages/@aws-cdk/aws-apigateway/lib/authorizers/index.ts index 338a17e47cf8c..57289c931f760 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/authorizers/index.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/authorizers/index.ts @@ -1 +1,2 @@ -export * from './lambda'; \ No newline at end of file +export * from './lambda'; +export * from './identity-source'; diff --git a/packages/@aws-cdk/aws-apigateway/lib/authorizers/lambda.ts b/packages/@aws-cdk/aws-apigateway/lib/authorizers/lambda.ts index 5175407981186..d984ca6d320fc 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/authorizers/lambda.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/authorizers/lambda.ts @@ -4,12 +4,12 @@ import { Construct, Duration, Lazy, Stack } from '@aws-cdk/core'; import { CfnAuthorizer } from '../apigateway.generated'; import { Authorizer, IAuthorizer } from '../authorizer'; import { RestApi } from '../restapi'; +import { IdentitySource } from './identity-source'; /** - * Properties for TokenAuthorizer + * Base properties for all lambda authorizers */ -export interface TokenAuthorizerProps { - +export interface LambdaAuthorizerProps { /** * An optional human friendly name for the authorizer. Note that, this is not the primary identifier of the authorizer. * @@ -27,14 +27,6 @@ export interface TokenAuthorizerProps { */ readonly handler: lambda.IFunction; - /** - * The request header mapping expression for the bearer token. This is typically passed as part of the header, in which case - * this should be `method.request.header.Authorizer` where Authorizer is the header containing the bearer token. - * @see https://docs.aws.amazon.com/apigateway/api-reference/link-relation/authorizer-create/#identitySource - * @default 'method.request.header.Authorization' - */ - readonly identitySource?: string; - /** * How long APIGateway should cache the results. Max 1 hour. * Disable caching by setting this to 0. @@ -43,6 +35,89 @@ export interface TokenAuthorizerProps { */ readonly resultsCacheTtl?: Duration; + /** + * An optional IAM role for APIGateway to assume before calling the Lambda-based authorizer. The IAM role must be + * assumable by 'apigateway.amazonaws.com'. + * + * @default - A resource policy is added to the Lambda function allowing apigateway.amazonaws.com to invoke the function. + */ + readonly assumeRole?: iam.IRole; +} + +abstract class LambdaAuthorizer extends Authorizer implements IAuthorizer { + + /** + * The id of the authorizer. + * @attribute + */ + public abstract readonly authorizerId: string; + + /** + * The ARN of the authorizer to be used in permission policies, such as IAM and resource-based grants. + */ + public abstract readonly authorizerArn: string; + + /** + * The Lambda function handler that this authorizer uses. + */ + protected readonly handler: lambda.IFunction; + + /** + * The IAM role that the API Gateway service assumes while invoking the Lambda function. + */ + protected readonly role?: iam.IRole; + + protected restApiId?: string; + + protected constructor(scope: Construct, id: string, props: LambdaAuthorizerProps) { + super(scope, id); + + this.handler = props.handler; + this.role = props.assumeRole; + + if (props.resultsCacheTtl && props.resultsCacheTtl?.toSeconds() > 3600) { + throw new Error(`Lambda authorizer property 'resultsCacheTtl' must not be greater than 3600 seconds (1 hour)`); + } + } + + /** + * Attaches this authorizer to a specific REST API. + * @internal + */ + public _attachToApi(restApi: RestApi) { + if (this.restApiId && this.restApiId !== restApi.restApiId) { + throw new Error(`Cannot attach authorizer to two different rest APIs`); + } + + this.restApiId = restApi.restApiId; + } + + /** + * Sets up the permissions necessary for the API Gateway service to invoke the Lambda function. + */ + protected setupPermissions() { + if (!this.role) { + this.handler.addPermission(`${this.node.uniqueId}:Permissions`, { + principal: new iam.ServicePrincipal('apigateway.amazonaws.com'), + sourceArn: this.authorizerArn + }); + } else if (this.role instanceof iam.Role) { // i.e. not imported + this.role.attachInlinePolicy(new iam.Policy(this, 'authorizerInvokePolicy', { + statements: [ + new iam.PolicyStatement({ + resources: [ this.handler.functionArn ], + actions: [ 'lambda:InvokeFunction' ], + }) + ] + })); + } + } +} + +/** + * Properties for TokenAuthorizer + */ +export interface TokenAuthorizerProps extends LambdaAuthorizerProps { /** * An optional regex to be matched against the authorization token. When matched the authorizer lambda is invoked, * otherwise a 401 Unauthorized is returned to the client. @@ -52,12 +127,12 @@ export interface TokenAuthorizerProps { readonly validationRegex?: string; /** - * An optional IAM role for APIGateway to assume before calling the Lambda-based authorizer. The IAM role must be - * assumable by 'apigateway.amazonaws.com'. - * - * @default - A resource policy is added to the Lambda function allowing apigateway.amazonaws.com to invoke the function. + * The request header mapping expression for the bearer token. This is typically passed as part of the header, in which case + * this should be `method.request.header.Authorizer` where Authorizer is the header containing the bearer token. + * @see https://docs.aws.amazon.com/apigateway/api-reference/link-relation/authorizer-create/#identitySource + * @default `IdentitySource.header('Authorization')` */ - readonly assumeRole?: iam.IRole; + readonly identitySource?: string; } /** @@ -67,75 +142,95 @@ export interface TokenAuthorizerProps { * * @resource AWS::ApiGateway::Authorizer */ -export class TokenAuthorizer extends Authorizer implements IAuthorizer { +export class TokenAuthorizer extends LambdaAuthorizer { - /** - * The id of the authorizer. - * @attribute - */ public readonly authorizerId: string; - /** - * The ARN of the authorizer to be used in permission policies, such as IAM and resource-based grants. - */ public readonly authorizerArn: string; - private restApiId?: string; - constructor(scope: Construct, id: string, props: TokenAuthorizerProps) { - super(scope, id); - - if (props.resultsCacheTtl && props.resultsCacheTtl.toSeconds() > 3600) { - throw new Error(`Lambda authorizer property 'resultsCacheTtl' must not be greater than 3600 seconds (1 hour)`); - } + super(scope, id, props); const restApiId = Lazy.stringValue({ produce: () => this.restApiId }); - const resource = new CfnAuthorizer(this, 'Resource', { name: props.authorizerName ?? this.node.uniqueId, restApiId, type: 'TOKEN', authorizerUri: `arn:aws:apigateway:${Stack.of(this).region}:lambda:path/2015-03-31/functions/${props.handler.functionArn}/invocations`, - authorizerCredentials: props.assumeRole ? props.assumeRole.roleArn : undefined, - authorizerResultTtlInSeconds: props.resultsCacheTtl && props.resultsCacheTtl.toSeconds(), + authorizerCredentials: props.assumeRole?.roleArn, + authorizerResultTtlInSeconds: props.resultsCacheTtl?.toSeconds(), identitySource: props.identitySource || 'method.request.header.Authorization', identityValidationExpression: props.validationRegex, }); this.authorizerId = resource.ref; - this.authorizerArn = Stack.of(this).formatArn({ service: 'execute-api', resource: restApiId, resourceName: `authorizers/${this.authorizerId}` }); - if (!props.assumeRole) { - props.handler.addPermission(`${this.node.uniqueId}:Permissions`, { - principal: new iam.ServicePrincipal('apigateway.amazonaws.com'), - sourceArn: this.authorizerArn - }); - } else if (props.assumeRole instanceof iam.Role) { // i.e., not imported - props.assumeRole.attachInlinePolicy(new iam.Policy(this, 'authorizerInvokePolicy', { - statements: [ - new iam.PolicyStatement({ - resources: [ props.handler.functionArn ], - actions: [ 'lambda:InvokeFunction' ], - }) - ] - })); - } + this.setupPermissions(); } +} +/** + * Properties for RequestAuthorizer + */ +export interface RequestAuthorizerProps extends LambdaAuthorizerProps { /** - * Attaches this authorizer to a specific REST API. - * @internal + * An array of request header mapping expressions for identities. Supported parameter types are + * Header, Query String, Stage Variable, and Context. For instance, extracting an authorization + * token from a header would use the identity source `IdentitySource.header('Authorizer')`. + * + * Note: API Gateway uses the specified identity sources as the request authorizer caching key. When caching is + * enabled, API Gateway calls the authorizer's Lambda function only after successfully verifying that all the + * specified identity sources are present at runtime. If a specified identify source is missing, null, or empty, + * API Gateway returns a 401 Unauthorized response without calling the authorizer Lambda function. + * + * @see https://docs.aws.amazon.com/apigateway/api-reference/link-relation/authorizer-create/#identitySource */ - public _attachToApi(restApi: RestApi) { - if (this.restApiId && this.restApiId !== restApi.restApiId) { - throw new Error(`Cannot attach authorizer to two different rest APIs`); + readonly identitySources: IdentitySource[]; +} + +/** + * Request-based lambda authorizer that recognizes the caller's identity via request parameters, + * such as headers, paths, query strings, stage variables, or context variables. + * Based on the request, authorization is performed by a lambda function. + * + * @resource AWS::ApiGateway::Authorizer + */ +export class RequestAuthorizer extends LambdaAuthorizer { + + public readonly authorizerId: string; + + public readonly authorizerArn: string; + + constructor(scope: Construct, id: string, props: RequestAuthorizerProps) { + super(scope, id, props); + + if ((props.resultsCacheTtl === undefined || props.resultsCacheTtl.toSeconds() !== 0) && props.identitySources.length === 0) { + throw new Error(`At least one Identity Source is required for a REQUEST-based Lambda authorizer if caching is enabled.`); } - this.restApiId = restApi.restApiId; + const restApiId = Lazy.stringValue({ produce: () => this.restApiId }); + const resource = new CfnAuthorizer(this, 'Resource', { + name: props.authorizerName ?? this.node.uniqueId, + restApiId, + type: 'REQUEST', + authorizerUri: `arn:aws:apigateway:${Stack.of(this).region}:lambda:path/2015-03-31/functions/${props.handler.functionArn}/invocations`, + authorizerCredentials: props.assumeRole?.roleArn, + authorizerResultTtlInSeconds: props.resultsCacheTtl?.toSeconds(), + identitySource: props.identitySources.map(is => is.toString()).join(','), + }); + + this.authorizerId = resource.ref; + this.authorizerArn = Stack.of(this).formatArn({ + service: 'execute-api', + resource: restApiId, + resourceName: `authorizers/${this.authorizerId}` + }); + + this.setupPermissions(); } -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/aws-apigateway/package.json b/packages/@aws-cdk/aws-apigateway/package.json index 3ab5ac3205411..3193d1102444a 100644 --- a/packages/@aws-cdk/aws-apigateway/package.json +++ b/packages/@aws-cdk/aws-apigateway/package.json @@ -292,8 +292,10 @@ "props-default-doc:@aws-cdk/aws-apigateway.MethodOptions.requestModels", "props-default-doc:@aws-cdk/aws-apigateway.MethodOptions.requestValidator", "docs-public-apis:@aws-cdk/aws-apigateway.ResourceBase.url", + "attribute-tag:@aws-cdk/aws-apigateway.LambdaAuthorizer.authorizerArn", + "attribute-tag:@aws-cdk/aws-apigateway.RequestAuthorizer.authorizerArn", "attribute-tag:@aws-cdk/aws-apigateway.TokenAuthorizer.authorizerArn" ] }, "stability": "stable" -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.request-authorizer.expected.json b/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.request-authorizer.expected.json new file mode 100644 index 0000000000000..692af2d9f0847 --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.request-authorizer.expected.json @@ -0,0 +1,312 @@ +{ + "Resources": { + "MyAuthorizerFunctionServiceRole8A34C19E": { + "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" + ] + ] + } + ] + } + }, + "MyAuthorizerFunction70F1223E": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "AssetParameters1aae6dca4c69aacfac5abba2d62571e892f6748d8d14b11a197f80159ab832f4S3BucketC6C6A669" + }, + "S3Key": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters1aae6dca4c69aacfac5abba2d62571e892f6748d8d14b11a197f80159ab832f4S3VersionKeyBB6680D1" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters1aae6dca4c69aacfac5abba2d62571e892f6748d8d14b11a197f80159ab832f4S3VersionKeyBB6680D1" + } + ] + } + ] + } + ] + ] + } + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "MyAuthorizerFunctionServiceRole8A34C19E", + "Arn" + ] + }, + "Runtime": "nodejs10.x" + }, + "DependsOn": [ + "MyAuthorizerFunctionServiceRole8A34C19E" + ] + }, + "MyAuthorizerFunctionRequestAuthorizerIntegMyAuthorizer5D9D41C5PermissionsCB8B246E": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "MyAuthorizerFunction70F1223E", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "MyRestApi2D1F47A9" + }, + "/authorizers/", + { + "Ref": "MyAuthorizer6575980E" + } + ] + ] + } + } + }, + "MyRestApi2D1F47A9": { + "Type": "AWS::ApiGateway::RestApi", + "Properties": { + "Name": "MyRestApi" + } + }, + "MyRestApiDeploymentB555B5828fad37a0e56bbac79ae37ae990881dca": { + "Type": "AWS::ApiGateway::Deployment", + "Properties": { + "RestApiId": { + "Ref": "MyRestApi2D1F47A9" + }, + "Description": "Automatically created by the RestApi construct" + }, + "DependsOn": [ + "MyRestApiANY05143F93" + ] + }, + "MyRestApiDeploymentStageprodC33B8E5F": { + "Type": "AWS::ApiGateway::Stage", + "Properties": { + "RestApiId": { + "Ref": "MyRestApi2D1F47A9" + }, + "DeploymentId": { + "Ref": "MyRestApiDeploymentB555B5828fad37a0e56bbac79ae37ae990881dca" + }, + "StageName": "prod" + } + }, + "MyRestApiCloudWatchRoleD4042E8E": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "apigateway.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs" + ] + ] + } + ] + } + }, + "MyRestApiAccount2FB6DB7A": { + "Type": "AWS::ApiGateway::Account", + "Properties": { + "CloudWatchRoleArn": { + "Fn::GetAtt": [ + "MyRestApiCloudWatchRoleD4042E8E", + "Arn" + ] + } + }, + "DependsOn": [ + "MyRestApi2D1F47A9" + ] + }, + "MyRestApiANY05143F93": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "HttpMethod": "ANY", + "ResourceId": { + "Fn::GetAtt": [ + "MyRestApi2D1F47A9", + "RootResourceId" + ] + }, + "RestApiId": { + "Ref": "MyRestApi2D1F47A9" + }, + "AuthorizationType": "CUSTOM", + "AuthorizerId": { + "Ref": "MyAuthorizer6575980E" + }, + "Integration": { + "IntegrationResponses": [ + { + "StatusCode": "200" + } + ], + "PassthroughBehavior": "NEVER", + "RequestTemplates": { + "application/json": "{ \"statusCode\": 200 }" + }, + "Type": "MOCK" + }, + "MethodResponses": [ + { + "StatusCode": "200" + } + ] + } + }, + "MyAuthorizer6575980E": { + "Type": "AWS::ApiGateway::Authorizer", + "Properties": { + "RestApiId": { + "Ref": "MyRestApi2D1F47A9" + }, + "Type": "REQUEST", + "AuthorizerUri": { + "Fn::Join": [ + "", + [ + "arn:aws:apigateway:", + { + "Ref": "AWS::Region" + }, + ":lambda:path/2015-03-31/functions/", + { + "Fn::GetAtt": [ + "MyAuthorizerFunction70F1223E", + "Arn" + ] + }, + "/invocations" + ] + ] + }, + "IdentitySource": "method.request.header.Authorization,method.request.querystring.allow", + "Name": "RequestAuthorizerIntegMyAuthorizer5D9D41C5" + } + } + }, + "Parameters": { + "AssetParameters1aae6dca4c69aacfac5abba2d62571e892f6748d8d14b11a197f80159ab832f4S3BucketC6C6A669": { + "Type": "String", + "Description": "S3 bucket for asset \"1aae6dca4c69aacfac5abba2d62571e892f6748d8d14b11a197f80159ab832f4\"" + }, + "AssetParameters1aae6dca4c69aacfac5abba2d62571e892f6748d8d14b11a197f80159ab832f4S3VersionKeyBB6680D1": { + "Type": "String", + "Description": "S3 key for asset version \"1aae6dca4c69aacfac5abba2d62571e892f6748d8d14b11a197f80159ab832f4\"" + }, + "AssetParameters1aae6dca4c69aacfac5abba2d62571e892f6748d8d14b11a197f80159ab832f4ArtifactHashA6CE14CB": { + "Type": "String", + "Description": "Artifact hash for asset \"1aae6dca4c69aacfac5abba2d62571e892f6748d8d14b11a197f80159ab832f4\"" + } + }, + "Outputs": { + "MyRestApiEndpoint4C55E4CB": { + "Value": { + "Fn::Join": [ + "", + [ + "https://", + { + "Ref": "MyRestApi2D1F47A9" + }, + ".execute-api.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "MyRestApiDeploymentStageprodC33B8E5F" + }, + "/" + ] + ] + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.request-authorizer.handler/index.ts b/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.request-authorizer.handler/index.ts new file mode 100644 index 0000000000000..1f015b531e5aa --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.request-authorizer.handler/index.ts @@ -0,0 +1,25 @@ +// tslint:disable:no-console + +export const handler = async (event: any, _context: any = {}): Promise => { + const authToken: string = event.headers.Authorization; + const authQueryString: string = event.queryStringParameters.allow; + console.log(`event.headers.Authorization = ${authToken}`); + console.log(`event.queryStringParameters.allow = ${authQueryString}`); + if ((authToken === 'allow' || authToken === 'deny') && authQueryString === 'yes') { + return { + principalId: 'user', + policyDocument: { + Version: "2012-10-17", + Statement: [ + { + Action: "execute-api:Invoke", + Effect: authToken, + Resource: event.methodArn + } + ] + } + }; + } else { + throw new Error('Unauthorized'); + } +}; diff --git a/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.request-authorizer.ts b/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.request-authorizer.ts new file mode 100644 index 0000000000000..b08cfb2e684bd --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.request-authorizer.ts @@ -0,0 +1,42 @@ +import * as lambda from '@aws-cdk/aws-lambda'; +import { App, Stack } from '@aws-cdk/core'; +import * as path from 'path'; +import { MockIntegration, PassthroughBehavior, RestApi } from '../../lib'; +import {RequestAuthorizer} from '../../lib/authorizers'; +import {IdentitySource} from '../../lib/authorizers/identity-source'; + +// Against the RestApi endpoint from the stack output, run +// `curl -s -o /dev/null -w "%{http_code}" ` should return 401 +// `curl -s -o /dev/null -w "%{http_code}" -H 'Authorization: deny' ?allow=yes` should return 403 +// `curl -s -o /dev/null -w "%{http_code}" -H 'Authorization: allow' ?allow=yes` should return 200 + +const app = new App(); +const stack = new Stack(app, 'RequestAuthorizerInteg'); + +const authorizerFn = new lambda.Function(stack, 'MyAuthorizerFunction', { + runtime: lambda.Runtime.NODEJS_10_X, + handler: 'index.handler', + code: lambda.AssetCode.fromAsset(path.join(__dirname, 'integ.request-authorizer.handler')) +}); + +const restapi = new RestApi(stack, 'MyRestApi'); + +const authorizer = new RequestAuthorizer(stack, 'MyAuthorizer', { + handler: authorizerFn, + identitySources: [IdentitySource.header('Authorization'), IdentitySource.queryString('allow')], +}); + +restapi.root.addMethod('ANY', new MockIntegration({ + integrationResponses: [ + { statusCode: '200' } + ], + passthroughBehavior: PassthroughBehavior.NEVER, + requestTemplates: { + 'application/json': '{ "statusCode": 200 }', + }, +}), { + methodResponses: [ + { statusCode: '200' } + ], + authorizer +}); diff --git a/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.token-authorizer-iam-role.expected.json b/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.token-authorizer-iam-role.expected.json index d2c26cd1de4ba..2d30983eaf02f 100644 --- a/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.token-authorizer-iam-role.expected.json +++ b/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.token-authorizer-iam-role.expected.json @@ -36,7 +36,7 @@ "Properties": { "Code": { "S3Bucket": { - "Ref": "AssetParameters6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993aS3Bucket115F9EA4" + "Ref": "AssetParametersbb857fcbb64420f49c6bead2b96595eacd5fb9f9d8ea0301a3617a0091c744ddS3Bucket7D65DBAC" }, "S3Key": { "Fn::Join": [ @@ -49,7 +49,7 @@ "Fn::Split": [ "||", { - "Ref": "AssetParameters6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993aS3VersionKey1039487E" + "Ref": "AssetParametersbb857fcbb64420f49c6bead2b96595eacd5fb9f9d8ea0301a3617a0091c744ddS3VersionKey97EAAAFF" } ] } @@ -62,7 +62,7 @@ "Fn::Split": [ "||", { - "Ref": "AssetParameters6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993aS3VersionKey1039487E" + "Ref": "AssetParametersbb857fcbb64420f49c6bead2b96595eacd5fb9f9d8ea0301a3617a0091c744ddS3VersionKey97EAAAFF" } ] } @@ -105,7 +105,6 @@ "MyAuthorizer6575980E": { "Type": "AWS::ApiGateway::Authorizer", "Properties": { - "Name": "TokenAuthorizerIAMRoleIntegMyAuthorizer1DFDE3B5", "RestApiId": { "Ref": "MyRestApi2D1F47A9" }, @@ -135,7 +134,8 @@ ] ] }, - "IdentitySource": "method.request.header.Authorization" + "IdentitySource": "method.request.header.Authorization", + "Name": "TokenAuthorizerIAMRoleIntegMyAuthorizer1DFDE3B5" } }, "MyAuthorizerauthorizerInvokePolicy0F88B8E1": { @@ -277,17 +277,17 @@ } }, "Parameters": { - "AssetParameters6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993aS3Bucket115F9EA4": { + "AssetParametersbb857fcbb64420f49c6bead2b96595eacd5fb9f9d8ea0301a3617a0091c744ddS3Bucket7D65DBAC": { "Type": "String", - "Description": "S3 bucket for asset \"6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993a\"" + "Description": "S3 bucket for asset \"bb857fcbb64420f49c6bead2b96595eacd5fb9f9d8ea0301a3617a0091c744dd\"" }, - "AssetParameters6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993aS3VersionKey1039487E": { + "AssetParametersbb857fcbb64420f49c6bead2b96595eacd5fb9f9d8ea0301a3617a0091c744ddS3VersionKey97EAAAFF": { "Type": "String", - "Description": "S3 key for asset version \"6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993a\"" + "Description": "S3 key for asset version \"bb857fcbb64420f49c6bead2b96595eacd5fb9f9d8ea0301a3617a0091c744dd\"" }, - "AssetParameters6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993aArtifactHash1A0BBA4E": { + "AssetParametersbb857fcbb64420f49c6bead2b96595eacd5fb9f9d8ea0301a3617a0091c744ddArtifactHashCA306BF0": { "Type": "String", - "Description": "Artifact hash for asset \"6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993a\"" + "Description": "Artifact hash for asset \"bb857fcbb64420f49c6bead2b96595eacd5fb9f9d8ea0301a3617a0091c744dd\"" } }, "Outputs": { diff --git a/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.token-authorizer.expected.json b/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.token-authorizer.expected.json index 60c98adf6e1d2..8107871d15f64 100644 --- a/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.token-authorizer.expected.json +++ b/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.token-authorizer.expected.json @@ -36,7 +36,7 @@ "Properties": { "Code": { "S3Bucket": { - "Ref": "AssetParameters6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993aS3Bucket115F9EA4" + "Ref": "AssetParametersbb857fcbb64420f49c6bead2b96595eacd5fb9f9d8ea0301a3617a0091c744ddS3Bucket7D65DBAC" }, "S3Key": { "Fn::Join": [ @@ -49,7 +49,7 @@ "Fn::Split": [ "||", { - "Ref": "AssetParameters6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993aS3VersionKey1039487E" + "Ref": "AssetParametersbb857fcbb64420f49c6bead2b96595eacd5fb9f9d8ea0301a3617a0091c744ddS3VersionKey97EAAAFF" } ] } @@ -62,7 +62,7 @@ "Fn::Split": [ "||", { - "Ref": "AssetParameters6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993aS3VersionKey1039487E" + "Ref": "AssetParametersbb857fcbb64420f49c6bead2b96595eacd5fb9f9d8ea0301a3617a0091c744ddS3VersionKey97EAAAFF" } ] } @@ -85,6 +85,46 @@ "MyAuthorizerFunctionServiceRole8A34C19E" ] }, + "MyAuthorizerFunctionTokenAuthorizerIntegMyAuthorizer793B1D5FPermissions7557AE26": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "MyAuthorizerFunction70F1223E", + "Arn" + ] + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "MyRestApi2D1F47A9" + }, + "/authorizers/", + { + "Ref": "MyAuthorizer6575980E" + } + ] + ] + } + } + }, "MyRestApi2D1F47A9": { "Type": "AWS::ApiGateway::RestApi", "Properties": { @@ -199,7 +239,6 @@ "MyAuthorizer6575980E": { "Type": "AWS::ApiGateway::Authorizer", "Properties": { - "Name": "TokenAuthorizerIntegMyAuthorizer793B1D5F", "RestApiId": { "Ref": "MyRestApi2D1F47A9" }, @@ -223,62 +262,23 @@ ] ] }, - "IdentitySource": "method.request.header.Authorization" - } - }, - "MyAuthorizerFunctionTokenAuthorizerIntegMyAuthorizer793B1D5FPermissions7557AE26": { - "Type": "AWS::Lambda::Permission", - "Properties": { - "Action": "lambda:InvokeFunction", - "FunctionName": { - "Fn::GetAtt": [ - "MyAuthorizerFunction70F1223E", - "Arn" - ] - }, - "Principal": "apigateway.amazonaws.com", - "SourceArn": { - "Fn::Join": [ - "", - [ - "arn:", - { - "Ref": "AWS::Partition" - }, - ":execute-api:", - { - "Ref": "AWS::Region" - }, - ":", - { - "Ref": "AWS::AccountId" - }, - ":", - { - "Ref": "MyRestApi2D1F47A9" - }, - "/authorizers/", - { - "Ref": "MyAuthorizer6575980E" - } - ] - ] - } + "IdentitySource": "method.request.header.Authorization", + "Name": "TokenAuthorizerIntegMyAuthorizer793B1D5F" } } }, "Parameters": { - "AssetParameters6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993aS3Bucket115F9EA4": { + "AssetParametersbb857fcbb64420f49c6bead2b96595eacd5fb9f9d8ea0301a3617a0091c744ddS3Bucket7D65DBAC": { "Type": "String", - "Description": "S3 bucket for asset \"6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993a\"" + "Description": "S3 bucket for asset \"bb857fcbb64420f49c6bead2b96595eacd5fb9f9d8ea0301a3617a0091c744dd\"" }, - "AssetParameters6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993aS3VersionKey1039487E": { + "AssetParametersbb857fcbb64420f49c6bead2b96595eacd5fb9f9d8ea0301a3617a0091c744ddS3VersionKey97EAAAFF": { "Type": "String", - "Description": "S3 key for asset version \"6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993a\"" + "Description": "S3 key for asset version \"bb857fcbb64420f49c6bead2b96595eacd5fb9f9d8ea0301a3617a0091c744dd\"" }, - "AssetParameters6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993aArtifactHash1A0BBA4E": { + "AssetParametersbb857fcbb64420f49c6bead2b96595eacd5fb9f9d8ea0301a3617a0091c744ddArtifactHashCA306BF0": { "Type": "String", - "Description": "Artifact hash for asset \"6695c4a3dad80ddeef2797a1729306b7c136d67ce21d2187fdbc7bbad009993a\"" + "Description": "Artifact hash for asset \"bb857fcbb64420f49c6bead2b96595eacd5fb9f9d8ea0301a3617a0091c744dd\"" } }, "Outputs": { diff --git a/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.token-authorizer.handler/index.ts b/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.token-authorizer.handler/index.ts index f1ad21bc7b09a..86292e971d30c 100644 --- a/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.token-authorizer.handler/index.ts +++ b/packages/@aws-cdk/aws-apigateway/test/authorizers/integ.token-authorizer.handler/index.ts @@ -20,4 +20,4 @@ export const handler = async (event: any, _context: any = {}): Promise => { } else { throw new Error('Unauthorized'); } -}; \ No newline at end of file +}; diff --git a/packages/@aws-cdk/aws-apigateway/test/authorizers/test.identity-source.ts b/packages/@aws-cdk/aws-apigateway/test/authorizers/test.identity-source.ts new file mode 100644 index 0000000000000..65c589fd7d53a --- /dev/null +++ b/packages/@aws-cdk/aws-apigateway/test/authorizers/test.identity-source.ts @@ -0,0 +1,33 @@ +import * as nodeunit from 'nodeunit'; +import { IdentitySource } from '../../lib'; + +export = nodeunit.testCase({ + 'blank amount'(test: nodeunit.Test) { + test.throws(() => IdentitySource.context(''), /empty/); + test.done(); + }, + + 'IdentitySource header'(test: nodeunit.Test) { + const identitySource = IdentitySource.header('Authorization'); + test.equal(identitySource.toString(), 'method.request.header.Authorization'); + test.done(); + }, + + 'IdentitySource queryString'(test: nodeunit.Test) { + const identitySource = IdentitySource.queryString('param'); + test.equal(identitySource.toString(), 'method.request.querystring.param'); + test.done(); + }, + + 'IdentitySource stageVariable'(test: nodeunit.Test) { + const identitySource = IdentitySource.stageVariable('var1'); + test.equal(identitySource.toString(), 'stageVariables.var1'); + test.done(); + }, + + 'IdentitySource context'(test: nodeunit.Test) { + const identitySource = IdentitySource.context('httpMethod'); + test.equal(identitySource.toString(), 'context.httpMethod'); + test.done(); + }, +}); diff --git a/packages/@aws-cdk/aws-apigateway/test/authorizers/test.lambda.ts b/packages/@aws-cdk/aws-apigateway/test/authorizers/test.lambda.ts index f58edf2657ced..92a476a7ed2e5 100644 --- a/packages/@aws-cdk/aws-apigateway/test/authorizers/test.lambda.ts +++ b/packages/@aws-cdk/aws-apigateway/test/authorizers/test.lambda.ts @@ -3,7 +3,7 @@ import * as iam from '@aws-cdk/aws-iam'; import * as lambda from '@aws-cdk/aws-lambda'; import { Duration, Stack } from '@aws-cdk/core'; import { Test } from 'nodeunit'; -import { AuthorizationType, RestApi, TokenAuthorizer } from '../../lib'; +import { AuthorizationType, IdentitySource, RequestAuthorizer, RestApi, TokenAuthorizer } from '../../lib'; export = { 'default token authorizer'(test: Test) { @@ -41,6 +41,60 @@ export = { test.done(); }, + 'default request authorizer'(test: Test) { + const stack = new Stack(); + + const func = new lambda.Function(stack, 'myfunction', { + handler: 'handler', + code: lambda.Code.fromInline('foo'), + runtime: lambda.Runtime.NODEJS_12_X, + }); + + const auth = new RequestAuthorizer(stack, 'myauthorizer', { + handler: func, + resultsCacheTtl: Duration.seconds(0), + identitySources: [], + }); + + const restApi = new RestApi(stack, 'myrestapi'); + restApi.root.addMethod('ANY', undefined, { + authorizer: auth, + authorizationType: AuthorizationType.CUSTOM + }); + + expect(stack).to(haveResource('AWS::ApiGateway::Authorizer', { + Type: 'REQUEST', + RestApiId: stack.resolve(restApi.restApiId), + })); + + expect(stack).to(haveResource('AWS::Lambda::Permission', { + Action: 'lambda:InvokeFunction', + Principal: 'apigateway.amazonaws.com', + })); + + test.ok(auth.authorizerArn.endsWith(`/authorizers/${auth.authorizerId}`), 'Malformed authorizer ARN'); + + test.done(); + }, + + 'invalid request authorizer config'(test: Test) { + const stack = new Stack(); + + const func = new lambda.Function(stack, 'myfunction', { + handler: 'handler', + code: lambda.Code.fromInline('foo'), + runtime: lambda.Runtime.NODEJS_12_X, + }); + + test.throws(() => new RequestAuthorizer(stack, 'myauthorizer', { + handler: func, + resultsCacheTtl: Duration.seconds(1), + identitySources: [], + }), Error, 'At least one Identity Source is required for a REQUEST-based Lambda authorizer if caching is enabled.'); + + test.done(); + }, + 'token authorizer with all parameters specified'(test: Test) { const stack = new Stack(); @@ -76,6 +130,39 @@ export = { test.done(); }, + 'request authorizer with all parameters specified'(test: Test) { + const stack = new Stack(); + + const func = new lambda.Function(stack, 'myfunction', { + handler: 'handler', + code: lambda.Code.fromInline('foo'), + runtime: lambda.Runtime.NODEJS_12_X, + }); + + const auth = new RequestAuthorizer(stack, 'myauthorizer', { + handler: func, + identitySources: [IdentitySource.header('whoami')], + authorizerName: 'myauthorizer', + resultsCacheTtl: Duration.minutes(1), + }); + + const restApi = new RestApi(stack, 'myrestapi'); + restApi.root.addMethod('ANY', undefined, { + authorizer: auth, + authorizationType: AuthorizationType.CUSTOM + }); + + expect(stack).to(haveResource('AWS::ApiGateway::Authorizer', { + Type: 'REQUEST', + RestApiId: stack.resolve(restApi.restApiId), + IdentitySource: 'method.request.header.whoami', + Name: 'myauthorizer', + AuthorizerResultTtlInSeconds: 60 + })); + + test.done(); + }, + 'token authorizer with assume role'(test: Test) { const stack = new Stack(); @@ -125,6 +212,60 @@ export = { expect(stack).notTo(haveResource('AWS::Lambda::Permission')); + test.done(); + }, + + 'request authorizer with assume role'(test: Test) { + const stack = new Stack(); + + const func = new lambda.Function(stack, 'myfunction', { + handler: 'handler', + code: lambda.Code.fromInline('foo'), + runtime: lambda.Runtime.NODEJS_12_X, + }); + + const role = new iam.Role(stack, 'authorizerassumerole', { + assumedBy: new iam.ServicePrincipal('apigateway.amazonaws.com'), + roleName: 'authorizerassumerole' + }); + + const auth = new RequestAuthorizer(stack, 'myauthorizer', { + handler: func, + assumeRole: role, + resultsCacheTtl: Duration.seconds(0), + identitySources: [] + }); + + const restApi = new RestApi(stack, 'myrestapi'); + restApi.root.addMethod('ANY', undefined, { + authorizer: auth, + authorizationType: AuthorizationType.CUSTOM + }); + + expect(stack).to(haveResource('AWS::ApiGateway::Authorizer', { + Type: 'REQUEST', + RestApiId: stack.resolve(restApi.restApiId), + })); + + expect(stack).to(haveResource('AWS::IAM::Role')); + + expect(stack).to(haveResource('AWS::IAM::Policy', { + Roles: [ + stack.resolve(role.roleName) + ], + PolicyDocument: { + Statement: [ + { + Resource: stack.resolve(func.functionArn), + Action: 'lambda:InvokeFunction', + Effect: 'Allow', + } + ], + } + }, ResourcePart.Properties, true)); + + expect(stack).notTo(haveResource('AWS::Lambda::Permission')); + test.done(); } }; diff --git a/packages/@aws-cdk/aws-apigateway/test/integ.restapi.books.expected.json b/packages/@aws-cdk/aws-apigateway/test/integ.restapi.books.expected.json index 55bbd73b8a143..b4a0de0c99ce1 100644 --- a/packages/@aws-cdk/aws-apigateway/test/integ.restapi.books.expected.json +++ b/packages/@aws-cdk/aws-apigateway/test/integ.restapi.books.expected.json @@ -880,4 +880,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigateway/test/integ.restapi.multistack.expected.json b/packages/@aws-cdk/aws-apigateway/test/integ.restapi.multistack.expected.json index 1cdcd9201dd7e..89fc935988bf7 100644 --- a/packages/@aws-cdk/aws-apigateway/test/integ.restapi.multistack.expected.json +++ b/packages/@aws-cdk/aws-apigateway/test/integ.restapi.multistack.expected.json @@ -319,4 +319,4 @@ } } } -] +] \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigateway/test/integ.restapi.multiuse.expected.json b/packages/@aws-cdk/aws-apigateway/test/integ.restapi.multiuse.expected.json index 3ada6b4b13276..c90afb57e13c3 100644 --- a/packages/@aws-cdk/aws-apigateway/test/integ.restapi.multiuse.expected.json +++ b/packages/@aws-cdk/aws-apigateway/test/integ.restapi.multiuse.expected.json @@ -523,4 +523,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-apigateway/test/integ.usage-plan.multikey.expected.json b/packages/@aws-cdk/aws-apigateway/test/integ.usage-plan.multikey.expected.json index 669c3c14f0c20..8e761f40e2a26 100644 --- a/packages/@aws-cdk/aws-apigateway/test/integ.usage-plan.multikey.expected.json +++ b/packages/@aws-cdk/aws-apigateway/test/integ.usage-plan.multikey.expected.json @@ -1,8 +1,7 @@ { "Resources": { "myusageplan4B391740": { - "Type": "AWS::ApiGateway::UsagePlan", - "Properties": {} + "Type": "AWS::ApiGateway::UsagePlan" }, "myusageplanUsagePlanKeyResource095B4EA9": { "Type": "AWS::ApiGateway::UsagePlanKey", From b6d4d28c9a87e0a2633adc3670d5bdcbaed89a6b Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Mon, 24 Feb 2020 15:54:09 +0000 Subject: [PATCH 2/4] chore(deps-dev): bump nock from 12.0.0 to 12.0.1 (#6420) Bumps [nock](https://github.com/nock/nock) from 12.0.0 to 12.0.1. - [Release notes](https://github.com/nock/nock/releases) - [Changelog](https://github.com/nock/nock/blob/master/CHANGELOG.md) - [Commits](https://github.com/nock/nock/compare/v12.0.0...v12.0.1) Signed-off-by: dependabot-preview[bot] --- packages/@aws-cdk/aws-lambda/package.json | 2 +- packages/@aws-cdk/custom-resources/package.json | 2 +- yarn.lock | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/@aws-cdk/aws-lambda/package.json b/packages/@aws-cdk/aws-lambda/package.json index a45e25a5c2cc7..b9088b913eafd 100644 --- a/packages/@aws-cdk/aws-lambda/package.json +++ b/packages/@aws-cdk/aws-lambda/package.json @@ -77,7 +77,7 @@ "cdk-integ-tools": "1.25.0", "cfn2ts": "1.25.0", "lodash": "^4.17.15", - "nock": "^12.0.0", + "nock": "^12.0.1", "nodeunit": "^0.11.3", "pkglint": "1.25.0", "sinon": "^9.0.0" diff --git a/packages/@aws-cdk/custom-resources/package.json b/packages/@aws-cdk/custom-resources/package.json index a802f8feb403a..3d1250c27501e 100644 --- a/packages/@aws-cdk/custom-resources/package.json +++ b/packages/@aws-cdk/custom-resources/package.json @@ -79,7 +79,7 @@ "cdk-integ-tools": "1.25.0", "cfn2ts": "1.25.0", "fs-extra": "^8.1.0", - "nock": "^12.0.0", + "nock": "^12.0.1", "pkglint": "1.25.0", "sinon": "^9.0.0" }, diff --git a/yarn.lock b/yarn.lock index 859a5f72d6013..36255ff220f31 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8020,10 +8020,10 @@ nise@^4.0.1: just-extend "^4.0.2" path-to-regexp "^1.7.0" -nock@^12.0.0: - version "12.0.0" - resolved "https://registry.yarnpkg.com/nock/-/nock-12.0.0.tgz#f405309bbf305d9a5bba2e9718aab4463edc9572" - integrity sha512-aTzDlXFH/Xq4m2V5x5nV13RTvYX8RXXcurCx6z4+y8IsloFMizZsDe/189GX1pSMJ99HBFZAokS5sMiMX/qfaQ== +nock@^12.0.1: + version "12.0.1" + resolved "https://registry.yarnpkg.com/nock/-/nock-12.0.1.tgz#6d497d01f23cb52c733545c97e09e8318f6af801" + integrity sha512-f5u5k7O5D2YXH2WEFQVLLPa36D5C0dxU9Lrg6KOuaFCMDt7yd1W4S3hbZClCMczxc4EZ0k1bEhPeMWSewrxYNw== dependencies: debug "^4.1.0" json-stringify-safe "^5.0.1" From e5493bd2cea897a2d4e1576d3084e9fb2e9f6b7f Mon Sep 17 00:00:00 2001 From: strazeadin <46366030+strazeadin@users.noreply.github.com> Date: Tue, 25 Feb 2020 03:38:47 +1100 Subject: [PATCH 3/4] feat(sns): support multiple tokens as url and email subscriptions (#6357) fixes #3996 to allow using tokens in email subscriptions, additionally fixes a bug with URL subscriptions when using more than one token subscription. **The Issue** Email Subscriptions currently use the value passed in as the construct ID, when the value passed in is a token (For example a parameter) it causes an error as tokens aren't supported as construct IDs. A previous fix was done for URL Subscriptions but it also errors when more than one URL subscription with a token is used. **The fix** In the topic base, identify if the subscription ID is a token and override it to a valid construct ID. The method of ensuring a valid ID is to convert it to a special prefix suffixed by a number and doing an increment of the number for each new topic created with a token. Subscriptions not utilizing a token are not effected. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .../@aws-cdk/aws-sns-subscriptions/README.md | 22 +- .../@aws-cdk/aws-sns-subscriptions/lib/url.ts | 2 +- .../aws-sns-subscriptions/test/subs.test.ts | 203 +++++++++++++++++- packages/@aws-cdk/aws-sns/lib/topic-base.ts | 24 ++- 4 files changed, 238 insertions(+), 13 deletions(-) diff --git a/packages/@aws-cdk/aws-sns-subscriptions/README.md b/packages/@aws-cdk/aws-sns-subscriptions/README.md index 416177f518138..1956484a309ee 100644 --- a/packages/@aws-cdk/aws-sns-subscriptions/README.md +++ b/packages/@aws-cdk/aws-sns-subscriptions/README.md @@ -31,7 +31,7 @@ const myTopic = new sns.Topic(this, 'MyTopic'); ### HTTPS -Add an HTTPS Subscription to your topic: +Add an HTTP or HTTPS Subscription to your topic: ```ts import subscriptions = require('@aws-cdk/aws-sns-subscriptions'); @@ -39,6 +39,16 @@ import subscriptions = require('@aws-cdk/aws-sns-subscriptions'); myTopic.addSubscription(new subscriptions.UrlSubscription('https://foobar.com/')); ``` +The URL being subscribed can also be [tokens](https://docs.aws.amazon.com/cdk/latest/guide/tokens.html), that resolve +to a URL during deployment. A typical use case is when the URL is passed in as a [CloudFormation +parameter](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/parameters-section-structure.html). The +following code defines a CloudFormation parameter and uses it in a URL subscription. + +```ts +const url = new CfnParameter(this, 'url-param'); +myTopic.addSubscription(new subscriptions.UrlSubscription(url.valueAsString())); +``` + ### Amazon SQS Subscribe a queue to your topic: @@ -82,5 +92,15 @@ import subscriptions = require('@aws-cdk/aws-sns-subscriptions'); myTopic.addSubscription(new subscriptions.EmailSubscription('foo@bar.com')); ``` +The email being subscribed can also be [tokens](https://docs.aws.amazon.com/cdk/latest/guide/tokens.html), that resolve +to an email address during deployment. A typical use case is when the email address is passed in as a [CloudFormation +parameter](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/parameters-section-structure.html). The +following code defines a CloudFormation parameter and uses it in an email subscription. + +```ts +const emailAddress = new CfnParameter(this, 'email-param'); +myTopic.addSubscription(new subscriptions.EmailSubscription(emailAddress.valueAsString())); +``` + Note that email subscriptions require confirmation by visiting the link sent to the email address. diff --git a/packages/@aws-cdk/aws-sns-subscriptions/lib/url.ts b/packages/@aws-cdk/aws-sns-subscriptions/lib/url.ts index da1709fb4e6d8..12cdabd40e7b0 100644 --- a/packages/@aws-cdk/aws-sns-subscriptions/lib/url.ts +++ b/packages/@aws-cdk/aws-sns-subscriptions/lib/url.ts @@ -53,7 +53,7 @@ export class UrlSubscription implements sns.ITopicSubscription { public bind(_topic: sns.ITopic): sns.TopicSubscriptionConfig { return { - subscriberId: this.unresolvedUrl ? 'UnresolvedUrl' : this.url, + subscriberId: this.url, endpoint: this.url, protocol: this.protocol, rawMessageDelivery: this.props.rawMessageDelivery, diff --git a/packages/@aws-cdk/aws-sns-subscriptions/test/subs.test.ts b/packages/@aws-cdk/aws-sns-subscriptions/test/subs.test.ts index d7bfe9c6d6c21..095ba53f29f01 100644 --- a/packages/@aws-cdk/aws-sns-subscriptions/test/subs.test.ts +++ b/packages/@aws-cdk/aws-sns-subscriptions/test/subs.test.ts @@ -2,7 +2,7 @@ import '@aws-cdk/assert/jest'; import * as lambda from '@aws-cdk/aws-lambda'; import * as sns from '@aws-cdk/aws-sns'; import * as sqs from '@aws-cdk/aws-sqs'; -import { CfnParameter, SecretValue, Stack } from '@aws-cdk/core'; +import { CfnParameter, Stack, Token } from '@aws-cdk/core'; import * as subs from '../lib'; // tslint:disable:object-literal-key-quotes @@ -72,9 +72,8 @@ test('url subscription (with raw delivery)', () => { }); test('url subscription (unresolved url with protocol)', () => { - const secret = SecretValue.secretsManager('my-secret'); - const url = secret.toString(); - topic.addSubscription(new subs.UrlSubscription(url, {protocol: sns.SubscriptionProtocol.HTTPS})); + const urlToken = Token.asString({ Ref : "my-url-1" }); + topic.addSubscription(new subs.UrlSubscription(urlToken, {protocol: sns.SubscriptionProtocol.HTTPS})); expect(stack).toMatchTemplate({ "Resources": { @@ -85,10 +84,52 @@ test('url subscription (unresolved url with protocol)', () => { "TopicName": "topicName" } }, - "MyTopicUnresolvedUrlBA127FB3": { + "MyTopicTokenSubscription141DD1BE2": { "Type": "AWS::SNS::Subscription", "Properties": { - "Endpoint": "{{resolve:secretsmanager:my-secret:SecretString:::}}", + "Endpoint": { + "Ref": "my-url-1" + }, + "Protocol": "https", + "TopicArn": { "Ref": "MyTopic86869434" }, + } + } + } + }); +}); + +test('url subscription (double unresolved url with protocol)', () => { + const urlToken1 = Token.asString({ Ref : "my-url-1" }); + const urlToken2 = Token.asString({ Ref : "my-url-2" }); + + topic.addSubscription(new subs.UrlSubscription(urlToken1, {protocol: sns.SubscriptionProtocol.HTTPS})); + topic.addSubscription(new subs.UrlSubscription(urlToken2, {protocol: sns.SubscriptionProtocol.HTTPS})); + + expect(stack).toMatchTemplate({ + "Resources": { + "MyTopic86869434": { + "Type": "AWS::SNS::Topic", + "Properties": { + "DisplayName": "displayName", + "TopicName": "topicName" + } + }, + "MyTopicTokenSubscription141DD1BE2": { + "Type": "AWS::SNS::Subscription", + "Properties": { + "Endpoint": { + "Ref" : "my-url-1" + }, + "Protocol": "https", + "TopicArn": { "Ref": "MyTopic86869434" }, + } + }, + "MyTopicTokenSubscription293BFE3F9": { + "Type": "AWS::SNS::Subscription", + "Properties": { + "Endpoint": { + "Ref" : "my-url-2" + }, "Protocol": "https", "TopicArn": { "Ref": "MyTopic86869434" }, } @@ -103,9 +144,9 @@ test('url subscription (unknown protocol)', () => { }); test('url subscription (unresolved url without protocol)', () => { - const secret = SecretValue.secretsManager('my-secret'); - const url = secret.toString(); - expect(() => topic.addSubscription(new subs.UrlSubscription(url))) + const urlToken = Token.asString({ Ref : "my-url-1" }); + + expect(() => topic.addSubscription(new subs.UrlSubscription(urlToken))) .toThrowError(/Must provide protocol if url is unresolved/); }); @@ -329,6 +370,150 @@ test('email subscription', () => { }); }); +test('email subscription with unresolved', () => { + const emailToken = Token.asString({ Ref : "my-email-1" }); + topic.addSubscription(new subs.EmailSubscription(emailToken)); + + expect(stack).toMatchTemplate({ + "Resources": { + "MyTopic86869434": { + "Type": "AWS::SNS::Topic", + "Properties": { + "DisplayName": "displayName", + "TopicName": "topicName" + } + }, + "MyTopicTokenSubscription141DD1BE2": { + "Type": "AWS::SNS::Subscription", + "Properties": { + "Endpoint": { + "Ref" : "my-email-1" + }, + "Protocol": "email", + "TopicArn": { + "Ref": "MyTopic86869434" + } + } + } + } + }); +}); + +test('email and url subscriptions with unresolved', () => { + const emailToken = Token.asString({ Ref : "my-email-1" }); + const urlToken = Token.asString({ Ref : "my-url-1" }); + topic.addSubscription(new subs.EmailSubscription(emailToken)); + topic.addSubscription(new subs.UrlSubscription(urlToken, {protocol: sns.SubscriptionProtocol.HTTPS})); + + expect(stack).toMatchTemplate({ + "Resources": { + "MyTopic86869434": { + "Type": "AWS::SNS::Topic", + "Properties": { + "DisplayName": "displayName", + "TopicName": "topicName" + } + }, + "MyTopicTokenSubscription141DD1BE2": { + "Type": "AWS::SNS::Subscription", + "Properties": { + "Endpoint": { + "Ref" : "my-email-1" + }, + "Protocol": "email", + "TopicArn": { + "Ref": "MyTopic86869434" + } + } + }, + "MyTopicTokenSubscription293BFE3F9": { + "Type": "AWS::SNS::Subscription", + "Properties": { + "Endpoint": { + "Ref" : "my-url-1" + }, + "Protocol": "https", + "TopicArn": { + "Ref": "MyTopic86869434" + } + } + } + } + }); +}); + +test('email and url subscriptions with unresolved - four subscriptions', () => { + const emailToken1 = Token.asString({ Ref : "my-email-1" }); + const emailToken2 = Token.asString({ Ref : "my-email-2" }); + const emailToken3 = Token.asString({ Ref : "my-email-3" }); + const emailToken4 = Token.asString({ Ref : "my-email-4" }); + + topic.addSubscription(new subs.EmailSubscription(emailToken1)); + topic.addSubscription(new subs.EmailSubscription(emailToken2)); + topic.addSubscription(new subs.EmailSubscription(emailToken3)); + topic.addSubscription(new subs.EmailSubscription(emailToken4)); + + expect(stack).toMatchTemplate({ + "Resources": { + "MyTopic86869434": { + "Type": "AWS::SNS::Topic", + "Properties": { + "DisplayName": "displayName", + "TopicName": "topicName" + } + }, + "MyTopicTokenSubscription141DD1BE2": { + "Type": "AWS::SNS::Subscription", + "Properties": { + "Endpoint": { + "Ref" : "my-email-1" + }, + "Protocol": "email", + "TopicArn": { + "Ref": "MyTopic86869434" + } + } + }, + "MyTopicTokenSubscription293BFE3F9": { + "Type": "AWS::SNS::Subscription", + "Properties": { + "Endpoint": { + "Ref" : "my-email-2" + }, + "Protocol": "email", + "TopicArn": { + "Ref": "MyTopic86869434" + } + } + }, + "MyTopicTokenSubscription335C2B4CA": { + "Type": "AWS::SNS::Subscription", + "Properties": { + "Endpoint": { + "Ref" : "my-email-3" + }, + "Protocol": "email", + "TopicArn": { + "Ref": "MyTopic86869434" + } + } + }, + "MyTopicTokenSubscription4DBE52A3F": { + "Type": "AWS::SNS::Subscription", + "Properties": { + "Endpoint": { + "Ref" : "my-email-4" + }, + "Protocol": "email", + "TopicArn": { + "Ref": "MyTopic86869434" + } + } + } + } + }); +}); + test('multiple subscriptions', () => { const queue = new sqs.Queue(stack, 'MyQueue'); const func = new lambda.Function(stack, 'MyFunc', { diff --git a/packages/@aws-cdk/aws-sns/lib/topic-base.ts b/packages/@aws-cdk/aws-sns/lib/topic-base.ts index 44e2bc9dad9dc..ff025cb09072f 100644 --- a/packages/@aws-cdk/aws-sns/lib/topic-base.ts +++ b/packages/@aws-cdk/aws-sns/lib/topic-base.ts @@ -1,5 +1,5 @@ import * as iam from '@aws-cdk/aws-iam'; -import { IResource, Resource } from '@aws-cdk/core'; +import { Construct, IResource, Resource, Token } from '@aws-cdk/core'; import { TopicPolicy } from './policy'; import { ITopicSubscription } from './subscriber'; import { Subscription } from './subscription'; @@ -59,7 +59,10 @@ export abstract class TopicBase extends Resource implements ITopic { const subscriptionConfig = subscription.bind(this); const scope = subscriptionConfig.subscriberScope || this; - const id = subscriptionConfig.subscriberId; + let id = subscriptionConfig.subscriberId; + if (Token.isUnresolved(subscriptionConfig.subscriberId)) { + id = this.nextTokenId(scope); + } // We use the subscriber's id as the construct id. There's no meaning // to subscribing the same subscriber twice on the same topic. @@ -102,4 +105,21 @@ export abstract class TopicBase extends Resource implements ITopic { }); } + private nextTokenId(scope: Construct) { + let nextSuffix = 1; + const re = /TokenSubscription:([\d]*)/gm; + // Search through the construct and all of its children + // for previous subscriptions that match our regex pattern + for (const source of scope.node.findAll()) { + const m = re.exec(source.node.id); // Use regex to find a match + if (m !== null) { // if we found a match + const matchSuffix = parseInt(m[1], 10); // get the suffix for that match (as integer) + if (matchSuffix >= nextSuffix) { // check if the match suffix is larger or equal to currently proposed suffix + nextSuffix = matchSuffix + 1; // increment the suffix + } + } + } + return `TokenSubscription:${nextSuffix}`; + } + } From 2a418b9490f65ddcc34d96afb64c0d49041ae049 Mon Sep 17 00:00:00 2001 From: Elad Ben-Israel Date: Mon, 24 Feb 2020 19:46:33 +0200 Subject: [PATCH 4/4] fix(core): top-level resources cannot use long logical ids (#6419) * fix(core): top-level resources cannot use long logical ids When allocating a logical ID for a CloudFormation resource we normally render the id by taking in the full construct path, trim it down to 240 characters and append a hash to it to make sure its unique. This technically allows construct IDs to be of any length. However, when a resource is defined as a top-level resource (i.e. the child of a stack), it won't get this treatment and we will just use the construct id as it's logical id. In this case, synthesis will fail with an error indicating the logical ID is invalid (too long). This might work ok if the logical ID is human provided, but in some cases (like #6190), the id is actually created automatically and in some cases can be longer than 255. This fix will only apply the special treatment to top-level resources if their construct ID is shorter than the maximum allowed length (255). Otherwise, it will use the same mechanism to trim them and append the hash. Fixes #6190. * check length after trimming non-alpha Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- packages/@aws-cdk/core/lib/private/uniqueid.ts | 14 +++++++++++++- packages/@aws-cdk/core/test/test.logical-id.ts | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/@aws-cdk/core/lib/private/uniqueid.ts b/packages/@aws-cdk/core/lib/private/uniqueid.ts index ef0798d0e2da7..be04dcf7cf0ed 100644 --- a/packages/@aws-cdk/core/lib/private/uniqueid.ts +++ b/packages/@aws-cdk/core/lib/private/uniqueid.ts @@ -19,6 +19,7 @@ const PATH_SEP = '/'; const HASH_LEN = 8; const MAX_HUMAN_LEN = 240; // max ID len is 255 +const MAX_ID_LEN = 255; /** * Calculates a unique ID for a set of textual components. @@ -46,7 +47,18 @@ export function makeUniqueId(components: string[]) { // transparent migration of cloudformation templates to the CDK without the // need to rename all resources. if (components.length === 1) { - return removeNonAlphanumeric(components[0]); + // we filter out non-alpha characters but that is actually a bad idea + // because it could create conflicts ("A-B" and "AB" will render the same + // logical ID). sadly, changing it in the 1.x version line is impossible + // because it will be a breaking change. we should consider for v2.0. + // https://github.com/aws/aws-cdk/issues/6421 + const candidate = removeNonAlphanumeric(components[0]); + + // if our candidate is short enough, use it as is. otherwise, fall back to + // the normal mode. + if (candidate.length <= MAX_ID_LEN) { + return candidate; + } } const hash = pathHash(components); diff --git a/packages/@aws-cdk/core/test/test.logical-id.ts b/packages/@aws-cdk/core/test/test.logical-id.ts index 25a6219ee86a8..b2f9a096f347a 100644 --- a/packages/@aws-cdk/core/test/test.logical-id.ts +++ b/packages/@aws-cdk/core/test/test.logical-id.ts @@ -28,13 +28,29 @@ export = { // WHEN const r = new CfnResource(stack, 'MyAwesomeness', { type: 'Resource' }); + const r2 = new CfnResource(stack, 'x'.repeat(255), { type: 'Resource' }); // max length + const r3 = new CfnResource(stack, '*y-'.repeat(255), { type: 'Resource' }); // non-alpha are filtered out (yes, I know it might conflict) // THEN test.equal(stack.resolve(r.logicalId), 'MyAwesomeness'); + test.equal(stack.resolve(r2.logicalId), 'x'.repeat(255)); + test.equal(stack.resolve(r3.logicalId), 'y'.repeat(255)); test.done(); }, + 'if resource is top-level and logical id is longer than allowed, it is trimmed with a hash'(test: Test) { + // GIVEN + const stack = new Stack(undefined, 'TestStack'); + + // WHEN + const r = new CfnResource(stack, 'x'.repeat(256), { type: 'Resource' }); + + // THEN + test.equals(stack.resolve(r.logicalId), 'x'.repeat(240) + `C7A139A2`); + test.done(); + }, + 'Logical IDs can be renamed at the stack level'(test: Test) { // GIVEN const stack = new Stack();