Using Pundit for authorization in Rails - recipes and best practices

Written by Akshay Sasidharan on July 29, 2018; tagged under rails

Web applications involving user management has two parts to it, which is authentication and authorization. And you don’t get to authorization without authentication, as we can’t determine what you can do unless we know who you are in the first place.

Hand rolling out user authentication is a tedious task and majority of the Rails community has delegated out authentication to cool gems such as Devise.

So in this post, we will be talking about another awesome gem which you can leverage to delegate out authorization. And that is Pundit.

So what is Pundit?

When there arises need for restricting access to your application for certain users, role based authorization comes into play. This is where you can make leverage of Pundit. Pundit helps us to define policies which are PORC - Plain Old Ruby Classes - which means that the class does not inherit from other classes nor include in other modules from the framework. Thus makes it very easy to understand the code.

We would still need to define roles for our Users. But now the advantage is that we get to keep our controllers and models skinny. Policies that you define takes away code complexity from the model/controller which otherwise would have been used to determine access to a particular page. Makes our life easy, don’t you think?

Setting up Pundit

It’s very easy to set it up into your application. The documentation for the gem is well explained.

Nonetheless, let me put it down here:

The Policies will be defined in app/policies/ directory. And don’t forget to restart the Rails server so that Rails can pick up new classes that you define there.

Understanding Policies

Like mentioned earlier, policies are PORC, which houses the authorization for a particular page.

Let’s look at a policy class example taken out from the documentation.

  class PostPolicy
    attr_reader :user, :post

    def initialize(user, post)
      @user = user
      @post = post
    end

    # CRUD actions
    def update?
      user.admin? or not post.published?
    end
  end

This is a policy defined to impose restriction for updating a post if the user is an admin, or if the post is unpublished.

Characteristics of Policy class

NOTE: Incase the controller does not have access to current_user method we can define a pundit_user method which will then be used instead.

def pundit_user
  User.find_by_other_means
end

We can further abstract this Policy if we run the generator rails g pundit:install, which creates an Application policy with defaults for controller actions and also takes care of the initialization part. This can be inherited by other policies.

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

  class Scope
    attr_reader :user, :scope

    def initialize(user, scope)
      @user = user
      @scope = scope
    end

    def resolve
      scope
    end
  end
end

But hold on sec, what is a class Scope doing in the generated ApplicationPolicy?. And that is what makes Pundit even more awesome, which we will be getting into soon.

With this generated base policy can simpify our PostPolicy as

  class PostPolicy < ApplicationPolicy
    # Here we are overriding :update? inherited from ApplicationPolicy
    def update?
      user.admin? or not record.published?
    end
  end

With this setup in place, let’s see what changes at the controller level:

class PostController < ApplicationController
  def update
    post = current_user.posts.find(params[:id])
    authorize post
    if post.update(post_params)
      redirect_to post
    else
      render :edit
    end
  end

  # other controller actions
end

With this code in plate, update action of the controller when invoked is authorized and the authorize method that we invoke here will retrieve the policy for the given record, initialize it with the record and current user and finally throw an error if the user is not authorized to perform the given action.

Understanding Scopes

Scopes are just like using the scopes you define for a model. But in our case, these scopes are done within the policy in context of the user’s role for a particular controller action. Scopes are used to retrieve a subset of the records that we have. For example, in a blog app, a non admin user should be restricted to see only posts which has been published but not in draft state. I see you already imagining the controllers and models becoming thinner.

Let’s rework our Post policy:

class PostPolicy < ApplicationPolicy
  # Inheriting from the application policy scope generated by the generator
  class Scope < Scope
    def resolve
      if user.admin?
        scope.all
      else
        scope.where(published: true)
      end
    end
  end

  def update?
    user.admin? or not record.published?
  end
end

Here we have created a class which will scope the posts based on the user’s role. And in order to use it in our controller, we just need to make use of the method policy_scope.

Characteristics of Scope class

So now, we revise our Post controller’s index to be like:

class PostController < ApplicationController
  def new
    # code to render new view
  end

  def create
    # code to create
  end

  def edit
    # code to render edit
  end

  def update
    post = current_user.posts.find(params[:id])
    authorize post
    if post.update(post_params)
      redirect_to post
    else
      render :edit
    end
  end

  def show
    # code to render show
  end

  def index
    policies = policy_scope(Post)
    # code to render index
  end
end

The index action will show only published posts unless the user is an admin.

Good Practices that can be leveraged using pundit

Keeping authorization explicit

Rather than making authorization or scoping implicit we rather be explict about it. We can add in checks at the ApplicationController level so that exception is raised if we forget to add in authorize or policy_scope in our controller.

class ApplicationController < ActionController::Base
  include Pundit
  after_action :verify_authorized, except: :index
  after_action :verify_policy_scoped, only: :index
end

But still, we can make use of skip_authorization or skip_policy_scope in circumstances where you don’t want to disable verification for the entire action.

Keeping a closed system

If we are making use of a Base policy such as ApplicationPolicy. We can fail gracefully if at all an unauthenticated user makes through.

class ApplicationPolicy
  def initialize(user, record)
    raise Pundit::NotAuthorizedError, "must be logged in" unless user
    @user = user
    @record = record
  end
end

Handling errors on authorization

Since Pundit::NotAuthorizedError will be raised if not authorized, we’d need to handle it gracefully. This can be done by making use of rescue_from directive for Pundit::NotAuthorizedError and then pass in a method to handle the exception.

We can also go a step further and customize error messages based on which policy’s action was not authorized.

class ApplicationController < ActionController::Base
  protect_from_forgery
  include Pundit

  rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized

  private

  def user_not_authorized
    policy_name = exception.policy.class.to_s.underscore

    flash[:error] = t "#{policy_name}.#{exception.query}", scope: "pundit", default: :default
    redirect_to root_path
  end
end

And you can have your locale file to be like this:

en:
 pundit:
   default: 'You cannot perform this action.'
   post_policy:
     update?: 'You cannot edit this post!'
     create?: 'You cannot create posts!'

This is a way to setup error messages for authorization as here we make use of the information NotAuthorizedError provide ie. what query (e.g. :create?), what record (e.g. an instance of Post), and what policy (e.g. an instance of PostPolicy) caused the error to be raised. Ultimately, it’s up to you on how you organize your locale files. Alternatively, we can also serve them with 403 error page by configuring in application.rb

config.action_dispatch.rescue_responses["Pundit::NotAuthorizedError"] = :forbidden

Extending policy with multiple roles

Often there comes in requirement that a particular CRUD action’s authorization varies on multiple roles. In context of our example, say, there also comes in a role ‘premium’. And now there exists posts which can be viewed by premium users and the admin only. No worries, just create a new ‘premium’ role and update our PostPolicy as below:

class PostPolicy < ApplicationPolicy
  # Inheriting from the application policy scope generated by the generator
  class Scope < Scope
    def resolve
      if user.admin?
        scope.all
      elsif user.premium?
        scope.where(published: true)
      else
        scope.where(published: true, premium: false)
      end
    end
  end

  def update?
    user.admin? || !record.published?
  end

  def show?
    return user.premium? || user.admin? if record.premium?
    true
  end
end

With the above changes now a normal user can’t view premium posts in the index view listings as we are scoping it out and also we are authorizing the show page as to not allow non-premium users to see premium post content. Pretty neat isn’t it? We no longer need to delegate the app execution flow to model or controller and let Pundit do all the heavy lifting.

This gives us fine granularity in controlling role based access and now that we understand how Pundit is structured and what conventions we need to follow, writing authorization code becomes intuitive. Skinny controllers and skinny models FTW!

If you have any questions or feedback, feel free to drop us a mail at team@codemancers.com.