Promiscuous models and capable controllers

Published July 30, 2009 in Rails and Ruby

By default ActiveRecord allows visitors access to any writer method, that is, any method ending with an equal sign. This comes courtesy of the ActiveRecord::Base#attributes= method, which is used internally by the main methods that handle creating and updating records, including new(), create(), and update_attributes().

This topic recently came up on the SF and East Bay Ruby meetup lists (original message here, associated blog post here). I started writing a response in my email client and decided it was big enough that it would benefit from a blog post.

Abstract: Even though attr_protected and attr_accessible exist, models
shouldn’t be filtering — controllers should be. Filtered params should be defined on the model for locality of reference, not on the controller. Hash#slice and Hash#except aren’t enough because they don’t handle nested hashes, so I explore a recursive filter (blacklist & whitelist).


Model-level access controls are bad

There’s a lot of talk about attr_accessible and attr_protected, but I think this approach is fundamentally wrong. Models should not be doing security filtering; they don’t (and shouldn’t) have access to enough information (the current user’s access priveleges / roles) to do this intelligently. When the controller says “model, here are your parameters,” the model should not be filtering. I’ve worked with code that tried to do user based security in the model and it’s a super-bad plan because it totally breaks MVC. One of the important functions of the controller in MVC is to handle things like user authentication, and param restriction/filtering is part of that responsibility.

Additionally, the attr_accessible / attr_protected approach yields situations like

class WidgetController < ActionController::Base
  def update
    @widget = Widget.find params[:id]
    @widget.attributes = params[:widget]
    if logged_in? && current_user.admin?
      %w(sensitive_var_1 sensitive_var_2).each do |var|
        if params[:widget].key? var
          @widget.send :"#{var}=", params[:widget][var]
        end
      end
    end
    @widget.save
    redirect_to @widget
  end
end

This pollutes the model with information about what is filtered and the send is redundant to attributes=.

Solution 1

Use Hash#slice and Hash#except to filter params before they get to the model.

class WidgetController < ActionController::Base
  def update
    @widget = Widget.find params[:id]
    unless logged_in? && current_user.admin?
      params[:widget].except! :sensitive_1, :sensitive_2
    end
    @widget.update_attributes params[:widget]
    redirect_to @widget
  end
end

“But,” you say impetuously, “my controller shouldn’t have to know about my model’s sensitive bits! That’s ugly.” Well, you’re right.

Solution 2

Keep it in the model

So here’s what we’ll do:

class << ActiveRecord::Base
  attr_accessor :sensitive_params
end

class Widget < ActiveRecord::Base
  self.sensitive_params = [:sensitive_1, :sensitive_2]
end

class WidgetController < ActionController::Base
  def update
    @widget = Widget.find params[:id]
    unless logged_in? && current_user.admin?
      params[:widget].except! Widget.sensitive_params
    end
    @widget.update_attributes params[:widget]
    redirect_to @widget
  end
end

So here’s a halfway ground between the attr_accessible/_protected approach and controller filtering. The model is the only place that contains the sensitive params, keeping the controller fairly abstract, but the controller is still the one doing the access control. Obviously, this could be dressed up with a good bit of syntactic sugar and extended to support blacklisting as well as whitelisting.

But what if we need to filter two models at the same time, as with accepts_nested_attributes_for?

Solution 3

Recursive black-/white-listing

A while ago, I hacked out this code to deal with recursive filtering:

class Hash
  def whitelist(*list) filter list, :type => :whitelist end
  alias_method :&, :whitelist
  def whitelist!(*list) replace whitelist(*list) end

  def blacklist(*list) filter list, :type => :blacklist end
  alias_method :^, :blacklist
  def blacklist!(*list) replace blacklist(*list) end

  def filter(list, options = {})
    list.flatten! if list.length == 1 && list.first.is_a?(Array)
    options[:type] ||= :whitelist
    unless [:whitelist, :blacklist].include?(options[:type])
      raise "filter type #{options[:type]} unknown"
    end

    inject Hash.new do |h, (k, v)|
      relevant_hash = v.respond_to?(:filter) && list.detect{|i| i.respond_to?(:[]) && i[k]}
      listed = list.include? k
      if (options[:type] == :whitelist) ? (relevant_hash || listed) : !listed
        h[k] = relevant_hash ? v.filter([relevant_hash[k]], options) : v
      end
      h
    end
  end
end

This would let you whitelist or blacklist nested hashes, so you could

class << ActiveRecord::Base
  attr_accessor :blacklist_params
end

class Dongle < ActiveRecord::Base
  has_many :widgets
  self.blacklist_params = [:wibble]
end

class Widget < ActiveRecord::Base
  belongs_to :dongle
  accepts_nested_attributes_for :dongle
  self.blacklist_params = [:admin, {:dongle_attributes => Dongle.blacklist_params}]
end

class WidgetController < ActionController::Base
  def update
    @widget = Widget.find params[:id]
    unless logged_in? && current_user.admin?
      params[:widget].blacklist! Widget.blacklist_params
    end
    @widget.update_attributes params[:widget]
    redirect_to @widget
  end
end

Now, this would also catch params[:widget][:dongle_attributes][:wibble] and still have all the filtering in the controller.

Wrapping up

Backing out of the rabbit hole

Disclaimers: The above are intended to be sketches / expressive hacks intended to communicate a pattern, not code to be taken seriously. I haven’t played with this in any production code, since I haven’t refactored away from attr_accessible/attr_protected yet. I’ll update this when that happens.

The hacks above could pretty easily be wrapped up (and tested) into a simple little plugin, but first I want to play with the ideas a bit and see if they stick. The general approach seems similar to param_protected but I think it’s pretty important that the model contain the definition of the filtered params for locality of reference.

For now, I’m curious what people think about the general strategy for parameter filtering. Has this been covered a thousand times? Obvious flaws? Add comments below.