Designing a Flexible Permissions System with Casbin
By: Upal Saha
Tags: Backend, Software Engineering, Golang Tutorial, Runtime Permissions, Go

At Silo, we recently worked on an overhaul of our user management system. The overhaul primarily involved adding the ability to assign permissions over specific features in our marketplace application. As we went through the planning phases of our new user management architecture, we insisted on not reinventing the wheel and decided to use Casbin to support user permissions in our Go backend. Casbin has great built-in support for a lot of common access models like ACL and RBAC, so we were able to adapt our user permissions design to use the Casbin framework even though our design was a bit of a hybrid model. Casbin is super flexible so it can be daunting to dive into the ecosystem, but we hope the examples we include here can provide some insight into how you can use Casbin in your own applications.

Defining a Casbin Model for Feature-Based Access Control

The model definition you choose dictates how Casbin ingests requests and enforces access rules. Casbin has some general requirements for the model definition, mainly that the .conf file needs at least four sections: [request_definition] , [policy_definition] , [policy_effect] , and [matchers] . Additionally, if the model is following RBAC it should contain a [role_definition] section. We’ll explore what all these sections entail when walking through Silo’s model definition.

There are several example files in the Casbin documentation that show configuration for common access models, but since our design is a little unique we’ll skip past those.

There are a few high level requirements we need to fulfill with our access model. We need an access model that is focused on feature-based access, in that users are provided access over a subset of features you can mix-and-match rather than assuming a role that itself is a container for specific access rules (as you would find in an RBAC model). We want to construct a feature hierarchy where if a user is given access over a feature, they are also granted access over all of that feature’s child features without having to manually add separate rules for all those child features or migrate the user’s feature set when new child features are added. Initially, we only need support for two different access types, view and edit , but we also want the ability to add additional types in the future. Lastly, we need a way to easily allow admin access over all features, without having to add an explicit “role” for that functionality. Included below is a configuration file which has support for all the requirements described:

[request_definition]

r = user_id, feature, action [policy_definition]

p = user_id, feature, action [role_definition]

g = _, _ [policy_effect]

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

m = p.user_id == r.user_id && ((g(p.feature, r.feature) ||

(p.feature == r.feature)) && (p.action == r.action ||

p.action == 'edit' && r.action == 'view') || p.feature == 'admin')

Let’s break down the components of the configuration file:

The request_definition is how we interact with Casbin. We define our authorization requests to be in the format user_id, feature, action (with action being either edit or view , which we will see later).

[request_definition]

r = user_id, feature, action

The policy_definition is how we store permissions. In Casbin’s terminology, “policy” is equivalent to our concept of a “permission.” We define our policies to be stored in the format user_id, feature, action , mirroring how we send requests to Casbin to verify permissions.

[policy_definition]

p = user_id, feature, action

The role_definition in this case is used to create the feature hierarchy. The first _ indicates the parent feature and the second _ indicates the child feature. This is a little opaque, but functionally this block defines a “grouping policy” in the format g(a, b) where we can basically store and verify the relationship between a pair of strings (which in our case are parent and child features names).

[role_definition]

g = _, _

The policy_effect defines what action is taken if a request is matched according to the matcher in the configuration(which we’ll take a look at later). In this block, we’re basically saying that if some policy is matched, the effect is allow .

[policy_effect]

e = some(where (p.eft == allow))

matchers defines the boolean expression needed to satisfy an authorization request with existing policies.

[matchers]

m = p.user_id == r.user_id && ((g(p.feature, r.feature) ||

(p.feature == r.feature)) && (p.action == r.action ||

p.action == 'edit' && r.action == 'view') || p.feature == 'admin')

The matcher here can be broken down into several clauses:

The first overall condition is that there has to exist some policy p stored in Casbin that matches the user_id in the request r .

p.user_id == r.user_id

The second condition is a bit more involved:

((g(p.feature, r.feature) ||

(p.feature == r.feature)) && (p.action == r.action ||

p.action == 'edit' && r.action == 'view') || p.feature == 'admin')

The g(p.feature, r.feature) function matches a permission to a child feature, meaning that if the user has access to the parent feature, match on its child.

g(p.feature, r.feature)

Either that parent/child relationship condition can be fulfilled, or the user could have an exact match with a permission over the request feature, so p.feature == r.feature is fulfilled.

p.feature == r.feature

Then, the action in the request has to match the action granted in the policy, or if the request is view but the viewer has edit that will also be granted.

p.action == r.action || p.action == 'edit' && r.action == 'view'

Or, ignoring all of the logic for matching specific features, if the user has an admin permission stored in Casbin, access is granted regardless of what feature is being requested.

p.feature == 'admin'

Now that we have our model defined, let’s run through some examples.

Policies in Action

Let’s create some policies to give permissions to some users and set up a feature hierarchy:

p, alice, inventory, edit

p, bob, inventory, view

p, paul, admin, edit g, inventory, inventory_items

g, inventory, inventory_photos

In the above file, we define three users with three different permissions and a simple feature hierarchy with one parent ( inventory ) and two children ( inventory_items and inventory_photos ). Let’s look at how Casbin will respond when we send permission requests with our model definition and the policies we just defined:

Request: alice, inventory_items, view Casbin response: true

alice has edit access on inventory so the request for view access is granted.

Request: bob, inventory_items, edit Casbin response: false

bob only has view access on inventory , so bob can’t have edit access over the child inventory_items feature.

Request: bob, inventory_photos, view Casbin response: true

bob has view access on inventory so bob has view access over the child inventory_photos feature.

Request: paul, inventory, edit Casbin response: true

paul is an admin , so he has access to all features

If you’d like to experiment more with our model definition and other policies or even test out your own model definitions, check out Casbin’s nifty editor.

Implementation

Now that we have the overall design of how the system will work, we can dig into some of the implementation specifics.

Disclaimer: The following code snippets are definitely not production-ready, so please use them only as a guide.

The default method of adding policies to Casbin is to create a policy CSV file, but that method doesn’t quite fit our use cases. We want our own admin users to assign privileges to other users in their account. Additionally, maintaining a CSV file for sensitive permission storage can quickly become untenable. With those reservations in mind, we chose to instead store these policies within our application’s database. Thankfully, there’s a host of adapter middlewares to connect Casbin to your storage method of choice. We went with the GORM adapter since our application heavily leverages GORM as our ORM library.

For the enforcement of policies, we attach a casbin.CachedEnforcer to our overall application struct so that all of our HTTP handlers are able to interface with Casbin to verify permissions.

type App struct {

... enforcer *casbin.CachedEnforcer ...

} app.enforcer = casbin.NewCachedEnforcer(<configuration file path>, gormadapter.NewAdapterByDB(<GORM DB struct>))

The CachedEnforcer stores the results of previous permission requests in a map within the runtime enforcer struct to reduce the amount of database calls. To ensure the cache doesn’t get stale, we clear the cache whenever a user’s permissions change.

# Add new permission

app.enforcer.AddPolicySafe("alice", "inventory_items", "view") # Clear cache

app.enforcer.InvalidateCache()

For the actual authorization of access to our endpoints, we “enforce” the minimum permission required for a specific endpoint through our HTTP handler flow. If the requirements aren’t met (Casbin simply returns false ), we return a 403 Forbidden , otherwise the handler proceeds as normal.

func (app *App) ViewItems(w http.ResponseWriter, r *http.Request) error {

userID := GetUserID(r)

if !app.enforcer.Enforce(userID, "inventory_items", "view") {

return fmt.Errorf("403 Forbidden")

} // User has access so proceed

...

}

And that’s it! Using the core Casbin functionality is straightforward enough that you can quickly get set up with permission handling with just a few lines of code. For larger applications you can easily abstract the boilerplate of adding, removing, and enforcing permissions. Hopefully this has given you enough background to get you started with designing your own access control system. Have fun!