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.
In the Last Episode…
We built an authorization (not authentication!) system, guarding various bits of an application’s backend panel. Access was granted on a set of named permissions, each mapping to a flag on the user’s object. However, bloat happened, and the solution is no longer maintainable in a large application, with dozens of roles and hundreds of actions.
Pundit
Enter Pundit. It’s a minimal object-oriented authorization library, with no DSL or pre-baked role system of any kind. It is up to you to describe how your permissions work, where are they stored, and how they interact with each other.
Your case statement is now a Policy
class:
class ExamplePolicy
attr_reader :admin, :object
def initialize(admin, object)
@admin = admin
@object = object
end
def index?
admin.sales?
end
def show?
admin.customer_service?
end
end
Typically these classes describe a single resource each. That resource is passed to the policy in the second parameter, and it’s up to the policy class to decide what checks need to be done for authorization. In your permission-based system, the object is unnecessary as you only look into the admin.
# spec/controllers/pundit_bits_authorization_spec.rb
describe "PunditBitsAuthorization", type: :controller do
let(:auth_error) { Pundit::NotAuthorizedError }
controller do
include Pundit
# Not current_admin like previously, because pundit expects this method
attr_reader :current_user
def index
authorize :example
head :ok
end
def show
authorize :example
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_user).and_return(admin) }
context "with disabled" do
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
Permission-granting code no longer lives in the controller (or its concerns). All decisions are now made inside ExamplePolicy
. This is very good, because you’ve achieved this nice level of separation, and no longer mix policy decisions with models or controllers. Plus there are only short methods there, short enough that they need no testing. So you convert that ugly case statement to a set of these classes.
Complexity, Now Hidden
But your app keeps growing. Policies didn’t help with one important thing: the permision system is now huge, complex, and nobody understands its internal workings. People bug you on IM with questions like “why can’t I access this action?” and “what can I do with role X?” Only you and other developers can answer these questions, but it requires understanding code. Let’s try answering the second question with some code.
# spec/policy_query_spec.rb
describe PolicyQuery do
let(:admin) { BitEnabledAdmin.new }
subject { described_class.new(admin) }
context "with superuser" do
before { allow(admin).to receive(:superuser?).and_return(true) }
example do
expect(subject.call).to eq({"ExamplePolicy" => { :index? => true, :show? => true } })
end
end
context "with sales" do
before { allow(admin).to receive(:sales?).and_return(true) }
example do
expect(subject.call).to eq({"ExamplePolicy" => { :index? => true, :show? => false } })
end
end
context "with customer_service" do
before { allow(admin).to receive(:customer_service?).and_return(true) }
example do
expect(subject.call).to eq({"ExamplePolicy" => { :index? => false, :show? => true } })
end
end
context "with customer_service + sales" do
before { allow(admin).to receive(:customer_service?).and_return(true) }
before { allow(admin).to receive(:sales?).and_return(true) }
example do
expect(subject.call).to eq({"ExamplePolicy" => { :index? => true, :show? => true } })
end
end
end
PolicyQuery
is given a user, and returns a map of policy class names to boolean-valued maps keyed by action names. Such a map is useful to display in some kind of interface, e.g. where you manage the Admins. Instead of just listing roles and having to remember which does what, or looking into policies to find that out. How does this work? With a bit of introspection.
# spec/policy_query.rb
class PolicyQuery
def initialize(admin)
@admin = admin
end
def call
policy_classes.map do |klass|
inst = klass.new(@admin, nil)
[ klass.name,
permission_methods(klass).map do |sym|
[sym, inst.public_send(sym)]
end.to_h ]
end.to_h
end
private
def policy_classes
# .descendants is supplied by ActiveSupport.
# But you could build it yourself by using `Class.inherited` callback
BasePolicy.descendants
end
def permission_methods(klass)
public_predicates(klass)
end
def public_predicates(klass)
# Passing false to instance_methods returns only the class's *own* methods. Not any inherited ones.
klass.instance_methods(false)
.select { |name| name.to_s.ends_with? "?" }
end
end
Trivial, really. Enumerate all policies, and for each of them enumerate its permission methods. Map names to results, done. As policies shouldn’t do anything else than query for roles, this is perfectly safe.
One more question remains unasked. It’s “which roles do I need to access action Y?”
Does answering it require scary metaprogramming? No. Does it require rewriting policies with a DSL we can then reverse-engineer? Not really. Are we going to decompile policy code and get an answer from the AST? No, but that would work too!
We’ll try authorizing with a different person. Not an administrator. A spy.
# spec/policy_inspector_spec.rb
describe PolicyInspector do
subject { described_class.new(policy, action) }
context "ExamplePolicy" do
let(:policy) { "ExamplePolicy" }
context "#index" do
let(:action) { "index?" }
example do
expect(subject.to_s).to eq("superuser || sales")
end
end
end
end
# spec/policy_inspector.rb
class PolicyInspector
def initialize(policy, action)
@klass = policy.constantize
@action = action
end
def to_s
result.map(&:to_s).join(" || ")
end
def result
@klass.new(spy, nil).send(@action)
end
private
def spy
Spy.new
end
end
Except it doesn’t work:
Failures:
1) PolicyInspector ExamplePolicy #index should eq "superuser || sales"
Failure/Error: expect(subject.to_s).to eq("superuser || sales")
expected: "superuser || sales"
got: "superuser"
(compared using ==)
# ./spec/policy_inspector_spec.rb:15:in `block (4 levels) in <top (required)>'
Let’s explain what happens here. The basic premise is this: boolean predicates on the user now return a single-element set. Logical operations on booleans map well to sets. Alternative corresponds to union, conjunction to intersection. Chained boolean conditions are now chained set operations! Policy methods that returned a boolean now return a set of flags used in its condition.
Except that we didn’t do set operations here. The error is very basic: ||
is always boolean, and short-circuiting. Two sets joined by ||
will just return the first one, and not the union of both. That’s why we’re only getting superuser here. Let’s fix it:
class BasePolicy
# keeping the rest untouched
ROLES.each do |role|
define_method("#{role}?") do
admin.present? && (superuser? | admin.send("#{role}?"))
end
end
end
class ComplexPolicy < BasePolicy
def invoice?
warehouse? | billing?
end
end
And now it works. Keep in mind that we lost an important bit of behavior this way: short-circuiting. Unlike earlier, boolean conditions will be evaluated fully, instead of possibly skipping some arguments. For ||
this means that when the left argument is true, there’s no need to check the right one – the result is true regardless. Similarly &&
returns false
when its first argument is falsy. And while |
and &
ultimately return the same result, they won’t take these shortcuts – which is exactly what we need to build the full set.
This string result is a Disjunctive Normal Form (DNF), a list of (possibly negated) boolean variables joined by OR
. We actually don’t care about negations, since there are no checks for absent permissions here.
But What If My Conditions Use &&
and Not Only ||
?
You need a smarter spy.
# spec/complex_policy.rb
class ComplexPolicy < BasePolicy
def invoice?
warehouse? | billing?
end
def cancel?
billing? & sales?
end
end
# spec/policy_logic_spec.rb
describe PolicyLogic do
subject { described_class.new(policy, action) }
context "ComplexPolicy" do
let(:policy) { "ComplexPolicy" }
context "#invoice" do
let(:action) { "invoice?" }
example do
expect(subject.to_s).to eq("warehouse || billing")
end
end
context "#invoice" do
let(:action) { "cancel?" }
example do
expect(subject.to_s).to eq("(billing && sales)")
end
end
end
end
First, we’ll skip returning superuser, as it’s present in every condition and not interesting. Mapping it to an empty set does the job. Second, we need a way to group expressions created with &
separately from the others, and two subclasses of Set
will do the job nicely.
# spec/policy_logic.rb
class OrSet < Set
def &(other)
AndSet.new([self, other])
end
def to_s
each.map(&:to_s).join(' || ')
end
end
class AndSet < Set
def to_s
"(" + each.map(&:to_s).join(' && ') + ")"
end
end
class DNFSpy
BitEnabledAdmin::ROLES.each do |role|
define_method("#{role}?") { OrSet.new([role]) }
end
def superuser?
# Superuser isn't interesting. Skip it from results.
OrSet.new
end
end
class PolicyLogic < PolicyInspector
def spy
DNFSpy.new
end
def to_s
result.to_s
end
end
See that this result is still a DNF, in this case what’s joined by OR
operations are parenthesized AND
operations, with arguments possibly negated (though still not doing that here). If necessary, you could add an override for unary !
on OrSet
, which would return a single-element NotSet
that stringifies to your preferred notation, for example ~warehouse
.
But What If My Conditions Check Actual Objects Passed?
Then spy on the object too! We’re firmly in overkill territory now.
# spec/arbitrary_policy.rb
class ArbitraryPolicy < BasePolicy
def initialize(admin, object)
super
@object = object
end
def invoice?
warehouse? | @object.unpaid?
end
def cancel?
@object.price.zero? | @object.sourced_from?("EU")
end
end
# spec/advanced_policy_logic_spec.rb
describe AdvancedPolicyLogic do
subject { described_class.new(policy, action) }
context "ArbitraryPolicy" do
let(:policy) { "ArbitraryPolicy" }
context "#invoice" do
let(:action) { "invoice?" }
example do
expect(subject.to_s).to eq("warehouse || object.unpaid?")
end
end
context "#cancel" do
let(:action) { "cancel?" }
example do
expect(subject.to_s).to eq(%{object.price.zero? || object.sourced_from?("EU")})
end
end
end
end
Now we can discover arbitrary chains of calls on the objects as well. With some limitations: the calls need to either take no arguments, or these arguments must be constants or methods on the object itself as well. No outside state can be accessed here directly. Good design argues that this state should be queried by methods on the object itself though.
To make this work, we pass two spies to the policy: for both admin and object. The object spy has a method_missing
, returning a special set with its sole member named after the method. That set in turn also has method_missing
, to catch further calls, and it returns another set that appends any nested calls onto its first member description.
# spec/advanced_policy_logic.rb
def format_args(*args)
return "" if args.size == 0
"(#{args.map(&:inspect).join(", ")})"
end
class AdvancedOrSet < OrSet
def method_missing(name, *args, &block)
# `first` is our previous callchain's representation
self.class.new(["#{first}.#{name}#{format_args(*args)}"])
end
end
class ObjectSpy
def method_missing(name, *args, &block)
AdvancedOrSet.new(["object.#{name}#{format_args(*args)}"])
end
end
class AdvancedPolicyLogic < PolicyInspector
def result
@klass.new(spy, object_spy).send(@action)
end
def spy
DNFSpy.new
end
def object_spy
ObjectSpy.new
end
delegate :to_s, to: :result
end
Conclusions
The system described above was implemented in a client’s application, which suffered from the exact problem of too many overlapping roles and bloated, unreadable permission code. It was received very well – when adding or editing an admin user, the permission map updates immediately (because unsaved objects work with PolicyInspector
too!). It can also be queried both ways – clicking on an action highlights the roles needed. This wasn’t covered here, as it’s too much detail, but can be built from parts described.
After the change it feels very natural to write policy classes. Occassionally discussions pop up whether some new functionality should use an existing policy class or a new one, usually resolved by asking if this is an existing resource, or a new one. It also turned out that a navigation bar is a resource that can be described using policies, exposing different links and menus depending on available roles.
Using Pundit to isolate permission decisions is not only good OO design, but also provides additional benefits, in that we can get different output depending on what object we pass to them. In regular usage, when passing user objects, it’s a boolean, and for spies it’s an algebraic expression containing required flags, encoded as a nested Set
.
Regular users just use the system. Spies pass through it, discover things and learn.