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):

  1. Symbol target — if you pass authorize! :admin, it looks up AdminPolicy
  2. policy_class method — if the record responds to policy_class, that value is used
  3. policy_name method — if the record responds to policy_name, that string is used as the class name
  4. Class name inference — converts the record’s class name to a policy name (e.g., ProductProductPolicy)
  5. Namespace traversal — tries parent namespaces if a scoped class doesn’t have its own policy

Inspecting the Default Chain

# In a Rails console
ActionPolicy::LookupChain.chain
# => [#<Proc ...>, #<Proc ...>, ...]

Replacing the Entire Chain

You can replace the chain completely with your own array of probes:

config/initializers/action_policy.rb
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 fail
ActionPolicy::LookupChain.chain << lambda { |record, **options|
# Custom fallback logic
}
# Prepend: runs before all default probes
ActionPolicy::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:

app/policies/null_policy.rb
class NullPolicy < ActionPolicy::Base
# deny? is the default rule — used when the specific rule isn't found
default_rule :any?
def any?
false
end
end
# config/initializers/action_policy.rb
ActionPolicy::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:

config/initializers/action_policy.rb
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 record
policy_class = ActionPolicy.lookup(product)
# => ProductPolicy
# With options
policy_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
end
end

Or at the instance level:

class Document < ApplicationRecord
def policy_class
if classified?
ClassifiedDocumentPolicy
else
DocumentPolicy
end
end
end

The 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
end
end

Or configure namespace on the controller itself:

class Admin::BaseController < ApplicationController
authorize_target_namespace Admin
end
Powered by WebContainers