Second Refactoring of Security Roles

Tammer Saleh

A little refresher on what not to do

The wrapper class approach is just fine for a single resource that needs security applied to it. For a refresher, here’s the model…

class Article < ActiveRecord::Base
  # Columns include submitted and accepted
end

And here’s the wrapper that you would use in its place when in an Admin controller…

class AdminArticle
  def self.method_missing(method, *args)
    scope = { :find => { :conditions => ["submitted = ?", true] }}

    Article.with_scope(scope) do
      # we've limited the scope, so just pass the method call on...
      Article.send(method, *args)
    end
  end

  def self.new(*args)
    # need to explicitly pass this on
    self.method_missing(:new, *args)
  end
end

And here’s the wrapper that you would use in its place when in a Member controller…

class MemberArticle
  def self.method_missing(method, *args)
    # We get current_user via a class-level accessor I've left out for sanity
    scope = {
      :find => {
        :conditions => ["approved = ? or user_id = ?", true, current_user]
      }
    }
    Article.with_scope(scope) do
      # we've limited the scope, so just pass the method call on...
      Article.send(method, *args)
    end
  end

  def self.new(*args)
    # need to explicitly pass this on
    self.method_missing(:new, *args)
  end
end

You can already see how this isn’t gonna scale as you add more AR classes that need security? Not to mention the fact that it violates the newly private nature of ActiveRecord.with\_scope. Finally, it’s not nearly as purty as I’d like. We all like purty, right? Lemme see some hands!

That’s much better

First, the library…

module Filterable
  def self.included(clazz)
    clazz.class_eval do
      extend ClassMethods
    end
  end

  module ClassMethods
    def scope_to_role(role, user = nil)
      case role
      when :public
        scope = { :find => { :conditions => ["approved = true"] } }
      when :admin
        scope = { :find => { :conditions => ["submitted = true"] } }
      when :member
        raise RuntimeError,
          "#{self.name}.scope_to_role(:member, nil) called,
           which doesn't make sense" unless user
        scope = {
          :find => { :conditions => ["approved = true OR user_id = ?", user.id]}
        }
      else
        raise RuntimeError,
          "#{self.name}.scope_to_user called with unrecognized role #{role}"
      end

      with_scope(scope) do
        yield
      end
    end
  end
end

Then the models…

class Article < ActiveRecord::Base
  include Filterable
  # Columns include submitted and accepted
end

And using it in the controllers…

class ApplicationController < ActionController::Base
  around_filter :filter_results_for_public

  def filter_results_for_public
    People.scope_to_role(:public) do
      Article.scope_to_role(:public) do
        Photo.scope_to_role(:public) do
          yield
        end
      end
    end
  end

  def filter_results_for_admin
    People.scope_to_role(:admin) do
      Article.scope_to_role(:admin) do
        Photo.scope_to_role(:admin) do
          yield
        end
      end
    end
  end

  def filter_results_for_member
    People.scope_to_role(:member, current_user) do
      Article.scope_to_role(:member, current_user) do
        Photo.scope_to_role(:member, current_user) do
          yield
        end
      end
    end
  end
end

class Admin::BaseController < ApplicationController
  skip_filter :filter_results_for_public
  around_filter :filter_results_for_admin
end

class Member::BaseController < ApplicationController
  skip_filter :filter_results_for_public
  around_filter :filter_results_for_member
end

There’s a downside

The biggest downside is that you’re yielding about six times for each and every request. You could trim that down by excluding certain actions, but it’s still not the most efficient design.

Also, we could probably get all clever and combine those filter_results_for* methods into one which introspects on the url or current controller. Do we want to do that? I didn’t think so.