Authorization is the process of determining whether a user has the permission to access specific resources or perform certain actions within an application. It ensures that users can only perform actions they are allowed to based on their role or permission level.
In this guide, we'll learn how to implement robust authorization in a web application using Cerbos, a powerful authorization engine, and Echo, a fast and minimalist Go web framework for building our application's backend.
By the end of this guide, you'll know how to set up a secure RESTful API in Go and enforce access control policies using Cerbos.
Before we delve into the implementation, it's crucial to understand the different components of our web application and its overall architecture.
Our application is essentially a simple RESTful API which enables you to perform the basic CRUD operations (create, read, update and delete) on blog posts. To demonstrate the authorization mechanism effectively, we have defined the following users and their corresponding roles:
Users | Passwords | Roles |
---|---|---|
kunal | kunalPass | admin |
bella | bellaPass | user |
john | johnPass | user |
Additionally, each role is associated with a distinct set of permissions:
admin
role:
user
role:
A detailed look at the architecture (file structure) of our application is shown below:
Before we begin with the tutorial, ensure you have the following:
In this step, we'll perform the following tasks:
Let's begin by creating a new directory for our project and initializing a new Go module (go.mod
):
$ mkdir blogapi-cerbos
$ cd blogapi-cerbos
$ go mod init github.com/USERNAME/blogapi-cerbos
Next, let's install the modules necessary for our project:
# echo web framework
$ go get github.com/labstack/echo/v4
# cerbos Go SDK
$ go get github.com/cerbos/cerbos-sdk-go
These commands will install the latest versions of both modules, which includes all the necessary packages we’ll need to first build our REST API and then implement authorization using Cerbos.
Let us first start with building the core of our application, which is the REST API for blog posts. There are three main components we need to configure:
Let's start by creating an in-memory database for both users and posts using Go structs. This approach simplifies things by storing data in RAM rather than a traditional database system.
For the blog posts database, create a file named db/posts.go
and add the following code:
package db
import (
"errors"
)
type Post struct {
PostId uint64 `json:"postId"`
Title string `json:"title"`
Owner string `json:"owner"`
}
type PostDB struct {
postCounter uint64
posts map[uint64]*Post
}
// initiatialise a new PostDB instance with an empty map of posts
func NewPostDB() *PostDB {
return &PostDB{
posts: make(map[uint64]*Post),
}
}
// create a new post
func (pdb *PostDB) CreatePost(owner string, post Post) uint64 {
pdb.postCounter++
pdb.posts[pdb.postCounter] = &Post{
PostId: pdb.postCounter,
Title: post.Title,
Owner: owner,
}
return pdb.postCounter
}
// update a post
func (pdb *PostDB) UpdatePost(postId uint64, post Post) error {
po, found := pdb.posts[postId]
if !found {
return errors.New("post not found")
}
// update title
po.Title = post.Title
return nil
}
// delete a post
func (pdb *PostDB) DeletePost(postId uint64) error {
_, found := pdb.posts[postId]
if !found {
return errors.New("post not found")
}
// delete a post from the map, having the id
delete(pdb.posts, postId)
return nil
}
// get a post by Id
func (pdb *PostDB) GetPost(postId uint64) (Post, error) {
po, found := pdb.posts[postId]
if !found {
return Post{}, errors.New("post not found")
}
return *po, nil
}
Here's a breakdown of the core logic:
PostDB
uses a map to store posts in memory, allowing quick access and manipulation of post data.CreatePost
, UpdatePost
, DeletePost
, and GetPost
enable basic CRUD operations on posts.For the users database, create a file named db/users.go
and add the following code:
package db
import (
"context"
"errors"
)
type UserRecord struct {
Password []byte
Roles []string
Blocked bool
}
var users = map[string]*UserRecord{
"kunal": {
Password: []byte(`$2y$10$s3QvUpMDYhdxO8LbPyiDou7KSTup.Hj9ip5ntB2h0NkW1fHIbYMm6`),
Roles: []string{"admin"},
Blocked: false,
},
"bella": {
Password: []byte(`$2y$10$0V3N6CPkEozFKWhRgYSXJeXUEra2G7IYWr5BCSGwBSILRpLsfpVUm`),
Roles: []string{"user"},
Blocked: false,
},
"john": {
Password: []byte(`$2y$10$RW1ItHGul1VXGZFs03YLFuwIvBijMv86uHq2pSHCkgvnvPHx10gj6`),
Roles: []string{"user"},
Blocked: false,
},
}
// retrieve user info from the database
func FindUser(ctx context.Context, username string) (*UserRecord, error) {
record, err := users[username]
if !err {
return nil, errors.New("record not found")
}
return record, nil
}
Here's a breakdown of the core logic:
UserRecord
Struct - Defines the structure for user records, including fields for password (stored as a byte slice), roles (a list of roles assigned to the user), and a blocked status.users
) to store user records in memory, with predefined users and their corresponding attributes.FindUser()
- Retrieves a user record by username
from the in-memory map.Let us now define the API routes and handlers using the Echo web framework. We'll set up routes for creating, viewing, updating, and deleting blog posts and implement the corresponding handler functions.
First, let’s initialize the Echo client and define the routes for our API.
package service
import (
"github.com/labstack/echo/v4"
)
// Service implements the posts API.
type Service struct {
posts *db.PostDB
}
func (s *Service) Handler() *echo.Echo {
// new echo instance
e := echo.New()
// API routes
e.PUT("/posts", s.handlePostCreate)
e.GET("/posts/:postId", s.handlePostView)
e.POST("/posts/:postId", s.handlePostUpdate)
e.DELETE("/posts/:postId", s.handlePostDelete)
return e
}
Next, we define the handler functions for each route. These functions will handle the HTTP requests and interact with our in-memory database.
Create a post
func (s *Service) handlePostCreate(ctx echo.Context) error {
var post db.Post
if err := ctx.Bind(&post); err != nil {
return ctx.String(http.StatusBadRequest, "Invalid post data")
}
// Create the post
username := getCurrentUser(ctx.Request().Context())
postId := s.posts.CreatePost(username, post)
return ctx.JSON(http.StatusCreated, struct {
PostId uint64 `json:"postId"`
}{PostId: postId})
}
View a post
// view the post with id
func (s *Service) handlePostView(ctx echo.Context) error {
// retrieve post info from request
post, err := s.retrievePost(ctx)
if err != nil {
log.Printf("ERROR: %v", err)
return ctx.String(http.StatusBadRequest, "Post not found")
}
return ctx.JSON(http.StatusOK, post)
}
Update a post
func (s *Service) handlePostUpdate(ctx echo.Context) error {
postId := ctx.Param("postId")
var post db.Post
if err := ctx.Bind(&post); err != nil {
return ctx.String(http.StatusBadRequest, "Invalid post data")
}
err := s.posts.UpdatePost(postId, post)
if err != nil {
return ctx.String(http.StatusBadRequest, "Post not found")
}
return ctx.String(http.StatusOK, "Post updated")
}
Delete a post
func (s *Service) handlePostDelete(ctx echo.Context) error {
postId := ctx.Param("postId")
err := s.posts.DeletePost(postId)
if err != nil {
return ctx.String(http.StatusBadRequest, "Post not found")
}
return ctx.String(http.StatusOK, "Post deleted")
}
Note: The functions
getCurrentUser()
andretrievePost()
are utility functions used in the above section for retrieving user information and post details from the current request, respectively.
Finally, we'll set up the Echo server to handle incoming requests. Use the following code in the main.go
file:
package main
import (
"log"
"net/http"
"github.com/verma-kunal/blogapi-auth-cerbos/service"
)
func main() {
// Create an instance of the Service struct
svc := &service.Service{}
// Initialize the HTTP server
srv := http.Server{
Addr: ":8080", // Set the address and port for the server
Handler: svc.Handler(), // Set the Echo handler
}
log.Printf("Listening on %s", ":8080")
// Start the server and listen for incoming requests
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err)
}
}
With this setup, we've successfully implemented a RESTful API using Echo, capable of performing all basic CRUD operations.
In our previous section, we haven’t yet implemented any authorization mechanism in our application. This means that all the users in our database, regardless of their assigned roles, can perform any operation—which is not ideal to say the least!
We’ll be using Cerbos to implement this authorization mechanism, but before that let’s have a closer look into Cerbos and some of its key features.
Cerbos is an open source authorization engine that enables developers to enforce access control policies within their applications. It works by evaluating access requests against predefined policies, typically defined in yaml
format, to determine whether to allow or deny access to resources and actions within the application.
Cerbos supports two primary access control paradigms:
Both offer developers a flexible and granular approach to defining authorization configurations.
Some of the key features offered by Cerbos are as follows:
As mentioned previously. Cerbos relies on access control policies to govern resource access and actions. For our blog application, we'll focus on two types of policies:
In order to implement authorization using Cerbos in our Go application, here are the different components we need to configure:
While defining Cerbos policies, we need to consider the following components:
admin
and user
.If you're new to Cerbos or unsure how to write a policy, you can use Cerbos Playground to create and test policies in your browser.
For our project today, we are defining the following policies:
Derived Role:
apiVersion: "api.cerbos.dev/v1"
derivedRoles:
name: custom-roles
definitions:
- name: post-owner
parentRoles: ["user"]
condition:
match:
expr: request.resource.attr.owner == request.principal.id
Resource Policy:
apiVersion: api.cerbos.dev/v1
resourcePolicy:
version: "default"
importDerivedRoles:
- custom-roles
resource: post
rules:
# Any user can create a new post
- actions: ["CREATE"]
roles:
- user
- admin
effect: EFFECT_ALLOW
# A post can only be viewed by the user who created it or the admin.
- actions: ["VIEW"]
derivedRoles:
- post-owner
roles:
- admin
effect: EFFECT_ALLOW
# A post can only be updated/deleted by the user who created it or the admin.
- actions: ["UPDATE", "DELETE"]
derivedRoles:
- post-owner
roles:
- admin
effect: EFFECT_ALLOW
Here, we are defining the actual permissions for our post
resource. This includes the following rules:
user
or admin
roles.post-owner
derived role (i.e., the creator of the post).admin
role.post-owner
derived role.admin
role.Before moving on to implementing the core authorization logic, let us initialize the Cerbos PDP client.
Add the Cerbos GRPC client to the Service
type in service/service.go
:
type Service struct {
cerbos *cerbos.GRPCClient
posts *db.PostDB
}
Create a function that utilizes Cerbos.New()
to initialize a new Cerbos client in service/service.go
:
// new cerbos instance
func New(cerbosAddr string) (*Service, error) {
cerbosInstance, err := cerbos.New(cerbosAddr, cerbos.WithPlaintext())
if err != nil {
return nil, err
}
return &Service{cerbos: cerbosInstance, posts: db.NewPostDB()}, nil
}
Initialize the service instance and provide the Cerbos server address in main.go
:
cerbosAddr := flag.String("cerbos", "localhost:3593", "Address of the Cerbos server")
flag.Parse()
// start the service API
svc, err := service.New(*cerbosAddr)
if err != nil {
log.Fatalf("Failed to create service: %v", err)
}
In this section, we’ll implement the following steps to integrate Cerbos authorization in our Go application:
post
resourcepost
resourceNow that we have defined the policies against the post
resource, we need to define the resource itself. Here’s the implementation to define the new Cerbos post
resource:
// cerbos resource for the given post
func postResource(post db.Post) *cerbos.Resource {
return cerbos.NewResource("post", strconv.FormatUint(post.PostId, 10)).
WithAttr("title", post.Title).
WithAttr("owner", post.Owner)
}
Here, the postResource
function converts a blog post into a Cerbos resource (*cerbos.Resource
) and attaches additional attributes like the post's title and owner.
The authorization middleware intercepts requests to authenticate users and attaches their authentication context to the request. Here’s how to implement it:
func AuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
// get basic auth creds from the request
user, pass, ok := ctx.Request().BasicAuth()
if ok {
// check the password and retrieve the auth context.
authCtx, err := buildAuthContext(user, pass, ctx)
if err != nil {
log.Printf("Failed to authenticate user [%s]: %v", user, err)
} else {
// Add the retrieved principal to the context.
newCtx := context.WithValue(ctx.Request().Context(), authCtxKey, authCtx)
ctx.SetRequest(ctx.Request().WithContext(newCtx)) // setting the new request context
return next(ctx)
}
}
return next(ctx)
}
}
Here’s a brief breakdown of the core logic:
buildAuthContext()
to validate the credentials and retrieve the authentication context.isAllowedByCerbos()
Next, we’ll create a function that checks if a user is authorized to perform a specific action on a Cerbos resource. This function will be used within our handlers to enforce authorization rules.
// Cerbos check function
func (s *Service) isAllowedByCerbos(ctx context.Context, resource *cerbos.Resource, action string) bool {
// Get current Cerbos principal
principalCtx := s.principalContext(ctx)
if principalCtx == nil {
return false
}
// Using the IsAllowed() utility function from "cerbos Principal Context"
allowed, err := principalCtx.IsAllowed(ctx, resource, action)
if err != nil {
return false
}
return allowed
}
Here’s a brief breakdown of the core logic:
principalContext()
function.IsAllowed
method to check if the action on the resource is permitted for the principal.true
if the action is allowed, otherwise returns false
.Now that we have the core authorization logic in place, we’ll integrate the policy checks into our existing Echo handler functions.
Create a post
func (s *Service) handlePostCreate(ctx echo.Context) error
...
// create a new cerbos resource
cerbosResource := cerbos.NewResource("post", "new").
WithAttr("title", post.Title).
WithAttr("owner", post.Owner)
// cerbos auth
if !s.isAllowedByCerbos(ctx.Request().Context(), cerbosResource, "CREATE") {
return ctx.String(http.StatusForbidden, "Operation not allowed")
}
...
}
View a post
func (s *Service) handlePostView(ctx echo.Context) error {
...
// cerbos policy check
if !s.isAllowedByCerbos(ctx.Request().Context(), postResource(post), "VIEW") {
return ctx.String(http.StatusForbidden, "Operation not allowed")
}
...
}
Update a post
func (s *Service) handlePostUpdate(ctx echo.Context) error {
...
// cerbos auth
if !s.isAllowedByCerbos(ctx.Request().Context(), postResource(post), "UPDATE") {
return ctx.String(http.StatusForbidden, "Operation not allowed")
}
...
}
Delete a post
func (s *Service) handlePostDelete(ctx echo.Context) error {
...
// cerbos auth
if !s.isAllowedByCerbos(ctx.Request().Context(), postResource(post), "DELETE") {
return ctx.String(http.StatusForbidden, "Operation not allowed")
}
...
}
Let us quickly summarize what all we have accomplished in this section:
title
and owner
.CREATE
, VIEW
, UPDATE
, DELETE
.Now that we have implemented the core authorization logic using Cerbos, we can test all our API endpoints with access control in place.
Make sure to start the Echo server before making the API requests. Use the following command to start the PDP and Echo server:
$ cerbos run --set=storage.disk.directory=cerbos/policies -- go run main.go
Here are a few examples to try out:
kunal can create, view a new post (having admin
role):
$ curl -i -u kunal:kunalPass -X PUT http://localhost:8080/posts -d '{"title": "gitops 101", "owner": "kunal"}'
# output
{"postId":1}
$ curl -i -u kunal:kunalPass -X GET http://localhost:8080/posts/1
# output
{"postId":1,"title":"gitops 101"","owner":"kunal"}
bella can create, view a new post (having user
role):
$ curl -i -u bella:bellaPass -X PUT http://localhost:8080/posts -d '{"title": "cebos-test", "owner": "bella"}'
# output
{"postId":2}
$ curl -i -u bella:bellaPass -X GET http://localhost:8080/posts/2
#output
{"postId":2,"title":"cebos-test","owner":"bella"}
bella cannot view, edit, delete kunal’s post:
$ curl -i -u bella:bellaPass -X GET http://localhost:8080/posts/1
# output
Operation not allowed
$ curl -i -u bella:bellaPass -X POST http://localhost:8080/posts/1 -d '{"title": "gitops 101", "owner": "kunal"}'
# output
Operation not allowed
$ $ curl -i -u bella:bellaPass -X DELETE http://localhost:8080/posts/1
# output
Operation not allowed
kunal can view, edit, delete bella’s post:
$ curl -i -u kunal:kunalPass -X GET http://localhost:8080/posts/2
# output
{"postId":2,"title":"cebos-test","owner":"bella"}
$ curl -i -u kunal:kunalPass -X POST http://localhost:8080/posts/2 -d '{"title": "edited-by-admin", "owner": "bella"}'
#output
Post updated
$ curl -i -u kunal:kunalPass -X DELETE http://localhost:8080/posts/2
# output
Post Deleted
In this guide, we learned how to set up a robust authorization mechanism in a Go application using the Echo framework and Cerbos. By combining these technologies, we created an efficient role-based access control (RBAC) system in a RESTful API, which controls who can access and manage blog posts.
Book a free Policy Workshop to discuss your requirements and get your first policy written by the Cerbos team
Join thousands of developers | Features and updates | 1x per month | No spam, just goodies.