โ† Back to Course

SRP Series ยท 4 of 7

Policy Objects

Where the pattern comes from, why authorization keeps escaping into the wrong files, and how Forem uses ListingPolicy to keep auth rules co-located with the resources they protect.

Where this rule comes from

Authorization in Rails has been through several generations. The earliest apps put it on the user model, user.admin?, user.can_edit?(post), user.can_destroy?(comment). Every "who is allowed to do what" question became a predicate method on User. When the app had three resources, this was manageable. When it had thirty, the User model had hundreds of methods named after every other model in the codebase.

The first generation of authorization gems tried to fix this by giving the rules their own home. CanCan, released by Ryan Bates in 2009, introduced a single Ability class that contained every authorization rule in the system. The rules used a DSL: can :update, Post, user_id: user.id. It pulled the rules off User, but it put them all into one file, which became its own kind of problem at scale. A rule change for listings meant editing the same file that owned rules for comments, articles, and reactions.

Pundit, released by Jonas Nicklas at Elabs in 2012, applied SRP to the problem itself. Instead of one Ability class for the whole app, Pundit gives every resource its own policy class, located at app/policies/{resource}_policy.rb. The Listing model gets ListingPolicy. The Article model gets ArticlePolicy. Each policy class contains the predicates for that resource and only that resource. One file per resource means one team's rule changes affect one team's policy file.

The underlying rule the pattern enforces: "who you are" and "what you are allowed to do to a Listing" are different responsibilities, owned by different teams, and they should not live in the same class. The User model owns identity, profile, account state. The ListingPolicy owns the authorization rules around listings. The Listing model itself owns the data and the domain logic, and it never asks the user-permission question directly.

The anti-pattern

Authorization in a Rails app without policies lands in one of two places, and both have specific failure modes.

Place one: the User model.

class User < ApplicationRecord
  def can_edit_listing?(listing)
    listing.user_id == id || org_admin?(listing.organization_id)
  end

  def can_destroy_listing?(listing)
    can_edit_listing?(listing)
  end

  def can_create_listing?
    confirmed? && !suspended?
  end

  # ... 30 more can_*? methods for every other resource
end

Every team's authorization tweak ends up touching User. The listings team adds a rule, the articles team adds a rule, the comments team adds a rule. User becomes a file that owns "what is a user" plus thirty unrelated authorization predicates. Five teams sharing one file means five reasons that file changes, and five sources of regressions every time someone edits it.

Place two: the controller.

class ListingsController < ApplicationController
  before_action :authorize_owner_or_admin, only: [:edit, :update, :destroy]

  private

  def authorize_owner_or_admin
    listing = Listing.find(params[:id])
    unless listing.user_id == current_user.id ||
           current_user.org_admin?(listing.organization_id)
      head :forbidden
    end
  end
end

Better than the User-model version, but it has its own problem. The same authorization rule will land in any other place a Listing can be edited. An admin controller. An API controller. A GraphQL resolver. An importer that updates listings on behalf of a user. Each one re-implements the rule, slightly differently each time. The day the auth team changes the rule, four files need to update at once, and missing one is a security bug.

There is a third problem: the controller version is impossible to test in isolation. To verify "Alice can edit her own listing but cannot edit Bob's," you need to spin up a request, sign in as Alice, hit the controller, check the response. That same coverage at the policy-class level is two lines of Ruby and runs in milliseconds.

How Forem solves it

Forem (the codebase behind dev.to) uses Pundit, and every resource that can be authorized has its own policy class. Here is the real Listing one:

# forem/app/policies/listing_policy.rb ยท @9eb974c4
# License: AGPL-3.0

class ListingPolicy < ApplicationPolicy
  def edit?
    user_author? || authorized_organization_admin_editor?
  end

  alias update? edit?
  alias delete_confirm? edit?
  alias destroy? edit?

  def authorized_organization_poster?
    user.org_member?(record.organization_id)
  end

  private

  def user_author?
    record.user_id == user.id
  end

  def authorized_organization_admin_editor?
    user.org_admin?(record.organization_id)
  end
end

Read it carefully. Four design choices are doing the work:

The class name matches the resource it protects. ListingPolicy answers every authorization question about listings. If you want to know what is allowed for listings, you open one file. Locality is the whole point.

One predicate per controller action. edit?, update?, destroy?, show?, index?. The naming convention mirrors RESTful actions, so the controller side becomes a one-liner: authorize(@listing) automatically picks the right predicate based on the action name.

Aliases collapse equivalent rules. "If you can edit, you can update and destroy" is expressed in three alias lines, not three near-identical methods. The rule is stated once and reused; there is no drift between "edit" and "destroy" because they literally point at the same code.

Private helpers spell out the rule in plain English. user_author? and authorized_organization_admin_editor?. Reading the class top to bottom is the spec for "who can edit a listing." A new engineer onboarding to the team can answer that question in thirty seconds without grepping the codebase.

The controller side is one method call per action:

class ListingsController < ApplicationController
  def edit
    @listing = Listing.find(params[:id])
    authorize(@listing)   # Pundit checks ListingPolicy#edit?
  end

  def update
    @listing = Listing.find(params[:id])
    authorize(@listing)   # Pundit checks ListingPolicy#update?
    @listing.update!(listing_params)
    redirect_to @listing
  end
end

Why this design holds up

Four benefits, each one addressing a specific failure of the no-policy version.

User stops being the answer to every authorization question. The User model describes who the user is, the columns, the associations, the account state. The ListingPolicy describes what someone can do to a listing. A new resource means a new policy file, not a User edit. The number of teams editing User collapses back to one.

The same policy is consulted from every caller. Controller, API endpoint, GraphQL resolver, background importer, console session, every one of them asks ListingPolicy.new(user, listing).edit?. There is no risk of the API and the web UI enforcing different rules, because there is only one rule and every caller routes through it.

Tests get fast. A policy spec instantiates the policy with a user and a record and asserts on the predicates. No HTTP, no DB transactions, no controller cycle. "Alice can edit her own listing, Alice cannot edit Bob's, an org admin can edit any listing in their org" is three test cases, three assertions, milliseconds each.

Auditability is built in. "Who can edit a listing?" is one file. Compliance audits, security reviews, new-hire onboarding, all benefit from this kind of locality. The question "where do we enforce permissions for X?" has a one-word answer: XPolicy.

A caveat on scopes

Pundit also lets policies own visibility, not only action permissions, through a nested Scope class inside the policy file. "Which listings can this user even see?" belongs in the same place as "Can this user edit a listing?" because the answer comes from the same set of facts about the user and the resource. Forem and most Pundit-shaped apps put visibility scopes and action predicates in the same policy file. The SRP framing is: everything that decides what a user can do or see with respect to listings.

Resist the urge to put business logic in policy classes only because it happens to involve a user. Policies are about permission, not behavior. If a method calculates a price, transitions a status, or sends an email, it does not belong in a policy. Policies answer yes-or-no questions. Anything else belongs in a service object or on the model.

The principle at play

Authorization is not a property of the user, and it is not a property of the resource. It is a relationship between the two of them, evaluated in a specific context. That relationship has its own reasons to change (new compliance rules, new role types, new business logic), and those reasons are separate from "who is the user" and "what is the resource." Giving the relationship its own class is the SRP move.

What Pundit got right, in retrospect, was sizing the unit of code to match the unit of change. CanCan had one Ability class for the whole app, one file, every team's rules. Pundit has one policy class per resource, one file, one team's rules. Smaller scope, less coordination between teams, fewer regressions when one team's rule moves.

The pattern generalizes. Any time a question is asked across many places in your codebase about a specific resource, visibility, billing, eligibility, validation, extract a class whose name names the resource and whose methods name the questions. Authorization is the most common case, but the technique is the same for anything that has the same "asked everywhere, owned by one team" shape.

Practice exercise

  1. Run grep -rEn "(def can_|def authorized_|head :forbidden|return false unless current_user)" app/.
  2. Look at the output. Are authorization decisions scattered across models, controllers, and helpers? Count the distinct files. If the count is more than two or three, authorization is escaping into too many places.
  3. Pick one resource (Listing, Article, Order, whatever you have). Trace every authorization decision about that resource across the codebase.
  4. Sketch the policy class. List the action predicates (create?, edit?, update?, destroy?, show?, index?) and group them with aliases when the rules are identical.
  5. Bonus: if you find the same auth rule implemented in two different places (controller + helper, controller + admin controller, web + API), write down which one is more strict. The looser one is the bug. Policy extraction prevents that class of bug by construction.