Role-based access control (RBAC) is an access model where permissions belong to roles, and users get permissions only through the roles they hold. A support agent can refund orders because the support role can refund orders, not because someone ticked a box on that agent's account. When the agent moves to finance, you change one role and every permission follows.
Users, roles and permissions
RBAC has three parts and two links between them.
- Users are the people (or service accounts) signing in.
-
Roles are named jobs:
admin,support,finance,viewer. - Permissions are operations on things: read invoices, refund an order, delete a user.
Users are assigned roles. Roles are granted permissions. A user never holds a permission directly. The model was formalized by David Ferraiolo and Rick Kuhn at NIST in 1992 and became the ANSI/INCITS 359 standard in 2004, revised in 2012.
The payoff is that the list of roles is short and stable, while the list of people churns. Reviewing access means reading a handful of role definitions, not auditing hundreds of accounts.
A role as an enum column
Most Rails apps start with one role per user, stored as a column on users.
# db/migrate/20261112000000_add_role_to_users.rb
class AddRoleToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :role, :string, null: false, default: "viewer"
add_index :users, :role
end
end
# app/models/user.rb
class User < ApplicationRecord
enum :role, {
viewer: "viewer",
support: "support",
finance: "finance",
admin: "admin"
}, validate: true
end
Storing strings instead of integers keeps the column readable in SQL and safe to reorder. The enum gives you user.admin?, user.support!, and scopes like User.finance and User.not_admin. With validate: true, an unknown role makes the record invalid instead of raising ArgumentError.
Keep the default at the least powerful role. A new account that nobody configured should be able to do the least, not the most.
Checking roles in Pundit policies
Pundit has no concept of roles. It gives you one Ruby class per model with a boolean method per action, and you decide what goes inside. Role checks are the most common thing to put there.
# app/policies/application_policy.rb
class ApplicationPolicy
attr_reader :user, :record
def initialize(user, record)
@user = user
@record = record
end
def index? = false
def show? = false
def create? = false
def update? = false
def destroy? = false
class Scope
attr_reader :user, :scope
def initialize(user, scope)
@user = user
@scope = scope
end
end
private
def staff? = user.admin? || user.support? || user.finance?
end
# app/policies/order_policy.rb
class OrderPolicy < ApplicationPolicy
def index? = staff?
def show? = staff?
def update? = user.admin? || user.support?
def refund? = user.admin? || user.finance?
def destroy? = user.admin?
class Scope < ApplicationPolicy::Scope
def resolve
user.viewer? ? scope.none : scope.all
end
end
end
Every method in the base class returns false, so a new action is denied until someone writes a rule for it. That is the same idea as least privilege: grant on purpose, never by omission.
Keep the role names inside policies. If user.admin? shows up in views, controllers and jobs, changing what admins can do turns into a search across the codebase. Ask the policy instead: policy(@order).refund?.
Restricting admin screens by role
Put your back-office controllers behind one base class that authorizes every request and turns a denial into a 403.
# app/controllers/admin/base_controller.rb
class Admin::BaseController < ApplicationController
include Pundit::Authorization
after_action :verify_authorized, except: :index
after_action :verify_policy_scoped, only: :index
rescue_from Pundit::NotAuthorizedError do
head :forbidden
end
end
# app/controllers/admin/orders_controller.rb
class Admin::OrdersController < Admin::BaseController
def index
@orders = policy_scope(Order)
end
def refund
@order = Order.find(params[:id])
authorize @order
@order.refund!
redirect_to admin_order_path(@order)
end
end
verify_authorized is a safety net for developers, not an access control mechanism. It raises when an action forgot to call authorize, which catches mistakes in tests before they reach production. Hide the links and buttons in the views too, but treat that as tidiness. The check in the controller is the one that protects the data.
When a roles table or rolify is worth it
The enum column stops working in two situations.
-
A user needs more than one role. Someone is both
supportandfinance. You can model that with a join table (roles,user_roles) and ahas_many :roles, through: :user_rolesassociation, then checkuser.roles.exists?(name: "finance"). - A role applies to one record, not the whole app. Someone is an editor of one project and a viewer of another.
The second case is what rolify was built for. Its generator creates the tables, and roles can be global, per class or per record.
# rails g rolify Role User
class User < ApplicationRecord
rolify
end
class Project < ApplicationRecord
resourcify
end
user.add_role :admin # global
user.add_role :editor, Project.first # scoped to one record
user.has_role? :editor, Project.first # => true
Project.with_role(:editor, user)
The latest rolify release is 6.0.1, from February 2023 (checked in November 2026). The repository is not archived and still receives commits, but releases have been rare. It is stable and small, and fine to adopt if you need per-record roles. If you only need a few global roles, the enum column is less code and one fewer dependency.
Per-record roles are a step toward record-level permissions, where the question is no longer "what is this user's job" but "what is this user's relationship to this record".
RBAC vs ABAC, and role explosion
Attribute-based access control (ABAC) decides access from attributes of the user, the record and the context, evaluated as a rule. "Support can edit orders in their own region, during their shift, under 500 euros" is an ABAC rule. RBAC can only answer "is this person in the support role".
The pressure to move from one to the other usually shows up as role explosion. Each new exception becomes a new role: support, support_eu, support_eu_readonly, support_eu_refunds. After a year, nobody can say what each role allows, and assigning the right one is guesswork. The model that was supposed to make access easy to review has become harder to review than per-user permissions.
In Rails you rarely pick one model. A Pundit method can mix both.
# app/policies/order_policy.rb
def update?
return true if user.admin?
user.support? &&
record.region == user.region &&
record.total_cents < 50_000
end
A practical split:
- Use roles for the coarse question: which screens and actions a job needs.
- Use attributes and ownership for the fine question: which records, and under what conditions.
- When you are about to add a role that differs from an existing one by a single condition, write the condition in the policy instead.
Roles in your Avo app
When your team builds its back office with Avo, the Authorization add-on runs the admin through the same Pundit policies as the rest of your app. Your role checks gate the sidebar, the buttons, associations, file fields and actions, and a policy Scope filters each resource Index view to the rows that role may load. With explicit authorization turned on, a missing policy method denies access instead of granting it. For the per-association checks such as view_comments?, see Advanced authorization rules.
Frequently asked questions
What is role-based access control in simple terms?
Role-based access control gives permissions to roles, such as admin or support, and then assigns roles to people. A user can do what their roles allow and nothing else. Changing someone's access means changing their role.
What is the difference between RBAC and ABAC?
RBAC grants access based on the role a user holds. ABAC grants access based on attributes of the user, the record and the context, such as region, ownership or time of day. Many Rails apps use roles for broad access and attribute checks for the exceptions.
Does Pundit support roles?
Pundit has no built-in roles. It calls a Ruby method on a policy class for each action, and you write the role check inside that method, for example by calling an enum predicate like user.admin?. Roles can come from an enum column, a roles table or rolify.
Do I need rolify for roles in Rails?
No. A single enum column on the users table covers apps where each user has one global role. rolify becomes useful when users hold several roles or when a role applies to one specific record, such as editor of one project.
What is role explosion?
Role explosion is when a team adds a new role for every exception, until there are dozens of near-identical roles nobody can explain. It is a sign that some rules depend on attributes, like region or ownership, and belong in policy conditions instead of new roles.