Abilities and Permissions in a RoR app

Dan Powell
Jul 28, 2017 · 6 min read

Background

I work for PrivatePrep, a tutoring company, building and maintaining a Ruby on Rails app we call the Dashboard. Our application handles all internal logistics and makes use of the cancancan gem for handling in-app permissions.

Our project started with only 2 role types (:director and :coach). In this environment, it was fine to give :manage permissions to models. :manage by cancancan’s design allows a user to do anything to that model.

By in large, it was understood that a director could CRUD (create, read, update, and destroy) most every model class in almost every case--especially when there were only the original PP markets.

As the app has grown along with the business and added user types and markets, this became an issue. At the time of this wiki’s post, we are in the process of breaking up models’ Ability#manage into several smaller, more descriptive pieces. This has three main benefits:

  1. Creating more visibility into the application of who can do what and how,
  2. Allowing abilities to be accurately unit tested, which is WAY faster than testing through Capybara, and
  3. Lowering the cost of future role development

Basic usage

Abilities are described in app/models/ability.rb and inside of the Abilities module that is located inside of app/models/abilities/*.rb.

When a user logs into the system, an CanCanCan ability file is generated for them. This can be reproduced as follows:

user = Person.new
ability = Ability.new(user)

The ability object has many permissions inside of it which are most commonly checked through the authorize! and can? methods as shown below:

ability.can?(:action, subject)
authorize! :action, subject

Authorizing actions inside of controllers

It is our goal that each route inside of the controller has a permissions check. In the example below if the current user cannot “action” upon the relevant @var, a CanCan::AccessDenied exception is thrown. Most often in the application, this redirects the user to a 404 page.

def controller_action
@var = Var.find params[:id]
authorize! :action, @var
...
end

Conditional rendering in views

It is also our goal that actions shown through links and buttons in the interface are also protected with permission checks. This is done through CanCanCan’s provided can? method that is called upon the current_user’s ability file.

If a user can’t do something, they shouldn’t have the option to try. Sometimes this comes in the form of a disabled link with a “you can’t do this because” type message shown on hover. Other times the button is simply not rendered, as shown in the example below.

<% if can?(:action, @subject)%>
<%= link_to "Action", subject_action_path(@subject) %>
<% end %>

How to give a permission

Granting permissions is pretty straight forward

can :action, Model

Of course in the real world app that is the DB, we have it a tad more complicated to keep it organized, testable, and easier to understand.

When the app began we started app/models/ability.rb and defined permissions as such:

class Ability
include CanCan::Ability
def initialize(user)
if user.in_role? :superuser
can :manage, :all
elsif user.in_role? :director
can [:create, :edit], Profile
can :manage, ExamType
...
elsif user.in_role? :coach
can :view, Parent do |parent|
user.coached_students.map(&:parents).flatten.include?(parent)
end
can :create, PracticeTestRegistration
...
end
if user.in_role? :operations
can :manage, PracticeTest
can [:create, :edit], Profile
end
end end

As you can tell this quickly became a giant case statement. Though functional, it limited easy visibility into who could operate on what in such a way.

To combat this, we have transitioned to a new mode of doing things where permissions are defined inside of an Abilities module. These are separate files, one for each model and can be found inside of the app/models/abilities/model_name_ability.rb files.

Each of these is then merged into ability class as follows in ABC order:

self.merge(Abilities::AccommodationAbility.new(user))
self.merge(Abilities::ClientAbility.new(user))
...
self.merge(Abilities::StudentAbility.new(user))
self.merge(Abilities::UserAbility.new(user))

CanCanCan’s sometimes annoying optimism

CanCanCan is not a perfect tool. One aspect that we find frustrating about it is its optimistic nature.

If a role or person type is granted a permission based on an instance check, the gem optimistically assumes that a role can act upon the general model in the same way.

Consider the following example:

class CakeAbility
include CanCan::Ability
if user.in_role?(:customer)
can :eat, Cake do |cake|
cake.prepared_for?(user)
end
end
endcustomer = Person.new(type: :customer)
customer_ability = CakeAbility.new(customer)
cake = Cake.new
cake.stubs(:prepared_for?).returns(false)
# when supplied the instance acts as expected
customer_ability.can?(:eat, cake)
#=> returns false as expected
# when supplied the general class-level case, CanCanCan assumes true
customer_ability.can?(:eat, Cake)
#=> returns true

A customer can, in fact, eat Cake, but the logic feels as though customer can only eat cake if it is theirs. This is not the case. In our example, our customers can always eat Cake, but are not always able to eat cake.

Put differently, the conditional logic is only applied when the user’s ability is supplied a class instance. If the user can eat any type of cake they can also eat general Cake.

We find this annoying.

Defensive strategies

To get around this, we do three main things:

  1. Wrap any granted permission in a logical condition,
  2. Aim to supply specific instances whenever possible, and
  3. Have clear test cases that describe this funky behavior.

1. Wrapping permission grants

Wrapping permissions in a logical check works to ensure that only that type of user can get the stated permission. Consider the following example where logic is not wrapped.

## Bad
can :operate_on, Person do |person|
person.needs_surgery? && user.is_a?(doctor)
end

This seems like it would be fine. It is not, however. In this case the permission of can?(:operate_on, Person) would be given to all users regardless of their occupation or ability!

## note that the permission leaks out
child = Person.new(type: :child)
child_ability = Ability.new(child)
child_ability.can?(:operate_on, Person)
#=> returns true
## any kind of user will have the permission
shark = Animal.new(type: :shark)
shark_ability = Ability.new(shark)
shark_ability.can?(:operate_on, Person)
#=> returns true :(

Wrapping the granting of permission with roles checks is a good way to guard against the worst kinds of leakage. CanCanCan will still be optimistic as shown in our cake example, but we can take steps to ensure that the optimism is not extreme or dangerous.

## wrapping blocks crazy leakage
if user.in_role?(:doctor)
can :operate_on, Person do |person|
person.is_sick? && person.has_consulted_with?(user)
end
end

This ensures that only doctors can?(:operate_on, Person). When passed a more specific case (person = Person.new), the additional is_sick? and has_consulted_with? logic would then be applied.

2. Aim to supply specific instances whenever possible

When invoking abilities in the controller or in the view layer it is preferred that you pass the instance you are checking rather than the Model that you are working with.

We want to ask “can user update this specific #{model} instance” instead of “can the user update any sort of #{Model}” as shown in the example below.

## this is overly general: "can user update any sort of client?"
<% if can?(:update, Client) %>
<%= link_to edit_client_path(@client) %>
<% end %>
## this is more specific: "can user update this specific client?"
<% if can?(:update, @client) %>
<%= link_to edit_client_path(@client) %>
<% end %>

3. Have clear test cases that describe this funky behavior

We use our tests to clearly illustrate and document behavior.

We both assert and refute permissions on all class-level cases and check specific instances through the use of stub.

Referring back to our cake example we would want tests as follows. To make it more real world like, let’s assume that chefs can eat any type of cake.

# app/models/ability.rbclass Ability  def initialize(user)

self.merge(Abilities::CakeAbility.new(user))

end
end# app/models/abilities/cake_ability.rb module Abilities class CakeAbility

include CanCan::Ability
if user.in_role?(:chef)
can :eat, Cake
end
if user.in_role?(:customer)
can :eat, Cake do |cake|
cake.prepared_for?(user)
end
end
endend# test/unit/abilities/cake_ability_test.rbrequire 'test_helper'
require_relative '../../lib/ability_test_case'
module Abilities class CakeAbilityTest < ActiveSupport::TestCase include AbilityTestCase

## Class level checks
# for chef + customer
assert_role_permission_for(
:eat,
Cake,
:chef, :customer
)
## Instance level checks
# for customer
test "customer can eat cake if it was prepared for them" do
cake = Cake.new
cake.stubs(:prepared_for?).returns(true)
customer = Person.new(type: :customer)

customer_ability = CakeAbility.new(customer)
assert customer_ability.can?(:eat, cake)
end
test "customer cannot eat cake if it was not prepared for them" do
cake = Cake.new
cake.stubs(:prepared_for?).returns(false)
customer = Person.new(type: :customer)

customer_ability = CakeAbility.new(customer)
refute customer_ability.can?(:eat, cake)
end
endend

Notice that the given test file clearly shows that both chefs and customers can eat Cake but customers can only eat cake if it was prepared for them.

Preferred organization

Our preferred organization runs as follows:

Class-level checks

  • anyone who can do anything to the model
  • takes into account CanCanCan’s overly optimistic nature with wraps
  • uses assert_role_permissions_role helper
  • ordered from most to least shared, ABC’d throughout

Instance-level checks

  • conditional logic stubbed for both assert and refute
  • ordered from most to least shared, ABC’d throughout

This is the case for both the model and the test cases and is outlined in the Cake example just above.

Feel free to reach out if you want any deeper examples or have questions!

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