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, editg, 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, viewCasbin response: true
alice
has edit
access on inventory so the request for view
access is granted.
Request: bob, inventory_items, editCasbin 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, viewCasbin response: true
bob
has view
access on inventory
so bob
has view
access over the child inventory_photos
feature.
Request: paul, inventory, editCasbin 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!