Rails pundit tutorial
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. This article is about the functionality of the Devise gem, authentication mechanism for Ruby-on-Rails applications.
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.
1
2
3
4
5
6
7
8
9
# inside application_controller.rb
class ApplicationController < ActionController::Base
include Pundit
def pundit_user
AuthorizationContext.new(current_user, current_office)
end
end
1
2
3
4
5
6
7
8
9
10
# inside authorization_context.rb
class AuthorizationContext
attr_reader :user, :office
def initialize(user, office)
@user = user
@office = office
end
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 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.
1
2
3
4
5
6
7
8
$> ruby --version
=> 3.1.2
$> rails --version
=> 7.0.4
$> 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:
1
2
3
4
5
source 'https://rubygems.org'
ruby '3.1.2'
gem 'rails', '~> 7.0.4'
And then run
1
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
1
bundle exec rails new . --force -d=postgresql
- rails is the Rails CLI (command line interface) tool
- new tells the Rails CLI that we want to generate a new application
- . means working in the current directory
- ā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
1
bin/rails db:create
and
1
bin/rails db:migrate
Great! Now run up your development server
1
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.
1
**NOTE:** *You must follow all the steps to authenticate users as in the [Devise tutorial](/blog/ruby-on-rails-authentication-tutorial-with-devise/).*
Now letās add a scaffold for Article
so we have something to work with:
1
bin/rails g scaffold Article title body:text published:boolean user:belongs_to
and then
1
bin/rails db:migrate
We will add a column 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:
1
bin/rails g migration AddAdminToUser admin:boolean
1
bin/rails db:migrate
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:
1
gem 'pundit'
And run
1
bundle install
Include Pundit in your application controller:
1
2
3
class ApplicationController < ActionController::Base
include Pundit::Authorization
end
Also, you can run the generator to set up an application policy with some useful defaults:
1
rails g pundit:install
After that, you need to restart the Rails server. Now Rails can pick up any classes in the new app/policies/
directory.
Adding policies:
1
2
mkdir app/policies
touch app/policies/article_policy.rb
Our article_policy.rb
with some code:
1
2
3
4
5
6
7
8
9
10
11
12
class ArticlePolicy < ApplicationPolicy
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class ArticlePolicy < ApplicationPolicy
attr_reader :user, :article
def initialize(user, article)
@user = user
@article = article
end
def index?
true
# if set to false - no one will have access
end
def show?
true
end
# Same as for create
def new?
create?
end
# Same as that of the update.
def edit?
update?
end
# Only admin is allowed to update the article and only if article is not published
def update?
user.admin? || !article.published
end
# Only admin is allowed to create the article.
def create?
user.admin?
end
def destroy?
user.admin?
end
end
We need to generate the controller:
1
bin/rails g controller Articles index
Donāt forget about routes:
1
2
3
4
5
Rails.application.routes.draw do
root 'articles#index'
resources :articles
devise_for :users
end
Now we will manage the articles_controller.rb
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
class ArticlesController < ApplicationController
before_action :set_article, only: %i[show edit update destroy]
before_action :authenticate_user!, except: %i[index show]
# GET /articles or /articles.json
def index
@articles = Article.all
end
# GET /articles/1 or /articles/1.json
def show
end
# GET /articles/new
def new
@article = Article.new
end
# GET /articles/1/edit
def edit
end
# POST /articles or /articles.json
def create
@article = Article.new(article_params)
authorize @article
respond_to do |format|
if @article.save
format.html { redirect_to article_url(@article), notice: "Article was successfully created." }
format.json { render :show, status: :created, location: @article }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @article.errors, status: :unprocessable_entity }
end
end
end
# PATCH/PUT /articles/1 or /articles/1.json
def update
authorize @article
respond_to do |format|
if @article.update(article_params)
format.html { redirect_to article_url(@article), notice: "Article was successfully updated." }
format.json { render :show, status: :ok, location: @article }
else
format.html { render :edit, status: :unprocessable_entity }
format.json { render json: @article.errors, status: :unprocessable_entity }
end
end
end
# DELETE /articles/1 or /articles/1.json
def destroy
@article.destroy
respond_to do |format|
format.html { redirect_to articles_url, notice: "Article was successfully destroyed." }
format.json { head :no_content }
end
end
private
# Use callbacks to share common setup or constraints between actions.
def set_article
@article = Article.find(params[:id])
end
# Only allow a list of trusted parameters through.
def article_params
params.require(:article).permit(:title, :body, :published)
end
end
Do some work on the views:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# inside views\articles\index.html.erb
<p style="color: green"><%= notice %></p>
<h1>Articles</h1>
<div id="articles">
<p>
<strong>Title:</strong>
<%= @article.title %>
</p>
<p>
<strong>Body:</strong>
<%= @rticle.body %>
</p>
<p>
<strong>Published:</strong>
<%= @article.published %>
</p>
<p>
<%= link_to "Show this article", article %>
</p>
</div>
<%= link_to "New article", new_article_path %>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# inside views\articles\show.html.erb
<p style="color: green"><%= notice %></p>
<p>
<strong>Title:</strong>
<%= @article.title %>
</p>
<p>
<strong>Body:</strong>
<%= @article.body %>
</p>
<p>
<strong>Published:</strong>
<%= @article.published %>
</p>
<div>
<%= link_to "Edit this article", edit_article_path(@article) %> |
<%= link_to "Back to articles", articles_path %>
<%= button_to "Destroy this article", @article, method: :delete %>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# inside views\articles\new.html.erb
<h1>New article</h1>
<%= form_with(model: @article) do |form| %>
<% if @article.errors.any? %>
<div style="color: red">
<h2><%= pluralize(@article.errors.count, "error") %> prohibited this article from being saved:</h2>
<ul>
<% @article.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<div>
<%= form.label :title, style: "display: block" %>
<%= form.text_field :title %>
</div>
<div>
<%= form.label :body, style: "display: block" %>
<%= form.text_area :body %>
</div>
<div>
<%= form.label :published, style: "display: block" %>
<%= form.check_box :published %>
</div>
<div>
<%= form.submit %>
</div>
<% end %>
<br>
<div>
<%= link_to "Back to articles", articles_path %>
</div>
Stop and run up again your development server and go to the /articles
page:
Update the application_controller.rb
to handle an error with flash messages:
1
2
3
4
5
6
7
8
9
10
11
12
class ApplicationController < ActionController::Base
include Pundit::Authorization
rescue_from Pundit::NotAuthorizedError, with: :pundishing_user
private
def pundishing_user
flash[:notice] = "You are not authorized to perform this action."
redirect_to article_path
end
end
And try to do actions as not an admin user:
Or login as an admin and try to delete the article:
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 own articles.
Modify the article_policy.rb
:
1
2
3
4
5
6
7
8
9
10
11
12
class ArticlePolicy < ApplicationPolicy
# ...
class Scope < Scope
def resolve
if @user.has_role? :admin
scope.all
else
scope.where(articles: {article_id: current_user.id})
end
end
end
end
And then add to your ArticlesController:
1
2
3
4
5
6
7
8
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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.