Extending Pundit with dedicated policies per user role
I am a big fan of using Pundit for authorization. It is simple, easy to implement and extensible. On a recent project, we had to deal with multiple user roles and as such we were starting to get a quite complex policy object. In particular we had to start checking the type of user we had, leading to complex logic branches:
This is solely for resolving a Scope of records accessible by an user. You can imagine how complex the logic for permitting an individual action can get at this point.
As more actions needed additional conditional logic, we started to encounter small bugs and our tests became less and less clear as source of truth for who could do what.
Our first step was to actually write the code we wish we had and let our integration tests guide the refactoring, starting from the top of the conditional branch in the resolve block:
Since Pundit policies are based on a user and a record being passed in, or in the case of a Scope, a class name, we knew we had to provide some additional context in order for this to work. Passing additional arguments to the policy would not help our case, since we needed a dynamic way of instantiating the correct policy object for each of our users. Our next port of call was to investigate how Pundit itself retrieves policy objects. We quickly found the answer in the
Pundit checks if the passed in object responds to a instance or class policy_class method. Failing that it assigns the klass local variable to the objects model name or class and appends the value of SUFFIX, which is “Policy”. Armed with this knowledge it becomes simple to implement our requirement: we need to wrap the object we pass to Pundit with something that responds to policy_class, returning the correct name of the policy to instantiate.
For there on, it was easy to arrive at our solution:
Given an User with a role of admin, our policy class method will respond with “AdminFooPolicy”, which Pundit will then constantize and initiate. We expose a reader for the record so that we can then unwrap the record to authorize in our policy object.
This will work both with instantiating a record policy, a scope policy and a specific controller action. The calls in our controller now look like this:
Our tests per individual policy are now much cleaner and we’ve managed to get rid of a lot of conditional logic and subsequent potential for hidden bugs.