Hi all 👋, this time there are two new versions of CanCanCan: 3.7.0 is out, and 4.0 is coming soon.
Please read the whole CHANGELOG, because both versions contain changes in behaviour, and some of them are marked with a WARNING.
Why two releases?
The amount of work needed to tackle all open CanCanCan issues and pull requests increased drastically in the last years. I found myself overwhelmed and decided not to do much more than the bare minimum necessary to keep it going and support the latest Rails version.
In the last months I went through the long list of open issues and pull requests. Many of them were about the same
problem: can? and accessible_by did not always agree. You could have a record for which can?(:read, record)
returned false, but that was still returned by Model.accessible_by(ability).
For an authorization library, this is the worst kind of bug.
Fixing all of them required rewriting how I build the SQL conditions, and that was a very long task.
Then Bruno opened a couple of PRs tackling security issues and this pushed me to find the time to release a new version.
I started with the idea of just releasing a minor version, but some of the bugfixes and open PRs turned out to be really complicated, and not worth the risk of breaking a version that so many applications depend on.
So I decided to split the work:
- 3.7.0 contains the smaller improvements and the easy bugfixes. I expect most apps to upgrade without any issue.
- 4.0 contains everything else: the Arel rewrite, the bigger behaviour changes, performance improvements, and some more bugfixes and features that will probably cause breaking changes.
Minimum requirements
CanCanCan 3.7.0 does not change the requirements: it is tested with Ruby 2.7+ and Active Record from 5.2 to 8.1. CanCanCan 4.0 requires Ruby 3.2+ and Rails 8.0+. This is also why many improvements have been made only on the 4.0 branch: keeping support for Rails 5.2 makes no sense anymore. Supporting such a wide range of versions has a cost, so if you run a Rails version below 8.0, CanCanCan 3.7 is the last minor version I will release.
CanCanCan 3.7.0
can? and accessible_by agree
Most of the changes in this release are about consistency between can? and accessible_by.
That’s also why there might be some corner cases where this new minor version could break your tests. But in a good way.
A cannot rule on an association now excludes a record when any of its associated records matches:
can :read, Post
cannot :read, Post, comments: { spam: true }
# A post with one spam comment and one normal comment
can? :read, post # => false
Post.accessible_by(ability) # 3.6: returned the post, 3.7: excludes it
The cannot rules are now checked in a subquery, NOT (posts.id IN (SELECT ...)), instead of on each joined row. As a
side effect, all the strategies now return the same records, and SQL Server and Oracle work too, since they do not
support the IS NOT TRUE I used before.
Single Table Inheritance also got a bunch of fixes. For example:
class Animal < ApplicationRecord; end
class Dog < Animal; end
class Puppy < Dog; end
can :read, Animal
cannot :read, Dog
Animal.accessible_by(ability) # 3.6: returned the puppies, 3.7: returns neither dogs nor puppies
And a rule on a subclass together with another class, like can :read, [Car, Brand], no longer returns every vehicle
when you call Vehicle.accessible_by(ability).
has_many :through associations that go through the same table keep all their conditions:
class Post < ApplicationRecord
has_many :taggings
has_many :tags, through: :taggings
has_many :categories, through: :taggings
end
can :read, Post, tags: { name: 'ruby' }, categories: { name: 'rails' }
Post.accessible_by(ability) # 3.6: ignored the condition on the tags
Finally, the conditions of accessible_by now use the same table aliases that Active Record gives to the joined
associations. This was the cause of several queries that failed or returned the wrong records.
String and Symbol actions
Strings and symbols are now treated the same way. This sounds like a detail, but look at this one:
can :manage, User
cannot :destroy, User
can? 'destroy', user # 3.6: true, 3.7: false
The same applied to skip_authorize_resource except: 'destroy', which also skipped the authorization of destroy.
can? also matches a symbol in a condition with a string attribute, as accessible_by already did:
cannot :read, Post, state: :draft
can? :read, Post.new(state: 'draft') # 3.6: true, 3.7: false
Custom strategies
If none of the built-in SQL strategies fits your needs, you can now write your own:
class InnerJoinStrategy < CanCan::ModelAdapters::Strategies::Base
def execute!
relation.joins(joins).distinct
end
end
CanCan.accessible_by_strategy = InnerJoinStrategy
# or on a single call
Article.accessible_by(current_ability, strategy: InnerJoinStrategy)
Speaking of strategies: with_accessible_by_strategy is now thread safe. The strategy chosen in the block no longer
leaks into concurrent requests.
disallowed_attributes
disallowed_attributes is the opposite of permitted_attributes. It returns the attributes forbidden by a cannot
rule:
can :update, Product
cannot :update, Product, [:price]
current_ability.disallowed_attributes(:update, @product) # => [:price]
This is useful when the input contains keys that are not attributes of the model, like a GraphQL input object.
Things to check when upgrading
permitted_attributesno longer returns:id. It was documented this way, but a bug kept it in the array. If you need it, permit it explicitly.- A
cannotrule with a scope and nocanrule no longer returns the records of the scope. The documentation suggested defining the “inverse” scope withcannot: define it withcaninstead. accessible_bycalled on an association, likegroup.posts.accessible_by(ability), keeps the conditions of the association when a rule is a scope. Before, it returned the posts of every group matched by the scope.- Aliasing an action to
:manage(which granted every action) or to itself (which looped forever) now raises an error. - A request like
/books/%20now raisesActiveRecord::RecordNotFound, instead of running the action with anilresource.
CanCanCan 4.0
4.0 is not released yet, but the work is done on the 4.x branch and you can already try it:
gem 'cancancan', github: 'CanCanCommunity/cancancan', branch: '4.x'
Active Record scopes can be combined with other rules
This is the change I like the most. It has been one of the most requested features for years, and it is possible thanks to the Arel rewrite started by @mamhoff in #717:
can :read, Article, published: true
can :read, Article, Article.where(author: user)
# 3.x: raises "Unable to merge an Active Record scope with other conditions"
# 4.0: returns the published articles and the articles of the user
Article.accessible_by(ability)
Careful: a cannot rule with a scope now excludes the records of the scope, exactly like a cannot rule with a
hash of conditions. In 3.x the scope had to select the opposite of what the rule says:
# 3.x
can :read, Product
cannot :read, Product, Product.where(discontinued: false)
# 4.0
can :read, Product
cannot :read, Product, Product.where(discontinued: true)
If you keep a 3.x rule as it is, you get the opposite records. To help you with the upgrade, every cannot rule with a
scope prints a warning once. When you have reviewed your rules, you can disable it with
CanCan.warn_on_cannot_rules_with_scope = false.
Faster can?
can? is between 20% and 100% faster on CRuby, depending on the check. Thanks to
@tr4b4nt for #887.
Also, conditions on an association that is not loaded are now checked with one query, instead of loading all the associated records:
can :read, Author, books: { user: user }
# 3.x: loads every book of the author
# 4.0: checks the books with one query
can? :read, author
Composite primary keys
Models with a composite primary key are now supported by all the subquery strategies:
class Membership < ApplicationRecord
self.primary_key = %i[team_id user_id]
end
Membership.accessible_by(ability)
Controllers
The skip helpers support :if and :unless, like check_authorization already does:
skip_authorize_resource if: :public_page?
skip_load_resource unless: -> { params[:id].present? }
And :only and :except are now combined with AND, like before_action does. They were combined with OR:
# 3.x: skips every action but `show`
# 4.0: skips no action
skip_authorize_resource only: :index, except: :show
A resource named differently than the controller is considered a parent only when it is actually nested. Before, it
was always a parent, so it was authorized with :show instead of the action of the controller:
class BookServicesController < ApplicationController
load_and_authorize_resource :book
end
# PATCH /book_services/1
# 3.x: authorizes :show on the book, so `can :read, Book` is enough
# 4.0: authorizes :update on the book
Please check your controllers for this one: in 3.x an update could pass with only a :read permission.
Other breaking changes
-
The RSpec matcher raises an error when negated with several actions. It passed as soon as one of the actions was forbidden, which was often read as “all the actions are forbidden”:
# 4.0: raises CanCan::ImplementationRemoved expect(ability).not_to be_able_to(%i[read update], article) # write instead expect(ability).not_to be_able_to(:read, article) expect(ability).not_to be_able_to(:update, article) -
can?matches the ranges in an array of values, asaccessible_byalready did:can :read, Product, stock: [1.., nil] can? :read, Product.new(stock: 5) # 3.x: false, 4.0: true -
The conditions of the ability pre-fill a new resource only when they restrict it. With
can :create, Boatfor admins andcan :create, Boat, slow: truefor everyone, the boats built for an admin are no longer slow.
How to upgrade
My suggestion is to upgrade to 3.7.0 first, run your tests, and read the warnings in the changelog. Most of the
accessible_by fixes are already there, so the step to 4.0 will be smaller. Then, when you are on Rails 8, try the
4.x branch and let me know if something breaks.
Thanks to everyone who opened issues and pull requests over the years, and to Renuo for the support. If you use CanCanCan, please consider sponsoring the project.
Enjoy!
P.S. I published a first page for Euruko 2027, which will happen in Modena, Italy. Go and subscribe to receive an email when the tickets are available.