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.