Authorization โ
Control what users can do once authenticated. Plutonium uses ActionPolicy with extensions for attribute permissions and tenant scoping.
Goal โ
For each resource, decide who can create / read / update / destroy / run custom actions, and which fields they can see and edit.
The three layers โ
Every policy controls three things:
- Action permissions:
create?,read?,update?,destroy?, plus your custom action methods. - Attribute permissions:
permitted_attributes_for_create,_for_read, etc. - Collection scope:
relation_scope(which records show up in lists).
๐จ Critical โ
create?andread?default tofalse. Always override them explicitly. Derived methods (update?,show?,index?) inherit automatically.permitted_attributes_for_*must be explicit in production. Dev auto-detects; production raises.relation_scopemust end up callingdefault_relation_scope(relation)somewhere in the chain. Prefer calling it explicitly in your override.superis fine when extending a parent policy (e.g., a package-level base) that itself callsdefault_relation_scope. See Reference โบ Behavior โบ Policies.- Custom action โ policy method.
action :publishneedsdef publish?on the policy. Undefined methods returnfalseโ action silently disappears.
Steps โ
1. Open the generated policy โ
After pu:res:scaffold + pu:res:conn, you have:
app/policies/post_policy.rb(base policy)packages/admin_portal/app/policies/admin_portal/post_policy.rb(per-portal override, seeded bypu:res:conn)
2. Override create? and read? explicitly โ
class PostPolicy < ResourcePolicy
def create? = user.present?
def read? = true
endThese default to false, without an explicit override, nobody can create or read records.
3. Override derived methods only when rules differ โ
update? inherits from create?. index?/show? inherit from read?. Only override when the rule is genuinely different:
def update?
user.admin? || record.author == user
end
def destroy?
user.admin?
end๐จ record is the resource CLASS on collection routes. read? backs both show? (called with the record) and index? (called with the class: there is no single record to pass). The same goes for create?/new?, export_csv?, search?, and resource-action gates. So def read? = record.published? raises NoMethodError the moment the index renders. Keep record-state rules out of these methods: filter what the list shows in relation_scope (step 6 below), and gate individual records in show? (which always receives the record). Record-action methods like publish? are safe; they are always evaluated against an instance.
4. Declare attribute permissions โ
def permitted_attributes_for_create
%i[title content category]
end
def permitted_attributes_for_read
%i[title content category author published_at created_at]
endIndex has no record instance
permitted_attributes_for_index runs at collection level, where record is the resource class. _for_index falls back to _for_read (and _for_export to _for_index), so if you write a record-dependent _for_read, you MUST also declare an explicit _for_index that never touches record. See Reference โบ Behavior โบ Policies โบ Index has no record.
5. Custom action methods โ
def publish?
update? && record.draft?
end
def archive?
user.admin?
endThe method name matches the action name plus ?. Undefined methods return false.
6. Optionally filter the collection: relation_scope โ
relation_scope do |relation|
default_relation_scope(relation).where(published: true)
end๐จ default_relation_scope(relation) must be called somewhere in the chain, otherwise verify_default_relation_scope_applied! raises at runtime. Calling it explicitly here is safest. super works only when the parent policy also calls it.
Common patterns โ
Owner-based โ
def update? = record.author == user || user.admin?
def destroy? = update?Role-based โ
def create? = user.admin? || user.editor?
def update?
return true if user.admin?
user.editor? && record.author == user
endBlock archived records โ
def update? = !record.try(:archived?) && super
def destroy? = !record.try(:archived?) && superConditional attribute access โ
def permitted_attributes_for_create
attrs = %i[title content]
attrs += %i[featured author_id] if user.admin?
attrs
endTime-based โ
def update?
return false if record.created_at < 24.hours.ago
owner?
endBulk action authorization: per record โ
def bulk_archive?
create? && !record.locked? # checked PER record in the selection
end- Backend: if any selected record fails, the entire request is rejected.
- UI: only actions ALL selected records support are shown (intersection).
Records come from current_authorized_scope; users can only select records they can access.
Portal-specific policies โ
class PostPolicy < ResourcePolicy
def create? = user.present?
end
# Admin: more permissive
class AdminPortal::PostPolicy < ::PostPolicy
include AdminPortal::ResourcePolicy
def destroy? = true
def permitted_attributes_for_create = %i[title content featured internal_notes]
end
# Public: read-only
class PublicPortal::PostPolicy < ::PostPolicy
include PublicPortal::ResourcePolicy
def create? = false
endShow-page association tabs โ
def permitted_associations
%i[comments tags author]
endDrives the show-page tablist. Each named association must exist on the model AND be a registered Plutonium resource in every portal that uses the policy; a portal that doesn't register the child raises ArgumentError ... is not a registered resource on its show page. To add a tab in one portal only, put it in a portal-specific policy (rails g pu:res:conn <Resource> --dest=<portal> --policy). See Reference โบ Behavior โบ Policies โบ Association permissions.
Not for nested forms
permitted_associations is for show-page navigation tabs, NOT nested forms. Nested forms come from nested_input :variants in the definition. See Reference โบ Resource โบ Definition โบ Nested inputs.
Multi-tenant scoping โ
When the portal sets scope_to_entity Organization, the inherited relation_scope automatically filters everything to the current org, no work in the policy. To add filters on top:
relation_scope do |relation|
default_relation_scope(relation).where(archived: false)
endSee Multi-tenancy and Reference โบ Tenancy โบ Entity scoping.
Anti-pattern: nested-attributes hashes in policies โ
# โ NEVER
def permitted_attributes_for_create
[:name, {variants_attributes: [:id, :name, :_destroy]}]
endNested params are extracted by the form definition, not the policy. The hash entry renders as a literal text input. Use just the association name:
# โ
Policy permits just the association name
def permitted_attributes_for_create
[:name, :variants]
endnested_input :variants in the definition handles the rest. See Reference โบ Resource โบ Definition โบ Nested inputs.
Custom authorization context โ
# Policy
class PostPolicy < ResourcePolicy
authorize :department, allow_nil: true
def create? = department&.allows_posting?
end
# Controller
class PostsController < ResourceController
authorize :department, through: :current_department
private
def current_department = current_user.department
endCommon issues โ
- Undefined custom action policy method: the button silently disappears (undefined returns
false). Adddef my_action?to the policy. record.Xcrashes during index:recordis the resource class on index, not an instance. Add an explicitpermitted_attributes_for_indexthat doesn't depend onrecord.verify_default_relation_scope_applied!raises: your customrelation_scopedoesn't calldefault_relation_scope(relation). Fix by composing:default_relation_scope(relation).where(...).superinrelation_scope; works when you're extending a parent policy that itself callsdefault_relation_scope. If you're not sure (or you're inheriting fromPlutonium::Resource::Policydirectly), calldefault_relation_scope(relation)explicitly. The runtime check verifiesdefault_relation_scopewas hit somewhere: not that you wrote it in this class.
Related โ
- Reference โบ Behavior โบ Policies: full policy surface
- Reference โบ Tenancy โบ Entity scoping:
default_relation_scope, multi-tenant patterns - Authentication: who's the user in the first place
- Multi-tenancy: entity scoping setup
- Custom actions: defining the actions that need policy methods
