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
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 :)