Policy
Authorization for resources. Built on ActionPolicy. Plutonium adds:
- Attribute permissions (
permitted_attributes_for_*) - Association permissions (
permitted_associations) - Automatic entity scoping via
default_relation_scope - Derived action methods (
update?inherits fromcreate?, etc.)
🚨 Critical
create?andread?default tofalse. You MUST override them explicitly. Everything else (update?,destroy?,index?,show?, …) derives from one of those.permitted_attributes_for_*must be explicit in production. Dev auto-detects; production raises.relation_scopemust calldefault_relation_scope(relation)explicitly — neversuper. Bypassing it triggersverify_default_relation_scope_applied!.- For
has_centsfields, use the virtual name (:price), NEVER:price_cents. - Don't put
*_attributeshashes inpermitted_attributes_for_*. Nested forms are extracted from the form definition, not the policy. List the association name (:variants) and thenested_inputin the definition handles the rest. - Custom action ⇒ policy method.
action :publishneedsdef publish?. Undefined methods returnfalse→ action silently disappears. - Index has no
record. Record-dependent_for_readoverrides need an explicit_for_indextoo (see below).
Base class
# app/policies/resource_policy.rb — installed once
class ResourcePolicy < Plutonium::Resource::Policy
end
# app/policies/post_policy.rb — per resource, generated
class PostPolicy < ResourcePolicy
def create? = user.present?
def read? = true
def permitted_attributes_for_create
%i[title content]
end
def permitted_attributes_for_read
%i[title content author created_at]
end
endAuthorization context
Inside a policy:
| Variable | Description |
|---|---|
user | Current authenticated user (required) |
record | Resource being authorized |
entity_scope | Current scoped entity (multi-tenancy) |
parent | Parent record for nested resources (nil otherwise) |
parent_association | Association name on parent (e.g. :comments) |
Action permissions
Must override
def create? # default: false
user.present?
end
def read? # default: false
true
endDerived (inherit automatically)
| Method | Inherits from | Override when |
|---|---|---|
update? | create? | Different update rules |
destroy? | create? | Different delete rules |
index? | read? | Custom listing rules |
show? | read? | Record-specific read rules |
new? | create? | Rarely needed |
edit? | update? | Rarely needed |
search? | index? | Search-specific rules |
typeahead? | index? | Autocomplete on inputs/filters targeting this resource |
Custom actions
Define def <action>? matching the definition's action :<action>. Undefined methods return false:
def publish? = update? && record.draft?
def archive? = create? && !record.archived?
def invite_user? = user.admin?Bulk actions — per-record authorization
def bulk_archive?
create? && !record.locked? # checked per record in the selection
endHow it works:
- Policy is checked per record in the selected set.
- Backend: if any 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're allowed to access.
Attribute permissions
# Must override for production
def permitted_attributes_for_read
%i[title content author published_at created_at]
end
def permitted_attributes_for_create
%i[title content]
endDerived
| Method | Inherits from |
|---|---|
permitted_attributes_for_update | permitted_attributes_for_create |
permitted_attributes_for_index | permitted_attributes_for_read |
permitted_attributes_for_show | permitted_attributes_for_read |
permitted_attributes_for_new | permitted_attributes_for_create |
permitted_attributes_for_edit | permitted_attributes_for_update |
Per-action override
def permitted_attributes_for_index
%i[title author created_at] # minimal for the table
end
def permitted_attributes_for_read
%i[title content author tags created_at] # fuller for the show page
endIndex has no record
🚨 permitted_attributes_for_index is evaluated at the collection level — record is nil. permitted_attributes_for_show (and _for_read) ARE evaluated per record.
If you write a record-dependent _for_read:
def permitted_attributes_for_read
attrs = %i[title content]
attrs << :archive_reason if record.archived? # uses record
attrs
end…you MUST also define an explicit permitted_attributes_for_index — otherwise inheritance kicks in, runs the _for_read body during the table render, and record.archived? blows up on NoMethodError: undefined method 'archived?' for nil.
def permitted_attributes_for_index
%i[title content] # no record-dependent fields
endSame rule for permitted_attributes_for_create vs _for_new (new has no persisted record).
Conditional attribute access
def permitted_attributes_for_create
attrs = %i[title content]
attrs += %i[featured author_id] if user.admin?
attrs
end
def permitted_attributes_for_update
case record.status
when 'draft' then %i[title content category_id]
when 'published' then %i[content] # only the body once published
else []
end
endDefinition declares HOW, policy declares WHAT
permitted_attributes_for_* controls which fields appear on a view. The definition's field/input/display/column declarations only control how they render. A field :name in the definition does nothing unless :name is also in the relevant permitted_attributes_for_*.
Common mistake: adding a definition declaration and wondering why the field doesn't show — check the policy.
Anti-pattern: nested-attributes hashes
# ❌ NEVER
def permitted_attributes_for_create
[
:name,
{variants_attributes: [:id, :name, :_destroy]},
{comments_attributes: [:id, :body, :_destroy]}
]
endPlutonium extracts nested params via the form definition, not the policy. Hash entries here get iterated as field names by the form renderer and render as literal text inputs with names like model[{:variants_attributes=>[...]}].
# ✅ Policy permits just the association name
def permitted_attributes_for_create
[:name, :variants]
endnested_input :variants in the definition handles the rest. See Resource › Definition › Nested inputs.
Auto-detection (dev only)
In development, undefined permitted_attributes_for_* methods auto-detect from the model. Production raises with a clear error:
🚨 Resource field auto-detection: PostPolicy#permitted_attributes_for_create
Auto-detected resource fields result in security holes and will fail outside of development.Always declare explicitly before deploying.
Association permissions
def permitted_associations
%i[comments tags author]
endDeclares which associations get their own tab on the show page. When non-empty, the show page renders a tablist: a "Details" tab (the main field card + metadata aside) plus one tab per association — each lazy-loaded via a frame navigator panel pointing at the associated has_many collection, has_one record, or belongs_to target. When empty, the show page renders without tabs.
Each named association must:
- Exist on the model (raises
ArgumentError: unknown association ...otherwise). - Point to a class that's itself a registered Plutonium resource (raises
... is not a registered resourceotherwise).
This is NOT the same as:
- Nested forms — declared with
nested_input :variantsin the definition, requiresaccepts_nested_attributes_foron the model. See Resource › Definition › Nested inputs. - Association fields on tables / show details — controlled by
permitted_attributes_for_index/_for_showlisting the association name.
Collection scoping (relation_scope)
Filter which records the user can see.
Always compose with default_relation_scope
🚨 relation_scope MUST call default_relation_scope(relation) explicitly. Never super — the semantics depend on how ActionPolicy's DSL registered the scope. Plutonium enforces this at runtime via verify_default_relation_scope_applied!.
# ✅ Best — don't override at all. The inherited scope already calls default_relation_scope.
# ✅ Extra filters on top
relation_scope do |relation|
default_relation_scope(relation).where(archived: false)
end
# ✅ Role-based
relation_scope do |relation|
relation = default_relation_scope(relation)
user.admin? ? relation : relation.where(author: user)
endWrong patterns
# ❌ Manually filtering by entity — bypasses default_relation_scope
relation_scope { |r| r.where(organization: current_scoped_entity) }
# ❌ Manual joins — same problem
relation_scope { |r| r.joins(:project).where(projects: {organization_id: current_scoped_entity.id}) }
# ❌ Missing default_relation_scope entirely — raises at runtime
relation_scope { |r| r.where(published: true) }What default_relation_scope does
- If a parent is present (nested resource), scopes via the parent association.
- Otherwise, applies
relation.associated_with(entity_scope)for multi-tenancy.
Parent scoping takes precedence over entity scoping — the parent was already authorized and entity-scoped during its own authorization, so double-scoping isn't needed.
Full mechanics in Tenancy › Entity scoping.
Intentionally skipping
Rare. Use skip_default_relation_scope! explicitly — never silently bypass:
relation_scope do |relation|
skip_default_relation_scope!
relation
endBefore reaching for this, consider a separate, unscoped portal.
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
endCustom 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
endAuthorization errors
# Failed authorization raises ActionPolicy::Unauthorized
# Handle globally
rescue_from ActionPolicy::Unauthorized do
redirect_to root_path, alert: "Not authorized"
endCommon patterns
Block archived records
def update? = !record.try(:archived?) && super
def destroy? = !record.try(:archived?) && superOwner-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
endStatus-based
def update?
return false if record.archived?
owner? || admin?
endTime-based
def update?
return false if record.created_at < 24.hours.ago
owner?
endDebugging
# Console
user = User.find(1)
post = Post.find(1)
policy = PostPolicy.new(post, user: user)
policy.update?
policy.permitted_attributes_for_updateRelated
- Controllers — call policies via
authorize_current!andauthorized_resource_scope - Interactions — custom actions whose policy methods you define
- Resource › Actions — registering actions that need policy methods
- Tenancy › Entity scoping —
default_relation_scope, three model shapes, custom scopes - ActionPolicy docs — the underlying library
