authorization-pundit
SKILL.md
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
authorizeandpolicy_scope -
verify_authorizedafter_action enabled - Views use
policy(@record).action? - Error handling configured
- Multi-tenancy enforced in Scope
- All tests GREEN
Weekly Installs
2
Repository
dchuk/rails_ai_agentsFirst Seen
7 days ago
Security Audits
Installed on
opencode2
gemini-cli2
antigravity2
claude-code2
windsurf2
codex2