class ApplicationPolicy
attr_reader :user, :record
def initialize(user, record)
@user = user
@record = record
end
def index?
false
end
def show?
false
end
def create?
false
end
def update?
false
end
def destroy?
false
end
class Scope
attr_reader :user, :scope
def initialize(user, scope)
@user = user
@scope = scope
end
def resolve
raise NotImplementedError, "#{self.class} must implement #resolve"
end
end
end
class ArticlePolicy < ApplicationPolicy
def index?
true
end
def show?
record.published? || owner_or_admin?
end
def create?
user.present?
end
def update?
owner_or_admin?
end
def destroy?
owner_or_admin?
end
def permitted_attributes
if user.admin?
[:title, :body, :published, :featured]
else
[:title, :body, :published]
end
end
class Scope < ApplicationPolicy::Scope
def resolve
return scope.all if user&.admin?
scope.where(published: true).or(scope.where(author_id: user&.id))
end
end
private
def owner_or_admin?
return false unless user
user.admin? || record.author_id == user.id
end
end
class ArticlesController < ApplicationController
include Pundit::Authorization
before_action :authenticate_user!, except: %i[index show]
before_action :set_article, only: %i[show edit update destroy]
after_action :verify_authorized, except: :index
after_action :verify_policy_scoped, only: :index
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
def index
@articles = policy_scope(Article).order(created_at: :desc)
end
def show
authorize @article
end
def create
@article = current_user.articles.new
authorize @article
@article.assign_attributes(permitted_attributes(@article))
if @article.save
redirect_to @article, notice: "Article created."
else
render :new, status: :unprocessable_entity
end
end
def update
authorize @article
if @article.update(permitted_attributes(@article))
redirect_to @article, notice: "Article updated."
else
render :edit, status: :unprocessable_entity
end
end
def destroy
authorize @article
@article.destroy
redirect_to articles_path, notice: "Article deleted."
end
private
def set_article
@article = Article.find(params[:id])
end
def user_not_authorized
flash[:alert] = "You are not authorized to perform this action."
redirect_back(fallback_location: root_path)
end
end
This snippet shows how a Pundit-style authorization layer is wired into a Rails app across three collaborating files: the base policy that all policies inherit from, a concrete ArticlePolicy, and the ArticlesController that invokes it. The central idea of the policy object pattern is that authorization logic — the answer to "can this user perform this action on this record?" — lives in a plain Ruby class dedicated to one resource, keeping controllers thin and business rules testable in isolation.
In ApplicationPolicy, the base class captures the two arguments every Pundit policy receives: the user and the record under consideration. The default predicate methods (index?, show?, create?, and so on) all return false, following a secure-by-default posture where nothing is permitted unless a subclass explicitly opts in. The nested Scope class encapsulates the other half of authorization: filtering collections down to the records a user is allowed to see, rather than checking one record at a time.
ArticlePolicy overrides those predicates with real rules. show? allows access to published articles or to the owner; update? and destroy? delegate to a private owner_or_admin? helper so ownership logic is defined once. Its inner Scope#resolve narrows the query for non-admins to published articles plus the user's own drafts using an or chain, which is exactly how per-tenant or per-owner visibility is enforced at the database level.
In ArticlesController, authorize @article raises Pundit::NotAuthorizedError when a predicate returns false, and policy_scope(Article) runs the scope resolver so the index only ever loads permitted rows. The rescue_from handler converts that exception into a flash message and redirect, giving a consistent denial response. permitted_attributes even lets the policy declare which params are assignable, so mass-assignment rules track the same object.
The trade-off is discipline: every collection must go through policy_scope and every member action through authorize, or records leak. The verify_authorized and verify_policy_scoped callbacks exist precisely to catch that mistake in development. This pattern shines once authorization rules grow beyond a single if current_user.admin? check.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.