I18n for Authorization Messages
Hard-coding error messages in your controller means rewriting them in every app and every language. Action Policy has built-in I18n support so your authorization messages live in locale files — and translate automatically.
The Default Message
Right now the controller falls back to a generic string:
rescue_from ActionPolicy::Unauthorized do |ex| message = ex.result.message || "Access denied." redirect_to products_path, alert: messageendWhen no locale translations exist yet, result.message returns nil and the fallback fires. Let’s fix that.
Creating the Locale File
Open . It already has a skeleton:
en: action_policy: policy: product: # Add your policy-specific translations hereAction Policy looks up messages using the key pattern:
action_policy.policy.<identifier>.<rule>The identifier is the policy’s snake_case name without the Policy suffix — so ProductPolicy becomes product. The rule is the predicate method name: create?, update?, destroy?, and so on.
Add translations for the three rules our policy enforces:
en: action_policy: policy: product: update?: "You are not allowed to edit this product" destroy?: "Only administrators can delete products" create?: "You must be logged in to create products" manage?: "You are not authorized to perform this action" unauthorized: "Access denied"Translation Lookup Order
When result.message is called, Action Policy walks through a chain of keys from most specific to most general:
- Policy-specific rule —
action_policy.policy.product.update? - Ancestor policy rule —
action_policy.policy.application.update? - Global rule —
action_policy.manage? - Top-level default —
action_policy.unauthorized
This means you can define a catch-all message at action_policy.unauthorized and override it only where you need something more specific — without touching any Ruby code.
result.message vs result.reasons.full_messages
These two APIs serve different purposes:
# The message for the top-level result (the rule that was applied directly)ex.result.message#=> "You are not allowed to edit this product"
# Messages from nested policy calls (when a policy calls allowed_to? on another policy)ex.result.reasons.full_messages#=> [] # empty for single-policy denialsresult.message gives you the single localized message for the rule that was directly applied — this is what you want for the common case. reasons.full_messages collects messages across a chain of nested allowed_to? calls between policies; it returns an empty array when there are no nested policy checks involved.
Use result.message for single-policy authorization failures. Use reasons.full_messages when you need to surface every reason from a deep policy chain.
Interpolation with details
You can pass data from the policy into the translation string using the details hash:
def update? unless user.present? details[:reason] = :not_logged_in return deny! end
details[:title] = record.name deny!(:not_owner) unless user.id == record.user_id
trueenden: action_policy: policy: product: not_owner: "You cannot edit '%{title}'"Action Policy passes details as interpolation variables automatically. The result:
ex.result.message#=> "You cannot edit 'Leather Wallet'"Demo: Trigger Localized Messages
Save your locale file, then try these in the running app:
- Log out, then click New Product — you should see “You must be logged in to create products.”
- Log in as a regular user, then try to edit a product — you should see “You are not allowed to edit this product.”
- Log in as a regular user, then try to delete a product — you should see “Only administrators can delete products.”
You can also verify in the console:
$ bin/rails consolestore(dev)> policy = ProductPolicy.new(Product.first, user: nil)=> #<ProductPolicy:0x...>store(dev)> policy.apply(:create?)=> falsestore(dev)> policy.result.message=> "You must be logged in to create products"Now let’s move on to testing — making sure our policies are correct and stay correct as the app evolves.
- Preparing Ruby runtime
- Prepare development database
- Starting Rails server