Lazy Load CanCanCan Abilities In Rails
Tags: rails, ruby, cancancan
Published: 2021-03-20 01:08:33

This [Rails](https://rubyonrails.org/) strategy lets you lazy load your [CanCanCan](https://github.com/CanCanCommunity/cancancan) abilities so you only load them when needed helping improve overall performance.

![cancancan_800](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7wmjm8jnndlptj5q6pk7.png)
  
I love [CanCanCan](https://github.com/CanCanCommunity/cancancan), it’s a powerful authorization library for [Rails](https://rubyonrails.org/) initially created the [Ryan Bates](https://github.com/ryanb) and later adopted by the [Rails community](https://github.com/CanCanCommunity/) to support and maintain. It’s my default authorization gem for all my Rails projects.

I also like to follow [STI pattern](https://api.rubyonrails.org/classes/ActiveRecord/Inheritance.html) 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](https://medium.com/@coorasse/cancancan-that-scales-d4e526fced3d). 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.
```ruby
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
```
#### 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.
```ruby
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
```

This keeps the implementing ability pretty clean and readable, which helps a lot when you have many user types.
```ruby
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
```

---

#### 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_.
```ruby
def current_ability
  @current_ability ||= PostAbility.new(current_user)
end
```

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.
```ruby
def current_ability
  model_name = controller_name.classify
  @current_ability ||= "#{model_name}Ability".constantize.new(current_user)
end
```

---

#### 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_.
```ruby
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
```

Although Rails has a [controller_name](https://apidock.com/rails/ActionController/Metal/controller_name/class) 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_.
```erb
<% if can?(:index, Comment) %>
  <%= render partial: "comments/comment", collection: @post.comments.accessible_by(current_ability) %>
<% end %>
```

#### 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](https://apidock.com/rails/ActiveSupport/Memoizable/memoize) a _model_abilities_ hash where these are stored.
```ruby
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
```

---

_That’s it!_ Now we can have our cake and eat it too! Here’s a [gist of the complete solution](https://gist.github.com/dalezak/ab763b41f2fb086c39ef42018879f31e), _enjoy!_ 🍰