Dynamically Configure Your Lambda@Edge Functions
Tags: aws, serverless, lambda, edge
Published: 2021-06-06 22:24:54

Lambda@Edge is the thing I always find something new to gripe about each time I come back to it. Almost every time the core of my issue is the lack of any ability to perform dynamic configuration of that running code.

This is a situation that I find the AWS blog doesn't help with. Go look and you will find all sorts of [examples](https://aws.amazon.com/blogs/networking-and-content-delivery/dynamically-route-viewer-requests-to-any-origin-using-lambdaedge/) where the configuration and/or secrets are hard coded in their functions.

I don't consider this to be best practice at all. My serverless app should be able to be redeployed without branching to commit different resource ARNs, or customizing the build process to inject values at deployment. I need to be able to have my template - the definition of my app in CloudFormation - to be able to determine all of this using the standard tooling AWS provides. 

## Limitations

There are two sets of [event types](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-cloudfront-trigger-events.html) for Lambda@Edge functions with CloudFront: `viewer-request`, `origin-request`, `origin-response`, and `viewer-response`. I wrote them in the order they occur during the client request lifecycle. As the names imply, `viewer-` event occur on the client's side of the CloudFront distribution, while `origin-` events occur the origin/source's side.

For Lambda@Edge, the triggering defines where our limitations are going to be. `origin-` events allow the most freedom. We can set function memory as high as we want, the timeout can be a full 30 seconds (same as an API Gateway event source), and the size of the function code can be up to 50 MB.

Switching to `viewer-` events severely restricts our resources. Function memory can only be the default 128 MB, our timeout can not exceed 5 seconds, and the function code cannot be more than 1 MB.

Beyond this, both event types allow us network access. As long as our functions work without the bounds set on them, we are able to leverage AWS (or other) services in our code.

For any other serverless app we would rely on environment variables to determine how we configure our code at runtime. Things like ARNs of required resources, paths or locations to read in needed secrets, all the usual suspects. With Lambda@Edge we not allow to use environment variables. At all. Period.

## Origin-Request Example

For `origin-request` event functions there is a trick we have available: _origin custom headers_. These are a configuration on the origin for our CloudFront distribution, and we can dynamically set these values in our CloudFormation template. Essentially, the custom headers will become our missing environment variables.

Here's an example of one I implemented once. The use case here is that my distribution serves content from a variety of S3 buckets in numerous AWS regions. I have an API, deployed in all those regions, that can take a file identifier and then return the key name and bucket domain of where it is located. I need the Lambda@Edge function to take the file from the request, look it up in the API, and then set the origin for the request to serve it. My implementation is a variation on [this AWS blog post](https://aws.amazon.com/blogs/networking-and-content-delivery/dynamically-route-viewer-requests-to-any-origin-using-lambdaedge/).

In this example, my API uses a Cognito User Pool for authentication. I created an app client specifically for the CloudFront distribution's use, and it can [obtain access tokens](https://docs.aws.amazon.com/cognito/latest/developerguide/token-endpoint.html) uses `client credentials` flow.

I'm going to skip most of the template as the important piece is how we configure the default origin of the CloudFront distribution.

```yaml
Origins:
  - DomainName: !GetAtt DefaultBucket.DomainName # See comment below
    Id: default-origin
    OriginCustomHeaders:
      - HeaderName:api-domain
        HeaderValue: !Ref ApiDomainName # Template parameter
      - HeaderName: cognito-domain
        HeaderValue: !Ref UserPoolDomain # Template parameter
      - HeaderName: client-id
        HeaderValue: !Ref DistributionClientId
      - HeaderName: client-secret
        HeaderValue: !Ref DistributionClientSecret
    S3OriginConfig:
      OriginAccessIdentity: !Sub origin-access-identity/cloudfront/${CloudFrontOAI} # Template parameter
```

In this template I do have a `DefaultBucket` S3 bucket resource to use with this. You don't have to do that, but the `DomainName` attribute must be set. In any event, this never gets used as our function is routing to another origin or returning 404 responses.

This isn't without drawbacks. We _are_ exposing the client credentials to CloudFront (though they are also visible in the Cognito console). The client that made the original request will never see these headers, and because they're injected by CloudFront after receiving the request. Our Lambda@Edge function processes the event before the origin, and we have the option of removing these headers as we modify the event and pass it back to CloudFormation (thus preventing exposed to the origin).

Consider an adaptations of this pattern where you pass SSM or Secrets Manager paths that the code can then read at runtime and cache for successive invocations.

## Viewer Request Example

The next example I'm going to show really applies to the remaining three event types (custom headers apply _only_ to `origin-request` events), but my example is specifically around implementing HTTP basic auth for resources behind my CloudFront distribution.

Here, my plan was to have the function perform a lookup in a DynamoDB table for the user that it would perform the comparison against and allow or deny access. My challenge is to 

I'm not entirely sure how I was struck by this inspiration, but it occurred to me one day that the Lambda@Edge function does indeed already know exactly what it needs to talk to. That information is in the IAM role I assigned to it.

This solution can be distilled down very simply to granting the function the ability to read its own IAM role and then extract the DynamoDB ARN to configure the `boto3` client. It _feels wrong_, but _**it works**_, and it works within the 128 MB memory and 5 second timeout limits imposed by this event type (including cold start invocations).

Let's look at the fuction's definition in my CloudFormation/SAM template first.

```yaml
  DistributionAuthorizer:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: python3.8
      Handler: index.lambda_handler
      CodeUri: ./src/distribution/authorizer
      Role: !GetAtt DistributionAuthorizerRole.Arn
      MemorySize: 128 # Max for viewer-request
      Timeout: 5 # Max for viewer-request
      AutoPublishAlias: live

  DistributionAuthorizerRole:
    Type: AWS::IAM::Role
    Properties:
      Path: "/"
      ManagedPolicyArns:
          - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Action:
              - sts:AssumeRole
            Principal:
              Service:
                - lambda.amazonaws.com
                - edgelambda.amazonaws.com
      Policies:
        - PolicyName: root
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action: iam:GetRole
                Resource: !Sub arn:aws:iam::${AWS::AccountId}:role/${AWS::StackName}-DistributionAuthorizer-*
              - Effect: Allow
                Action: dynamodb:GetItem
                Resource: !GetAtt ServiceTable.Arn
```

All very standard for a Lambda@Edge function, but I have added a new permission for this: `iam:GetRole`. Because we have a chicken-and-the-egg scenario unfolding where I am grating a permission to read the IAM role that the permission is defined within, I'm taking advantage of how CloudFormation names resources ( _{stack name}-{logical ID}-{random suffix}_ ) to be sure I've targeted down the resource as best as I can without having to name it (it is best practice to _not_ name your resources and let CloudFormation do it for you).

To make this happen in code, I need to use STS `GetCallerIdentity` to find out the name of the IAM role used for the credentials my function was provided, then use IAM `GetRolePolicy` to read the policy of the role, and then after parsing it I can find the ARN of the DynamoDB table and configure my client.

Here's the code.

```python
import boto3


session = boto3.Session()
iam_client = session.client("iam")
sts_client = session.client("sts")

ROLE_NAME = sts_client.get_caller_identity()["Arn"].split("/")[-2]
ROLE_POLICY = iam_client.get_role_policy(RoleName=ROLE_NAME, PolicyName="root")[
    "PolicyDocument"
]

for arn in ROLE_POLICY["Statement"][0]["Resource"]:
    if arn.startswith("arn:aws:dynamodb:"):
        arn_parts = arn.split("/")
        TABLE_NAME = arn_parts[-1]
        TABLE_REGION = arn_parts[0].split(":")[3]

dynamodb_table = session.resource("dynamodb", region_name=TABLE_REGION).Table(
    TABLE_NAME
)


def lambda_handler(event, context):
    result = dynamodb_table.get_item(Key={"pk": "U#username", "sk": "A"})
    # Basic auth code will go here. L@E function always returns a 401 response for now.

    return {
        "status": "401",
        "statusDescription": "Unauthorized",
        "body": "Unauthorized",
        "headers": {
            "www-authenticate": [{"key": "WWW-Authenticate", "value": "Basic"}]
        },
    }
```

This is an incomplete HTTP basic authorizer as it isn't parsing the authorization header to get the username to perform the lookup, but it does demonstrate all the working pieces of configuring `boto3` by referencing the IAM role policy and then performing a lookup against the table.

The runtime results, despite the number of AWS API calls being made (across three services) are encouraging:

```text
# Cold Start
Duration: 171.38 ms	Billed Duration: 172 ms	Memory Size: 128 MB	Max Memory Used: 80 MB	Init Duration: 519.87 ms

# Warm Executions
Duration: 24.46 ms	Billed Duration: 25 ms	Memory Size: 128 MB	Max Memory Used: 80 MB
Duration: 43.25 ms	Billed Duration: 44 ms	Memory Size: 128 MB	Max Memory Used: 80 MB
Duration: 25.76 ms	Billed Duration: 26 ms	Memory Size: 128 MB	Max Memory Used: 80 MB
Duration: 30.47 ms	Billed Duration: 31 ms	Memory Size: 128 MB	Max Memory Used: 80 MB
```

Well within limits. It works.

It's not really _good_, through, is it?

## We Shouldn't Have To Do This

All of the above might sound very clever, and if it does that should tell us that it shouldn't have to be this way. My issue with Lambda@Edge is I don't feel it's developer friendly. Look at the mental hoops I've gone through to figure out how to configure my Lambda@Edge functions the way I would a normal Lambda function. There's a massive ripple effect that comes from not having environmental configuration built in.

I've reached out on this topic before, and I've never received a really good answer on the "how" to do it. If anyone from AWS's CloudFront, Lambda@Edge, CloudFormation, and/or SAM teams are stumbling across this; I ask you to reflect on the what many other customers must also have to contend with for something that should be simple, straightforward, and is easy for developers to do.