Rails 8 Authorization with Action Policy

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.

11 claps
← Back to Blog