Chapter 8

The Core Module: Authorization with Pundit

Authorization is needed to ensure that users can’t access pages they are not supposed to see. For example, a regular user should not be able to see the admin panel. Pundit is going to let us do just that by creating Plain Old Ruby Objects (POROs).

Box 8.1. What are Plain Old Ruby Objects, a.k.a POROs?

POROs are basically built from classes that don’t inherit from anything (well other than Object). There’s no hidden magic in them, unlike with Rails models or controllers. Add an initialize method and a bunch of other domain-specific methods, and you’ve got yourself a PORO.

First, let’s checkout a branch for this chapter:

git checkout -b Chapter-8

8.1. Adding the Pundit gem

The first thing we need to do is add the Pundit gem to the gemspec file of our Core module:

Listing 0.1: Add Pundit in Core’s gemspec file core/blast_core.gemspec
# ...
spec.add_dependency 'devise', '~> 4.6.2'
spec.add_dependency 'pundit', '~> 2.0.1'

spec.add_development_dependency 'sqlite3', '~> 1.4.1'
# ...

and require it in the core.rb file:

Listing 0.2: Requiring Pundit in Core engine core/lib/blast/core.rb
require 'devise'
require_relative 'core/engine'
require 'sass-rails'
require 'bootstrap'
require 'jquery-rails'
require 'pundit'

module Blast
  module Core
  end
end

Finally, run bundle install from the parent application.

Don’t forget to restart your app if it was running.

8.2. Setting up Pundit

Next, we will set up Pundit by running the generator bundled with the gem (from within the Core module):

rails g pundit:install

You will know it ran successfully when you see the below output:

create app/policies/application_policy.rb

This command has generated the ApplicationPolicy class that you see in Listing 3. This class is a general Pundit policy from which we can inherit in our future policies.

Listing 0.3: Requiring Pundit in Core engine core/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
    attr_reader :user, :scope

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

    def resolve
      scope.all
    end
  end
end

8.3. Update the application controller

Now that Pundit is installed, we need to actually start using it. To do that, we need to include it in Blast::ApplicationController:

Listing 0.4: Pundit in Core’s ApplicationController core/app/controllers/blast/application_controller.rb
module Blast
  class ApplicationController < ActionController::Base
    include Pundit

    protect_from_forgery with: :exception
    before_action :authenticate_user!
  end
end

8.4. Protecting the dashboard controller

We will need to create a policy for each one of our controllers. Let’s start with the dashboard controller. Note that since this is not a typical CRUD controller, we won’t inherit from the ApplicationPolicy class. Instead, we’ll create a headless policy, with the contents of Listing 5:

mkdir app/policies/blast && \
touch app/policies/blast/dashboard_policy.rb
Listing 0.5: Contents of Dashboard’s Headless Policy core/app/policies/blast/dashboard_policy.rb
module Blast
  class DashboardPolicy < Struct.new(:user, :dashboard)
    def index?
      user.present?
    end
  end
end
Box 8.2. Don’t forget the Blast namespace

Don’t forget that since our Core is in the Blast namepsace, we will have to add all our policies in a policies/blast directory.

To use this new policy, we need to update the dashboard controller and call Pundit’s authorize method, as shown in Listing 6 below:

Listing 0.6: Contents of Dashboard’s Headless Policy core/app/controllers/blast/dashboard_controller.rb
module Blast
  class DashboardController < ApplicationController
    def index
      # We're using [:blast, :dashboard] because of our namespace
      authorize [:blast, :dashboard], :index?
    end
  end
end

As you can see in Listing 6, we just need to specify two things: a target (our dashboard) and an action (the index). The current_user method is automatically used by Pundit to represent the actor. With those three entities, Pundit will answer the question:

Can actor call action on target?
or:
Can the current user call index on the dashboard?

In this case, the answer is a big yes because the index? method in the Dashboard Policy only requires a user to be present, as shown in Listing 5, with the inclusion of the below lines:

def index?
  user.present?
end

8.5. Protecting the users controller

The user policy we need to create for the users controller is going to be a bit different. Since this is a feature of the admin panel and not one of the generally accessible CRM screens, we need to ensure that only admin users have access.

This translates to the UserPolicy below:

touch app/policies/blast/user_policy.rb
Listing 0.7: Initial UserPolicy core/app/policies/blast/user_policy.rb
module Blast
  class UserPolicy < ApplicationPolicy
    def index?
      user.admin?
    end
  end
end

But let’s not stop there. Because the users controller is a typical CRUD controller, users can be listed with the index action. That’s where another feature from Pundit kicks in: scopes.

Listing 8 shows an example of a scope allowing an admin to get all records, while non-admin users can only get records that have the published attribute set to true:

Listing 0.8: Updated Scope in ApplicationPolicy core/app/policies/application_policy.rb
class ApplicationPolicy
  .
  .
  .
  class Scope
    attr_reader :user, :scope

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

    def resolve
      # We now check if the our user is an admin
      if user.admin?
        scope.all
      else
        scope.where(published: true)
      end
    end
  end
end

Currently, we only want to allow admin users to be able to see a list of all users, so we’ll just completely block non-admin users. Listing 9 shows the updated user policy to achieve this:

Listing 0.9: Updated Scope in ApplicationPolicy core/app/policies/blast/user_policy.rb
module Blast
  class UserPolicy < ApplicationPolicy
    def index?
      user.admin?
    end

    class Scope < Scope
      def resolve
        if user.admin?
          scope.all
        else
          scope.none
        end
      end
    end
  end
end

With these 5 lines of code, we allow admins to get a scoped query returning all the users, while non-admin users will receive an empty query:

if user.admin?
  scope.all
else
  scope.none
end

Finally, we have to update the users controller to call authorize and use scopes from our policy. Take a look at Listing 10:

Listing 0.10: Using Scopes in UsersController core/app/controllers/blast/admin/users_controller.rb
module Blast
  module Admin
    class UsersController < AdminController
      def index
        authorize Blast::User
        @users = policy_scope(Blast::User).ordered
        @users_count = @users.count
      end
    end
  end
end

We’ll update the users count displayed in the navigation and use @users_count instead of Blast::User.count to show the appropriate count depending on the user.

8.6. Protecting the admin controller

The final controller we need to update is the admin controller. First, let’s create a headless policy that will only allow admin users to access the index action of the admin panel:

touch app/policies/blast/admin_policy.rb
Listing 0.11: Initial AdminPolicy core/app/policies/blast/admin_policy.rb
module Blast
  class AdminPolicy < Struct.new(:user, :admin)
    def index?
      user.admin?
    end
  end
end

Next, we update the admin controller to enforce that policy and use the user policy to scope down the list of users:

Listing 0.12: Using Scopes in AdminController core/app/controllers/blast/admin/admin_controller.rb
module Blast
  module Admin
    class AdminController < ApplicationController
      def index
        authorize [:blast, :admin], :index?
        @users = policy_scope(Blast::User).ordered.limit(3)
        @users_count = policy_scope(Blast::User).count
      end
    end
  end
end

Finally, we update the index view to use the properly scoped @users variable, as shown in Listing 13:

Listing 0.13: Updated index view with user scopes core/app/views/blast/admin/admin/index.html.erb
<h2 class='float-left'>Admin Panel</h2>

<%= render 'admin/shared/nav' %>

<div class='clearfix'></div>
<hr>

<div class="row">
  <div class="col-md-6">
    <div class="card">
      <div class="card-header">
        Last 3 users
        <div class="float-right">
          <%= link_to 'See All', blast.admin_users_path %>
        </div>
      </div>
      <table class="table table-bordered mb-0">
        <tbody>
          <%- @users.each do |user| %>
            <tr>
              <td><%= user.id %></td>
              <td><%= user.email %></td>
              <td class="text-right">
                <%= user.created_at.strftime("%d %b. %Y") %>
              </td>
            </tr>
          <% end %>
        </tbody>
      </table>
    </div>
  </div>
</div>

8.7. Updating the navigation partial

Our admin controllers now all set the @users_count variable, so it’s time to start using it in the admin navigation menu:

Listing 0.14: Users count in Admin Navigation Menu core/app/views/blast/admin/shared/_nav.html.erb
<ul class="nav nav-pills float-right">
  <li class="nav-item">
    <%= link_to 'Dashboard', blast.admin_path,
                class: "nav-link #{active(admin_path)}" %>
  </li>

  <li class="nav-item">
    <%= link_to blast.admin_users_path,
                class: "nav-link #{active(blast.admin_users_path)}" do %>
     Users
     <span class="badge">(<%= @users_count %>)</span>
   <% end %>
  </li>
</ul>

8.8. Handling unauthorized exceptions

We’ve got a big problem: if you try to access /admin as a non-admin user, the app will crash with a 500 error as you can see in Figure 1:

https://s3.amazonaws.com/devblast-modr-book/images/figures/02_08/error_on_failed_authorization
Figure 1

Luckily, we can fix this by catching the exception being raised by Pundit (Pundit::NotAuthorizedError) in the ApplicationController class. Simply update the ApplicationController to reflect the contents of Listing 15:

Listing 0.15: Catching Pundit’s NotAuthorizedError core/app/controllers/blast/application_controller.rb
module Blast
  class ApplicationController < ActionController::Base
    include Pundit

    protect_from_forgery with: :exception
    before_action :authenticate_user!

    rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized

    private

      def user_not_authorized
        flash[:alert] = 'You are not authorized to perform this action.'
        redirect_to(request.referrer || root_path)
      end
  end
end

With this code, we should now be redirected to the previous page (or to the root path) if we are not authorized to access a page:

https://s3.amazonaws.com/devblast-modr-book/images/figures/02_08/good_failed_authorization
Figure 2

8.9. Pushing Our Changes

Once again:

  1. Check the changes:
    git status
    
  2. Stage them:
    git add .
    
  3. Commit them:
    git commit -m "Set up Pundit Authorization"
    
  4. Push to your GitHub repo if you’ve configured it:
    git push origin Chapter-8
    

10 Wrap Up

In this chapter, we’ve added the last pillar to our foundation. The Core module is ready! From now on, we will be able to build on top of it, starting in the very next chapter.

10.1 What did we learn?

  • Authorization is a requirement in pretty much any application.
  • Pundit makes it easy to create POROs to handle our permissions.

10.2 Next Step

In the next chapter, we’ll start working on a new module, the Contact module, and see how to interact with the Core module.