Using Devise and Pundit with Rails 5 to delegate authorization to users

Todd Baur

Let’s say you want to let your users manage people under their organization. You have a database and want to make a really flexible authorization system.

This article describes our strategy for delegating and authorizing users with Pundit backed by a database. Pundit is really awesome. By creating simple classes and using OOP you can get really creative with how authorization works. It cleans up and prevents the common fat-controller problem when complex authorization ends up inside the controller or model methods.

Edit: Devise is used in this scenario but I have since switched to Knock and didn’t need to change anything Pundit related; I suspect you may have a similar experience if choosing JWT/Knock over Devise.


Scenario:

  • A new user signs up (we’ll call him Tom)
  • Tom creates his organization/company
  • Tom needs to assign permissions to users that belong to his company
  • The app needs to handle unauthorized access in a way that is informative and graceful.

I’ll skip the part of installing Devise and Pundit here. I’m assuming you know how to do that already, and if not drop a comment in the bottom of the article and I’ll help you as best as I can.

In my examples I am using UUID’s in my database models, you may need/want to adjust to using integers if you are not using UUID’s. However UUID’s can free you from worrying about cross-table relationships in your authorization scheme since they have an incredibly unlikely chance of ever being duplicated, while assisting your app the ability to scale to hundreds of billions of records.

Setting up the data

We’re going to work with four different models here.

  • user
  • company
  • roles
  • assignments

User is what you would expect it is; Devise is configured to handle users here. The company model is the organization that our user Tom is going to be in charge of delegating permissions to others. He’ll sign up and then fill out the information needed to create a new company. Roles is where we’ll create roles for each company that is created, and then assignments will be used join users assigned to roles.

Models

Create the role model with rails generate model role name:string entity_id:uuid. When a company is created, you’ll create the roles too. In my application, I did not declare the entity_id column as a foreign key to company. By skipping this and using UUID’s I don’t need to care about a foreign key here, since UUID’s are unique. If there is only one model to concern yourself with and you are not using UUID’s then a foreign key is a good choice.

Create the assignment model next with rails generate model assignment role:reference user:reference

The assignments migration should look like this:

class CreateAssignments < ActiveRecord::Migration[5.1]
def change
create_table
:assignments do |t|
t.references :user, foreign_key: true, type: :uuid
t.references :role, foreign_key: true

t.timestamps
end
end
end

The assignments model is a join table. Next we’ll configure the Users model to associate with the new Assignments and Roles.

class User < ApplicationRecord
has_many :assignments
has_many :roles, through: :assignments
def role?(role, entity_id = nil)
if entity_id.present?
roles.where(entity_id: entity_id, name: role).any?
else
logger.warn "Role check used without an entity id presented"
#{role} called for #{id} user"
roles.any? {|r| r.name.to_sym == role}
end
end
end

This adds the has_many association needed for the two new models to work from the user’s record. It also adds the role? check method. I opted to log when an entity_id isn’t presented to this method because it reminds me in development to rethink how I’m approaching the security regarding the database record in question without stopping productivity. Additionally if it somehow leaked out to the test environment it would get caught by another team member where, justifiably, the morale beatings commence. Pundit’s approach about adding additional context says it best.

Controllers

We’re really going to focus on only protecting index, show, create, update, and delete methods in a controller. The new and edit methods should just call as create and update, respectively, because it doesn’t really make sense to show the user a form they can’t save.

In Rails 5 the controller generator creates a method like:

def set_modelname
@modelname = Modelname.find(params[:id]
end

Just add authorize:

def set_modelname
@modelname = Modelname.find(params[:id]
authorize @modelname
end

Pundit policies

You can also use Pundit’s generator to do this next part if you haven’t already.

Create a folder for app/policies. In it, you’ll need a company_policy.rb file and an application_policy.rb file. As you might expect, the application policy holds your default policy values for controller actions, and your other policies may or may not inherit from it. You have the full power of object oriented programming here, so feel free to be creative within the rules of the Pundit framework.

class ApplicationPolicy
attr_reader :user, :record

def
initialize(user, record)
@user = user
@record = record
end
def index?
true
end
# and so on
end

This is the basics of the methods in the ApplicationPolicy. All the methods should return true or false. Don’t make it more complicated than that. You do not have access to current_user or other Devise helpers here. Pundit is passing current_user to it in the initialize method and it will receive the record from the authorize call.

That’s really all the magic you might need. Maybe you want more context, like IP checking or other fancy stuff, but really I advise against it here. It can be done by adding more options to the initialize method in your child policies, but I can’t stress enough how important it is to keep it to user and record as much as possible and let the other things get handled before the app services the request.

Let It role?

Now for the icing on the cake. Tom created an account, and now he is filling in the forms to add his company.

In our child policies we did something like this method:

class CompanyPolicy < ApplicationPolicy

def
index?
@user.companies.exists? if @user.present?
end

def
show?
@user.role?("company.show", record.id) if @user.present?
end

def
edit?
update?
end

def
update?
@user.role?("company.update", record.id) if @user.present?
end

def
destroy?
@user.role?("company.destroy", record.id) if @user.present?
end

def
new?
create?
end

def
create?
true
end

end

So Tom (and any other current_user) can make a company, but then can’t see or update or destroy their company? Let’s fix that in our company controller’s create method with an after_action hook:

after_action :assign_user_roles, only: [:create]def assign_user_roles
# Roles for the company
%w(show update destroy).each do |action|
company_role = Role.create name: "company.#{action}", entity_id: @company.id
current_user.assignments.create({role_id: company_role.id})
end
end

This will create the roles for the entity_id of the company we just created, and then assign Tom to them.

RSpec

What good is all of this if we can’t test it? Just as the Pundit documentation states, go add the pundit/rspec and pundit/matchers to your rails_helper.rb file.

Additionally in rails_helper.rb add this matcher:

RSpec::Matchers.define :authorize do |action|
match do |policy|
policy.public_send("#{action}?")
end

failure_message do |policy|
"#{policy.class} does not authorize #{action} on #{policy.record} for #{policy.user.inspect}."
end

failure_message_when_negated do |policy|
"#{policy.class} does not forbid #{action} on #{policy.record} for #{policy.user.inspect}."
end
end

This gives you some helpful messaging for failed policy tests, as well as correctly send the right objects to the policy.

Now for our CompanyPolicy test, we create a policies/company_policy_spec.rb and fill it with this:

require 'rails_helper'

describe CompanyPolicy do

subject {described_class.new(user, company)}
let(:company) {create(:company)}
let(:role_show) {create(:role, name: "company.show", entity_id: company.id)}
let(:role_update) {create(:role, name: "company.update", entity_id: company.id)}
let(:role_destroy) {create(:role, name: "company.destroy", entity_id: company.id)}

context "not signed in" do
let(:user) {nil}
it "does not allow #index" do
expect { Pundit.authorize(user, company, :index?) }.to raise_error(Pundit::NotAuthorizedError)
end
it "does not allow #show" do
expect { Pundit.authorize(user, company, :show?) }.to raise_error(Pundit::NotAuthorizedError)
end
it "does not allow #update" do
expect { Pundit.authorize(user, company, :update?) }.to raise_error(Pundit::NotAuthorizedError)
end
it "does not allow #destroy" do
expect { Pundit.authorize(user, company, :destroy?) }.to raise_error(Pundit::NotAuthorizedError)
end
it {is_expected.to permit_action(:new)}
it {is_expected.to permit_action(:create)}

end

context "signed in" do
let(:user) {create(:user, roles: [role_show, role_update, role_destroy])}
before(:each) {
user.companies << company
}
it {is_expected.to permit_action(:index)}
it {is_expected.to permit_action(:show)}
it {is_expected.to permit_action(:new)}
it {is_expected.to permit_action(:create)}
it {is_expected.to permit_action(:update)}
it {is_expected.to permit_action(:destroy)}
end

end

That should give you a green check mark with passing tests!

Conclusion

Pundit is one of those libraries that does a great job for the scenario it fills while providing the ability to scale for the unknown. Common OOP practicality bundled for an authorization system is important to the growth of an application in ways that will pay huge dividends for teams looking to experiment without sacrificing too much security or convenience.

I am always eager to hear how others approached their Pundit implementation, and if anyone have and advice for the approach taken here please feel free to comment below. Thanks for reading!

Edit: Want to do even more? Check out part two: https://medium.com/@toddbaur/part-2-devise-and-pundit-with-groups-8f5af2d5b0e1?source=linkShare-f8354741d7e6-1535864623

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade