Devise Part 8: Policy Authorization In Rails 7 With Pundit
Published: Mar 9, 2022
Last updated: Mar 9, 2022
Enforcing policies in our Ruby on Rails applications helps us to decouple and enforce requirements in order to make authorized requests.
In this post, we will continue on with our Devise project to demonstrate how we can add to our authentication protection by layering on some authorization policies in order for our users to take actions based on their roles.
We will do this by adding a new user role column to our users table which will be an enum of admin or basic. Based on the roles, we will aim for the following in our Document controller:
We will be working from the source code found here.
# Clone and change into the project
$ git clone https://github.com/okeeffed/demo-rails-7-with-devise-series
$ cd demo-rails-7-with-devise-series
# Continue from part 7 - ensure you've gone through the previous parts
$ git checkout 7-testing-authentication
# Add the required gems
$ bundler add pundit
# Setup pundit
$ bin/rails g pundit:install
create app/policies/application_policy.rb
# Create a document policy file
$ touch app/policies/document_policy.rb
In the above setup, we installed the required Pundit gem, generated the basic policy setup and added the document policy that we will use.
At this stage, we are ready to start adjusting some files.
Adding a role to the user
We will add an enum to the User type to be either admin or basic.
In our example, we want one admin user that can create, view, update and delete a document and another basic user that can only view their own documents.
A quick reference on how we can add an enum can be found here.
Follow the steps to add in the migration:
# Add migration file
$ bin/rails g migration AddRoleToUsers role:integer
Afterwards, open up the new migration file that was generated at db/migrate/<timestamp>_add_role_to_users.rb and ensure you add a default of 0 which will be our basic user:
class AddRoleToUsers < ActiveRecord::Migration[7.0]
def change
add_column :users, :user_type, :integer, default: 0
end
end
Updating the user model
Now we can update our User model to include the new enum.
Update app/models/user.rb to the following:
class User < ApplicationRecord
enum role: {
basic: 0,
admin: 1
}
has_and_belongs_to_many :documents
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable, :omniauthable, omniauth_providers: %i[github]
def self.from_omniauth(auth)
where(provider: auth.provider, uid: auth.uid).first_or_create do |user|
user.email = auth.info.email
user.password = Devise.friendly_token[0, 20]
# user.name = auth.info.name # assuming the user model has a name
# user.image = auth.info.image # assuming the user model has an image
# If you are using confirmable and the provider(s) you use validate emails,
# uncomment the line below to skip the confirmation emails.
# user.skip_confirmation!
end
end
end
This will map our basic and admin roles to the enums with basic being our default of 0.
We can then run a migration to apply this change using bin/rails db:migrate.
# Migrate new enum changes
bin/rails db:migrate
Ruby will automatically give us some helper methods for this model now so that we can run methods like user.basic? and user.admin? to check the user type.
We can also assign types with user.basic! and user.admin!.
Setting up our policies
First we need to update the application policy at app/policies/application_policy.rb:
# frozen_string_literal: true
class ApplicationPolicy
include Pundit::Authorization
end
Next we will construct a document policy that inherits from the application policy in app/policies/document_policy.rb:
class DocumentPolicy < ApplicationPolicy
attr_reader :current_user, :document
def initialize(current_user, document)
@current_user = current_user
@document = document
end
def create?
current_user.admin?
end
def index?
current_user.admin?
end
def show?
current_user.admin? || current_user.documents.exists?(id: document.id)
end
def update?
current_user.admin?
end
def destroy?
current_user.admin?
end
end
The above methods can be referenced in our document controller and check if the user is an admin or not. The only difference is in show where a basic user can view the document but they must be the owner.
This is a pretty naive policy check, but you could add more requirements as needed. In practice, you would want more robust policies in place based on the business needs.
Updating our application controller
In our top-level application controller, we want to handle all Pundit::NotAuthorizedError errors.
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
before_action :set_csrf_cookie
before_action :authenticate_user!
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
private
def set_csrf_cookie
cookies['CSRF-TOKEN'] = form_authenticity_token
end
def user_not_authorized(exception)
policy_name = exception.policy.class.to_s.underscore
err_message = t "#{policy_name}.#{exception.query}", scope: 'pundit', default: :default
render json: { message: err_message }, status: :unauthorized
end
end
This is an easy way to return a 403 status.
Updating our documents controller
Let's update our documents controller to use the new policy at app/controllers/documents_controller.rb:
class DocumentsController < ApplicationController
include Pundit::Authorization
def index
@docs = Document.all
authorize @docs
render json: @docs
end
def create
@doc = Document.new(body: params[:body])
authorize @doc
@doc.save!
render json: @doc, status: :created
end
def show
@doc = Document.find(params[:id])
authorize @doc
render json: @doc
end
def update
@doc = Document.find(params[:id])
authorize @doc
@doc.update!(document_params)
render json: @doc
end
def destroy
@doc = Document.find(params[:id])
authorize @doc
@doc.destroy
render status: :no_content
end
private
# Using a private method to encapsulate the permissible parameters
# is just a good pattern since you'll be able to reuse the same
# permit list between create and update. Also, you can specialize
# this method with per-user checking of permissible attributes.
def document_params
params.require(:document).permit(:body)
end
end
Addressing the failing test
As things currently are, our tests will now fail.
$ bundler exec rspec
F.
Failures:
1) DocumentsController GET #index successful responses returns all posts when user is authorized
Failure/Error: authorize @doc, :index?
Pundit::NotDefinedError:
unable to find policy `NilClassPolicy` for `nil`
# ./app/controllers/documents_controller.rb:16:in `index'
# ./spec/controllers/documents_controller_spec.rb:10:in `block (4 levels) in <top (required)>'
Finished in 0.192 seconds (files took 5.42 seconds to load)
2 examples, 1 failure
Failed examples:
rspec ./spec/controllers/documents_controller_spec.rb:9 # DocumentsController GET #index successful responses returns all posts when user is authorized
This is because our first test with a logged in user is not an admin - the default value after migration is that every user has a role of basic.
To fix this, we will add a new trait to our User factory and adjust our controller macros test helper.
First, update spec/factories/user_factory.rb for the new trait:
FactoryBot.define do
factory :user do
id { 2 }
email { 'hello@example.com' }
password { 'password123' }
end
trait :admin do
role { 'admin' }
end
end
We can now implement this trait in our support controller macro file spec/support/controller_macros.rb:
module ControllerMacros
def login_user
# Before each test, create and login the user
before(:each) do
@request.env['devise.mapping'] = Devise.mappings[:user]
sign_in FactoryBot.create(:user)
end
end
def login_admin
# Before each test, create and login the user
before(:each) do
@request.env['devise.mapping'] = Devise.mappings[:user]
sign_in FactoryBot.create(:user, :admin)
end
end
end
The new login_admin will create an admin user for our test.
Fixing our routes
We added a show route to our documents controller, but we have not adjusted it from the last blog post.
Update config/routes.rb to include documents#show:
Rails.application.routes.draw do
get 'session/index'
devise_for :users,
controllers: { omniauth_callbacks: 'users/omniauth_callbacks', sessions: 'users/sessions',
registrations: 'users/registrations' }
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
resources :session, only: [:index]
resources :home, only: %i[index create]
resources :documents, only: %i[index create update destroy show]
# Defines the root path route ("/")
root 'home#index'
end
Updating our test file
Inside of spec/controllers/documents_controller_spec.rb we can update login_user to login_admin to test the admin user.
require 'rails_helper'
RSpec.describe DocumentsController, type: :controller do
describe 'GET #index' do
let(:subject) { create(:document) }
context 'successful responses' do
login_admin
it 'returns all posts when user is authorized' do
get :index, params: { body: subject.body }
expect(response.status).to eq(200)
expect(response.parsed_body).to eq([subject.as_json])
end
end
context 'unsuccessful responses' do
it 'redirects user when they are not logged in' do
get :create, params: { body: subject.body }
expect(response.status).to eq(302)
end
end
end
end
Refactoring the tests to cover all cases
With a focus once again on the documents#index controller, let's ensure we are catching these three cases:
403 when the user is not authorized.
302 when the user is not logged in.
200 when the user is logged in.
I've updated the tests to the following:
require 'rails_helper'
RSpec.describe DocumentsController, type: :controller do
describe 'GET #index' do
let(:subject) { create(:document) }
context 'successful responses' do
login_admin
it 'returns all posts when user is authorized' do
get :index, params: { body: subject.body }
expect(response.status).to eq(200)
expect(response.parsed_body).to eq([subject.as_json])
end
end
context 'unsuccessful responses' do
context 'not authenticated' do
it 'redirects user when they are not logged in' do
get :create, params: { body: subject.body }
expect(response.status).to eq(302)
end
end
context 'not authorized' do
login_user
it 'redirects user when they are not logged in' do
get :create, params: { body: subject.body }
expect(response.status).to eq(401)
end
end
end
end
end
The blocks may be getting too deep for your liking, so adjust as suits for your application.
Adding tests for the show method
Finally, we will write some basic tests for the documents#show method to ensure that a basic user can still view their own document.
require 'rails_helper'
RSpec.describe DocumentsController, type: :controller do
# ... rest omitted
describe 'GET #show' do
context 'successful responses' do
context 'admin' do
login_admin
let(:subject) { create(:document, users: [User.first]) }
let(:document_two) { create(:document) }
it 'can view their own post' do
get :show, params: { id: subject.id }
expect(response.status).to eq(200)
expect(response.parsed_body).to eq(subject.as_json)
end
it 'can view another post' do
get :show, params: { id: document_two.id }
expect(response.status).to eq(200)
expect(response.parsed_body).to eq(document_two.as_json)
end
end
context 'basic user' do
login_user
let(:subject) { create(:document, users: [User.first]) }
it 'can view their own post' do
get :show, params: { id: subject.id }
expect(response.status).to eq(200)
expect(response.parsed_body).to eq(subject.as_json)
end
end
end
context 'unsuccessful responses' do
let(:subject) { create(:document) }
it 'redirects user when they are not logged in' do
get :create, params: { id: subject.id }
expect(response.status).to eq(302)
end
context 'basic user is not related to document' do
login_user
it 'can view their own post' do
get :show, params: { id: subject.id }
expect(response.status).to eq(401)
end
end
end
end
end
The above tests check:
An admin can view their own post.
An admin can view a post they do not own.
A basic user can view their own post.
A basic user cannot view a post they do not own.
A user is not logged in and is redirected to the login page.
If we run our tests again we can see they all pass with flying colors.
$ bundler exec rspec
........
Finished in 0.24726 seconds (files took 2.73 seconds to load)
8 examples, 0 failures
Summary
In our post today, we setup the Pundit gem to handle authorization for our controllers.
We then updated our tests to check both the authorization and authentication of our controllers.
Our tests and policies are certainly not exhaustive, but this section is a good start to demonstrating what we are trying to achieve.