Cancancan or Pundit?
I’ve always been in two minds about pundit
and cancancan
as authorization libraries. Ryan Bates championed the latter, which probably has a lot to do with its success. However, after using both, my conclusions are more fully formed: for simple use cases: both cancancan and pundit work equally well. But, for complex use cases, pundit
’s better.
Summary / Conclusion: Use Pundit. Ditch cancancan.
Here’s why:
Cancancan is complex
Wanna drop it in and continue blazing away? Think again. You’ll have to get through cancancan’s documentation first. Which is not trivial.
Or consider this exercise: of the options below, what is simpler?
### ability.rb
can :read, Article, published: true
# use it:
can?(:read, @article)
And pundit:
class ArticlePolicy < ApplicationPolicy
# etc...
def initialize(current_user, article)
@current_user = current_user
@article = article
end
def read?
article.published?
end
end
# use it:
ArticlePolicy.new(current_user, @article).read?
Cancancan
Problem 1: Complexity (Understanding what it means)
What is :read
? That’s cancancan short-hard for :show
and :index
. What’s that? That’s short-hand which corresponds to your RESTFUL controller actions. And what’s with the hash: :read, Article, published: true
? That allows users to :show
or :index
any article where published == true
. Great! What if you have another policy? No problem, cancancan automatically ORs
them. And what if you can’t or
them? Ok, just pass in a block. Well does that still work when we’re scoping? Why is my brain hurting?
Why oh why do I need a DSL that constrains me in order to approve / deny access to a controller action?
Why bother with the cancancan
“DSL” when you can minimize it with pundit?
The latter is pure ruby: is this authorized? Just say “yes” or “no” - and that’s all that matters. Forget worrying about abilities precedence and overriding, forget about using a hash to define your conditions - and if that doesn’t work, no need to bother with using a block or complex block conditions with scopes - why oh why? With the latter two options, you might have to stuff around with sql. Moreover, you won’t be able to use load_resource
with a block to define an ability
, without also supply an sql string - because the instance variable created by load_resource
will not be set. And if you’re using a block condition with an active record scope, then multiple can
definitions are not possible….did you catch all that?
Rules, rules, and complex rules, which require: documentation, reading, confusion, debugging, hair pulling etc… Yes, Pundit also has it’s own documentation, and magic methods: e.g. the authorize
method. But you are not BOUND to it, and it’s far simpler - still a pain, but less so.
For more complex cases, how are you meant to do something like below in cancancan
?
class IssueStatusPolicy < ApplicationPolicy
def edit?(issue_state_params)
return line_item.aasm(:issue_state).events(permitted: true).map(&:name).any?{|e| e == issue_state_params}
end
end
Good luck comprehending how that fits in with abilities precedence, and maintaining/testing.
Problem 2: Precedence
If you allow something, and then disallow it - what applies? What happens if you do vice versa? Why can’t we just return the second some code is run?
Order and precedence will confuse the life out of you. It is particularly mortifying if, while testing, you discover that someone is getting unauthorized access to something.
Cancancan, it’s not you, sweetheart, it’s me: I gotta find a gem that I’m compatible with, and that’s a joy to come home to. We had fun babez. Pundit’s not super sexy, yes, but she’s stable, simple, and sane, and doesn’t drive me crazy with drama.
Problem 3: Debugging
Debugging cancancan? What a nightmare. For some reason, some test is failing. I cannot work out why. I would like to step through a debugger. How are you meant to do that with the order precedence, rules_index?
But Pundit Ain’t Perfect
Problem 1: parameters
Cancancan, in a way, allows you to pass in parameters to its authorize method. Pundit makes it difficult. There are ways around it: just instantiate what you want directly, but it feels kinda dirty, and it isn’t clear what’s going on when you’re reading the code.
For example:
# preferred
def initialize(user, line_item, quote)
@user = user
@line_item = line_item
@quote = quote
end
The above should be preferred over the below:
# ugh:
def initialize(user, line_item)
@user = user
@line_item = line_item
@quote = line_item.quote # not explicit
end
And when using it:
# not preferred
LineItemPolicy.new(current_user, line_item).update?
# you have no idea that @quote is secretly being used
# behind the scenes.
Notice how the use of @quote
is opaque? So now what? If you want to inject, you’ll have to set your own UserContext
? What. A. Pain. I thought Pundit’s promise was that we get to use ruby to authorize, and it would then get out of our way?
Problem #2: everything must by directly mapped from a controller action to an authorization method
What if I have two types of LineItemController(s)
with two different update?
actions? And both modes of authorization are different: what am I meant to do here?
Pundit liberates you from slavery, only to be manacled by its design impositions. You’re encouraged to rethink your problems to conform. I appreciate that. But sometimes I just want to make a small change, without having to deal with a bureaucracy built into the gem. It seems directly contrary to the philosophy of Rails: provide sharp knives.
Authorization should be simple. We ought not to have our freedoms curtailed by accursed impositions from the North: the only solution is immediate and unconditional succession.
Problem #3: Rescuing Exceptions
This is where a routing tree model, as with Roda, crushes rails. The former framework allows us to route requests even before they hit controller actions. But with rails, we must first hit the action, and then redirect it somewhere. The pundit model simply raises an exception if you are unauthorized, which is then requested. Why raise and rescue? We should reroute before hitting the action in the first place.
# posts_controller
def show
authorize! @post
# if unauthorized then I have to rescue
end
# but I want to redirect if unauthorized!
def show
if user.admin?
redirect_to admin_path
elsif user.guest?
redirect_to guest_path
else
redirect_to unauthorized_path
end
authorize! @post # raise exception after redirecting
end
Epilogue
I say the above with the utmost respect for the authors / contributors of those gems: those gems were written for a purpose in mind (their purposes) and it’s the height of impudence for an upstart to criticise their freely libated work, without at least offering a comparable solution. Criticizing is easy. Solving problems is harder. I have used both libraries to great advantage. So please don’t get me wrong: Bates has done a marvelous thing for the Rails community, as have all the contributors to both libraries.