RBAC (Casbin): the complete guide

Role-based access control, opt-in via RBAC_ENABLED=true. Casbin decides; internal/auth authenticates. If you are an LLM adding a feature, read "Adding a feature" and follow it mechanically — the tests fail naming the exact gap when you skip a step.

How a request flows

  1. The middleware (internal/rbac/middleware.go, wired once in BootChi) resolves the session to a subject: admin, user, viewer, or anonymous. Unknown users.role values fall back to user — never up, never lockout.
  2. It calls Enforce(subject, path, method) against the embedded policy. Deny (or enforcer error) answers 403 with no body and a warn log carrying subject/object/action.
  3. Disabled (RBAC_ENABLED=false) the middleware passes everything through with identical wiring.

File map

The model, in one paragraph

Every decision is Enforce(subject, obj, act) where obj is a path string or an ABAC Object (see "Dynamic permissions"). A row p, user, /todo/, . allows when the subject has the role (g), the path matches (pathMatch, which is keyMatch2: spans /), the method matches (regexMatch: (GET|POST) or .), and attrOK passes. Effect: any allow wins. No g, rows ship — roles are flat (see below).

Policy row anatomy

Adding a feature (checklist)

  1. Mount routes in internal/server/chi.go buildChiMux (one arg per line).
  2. Add p, user, /prefix, . (+ /prefix/) rows in the user block.
  3. Add enumerated viewer rows only if the feature has a viewer-safe surface (read/create/toggle class, plus delete-own — never shared deletes, never paid).
  4. Extend TestRBACPolicyCoversMuxRoutes with the new mounts.
  5. Run go test -count=1 ./internal/rbac/ (~2s; the rbac-policy pre-commit hook runs the shape subset on commits touching internal/rbac/).
  6. Roll out with RBAC_PERMISSIVE=true for one deploy, read the rbac: permissive allow (would deny) lines, add the missing rows, then turn it off. Production posture is permissive=false.

Roles: vocabulary, promotion, flatness

Display hint vs enforcement

rbac.IsViewer(role) is a DISPLAY hint only (disable buttons, show the reason). Enforcement stays in policy.csv, so a wrong hint degrades to a 403, never to an unauthorized write. Never gate a mutation on IsViewer in a handler — the middleware already denied it.

Env reference

Test map (what pins what)

Adapting this guard for your product

The tests pin this template's choices. Downloaders with new roles or surfaces hit them by design — each failure tells you what to decide:

Dynamic permissions

Three dynamic inputs sit on top of the static policy. All are no-ops for a bare path string, so the default decisions never change.

1. ABAC attributes (internal/rbac/dynamic.go). The request obj may be an Object instead of a path string:

rbac.Object{Path: "/api/todos/abc", SubjectID: userID, Owner: rec.Owner}

The matcher's attrOK(r.sub, r.obj) passes a bare string and an ownerless Object; an Object with an owner allows admin or the owner (SubjectID == Owner). The middleware already passes an Object carrying Path, SubjectID and the client IP; a surface that needs ownership fills Owner from its own record loader. Add a tenant or IP rule by extending attrsOK — no model change needed.

2. Code-defined conditions. The matcher functions are plain Go, so any conditional (a computed flag, a variable, a DB read) can gate a decision: register another function with e.AddFunction("myCond", fn) in dynamic.go and name it in model.conf. This is the seam for "permissions from code".

3. Policy in the database (internal/rbac/adapter.go). With RBAC_POLICY_SOURCE=db the enforcer loads its rules from the casbin_rule table instead of policy.csv. The first boot seeds the table from the embedded policy, so switching source changes no decision until a row is edited; an insert/delete then takes effect on the next build. DBAdapter is a full persist.Adapter, so e.AddPolicy / e.RemovePolicy write through to the table too. The embedded policy stays the default and is still what the shape/coverage tests pin.

What RBAC does not do