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.