🎉 I'm releasing 12 products in 12 months! If you love product, checkout my new blog workingoutloud.dev

Back to home

Devise Part 8: Policy Authorization In Rails 7 With Pundit

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:

  • Admins can do anything.
  • Basic users can only read their own documents.

Source code can be found here.

Prerequisites

Getting started

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:

  1. 403 when the user is not authorized.
  2. 302 when the user is not logged in.
  3. 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:

  1. An admin can view their own post.
  2. An admin can view a post they do not own.
  3. A basic user can view their own post.
  4. A basic user cannot view a post they do not own.
  5. 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.

Resources and further reading

Photo credit: gregbulla

Personal image

Dennis O'Keeffe

@dennisokeeffe92
  • Melbourne, Australia

Hi, I am a professional Software Engineer. Formerly of Culture Amp, UsabilityHub, Present Company and NightGuru.
I am currently working on Visibuild.

1,200+ PEOPLE ALREADY JOINED ❤️️

Get fresh posts + news direct to your inbox.

No spam. We only send you relevant content.

Devise Part 8: Policy Authorization In Rails 7 With Pundit

Introduction

Share this post