ActiveRecord Mass-Assignment Order

ActiveRecord Mass-Assignment Order

Craig Demyanovich
Craig Demyanovich

August 12, 2013

For over a month, I received occasional reports that an existing feature, adding secondary accounts, wasn't working. I could never reproduce the failure in any browser or with a unit test, though.

Recently, I received a report about another feature, creating an account, that failed. I could see it fail if I watched the application log while the feature was used, but, again, I couldn't reproduce the failure.

Calling for Reinforcements

I revisited the problem this week. After many more attempts at reproducing the problem, I was out of ideas. I called for reinforcements. I needed another pair of eyes on this one.

It wasn't long before there was a spark! My pair remembered facing a problem on an old project. It involved mass-assignment. The feature on my project used mass-assignment. It involved overwritten mutators. The feature on my project used overwritten mutators. Could this problem be the same?

The Overwritten Mutators

Here are the overwritten mutators, password= and password_confirmation=.

class Account < ActiveRecord::Base
		validates_confirmation_of :password

		def password=(value)
				if store_locally?
						write_attribute(:password, encrypt(value))
				else
						ldap_account.password = value
				end
		end

		def password_confirmation=(value)
				@password_confirmation = if store_locally?
																															encrypt(value)
																													else
																															ldap_account.password_confirmation = value
																													end
		end
end

They either store their value locally or on an object backed by LDAP. This decision is made in store_locally?, which checks another attribute named role_type.

The Problem

Here's the code that has been intermittently failing.

def create_login_account(dealer)
		Account.create!(
				:role_type => AccountRole.dealer,
				:user_name => dealer.user_name,
				:password => dealer.password,
				:password_confirmation => dealer.password
		)
end

It would cause the password confirmation validation to fail, complaining that the provided password and password confirmation didn't match.

The Solution

password= and password_confirmation=, through store_locally?, depend on role_type. Though create_login_account builds the hash of attributes in an order which puts role_type first, there is no guarantee 1 on the order in which the attributes will be used to assign the attributes on Account. Thus, password or password_confirmation could be assigned before role_type.

To ensure that role_type is assigned before password and password_confirmation, we abandon mass-assignment, instead assigning the attributes one at a time.


def create_login_account(dealer)
		account = dealer.build_account
		account.role_type = AccountRole.dealer
		account.user_name = dealer.user_name
		account.password = dealer.password
		account.password_confirmation = dealer.password
		account.save!
		account
end

This approach, though not necessarily idiomatic, solves the problem. I added a brief comment in the project so that anyone, including me, who works on this code later will know why it deviates from the norm.

Notes

  1. There's no guarantee in Ruby 1.8, anyway, which this application uses. In Ruby 1.8, “The order in which you traverse a hash by either key or value may seem arbitrary, and will generally not be in the insertion order.” In Ruby 1.9 and 2.0, “Hashes enumerate their values in the order that the corresponding keys were inserted.”