Coming from a JavaScript background, I’m used to handling permissions with simple if statements. Maybe a global “roles” object, or a quick check on the frontend that hides buttons if the user isn’t an admin. Working with Rails and Pundit forced me to think differently about authorization as a system.
Understanding the request flow and where helper methods come from would’ve saved me a lot of time upfront.
Philosophy of Pundit
At its core, a Pundit Policy answers the question:
Can \$USER take \$ACTION on \$RESOURCE?
A Policy Scope answers:
Scope down \$COLLECTION to only the ones \$USER can access.
These two layers keep authorization clean and explicit:
What belongs in Pundit:
- Can this user edit contacts from other teams?
- Can admins see all records, but agents only see their own?
- Is this user allowed to destroy this resource?
What doesn’t belong in Pundit:
- Default sorting (
.order(created_at: :desc)) - Business filters (
.where(status: 'active')) - Pagination logic
- Eager loading associations
Keep authorization separate from querying concerns. Your scopes should answer “who can see what,” not “how should we display it.”
How Pundit Actually Works: The Request Flow
Before diving into helper methods and syntax, let’s trace a request from start to finish. This is the foundation that makes everything else fall into place.
Scenario: User tries to edit a contact
Step 1: Request comes in
# User clicks "Edit Contact #42"
# Rails routes to: ContactsController#edit
Step 2: Controller loads the record and calls authorize
class ContactsController < ApplicationController
def edit
@contact = Contact.find(params[:id]) # Load contact #42
authorize @contact # Ask Pundit: "Can this user edit this contact?"
# ... rest of the action
end
end
Step 3: Pundit looks up the policy
Behind the scenes, Pundit:
- Looks at the class name:
@contactis aContactobject - Finds the matching policy:
ContactPolicy - Looks at the controller action:
editaction → callsedit?method
Step 4: Pundit creates a policy instance
# Pundit does this automatically:
policy = ContactPolicy.new(current_user, @contact)
Step 5: Pundit calls the appropriate method
# Pundit calls:
allowed = policy.edit?
# Which runs your code in ContactPolicy:
class ContactPolicy < ApplicationPolicy
def edit?
user.admin? || record.user_id == user.id
end
end
Step 6: Pundit makes the decision
# If edit? returns true:
# → Controller continues, user sees the edit form
# If edit? returns false:
# → Pundit raises Pundit::NotAuthorizedError
# → Rails shows an error page (or you handle it)
Visual Flow
User Request
↓
Controller: "authorize @contact"
↓
Pundit: "What class is @contact?" → Contact
↓
Pundit: "Find ContactPolicy"
↓
Pundit: "What action?" → edit
↓
Pundit: "Call ContactPolicy.new(current_user, @contact).edit?"
↓
Your Code: user.admin? || record.user_id == user.id
↓
Returns true or false
↓
true → Allow action
false → Raise error
Concrete Example with Real Data
Let’s say the current user is Sarah (ID: 5, admin: false) and the contact being edited is John (ID: 42, user_id: 5).
# Controller
@contact = Contact.find(42) # John
authorize @contact
# Pundit does:
policy = ContactPolicy.new(sarah, john)
allowed = policy.edit?
# Inside edit? method:
def edit?
user.admin? || record.user_id == user.id
# sarah.admin? || john.user_id == sarah.id
# false || (5 == 5)
# false || true
# => true ✅ ALLOWED
end
If Sarah tries to edit a contact owned by someone else (user_id: 99):
def edit?
user.admin? || record.user_id == user.id
# false || (99 == 5)
# false || false
# => false ❌ DENIED → Pundit::NotAuthorizedError
end
The key insight: Pundit is just a fancy way to call your Ruby methods with the right arguments. Instead of cluttering your controller with authorization logic, you write a clean policy class and Pundit orchestrates the call. Your authorization logic resides in a single testable location.
Where Do user and record Come From?
When you write:
class ContactPolicy < ApplicationPolicy
def edit?
user.admin? || record.user_id == user.id
end
end
Where do user and record come from?
They come from the ApplicationPolicy base class. When you install Pundit, it generates this file at app/policies/application_policy.rb:
class ApplicationPolicy
attr_reader :user, :record
def initialize(user, record)
@user = user
@record = record
end
end
When you call authorize(@contact) in your controller:
- Pundit creates:
ContactPolicy.new(current_user, @contact) - The initializer sets:
@user = current_user(the logged-in user)@record = @contact(the object you’re authorizing)
- The
attr_readermakes them accessible asuserandrecordin your methods
So user is the current logged-in user (whatever current_user returns), and record is the specific object you passed to authorize.
How Scopes Work
Scopes follow the same pattern but for filtering collections instead of authorizing individual records.
The Flow for Scopes
# Controller
def index
@contacts = policy_scope(Contact)
end
Pundit finds ContactPolicy::Scope, creates ContactPolicy::Scope.new(current_user, Contact), calls .resolve, and returns the filtered ActiveRecord relation.
class ContactPolicy < ApplicationPolicy
class Scope < Scope
def resolve
if user.admin?
scope.all # Admin sees everything
else
scope.where(user_id: user.id) # Users see only their own contacts
end
end
end
end
In scopes, user is the current logged-in user and scope is the model class or relation you passed in (e.g., Contact).
Why class Scope < Scope Looks Weird
You’ll see this pattern and wonder why it looks circular:
class ContactPolicy < ApplicationPolicy
class Scope < Scope # ← Why inherit from itself?
def resolve
# ...
end
end
end
The second Scope isn’t the same as the first. class Scope creates a new class called ContactPolicy::Scope, and < Scope makes it inherit from ApplicationPolicy::Scope, which itself inherits from Pundit::Scope. It’s just Ruby class inheritance, but nested classes make it look circular.
The shorthand works because you’re already inside ContactPolicy, which inherits from ApplicationPolicy, so Ruby knows what Scope means in that context.
Pundit::Scope defines the base structure:
module Pundit
class Scope
attr_reader :user, :scope
def initialize(user, scope)
@user = user
@scope = scope
end
end
end
Setting Up Pundit in Your Controllers
To get access to authorize and policy_scope methods, include Pundit in your ApplicationController:
class ApplicationController < ActionController::Base
include Pundit
end
Now in any controller:
class ContactsController < ApplicationController
def index
@contacts = policy_scope(Contact) # Filters the collection
end
def show
@contact = Contact.find(params[:id])
authorize @contact # Checks if user can view this specific contact
end
def edit
@contact = Contact.find(params[:id])
authorize @contact # Checks if user can edit this specific contact
end
def update
@contact = Contact.find(params[:id])
authorize @contact
if @contact.update(contact_params)
redirect_to @contact
else
render :edit
end
end
end
Use authorize(@record) for show, edit, update, and destroy actions to check whether this user can perform the action on this specific record. Use policy_scope(Model) for index actions to return only the records this user is allowed to see.
Class-Level Authorization
Sometimes you need to authorize an action before you have a specific record:
# Can this user create a new contact?
def new
authorize Contact # Checks ContactPolicy#new?
@contact = Contact.new
end
# Can this user export all contacts?
def bulk_export
authorize Contact, :export? # Checks ContactPolicy#export?
# ... export logic
end
Your policy handles this:
class ContactPolicy < ApplicationPolicy
def new?
user.present? # Only logged-in users can create contacts
end
def export?
user.admin? # Only admins can bulk export
end
end
When you call authorize(Contact), the record inside your policy will be the Contact class itself, not an instance. Write separate methods like new? or create? for class-level actions.
Enforce Authorization Checks Automatically
Make Rails fail loudly if you forget to authorize or scope. Add these enforcement checks to your ApplicationController:
class ApplicationController < ActionController::Base
include Pundit
# Ensure authorize is called on non-index actions
after_action :verify_authorized, except: :index
# Ensure policy_scope is called on index actions
after_action :verify_policy_scoped, only: :index
end
Now, if you forget to call authorize or policy_scope, Rails will raise an error in development. This catches authorization bugs before they reach production.
When you need to skip authorization intentionally:
def public_landing
skip_authorization
# ... public content
end
def health_check
skip_authorization
render json: { status: 'ok' }
end
If you manually scope records instead of using policy_scope:
def custom_index
@contacts = Contact.where(published: true).where(user_id: current_user.id)
skip_policy_scope
end
This enforcement pattern transforms authorization from “remember to do this” to “the framework ensures you do this.”
Understanding Test Failures
If your tests throw Pundit::NotAuthorizedError, Pundit::AuthorizationNotPerformedError, or Pundit::PolicyScopingNotPerformedError after adding enforcement checks, the system is working as designed. Check if you signed in a user, called authorize or policy_scope, or need to call skip_authorization or skip_policy_scope for intentionally public or manually-scoped actions.
Reusing Scopes Outside Controllers
Scopes aren’t just for controllers. You can reuse them anywhere:
# In a background job
class GenerateReportJob
def perform(user)
contacts = ContactPolicy::Scope.new(user, Contact).resolve
# ... generate report
end
end
# In a service object
class ContactExporter
def initialize(user)
@user = user
end
def export
contacts = ContactPolicy::Scope.new(@user, Contact).resolve
# ... export logic
end
end
You can also include Pundit to get access to the policy_scope helper method:
class ContactExporter
include Pundit
def initialize(user)
@user = user
end
def export
contacts = policy_scope(Contact) # Uses @user automatically
# ... export logic
end
private
def pundit_user
@user # Tell Pundit which user to authorize with
end
end
You can also use policy scopes within other Pundit policy methods:
class ContactPolicy < ApplicationPolicy
def index?
# Check if user has access to any contacts
allowed_contacts = Scope.new(user, Contact).resolve
allowed_contacts.exists?
end
class Scope < ApplicationPolicy::Scope
def resolve
if user.admin?
scope.all
else
scope.where(owner: user)
end
end
end
end
This keeps your access rules centralized and DRY.
Conclusion
When a request comes in, the controller calls authorize(@record). Pundit finds the matching policy, creates an instance, and calls the appropriate method. Your code decides by returning true or false, and Pundit enforces the decision by allowing the action or raising an error.
Quick reference: authorize(@record) checks if user can perform the action on a specific record. policy_scope(Model) filters collections to what user can see. In policies, user is the current logged-in user, record is the object being authorized, and scope is the model/relation being filtered.
The shift from frontend permission checks to backend policy objects feels weird at first. But once it clicks, you’ll appreciate having all your authorization rules in one testable place instead of scattered across controllers and views.