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.
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.
- Users will access the API.
- Amazon API Gateway will call the custom OPA Lambda authorizer.
- OPA Lambda authorizer evaluates the policy with the context data and will return an IAM policy object.
- 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:
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:
Testing
Test case 1—ViewerGroup
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 2—ViewerGroup
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 3—AdminGroup
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.