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:
| Pundit | Action Policy |
|---|---|
authorize record | authorize! record |
authorize record, :action? | authorize! record, to: :action? |
policy(record).edit? | allowed_to?(:edit?, record) |
policy_scope(Post) | authorized_scope(Post.all) |
skip_authorization | skip_authorization |
skip_policy_scope | skip_verify_authorized |
after_action :verify_authorized | verify_authorized (callback) |
after_action :verify_policy_scoped | verify_authorized with scope check |
Pundit::NotAuthorizedError | ActionPolicy::Unauthorized |
pundit_policy_authorized? | authorization_performed? |
pundit_policy_scoped? | authorization_scope_performed? |
policy_class (Scope inner class) | relation_scope block in policy |
UserContext object | Authorization 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" # Removegem "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" endend
# 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" endendStep 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:
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] # endendStep 4: Migrate Scope classes
Pundit’s inner Scope class:
# Pundit styleclass PostPolicy < ApplicationPolicy class Scope < Scope def resolve if user.admin? scope.all else scope.where(published: true) end end endendAction Policy uses blocks instead. Migrate to relation_scope:
# Action Policy styleclass PostPolicy < ApplicationPolicy relation_scope do |scope| if user.admin? scope.all else scope.where(published: true) end endendStep 5: Update controller calls
# Before (Pundit)def index @posts = policy_scope(Post)end
def show authorize @postend
# After (Action Policy)def index @posts = authorized_scope(Post.all)end
def show authorize! @postendStep 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: :requestend
# After (Action Policy)RSpec.configure do |config| config.include ActionPolicy::RSpec::Helpers, type: :requestendPhase 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 endendAdd 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::CachedApplyendAdd 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? endend
# 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.firstendAdd 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?endMigration Tips
-
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.
-
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.
-
Start with the easiest policies. Simple policies with just a few rules are easier to migrate and verify than complex policies with many checks.
-
Check for
UserContext. Some Pundit setups pass aUserContextwrapper 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.