Safeguard Your REST APIs Using Open Policy Agent - OPA

Authorization is a crucial concern for most applications. As app logic grows, permission checks often get scattered across handlers, middlewares, and external services. This leads to duplicated logic and inconsistencies.

Open Policy Agent (OPA) provides a unified approach to manage authorization policies separate from application code. We can use the same OPA file and use it for applications written in multiple languages, but for the sake of this blog, we'll integrate it with a Golang application.

Introduction to OPA

OPA is an open-source policy engine that evaluates policies to make decisions about access control, configuration validation, quota management and more.

OPA decouples policy decisions from policy enforcement. Developers define policies using OPA’s declarative language Rego. These policies get enforced across infrastructure like API gateways, Kubernetes, CI/CD pipelines etc.

Creating OPA Policy

OPA policies are written in the Rego language. Rego is a declarative language designed for specifying policy rules concisely.

Some key concepts in Rego:

  • Rules - These define relations between entities. For example:

      allow {
        input.subject.clearance == "secret"
        input.action == "GET"
      }
    
  • Packages - Related rules can be grouped into packages:

      package authz
    
      allow {...}
    
      deny {...}
    
  • Input - This contains the request details like subject, action etc.

  • Query - Executing a rule returns true/false for the query.

Let's look at an example policy implementing role-based access control:

# filename - auth.rego
package authz

import future.keywords

default allow = false

allow {
  input.role == "admin"
  access_groups = ["write", "read"]
  input.access in access_groups
}

allow {
  input.role == "user"
  access_groups = ["read"]
  input.access in access_groups
}

This rego file allows the admin to have read and write access but will restrict the user from only having read access.

Testing OPA Policies

We can test the OPA file that we have created above with a test file like below

# filename - auth_test.rego
package authz

# Test allow rule for admin
test_allow_admin_write {
  allow with input as {"role": "admin", "access": "write"}
}

test_allow_admin_read {
  allow with input as {"role": "admin", "access": "read"}
}

# Test allow rule for user
test_allow_user_read {
  allow with input as {"role": "user", "access": "read"}
}

test_deny_user_write {
  not allow with input as {"role": "user", "access": "write"}
}

# Test default deny
test_default_deny {
  not allow with input as {"role": "unknown", "access": "something"}
}

To see if the tests are passing and the rego file is working perfectly, we can run the tests using the below command.

NOTE: You will need to install the OPA tool to run the commands. Click here to view the install docs

$❯ opa test auth.rego auth_test.rego --verbose

auth_test.rego:
data.authz.test_allow_admin_write: PASS (913.375µs)
data.authz.test_allow_admin_read: PASS (133.875µs)
data.authz.test_allow_user_read: PASS (194.625µs)
data.authz.test_deny_user_write: PASS (186.958µs)
data.authz.test_default_deny: PASS (120.333µs)
--------------------------------------------------------------------------------
PASS: 5/5

You can also check the test coverage of the OPA file using the below command

$❯ opa test auth.rego auth_test.rego --coverage

... start of the output
  "covered_lines": 19,
  "not_covered_lines": 0,
  "coverage": 100
....

Integrating OPA with Golang Gin Application

Creating Gin Middleware

To Integrate OPA with Golang Gin application, we will need to add it as middleware as below

func OpaMiddlware() gin.HandlerFunc {
    // open rego file
    authzFile, err := os.Open("auth.rego")
    if err != nil {
       log.Fatalf("error opening file: %v", err)
    }
    defer authzFile.Close()

    // read rego file
    module, err := io.ReadAll(authzFile)
    if err != nil {
       log.Fatalf("error reading file: %v", err)
    }

    // return middleware
    return func(c *gin.Context) {
       // prepare rego query
       query, err := rego.New(
          rego.Query("data.authz.allow"),
          rego.Module("authz.rego", string(module)),
       ).PrepareForEval(c)
       if err != nil {
          log.Printf("error preparing query: %v\n", err)
       }

       // evaluate rego query by supplying values extracted from header
       result, err := query.Eval(context.Background(), rego.EvalInput(map[string]interface{}{
          "role": c.Request.Header.Get("role"),
          "access":   c.Request.Header.Get("access"),
       }))
       if err != nil {
          log.Printf("error evaluating query: %v\n", err)
       }

       // check if the user is allowed to access the resource
       if result[0].Expressions[0].Value == true {
          c.Next()
          return
       } else {
          c.JSON(http.StatusForbidden, gin.H{
             "message": "access forbidden",
          })
          c.Abort()
          return
       }
    }
}

This middleware reads the auth.rego file and prepare the rego query. This function returns a handler function that will run the opa policy for all the incoming requests by supplying headers, before forwarding the request to appropriate endpoints.

Integrating Auth Middleware with Gin Router

We'll instruct Gin to attach the middleware to the router by calling the router.Use function like below

func main() {
    r := gin.Default()
    r.Use(OpaMiddlware()) // we are attaching the auth middleware here

    r.GET("/ping", func(c *gin.Context) {
        // do some logic with header
        c.JSON(http.StatusOK, gin.H{
            "message": "pong",
        })
    })
    r.Run()
}

Running the Go API

When we run the golang application and hit the /ping endpoint we get the output as below. (added print header statements in logs for easy understanding)

$❯ go run main.go

.......
[GIN-debug] Listening and serving HTTP on :8080
access: read
role: writer
[GIN] 2023/09/06 - 21:42:00 | 403 |    5.589708ms |       127.0.0.1 | GET      "/ping"
access: read
role: admin
[GIN] 2023/09/06 - 21:42:10 | 200 |    1.925834ms |       127.0.0.1 | GET      "/ping"
access: read
role: user
[GIN] 2023/09/06 - 21:42:15 | 200 |       775.5µs |       127.0.0.1 | GET      "/ping"
access: write
role: user
[GIN] 2023/09/06 - 21:42:20 | 403 |    3.746209ms |       127.0.0.1 | GET      "/ping"

From the console logs we see that when invalid requests are sent, we get 403 errors as per the policy and when valid requests are sent, we get back the response with the status code 200

To be exact, The middleware extracts the subject role and request method, queries OPA, and denies access if the policy evaluation fails.

Conclusion

In this post, we looked at using Open Policy Agent to externalize authorization logic from a Golang application. OPA provides a unified way to manage access policies across services using its declarative language Rego.

We saw how to:

  • Authorize Rego policies for enforcing role-based access control

  • Test policies thoroughly using OPA's built-in test framework

  • Integrate OPA with a Golang API server using middleware

  • Evaluate policies on each request to make access decisions

OPA integrates well with infrastructures like Kubernetes, allowing consistent policy enforcement across large distributed environments.

Using OPA results in more maintainable applications by separating policy code from business logic. Authorization policies can be modified independently without changing backend services. OPA provides a scalable way to manage fine-grained access control that evolves with application needs.

Hope this gives a good overview of securing Golang apps with OPA. :)

Github Link - https://github.com/cksidharthan/opa-blog

0
Subscribe to my newsletter

Read articles from Sidharthan Chandrasekaran Kamaraj directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Sidharthan Chandrasekaran Kamaraj
Sidharthan Chandrasekaran Kamaraj

Yet another developer, learning new things everyday :)