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).
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
The first thing we need to do is add the Pundit gem to the gemspec
file of our Core
module:
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:
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.
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.
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
Now that Pundit is installed, we need to actually start using it. To do that, we need to include
it in Blast::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
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
core/app/policies/blast/dashboard_policy.rb
module Blast
class DashboardPolicy < Struct.new(:user, :dashboard)
def index?
user.present?
end
end
end
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:
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
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
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
:
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:
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:
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.
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
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:
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:
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>
Our admin controllers now all set the @users_count
variable, so it’s time to start using it in the 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>
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:
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:
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:
Once again:
git status
git add .
git commit -m "Set up Pundit Authorization"
git push origin Chapter-8
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.
In the next chapter, we’ll start working on a new module, the Contact
module, and see how to interact with the Core
module.