DEV Community

loading...

Lazy Load CanCanCan Abilities In Rails

Dale Zak
Senior mobile developer that was building and designing mobile apps, before mobile apps were cool.
Originally published at dalezak.Medium Updated on ・4 min read

This Rails strategy lets you lazy load your CanCanCan abilities so you only load them when needed helping improve overall performance.

cancancan_800

I love CanCanCan, it’s a powerful authorization library for Rails initially created the Ryan Bates and later adopted by the Rails community to support and maintain. It’s my default authorization gem for all my Rails projects.

I also like to follow STI pattern for authentication, for example having a base User with Admin, etc subclasses. To go along with these user types, I usually define UserAbility, AdminAbility, etc to encapsulate all specific user’s abilities in one file.

This works great, although as a project grows these user ability files tend to get large. And if you are using any kind of ids queries like _user.post_ids when defining your abilities, you can see a performance hit since these query will be executed every time and not just when checking that specific ability.

So I recently to adopt the strategy outlined by [Alessandro Rodi to separate abilities per model. For example where previously I had UserAbility or AdminAbility, I would now have PostAbility, CommentAbility, etc. However after implementing this pattern, I ran into a few problems so came up with some solutions.


Problem 1: If-Else Checks In Every Ability File

Since I have different user types, I’d need if-else checks inside every ability which gets repetitive and not great for readability.

class PostAbility
  include CanCan::Ability

  def initialize(user)
    if user.nil?
      # define User abilities
    elsif user.is_a?(Admin)
      # define Admin abilities
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Solution 1: Add Base ModelAbility Class

To avoid these if-else checks in every ability, I defined a ModelAbility base class which handles this check then calls a method named for each user type.

class ModelAbility
  include CanCan::Ability

  def initialize(user)
    if user.nil?
      self.user(user)
    elsif user.is_a?(Admin)
      self.admin(user)
    end
  end

  def admin(user)
    #child class defines Admin abilities here
  end

  def user(user)
    #child class defines User abilities here
  end
end
Enter fullscreen mode Exit fullscreen mode

This keeps the implementing ability pretty clean and readable, which helps a lot when you have many user types.

class PostAbility < ModelAbility
  def admin(user)
    can :manage, Post
  end

  def user(user)
    can :create, Post
    can :read, Post
    can :update, Post, user_id: user.id
  end
end
Enter fullscreen mode Exit fullscreen mode

Problem 2: Setting current_ability In Every Controller

To be able to use these model abilities, we need to set the current_ability according to the active controller being requested. For example the PostsController would load the PostAbility.

def current_ability
  @current_ability ||= PostAbility.new(current_user)
end
Enter fullscreen mode Exit fullscreen mode

This seems a bit error prone, for example if you forgot to define the current_ability in one of your controllers then your permission checks wouldn’t work.

Solution 2: Load current_ability In ApplicationController

Instead of setting the current_ability in every controller, we can instead set it once in the base ApplicationController. Now the current_ability is set for the active controller without us having to manually set it in every controller.

def current_ability
  model_name = controller_name.classify
  @current_ability ||= "#{model_name}Ability".constantize.new(current_user)
end
Enter fullscreen mode Exit fullscreen mode

Problem 3: Not Loading Parent Abilities

One issue with Solution 2 is that is only loads the ability for the current controller, however you may want to also load abilities for parent resources in the route. For the route /blogs/1/posts/2 you might need to load both BlogAbility and PostAbility.

Solution 3: Load Parent Abilities In TheRoute

One solution is to obtain all controller names in the route, then loop through and merge those abilities with the current_ability.

def controller_names
  @controller_names ||= begin
    regex_digit = /\d/
    regex_uuid = /[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}/
    controller_names = request.path.split("/")
    controller_names = controller_names.reject { |p| p.match?(regex_uuid) || p.match?(regex_digit) || p.empty? }
    controller_names = controller_names.map{ |p| File.basename(p, File.extname(p))}
    if controller_names.include?(controller_name)
      controller_names = controller_names.take(controller_names.index(controller_name)+1)
    end
  end
end

def current_ability
  @current_ability ||= begin
    current_ability = Ability.new(current_user)
    controller_names.to_a.each do |controller_name|
      model_name = controller_name.classify
      model_ability = "#{model_name}Ability".constantize rescue nil
      if model_ability.present?
        current_ability.merge(model_ability.new(current_user))
      end
    end
    current_ability
  end
end
Enter fullscreen mode Exit fullscreen mode

Although Rails has a controller_name helper, it doesn’t have a controller_names equivalent. So I wrote this method which skips ids both integers and uiids in the route.


Problem 4: Not Loading Child Abilities

Although Solution 3 will load the current controller model as well as parent controller models, it does not load any child model abilities. For example, you might need to load Comments so would also need to merge CommentAbility.

<% if can?(:index, Comment) %>
  <%= render partial: "comments/comment", collection: @post.comments.accessible_by(current_ability) %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

Solution 4: Extend can? Helper

If we extend the can? helper, we can intercept when any of our views check for abilities, then merge that ability if it hasn’t already been loaded. And to avoid merging existing abilities, we memoize a model_abilities hash where these are stored.

def model_abilities
  @model_abilities ||= {}
end

def controller_names
  @controller_names ||= begin
    regex_digit = /\d/
    regex_uuid = /[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}/
    controller_names = request.path.split("/")
    controller_names = controller_names.reject { |p| p.match?(regex_uuid) || p.match?(regex_digit) || p.empty? }
    controller_names = controller_names.map{ |p| File.basename(p, File.extname(p))}
    if controller_names.include?(controller_name)
      controller_names = controller_names.take(controller_names.index(controller_name)+1)
    end
  end
end

def current_ability
  @current_ability ||= begin
    current_ability = Ability.new(current_user)
    controller_names.to_a.each do |controller_name|
      model_name = controller_name.classify
      model_ability = "#{model_name}Ability".constantize rescue nil
      if model_ability.present? && model_abilities[model_ability].nil?
        model_abilities[model_ability] = model_ability.new(current_user)
        current_ability.merge(model_abilities[model_ability])
      end
    end
    current_ability
  end
end

def can?(*args)
  model_name = args[1].is_a?(Class) ? args[1].name : args[1].class.name
  model_ability = "#{model_name}Ability".constantize rescue nil
  if model_abilities[model_ability].nil?
    model_abilities[model_ability] = model_ability.new(current_user)
    current_ability.merge(model_abilities[model_ability])
  end
  current_ability.can?(*args)
end
Enter fullscreen mode Exit fullscreen mode

That’s it! Now we can have our cake and eat it too! Here’s a gist of the complete solution, enjoy! 🍰

Discussion (1)

Collapse
nikolahristov profile image
Niki Hristov

Just a heads up that you can add highlighting to the code blocks if you'd like. Just change: code block with no colors example ... to specify the language: code block with colors example More details in our editor guide!