How to implement authorization using Cerbos in Go

Published by Kunal Verma on October 22, 2024
How to implement authorization using Cerbos in Go

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.

Understanding the application structure

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:
    • Can create, read, update, and delete their own posts
    • Can perform all operations on other users' posts
  • user role:
    • Can create, read, update, and delete their own posts
    • Cannot perform any operations on other users' posts

A detailed look at the architecture (file structure) of our application is shown below:

An annotated file tree

Prerequisites

Before we begin with the tutorial, ensure you have the following:

  • Go installed - Make sure to have Go (version 1.16 or later) installed on your system.
  • Cerbos CLI installed - This is needed to interact with the Cerbos server (PDP).

Step 1 - Initial project setup

In this step, we'll perform the following tasks:

  • Set up the Go project environment.
  • Install the required modules.

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:

  1. Echo Web Framework
  2. Cerbos Go SDK
# 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.

Step 2 - Building the RESTful API

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:

  1. Setting up the Database - For Users and Posts
  2. Defining the API routes and handlers using Echo
  3. Starting the Echo server

Setting up the database

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:

  • Structs for Data Models - Defines the structure of a blog post and manages the collection of posts.
  • In-Memory Storage - PostDB uses a map to store posts in memory, allowing quick access and manipulation of post data.
  • CRUD Operations - Methods like 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.
  • In-Memory User Data - Uses a map (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.

Defining API routes and handlers using Echo

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.

1. Initializing the Echo client and defining routes

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
}

2. Implementing the handler functions

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() and retrievePost() are utility functions used in the above section for retrieving user information and post details from the current request, respectively.

3. Starting the Echo server

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.

Intermezzo - Introduction to Cerbos

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.

What is Cerbos?

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.

Feature highlights

Some of the key features offered by Cerbos are as follows:

  • Flexible Policy Definition: Cerbos allows you to define access control policies using a simple and intuitive policy format. These policies specify who can access what resources and under what conditions, providing fine-grained control over access permissions.
  • Dynamic Evaluation: Cerbos dynamically evaluates access requests at runtime, taking into account contextual information such as user attributes, resource attributes, and environmental factors. This enables adaptive access control based on the current state of the application and its users.
  • Policy Versioning and Rollback: Cerbos supports versioning of policies which allows you to manage changes to access control rules over time. You can roll back to previous policy versions if needed, ensuring consistency and auditability of access control decisions.
  • Centralized Policy Management: With Cerbos, you can manage access control policies centrally, making it easier to maintain and update authorization rules across multiple applications and services. This centralized approach streamlines policy management and ensures consistent enforcement of access control across your ecosystem.

Spotlight on access control policies

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:

  • Resource Policies: These define access control rules for specific resources, such as our example of blog posts. These policies specify which users or roles are allowed to perform actions (e.g., create, read, update, delete) on the resource.
  • Derived Roles: These are dynamically generated roles based on user attributes or other contextual information. These roles can be used to grant or restrict access to resources based on some dynamic criteria, enhancing the flexibility and adaptability of access control policies.

Step 3 - Implementing authorization using Cerbos

In order to implement authorization using Cerbos in our Go application, here are the different components we need to configure:

  1. Defining Cerbos Policies
  2. Initializing the Cerbos PDP Client
  3. Implementing the Authorization Logic Using Echo

Defining policies

While defining Cerbos policies, we need to consider the following components:

  • Role - Assigned to different users of our application, based on which their permissions will be assigned. In our case, we have two roles defined - admin and user.
  • Resource - The actual entity in our application that we want to protect. In our case, we have the blog post on which different operations are being performed.
  • Permission - This defines what kind of action users can perform on which resources, based on a specific role assigned to that 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:

  • Create Posts:
    • Allowed for users with the user or admin roles.
  • View Posts:
    • Allowed for users with the post-owner derived role (i.e., the creator of the post).
    • Also allowed for users with the admin role.
  • Update/Delete Posts:
    • Allowed for users with the post-owner derived role.
    • Also allowed for users with the admin role.

Initializing the Cerbos PDP client

Before moving on to implementing the core authorization logic, let us initialize the Cerbos PDP client.

  1. Add the Cerbos GRPC client to the Service type in service/service.go:

    type Service struct {
        cerbos *cerbos.GRPCClient
        posts  *db.PostDB
    }
    
  2. 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
    }
    
  3. 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)
    }
    

Implementing the authorization logic using Echo

In this section, we’ll implement the following steps to integrate Cerbos authorization in our Go application:

  • Creating a Cerbos post resource
  • Implementing the Authorization Middleware using Echo
  • Define a Function For Cerbos Policy Check
  • Enforcing Policy Check in our Handlers

1. Creating a Cerbos post resource

Now 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.

2. Implementing the authorization middleware using Echo

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:

  • Get Basic Auth Credentials: Extracts the username and password from the request's basic authentication header.
  • Authenticate User: Calls buildAuthContext() to validate the credentials and retrieve the authentication context.
  • Attach Auth Context: Adds the authentication context to the current request context.
  • Proceed with Request: Passes control to the next handler if authentication is successful.

3. Function for policy check - 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:

  • Get Cerbos Principal: Retrieves the current Cerbos principal context from the request context using the principalContext() function.
  • Check Permissions: Uses the IsAllowed method to check if the action on the resource is permitted for the principal.
  • Return Result: Returns true if the action is allowed, otherwise returns false.

4. Enforcing policy check in our handlers

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:

  • Creating a Cerbos resource: Converted a blog post into a Cerbos resource with additional attributes like title and owner.
  • Authorization middleware: Built a middleware to authenticate users and set up their context for authorization.
  • Cerbos policy check function: Created a function to check if actions are allowed based on Cerbos policies. The action defined are: CREATE, VIEW, UPDATE, DELETE.
  • Enforcing policy checks in handlers: Integrated the policy checks into our request handlers to ensure that only authorized users can perform specific actions on blog posts.

Step 4 - Performing CRUD operations with access control

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
    

Final thoughts

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.

Resources

Book a free Policy Workshop to discuss your requirements and get your first policy written by the Cerbos team