Repository: serverless/serverless (JavaScript)
Date: 2020-02-12 16:46:03+00:00
Task type: Feature

Issue:
Allow omitting the AWS::Lambda::Permission for cross-account authorizers
# Feature Proposal

Support skipping the creation of an `AWS::Lambda::Permission` resource for cross-account authorizer functions.

## Description

### Use case

When you specify an authorizer ARN for a function, the AWS provider for `serverless` currently generates an `AWS::Lambda::Permission` resource in the CloudFormation Template.

If the authorizer is [in another account](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-lambda-authorizer-cross-account-lambda-authorizer.html), creating the `AWS::Lambda::Permission` resource fails because the stack doesn't have permissions to modify Lambda permissions across accounts.

It would be good to be able to suppress generating this resource when the authorizer is a cross-account Lambda authorizer.

### Proposed config

```
functions:
  sample:
    ...
    events:
      - http:
          ...
          authorizer:
            name: authorizer
            arn: arn:aws:lambda:REGION:ACCOUNT:function:FUNCTION_NAME
            crossAccount: true
```

When `event.authorizer.crossAccount` is true, skip generating the `AWS::Lambda::Permission` resource.

## Related code

https://github.com/serverless/serverless/blob/b9c8049455b4f48ce9247edeb6561e5c302373ae/lib/plugins/aws/package/compile/events/apiGateway/lib/permissions.js#L60


Issue discussion:
@glb thanks for request. Code you point sets necessary permissions for API Gateway (so it can invoke needed lambdas).

How can API Gateway trigger the authorizer lambda from different account? Does it work, by ensuring necessary permissions on that lambda, but in scope of that different account?
@medikoo see the [AWS docs for cross-account authorizers](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-lambda-authorizer-cross-account-lambda-authorizer.html) ... API Gateway checks that the permission already exists on the target lambda as part of the authorizer creation checks.

Because it's not possible for the "local" cloudformation stack to create the permission, using cross-account authorizers requires the owner of the lambda to add the permission separately. This can be managed through code (for example by creating an `AWS::Lambda::Permission` that authorizes API Gateway with a `SourceArn` that references the source account), but must be done in a stack in the account where the lambda lives.

You can see the current behaviour by attempting to create a stack with an authorizer lambda in a separate account as shown above; creating the `AWS::Lambda::Permission` resource in the local stack will fail.

Solution patch:
diff --git a/docs/providers/aws/events/apigateway.md b/docs/providers/aws/events/apigateway.md
index 0e1925b1e9a..cef6ea35b12 100644
--- a/docs/providers/aws/events/apigateway.md
+++ b/docs/providers/aws/events/apigateway.md
@@ -444,11 +444,32 @@ functions:
           method: post
           authorizer:
             arn: xxx:xxx:Lambda-Name
+            managedExternally: false
             resultTtlInSeconds: 0
             identitySource: method.request.header.Authorization
             identityValidationExpression: someRegex
 ```
 
+If permissions for the Authorizer function are managed externally (for example, if the Authorizer function exists
+in a different AWS account), you can skip creating the permission for the function by setting `managedExternally: true`,
+as shown in the following example:
+
+```yml
+functions:
+  create:
+    handler: posts.create
+    events:
+      - http:
+          path: posts/create
+          method: post
+          authorizer:
+            arn: xxx:xxx:Lambda-Name
+            managedExternally: true
+```
+
+**IMPORTANT NOTE**: The permission allowing the authorizer function to be called by API Gateway must exist
+before deploying the stack, otherwise deployment will fail.
+
 You can also use the Request Type Authorizer by setting the `type` property. In this case, your `identitySource` could contain multiple entries for your policy cache. The default `type` is 'token'.
 
 ```yml
diff --git a/lib/plugins/aws/package/compile/events/apiGateway/lib/permissions.js b/lib/plugins/aws/package/compile/events/apiGateway/lib/permissions.js
index 1c423c2b171..d2a59474ab8 100644
--- a/lib/plugins/aws/package/compile/events/apiGateway/lib/permissions.js
+++ b/lib/plugins/aws/package/compile/events/apiGateway/lib/permissions.js
@@ -57,6 +57,9 @@ module.exports = {
           }
 
           if (cfResources[authorizerPermissionLogicalId]) return;
+
+          if (authorizer.managedExternally) return;
+
           Object.assign(cfResources, {
             [authorizerPermissionLogicalId]: {
               Type: 'AWS::Lambda::Permission',
diff --git a/lib/plugins/aws/package/compile/events/apiGateway/lib/validate.js b/lib/plugins/aws/package/compile/events/apiGateway/lib/validate.js
index 36436772f76..f421ba74d1f 100644
--- a/lib/plugins/aws/package/compile/events/apiGateway/lib/validate.js
+++ b/lib/plugins/aws/package/compile/events/apiGateway/lib/validate.js
@@ -225,6 +225,7 @@ module.exports = {
     let type;
     let name;
     let arn;
+    let managedExternally;
     let identitySource;
     let resultTtlInSeconds;
     let identityValidationExpression;
@@ -273,6 +274,18 @@ module.exports = {
 
       identitySource = authorizer.identitySource;
       identityValidationExpression = authorizer.identityValidationExpression;
+
+      if (typeof authorizer.managedExternally === 'undefined') {
+        managedExternally = false;
+      } else if (typeof authorizer.managedExternally === 'boolean') {
+        managedExternally = authorizer.managedExternally;
+      } else {
+        const errorMessage = [
+          `managedExternally property in authorizer for function ${functionName} is not boolean.`,
+          ' Please check the docs for more info.',
+        ].join('');
+        throw new this.serverless.classes.Error(errorMessage);
+      }
     } else {
       const errorMessage = [
         `authorizer property in function ${functionName} is not an object nor a string.`,
@@ -283,6 +296,10 @@ module.exports = {
       throw new this.serverless.classes.Error(errorMessage);
     }
 
+    if (typeof managedExternally === 'undefined') {
+      managedExternally = false;
+    }
+
     if (typeof identitySource === 'undefined') {
       identitySource = 'method.request.header.Authorization';
     }
@@ -305,6 +322,7 @@ module.exports = {
       type,
       name,
       arn,
+      managedExternally,
       authorizerId,
       resultTtlInSeconds,
       identitySource,


Test patch:
diff --git a/lib/plugins/aws/package/compile/events/apiGateway/lib/permissions.test.js b/lib/plugins/aws/package/compile/events/apiGateway/lib/permissions.test.js
index ce7aa18daa5..360656e870d 100644
--- a/lib/plugins/aws/package/compile/events/apiGateway/lib/permissions.test.js
+++ b/lib/plugins/aws/package/compile/events/apiGateway/lib/permissions.test.js
@@ -324,4 +324,69 @@ describe('#awsCompilePermissions()', () => {
       ).to.deep.equal({});
     });
   });
+
+  it('should not create permission resources when the authorizer is managed externally', () => {
+    const event = {
+      functionName: 'First',
+      http: {
+        authorizer: {
+          name: 'authorizer',
+          arn: { 'Fn::GetAtt': ['AuthorizerLambdaFunction', 'Arn'] },
+          managedExternally: true,
+        },
+        path: 'foo/bar',
+        method: 'post',
+      },
+    };
+
+    awsCompileApigEvents.validated.events = [event];
+    awsCompileApigEvents.apiGatewayRestApiLogicalId = 'ApiGatewayRestApi';
+    awsCompileApigEvents.permissionMapping = [
+      {
+        lambdaLogicalId: 'FirstLambdaFunction',
+        resourceName: 'FooBar',
+        event,
+      },
+    ];
+
+    // the important thing in this object is that it does *not* contain
+    // a permission allowing API Gateway to call the authorizer. If
+    // managedExternally was false (as it is in other tests), then the
+    // permission would be created.
+    const deepObj = {
+      FirstLambdaPermissionApiGateway: {
+        DependsOn: undefined,
+        Properties: {
+          Action: 'lambda:InvokeFunction',
+          FunctionName: {
+            'Fn::GetAtt': ['FirstLambdaFunction', 'Arn'],
+          },
+          Principal: 'apigateway.amazonaws.com',
+          SourceArn: {
+            'Fn::Join': [
+              '',
+              [
+                'arn:',
+                { Ref: 'AWS::Partition' },
+                ':execute-api:',
+                { Ref: 'AWS::Region' },
+                ':',
+                { Ref: 'AWS::AccountId' },
+                ':',
+                { Ref: 'ApiGatewayRestApi' },
+                '/*/*',
+              ],
+            ],
+          },
+        },
+        Type: 'AWS::Lambda::Permission',
+      },
+    };
+
+    return awsCompileApigEvents.compilePermissions().then(() => {
+      expect(
+        awsCompileApigEvents.serverless.service.provider.compiledCloudFormationTemplate.Resources
+      ).to.deep.equal(deepObj);
+    });
+  });
 });
diff --git a/lib/plugins/aws/package/compile/events/apiGateway/lib/validate.test.js b/lib/plugins/aws/package/compile/events/apiGateway/lib/validate.test.js
index b5be8d52fe1..b7969863843 100644
--- a/lib/plugins/aws/package/compile/events/apiGateway/lib/validate.test.js
+++ b/lib/plugins/aws/package/compile/events/apiGateway/lib/validate.test.js
@@ -449,6 +449,7 @@ describe('#validate()', () => {
     const authorizer = validated.events[0].http.authorizer;
     expect(authorizer.resultTtlInSeconds).to.equal(300);
     expect(authorizer.identitySource).to.equal('method.request.header.Authorization');
+    expect(authorizer.managedExternally).to.equal(false);
   });
 
   it('should accept authorizer config', () => {
@@ -465,6 +466,7 @@ describe('#validate()', () => {
                 resultTtlInSeconds: 500,
                 identitySource: 'method.request.header.Custom',
                 identityValidationExpression: 'foo',
+                managedExternally: true,
               },
             },
           },
@@ -477,6 +479,7 @@ describe('#validate()', () => {
     expect(authorizer.resultTtlInSeconds).to.equal(500);
     expect(authorizer.identitySource).to.equal('method.request.header.Custom');
     expect(authorizer.identityValidationExpression).to.equal('foo');
+    expect(authorizer.managedExternally).to.equal(true);
   });
 
   it('should accept authorizer config with a type', () => {
@@ -534,6 +537,31 @@ describe('#validate()', () => {
     expect(authorizer.identityValidationExpression).to.equal('foo');
   });
 
+  it('should throw an error if authorizer "managedExternally" exists and is not a boolean', () => {
+    awsCompileApigEvents.serverless.service.functions = {
+      foo: {},
+      first: {
+        events: [
+          {
+            http: {
+              method: 'GET',
+              path: 'foo/bar',
+              authorizer: {
+                name: 'foo',
+                resultTtlInSeconds: 0,
+                identitySource: 'method.request.header.Custom',
+                identityValidationExpression: 'foo',
+                managedExternally: 'not a boolean',
+              },
+            },
+          },
+        ],
+      },
+    };
+
+    expect(() => awsCompileApigEvents.validate()).to.throw(Error, 'managedExternally property');
+  });
+
   it('should throw an error if "origin" and "origins" CORS config is used', () => {
     awsCompileApigEvents.serverless.service.functions = {
       first: {