Authorization with Pundit for Rails 8
Overview
Pundit provides policy-based authorization:
-
Plain Ruby policy objects
-
Convention over configuration
-
Easy to test with Minitest
-
Scoped queries for collections
-
Works with any authentication system
Quick Start
bundle add pundit bin/rails generate pundit:install bin/rails generate pundit:policy Event
TDD Workflow
Authorization Progress:
- Step 1: Write policy test (RED)
- Step 2: Run test (fails)
- Step 3: Implement policy
- Step 4: Run test (GREEN)
- Step 5: Add policy to controller
- Step 6: Test integration
Base Policy
app/policies/application_policy.rb
class ApplicationPolicy attr_reader :user, :record
def initialize(user, record) @user = user @record = record end
def index? false end
def show? false end
def create? false end
def new? create? end
def update? false end
def edit? update? end
def destroy? false end
class Scope def initialize(user, scope) @user = user @scope = scope end
def resolve
raise NotImplementedError, "Define #resolve in #{self.class}"
end
private
attr_reader :user, :scope
end end
Policy Testing (Minitest)
Basic Policy Test
test/policies/event_policy_test.rb
require "test_helper"
class EventPolicyTest < ActiveSupport::TestCase setup do @account = accounts(:one) @user = users(:one) # belongs to @account @other_user = users(:other_account) # different account @event = events(:one) # belongs to @account end
-- index --
test "index? permits any authenticated user" do policy = EventPolicy.new(@user, Event) assert policy.index? end
-- show --
test "show? permits user from same account" do policy = EventPolicy.new(@user, @event) assert policy.show? end
test "show? denies user from different account" do policy = EventPolicy.new(@other_user, @event) assert_not policy.show? end
-- create --
test "create? permits user from same account" do new_event = Event.new(account: @account) policy = EventPolicy.new(@user, new_event) assert policy.create? end
-- update --
test "update? permits user from same account" do policy = EventPolicy.new(@user, @event) assert policy.update? end
test "update? denies user from different account" do policy = EventPolicy.new(@other_user, @event) assert_not policy.update? end
-- destroy --
test "destroy? permits user from same account" do policy = EventPolicy.new(@user, @event) assert policy.destroy? end
test "destroy? denies user from different account" do policy = EventPolicy.new(@other_user, @event) assert_not policy.destroy? end
-- Scope --
test "Scope returns events for user account only" do scope = EventPolicy::Scope.new(@user, Event).resolve
scope.each do |event|
assert_equal @user.account_id, event.account_id
end
end
test "Scope excludes other account events" do other_event = events(:other_account) scope = EventPolicy::Scope.new(@user, Event).resolve
assert_not_includes scope, other_event
end end
Role-Based Policy Test
test/policies/event_policy_test.rb (role-based extension)
class EventPolicyRoleTest < ActiveSupport::TestCase setup do @admin = users(:admin) @member = users(:one) @event = events(:one) end
test "destroy? permits admin" do policy = EventPolicy.new(@admin, @event) assert policy.destroy? end
test "publish? permits owner for draft events" do @event.update(status: :draft) policy = EventPolicy.new(@member, @event) assert policy.publish? end
test "publish? denies for non-draft events" do @event.update(status: :published) policy = EventPolicy.new(@member, @event) assert_not policy.publish? end end
Policy Implementation
Basic Policy (Account-Scoped)
app/policies/event_policy.rb
class EventPolicy < ApplicationPolicy def index? true end
def show? owner? end
def create? true end
def update? owner? end
def destroy? owner? end
private
def owner? record.account_id == user.account_id end
class Scope < ApplicationPolicy::Scope def resolve scope.where(account_id: user.account_id) end end end
Role-Based Policy
app/policies/event_policy.rb
class EventPolicy < ApplicationPolicy def index? true end
def show? owner? || admin? end
def create? member_or_above? end
def update? owner_or_admin? end
def destroy? admin? end
def publish? owner_or_admin? && record.draft? end
private
def owner? record.account_id == user.account_id end
def admin? user.admin? end
def member_or_above? user.member? || user.admin? end
def owner_or_admin? owner? || admin? end
class Scope < ApplicationPolicy::Scope def resolve if user.admin? scope.all else scope.where(account_id: user.account_id) end end end end
Controller Integration
app/controllers/events_controller.rb
class EventsController < ApplicationController def index @events = policy_scope(Event) end
def show @event = Event.find(params[:id]) authorize @event end
def create @event = current_account.events.build(event_params) authorize @event
if @event.save
redirect_to @event, notice: t(".success")
else
render :new, status: :unprocessable_entity
end
end
def destroy @event = Event.find(params[:id]) authorize @event @event.destroy redirect_to events_path, notice: t(".success") end end
Ensuring Authorization
app/controllers/application_controller.rb
class ApplicationController < ActionController::Base include Pundit::Authorization
after_action :verify_authorized, except: :index after_action :verify_policy_scoped, only: :index
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
private
def user_not_authorized flash[:alert] = t("pundit.not_authorized") redirect_back(fallback_location: root_path) end end
Testing Controller Authorization
test/controllers/events_controller_test.rb
require "test_helper"
class EventsControllerTest < ActionDispatch::IntegrationTest setup do @user = users(:one) @other_user = users(:other_account) @event = events(:one) # belongs to @user's account @other_event = events(:other_account) sign_in @user end
test "allows access to own events" do get event_path(@event) assert_response :success end
test "denies access to other account events" do get event_path(@other_event) assert_redirected_to root_path end
test "allows deletion of own events" do assert_difference("Event.count", -1) do delete event_path(@event) end assert_redirected_to events_path end
test "denies deletion of other account events" do assert_no_difference("Event.count") do delete event_path(@other_event) end assert_redirected_to root_path end end
View Integration
<%# app/views/events/show.html.erb %> <h1><%= @event.name %></h1>
<% if policy(@event).edit? %> <%= link_to t("common.edit"), edit_event_path(@event) %> <% end %>
<% if policy(@event).destroy? %> <%= button_to t("common.delete"), @event, method: :delete, data: { confirm: t("common.confirm_delete") } %> <% end %>
Headless Policies
For actions not tied to a specific record:
app/policies/dashboard_policy.rb
class DashboardPolicy < ApplicationPolicy def initialize(user, _record = nil) @user = user end
def show? true end
def admin_panel? user.admin? end end
Controller
authorize :dashboard, :admin_panel?
Error Messages
config/locales/en.yml
en: pundit: not_authorized: You are not authorized to perform this action.
Checklist
-
Policy test written first (RED)
-
Policy inherits from ApplicationPolicy
-
Scope defined for collections
-
Controller uses authorize and policy_scope
-
verify_authorized after_action enabled
-
Views use policy(@record).action?
-
Error handling configured
-
Multi-tenancy enforced in Scope
-
All tests GREEN