Experience Upgrading to Strong Params
In our workplace app, most models are used by different code paths (e.g. a customer and internal user creating the same resource). Using attr_accessible
, all we could do is to permit a large set of attributes except the ones explicitly assigned by the system (a lousy example, created_at
). Then we go about slicing the params in the controller as a first level defense for mass assignment from user input.
Hence, I've been eagerly waiting to upgrade our app to use strong params on Rails 4. Here's my experience upgrading our largest codebase.
Protected Attributes?
In Rails 4, attr_accessible
is extracted to a gem called protected_attributes
. We wanted the app to initialize successfully whilst changing to strong params, so we added it. (gem 'protected_attributes'
to Gemfile)
We turned off whitelisting so Rails doesn't moan at unprotected attributes (config.active_record.whitelist_attributes = false
to application.rb
)
With this setup, protected attributes plays nice with Rails 4, the next thing was basically to trawl the codebase for methods to refactor. Using tests you don't say? Well, bundling Rails 4 already caused us a red sea of spec failures + some specs came up with mysterious failures, unfortunately, find all comes in really handy.
Three uses of attr_accessible
Finding all for attr_accessible
only turned up three uses. Woo hoo! Thank you guys, erm, hopefully we sliced everywhere. This should be easy. In upgrading, we're trying our best to practice 1-to-1 refactoring. I.e, we do not want to do any refactoring that might change business logic. That comes later. The refactoring started out as
- Find all controllers that builds the said model.
- Put the permitted params in the model into the controller instead using strong params.
That worked well, BUT...
Protip 1: Don't forget attr_protected. It's part of model security too.
attr_protected
works the opposite way. Instead of whitelisting attribute for mass assignment, it says this attribute cannot be mass assigned.
Now that's not easy. Currently, strong_params doesn't seem to have a blacklisting
mode. If you said the logic
Allow everything except for the token attribute.
This might translate to something like
# Slice off the protected one
params.require(:my_model).permit!.except(:token)
# Alternatively with less side effects
params.require(:my_model).permit(my_model.columns.names - [:token, :created_at])
You can see how this might turn ugly in the long run. White listing is a better practice than blacklisting, but there must be quite a few use cases where blacklisting is useful. Let's watch strong_params for this. Currently, we stuck to direct assignment of the token
attribute and avoided both solutions above.
Protip 2: It works in models? HUH?
We know strong_params
is about shifting security to the controller. But how does it work? Well, of course, through the model! =). Strong Params guards against passing in unpermitted
parameters. For example,
# It blocks in create
def create
person = Person.new(person_params) # Works
person = Person.new(params[:person]) # Forbidden Attributes Error
end
# It blocks in new too.
def new
person = Person.new(person_params) # Works
person = Person.new(params[:person]) # Forbidden Attributes Error
end
private
def person_params
params.require(:person).permit(:name, :date_of_birth)
end
Using methods like build
, create
and new
raises ActiveModel::ForbiddenAttributesError
. It is NOT dependent on the controller action. This means that it's still linked to models in unexpected ways. The params hash is a special hash now belonging to Action:Controller::Parameters
. For a simple test,
params = ActionController::Parameters.new(person: { name: "Damon"})
params[:person].permitted? # => false
Person.new(params[:person]) # => Forbidden Attributes Error
# Let's clear it
permitted = params.permit(person: [:name])
Person.new(params[:person]) # Still fails
Person.new(permitted) # Passes. You have to whitelist everytime on params
Side Effects
In our app, we stick pretty close to the thin controller, fat model idiom. This introduces a side effect like this.
def some_controller_action
# Person builder is not an AR / ActiveModel object
person_builder = PersonBuilder.new(params)
render "person", person: person_builder.person
end
def somewhere_in_person_builder
Person.new(params) # Hey hey... Forbidden Attributes Error
end
Protip 3: Empty roots are not good enough
# params = { person: { name: "Damon" }, role: "" }
def person_params
params.require(:person).permit!
end
def role_params
params.require(:role).permit! # Require is going to throw an error.
end
Protip 4: Make your guys remember to use the Root
This is a person gribe, after refactoring over 40 controllers, I would say the Rails convention of having a root for resources to be pretty solid. This allows you to use params.require(:some_root)
and it cannot be clearer what these attributes belong to.
So if you see anyone in your team discard the root resource bacause XYZ framework doesn't do it, find a way to do it =)