Building RBAC in Go

Mar 24th, 2022

Roie Schwaber-Cohen avatar

Roie Schwaber-Cohen

Ronen Hilewicz avatar

Ronen Hilewicz

Authorization  |  

Integration

Building Role-based Access Control in Golang

Introduction

Role-Based Access Control (RBAC) is an access control pattern that governs the way users access applications based on the roles they are assigned. Roles are essentially groupings of permissions to perform operations on particular resources. Instead of assigning numerous permissions to each user, RBAC allows users to be assigned a role that grants them access to a set of resources. For example, a role could be something like evilGenius, or a sidekick. A sidekick like Morty Smith for example could have the permission to gather mega seeds, and an evilGenius like Rick would be able to create a portal gun.

multiverse

We’ll first demonstrate how to implement an RBAC authorization pattern in a Golang application without the use of any open-source libraries, and then review the usage of the casbin open-source library as well as the Aserto Golang SDK.

Prerequisites

In order to run the examples in this tutorial, you’ll need to have Go installed on your machine.

Setup

The code examples in this tutorial can be found in this repository. To run each of them, navigate to the corresponding directory and run:

go run .

Shared dependencies

Our vanilla and casbin examples share the user.json file which contains the users in our system and their roles.

[
  {
    "id": "beth@the-smiths.com",
    "roles": ["clone"]
  },
  {
    "id": "morty@the-citadel.com",
    "roles": ["sidekick"]
  },
  {
    "id": "rick@the-citadel.com",
    "roles": ["evilGenius", "squanch"]
  }
]

In addition, we have three shared dependencies found in the pkg directory in the go-rbac repo. Let’s look at those first:

authz

In this package, we define an interface for an Authorizer:

type Authorizer interface {
    HasPermission(userID, action, asset string) bool
}

An Authorizer must implement a single function, that given a userID, an action and an asset, returns a bool indicating whether the user has permission to perform the action on the asset.

The actionFromMethod function is used to map HTTP methods to actions:

func actionFromMethod(httpMethod string) string {
    switch httpMethod {
    case "GET":
        return "gather"
    case "POST":
        return "consume"
    case "DELETE":
        return "destroy"
    default:
        return ""
    }
}

Next, we define the middleware function which applies the authorizer on the HTTP call made to the server. The middleware takes an Authorizer as an argument and returns a http.Handler function that resolves the action the user is trying to perform, based on the HTTP method called. The function actionFromMethod is used to resolve the action based on the HTTP method (as described above).

Then, the middleware resolves the asset from the payload of the request and then calls the authorizer to determine whether the user has permission to perform that action on the asset or not. If the user does not have permissions or if no user information is sent with the request, the middleware returns a 403 Forbidden response. Otherwise, it calls the next handler in the chain.

func Middleware(a Authorizer) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            username, _, ok := r.BasicAuth()
            // This is where the password would normally be verified

            asset := mux.Vars(r)["asset"]
            action := actionFromMethod(r.Method)
            if !ok || !a.HasPermission(username, action, asset) {
                log.Printf("User '%s' is not allowed to '%s' resource '%s'", username, action, asset)
                w.WriteHeader(http.StatusForbidden)
                return
            }

            next.ServeHTTP(w, r)
        })
    }
}

server

In the server package, we define the Start function that simply starts an HTTP server that listens on port 8080.

func Start(handler http.Handler) {
    fmt.Println("Staring server on 0.0.0.0:8080")

    srv := http.Server{
        Handler: handler,
        Addr:    "0.0.0.0:8080",
    }
    log.Fatal(srv.ListenAndServe())
}

users

The users package contains helper functions that load a list of users defined in the users.json file:

package users

import (
    "encoding/json"

    "github.com/aserto-demo/go-rbac/pkg/file"
)

type User struct {
    ID    string   `json:"id"`
    Roles []string `json:"roles"`
}

type Users map[string]User

func Load() (Users, error) {
    jsonBytes, err := file.ReadBytes("../users.json")
    if err != nil {
        return nil, err
    }

    var userList []User
    if err := json.Unmarshal(jsonBytes, &userList); err != nil {
        return nil, err
    }

    users := Users{}
    for _, user := range userList {
        users[user.ID] = user
    }

    return users, nil
}

Vanilla Go RBAC

The first example is a simple RBAC implementation in Go, without the use of any libraries.

The roles.json file contains all the roles in the system. Each role maps an action to the set of assets on which it can be performed.

{
  "clone": {
    "gather": ["megaSeeds", "timeCrystals"]
  },
  "sidekick": {
    "gather": ["megaSeeds", "timeCrystals"],
    "consume": ["megaSeeds", "timeCrystals"]
  },
  "evilGenius": {
    "gather": ["megaSeeds", "timeCrystals"],
    "consume": ["megaSeeds", "timeCrystals"],
    "destroy": ["megaSeeds", "timeCrystals"]
  }
}

Next, let’s start taking a look at the main.go file:

package main

import (
    "log"

    "github.com/aserto-demo/go-rbac/pkg/authz"
    "github.com/aserto-demo/go-rbac/pkg/server"
    "github.com/aserto-demo/go-rbac/pkg/users"
    "github.com/gorilla/mux"
)

Here we import the packages mentioned above, the gorilla/mux - a request router and dispatcher for HTTP requests, and the log package.

The authorizer struct holds the users found in users.json and the roles loaded from roles.json:

type authorizer struct {
    users users.Users
    roles Roles
}

It satisfies the Authorizer interface by implementing the HasPermission function that iterates over the user’s roles and looks for one that allows the specified action on the asset being accessed.

func (a *authorizer) HasPermission(userID, action, asset string) bool {
    user, ok := a.users[userID]
    if !ok {
        // Unknown userID
        log.Print("Unknown user:", userID)
        return false
    }

    for _, roleName := range user.Roles {
        if role, ok := a.roles[roleName]; ok {
            resources, ok := role[action]
            if ok {
                for _, resource := range resources {
                    if resource == asset {
                        return true
                    }
                }
            }
        } else {
            log.Printf("User '%s' has unknown role '%s'", userID, roleName)
        }
    }

    return false
}

Finally, the program’s main function creates an HTTP router with authorization middleware that uses an authorizer initialized with the users and roles loaded from the corresponding files. It creates a router with a single handler that serves GET, POST, and DELETE requests to the /api/{asset} path:

func main() {
    users, err := users.Load()
    if err != nil {
        log.Fatal("Failed to load users:", err)
    }

    roles, err := LoadRoles()
    if err != nil {
        log.Fatal("Failed to load roles:", err)
    }

    middleware := authz.Middleware(&authorizer{users: users, roles: roles})

    router := mux.NewRouter()
    router.HandleFunc("/api/{asset}", server.Handler).Methods("GET", "POST", "DELETE")
    router.Use(middleware)

    server.Start(router)
}

Click here to view the full vanilla Go RBAC implementation.

Casbin

Casbin is a powerful and efficient open-source access control library. It has SDKs in many languages, including Javascript, Golang, Rust, Python, and more. It provides support for enforcing authorization based on various access control models: from a classic “subject-object-action” model, through RBAC and ABAC models to fully customizable models. It has support for many adapters for policy storage.

In Casbin, the access control model is encapsulated in a configuration file (src/rbac_model.conf):

[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[role_definition]
g = _, _

[matchers]
m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act

[policy_effect]
e = some(where (p.eft == allow))

Along with a policy/roles definition file (src/rbac_policy.conf)

p, clone, megaSeeds, gather
p, clone, timeCrystals, gather
p, sidekick, megaSeeds, consume
p, sidekick, timeCrystals, consume
p, evilGenius, megaSeeds, destroy
p, evilGenius, timeCrystals, destroy
g, sidekick, clone
g, evilGenius, sidekick

  • The request_definition section defines the request parameters. In this case, the request parameters are the minimally required parameters: subject (sub), object (obj) and action (act). It defines the parameters’ names as well as the order that the policy matcher uses to match the request.
  • The policy_definitions section dictates the structure of the policy. In our example, the structure matches that of the request, containing the subject, object, and action parameters. In the policy/roles definition file, we can see that there are policies (on lines beginning with p) for each role (clone, sidekick, and evilGenius)
  • The role_definition section is specific for the RBAC model. In our example, the model indicates that an inheritance group (g) is comprised of two members. In the policy/roles definition file, we can see two role inheritance rules for sidekick and evilGenius, where sidekick inherits from clone and evilGenius inherits from sidekick (which means the evilGenius will also have the clone permissions).
  • The matchers section defines the matching rules for policy and the request. In our example, the matcher is going to check whether each of the request parameters matches the policy parameters and that the role r.sub is in the policy.

In this implementation, the dependencies we’ll use are mostly similar to the ones we’ve seen before, with the one difference being the casbin package:

package main

import (
    "log"

    "github.com/aserto-demo/go-rbac/pkg/authz"
    "github.com/aserto-demo/go-rbac/pkg/server"
    "github.com/aserto-demo/go-rbac/pkg/users"
    "github.com/casbin/casbin"
    "github.com/gorilla/mux"
)

Our authorizer struct now includes a casbin.Enforcer field:

type authorizer struct {
    users    users.Users
    enforcer *casbin.Enforcer
}

The implementation of the HasPermission function uses the enforcer to check the permissions. We iterate over each role assigned to the user, and get the decision from the enforcer for the iterated role as well as the action and asset passed to the function.

func (a *authorizer) HasPermission(userID, action, asset string) bool {
    user, ok := a.users[userID]
    if !ok {
        // Unknown userID
        log.Print("Unknown user:", userID)
        return false
    }

    for _, role := range user.Roles {
        if a.enforcer.Enforce(role, asset, action) {
            return true
        }
    }

    return false
}

Finally, we define the main function of our program. We instantiate a new enforcer using the configuration and policy files we defined. We then load our users file, initialize the router and middleware just like we did in the previous example.

func main() {
    enforcer, err := casbin.NewEnforcerSafe("./rbac_model.conf", "./rbac_policy.csv")
    if err != nil {
        log.Fatal("Failed to create enforcer:", err)
    }

    users, err := users.Load()
    if err != nil {
        log.Fatal("Failed to load users:", err)
    }

    router := mux.NewRouter()
    router.HandleFunc("/api/{asset}", server.Handler).Methods("GET", "POST", "DELETE")
    router.Use(
        authz.Middleware(&authorizer{users: users, enforcer: enforcer}),
    )

    server.Start(router)
}

Click here to view the full Casbin implementation.

goRBAC

The goRBAC library is a lightweight role-based access control library for Golang. It is straightforward and easy to use, but isn’t as feature-rich as Casbin.

In this case, the role definition file looks like this:

{
  "clone": ["gather-megaSeeds", "gather-timeCrystals"],
  "sidekick": [
    "gather-megaSeeds",
    "gather-timeCrystals",
    "consume-megaSeeds",
    "consume-timeCrystals"
  ],
  "evilGenius": [
    "gather-megaSeeds",
    "gather-timeCrystals",
    "consume-megaSeeds",
    "consume-timeCrystals",
    "destroy-megaSeeds",
    "destroy-timeCrystals"
  ]
}

As you can see, each role is assign with permissions which are the action name concatenated with the asset name.

The dependencies we use are mostly similar to the ones we’ve seen before, with the one difference being the gorbac package.

The HasPermission function implementation is as follows:

type authorizer struct {
    users    users.Users
    rbac *gorbac.RBAC
    permissions gorbac.Permissions
}

func (a *authorizer) HasPermission(userID, action, asset string) bool {
    user, ok := a.users[userID]
    if !ok {
        // Unknown userID
        log.Print("Unknown user:", userID)
        return false
    }

    permission := action + "-" + asset
    for _, role := range user.Roles {
        if a.rbac.IsGranted(role, a.permissions[permission], nil) {
            return true
        }
    }

    return false
}

The authorizer struct now includes both the rbac field and the permissions field, with the required gorbac.RBAC object and the gorbac.Permissions object respectively.

In the HasPermission function implementation, we create the permission by concatenating the action and the asset passed to the function. Then, we iterate over each of the user’s assigned roles and apply the rbac.IsGranted function to check if the user has the permission.

The main function is implemented as follows:

func main() {
    // map[RoleId]PermissionIds
    var roles map[string][]string

    // Load roles information
    if err := file.LoadJson("roles.json", &roles); err != nil {
        log.Fatal(err)
    }

    rbac := gorbac.New()
    permissions := make(gorbac.Permissions)

    // Build roles and add them to goRBAC instance
    for rid, pids := range roles {
        role := gorbac.NewStdRole(rid)
        for _, pid := range pids {
            _, ok := permissions[pid]
            if !ok {
                permissions[pid] = gorbac.NewStdPermission(pid)
            }
            role.Assign(permissions[pid])
        }
        rbac.Add(role)
    }

    users, err := users.Load()
    if err != nil {
        log.Fatal("Failed to load users:", err)
    }

    router := mux.NewRouter()
    router.HandleFunc("/api/{asset}", server.Handler).Methods("GET", "POST", "DELETE")
    router.Use(
        authz.Middleware(&authorizer{users: users, rbac: rbac, permissions: permissions}),
    )

    server.Start(router)
}

In this example, we initialize an gorbac instance and a gorbac.Permissions map. We then load the roles information from the roles.json file, iterate over each role a permission tuple and add it to the gorbac instance. Finally, we load the users file and initialize the router and middleware just like in the previous example.

Click here to view the full goRBAC implementation.

Aserto

Aserto takes a fundamentally different approach to authorization than all of the examples we’ve seen above. First and foremost - Aserto is an authorization service, with an SDK that allows easy integration into the application. Aserto can be deployed as a microservice or sidecar to your application - which guarantees maximum availability as well as single millisecond response time for authorization decisions.

There are a couple of additional key differences that sets Aserto apart from the other libraries we’ve reviewed so far.

Policy as Code and Policy as Data

What we’ve seen in the examples so far could be grouped into an approach called “Policy as Data,” where the policy itself is reasoned through the data that represents it. Aserto supports that approach as well as the "Policy-as-Code" approach, where the policy is expressed and reasoned about as code. For more about the differences between policy-as-code and policy-as-data, go here.

Reasoning about the policy as code makes the policy a lot more natural to write and maintain by developers. It also allows for more flexibility in the policy definition, as policies can be defined in a much more declarative way. Instead of data structures, developers can write the policy in a way that is a lot more concise and readable - and changes to the policy are made by changing the rules of the policy as opposed to rows in a database.

Users as first-class citizens

With Aserto, users and their roles are first-class citizens. It provides a directory of users and their roles which is continuously synchronized with the Aserto authorizer. This allows Aserto to reason about users and their roles as part of the policy itself - without requiring role resolution as an additional external step (This is why the users.json file or the resolveUserRoles function are not going to be required as you’ll see below). Having the role resolution as part of the application comes with its own set of risks - and the directory eliminates the risk of contaminating the decision engine with untrustworthy data.

Aserto policies

Let’s take a look at how policies are defined in Aserto. For the use case we presented, we’ll need a policy for every route the application exposes. Let’s start by reviewing the policy for the GET /api/:asset route:

package rickandmorty.GET.api.__asset

import future.keywords.in

default allowed = false

allowed {
    roles := {"clone", "sidekick", "evil_genius"}

    some x in roles
    input.user.attributes.roles[_] == x
    input.resource.asset == data.assets[_]
}

The first line of the policy defines the name of the package, and it matches the route it protects. Next, we define that by default, the allowed decision will be false - this means we’re defaulting to a closed system, where access has to be explicitly granted.

The allowed clause iterates over each role in the roles list, and checks if the user has one of these roles assigned to them. The user roles are automatically resolved by Aserto based on the user’s identity.

The last line of the allowed clause checks whether the asset the user is trying to access is listed in the data.assets object, which is part of the policy. The asset is passed to the policy as part of the resource context (more details below). A policy can have a data file (data.json) attached which could be used in the context of the policy. In our case, it includes the list of assets users can access:

{
  "assets": ["megaSeeds", "timeCrystals"]
}

Using a separate data file to define the protected assets, we don’t have to explicitly define them in the policy (as we had to do in the previous examples).

The policies for POST /api/:asset and DELETE /api/:asset are similar to the ones for GET /api/:asset, except that the roles associated with each are different.

POST endpoint:

package rickandmorty.POST.api.__asset

import future.keywords.in

default allowed = false

allowed {
    roles := {"sidekick", "evil_genius"}

    some x in roles
    input.user.attributes.roles[_] == x

    input.resource.asset == data.assets[_]
}

DELETE endpoint:

package rickandmorty.DELETE.api.__asset

default allowed = false

allowed {
    input.user.attributes.roles[_] == "evil_genius"
    input.resource.asset == data.assets[_]
}

As you can see, the policy for the consume route is allowing both sidekick and evilGenius access, while the policy for the destroy route is allowing access only to evilGenius.

Application implementation

The implementation for the application using Aserto is slightly different than the previous examples. Let’s start by examining the dependencies:

package main

import (
    "context"
    "log"
    "net/http"
    "os"

    "github.com/aserto-demo/go-rbac/pkg/server"
    "github.com/aserto-dev/aserto-go/authorizer/grpc"
    "github.com/aserto-dev/aserto-go/client"
    "github.com/aserto-dev/aserto-go/middleware"
    "github.com/aserto-dev/aserto-go/middleware/http/std"
    "github.com/gorilla/mux"
    "github.com/joho/godotenv"
)

To use the Aserto authorizer, we import the authorizer/grpc package, as well as the Aserto Golang client and middleware packages. Similar to the other examples, we use gorilla/mux to handle the routing. Finally, we use godotenv to load the environment variables from the .env file.

The authorizer function is implemented as follows:

func AsertoAuthorizer(addr, tenantID, apiKey, policyID string) (*std.Middleware, error) {
    ctx := context.Background()
    authClient, err := grpc.New(
        ctx,
        client.WithAddr(addr),
        client.WithTenantID(tenantID),
        client.WithAPIKeyAuth(apiKey),
    )
    if err != nil {
        return nil, err
    }

    mw := std.New(
        authClient,
        middleware.Policy{
            ID:       policyID,
            Decision: "allowed",
        },
    )
    mw.Identity.Mapper(func(r *http.Request, identity middleware.Identity) {
        if username, _, ok := r.BasicAuth(); ok {
            identity.Subject().ID(username)
        }
    })
    mw.WithPolicyFromURL("rickandmorty")
    return mw, nil
}

To initialize the authorizer, we need to pass in the Aserto authorizer address, as well as the API key and Tenant ID. We create the middleware by passing the authorizer client we created and then passing the policy ID we are using, as well as the policy decision we’re interested in getting.

To construct the identity context needed for the authorizer, we use the mw.Identity.Mapper function which extracts the identity of the user from the http request, and sets it as a subject-name identifier on the middleware’s identity object.

The mw.WithPolicyFromURL function tells the middleware to construct the policy path from the incoming request URL. It takes the policy root (in this case rickandmorty) and then builds the path as <prefix>.METHOD.path.from.request. So for example, if the request is GET /api/users/:id (where :id is a parameter), we’ll end up with the policy path rickandmorty.GET.api.users.__id.

The main function is straightforward:

func main() {
    err := godotenv.Load()
  if err != nil {
    log.Fatal("Error loading .env file")
  }

    authorizerAddr := os.Getenv("AUTHORIZER_ADDRESS")
    if authorizerAddr == "" {
        authorizerAddr = "authorizer.prod.aserto.com:8443"
    }
    apiKey := os.Getenv("AUTHORIZER_API_KEY")
    policyID := os.Getenv("POLICY_ID")
    tenantID := os.Getenv("TENANT_ID")
    authorizer, err := AsertoAuthorizer(authorizerAddr, tenantID, apiKey, policyID)
    if err != nil {
        log.Fatal("Failed to create authorizer:", err)
    }

    router := mux.NewRouter()
    router.HandleFunc("/api/{asset}", server.Handler).Methods("GET", "POST", "DELETE")
    router.Use(authorizer.Handler)

    server.Start(router)
}

We load the .env file from the root of the project into the environment, and then get the authorizer address, API key, policy ID, and Tenant ID from the file. We then create the authorizer, define the routes as we did in previous examples, and start the server.

Click here to view the full Aserto implementation.

Setting up Aserto

Aserto offers a console for managing policies - to create a new policy, you’ll need to sign in. If you don’t already have an Aserto account, you can create one here.

Add the Acmecorp IDP

To simulate the behavior of a user directory, we’ll add the “Acmecorp IDP”, which include mock users that will be added to our directory. Head on to the Aserto Console, select the “Connections” tab and click the “Add Connection” button.

add-connection

From the drop-down menu, select “Acmecorp”

add-acmecorp-connection

Name the provider acmecorp and give it a description.

Finally, click “Add connection”:

add-connection

Set up a policy

Click on the “Connections” tab and click on the “Add a connection” button.

add-connection-button

From the drop-down menu, select opcr-public. Name the connection “public-images”, set the display name to be “OPCR Public Images” and click “Add connection”.

add-opcr-public

Create a policy instance

Click on the Policies tab then click "Create an instance" to create a new policy instance.

Select the “...by installing a template” option, select the Simple RBAC.

Name your policy instance go-rbac-dev and click the “Create an instance” button:

name-policy-instance

Click on the newly created policy instance, and click on the “Policy Settings” tab. Copy the Policy ID, Authorizer API Key, and Tenant ID to your .env file.

policy-settings

After updating your .env file you can start the Aserto example by running go run . in the aserto folder.


Testing

To test the authorizer, we can use the following commands:

curl -X GET -f -u beth@the-smiths.com:x http://localhost:8080/api/megaSeeds
curl -X DELETE -f -u rick@the-citadel.com:x http://localhost:8080/api/megaSeeds

Both commands will return:

"Got permission"

To test cases where permission is denied, we can use the following commands:

curl -X POST -f -u beth@the-smiths.com:x http://localhost:8080/api/megaSeeds
curl -X DELETE -f -u morty@the-citadel.com:x http://localhost:8080/api/megaSeeds

Both commands will return:

curl: (22) The requested URL returned error: 403 Forbidden

Summary

In the post, we reviewed multiple ways of adding RBAC to your application. We’ve seen that in most cases, users are not considered a first-class citizen in the authorization offering and that the process of role resolution is left to the developer, and ends up as part of the application itself, which introduces many risks. We’ve also seen that most solutions take the “Policy-as-Data” approach as opposed to the “Policy-as-Code” approach.

While it might seem easier to use a library to implement RBAC in your Golang application, it is important to consider the lifecycle of the application and how it’ll grow. How will new users and roles be added? What would be the implications of changing the authorization policy? How will we reason about the authorization policy when it gets to be more complex?

Using a library means that you assume ownership of the authorization component - which requires time and effort to build and maintain. By using a service such as Aserto you can offload the responsibility of managing the authorization flow - without sacrificing the performance or availability of your application.

This post was written in collaboration with Ronen Hilewicz - Thanks Ronen!



Roie Schwaber-Cohen avatar

Roie Schwaber-Cohen

Developer Advocate

Ronen Hilewicz avatar

Ronen Hilewicz

Principal Engineer