This Rails strategy lets you lazy load your CanCanCan abilities so you only load them when needed helping improve overall performance.
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
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
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
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
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
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
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 %>
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
That’s it! Now we can have our cake and eat it too! Here’s a gist of the complete solution, enjoy! 🍰
Top comments (0)