Plutonium Policies
🚨 Critical (read first)
- Use generators.
pu:res:scaffoldandpu:res:conncreate policies — never hand-write policy files. - Never bypass
default_relation_scope. Overridingrelation_scopewith a rawwhere(organization: …)or manual joins skips entity scoping and triggersverify_default_relation_scope_applied!at runtime. Compose like this:relation_scope { |r| default_relation_scope(r).where(archived: false) }. Calldefault_relation_scope(r)explicitly —superis unreliable inside the DSL block. Full rules inplutonium-entity-scoping. - Derived actions inherit.
update?falls back tocreate?,show?falls back toread?— don't duplicate unless the rules genuinely differ. Overridecreate?andread?explicitly; they default tofalse. - Define
permitted_attributes_for_*explicitly. Auto-detection works in development but raises in production. - For
has_centsfields, list the virtual name (:price), not the column (:price_cents). Generators occasionally emit the wrong one — fix it (and verify the model hashas_cents). Seeplutonium-model› Monetary Handling. - Related skills:
plutonium-entity-scoping(tenant-scoped overrides — required forrelation_scope),plutonium-model(associated_with),plutonium-definition(permitted_attributesusage),plutonium-controller(how controllers use policies).
Quick checklist
Writing / editing a policy:
- Confirm the policy was created by
pu:res:scaffoldorpu:res:conn. - Override
create?andread?explicitly — they default tofalse. - Define
permitted_attributes_for_readandpermitted_attributes_for_create(derived methods inherit). - For custom actions, add
def <action>?matching the definition'saction :<action>. - If you need
relation_scope, compose withdefault_relation_scope(relation).where(...)— never bypass it. - For tenant scoping, load
plutonium-entity-scopingand fix the model, not the policy. - Per-portal overrides go in the portal's policy file (created by
pu:res:conn). - Test: log in as a user who should NOT see a record, verify it's filtered out.
Policies are generated automatically - never create them manually:
rails g pu:res:scaffoldcreates the base policyrails g pu:res:conncreates portal-specific policies with attribute permissions
Policies control WHO can do WHAT with resources. Built on ActionPolicy.
Plutonium extends ActionPolicy with:
- Attribute permissions (
permitted_attributes_for_*) - Association permissions (
permitted_associations) - Automatic entity scoping for multi-tenancy
- Derived action methods (e.g.,
update?inherits fromcreate?)
Base Class
# app/policies/resource_policy.rb (generated during install)
class ResourcePolicy < Plutonium::Resource::Policy
# App-wide authorization defaults
end
# app/policies/post_policy.rb (per resource)
class PostPolicy < ResourcePolicy
def create?
user.present?
end
def read?
true
end
def permitted_attributes_for_create
%i[title content]
end
def permitted_attributes_for_read
%i[title content author created_at]
end
end
Action Permissions
Core Actions (Must Override)
def create? # Default: false - MUST override
user.present?
end
def read? # Default: false - MUST override
true
end
Derived Actions (Inherit by Default)
| 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 |
Custom Actions
Define methods matching your action names:
def publish?
update? && record.draft?
end
def archive?
create? && !record.archived?
end
def invite_user?
user.admin?
end
Actions are secure by default - undefined methods return false.
Bulk Action Authorization
Bulk actions (operating on multiple selected records) support per-record authorization:
def bulk_archive?
create? && !record.locked? # Per-record check
end
def bulk_publish?
user.admin? || record.author == user
end
How bulk authorization works:
- Policy method (e.g.,
bulk_archive?) is checked per record in the selection - Backend: If any selected record fails authorization, the entire request is rejected
- UI: Only actions that all selected records support are shown (intersection)
- Records are fetched via
current_authorized_scope- only accessible records can be selected
This provides full per-record authorization while keeping the UI clean - users only see actions they can actually perform on their entire selection.
Attribute Permissions
Core Methods (Must Override for Production)
# What users can see (index, show)
def permitted_attributes_for_read
%i[title content author published_at created_at]
end
# What users can set (create, update)
def permitted_attributes_for_create
%i[title content]
end
Derived Methods (Inherit by Default)
| 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 Attributes
Show different fields for different views:
def permitted_attributes_for_index
%i[title author created_at] # Minimal for list
end
def permitted_attributes_for_read
%i[title content author tags created_at updated_at] # Full for detail
end
Key insight: permitted_attributes_for_* controls which fields appear on each view (form, show, index). The column/field/input/display declarations in the definition only control how those fields render — they do NOT add or remove fields from the page. If your index page is showing fields you didn't want, override permitted_attributes_for_index (it does NOT inherit from _for_read automatically when you want a different shape). The same applies to forms: a field :name in the definition won't be rendered unless :name is in permitted_attributes_for_create/_update.
Anti-pattern: nested_attributes hashes in permitted_attributes
# ❌ DO NOT DO THIS
def permitted_attributes_for_create
[
:name,
{variants_attributes: [:id, :name, :_destroy]},
{comments_attributes: [:id, :body, :_destroy]}
]
end
Plutonium's form pipeline extracts nested params via the form definition (build_form(...).extract_input(...)), not the policy. Hash entries in permitted_attributes_for_* get iterated as field names by the form renderer and end up as literal text inputs with names like model[{:variants_attributes=>[...]}].
The correct pattern:
# ✅ Policy permits just the association name
def permitted_attributes_for_create
[:name, :variants, :comments]
end
# ✅ Definition declares the nested input (this drives both rendering AND param extraction)
class PostDefinition < ResourceDefinition
nested_input :variants do |n|
n.input :name
n.input :is_default, as: :boolean
end
end
# ✅ Model declares accepts_nested_attributes_for + inverse_of on the back-reference
class Post < ApplicationRecord
has_many :variants, inverse_of: :post, dependent: :destroy
accepts_nested_attributes_for :variants, allow_destroy: true, reject_if: :all_blank
end
class Variant < ApplicationRecord
belongs_to :post, inverse_of: :variants # ← required for nested validation
end
See plutonium-definition for the full nested_input API.
Auto-Detection (Development Only)
In development, undefined attribute methods auto-detect from the model. This raises errors in production - always define explicitly.
Association Permissions
Control which associations can be rendered:
def permitted_associations
%i[comments tags author]
end
Used for:
- Nested forms
- Related data displays
- Association fields in tables
Collection Scoping (relation_scope)
Filter which records users can see:
relation_scope do |relation|
relation = default_relation_scope(relation)
user.admin? ? relation : relation.where(author: user)
end
Always compose with default_relation_scope(relation) explicitly — not super. Plutonium enforces this via verify_default_relation_scope_applied!. Anything else (a raw where(organization: ...), manual joins) bypasses Plutonium's tenancy handling and will raise.
For the full rules — why
default_relation_scopeis required, how parent vs entity scoping interact, safe override patterns,skip_default_relation_scope!, and howassociated_withresolution works — see the plutonium-entity-scoping skill. It is the single source of truth for Plutonium tenant scoping.
Portal-Specific Policies
Override policies per portal:
# Base policy
class PostPolicy < ResourcePolicy
def create?
user.present?
end
end
# Admin portal - more permissive
class AdminPortal::PostPolicy < ::PostPolicy
include AdminPortal::ResourcePolicy
def destroy?
true # Admins can always delete
end
def permitted_attributes_for_create
%i[title content featured internal_notes] # More fields
end
end
# Public portal - restricted
class PublicPortal::PostPolicy < ::PostPolicy
include PublicPortal::ResourcePolicy
def create?
false # No public creation
end
end
Common Patterns
Check Model Capabilities
def archive?
return false unless record.respond_to?(:archived!)
return false if record.archived?
user.admin?
end
Prevent Actions on Archived Records
def update?
return false if record.try(:archived?)
super
end
def destroy?
return false if record.try(:archived?)
super
end
Owner-Based Permissions
def update?
record.author == user || user.admin?
end
def destroy?
update? # Same rules as update
end
Role-Based Permissions
def create?
user.admin? || user.editor?
end
def read?
true # Everyone can read
end
def update?
return true if user.admin?
return true if user.editor? && record.author == user
false
end
Conditional Attribute Access
def permitted_attributes_for_create
attrs = %i[title content]
attrs << :featured if user.admin?
attrs << :author_id if user.admin? # Only admins can set author
attrs
end
Authorization Context
Policies have access to:
user # Current user (required)
record # The resource being authorized
entity_scope # Current scoped entity (for multi-tenancy)
parent # Parent record for nested resources (nil if not nested)
parent_association # Association name on parent (e.g., :comments)
Nested Resource Context
For nested resources (e.g., /posts/123/nested_comments), the policy receives:
class CommentPolicy < ResourcePolicy
def create?
# parent is the Post instance
# parent_association is :comments
parent.present? && user.can_comment_on?(parent)
end
relation_scope do |relation|
# super() uses parent and parent_association for scoping
relation = super(relation)
relation
end
end
Custom Context
Add custom context in controllers:
# In policy
class PostPolicy < ResourcePolicy
authorize :department, allow_nil: true
def create?
department&.allows_posting?
end
end
# In controller
class PostsController < ResourceController
authorize :department, through: :current_department
private
def current_department
current_user.department
end
end
Controller Integration
Built-in CRUD actions automatically:
- Call
authorize_current!at the start of each action - Apply
relation_scopefor index/listings - Filter params through
permitted_attributes
After-action callbacks verify authorization was performed - if you add custom actions, you must call authorize_current! yourself or skip verification.
Skip Verification (When Needed)
class PostsController < ResourceController
skip_verify_authorize_current only: [:custom_action]
def custom_action
# Handle authorization manually
end
end
Best Practices
- Always override
create?andread?- They default tofalse - Define attributes explicitly - Auto-detection only works in development
- Call
default_relation_scope(relation)inrelation_scope- Preserves parent/entity scoping (do not rely onsuperfrom inside the block) - Use derived methods - Let
update?inherit fromcreate?when appropriate - Keep policies focused - Authorization logic only, no business logic
- Test edge cases - Archived records, nil associations, role combinations
Related Skills
plutonium- How policies fit in the resource architectureplutonium-definition- Actions that need policy methodsplutonium-controller- How controllers use policies
Converted and distributed by TomeVault — claim your Tome and manage your conversions.