Everybody has opinions on how AWS accounts should be used.
Some people think you should have a mono-account and store everything in your AWS ecosystem in one place.
Others believe an individual application (composed of multiple microservices) belong in a single AWS account.
And others take it to the extreme and keep a single microservice in an AWS account.
None of these approaches are inadvertently wrong (except you might run into some resource limitations with a mono-account), but they all run into the same problem:
How do you maintain consistent authorization across accounts?
If you use a custom Lambda authorizer, the answer is both simple and not-so-simple.
What is a Lambda Authorizer?
Chances are, if you chose to read this article you already know what a Lambda authorizer is.
But as a light refresher, a Lambda authorizer is an API Gateway feature that uses a Lambda function to perform authorization for calls into your API.
It can authenticate an OAuth or SAML token, apply some business logic to determine access, and anything in between.
For serverless applications, it can be useful to have a Lambda authorizer sit on top of your API gateway to help fine-tune control to your individual endpoints.
Defining a Lambda Authorizer in SAM
The Serverless Application Model (SAM) is a layer on top of CloudFormation designed to make the definition of serverless applications simple and easy.
For easiest consumability and portability, all the examples in this post will be defined in a SAM template so you may take what you learn and immediately deploy it in your own AWS account. No need to fumble through the console.
A Lambda authorizer is just a function. There's nothing special to how it is declared. It just expects a different event body than a Lambda proxied by API Gateway.
In my example repo, we define a Lambda authorizer like this:
LambdaAuthorizerCrossAccountFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: lambdas/lambda-authorizer
Runtime: nodejs12.x
Handler: lambda-authorizer.lambdaHandler
Role: !GetAtt LambdaAuthorizerRole.Arn
FunctionName: LambdaAuthorizer
The example repo has the SAM template, but not the code to implement the authorizer. For examples on building the authorizer itself, AWS has blueprints on GitHub.
With the authorizer function defined, the next step is to enable permissions for other accounts to use it.
Building Authorizer Permissions
Cross-account permissions can be tricky with AWS. Permissions need to be defined in the source account to allow consumers to access their resources. This means that you must know your consumers ahead of time (which is honestly a good thing).
With Lambda authorizers, permissions are straight forward. You must give API Gateway in the consumer accounts permission to execute the authorizer function.
Diagram of how the Lambda authorizer is consumed by API Gateway in other accounts
In our SAM template, the permission needed is defined as:
ConsumerOneAuthorizerPermission:
Type: AWS::Lambda::Permission
Properties:
Action: lambda:InvokeFunction
FunctionName: !Ref LambdaAuthorizerCrossAccountFunction
Principal: apigateway.amazonaws.com
SourceArn: !Sub arn:${AWS::Partition}:execute-api:${AWS::Region}:${ConsumerOneAccountId}:*/authorizers/*
This snippet is saying API Gateway, the principal, is allowed to invoke a function with a specific name. The SourceArn is stating which resource is allowed to execute the function. I have parameterized the account id along with the AWS partition (commercial or GovCloud) and region.
Each consuming account will need a permission giving it access to execute the authorizer. Since we're practicing POLP, this seems like a worthy tradeoff since we will have to maintain this over time as we bring on new accounts to the organization.
Consuming The Authorizer In Another Account
This is where the fun starts. With the authorizer built and permissions created, we can set up our consuming accounts to start using it.
In the SAM template of one of our consuming accounts, the serverless API and authorizer are defined with the following snippet:
ConsumerServiceApi:
Type: AWS::Serverless::Api
Properties:
StageName: test
Auth:
DefaultAuthorizer: LambdaAuthorizer
AddDefaultAuthorizerToCorsPreflight: false
Authorizers:
LambdaAuthorizer:
FunctionPayloadType: REQUEST
FunctionArn: !Sub arn:${AWS::Partition}:lambda:${AWS::Region}:${AuthorizerAccountId}:function:LambdaAuthorizer
Identity:
Headers:
- Authorization
ReauthorizeEvery: 3600
The major piece to note here is the FunctionArn
we are composing. For security, we store the AuthorizerAccountId
in a parameter and pass it in at deploy time so account ids are not in the source code.
This is similar to what was done above when we were defining the authorizer itself. It assembles the arn and uses that to point to the resource we want.
When this template is deployed (see deployment instructions) a Lambda authorizer will be created in the consumer account and the function it uses lives in the authorizer account.
This brings me to the final piece: a workaround for a defect in SAM.
Fixing the Glitch
A SAM template is just a fancy CloudFormation script that has a bunch of aliases that are transformed at deploy time. SAM provides nice, easy shortcuts to create serverless functions and triggers to said functions.
On deploy time, a Transform
is called that is maintained by AWS to take the shortcuts written in the spec and turn them into full CloudFormation resources. It's a much easier way to write infrastructure as code.
Unfortunately, there is a bug in this transform that prevents us from consuming a Lambda authorizer in a different account.
SAM tries to configure a lambda permission from the authorizer in the consumer account to the function in the authorizer account. Due to how IAM works, setting up permissions that direction doesn't work.
Permissions to cross-account resources must be declared in the account where the resource lives
SAM tries to be nice and configures the permission needed for the authorizer to invoke the function. But it doesn't know that function lives in another account, so the permission it creates will fail the deployment.
To get around this issue, we can create a CloudFormation macro to remove the permission automatically.
Macros are functions that run on deployment to transform resources for you automatically. In this case, we want to remove a permission resource generated by SAM.
I created a macro in NodeJS adapted exactly from the example in the reported GitHub issue and added it to the prerequisite
stack in the example repo.
Once you create and deploy the macro, all stacks that consume the cross account authorizer must be updated to use it.
In your SAM template, there is a Transform
property that already calls on the AWS SAM macro. To add the Lambda authorizer macro, we can make this property an array and it will begin to run on deployment.
AWSTemplateFormatVersion: '2010-09-09'
Transform: [ AWS::Serverless-2016-10-31, RemoveAuthorizerLambdaPermissions ]
Description: >
Consumer Stack. This AWS Account will consume the authorizer created in the *authorizer stack*
With the macro in place, the consumer stack will deploy and you have yourself a cross-account Lambda authorizer!
Final Thoughts
Reusing code and resources is key to maintainability at scale. Try to reuse what you can whenever it makes sense. Authorization/Authentication are perfect examples of reusability that also provides a cohesive feel to your applications.
Everybody wants to reinvent the wheel and build it themselves. That's the nature of developers. Getting to the point where you are consuming code, resources, APIs, and other features across your organization takes governance, it isn't just a technical problem.
Removing the technical hurdles is step one. Step two is putting it in action.
I have shown you step one for maintaining consistent Auth in your software ecosystem.
Now it's your turn.
Top comments (2)
This is, honestly, fantastic! I had never thought of centralizing the authorizer function - but it makes the system more secure, and far simpler to develop with!
Thank you! I just went through this whole process and it's an absolute game changer :)