Authorization
Authorization decides what each user can see and do in Avo: which resources show up in the sidebar, which buttons appear on a record, which records a list returns, and which fields a user can reach. Avo reads these answers from policy classes, using Pundit by default.
# app/policies/post_policy.rb
class PostPolicy < ApplicationPolicy
def index?
true
end
def show?
true
end
def update?
user.admin?
end
def destroy?
user.admin?
end
endAuthorization is off until you set the authorization client. Once it's on, a resource without a policy, or an action whose policy method is missing, is denied. That strictness is controlled by explicit_authorization.
Set up authorization
Add Pundit to your Gemfile. Avo doesn't pull it in for you.
# Gemfile
gem "pundit"If Pundit is new to the app, run bin/rails g pundit:install to generate ApplicationPolicy.
Then tell Avo to use it. The initializer Avo generates sets authorization_client to nil, which turns authorization off, so change that line to :pundit:
# config/initializers/avo.rb
Avo.configure do |config|
config.authorization_client = nil
config.authorization_client = :pundit
endWhile the client is nil, Avo doesn't call your policies, and anyone who can sign in to Avo can reach everything. If your initializer has no authorization_client line, Avo uses :pundit.
Policies receive the current user, so make sure Avo knows who that is. current_user is usually right; see the authentication guide if yours differs.
# config/initializers/avo.rb
Avo.configure do |config|
config.current_user_method = :current_user
endPundit alternative
If you'd rather use another library, such as Action Policy, see Use a different authorization library.
Write a policy
Generate a policy for each model Avo shows:
bin/rails g pundit:policy PostEach method answers one question for the current user and record. Avo calls the standard CRUD methods plus a few of its own:
| Method | Controls |
|---|---|
index? | The sidebar entry and the Index view |
show? | The view button and the Show view |
new? / create? | The "Create new" button, and saving a new record |
edit? / update? | The edit button, and saving changes |
destroy? | The delete button and deleting |
act_on? | The actions dropdown, and running actions |
reorder? | The record reordering controls |
search? | The resource search input |
preview? | The preview field endpoint |
A policy that answers all of them:
# app/policies/post_policy.rb
class PostPolicy < ApplicationPolicy
def index?
true
end
def show?
true
end
def new?
create?
end
def create?
user.admin?
end
def edit?
true
end
def update?
user.admin?
end
def destroy?
user.admin?
end
def act_on?
user.admin?
end
def reorder?
user.admin?
end
def search?
true
end
def preview?
true
end
endcreate? runs when the form is saved, so record already holds the submitted values. Use that if the answer depends on what the user typed.
update? runs twice on a save. Since avo >= v4.3.8 Avo asks it on the record as loaded from the database, then again with the submitted values filled in, and both have to allow the write. A rule like record.user_id == user.id has to hold for the record as it exists, so submitting a new user_id can't make a record pass its own check. A react_on re-render of an open edit form asks edit? the same way.
If you use the menu editor, index? doesn't hide its items. Add the check to the item's visible block instead.
Authorize association controls
The buttons on an association panel are answered by the parent's policy, with methods named after the association: {action}_{association}?. For a Post that has_many :comments:
# app/policies/post_policy.rb
class PostPolicy < ApplicationPolicy
def view_comments?
true
end
def create_comments?
user.admin?
end
def attach_comments?
user.admin?
end
def detach_comments?
record.user_id == user.id
end
def show_comments?
true
end
def edit_comments?
record.user_id == user.id
end
def destroy_comments?
user.admin?
end
def act_on_comments?
user.admin?
end
def reorder_comments?
user.admin?
end
endWARNING
Match the association's pluralization. For has_many :comments, write detach_comments?, not detach_comment?.
Methods for panel-level controls (view_, create_, attach_, act_on_) get the parent Post as record. Methods for row controls (show_, edit_, detach_, destroy_, reorder_) get each Comment. The association policy methods reference lists which is which.
If you want to hide the whole comments panel, use view_comments?. If you want to hide only the view button on each row, use show_comments?.
Most of these methods only control buttons. Access to the comment's own pages is still decided by CommentPolicy, so if CommentPolicy#edit? allows it, a user can reach the edit page by URL even when edit_comments? hides the button. Likewise, running an action from the comments panel also needs CommentPolicy#act_on? for each selected comment, whatever act_on_comments? allows.
view_comments? is the exception: it also answers the requests behind the panel.
- The association's show route. Since
avo >= v4.3.4/resources/posts/:id/comments/:comment_idrenders one comment's show page. Ahas_onepanel loads its frame from it, and for ahas_manyit is reachable by URL. It asksview_comments?, thenCommentPolicy#show?for the comment, and looks ahas_manyrow up insideCommentPolicy::Scope, so a comment the scope doesn't hold is not found. Before that, anyone who could see the post could open any of its comments (GHSA-x65r-p6hf-94hv). - The requests that list the association. Since
avo >= v4.3.8The panel's rows, its search, asummarizablecolumn's chart, and scope counts all askview_comments?. Each has to name the resource the association field lists with, itsuse_resourcewhen the field sets one, and answers404otherwise (GHSA-5qcq-2vq9-62hj).
Reuse another policy for an association
Often the answer for edit_comments? is the same as CommentPolicy#edit?. Include Avo::Authorization::Concerns::PolicyHelpers in ApplicationPolicy and inherit the association's rules from its own policy with inherit_association_from_policy:
# app/policies/application_policy.rb
class ApplicationPolicy
include Avo::Authorization::Concerns::PolicyHelpers
end# app/policies/post_policy.rb
class PostPolicy < ApplicationPolicy
inherit_association_from_policy :comments, CommentPolicy
def destroy_comments?
false
end
endEvery *_comments? method now delegates to CommentPolicy, except the ones you define yourself, like destroy_comments? above.
For a single method, delegate by hand:
# app/policies/post_policy.rb
class PostPolicy < ApplicationPolicy
def edit_comments?
Pundit.policy!(user, record).edit?
end
endKeeping the two sets of rules separate is sometimes what you want. If ContractPolicy#edit? lets admins edit any contract, UserPolicy#edit_contracts? can still hide the edit button when those contracts are listed on a user.
Authorize file attachments
File fields get three methods each, named after the field id: upload_{FIELD_ID}?, download_{FIELD_ID}?, and delete_{FIELD_ID}?.
# app/policies/post_policy.rb
class PostPolicy < ApplicationPolicy
def upload_cover_photo?
user.admin?
end
def download_cover_photo?
true
end
def delete_cover_photo?
user.admin?
end
endIf several fields share the same rule, define them in a loop:
# app/policies/post_policy.rb
class PostPolicy < ApplicationPolicy
[:cover_photo, :audio].each do |file|
[:upload, :download, :delete].each do |action|
define_method "#{action}_#{file}?" do
true
end
end
end
endThe same methods authorize file fields in actions that run on the resource. An action on Avo::Resources::Post with field :cover_photo, as: :file checks PostPolicy#upload_cover_photo?.
Limit which records a user sees
Add a Scope to the policy to filter the records on the Index, Show, and Edit views:
# app/policies/post_policy.rb
class PostPolicy < ApplicationPolicy
class Scope < Scope
def resolve
if user.admin?
scope.all
else
scope.where(published: true)
end
end
end
endThe scope also filters the rows of has_many and has_and_belongs_to_many association panels, using the policy of the resource the panel lists, not the parent's. A post's comments panel shows only what CommentPolicy::Scope returns, or the scope of the policy that resource sets with authorization_policy. The panel's buttons still ask the parent's policy. A has_one panel isn't filtered: the associated resource's show? decides whether its record shows.
The field's scope option runs on the query the policy scope already narrowed, so use it only to narrow further. If you added scope: -> { Pundit.policy_scope(parent, query) } from an earlier version of this page, remove it: it passes the post where Pundit expects the user.
Hide fields from a user
A policy can name which of a resource's fields a user may reach. A withheld field isn't rendered anywhere Avo draws it and can't be set from anywhere.
# app/policies/post_policy.rb
class PostPolicy < ApplicationPolicy
def blacklisted_fields
user.admin? ? [] : [:budget, :internal_notes]
end
endA non-admin never sees budget on the index table, the show page, the edit form, in global search, in a record_link, through the REST API, in the AI chat, or over MCP. They can't set it with a hand-built form submission either, because it's gone from the permitted params too.
Allow or deny fields
Declare blacklisted_fields to deny specific fields, whitelisted_fields to allow only specific fields, or both. The allowlist applies first and the denylist subtracts from it, so a denial on ApplicationPolicy survives an allowlist on a subclass:
# app/policies/application_policy.rb
class ApplicationPolicy
# Nobody reaches api_secret through Avo, whatever a subclass allows.
def blacklisted_fields
[:api_secret]
end
end# app/policies/user_policy.rb
class UserPolicy < ApplicationPolicy
def whitelisted_fields
user.admin? ? :all : [:id, :name, :email]
end
endList field ids, not column names. :price covers both columns behind a money field and :author covers author_id for a belongs_to. An id that matches no field on the current view is ignored, so ApplicationPolicy can deny a field only some resources have.
If a list can't be resolved, Avo raises instead of showing everything. See the errors it raises.
A policy that declares neither list restricts nothing, whatever explicit_authorization says.
A policy that refuses a nil user
Some policies raise in their constructor when there is no current user (raise Pundit::NotAuthorizedError, "must be logged in" if user.nil?), and a resource built in a job, a mailer, a rake task or a model spec has no user. When the policy declares neither list there is nothing to read from it, so avo-authorization swallows that error and the answer is "no restriction". Since avo-authorization >= v4.2.2
Declare a list and the error propagates, because resolution never falls open. Hand those call sites a user, or let the constructor accept a nil user and have the list answer restrictively for it. The list's own body never runs when the constructor raises first.
What a withheld field is withheld from
Denied means invisible and unsettable, from one list. A withheld field is:
- Not rendered on the
Index,ShowandEditviews, including inside apanel,tabsorsidebarblock, and not offered as a dynamic filter. - Not in the permitted params, so a form post naming it doesn't set it. Nested forms inherit this.
- Not used as a record's title. Global search results, breadcrumbs,
record_linkfields and association pickers name the record by its id when its title attribute is withheld. - Not in the REST API's responses, and not settable through it.
- Not returned, named or written by the AI assistant's tools or over the MCP server. Columns with no declared field are never exposed to those surfaces either.
- Not in the changeset an audit trail row previews. The stored row is unchanged.
- Not writable by dragging a card on a kanban board grouped by it.
Trust each interface differently
If a user should see more in the admin panel than through the API or an AI client, branch on Avo::Current.interface:
# app/policies/user_policy.rb
class UserPolicy < ApplicationPolicy
def blacklisted_fields
# The panel is behind SSO; a leaked API token or a connected AI client is not.
case Avo::Current.interface
when :ui then []
else [:home_address, :date_of_birth]
end
end
endCombine with visible:
The policy removes fields first, then any visible: block narrows what's left. Nothing on the resource can bring back a field the policy withholds. Use visible: for rules about the record, such as a field that only makes sense in one state, and the policy for rules about who's asking.
Limits
- Without a client, it's inert. While
authorization_clientisnil, or left unset on a license without theavo-authorizationadd-on, every policy check is skipped, these lists included, so they aren't a security boundary. A client your initializer sets on a license without the add-on refuses every resource instead; see When the license lapses. - Only Avo's surfaces.
record.ssnin a custom partial, aself.search[:item]block, a background job or raw SQL reads the attribute regardless. - A column no field declares can't be governed. To govern a column, declare a field for it.
- "May see but may not write" isn't expressible in one list. See Let a user see a field but not change it.
- Kanban's read side. The column a card sits in discloses the value of the board's grouping property. A drag that would write a withheld field is refused, but the board still renders.
Let a user see a field but not change it
Put the rule in a policy method and call it from the field's disabled option:
# app/policies/product_policy.rb
class ProductPolicy < ApplicationPolicy
def amount?
user.admin?
end
end# app/avo/resources/product.rb
field :amount,
as: :money,
currencies: %w[USD],
disabled: -> { !@resource.authorization.authorize_action(:amount?, raise_exception: false) }Use a different policy for a resource
Avo finds the policy from the resource's model. If two resources share a model but need different rules, set authorization_policy on the resource:
# app/avo/resources/photo_comment.rb
class Avo::Resources::PhotoComment < Avo::BaseResource
self.model_class = "Comment"
self.authorization_policy = PhotoCommentPolicy
endRename the policy methods Avo calls
If your app already uses index?, show? and friends for something else, map Avo to its own methods with authorization_methods:
# config/initializers/avo.rb
Avo.configure do |config|
config.authorization_methods = {
index: "avo_index?",
show: "avo_show?",
edit: "avo_edit?",
new: "avo_new?",
update: "avo_update?",
create: "avo_create?",
destroy: "avo_destroy?",
search: "avo_search?"
}
endAvo now calls avo_index? for the Index view.
Handle missing policies
By default, a missing policy class or method denies the action. If you'd rather allow it, turn off explicit_authorization:
# config/initializers/avo.rb
Avo.configure do |config|
config.explicit_authorization = false
endIt also takes a lambda, if strictness should depend on who's asking:
# config/initializers/avo.rb
Avo.configure do |config|
config.explicit_authorization = -> { !current_user.admin? }
endIf you want a missing policy class to fail loudly instead, set raise_error_on_missing_policy. Avo raises Avo::NoPolicyError instead of silently allowing or denying.
# config/initializers/avo.rb
Avo.configure do |config|
config.raise_error_on_missing_policy = true
endWARNING
With explicit_authorization on, adding a policy with only destroy? denies every other method, so the resource disappears from the sidebar. Define every method whose control you want to show.
When the license lapses
Your policies run only while your license includes the avo-authorization add-on. If your initializer sets authorization_client and the license doesn't include the add-on, Avo fails closed instead of skipping your policies:
- every resource is refused, and lists come back empty;
- show and edit pages answer
404; - a banner in the panel explains why.
That covers a lapsed or downgraded license, and a development, test or CI environment that runs without your license key. The license status page stays reachable, so you can check the license from the panel.
If your initializer has no authorization_client line (Avo then uses :pundit), or sets it to nil, an unlicensed app skips your policies instead, and anyone who can sign in to Avo can reach everything.
When the license can't be validated
A license Avo can't confirm is a different case from one without the add-on. Once the grace period ends, or when no valid answer was ever held, Avo pauses the whole admin with a "Temporarily unavailable" page. No policy is skipped, because no page is served. Since avo-licensing >= v4.4.0
If you set run_community_admin_without_valid_license to true, the admin keeps running instead, and your policies are not applied. Anyone who can sign in to Avo can reach everything, so turn it on only if that is acceptable for your app. A valid license that lacks avo-authorization still fails closed when your initializer sets a client, with the setting on or off.
TIP
If every resource is refused at once, check the license before the policies.
Debug a denied action
When a developer user triggers a denial, Avo logs it. In development, the entry includes the global ids of the user and the record:
web | [Avo->] Unauthorized action 'reorder?' for 'UserPolicy'
web | user: gid://dummy/User/20
web | record: gid://dummy/User/31Load either one in a console with GlobalID::Locator.locate("gid://dummy/User/20").
In production, the entry names only the policy and the action:
web | [Avo->] Unauthorized action 'act_on?' for 'UserPolicy'Use a different authorization library
Write a small client class that adapts your library to Avo, and point authorization_client at it:
# config/initializers/avo.rb
Avo.configure do |config|
config.authorization_client = "Avo::ActionPolicyAuthorizationClient"
endThe client implements authorize, policy, policy! and apply_policy, and raises Avo::NotAuthorizedError on denial rather than returning false. The client contract has the signatures and rules. Here's a complete Action Policy client:
# app/services/avo/action_policy_authorization_client.rb
module Avo
class ActionPolicyAuthorizationClient
include ::ActionPolicy::Behaviour
authorize :user
attr_accessor :user
def authorize(user, record, action, policy_class: nil, **)
self.user = user
authorize!(record, to: action.to_sym, with: policy_class)
rescue ActionPolicy::Unauthorized => error
raise Avo::NotAuthorizedError, error.message
end
def policy(user, record, **)
policy!(user, record)
rescue Avo::NoPolicyError
nil
end
def policy!(user, record, **)
self.user = user
policy_for(record:)
rescue ActionPolicy::NotFound => error
raise Avo::NoPolicyError, error.message
end
def apply_policy(user, model, policy_class: nil, **)
policy = if policy_class.present?
policy_class.new(model, user:)
else
policy!(user, model)
end
policy.apply_scope(model, type: :active_record_relation)
end
end
endAvo passes action as a String ("index?"), and Action Policy looks up alias_rule and default_rule by Symbol. Without to_sym, an aliased rule skips its alias and is denied.
Place policies under the Avo namespace, or configure Action Policy's lookup to match your app:
# app/policies/avo/equipment_policy.rb
module Avo
class EquipmentPolicy < ApplicationPolicy
def index?
true
end
def new?
user.admin?
end
def create?
user.admin?
end
end
endIf you also want field lists, add the two optional field methods to the client. More community examples are in this issue.
Manage roles with Rolify
See the Rolify integration guide to add role management with Avo.