Authorization determines what authenticated users can do within an application. While authentication answers "who are you?", authorization answers "what can you access?" Rails 8 applications need a clean, maintainable authorization layer that scales with complexity.
Action Policy has emerged as the go-to authorization library for modern Rails applications. It builds on the policy object pattern popularized by Pundit but adds powerful features like scoping, caching, and better testing support. This tutorial covers implementing Action Policy from scratch in a Rails 8 application.
Setting Up Action Policy
Start by adding Action Policy to the Gemfile and running the installation generator:
# Gemfile
gem "action_policy", "~> 0.7"
Run the generator to create the base policy class:
# Terminal
bundle install
rails g action_policy:install
This creates the application policy that all other policies inherit from:
# app/policies/application_policy.rb
class ApplicationPolicy < ActionPolicy::Base
# Configure authorization context
authorize :user, allow_nil: true
# Default deny - explicit allowlisting is safer
def index?
false
end
def show?
false
end
def create?
false
end
def update?
false
end
def destroy?
false
end
private
def owner?
record.user_id == user.id
end
def admin?
user&.admin?
end
end
The authorize :user line defines the authorization context. Action Policy automatically looks for a current_user method in controllers and maps it to user within policies.
Building Resource Policies
Consider a project management application where users can create projects, and projects contain tasks. Different authorization rules apply based on ownership and role:
# app/policies/project_policy.rb
class ProjectPolicy < ApplicationPolicy
# Scoping determines which records appear in listings
relation_scope do |relation|
if admin?
relation.all
else
relation.where(user_id: user.id)
.or(relation.joins(:memberships).where(memberships: { user_id: user.id }))
end
end
def index?
true # Anyone authenticated can view their projects
end
def show?
owner? || member? || admin?
end
def create?
user.present? # Any authenticated user
end
def update?
owner? || admin?
end
def destroy?
owner? || admin?
end
# Custom actions beyond CRUD
def archive?
owner? && record.tasks.incomplete.none?
end
def invite_member?
owner? || admin?
end
private
def member?
record.memberships.exists?(user_id: user.id)
end
end
The relation_scope block filters Active Record relations based on authorization rules. This prevents N+1 authorization checks when displaying lists.
Controller Integration
Action Policy integrates cleanly with Rails controllers through the authorize! method and automatic scoping:
# app/controllers/projects_controller.rb
class ProjectsController < ApplicationController
before_action :set_project, only: [:show, :edit, :update, :destroy, :archive]
def index
# authorized_scope applies the relation_scope from the policy
@projects = authorized_scope(Project.all).includes(:user, :tasks)
end
def show
authorize! @project
end
def new
@project = Project.new
authorize! @project
end
def create
@project = current_user.projects.build(project_params)
authorize! @project
if @project.save
redirect_to @project, notice: "Project created successfully."
else
render :new, status: :unprocessable_entity
end
end
def update
authorize! @project
if @project.update(project_params)
redirect_to @project, notice: "Project updated successfully."
else
render :edit, status: :unprocessable_entity
end
end
def destroy
authorize! @project
@project.destroy
redirect_to projects_path, notice: "Project deleted."
end
def archive
authorize! @project
if @project.archive!
redirect_to projects_path, notice: "Project archived."
else
redirect_to @project, alert: "Cannot archive project with incomplete tasks."
end
end
private
def set_project
@project = Project.find(params[:id])
end
def project_params
params.require(:project).permit(:name, :description, :status)
end
end
When authorization fails, Action Policy raises ActionPolicy::Unauthorized. Handle this exception globally:
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
rescue_from ActionPolicy::Unauthorized do |exception|
respond_to do |format|
format.html do
flash[:alert] = "You are not authorized to perform this action."
redirect_back fallback_location: root_path
end
format.json { render json: { error: "Unauthorized" }, status: :forbidden }
format.turbo_stream do
render turbo_stream: turbo_stream.update(
"flash",
partial: "shared/flash",
locals: { alert: "You are not authorized to perform this action." }
)
end
end
end
end
View Layer Authorization
Action Policy provides view helpers to conditionally render UI elements based on permissions:
# app/views/projects/show.html.erb
<%= @project.name %>
<%% if allowed_to?(:update?, @project) %>
<%= link_to "Edit", edit_project_path(@project), class: "btn btn-secondary" %>
<%% end %>
<%% if allowed_to?(:archive?, @project) %>
<%= button_to "Archive", archive_project_path(@project),
method: :patch,
class: "btn btn-warning",
data: { turbo_confirm: "Archive this project?" } %>
<%% end %>
<%% if allowed_to?(:destroy?, @project) %>
<%= button_to "Delete", @project,
method: :delete,
class: "btn btn-danger",
data: { turbo_confirm: "Permanently delete this project?" } %>
<%% end %>
<%% if allowed_to?(:invite_member?, @project) %>
<%= link_to "Invite Members", new_project_membership_path(@project),
class: "btn btn-primary" %>
<%% end %>
<%= @project.description %>
This approach keeps authorization logic in policies while views remain declarative about what they want to display.
Testing Policies
Action Policy includes RSpec matchers for clean, readable policy tests:
# spec/policies/project_policy_spec.rb
require "rails_helper"
RSpec.describe ProjectPolicy do
let(:user) { create(:user) }
let(:admin) { create(:user, :admin) }
let(:project) { create(:project, user: owner) }
let(:owner) { create(:user) }
let(:member) { create(:user) }
before do
create(:membership, project: project, user: member)
end
describe "#show?" do
it "allows the owner" do
expect(described_class).to be_authorized_to(:show?, project)
.with_context(user: owner)
end
it "allows members" do
expect(described_class).to be_authorized_to(:show?, project)
.with_context(user: member)
end
it "allows admins" do
expect(described_class).to be_authorized_to(:show?, project)
.with_context(user: admin)
end
it "denies random users" do
expect(described_class).not_to be_authorized_to(:show?, project)
.with_context(user: user)
end
end
describe "#update?" do
it "allows the owner" do
expect(described_class).to be_authorized_to(:update?, project)
.with_context(user: owner)
end
it "denies members" do
expect(described_class).not_to be_authorized_to(:update?, project)
.with_context(user: member)
end
end
describe "#archive?" do
context "when project has incomplete tasks" do
before { create(:task, project: project, completed: false) }
it "denies even the owner" do
expect(described_class).not_to be_authorized_to(:archive?, project)
.with_context(user: owner)
end
end
context "when all tasks are complete" do
before { create(:task, project: project, completed: true) }
it "allows the owner" do
expect(described_class).to be_authorized_to(:archive?, project)
.with_context(user: owner)
end
end
end
describe "relation_scope" do
let!(:owned_project) { create(:project, user: user) }
let!(:member_project) { create(:project).tap { |p| create(:membership, project: p, user: user) } }
let!(:other_project) { create(:project) }
it "returns owned and member projects for regular users" do
scope = described_class.new(Project.all, user: user).apply_scope(Project.all, type: :relation)
expect(scope).to include(owned_project, member_project)
expect(scope).not_to include(other_project)
end
it "returns all projects for admins" do
scope = described_class.new(Project.all, user: admin).apply_scope(Project.all, type: :relation)
expect(scope).to include(owned_project, member_project, other_project)
end
end
end
Pre-checks and Policy Composition
Action Policy supports pre-checks that run before every authorization rule. This centralizes common logic:
# app/policies/application_policy.rb
class ApplicationPolicy < ActionPolicy::Base
authorize :user, allow_nil: true
# Pre-checks run before the actual rule
pre_check :allow_admins
pre_check :deny_guests
private
def allow_admins
allow! if admin?
end
def deny_guests
deny! if user.nil?
end
def admin?
user&.admin?
end
end
The allow! method short-circuits and grants access. The deny! method short-circuits and refuses access. This eliminates repetitive admin checks across every policy method.
Common Mistakes to Avoid
Forgetting to authorize: Add a verification callback to catch missing authorization calls during development:
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
after_action :verify_authorized, except: [:index], unless: :skip_authorization?
after_action :verify_policy_scoped, only: [:index], unless: :skip_authorization?
private
def skip_authorization?
devise_controller? || self.class.name.start_with?("ActiveStorage")
end
end
Authorizing in views only: View-level checks hide UI elements but do not protect endpoints. Always authorize in controllers—view helpers are supplementary.
Over-fetching then filtering: Use authorized_scope to filter at the query level rather than loading all records and filtering in Ruby.
Summary
Action Policy provides a structured approach to authorization that scales with application complexity. Policies encapsulate authorization logic in testable classes. Scopes handle collection filtering efficiently. Pre-checks eliminate repetitive code. The pattern keeps controllers thin and views declarative while maintaining security at every layer.
For applications requiring role-based access control beyond simple ownership checks, consider combining Action Policy with a roles gem like Rolify. The policy layer remains the same—only the helper methods change to query role assignments rather than simple boolean flags.