RBAC with Policy Engines (Casbin)

Authentication & Authorization advanced Building with AI ~40 min read

Overview

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.

Core Concepts

The PERM Model

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.

Key Concept: Model as Configuration

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.

Role Hierarchies

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.

Domain/Tenant Separation

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.

Key Concept: Policy Adapters

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.

How It Works

Model Definition

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.

Policy Evaluation Flow

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.

SQL-Backed Policy Storage

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.

Key Concept: Filtered Policy Loading

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.

Practical Applications

The Platform’s Authorization Architecture

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)
        })
    }
}

Dynamic Policy Management

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.

Implementation Patterns

Enforcer Initialization with PostgreSQL

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)
}

Role Hierarchy Setup

// 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()
}
Key Concept: HTTP Method Mapping

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

Common Pitfalls

Further Reading