Rails pundit tutorial

Posted October 31, 2022 - tagged ruby-on-rails

10 min read

Authorization is an important part of any SaaS. Let's see what it means, and how to implement it with the pundit gem for Rails.

Introducing Pundit

As you probably know, web applications need the ability to assign different roles and permissions.

New developers often confuse two terms - Authorization and Authentication.

Authentication is a method of granting access to users through the process of verifying the claimed identity of the user, device, or other entity using the user's credentials, such as username, email address, password, etc.

Authorization is a method of granting users or a group of users the ability to access data with restrictions or permission to perform only the tasks they are allowed to by assigning user roles or access levels to users or groups of users.

Usually, in web applications, granting limited access distinguishes between administrators and ordinary users. This can be done with a simple boolean that determines if the user is an administrator. However, in production applications, roles and permissions are more complex.

How well roles and access restrictions to actions and data are implemented determines the quality of your application.

In this post, we'll implement roles and permissions in a basic Ruby on Rails application using the Pundit gem.

Pundit is a gem that provides a set of helpers that guide you to use simple Ruby objects and object-oriented design patterns to create an authorization system. It's easy to use, has minimal permissions, and is great for managing role-based authorization using policies defined in simple Ruby classes.

To describe how the gem works, it binds the methods of the required class to the actions of the controller by executing the method corresponding to the action when a request is received. If the response evaluates to false, access is denied and an error is thrown.

To put it simply, Pundit's job is to authorize whether the user is allowed to perform an action or not. Then your policy methods return a boolean and a Pundit::NotAuthorizedError will be raised if it's false.

Policies

Each time you have to check whether something or someone is allowed to perform an action in the application you will refer to the Policy Object pattern. This pattern is used to deal with permissions and roles.

For example, we have a guest user in our application. Using a guest policy object we can check if this user is able to retrieve certain resources. And if the user is an admin, we can easily change guest policy object to an admin policy object with different rules.

We need to stick to these rules when working with Policy Object pattern:

  • The return has to be a boolean value
  • The logic has to be simple
  • Inside the method, we should only call methods on the passed objects

How to work with Pundit

  1. Create a Policy class that handles authorizing access to a specific type of record — whether it be a Post or User, or something else.

  2. Call the built-in authorization function, passing in what you need to authorize access to.

  3. Pundit will find the appropriate Policy class and call the Policy method that matches the name of the method you are authorizing. If it returns true, you have permission to perform the action. If not, it’ll throw an exception.

In which scenarios should you use them:

When your application has more than one type of restricted access and restricted actions. As an example, posts can be created with the following:

  • a restriction that only admins and/or editors can create posts
  • a requirement that editors need to be verified

By default, Pundit provides two objects to your authorization context: the User and the Record being authorized. This is enough if you have a system-wide role in your system like Admin, but not enough if you need to allow more specific context.

As an example, you worked with a system that supported the concept of an Office, with different roles and offices to support. The system-wide authorization would not be able to deal with it because it is unacceptable that an admin of Office One to be able to do things to Office Two unless they are an admin of both. In this case, you would need access to 3 items: the User, the Record, and the user’s role information in the Office.

Pundit provides the ability to provide additional context. You can change what is considered a user by defining a function called pundit_user. The object authorization context from this function will be available to your policies.

# inside application_controller.rb

class ApplicationController < ActionController::Base
  include Pundit

  def pundit_user
    AuthorizationContext.new(current_user, current_office)
  end
end
# inside authorization_context.rb

class AuthorizationContext
  attr_reader :user, :office

  def initialize(user, office)
    @user = user
    @office = office
  end
end
# inside application_policy.rb

class ApplicationPolicy
  attr_reader :request_office, :user, :record

  def initialize(authorization_context, record)
    @user = authorization_context.user
    @office = authorization_context.office
    @record = record
  end

  def index?
    # Your policy has access to @user, @office, and @record.  
  end
end

Create an empty Rails app

Here are the tools I used for this tutorial.

$> ruby --version  
=> 3.1.2  
$> rails --version  
=> 7.0.3.1
$> node --version  
=> 18.6.0  
$> yarn --version  
=> 1.22.19

Let's create our brand new Ruby-on-Rails application. There’s a lot of ways to do this, but the easiest and cleanest way probably is to create a file named Gemfile in your working directory, and fill it like this:

source 'https://rubygems.org'

ruby '3.1.2'

gem 'rails', '~> 7.0.3.1'

And then run

bundle install

So we can now create our application. You may want to simplify this if one day you need to create multiple Rails applications

bin/rails new myapp --database=postgresql
  • rails is the Rails CLI (command line interface) tool
  • new tells the Rails CLI that we want to generate a new application
  • myapp is, well, your app name
  • --database=postgresql is an optional parameter that tells Rails we want to use the PostgreSQL to persist our data (by default Rails has SQLite database)

After generating your new Rails app, you’ll need to cd into your new app and create your database.

Run at terminal

bin/rails db:create

and

bin/rails db:migrate

Great! Now run up your development server

bin/rails s

Make sure you can navigate to your browser at localhost:3000, and if everything has gone well, you should see the Rails default index page.

Now let's add a couple of models so we have something to work with:

bin/rails g model User name:string
bin/rails g model Article title:string body:text user:belongs_to

and then

bin/rails db:migrate

We will add an enum to the User type to be either admin or guest. We also wrote a tutorial about how to add a column. Follow the steps to add in the migration:

bin/rails g migration add_role_to_users role:integer
bin/rails db:migrate

Updating the user model:

class User < ApplicationRecord
  enum role: {
    guest: 0,
    admin: 1
  }
  has_many :articles
end

Pundit gem installation

It is quit easy to set up this gem. For clear instruction, you can check Gem's documentation. Now start to set up it:

Add gem "pundit" to Gemfle:

gem "pundit"

And run

bundle install

Include Pundit::Authorization in your application controller:

class ApplicationController < ActionController::Base
  include Pundit::Authorization
end

Optionally, you can run the generator, which will set up an application policy with some useful defaults for you:

rails g pundit:install

After generating your application policy, restart the Rails server so that Rails can pick up any classes in the new app/policies/ directory.

Adding policies:

mkdir app/policies
touch app/policies/article_policy.rb

Our article_policy.rb with some code:

class ArticlePolicy
  attr_reader :user, :article

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

  def show?
    # a condition which returns a boolean value
  end
end

The ArticlePolicy class has the same name as that of model class, only with the "Policy" suffix. Given that the first argument is a user, in your controller, Pundit will call the current_user method we defined in in ApplicationController, to get what to send into this argument. The second argument is the model object, whose authorization you want to check. And finally, some request method is implemented for the class in this case show?. This will map to the name of a specific controller action. Note that the method names should correspond to controller actions suffixed with a ?. So for controller actions such as new, create, update etc, the policy methods new?, create?, update? etc are to be defined.

Adding policy checks

Let's look at the required code for class ArticlePolicy:

class ArticlePolicy
  attr_reader :user, :article

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

  def index?
    true
    # if set to false - nobody has access
  end

  # Here the condition we want to check is that whether the record's creator is 
  # current user or record is assigned to the current user.
  def show?
    user.has_any_role? :admin, :guest || article.user == user
  end

  # Same as that of the show.
  def edit?
    show?
  end

  # Only admin and owner are allowed to update the article.
  def update?
    user.role == 'admin' || article.user == user
  end

  # For now, every user can create an article.
  def create?
    true
  end

  # Only admin and the user that has created the article, can delete it.
  def destroy?
    user.role == 'admin' || article.user == user
  end
end

Then go to articles_controller.rb:

class ArticlesController < ApplicationController
  before_action :set_article, only: %i[show update destroy]

  def index
    @articles = Article.order(created_at: :desc)
    authorize @articles
  end

  def show
    authorize @article
  end

  def create
    article = current_user.articles.new(article_params)
    authorize article
    article.save!
    respond_with_success(t("successfully_created", entity: "Article"))
  end

  def update
    authorize @article
    @article.update!(article_params)
    respond_with_success(t("successfully_updated", entity: "Article")
  end

  def destroy
    authorize @article
    @article.destroy!
    respond_with_json
  end

  private

  def article_params
    params.require(:article).permit(:title, :body, :user_id)
  end

  def set_article
    @article = Article.find_by!(slug: params[:slug])
  end
end

Update the application_controller.rb to handle an error with flash messages:

class ApplicationController < ActionController::Base

  rescue_from Pundit::NotAuthorizedError, with: :handle_authorization_error

  private

  def handle_authorization_error
    flash[:alert] = "You are not authorized. Access denied."
    redirect_to(request.referrer || root_path)
  end
end

Pundit policy scopes

This allows you to let users with different authorizations see different scopes of items.

For example, admins can see all articles, other users can see articles that have content not blank.

Modify the article_policy.rb:

class ArticlePolicy < ApplicationPolicy
  # ...
  class Scope < Scope
    def resolve
      if @user.has_role? :admin
        scope.all
      else
        scope.where.not(body: "")
      end
    end
  end
end

And then add to your ArticlesController:

class ArticlesController < ApplicationController
  # existing code

  def index
    @articles = policy_scope(Article).order(created_at: :desc)
    authorize @articles
  end
end

Extending policy with multiple roles

In practice, it is quite common to require that the authorization of a particular CRUD action be different for multiple roles. Let's add to our example, say, the role 'supporter'. And now there are articles that can only be viewed by supporter users and admins. We need to create a new 'supporter' role and update our ArticlePolicy as shown below:

class ArticlePolicy < ApplicationPolicy
  # ...
  class Scope < Scope
    def resolve
      if user.admin?
        scope.all
      elsif user.supporter?
        scope.where(published: true)
      else
        scope.where(published: true, supporter: false)
      end
    end
  end

  # ...

  def show?
    return user.supporter? || user.admin? if article.published?
    true
  end
end

Now a normal user can’t view articles for supporters in the index view listings as we are scoping it out. Also we are authorizing the show page as to not allow non-supporter users to see supporter content.

Conclusion

You have read the basics of authorization with Pundit. If you are looking for decentralized solutions for your Rails application it could be a nice one. Pundit can also be customized deeply to add your own methods or features.

The policy pattern concept produces big results. Each time you have to deal with simple or complex permissions a policy object could be applied. When it comes to testing, your policies are purely Ruby objects, and your testing will be simple and fast.

Build your MVP with Rails

👉 We are 2 Ruby-on-Rails lovers. By building BootrAils, we hope we will help you to launch your Startup idea (really) faster.
Take care ! Health first. 🙏
Shino & David.

🎉 Special offer : 10% off for lifetime license
Download