Abstract

There are plenty of authorization libraries for Ruby (or Rails). Many strive to implement an elegant DSL to express their rule system, attempting to become fully declarative (as opposed to imperative). While this approach has some merit, we’ll disregard it completely. Instead, we’ll see how using the simplest possible tool for the job can give us some unexpected benefits.

Part 1 of 2: A Rails Application’s Life

It starts like any other app: with rails new. At some point in the last ten years somebody invoked that command. You, or someone long since replaced. But we’ll put the entire blame on you. The app exposes an API for companion mobile applications, and has an extensive admin panel used to manage the things users care about. And your tests use RSpec.

Initially, there were only users and admins, and since the former can only use the API, you have only the latters’ authorization to worry about.

# spec/controllers/authorization_spec.rb


describe "Authorization", type: :controller do
  let(:auth_error) { MyAuthorization::AuthorizationFailed }
  controller(ApplicationController) do
    include MyAuthorization

    def current_admin
      nil
    end

    def index
      head :ok
    end
  end

  context "with no user" do
    it_behaves_like "forbids index"
  end

  context "with logged-in user" do
    let(:some_admin) { double("Administrator") }
    before { allow(controller).to receive(:current_admin).and_return(some_admin) }

    it_behaves_like "allows index"
  end
end

To make this work, we only need to check for admin’s existence.

# app/services/my_authorization.rb

module MyAuthorization
  extend ActiveSupport::Concern
  class AuthorizationFailed < StandardError; end

  included do
    before_filter :check_admin
  end

  def check_admin
    raise AuthorizationFailed unless current_admin.present?
  end
end

Months pass. Well-intentioned people persuade you to add more and more to the app. Managing things is now a full-time position, and there are good reasons to not show the whole panel to everyone (for example, you now have monetary transactions and invoices in the system, and interns should not see that, only accountants and senior employees). You stick with Administrator, but add an enum to tell who is who.

class Administrator < ActiveRecord::Base
  enum :role, %i(disabled intern accountant manager superuser)
end
# spec/controllers/role_authorization_spec.rb


describe "RoleAuthorization", type: :controller do
  let(:auth_error) { RoleAuthorization::AuthorizationFailed }
  controller(ApplicationController) do
    include RoleAuthorization

    def current_admin
      nil
    end

    def index
      require_intern!

      head :ok
    end

    def show
      require_accountant!

      head :ok
    end
  end

  context "with no user" do
    it_behaves_like "forbids index"
    it_behaves_like "forbids show"
  end

  context "with admin" do
    let(:admin) { LevelInquiringAdmin.new }
    before { allow(controller).to receive(:current_admin).and_return(admin) }

    context "disabled" do
      before { allow(admin).to receive(:role).and_return("disabled") }
      it_behaves_like "forbids index"
      it_behaves_like "forbids show"
    end

    context "intern" do
      before { allow(admin).to receive(:role).and_return("intern") }

      it_behaves_like "allows index"
      it_behaves_like "forbids show"
    end

    context "accountant" do
      before { allow(admin).to receive(:role).and_return("accountant") }

      it_behaves_like "allows index"
      it_behaves_like "allows show"
    end

    context "superuser" do
      before { allow(admin).to receive(:role).and_return("superuser") }

      it_behaves_like "allows index"
      it_behaves_like "allows show"
    end
  end
end

All these require_something! need to check whether the admin exists, and their role is sufficient to access the action. If more than one role needs to have access to the same action, this can get complicated.

# app/services/role_authorization.rb

module RoleAuthorization
  extend ActiveSupport::Concern
  class AuthorizationFailed < StandardError; end

  def require_admin!
    raise AuthorizationFailed unless current_admin.present?
  end

  def require_intern!
    require_admin!
    raise AuthorizationFailed unless current_admin.intern? || current_admin.accountant? || current_admin.manager? || current_admin.superuser?
  end

  def require_accountant!
    require_admin!
    raise AuthorizationFailed unless current_admin.accountant? || current_admin.manager? || current_admin.superuser?
  end
end

More months pass. These nice, well intentioned people continue to persuade and/or pay you for implementing more bloat business requirements. The company grows, splits into departments with more defined responsibilities, and so does the admin panel. You decide to adapt the role system to mimic the company’s structure, and roles now look like job titles. The system handles it in stride.

class Administrator < ActiveRecord::Base
  enum :role, %i(disabled
                 customer_service customer_service_manager
                 warehouse warehouse_manager
                 billing billing_manager
                 sales sales_manager
                 superuser)
end
# spec/controllers/job_authorization_spec.rb


describe "JobAuthorization", type: :controller do
  let(:auth_error) { JobAuthorization::AuthorizationFailed }
  controller(ApplicationController) do
    include JobAuthorization

    def current_admin
      nil
    end

    def index
      # Have any role but disabled
      authorize!

      head :ok
    end

    def show
      # Have any listed role
      authorize! :customer_service, :customer_service_manager, :sales, :sales_manager
      head :ok
    end
  end

  context "with no admin" do
    it_behaves_like "forbids index"
    it_behaves_like "forbids show"
  end

  context "with admin" do
    let(:admin) { JobInquiringAdmin.new }
    before { allow(controller).to receive(:current_admin).and_return(admin) }

    context "disabled" do
      before { allow(admin).to receive(:role).and_return("disabled") }

      it_behaves_like "forbids index"
      it_behaves_like "forbids show"
    end

    context "customer_service" do
      before { allow(admin).to receive(:role).and_return("customer_service") }

      it_behaves_like "allows index"
      it_behaves_like "allows show"
    end

    context "billing_manager" do
      before { allow(admin).to receive(:role).and_return("billing_manager") }
      it_behaves_like "allows index"
      it_behaves_like "forbids show"
    end

    context "superuser" do
      before { allow(admin).to receive(:role).and_return("superuser") }

      it_behaves_like "allows index"
      it_behaves_like "allows show"
    end
  end
end

Calling authorize! with no arguments just checks for an admin. Any arguments passed are these job titles, and any one is sufficient to access the action.

# app/services/job_authorization.rb

module JobAuthorization
  extend ActiveSupport::Concern
  class AuthorizationFailed < StandardError; end
  
  def authorize!(*roles)
    raise AuthorizationFailed unless current_admin.present? && !current_admin.disabled?

    return if roles.empty? || current_admin.superuser?
    raise AuthorizationFailed if roles.none? &method(:check_role)
  end

  private

  def check_role(role)
    current_admin.send "#{role}?"
  end
end

These lists of acceptable roles on each action start getting unwieldy, though. You add another layer of abstraction: give an action (or several actions) a permission name, and calculate these from the roles.

# spec/controllers/actions_authorization_spec.rb


describe "ActionAuthorization", type: :controller do
  let(:auth_error) { ActionAuthorization::AuthorizationFailed }
  controller(ApplicationController) do
    include ActionAuthorization

    def current_admin
      nil
    end

    def index
      authorize! :index_things
      head :ok
    end

    def show
      authorize! :show_things
      head :ok
    end
  end

  context "with no admin" do
    it_behaves_like "forbids index"
    it_behaves_like "forbids show"
  end

  context "with admin" do
    let(:admin) { JobInquiringAdmin.new }
    before { allow(controller).to receive(:current_admin).and_return(admin) }

    context "with disabled" do
      before { allow(admin).to receive(:role).and_return("disabled") }
      it_behaves_like "forbids index"
      it_behaves_like "forbids show"
    end

    context "with customer_service" do
      before { allow(admin).to receive(:role).and_return("customer_service") }
      it_behaves_like "allows index"
      it_behaves_like "allows show"
    end

    context "with sales" do
      before { allow(admin).to receive(:role).and_return("sales") }
      it_behaves_like "allows index"
      it_behaves_like "forbids show"
    end

    context "with sales_manager" do
      before { allow(admin).to receive(:role).and_return("sales_manager") }
      it_behaves_like "allows index"
      it_behaves_like "allows show"
    end

    context "with superuser" do
      before { allow(admin).to receive(:role).and_return("superuser") }
      it_behaves_like "allows index"
      it_behaves_like "allows show"
    end
  end
end

# app/services/action_authorization.rb

module ActionAuthorization
  extend ActiveSupport::Concern
  class AuthorizationFailed < StandardError; end

  # To skip typing `current_admin` every time, we'll delegate the role methods
  # In a real model, we could pull them out by introspection. Here, we'll just copy ROLES
  %i(disabled customer_service customer_service_manager
     warehouse_manager warehouse
     billing_manager billing
     sales_manager sales
     superuser).each { |key| delegate "#{key}?", to: :current_admin }

  def authorize!(permission=nil)
    raise AuthorizationFailed unless current_admin.present? && !current_admin.disabled?
    return if superuser? || permission.nil?

    allowed = case permission.to_sym
    when :show_things
      customer_service? || customer_service_manager? || sales_manager?
    when :index_things
      true
    else
      raise AuthorizationFailed, "Unknown permission #{permission}"
    end

    raise AuthorizationFailed unless allowed
  end
end

Because your app keeps growing, the list of actions also balloons considerably. That cute case is now 40 lines long and checks 97 different permission names. But you don’t consider using an authorization library yet.

However, your next mission is: to allow a single user to have more than one role assigned simultaneously. This looks easy! You already have these chains of ||s in the code, and no changes are needed there. But how do we allow multiple roles?

With bitfields. A civilized weapon for a more civilized age, when every bit counted and memory sizes were in the kilo- and megabytes range. A bitfield is technically a fixed-length vector, where each element is a boolean. Additionally, each of these elements has a distinguishing name, so a better description could be “named tuple”. Within ORMs we typically represent each of these elements with a getter/setter pair.

You upgrade the enum to a bitfield (using grosser/bitfields syntax in this example).

class Administrator < ActiveRecord::Base
  # This is the lazy version, where we don't care which exact bit a flag gets.
  bitfield :role, :customer_service, :customer_service_manager,
                  :warehouse, :warehouse_manager
                  :billing, :billing_manager,
                  :sales, :sales_manager,
                  :superuser
end
# spec/controllers/bits_authorization_spec.rb


describe "BitsAuthorization", type: :controller do
  let(:auth_error) { BitsAuthorization::AuthorizationFailed }
  controller do
    include BitsAuthorization

    def current_admin
      nil
    end

    def index
      authorize! :index_things
      head :ok
    end

    def show
      authorize! :show_things
      head :ok
    end
  end

  context "with no admin" do
    it_behaves_like "forbids index"
    it_behaves_like "forbids show"
  end

  context "with admin" do
    let(:admin) { BitEnabledAdmin.new }
    before { allow(controller).to receive(:current_admin).and_return(admin) }

    context "with disabled" do
      before { allow(admin).to receive(:disabled?).and_return(true) }
      it_behaves_like "forbids index"
      it_behaves_like "forbids show"
    end

    context "with customer_service" do
      before { allow(admin).to receive(:customer_service?).and_return(true) }

      it_behaves_like "forbids index"
      it_behaves_like "allows show"
    end

    context "with sales" do
      before { allow(admin).to receive(:sales?).and_return(true) }

      it_behaves_like "allows index"
      it_behaves_like "forbids show"
    end

    context "with sales + customer_service" do
      before do
        allow(admin).to receive(:sales?).and_return(true)
        allow(admin).to receive(:customer_service?).and_return(true)
      end

      it_behaves_like "allows index"
      it_behaves_like "allows show"
    end

    context "with superuser" do
      before { allow(admin).to receive(:superuser?).and_return(true) }
      it_behaves_like "allows index"
      it_behaves_like "allows show"
    end
  end
end

Hey, the controller code looks almost untouched! (except that we can now spec for a combination of roles).

# app/services/bits_authorization.rb

# Only rules in the case statement changed from ActionAuthorization
module BitsAuthorization
  extend ActiveSupport::Concern
  class AuthorizationFailed < StandardError; end

  %i(disabled customer_service customer_service_manager
     warehouse_manager warehouse
     billing_manager billing
     sales_manager sales
     superuser).each { |key| delegate "#{key}?", to: :current_admin }

  def authorize!(permission=nil)
    raise AuthorizationFailed unless current_admin.present? && !current_admin.disabled?
    return if superuser? || permission.nil?

    allowed = case permission.to_sym
    when :show_things
      customer_service? 
    when :index_things
      sales?
    else
      raise AuthorizationFailed, "Unknown permission #{permission}"
    end

    raise AuthorizationFailed unless allowed
  end
end

Same here, not many changes. This works because the admin object under test can responds with true to a combination of role queries. Previously, it would do so only for one role: that set in the enum.

# spec/controllers/bit_enabled_admin.rb

class BitEnabledAdmin
  ROLES = %i(disabled customer_service customer_service_manager
             warehouse warehouse_manager
             billing billing_manager
             sales sales_manager
             superuser).freeze
  # We're not doing actual bitfields here, just accessors.
  # We'll stub these later to return correct "bits"
  ROLES.each do |role|
    define_method("#{role}?") { false }
  end
end

At this point the permission checking can be simplified: each rule needs to check only its most relevant role flag, instead of all that may apply. But the case statement is still 40 lines long. Can we do better than this?

Yes, we can! We’ll do it in the next part of our tales full of suspense, pundits and spies.