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.

Written on February 15, 2022