Migrating from Pundit

If your application already uses Pundit, you don’t have to rewrite everything at once. Action Policy supports a two-phase migration strategy: first make your existing Pundit policies work under Action Policy, then gradually rewrite them to use Action Policy’s full feature set.

Quick Reference: Pundit to Action Policy

Before diving into the migration, here’s the complete mapping of concepts:

PunditAction Policy
authorize recordauthorize! record
authorize record, :action?authorize! record, to: :action?
policy(record).edit?allowed_to?(:edit?, record)
policy_scope(Post)authorized_scope(Post.all)
skip_authorizationskip_authorization
skip_policy_scopeskip_verify_authorized
after_action :verify_authorizedverify_authorized (callback)
after_action :verify_policy_scopedverify_authorized with scope check
Pundit::NotAuthorizedErrorActionPolicy::Unauthorized
pundit_policy_authorized?authorization_performed?
pundit_policy_scoped?authorization_scope_performed?
policy_class (Scope inner class)relation_scope block in policy
UserContext objectAuthorization context fields

Phase 1: Compatibility Layer

The compatibility layer lets your existing Pundit policies run through Action Policy without rewriting them. This is useful when you have a large codebase and want to migrate incrementally.

Step 1: Swap the gems

# Gemfile
# gem "pundit" # Remove
gem "action_policy"

Step 2: Update ApplicationController

Replace Pundit’s include with Action Policy:

# Before (Pundit)
class ApplicationController < ActionController::Base
include Pundit::Authorization
rescue_from Pundit::NotAuthorizedError, with: :handle_unauthorized
private
def handle_unauthorized
redirect_to root_path, alert: "Not authorized"
end
end
# After (Action Policy)
class ApplicationController < ActionController::Base
include ActionPolicy::Controller
rescue_from ActionPolicy::Unauthorized, with: :handle_unauthorized
private
def handle_unauthorized
redirect_to root_path, alert: "Not authorized"
end
end

Step 3: Add Pundit compatibility to ApplicationPolicy

Pundit policies inherit from ApplicationPolicy with attr_reader :user, :record. Action Policy uses record and user as well, but with different base class conventions. Add a shim:

app/policies/application_policy.rb
class ApplicationPolicy < ActionPolicy::Base
# Pundit uses attr_reader :user, :record
# Action Policy provides these automatically, but the names differ
# (Action Policy uses 'record' for the target, 'user' for the context)
# If your Pundit policies used a UserContext object, unwrap it here:
# def user
# authorization_context[:user]
# end
end

Step 4: Migrate Scope classes

Pundit’s inner Scope class:

# Pundit style
class PostPolicy < ApplicationPolicy
class Scope < Scope
def resolve
if user.admin?
scope.all
else
scope.where(published: true)
end
end
end
end

Action Policy uses blocks instead. Migrate to relation_scope:

# Action Policy style
class PostPolicy < ApplicationPolicy
relation_scope do |scope|
if user.admin?
scope.all
else
scope.where(published: true)
end
end
end

Step 5: Update controller calls

# Before (Pundit)
def index
@posts = policy_scope(Post)
end
def show
authorize @post
end
# After (Action Policy)
def index
@posts = authorized_scope(Post.all)
end
def show
authorize! @post
end

Step 6: Update view helpers

<%# Before (Pundit) %>
<% if policy(@post).edit? %>
<%= link_to "Edit", edit_post_path(@post) %>
<% end %>
<%# After (Action Policy) %>
<% if allowed_to?(:edit?, @post) %>
<%= link_to "Edit", edit_post_path(@post) %>
<% end %>

Step 7: Update RSpec helpers (if applicable)

# Before (Pundit)
RSpec.configure do |config|
config.include Pundit::Authorization, type: :request
end
# After (Action Policy)
RSpec.configure do |config|
config.include ActionPolicy::RSpec::Helpers, type: :request
end

Phase 2: Full Rewrite

Once all controllers and views are migrated to the Action Policy API, you can start adopting Action Policy’s more powerful features that don’t exist in Pundit.

Add Pre-checks

Pundit has no concept of pre-checks. Action Policy lets you define conditions that short-circuit all rules:

class PostPolicy < ApplicationPolicy
# Before: you checked user.admin? in every rule
# After: declare it once
pre_check :allow_admin?
def allow_admin?
allow! if user.admin?
end
def update?
record.user_id == user.id # No more admin check needed here
end
def destroy?
record.user_id == user.id # Or here
end
end

Add Caching

Action Policy’s memoization works without any extra setup. Repeated calls to the same rule on the same record return cached results:

class ApplicationPolicy < ActionPolicy::Base
# This is already included by default
# include ActionPolicy::Policy::CachedApply
end

Add Failure Reasons

Pundit raises Pundit::NotAuthorizedError with minimal context. Action Policy can tell you why authorization failed:

class PostPolicy < ApplicationPolicy
def update?
# Wrap checks with allowed_to? to populate failure reasons
allowed_to?(:update_content?, record) &&
allowed_to?(:update_metadata?, record)
end
def update_content?
record.user_id == user.id
end
def update_metadata?
user.editor?
end
end
# In the controller:
rescue_from ActionPolicy::Unauthorized do |e|
# e.result.reasons.full_messages gives specific failure messages
redirect_to root_path, alert: e.result.reasons.full_messages.first
end

Add Rule Aliases

Eliminate duplication with aliases:

class PostPolicy < ApplicationPolicy
def update?
record.user_id == user.id
end
# Instead of def edit? ; update? ; end
alias_rule :edit?, to: :update?
end

Migration Tips

  1. Migrate one controller at a time. You can run Pundit and Action Policy side by side temporarily — each controller can use one or the other.

  2. Run your existing tests throughout. If you have Pundit policy specs, they’ll continue to work as long as your policy class structure is compatible. Switch them over to Action Policy’s test helpers after the migration.

  3. Start with the easiest policies. Simple policies with just a few rules are easier to migrate and verify than complex policies with many checks.

  4. Check for UserContext. Some Pundit setups pass a UserContext wrapper object instead of a raw user. Action Policy’s authorization context is a hash, so you may need to adjust how you pass user data.

Powered by WebContainers