All Articles

Understanding Pundit Authorization

Introduction

Pundit is a minimal authorization gem that implements object-oriented design and plain old ruby classes (PORO). Today, we will be recreating the gem to get a better understanding of Pundit and object-oriented design.

Pundit's entire authorization system is built around a method called authorize. You create policy classes for every controller, and methods corresponding to every action. For example, if you have a post's controller, you might want to make a call to authorize and pass in the Post class.

def create
  authorize Post
  @post = Post.new(post_params)
  if @post.save
    redirect_to @post, notice: 'Post was successfully created.'
  else
    render :new
  end
end

Now in your post policy, you can make authorization decisions based on the current user and the specific record, if there is one. In this example, only admins are allowed to create posts:

class PostPolicy
  attr_reader :user, :record

  def initialize(user, record)
    @user = user
    @record = record
  end
  
  def create?
    user.admin?
  end
end

The policy method simply returns a boolean which if false, raises the Pundit::NotAuthorizedError:

Pundit::NotAuthorizedError:

For actions that involve specific resources, such as an update, you can pass the post instance to authorize:

def update
  @post = Post.find(params[:id])
  authorize @post
  ...
end

The Authorizer Module

We can start our recreation of Pundit my creating a module called Authorizer which will contain all of our authorization logic, and include it in our ApplicationController:

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  include Authorizer
end

# app/controllers/concerns/authorizer.rb
module Authorizer
end

Ok, now we can write the authorize method. The authorize method takes a record and returns that record if the corresponding policy method returns true. Otherwise, it raises the NotAuthorizedError:

def authorize(record)
  return record if policy...
  raise NotAuthorizedError.new(query: query, record: record)
end

The NotAuthorizedError class inherits from Ruby's Error. It takes a query and a record, and returns a call to super, passing in a helpful error message:

class NotAuthorizedError < StandardError
  def initialize(options = {})
    @query  = options[:query]
    @record = options[:record]
    message = "not allowed to #{@query} this #{@record.model_name}"
    super(message)
  end
end

Cool! Now, we have to find a way to get the policy class and method we need from the authorize method. We can find the class through a find_policy method:

def find_policy(record)
  policy = "#{record.model_name}#{"Policy"}".safe_constantize
  policy.new(current_user, record)
end

The find_policy method finds the class based on a string containing the record's model name and the keyword "Policy". For example, if you pass in a post instance, the policy string will be PostPolicy. It then calls safe_constantize, which will return the policy class with the same name as the policy string. Finally, it returns a new instance of the policy class, passing in the current user and record.

Now we have to find the corresponding policy action method. We could pass in the action with each call to authorize, but Rails provides us with an easier way. We can use the action_name method, which returns the name of the controller action and append a question mark. We can then use the policy class we found, and send the method name using public_send:

def authorize(record)
  query = "#{action_name}?"
  return record if find_policy(record).public_send(query)
  raise NotAuthorizedError.new(query: query, record: record)
end

The complete Authorizer module should look like this:

module Authorizer
  def authorize(record)
    query = "#{action_name}?"
    return record if find_policy(record).public_send(query)
    raise NotAuthorizedError.new(query: query, record: record)
  end
  
  def find_policy(record)
    policy = "#{record.model_name}#{"Policy"}".safe_constantize
    policy.new(current_user, record)
  end

  class NotAuthorizedError < StandardError
    def initialize(options = {})
      @query  = options[:query]
      @record = options[:record]
      message = "not allowed to #{@query} this #{@record.model_name}"
      super(message)
    end
  end
end

We can use inheritance to remove some boilerplate from our policy classes by creating an ApplicationPolicy with some useful defaults:

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 new?
    create?
  end

  def update?
    false
  end

  def edit?
    update?
  end

  def destroy?
    false
  end
end

The PostPolicy now looks like this:

class PostPolicy < ApplicationPolicy
  def create?
    false
  end
end

We re-created a simple version of the pundit gem in 21 lines of code! Hopefully, you now have a better understanding of Pundit and object-oriented design.