# Devise Part 8: Policy Authorization In Rails 7 With Pundit

> This post looks at how we outline required policies in order to fulfill requests in Ruby on Rails using the Pundit gem.

- Canonical: https://dennisokeeffe.com/blog/2022-03-09-part-8-policy-authorization-in-rails-7-with-pundit
- Published: 2022-03-09
- Updated: 2022-03-09
- Tags: ruby, ruby-on-rails, devise

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](https://github.com/okeeffed/demo-rails-7-with-devise-series).

## Prerequisites

- Catch up with previous parts in this series [Devise with Ruby on Rails 7](https://blog.dennisokeeffe.com/blog/series/devise-with-ruby-on-rails-7).
- Read my ["FactoryBot with Rails"](https://blog.dennisokeeffe.com/blog/2022-02-24-factory-bot-with-rails) blog post.

## Getting started

We will be working from the source code found [here](https://github.com/okeeffed/demo-rails-7-with-devise-series).

```s
# 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](https://betterprogramming.pub/how-to-use-enums-in-rails-6-87600e292476).

Follow the steps to add in the migration:

```s
# 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:

```rb
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:

```rb
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`.

```s
# 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`:

```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`:

```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.

```rb
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`:

```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.

```s
$ 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:

```rb
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`:

```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`:

```rb
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.

```rb
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:

```rb
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.

```rb
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.

```s
$ 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](https://github.com/varvet/pundit) 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

- [Devise with Ruby on Rails 7](https://blog.dennisokeeffe.com/blog/series/devise-with-ruby-on-rails-7)
- [Pundit gem](https://github.com/varvet/pundit)
- [Source code](https://github.com/okeeffed/demo-rails-7-with-devise-series)

_Photo credit: [`gregbulla`](https://unsplash.com/@gregbulla)_
