Skip to content

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:

  1. Action permissions: create?, read?, update?, destroy?, plus your custom action methods.
  2. Attribute permissions: permitted_attributes_for_create, _for_read, etc.
  3. Collection scope: relation_scope (which records show up in lists).

๐Ÿšจ Critical โ€‹

  • create? and read? default to false. 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_scope must end up calling default_relation_scope(relation) somewhere in the chain. Prefer calling it explicitly in your override. super is fine when extending a parent policy (e.g., a package-level base) that itself calls default_relation_scope. See Reference โ€บ Behavior โ€บ Policies.
  • Custom action โ‡’ policy method. action :publish needs def publish? on the policy. Undefined methods return false โ†’ 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 by pu:res:conn)

2. Override create? and read? explicitly โ€‹

ruby
class PostPolicy < ResourcePolicy
  def create? = user.present?
  def read?   = true
end

These 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:

ruby
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 โ€‹

ruby
def permitted_attributes_for_create
  %i[title content category]
end

def permitted_attributes_for_read
  %i[title content category author published_at created_at]
end

Index 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 โ€‹

ruby
def publish?
  update? && record.draft?
end

def archive?
  user.admin?
end

The method name matches the action name plus ?. Undefined methods return false.

6. Optionally filter the collection: relation_scope โ€‹

ruby
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 โ€‹

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

Block archived records โ€‹

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

Conditional attribute access โ€‹

ruby
def permitted_attributes_for_create
  attrs = %i[title content]
  attrs += %i[featured author_id] if user.admin?
  attrs
end

Time-based โ€‹

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

Bulk action authorization: per record โ€‹

ruby
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 โ€‹

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

Show-page association tabs โ€‹

ruby
def permitted_associations
  %i[comments tags author]
end

Drives 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:

ruby
relation_scope do |relation|
  default_relation_scope(relation).where(archived: false)
end

See Multi-tenancy and Reference โ€บ Tenancy โ€บ Entity scoping.

Anti-pattern: nested-attributes hashes in policies โ€‹

ruby
# โŒ NEVER
def permitted_attributes_for_create
  [:name, {variants_attributes: [:id, :name, :_destroy]}]
end

Nested params are extracted by the form definition, not the policy. The hash entry renders as a literal text input. Use just the association name:

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 Reference โ€บ Resource โ€บ Definition โ€บ Nested inputs.

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

Common issues โ€‹

  • Undefined custom action policy method: the button silently disappears (undefined returns false). Add def my_action? to the policy.
  • record.X crashes during index: record is the resource class on index, not an instance. Add an explicit permitted_attributes_for_index that doesn't depend on record.
  • verify_default_relation_scope_applied! raises: your custom relation_scope doesn't call default_relation_scope(relation). Fix by composing: default_relation_scope(relation).where(...).
  • super in relation_scope; works when you're extending a parent policy that itself calls default_relation_scope. If you're not sure (or you're inheriting from Plutonium::Resource::Policy directly), call default_relation_scope(relation) explicitly. The runtime check verifies default_relation_scope was hit somewhere: not that you wrote it in this class.

Released under the MIT License.