Granular Polymorphic User Permissions with Cancancan

I’ve recently been refactoring how user permissions work in a project to be more granular.

In this project, users are members of organisations, and organisations have many funds and needs.

The manager of the organisation can CRUD these funds and needs, but a regular user should only be able to read them, unless given special permission. This special permission would work on a per-item basis.

Users should also be able to read or manage funds from outside of their organisation if they have been granted special access, by someone within the organisation.

This sounds complex, but the tl;dr is this:

At it’s core, we want to be able to store a record in the database that say “this user has this type of permission to access this item”.

Once we’re storing this in the database, we can use Cancancan to write policies on who should be able to CRUD what, depending on their stored permissions.

Implementation

First, we’ll create a table to store our user permissions in our database. These records will link a user, with a given accessible item (either a Fund, Need or Organisation), and we’ll also have a column for what type of access they’ll have; read or write.

class CreatePermissions < ActiveRecord::Migration[7.0]
  def change
    create_enum :permission_role, ["read", "write"]

    create_table :permissions, id: :uuid do |t|
      t.references :user, null: false, foreign_key: true, type: :uuid
      t.references :accessible, polymorphic: true, null: false, type: :uuid
      t.enum :role, enum_type: :permission_role, default: "read", null: false

      t.timestamps
    end
  end
end

We’ll then fill out our model for our new Permissions table

class Permission < ApplicationRecord
  belongs_to :user
  belongs_to :accessible, polymorphic: true

  enum role: {
    read: "read",
    write: "write"
  }, suffix: true
end

I like to use suffix: true, which means rails will generate some helper methods for getting and setting roles made by joining our role and enum names, for example: permission.read_role?

Now we can add the other side of our permissions association to our user model, as well as each model we want to make “accessible”.

class User < ApplicationRecord
  has_many :organisations, through: :permissions, source: :accessible, source_type: "Organisation"
	has_many :permissions, dependent: :destroy
end
class Organisation < ApplicationRecord
	has_many :funds
	has_many :needs
	has_many :users, through: :permissions
	has_many :permissions, as: :accessible, dependent: :destroy
end
class Fund < ApplicationRecord
	belongs_to :organisation
	has_many :permissions, as: :accessible, dependent: :destroy
end
class Need < ApplicationRecord
	belongs_to :organisation
	has_many :permissions, as: :accessible, dependent: :destroy
end

Now we’re set up. You should be able to create a permission record in the console…

user = User.first
fund = Fund.first

Permission.create(user: user, accessible: fund)

Next we want to define the rules around who can access what. I’m using Cancancan for permissions, which generates an app/models/ability.rb file to store our access rules.

# frozen_string_literal: true

class Ability
  include CanCan::Ability

  def initialize(user)
    if user.admin?
      can :manage, :all
    else
      can :manage, [Fund, Need] do |accessible|
        # Can manage an accessible item via write permission for the organisation it belongs to
        Permission.find_by(accessible: accessible.organisation, user: user, role: "write")
      end

      can :read, [Fund, Need] do |accessible|
        # Can read an accessible item via write permission for the organisation it belongs to
        Permission.find_by(accessible: accessible.organisation, user: user, role: "read")
      end

      # Can read/manage an item if I have direct permission (overrides org level permissions)
      can :manage, [Fund, Need, Organisation], permissions: { user: user, role: "write" }
      can :read, [Fund, Need, Organisation], permissions: { user: user, role: "read" }
    end
  end
end

At the very top level, I let admin level users manage everything (this is just a boolean admin? column on the user model).

If a user is not an admin, the first thing I want to check is if they are a member of the organisation for the accessible item they’re trying to do something with.

I have two rules here, one for read level access, and once for write.

Every accessible item that isn’t an organisation can go here as long as they belong to an organisation and we can call .organisation on them.

Lastly, we have a read and a write rule for checking direct permissions (a link between a user and an accessible item directly without checking through the organisation), we can add organisations here too since a user can have a direct association with an organisation.

Putting access rules in this order means that if we have an accessible item, we check to see if we are a member of its organisation first.

If not, we check to see if we have a direct special permission with that item, and supersede the organisation level permissions.

I think this is a really elegant solution to complex permissions. It’s a lot of flexibility with surprisingly little code.

You can also add tests in your spec/models/user_spec.rb like this. This covers the various combinations of who can access what, and leaves a documentation trail for other developers.

require "rails_helper"
require "cancan/matchers"

RSpec.describe User, type: :model do
	describe "abilities" do
    subject(:ability) { Ability.new(user) }

    context "when an admin user" do
      let(:user) { create(:user, admin: true) }

      it { is_expected.to be_able_to(:manage, :all) }
    end

    context "when a manager of an organisation" do
      let(:user) { create(:user, admin: false) }
      let(:organisation) { create(:organisation) }
      let!(:external_organisation) { create(:organisation) }
      let!(:permission) { create(:permission, user: user, accessible: organisation, role: "write") }

      let!(:organisation_fund) { create(:fund, organisation: organisation) }
      let!(:organisation_need) { create(:need, organisation: organisation) }
      let!(:external_fund) { create(:fund, organisation: external_organisation) }
      let!(:external_need) { create(:need, organisation: external_organisation) }

      it { is_expected.to be_able_to(:manage, organisation_fund) }
      it { is_expected.to be_able_to(:manage, organisation_need) }
      it { is_expected.not_to be_able_to(:read, external_fund) }
      it { is_expected.not_to be_able_to(:read, external_need) }

      context "with read permission for an external fund" do
        let!(:permission) { create(:permission, user: user, accessible: external_fund, role: "read") }

        it { is_expected.to be_able_to(:read, external_fund) }
        it { is_expected.not_to be_able_to(:manage, external_fund) }
      end
      context "with write permission for an external fund" do
        let!(:permission) { create(:permission, user: user, accessible: external_fund, role: "write") }

        it { is_expected.to be_able_to(:manage, external_fund) }
      end
    end

    context "without being a member of an organisation" do
      let(:user) { create(:user, admin: false) }
      let(:organisation) { create(:organisation) }
      let!(:fund) { create(:fund, organisation: organisation) }
      let!(:need) { create(:need, organisation: organisation) }

      context "when reading a fund belonging to the organisation" do
        it { is_expected.not_to be_able_to(:read, fund) }
      end

      context "when reading a fund belonging to the organisation" do
        it { is_expected.not_to be_able_to(:read, need) }
      end

      context "with a read permission record" do
        let!(:fund_permission) { create(:permission, user: user, accessible: fund, role: "read") }
        let!(:need_permission) { create(:permission, user: user, accessible: need, role: "read") }

        it { is_expected.to be_able_to(:read, fund) }
        it { is_expected.to be_able_to(:read, need) }
      end

      context "with a write permission record" do
        let!(:fund_permission) { create(:permission, user: user, accessible: fund, role: "write") }
        let!(:need_permission) { create(:permission, user: user, accessible: need, role: "write") }

        it { is_expected.to be_able_to(:manage, fund) }
        it { is_expected.to be_able_to(:manage, need) }
      end
    end
	end
end

Lastly, I’m using GraphQL for the API in this application, so to restrict a query, we can use the .can? method on our ability class with the current user, fund, and permission we want to check for to return a boolean and a surrounding if statement to decide if we return the query or raise an error.

module Queries
  class Fund < Queries::BaseQuery
    description "Find a specific fund"

    argument :id, ID, required: true

    type Types::FundType, null: false

    def ready?(**args)
      authenticate
    end

    def resolve(id:)
      fund = ::Fund.find(id)

      if Ability.new(current_user).can?(:read, fund)
        fund
      else
        unauthorized_error
      end
    rescue ActiveRecord::RecordNotFound => error
      raise GraphQL::ExecutionError.new(error)
    end
  end
end