Skip to content

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 from create?, etc.)

🚨 Critical

  • create? and read? default to false. 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_scope must call default_relation_scope(relation) explicitly — never super. Bypassing it triggers verify_default_relation_scope_applied!.
  • For has_cents fields, use the virtual name (:price), NEVER :price_cents.
  • Don't put *_attributes hashes in permitted_attributes_for_*. Nested forms are extracted from the form definition, not the policy. List the association name (:variants) and the nested_input in the definition handles the rest.
  • Custom action ⇒ policy method. action :publish needs def publish?. Undefined methods return false → action silently disappears.
  • Index has no record. Record-dependent _for_read overrides need an explicit _for_index too (see below).

Base class

ruby
# 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
end

Authorization context

Inside a policy:

VariableDescription
userCurrent authenticated user (required)
recordResource being authorized
entity_scopeCurrent scoped entity (multi-tenancy)
parentParent record for nested resources (nil otherwise)
parent_associationAssociation name on parent (e.g. :comments)

Action permissions

Must override

ruby
def create?  # default: false
  user.present?
end

def read?    # default: false
  true
end

Derived (inherit automatically)

MethodInherits fromOverride 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:

ruby
def publish? = update? && record.draft?
def archive? = create? && !record.archived?
def invite_user? = user.admin?

Bulk actions — per-record authorization

ruby
def bulk_archive?
  create? && !record.locked?    # checked per record in the selection
end

How 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

ruby
# 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]
end

Derived

MethodInherits from
permitted_attributes_for_updatepermitted_attributes_for_create
permitted_attributes_for_indexpermitted_attributes_for_read
permitted_attributes_for_showpermitted_attributes_for_read
permitted_attributes_for_newpermitted_attributes_for_create
permitted_attributes_for_editpermitted_attributes_for_update

Per-action override

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

Index has no record

🚨 permitted_attributes_for_index is evaluated at the collection levelrecord is nil. permitted_attributes_for_show (and _for_read) ARE evaluated per record.

If you write a record-dependent _for_read:

ruby
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.

ruby
def permitted_attributes_for_index
  %i[title content]                          # no record-dependent fields
end

Same rule for permitted_attributes_for_create vs _for_new (new has no persisted record).

Conditional attribute access

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

Definition 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

ruby
# ❌ NEVER
def permitted_attributes_for_create
  [
    :name,
    {variants_attributes: [:id, :name, :_destroy]},
    {comments_attributes: [:id, :body, :_destroy]}
  ]
end

Plutonium 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=>[...]}].

ruby
# ✅ Policy permits just the association name
def permitted_attributes_for_create
  [:name, :variants]
end

nested_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

ruby
def permitted_associations
  %i[comments tags author]
end

Declares 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 resource otherwise).

This is NOT the same as:

  • Nested forms — declared with nested_input :variants in the definition, requires accepts_nested_attributes_for on the model. See Resource › Definition › Nested inputs.
  • Association fields on tables / show details — controlled by permitted_attributes_for_index / _for_show listing 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!.

ruby
# ✅ 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)
end

Wrong patterns

ruby
# ❌ 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

  1. If a parent is present (nested resource), scopes via the parent association.
  2. 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:

ruby
relation_scope do |relation|
  skip_default_relation_scope!
  relation
end

Before reaching for this, consider a separate, unscoped portal.

Portal-specific policies

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

Custom authorization context

ruby
# 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
end

Authorization errors

ruby
# Failed authorization raises ActionPolicy::Unauthorized

# Handle globally
rescue_from ActionPolicy::Unauthorized do
  redirect_to root_path, alert: "Not authorized"
end

Common patterns

Block archived records

ruby
def update?  = !record.try(:archived?) && super
def destroy? = !record.try(:archived?) && super

Owner-based

ruby
def update?  = record.author == user || user.admin?
def destroy? = update?

Role-based

ruby
def create? = user.admin? || user.editor?

def update?
  return true if user.admin?
  user.editor? && record.author == user
end

Status-based

ruby
def update?
  return false if record.archived?
  owner? || admin?
end

Time-based

ruby
def update?
  return false if record.created_at < 24.hours.ago
  owner?
end

Debugging

ruby
# Console
user = User.find(1)
post = Post.find(1)

policy = PostPolicy.new(post, user: user)
policy.update?
policy.permitted_attributes_for_update

Released under the MIT License.