AWS Open Source Blog

Creating a custom Lambda authorizer using Open Policy Agent

Organizations have complex infrastructure and need common tooling to make decisions about the system as a whole. In such scenarios, policy-based decision making could be implemented using Open Policy Agent (OPA). OPA is an open source, general-purpose policy engine, which decouples policy decision-making from policy enforcement.

When a web-based application needs to make a policy decision, attributes can be passed to OPA as structured data (for example, JSON). OPA makes policy decisions by evaluating the input against policies and context data. For OPA, policies are written using a high-level declarative language, called Rego. OPA provides several ways to integrate with microservices, Kubernetes, CI/CD pipelines, API gateways, and more. For more information on Open Policy Agent, visit the project website.

illustration showing When a web-based application needs to make a policy decision, attributes are passed to OPA as structured data (for example, JSON). OPA makes policy decisions by evaluating the input against policies and context data. For OPA, policies are written using a high-level declarative language, called Rego. OPA provides several ways to integrate with microservices, Kubernetes, CI/CD pipelines, API gateways, and more.

Building Custom Lambda authorizer using OPA

An AWS Lambda authorizer is an Amazon API Gateway feature that uses a Lambda function to control access to an API. In this post, we will show how to build a custom OPA Lambda authorizer to control access to your API. We will build a sample request parameter-based OPA Lambda authorizer that receives the caller’s identity in a combination of headers and converts them as structured context data for OPA to make a policy decision and authorize your API call.

architecture for sample request parameter-based OPA Lambda authorizer that receives the identity of the caller in a combination of headers and converts them as structured context data for OPA to make a policy decision and authorize your API call

  1. Users will access the API.
  2. Amazon API Gateway will call the custom OPA Lambda authorizer.
  3. OPA Lambda authorizer evaluates the policy with the context data and will return an IAM policy object.
  4. API Gateway will enforce the response.

Getting started

To build the architecture described in the preceding list, we use the AWS Cloud Development Kit (AWS CDK).

Prerequisites

Project setup and code walkthrough

Creating a CDK project

Run the following commands to initialize an empty AWS CDK project for TypeScript.

mkdir cdk-opa-blog
cd cdk-opa-blog
cdk init app --language=typescript

Creating helloworld_function Lambda function

First, we will create a Python Lambda function using the following steps, which will be integrated to API Gateway. This will be the API to which we want to authorize the call.

  • Create a directory helloworld_function in the root of the cdk-opa-blog directory.
  • Create a file called app.py inside helloworld_function directory.
  • Copy and paste the following code.
    import json
    def lambda_handler(event, context):
        return {
            "statusCode": 200,
            "body": json.dumps({
                "message": "Hello, You are authorized using Open Policy Agent Lambda Authorizer",
            }),
            "headers": {'Access-Control-Allow-Origin': '*'}
        }

Creating opaCustomGoAuthorizer Lambda function

Next, we will set up the context data that OPA will use to make an authorization decision.

  • Create a directory called opaCustomGoAuthorizer in the root of the cdk-opa-blog directory.
  • Create a directory called data inside the opaCustomGoAuthorizer directory.
  • Create a file called data.json inside the data directory and paste the following code.
    {
      "GroupPermissions":{ 
        "record1":["ViewerGroup","AdminGroup","Guest"],
        "record2": ["ViewerGroup","AdminGroup"],
        "record_secret": ["AdminGroup"]
      }
    }

With this JSON file, we are providing the contextual information to OPA that the resource record1 can be accessed by any user who is a member of either ViewerGroup, AdminGroup, or Guest. The record2 is accessible to the ViewerGroup and AdminGroup. The record_secret is restricted to be used only by users in the AdminGroup.

Next, we will add an OPA policy expressed in Rego.

Create a file called policies.rego inside the data directory and paste the following code. This policy denies any request by default; it will allow access if the user’s group matches any of the groups to which the requested record belongs.

package opablog
default allow=false
allow=true{
input.Usergroup == data.GroupPermissions[input.Resource][_]
}

Next, we will write the custom Lambda authorizer in Golang that will query the OPA policy. Follow these steps:

  • Create a file called main.go inside the opaCustomGoAuthorizer directory.
  • Copy and paste the following Go code. This custom Lambda authorizer loads the context data OPA would need to make a decision. This function expects two request headers: usergroup and resource. The values of these headers are passed to OPA and a query is made to the allow rule inside the opablog package written in policies.rego in the previous step. If the result of the query evaluation is true, an IAM policy is returned by the custom authorizer to allow access to the "HelloAPI", else an "Unauthorized" error is returned.
    package main
    
    import (
        "context"
        "errors"
        "fmt"
        "github.com/aws/aws-lambda-go/events"
        "github.com/aws/aws-lambda-go/lambda"
        "github.com/open-policy-agent/opa/ast"
        "github.com/open-policy-agent/opa/loader"
        "github.com/open-policy-agent/opa/rego"
        "github.com/open-policy-agent/opa/storage"
        "log"
        "time"
    )
    
    var (
        err      error
        compiler *ast.Compiler
        store    storage.Store
        ctx      = context.Background()
    )
    
    func init() {
    
        policyData, err := loader.All([]string{"data"})
        if err != nil {
            log.Fatalf("Failed to load bundle from disk: %v", err)
        }
    
        // Compile the module. The keys are used as identifiers in error messages.
        compiler, err = policyData.Compiler()
        if err != nil {
            log.Fatalf("Failed to compile policies in bundle: %v", err)
        }
    
        store, err = policyData.Store()
        if err != nil {
            log.Fatalf("Failed to create storage from bundle: %v", err)
        }
    }
    
    func handler(request events.APIGatewayCustomAuthorizerRequestTypeRequest) (events.APIGatewayCustomAuthorizerResponse, error) {
    
        usergroup := request.Headers["usergroup"]
        resource := request.Headers["resource"]
    
        fmt.Println("usergroup is = ", usergroup)
        fmt.Println("resource is = ", resource)
    
        // Run evaluation.
        start := time.Now()
        // Create a new query that uses the compiled policy from above.
        rego := rego.New(
            rego.Query("data.opablog.allow"),
            rego.Compiler(compiler),
            rego.Store(store),
            rego.Input(
                map[string]interface{}{
                    "Usergroup": usergroup,
                    "Resource":  resource,
                }),
        )
    
        elapsed := time.Since(start)
        fmt.Println("Query initiation  took ", elapsed)
        start_eval := time.Now()
        // Run evaluation.
        rs, err := rego.Eval(ctx)
        elapsed_eval := time.Since(start_eval)
        fmt.Println("Evaluation  took ", elapsed_eval)
    
        if err != nil {
            // Handle error.
        }
    
        fmt.Println("Result of query evaluation is = ", rs[0].Expressions[0].Value)
        if rs[0].Expressions[0].Value == true {
            return generatePolicy("user", "Allow", request.MethodArn), nil
        } else {
            return events.APIGatewayCustomAuthorizerResponse{}, errors.New("Unauthorized")
        }
    
    }
    
    func main() {
        lambda.Start(handler)
    }
    
    func generatePolicy(principalID, effect, resource string) events.APIGatewayCustomAuthorizerResponse {
        authResponse := events.APIGatewayCustomAuthorizerResponse{PrincipalID: principalID}
    
        if effect != "" && resource != "" {
            authResponse.PolicyDocument = events.APIGatewayCustomAuthorizerPolicy{
                Version: "2012-10-17",
                Statement: []events.IAMPolicyStatement{
                    {
                        Action:   []string{"execute-api:Invoke"},
                        Effect:   effect,
                        Resource: []string{resource},
                    },
                },
            }
        }
    
        return authResponse
    }

With this, the API and custom OPA Lambda authorizer code is complete. Next, we’ll prepare to build and deploy this solution so that we can test it.

Updating CDK project to create the infrastructure

For this, we will first describe the stack that we need to create. This TypeScript code creates all the cloud application resources, such as the REST API, Lambda functions, and authorizer, along with the required integrations. Follow these steps:

  • Navigate to lib/cdk-opa-blog-stack.ts.
  • Copy and paste the following code.
    import * as cdk from '@aws-cdk/core';
    import apigateway = require("@aws-cdk/aws-apigateway");
    import lambda = require("@aws-cdk/aws-lambda");
    
    
    export class CdkOpaBlogStack extends cdk.Stack {
      constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
          super(scope, id, props);
              const api = new apigateway.RestApi(this, "HelloApi", {
                restApiName: "HelloApi",
                description: "Hello World API"
              });
    
            const opaCustomAuthLambda = new lambda.Function(this, "OpaAuthorizer", {
              runtime: lambda.Runtime.GO_1_X, 
              code: lambda.AssetCode.fromAsset("opaCustomGoAuthorizer"),
              handler: "main",
              functionName:"OpaCustomGoAuthorizer"
            });
    
    
            const customOpaAuthorizer = new apigateway.RequestAuthorizer(this, 'customOpaAuthorizer', {
              handler: opaCustomAuthLambda,
              resultsCacheTtl:cdk.Duration.minutes(0),
              authorizerName: 'CustomOpaLambdaAuthorizer',
              identitySources: [apigateway.IdentitySource.header('Usergroup'),apigateway.IdentitySource.header('Resource') ],
            });
    
            const hello = api.root.addResource("hello");
            const hello_handler = new lambda.Function(this, "hello", {
              runtime: lambda.Runtime.PYTHON_3_8, 
              code: lambda.AssetCode.fromAsset("helloworld_function"),
              handler: "app.lambda_handler",
              functionName:"HelloWorldLambdaFunction"
            });
    
            const helloGetIntegration = new apigateway.LambdaIntegration(hello_handler);
            hello.addMethod("GET", helloGetIntegration,{
              authorizer: customOpaAuthorizer
            });
            
            new cdk.CfnOutput(this, "Hello API URL:", {
              value: api.url+"hello" ?? "Something went wrong with the deploy",
            });
      }
    }

Creating a Makefile for building the CDK project

To help build this solution, we need to add a Makefile. In the parent directory, cdk-opa-blog, create a file called Makefile. Copy and paste the following contents to it.

.PHONY: deps clean build
deps:
         go get github.com/aws/aws-lambda-go/events
         go get github.com/aws/aws-lambda-go/lambda
         go get github.com/open-policy-agent/opa/loader
         go get github.com/open-policy-agent/opa/rego
         go get github.com/open-policy-agent/opa/ast
         go get github.com/open-policy-agent/opa/storage
clean: 
         rm -rf ./opaCustomGoAuthorizer/main
opabuild:
         GOOS=linux GOARCH=amd64 go build -o opaCustomGoAuthorizer/main ./opaCustomGoAuthorizer

Installing CDK dependencies

Install the following dependencies by running npm i @aws-cdk/aws-apigateway @aws-cdk/aws-lambda in the terminal window.

Project directory structure

Your project directory structure should look like the following:

directory structure showing cdk-opa-blog on top

Deployment

Run the following commands to build and deploy the solution.

make deps
make clean
make opabuild
cdk deploy

Once deployed, copy the HelloAPIURL from the CDK outputs section:

output screenshot

Testing

Test case 1ViewerGroup accessing record1:

curl --location --request GET 'HelloAPIURL_FROM_OUTPUT' \--header 'usergroup: ViewerGroup' \--header 'resource: record1'

Once you run it, you should get the following result:

{"message": "Hello, You are authorized using Open Policy Agent (OPA) Lambda Authorizer"}

Test case 2ViewerGroup accessing record_secret:

curl --location --request GET 'HelloAPIURL_FROM_OUTPUT' \--header 'usergroup: ViewerGroup' \--header 'resource: record_secret'

Once you run it, you should get the following result:

{"message":"Unauthorized"}

Test case 3AdminGroup accessing record_secret:

curl --location --request GET 'HelloAPIURL_FROM_OUTPUT' \--header 'usergroup: AdminGroup' \--header 'resource: record_secret'

Once you run it, you should get the following result:

{"message": "Hello, You are authorized using Open Policy Agent (OPA) Lambda Authorizer"}

Cleanup

Run the following command to clean up the CDK stack.

cdk destroy

Conclusion

In this post, we demonstrated how you can create a custom Lambda authorizer to offload authorization decisions by leveraging the OPA policy engine.

In this example, we showed how authorization could be as straightforward as passing request headers to OPA to return a decision. This example can be extended to various other use cases. For an example, refer to the OPA documentation.

For more complex scenarios, the custom Lambda authorizer could query data stores based on JSON Web Token (JWT) claims to return additional context data to make a decision.

Srihari Prabaharan

Srihari Prabaharan

Srihari Prabaharan is a Cloud Application Architect and he works with customers to architect, design, automate, and build solutions on AWS for their business needs. Srihari's passion includes filmmaking and screenwriting and he made his debut independent feature film as writer and director in 2014.

Rucha Deshpande

Rucha Deshpande

Rucha Deshpande is a Solutions Developer at Amazon Web Services. She works on architecture and implementation of microservices. In her free time, she enjoys reading, gardening and travelling.