DEV Community

Cover image for Authorization Gems in Ruby: Pundit and CanCanCan
Aestimo K. for AppSignal

Posted on • Originally published at blog.appsignal.com

Authorization Gems in Ruby: Pundit and CanCanCan

Today, many web applications will feature pages that are publicly available — like a homepage — and more secure ones where a user has to log in to get access. The process of user registration, logging in, and tracking user session state is called "authentication".

At the same time, when dealing with logged-in users, it's necessary to separate actions and resources that are available to them depending on their user roles. For example, "admins" generally have more access than normal users. This process of separating authenticated user access is termed "authorization".

In this post, we'll explore two of the most popular authorization libraries in Ruby so far: Pundit and CanCanCan.

Let's dive in!

Setup and Prerequisites

In this article, we'll use a simple Rails 7 app featuring users and posts. Users will be assigned an "editor" or "writer" role. Such a scenario is perfect for showcasing how authorization works.

Check out the code repos for our example app with:

Even though this article focuses on authorization, the companion subject of authentication cannot be ignored.

We won't get into the details of setting up authentication as that's outside the scope of this post. You can follow the installation instructions in the Devise gem documentation (we'll pair Devise with our authorization gems).

One more thing — whether you deal with Pundit or CanCanCan, the actual work of defining your app's user roles does not happen automatically when you install either of the authorization gems. You will need to set it up manually.

Let's do that.

Defining User Roles

Let's assume you've already installed the Devise gem and set up a user model. The next step is to decide what roles the app's users will have. In our case, we'll set out the following roles:

  • Writer - This user role will be able to create, edit, update, and delete their own posts. At the same time, a writer can also view other writers' posts.
  • Editor - A user with the editor role can edit, update, view, and delete any user's posts, but they cannot create their own posts.

Add a column to store a user's role (using a migration to modify the user's table):

bundle exec rails generate migration add_column_role_to_users role:integer
Enter fullscreen mode Exit fullscreen mode

Then run the migration:

bundle exec rails db:migrate
Enter fullscreen mode Exit fullscreen mode

And then modify the user model to include the roles we've just defined:

# app/models/user.rb

class User < ApplicationRecord
  # User role
  enum role: %i[writer editor]

  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable

end
Enter fullscreen mode Exit fullscreen mode

While we are on this, let's go ahead and set up a Post model with the attributes of title and body, as well as a reference to the user who writes the post:

bundle exec rails g scaffold Post title body:text user:references
Enter fullscreen mode Exit fullscreen mode

We add the foreign key user_id into the Post model to associate every post that's created with a particular user. Since we have already set up user authentication using Devise, we can simply modify the create method of the Posts controller to automatically set the user_id to the currently logged-in user:

# app/controllers/posts_controller.rb
...

def create
    @post = current_user.posts.new(post_params) # automatically assign a post to the current user on creation

    respond_to do |format|
        if @post.save
        format.html { redirect_to post_url(@post), notice: 'Post was successfully created.' }
        format.json { render :show, status: :created, location: @post }
        else
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: @post.errors, status: :unprocessable_entity }
        end
    end
end
...
Enter fullscreen mode Exit fullscreen mode

We can also modify the User model to make sure it's associated with the Post model:

# app/models/user.rb

class User < ApplicationRecord
  has_many :posts

  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable

  enum role: %i[writer editor]
end
Enter fullscreen mode Exit fullscreen mode

Finally, let's seed the database with some users, each with a different role:

# db/seeds.rb

User.create(email: 'writer1@example.com', password: 'example', password_confirmation: 'example', role: 0) # this creates a user with the writer role
User.create(email: 'writer2@example.com', password: 'example', password_confirmation: 'example', role: 0) # second writer
User.create(email: 'editor@example.com', password: 'example', password_confirmation: 'example', role: 1) # this creates a user with the editor role
Enter fullscreen mode Exit fullscreen mode

Then seed the database:

bundle exec rails db:seed
Enter fullscreen mode Exit fullscreen mode

So far, our app now has:

  • Authentication set up using Devise.
  • Two defined user roles of "writer" and "editor".
  • A Post model.

With that, we now have everything we need to work properly with Pundit.

Authorization in Your Ruby App with Pundit

Pundit is an authorization library built around object-oriented architecture and plain Ruby classes. It gives you tools to build a solid authorization layer that can scale with your app.

Installing Pundit in Your Ruby App

Add the gem to your app's Gemfile:

# Gemfile

gem 'pundit'
Enter fullscreen mode Exit fullscreen mode

Then in the terminal, run the command:

bundle install
Enter fullscreen mode Exit fullscreen mode

Alternatively, run the following command:

bundle add pundit
Enter fullscreen mode Exit fullscreen mode

Since authorization mostly deals with granting or denying access to controller resources, the next step is to add Pundit's authorization module to the application controller:

# app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  include Pundit::Authorization
end
Enter fullscreen mode Exit fullscreen mode

And finally, generate a base policy class all other policies will inherit from:

bundle exec rails g pundit:install
Enter fullscreen mode Exit fullscreen mode

Which gives you the following base policy class:

# app/policies/application_policy.rb

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
    def initialize(user, scope)
      @user = user
      @scope = scope
    end

    def resolve
      raise NotImplementedError, "You must define #resolve in #{self.class}"
    end

    private

    attr_reader :user, :scope
  end
end
Enter fullscreen mode Exit fullscreen mode

With that, Pundit is now properly set up and ready to go. Additionally, we have authentication and user roles set up.

Next, let's use policies to implement rules that define how each user role will access the Post resource.

Configuring Pundit Policies

In Pundit lingo, a "policy" is a plain Ruby class where you define all the rules for how a user role interacts with different resources.

These policies come with some notable features:

  • Each policy is named after an existing model, suffixed with the word "Policy". For example, a policy defining how the Post model is accessed is called PostPolicy.
  • An attr_reader: this takes two arguments - the first is a user, specifically, the currently logged-in user — current_user — and the second argument is the model that you'd like to define authorization rules for, in our case, post.
  • Query methods that will map to the controller methods of the resource that has authorization rules set up. For organizational purposes, it's best to have all policies under the app/policies folder.

Since we know what access rules we need to define for the different user roles in our app, let's start with the writer role.

Defining a Pundit Policy for a Role

Let's use the writer role to see how this can be done. To begin with, we can outline the writer role's access to the Post resource as follows:

  • Create their own posts
  • Edit and update their own posts
  • View (or read) their own posts as well as other user's posts
  • Delete their own posts

With that in mind, go ahead and generate a new policy to control how posts are accessed:

bundle exec rails g pundit:policy post
Enter fullscreen mode Exit fullscreen mode

This gives us the following generic policy class that inherits from the base policy we generated earlier:

# app/policies/post_policy.rb

class PostPolicy < ApplicationPolicy
  class Scope < Scope
    # NOTE: Be explicit about which records you allow access to!
    # def resolve
    #   scope.all
    # end
  end
end
Enter fullscreen mode Exit fullscreen mode

Let's now add access rules for the writer role accordingly (these also override any rules that are inherited from the base policy class):

# app/policies/post_policy.rb

class PostPolicy < ApplicationPolicy
  ...
  def create?
    @user.writer? # a writer is able to create a post
  end

  def edit?
    @user.writer? # a writer is able to edit a post
  end

  def update?
    @user.writer? # a writer can update a post
  end

  def delete?
    @user.writer? # a writer can delete a post
  end
end
Enter fullscreen mode Exit fullscreen mode

Here, we define what a writer can do when creating, editing, updating, and deleting posts which are corresponding actions on the posts' controller. If you have noticed, these rules apply to posts in general and not necessarily to a writer's own posts (we'll get to that in the section on scopes).

For now, let's see how we can use this policy.

Using a Policy in Pundit

To use a policy, call Pundit's authorize method on the controller's method where you want to check access rules. You instantiate the relevant policy class and, more specifically, the action that should be called based on where the authorize method has been called.

For example, let's call authorize on the post controller's create method:

# app/controllers/post_controller.rb
...

def create
    @post = current_user.posts.new(post_params)
    authorize @post

    respond_to do |format|
        if @post.save
        format.html { redirect_to post_url(@post), notice: 'Post was successfully created.' }
        format.json { render :show, status: :created, location: @post }
        else
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: @post.errors, status: :unprocessable_entity }
        end
    end
end
...
Enter fullscreen mode Exit fullscreen mode

To test this, log in as an editor and try to create a post. Doing this will result in the following error:

Pundit's not authorized error

Though this does what we want, showing such an error page is not good for the user experience. In the next section, you will learn how to rescue the NotAuthorizedError and serve up something more user-friendly.

Rescuing From Pundit's NotAuthorizedError

First, we'll need to edit ApplicationController as follows:

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  include Pundit::Authorization

  rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized

  private

  def user_not_authorized
    flash[:alert] = "You are not authorized to perform this action."
    redirect_back(fallback_location: root_path)
  end
end
Enter fullscreen mode Exit fullscreen mode

Here, we are basically telling Pundit to use the user_not_authorized method whenever the NotAuthorizedError is raised. It will simply redirect the unauthorized user to a specific page and also provide a relevant flash message explaining what has happened:

Rescuing from a Pundit's not authorized error

We now have a simple authorization system capable of handling a very generalized permissions case.

But what if we want more fine-grained permissions? For this, we need to utilize Pundit's scopes.

Pundit's Scopes

Pundit scopes are similar to ActiveRecord scopes. In the latter, you can use scopes to fetch records according to specific criteria. However, with Pundit's scopes, you manage access to specific resources according to certain rules you set.

Let's say we want editors to be able to view and edit posts that are in "draft" status, and at the same time, allow writers to create, view, edit, update and delete posts that only belong to them.

We can start by editing the post policy to look like this:

# app/policies/post_policy.rb

class PostPolicy < ApplicationPolicy
  class Scope < Scope
    def resolve
      if user.editor?
        # an editor can only access posts in "draft" status
        scope.where(published: false)
      else
        # can access a post if they are the author
        scope.where(user: user)
      end
    end
  end
  def show?
    @user.writer? || @user.editor?
  end
  def create?
    @user.writer?
  end
  def edit?
    @user.writer? || @user.editor?
  end
  def update?
    @user.writer? || @user.editor?
  end
  def delete?
    @user.writer?
  end
end
Enter fullscreen mode Exit fullscreen mode

Then we'll authorize access to the resource in the posts controller, like so:

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  ...
  def index
    @posts = policy_scope(Post)
  end
  # GET /posts/1 or /posts/1.json
  def show
    @post = policy_scope(Post).find(params[:id])
  end
  ...
end
Enter fullscreen mode Exit fullscreen mode

Of course, scoping with Pundit can go way deeper than we've shown, but we'll leave it at that in this post. If you want, check out the Pundit documentation to see how you can use scopes in a more advanced way.

For now, let's go through how you can use the library with Rails' strong parameters.

Using Pundit with Rails' Strong Parameters

By combining Pundit's authorization rules with Rails' strong parameters, you can achieve lockdown access to a resource's attributes. Let's say you want editors to be the only ones with access to an excerpt field of the Post model. How would you go about it?

First, add an aptly-named block to the relevant policy:

# app/policies/post_policy.rb

class PostPolicy < ApplicationPolicy
...
  def permitted_attributes
    if user.editor?
      [:title, :body, :excerpt]
    else
      [:title, :body]
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Then, modify the permitted params block in the controller:

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  ...

  private

  def post_params
    params.require(:post).permit(policy(@post).permitted_attributes)
  end
end
Enter fullscreen mode Exit fullscreen mode

With that, you have made the excerpt attribute available to editors only.

We'll now shift gears to look at the other authorization library, CanCanCan.

Introducing CanCanCan for Your Ruby App

CanCanCan is an authorization library that uses an "ability" class to define who has access to what in a Rails app. Actual access control is achieved using an authorization module and various view helpers.

Installing CanCanCan

Installation is as easy as running the command below:

bundle add cancancan
Enter fullscreen mode Exit fullscreen mode

Just like Pundit, with CanCanCan, you can define all access rules within a plain Ruby class object called an "ability" class. Let's do that next with the following generator command:

bundle exec rails g cancan:ability
Enter fullscreen mode Exit fullscreen mode

Which generates the class object below:

# app/models/abilty.rb

class Ability
  include CanCan::Ability

  def initialize(user)
  end
end
Enter fullscreen mode Exit fullscreen mode

Let's learn more about how the ability class can define access rules for our example Rails app next.

Defining and Checking CanCanCan Abilities

We'll use the same user roles as in the Pundit example: writers and editors. A writer can create, edit, update, destroy their own posts, and view other writers' posts; an editor can do everything except create a post of their own.

To use CanCanCan, first define what each user or role can access in the ability class, following this format:

# app/models/ability.rb

can actions, subjects, conditions
Enter fullscreen mode Exit fullscreen mode

As an example:

# app/models/abilty.rb

class Ability
  include CanCan::Ability

  def initialize(user)
    can :update, Post, user: user # With CanCanCan, the update action covers both the edit and update actions
  end
end
Enter fullscreen mode Exit fullscreen mode

Then, in the controller, check if an access rule exists for a particular action — using our example, the edit action:

# app/controllers/posts_controller.rb

...
def edit
    authorize! :edit, @post
end
...
Enter fullscreen mode Exit fullscreen mode

With this in place, if we visit the edit post view as another writer, we get the error below:

CanCanCan access denied error

And just like we did with Pundit, let's rescue this error and show the user a better error page.

Handling CanCanCan’s “Access Denied” Errors

Whenever a resource is not authorized, CanCanCan will raise a CanCan::AccessDenied error. The easiest way to catch this exception is by modifying the ApplicationController as follows:

# app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  rescue_from CanCan::AccessDenied do |exception|
    respond_to do |format|
      format.json { head :forbidden }
      format.html { redirect_to root_path, alert: exception.message }
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Doing this makes for a good user experience. In the screenshot below, the unauthorized user is redirected to the home page and shown a relevant flash message:

Handling CanCanCan's access denied exception

You can even customize the error message shown to the user:

#config/locales/en.yml
en:
  unauthorized:
    update:
      all: "You're not authorized to %{action} %{subject}."
      writer: "You're not authorized to %{action} other writer's posts."
Enter fullscreen mode Exit fullscreen mode

If your app serves XML as a response, or you just want to dive deeper into handling the CanCanCan::AccessDenied exception, check out CanCanCan's documentation. For now, let's see how we can combine CanCanCan's various abilities to create a more robust authorization layer.

Combining Multiple CanCanCan Abilities

You can define multiple access rules for a resource in the ability class. Taking the writer and editor roles, for example, we can do this:

# app/models/ability.rb

class Ability
  include CanCan::Ability

  def initialize(user)
    can :update, Post, user: user # only a post's author/owner can update or edit a post
    can :read, Post # any user can read a post or list of posts (access both show and index actions)
    can :destroy, Post, user: user # only a post's author/owner can delete it

    return unless user.editor?
    cannot :create, Post # an editor role cannot create a post
    can :update, Post # an editor can update any post
  end
end
Enter fullscreen mode Exit fullscreen mode

The question is, why would you want to do this?

With CanCanCan, you can define all access rules in one ability file. This has both advantages and disadvantages.

Having all your rules in one place is very convenient for handling access rules since all rules are defined in one place.

However, if your app deals with many user roles or you have several resources that need authorization, the ability class can easily become too big and complex to handle. One way to handle this is by reorganizing the ability class to use method definitions, like so:

# app/models/ability.rb

class Ability
  include CanCan::Ability

  def initialize(user)
    anyone_abilities

    if user.writer?
      writer_abilities
    elsif user.editor?
      editor_abilities
    end

  end

  private

  def anyone_abilities
    can :read, Post
  end

  def writer_abilities
    can :update, Post, user: Current.user # we define Current.user in the application controller so that the ability class (which is a model) is able to pick up the currently logged in user
  end

  def editor_abilities
    cannot :create, Post # an editor role cannot create a post
    can :update, Post # an editor can update any post
  end

end
Enter fullscreen mode Exit fullscreen mode

There's a lot more to CanCanCan than can be effectively covered in this article. Do check out the detailed CanCanCan documentation to learn more.

To wrap up, let's briefly touch on the features of each library and give reasons why you might choose one over the other.

Feature Comparison: Pundit vs. CanCanCan for Your Ruby App

  • File organization - With Pundit, you can easily organize your app's authorization across multiple policy files. But with CanCanCan, authorization rules will live in one ability file. Working with multiple ability files is still possible, but this is not the default implementation style.
  • Tests - Because permissions code will mostly reside within a single class compared to Pundit's multiple ability classes, writing tests for CanCanCan could be easier than for Pundit.
  • Helpers - Both libraries provide a number of view helpers to check for permissions in the view layer. CanCanCan gives you the can? method to use in your views, while Pundit gives you relatively similar functionality with the policy helper.
  • Devise integration - As you can see from the examples we have used in the article, both libraries integrate with Devise very well.

Wrapping Up

In this article, we looked at two of the most popular authorization gems in the Ruby and Rails ecosystem: Pundit and CanCanCan.

Both libraries offer a rich set of features for managing permissions in your Rails app. Because of this, it is nearly impossible to tell you which gem to choose — both can manage even the most complex permissions setups.

We encourage you to try out Pundit and CanCanCan in your app and see which one fits your needs best.

Happy coding!

P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!

Top comments (0)