Custom Lookup Chain
When you call authorize! record, Action Policy needs to find the right policy class for that record. It does this using a lookup chain — an ordered list of strategies. Understanding and customizing this chain lets you handle unusual naming conventions, multi-tenant apps, and graceful fallback behavior.
How the Default Chain Works
The lookup chain is an array of lambdas. Each lambda receives the record and any options, and returns a policy class or nil. Action Policy tries each probe in order and uses the first non-nil result.
The default chain handles these cases (in order):
- Symbol target — if you pass
authorize! :admin, it looks upAdminPolicy policy_classmethod — if the record responds topolicy_class, that value is usedpolicy_namemethod — if the record responds topolicy_name, that string is used as the class name- Class name inference — converts the record’s class name to a policy name (e.g.,
Product→ProductPolicy) - Namespace traversal — tries parent namespaces if a scoped class doesn’t have its own policy
Inspecting the Default Chain
# In a Rails consoleActionPolicy::LookupChain.chain# => [#<Proc ...>, #<Proc ...>, ...]Replacing the Entire Chain
You can replace the chain completely with your own array of probes:
ActionPolicy::LookupChain.chain = [ # Custom probe: check a registry first lambda { |record, **options| PolicyRegistry.find_for(record) }, # Fall back to standard naming convention lambda { |record, **options| "#{record.class.name}Policy".safe_constantize }]Each probe must return a policy class (not an instance) or nil to pass to the next probe.
Extending the Chain
More commonly, you want to add to the chain rather than replace it. Append to use as a fallback, or prepend to use as a first check:
# Append: runs after all default probes failActionPolicy::LookupChain.chain << lambda { |record, **options| # Custom fallback logic}
# Prepend: runs before all default probesActionPolicy::LookupChain.chain.unshift(lambda { |record, **options| # Custom priority logic})The NullPolicy Pattern
A common use of custom lookup chains is adding a NullPolicy fallback — a policy that denies everything, used when no specific policy exists for a record.
Without a fallback, Action Policy raises ActionPolicy::NotFound when it can’t find a policy. With a NullPolicy fallback, unknown record types are silently denied rather than raising an error:
class NullPolicy < ActionPolicy::Base # deny? is the default rule — used when the specific rule isn't found default_rule :any?
def any? false endend
# config/initializers/action_policy.rbActionPolicy::LookupChain.chain << ->(_record, **_options) { NullPolicy }Now any record without a policy class will be handled by NullPolicy, which denies everything.
Multi-Tenant Policy Selection
In a multi-tenant application, different tenants might have different authorization rules. Use the lookup chain to select policies based on tenant context:
ActionPolicy::LookupChain.chain.unshift( lambda { |record, **options| tenant = options[:tenant] || Current.tenant next nil unless tenant
# Look for tenant-specific policy: "AcmeCorp::ProductPolicy" "#{tenant.namespace}::#{record.class.name}Policy".safe_constantize })Then pass the tenant when authorizing:
authorize! product, context: { tenant: current_tenant }Programmatic Lookup
Use ActionPolicy.lookup to find a policy class without authorizing:
# Find the policy for a recordpolicy_class = ActionPolicy.lookup(product)# => ProductPolicy
# With optionspolicy_class = ActionPolicy.lookup(product, namespace: Admin)# => Admin::ProductPolicy (if it exists)
# Returns nil if no policy found (won't raise)policy_class = ActionPolicy.lookup(UnknownRecord.new)# => NullPolicy (if you've added the fallback above)This is useful for building generic tools that need to discover policies without knowing the record type in advance.
Per-Record Policy Override
As an alternative to customizing the global chain, individual model classes can declare their own policy:
class LegacyPost < ApplicationRecord # Override the default ProductPolicy naming convention def self.policy_class PostPolicy endendOr at the instance level:
class Document < ApplicationRecord def policy_class if classified? ClassifiedDocumentPolicy else DocumentPolicy end endendThe lookup chain checks for policy_class on both the class and the instance, so this pattern works without any chain customization.
Namespace-Aware Lookup
For admin namespaces, Action Policy checks for a namespaced version of the policy when you pass namespace::
# Controllers can pass namespace through authorize!class Admin::ProductsController < Admin::BaseController def update authorize! @product, namespace: Admin # Looks for Admin::ProductPolicy, falls back to ProductPolicy endendOr configure namespace on the controller itself:
class Admin::BaseController < ApplicationController authorize_target_namespace Adminend