# Role-based access control (RBAC)
> Role-based access control (RBAC) is an access model where permissions belong to roles, and users get permissions only through the roles they are assigned. You change what a person can do by changing their role, not by editing their individual rights.

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

```ruby
# 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
```

```ruby
# 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](https://avohq.io/glossary/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.

```ruby
# 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
```

```ruby
# 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.

```ruby
# 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
```

```ruby
# 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 `support` and `finance`. You can model that with a join table (`roles`, `user_roles`) and a `has_many :roles, through: :user_roles` [association](https://avohq.io/glossary/associations), then check `user.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](https://github.com/RolifyCommunity/rolify) was built for. Its generator creates the tables, and roles can be global, per class or per record.

```ruby
# 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](https://avohq.io/glossary/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.

```ruby
# 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](https://avohq.io/addons/authorization) 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](https://avohq.io/blog/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.
