Dangerous attribute auto assignment by cancan that Rails developers should be awared of!
Cancan can save you a lot of time but sometime it makes you careless enough to produce hard-to-find bugs.
In web development, an application usually has multiple user roles such as admin, customer… Each role has a particular permission on some actions and objects. Permission management has become a well-known problem.
As a Rails developer, I use cancancan - community version of cancan to manage user permissions and I know a lot of Rails developers out there are using it too. It’s a handy gem, easy to use and provides permission checking on Model objects out of the box.
In controller, cancan
allows you to load corresponding resource and check permission of current_user
(if you are using devise
). If current_user
doesn’t have permission on particular action (such as update
), it immediately stop the code and does something like a redirect or whatever you want it to do. With cancan
, your code is more or less like this:
You will define users’ permission in ability model:
class Ability
include CanCan::Ability
def initialize(user)
# initialize user object if user is nil
user ||= User.new
# admin can do whatever he wants on every object
if user.admin?
can :manage, :all
else
# other users can manage only their posts
can :manage, Post, user_id: user.id
end
end
end
In your controller, you are likely to load the resource and authorize it:
class PostsController < ApplicationController
# get the post with params[:id] and assign it to @post
# after that, check if @post.user_id is equal to current_user.id or not
# if it is, the code runs normally, otherwise redirect to root or something
# else you want.
load_and_authorize_resource :post
def create
# @post is created by cancan before
if @post.save
redirect_to @post, notice: 'Your post has been created.'
else
render :new
end
end
private
def post_params
params.require(:post).permit(:text)
end
end
The cancan mysterious assignment
As you can see in the code above, I didn’t assign the user to that post. Really? This is so wrong. Let’s run the test.
describe AdUnitsController do
describe "POST create" do
it "creates a post with current_user associated" do
user = FactoryGirl.create(:user, role: "customer")
# log the user in
sign_in user
valid_attributes = {
text: "This is a test post body"
}
post :create, post: valid_attributes
# get the post created inside controller
created_post = assigns(:post)
expect(created_post.persisted?).to be true
# the post's user is expected to be the logged in user
expect(created_post.user_id).to eq user.id
end
end
end
I ran this test and it passed. HAHA. So I didn’t have to assign user to @post myself, it was done by cancan
transparently. It’s amazing. Is it? Might be. I feel a little bit confused and doubtful here.
The problem
After I ran the test, I thought that my code works properly but I wanted to check it one last time on the browser. This time, I already signed under an admin user, I tried to create a post and it was unable to save the post.
What? Why? Being confused, I started running the test repeatedly with stupid print-outs and wondered Why did the test pass when it actually doesn’t work correctly?
It turns out that user_id
was never be assigned on @post
, it was nil so it didn’t pass the validation. But wait, user_id
is assigned in the test, why isn’t it here?
How the hell does cancan know that I want to assign user to current_user? What causes this inconsistency?
After a while reading cancan
source code, I found that it automatically assigns some attributes to reasonable values without notice to developers. In my case, it assigns user_id
to current_user.id
. Why? Because I defined a rule to check current_user
permission on this @post
, the rule says :user_id => user.id
so cancan thinks that I want to assign user_id
to current_user.id
on @post
and it’s right. I do want that.
But that rule is applied only when the current_user
is not admin. On the other casewhere current_user
is an admin, cancan
does no pre-assignment so user_id
is not set on the fly. This is hard for developers to notice the difference because it’s done silently by cancan
.
Test agaist that problem
To make a failing test for this, you have to test the action on both admin and customer role like this:
describe AdUnitsController do
describe "POST create" do
context "customer" do
it "creates a post with current_user associated" do
user = FactoryGirl.create(:user, role: "customer")
test_post_create(user)
end
end
context "admin" do
it "creates a post with current_user associated" do
user = FactoryGirl.create(:user, role: "admin")
test_post_create(user)
end
end
def test_post_create(user)
# log the user in
sign_in user
valid_attributes = {
text: "This is a test post body"
}
post :create, post: valid_attributes
# get the post created inside controller
created_post = assigns(:post)
expect(created_post.persisted?).to be true
# the post's user is expected to be the logged in user
expect(created_post.user_id).to eq user.id
end
end
end
Conclusion
cancan
might pre-assign attributes it needs for permission checking to the comparing values. If you don’t assign them, they might be assigned bycancan
and might be not. Your test might pass and might not pass.- Always manually assign object attributes to expected values instead of depending on
cancan
. - It’s good to have tests on the same action with different user roles.