Role-Based Access Control (RBAC) is the most widely deployed authorization model in enterprise software. Rather than assigning permissions directly to individual users, RBAC groups permissions into roles and assigns roles to users. This indirection dramatically simplifies permission management—when a new feature requires a new permission, you add it to the appropriate role rather than updating every affected user. However, implementing RBAC from scratch is deceptively complex: you need role hierarchies, domain-scoped policies, wildcard matching, and efficient policy evaluation that doesn’t become a bottleneck on every request.
Casbin is an open-source authorization library that externalizes access control logic into a declarative policy model. Instead of scattering if user.role == "admin" checks throughout your codebase, you define a model file that describes your authorization scheme (RBAC, ABAC, ACL, or hybrids) and a policy file that contains the actual rules. The Casbin enforcer evaluates requests against these policies at runtime, returning allow/deny decisions without your application code knowing the details of the policy structure.
In a multi-tenant SaaS platform like the platform, Casbin powers fine-grained authorization with SQL-backed policy storage. Policies are persisted in PostgreSQL via a Casbin adapter, enabling runtime policy updates without redeployment. The system implements hierarchical RBAC with domain separation—a user can be an admin in one workspace and a viewer in another—and integrates with the JWT authentication layer to extract role claims for enforcement at the API middleware level.
Casbin uses the PERM metamodel: Policy, Effect, Request, and Matchers. The request definition specifies what an authorization query looks like (e.g., sub, obj, act for subject, object, action). The policy definition specifies the structure of policy rules. Matchers are boolean expressions that determine if a request matches a policy. The effect aggregation rule combines multiple matching policies into a final allow/deny decision.
Casbin separates the authorization model (what kind of access control you use) from the policy (the actual rules). You can switch from ACL to RBAC to ABAC by changing the model file alone, without modifying application code or policy storage.
RBAC with Casbin supports role inheritance through g (grouping) functions. A role definition like g, alice, admin means Alice has the admin role. Role hierarchies are expressed as chains: if g, admin, superadmin, then superadmin inherits all permissions of admin. Casbin traverses the role graph during enforcement to check inherited permissions automatically.
Multi-tenant systems require domain-scoped RBAC. Casbin supports this through domain parameters in the request and policy definitions. A user can hold different roles in different domains: g, alice, admin, workspace_1 and g, alice, viewer, workspace_2. The matcher includes the domain in its evaluation, ensuring cross-tenant isolation.
Casbin adapters decouple policy storage from the enforcement engine. Adapters exist for PostgreSQL, MySQL, Redis, files, and dozens of other backends. The adapter interface is simple—LoadPolicy and SavePolicy—enabling you to store policies wherever your infrastructure demands, including in the same database your application already uses.
A Casbin model file defines four sections. Here is a typical RBAC model with domain support:
[request_definition]
r = sub, dom, obj, act
[policy_definition]
p = sub, dom, obj, act
[role_definition]
g = _, _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub, r.dom) && r.dom == p.dom && r.obj == p.obj && r.act == p.act
The matcher uses the g() function to check role membership within a domain. The effect rule says that if any matching policy allows the request, the final decision is allow. You can also use !some(where (p.eft == deny)) for deny-override semantics.
When Enforce(sub, dom, obj, act) is called, Casbin: (1) loads all policies matching the request structure, (2) evaluates each policy against the matcher expression, (3) for policies that match, checks the eft (effect) field (default: allow), (4) applies the effect aggregation rule to produce a final boolean decision. The role graph is traversed lazily during matcher evaluation when the g() function is invoked.
For production systems, file-based policies are insufficient—you need runtime updates, audit trails, and transactional consistency. The PostgreSQL adapter stores policies in a casbin_rule table with columns for each policy field (ptype, v0 through v5). Policy updates go through the adapter, and the enforcer can be configured to auto-reload policies on a schedule or on demand.
For large policy sets, loading all policies into memory is expensive. Casbin supports filtered adapters that load only the policies relevant to a specific domain or subject. This keeps memory usage bounded and enforcement latency low, even with millions of policy rules.
The platform implements workspace-scoped RBAC where each workspace is a Casbin domain. When a user authenticates via JWT, the middleware extracts their user ID and resolves their roles for the target workspace. The Casbin enforcer then evaluates whether the role grants the requested action on the target resource. This happens in Go middleware that wraps every API handler:
func AuthzMiddleware(enforcer *casbin.Enforcer) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims := auth.ClaimsFromContext(r.Context())
workspace := chi.URLParam(r, "workspaceID")
resource := extractResource(r)
action := mapHTTPMethod(r.Method)
ok, err := enforcer.Enforce(claims.UserID, workspace, resource, action)
if err != nil {
http.Error(w, "authorization error", http.StatusInternalServerError)
return
}
if !ok {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
The platform exposes admin API endpoints for managing policies at runtime. Workspace owners can invite users, assign roles, and define custom permissions without system restarts. Each policy change is written to PostgreSQL through the Casbin adapter and triggers an in-memory policy reload. The system also maintains an audit log of all policy changes for compliance.
package authz
import (
"github.com/casbin/casbin/v2"
gormadapter "github.com/casbin/gorm-adapter/v3"
"gorm.io/gorm"
)
func NewEnforcer(db *gorm.DB, modelPath string) (*casbin.Enforcer, error) {
// Create adapter from existing GORM database connection
adapter, err := gormadapter.NewAdapterByDB(db)
if err != nil {
return nil, fmt.Errorf("creating casbin adapter: %w", err)
}
// Load model and create enforcer
enforcer, err := casbin.NewEnforcer(modelPath, adapter)
if err != nil {
return nil, fmt.Errorf("creating enforcer: %w", err)
}
// Load policies from database
if err := enforcer.LoadPolicy(); err != nil {
return nil, fmt.Errorf("loading policies: %w", err)
}
return enforcer, nil
}
// AddUserRole assigns a role to a user within a workspace domain
func AddUserRole(enforcer *casbin.Enforcer, userID, role, workspace string) error {
_, err := enforcer.AddGroupingPolicy(userID, role, workspace)
if err != nil {
return fmt.Errorf("adding role: %w", err)
}
return nil
}
// CheckPermission evaluates whether a user can perform an action
func CheckPermission(enforcer *casbin.Enforcer, userID, workspace, resource, action string) (bool, error) {
return enforcer.Enforce(userID, workspace, resource, action)
}
// Define role hierarchy: superadmin > admin > editor > viewer
func SetupRoleHierarchy(enforcer *casbin.Enforcer, workspace string) error {
// viewer permissions
enforcer.AddPolicy("viewer", workspace, "documents", "read")
enforcer.AddPolicy("viewer", workspace, "projects", "read")
// editor inherits viewer, adds write
enforcer.AddGroupingPolicy("editor", "viewer", workspace)
enforcer.AddPolicy("editor", workspace, "documents", "write")
enforcer.AddPolicy("editor", workspace, "projects", "write")
// admin inherits editor, adds delete and user management
enforcer.AddGroupingPolicy("admin", "editor", workspace)
enforcer.AddPolicy("admin", workspace, "documents", "delete")
enforcer.AddPolicy("admin", workspace, "members", "write")
// superadmin inherits admin, adds workspace settings
enforcer.AddGroupingPolicy("superadmin", "admin", workspace)
enforcer.AddPolicy("superadmin", workspace, "settings", "write")
enforcer.AddPolicy("superadmin", workspace, "billing", "write")
return enforcer.SavePolicy()
}
A common pattern maps HTTP methods to CRUD actions: GET → read, POST → create, PUT/PATCH → update, DELETE → delete. This simplifies policy definitions by using semantic actions rather than HTTP verbs, making policies readable and reusable across different transport protocols (HTTP, gRPC, GraphQL).
AddPolicy() call won’t be visible to others until they call LoadPolicy(). Use a watcher (Redis, etcd, or polling) to synchronize policy changes across instances.some(where (p.eft == deny)), ensure deny rules take precedence by using the priority effect or deny-override aggregation.RemovePolicy() in test teardown.