Agent skill

plutonium-policy

Plutonium resource policies - authorization, attribute permissions, and scoping

Stars 163
Forks 31

Install this agent skill to your Project

npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/data/plutonium-policy

SKILL.md

Plutonium Policies

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

Base Class

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

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

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

ruby
def bulk_archive?
  create? && !record.locked?  # Per-record check
end

def bulk_publish?
  user.admin? || record.author == user
end

How bulk authorization works:

  1. Policy method (e.g., bulk_archive?) is checked per record in the selection
  2. Backend: If any selected record fails authorization, the entire request is rejected
  3. UI: Only actions that all selected records support are shown (intersection)
  4. 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)

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

ruby
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

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:

ruby
def permitted_associations
  %i[comments tags author]
end

Used for:

  • Nested forms
  • Related data displays
  • Association fields in tables

Collection Scoping

Filter which records users can see:

ruby
relation_scope do |relation|
  if user.admin?
    relation
  else
    relation.where(author: user)
  end
end

With Parent Scoping (Nested Resources)

For nested resources, call super to apply automatic parent scoping:

ruby
relation_scope do |relation|
  relation = super(relation)  # Applies parent scoping automatically

  if user.admin?
    relation
  else
    relation.where(published: true)
  end
end

Parent scoping takes precedence over entity scoping. When a parent is present:

  • For has_many: scopes via parent.association_name
  • For has_one: scopes via where(foreign_key: parent.id)

With Entity Scoping (Multi-tenancy)

When no parent is present, super applies entity scoping:

ruby
relation_scope do |relation|
  relation = super(relation)  # Apply entity scope if no parent

  if user.admin?
    relation
  else
    relation.where(published: true)
  end
end

default_relation_scope is Required

Plutonium verifies that default_relation_scope is called in every relation_scope. This prevents accidental multi-tenancy leaks when overriding scopes.

ruby
# ❌ This will raise an error
relation_scope do |relation|
  relation.where(published: true)  # Missing default_relation_scope!
end

# ✅ Correct - call default_relation_scope
relation_scope do |relation|
  default_relation_scope(relation).where(published: true)
end

# ✅ Also correct - super calls default_relation_scope
relation_scope do |relation|
  super(relation).where(published: true)
end

When overriding an inherited scope:

ruby
class AdminPostPolicy < PostPolicy
  relation_scope do |relation|
    # Replace inherited scope but keep Plutonium's parent/entity scoping
    default_relation_scope(relation)
  end
end

Skipping Default Scoping (Rare)

If you intentionally need to skip scoping, call skip_default_relation_scope!:

ruby
relation_scope do |relation|
  skip_default_relation_scope!
  relation  # No parent/entity scoping applied
end

Consider using a separate portal instead of skipping scoping.

Portal-Specific Policies

Override policies per portal:

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

ruby
def archive?
  return false unless record.respond_to?(:archived!)
  return false if record.archived?

  user.admin?
end

Prevent Actions on Archived Records

ruby
def update?
  return false if record.try(:archived?)
  super
end

def destroy?
  return false if record.try(:archived?)
  super
end

Owner-Based Permissions

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

def destroy?
  update?  # Same rules as update
end

Role-Based Permissions

ruby
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

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

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

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

ruby
# 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_scope for 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)

ruby
class PostsController < ResourceController
  skip_verify_authorize_current only: [:custom_action]

  def custom_action
    # Handle authorization manually
  end
end

Best Practices

  1. Always override create? and read? - They default to false
  2. Define attributes explicitly - Auto-detection only works in development
  3. Call super in relation_scope - Preserves entity scoping
  4. Use derived methods - Let update? inherit from create? when appropriate
  5. Keep policies focused - Authorization logic only, no business logic
  6. Test edge cases - Archived records, nil associations, role combinations

Related Skills

  • plutonium-resource - How policies fit in the resource architecture
  • plutonium-definition-actions - Actions that need policy methods
  • plutonium-controller - How controllers use policies

Expand your agent's capabilities with these related and highly-rated skills.

Didn't find tool you were looking for?

Be as detailed as possible for better results