Friday, March 16, 2007

Keep users from looking at other people's data with a simple ownership filter

Happy St. Patrick's day everyone!

Well. Everyone's pretty familiar with authentication. That's where you force the users to login, before you'll show any of the pages. Usually, this is achieved with a before_filter, so that you're not checking if a user has logged in in the beginning of each action in the controller. This keeps your code mighty DRY. But what about ownership of data? Within any web app, you have data that's owned by some users, and other data that's owned by other users. Just because they're logged in doesn't mean that they should be able to see other users' data.

Oh, these tables of mine

A good example are friends in a social network app. Each user has a set of Friend records in the database that belongs to them. (btw, don't use the User table below in your own app. you never want to store cleartext passwords in the database. Check out authentication tutorials)
create_table :users, :type => "InnoDB" do |t|
t.column :username, :string
t.column :password, :string
end

create_table :friends, :type => "InnoDB" do |t|
t.column :user_id, :string
t.column :name, :string
t.column :email_address, :string
end

create_table :posts, :type => "InnoDB" do |t|
t.column :user_id, :string
t.column :timestamp, :datetime
t.column :body, :text
end

Your first instinct sucks

You only want those records of friends that belong to them to be available to them. So what's the first thing that you're inclined to do? Assume that you have the User record as an indication of being logged in the session data.
class FriendController < friends =" Friend.find(:all," conditions =""> ["user_id = ?", session[:user].id])
end

def show
@friend = Friend.find(params[:id])
unless @friend.user_id == session[:user].id
flash[:error] = "That friend does not exist"
redirect_to :action => :list
end
end
end

In list, you want to make sure that the list of friends returned belongs to the user, and that's why you find all by the user_id. In show, a user can easily change the URL's id number to reflect another record. You want to check whether that friend actually belongs to them.

While this is all good and well, the problem is, you'd have to do this for every method that you write. We are a lazy kind, so there HAS to be a better way.

Scope it, my brotha from anotha motha

Of course, the Rails geniuses have come up with with_scope() for all ActionControllers.
class FriendController < find =""> { :conditions => ["user_id = ?", session[:user].id] }) do
@friends = Friend.find(:all)
end
end

def show
Friend.with_scope(:find => { :conditions => ["user_id = ?", session[:user].id] }) do
@friend = Friend.find(params[:id])
end
if @friend.nil?
flash[:error] = "That friend does not exist"
redirect_to :action => :list
end
end
end

"But hold on, there's still duplication!" This is only part of the solution. with_scope() is useful if you have multiple database finds within the same block. That way you don't need to keep putting it in the conditions. So how do you get rid of the duplications? With filters, of course.

Add a sprinkle of filter magic

I personally liken filters to programming 'common sense' into the classes. It's what's intuitively understood to have to be done before and after every action. Luckily, there's a filter called around_filter that we can use.
class FriendController < find =""> { :conditions => ["user_id = ?", session[:user].id] }) do
yield
end
end

def list
@friends = Friend.find(:all)
end

def show
@friend = Friend.find(params[:id])
if @friend.nil?
flash[:error] = "That friend does not exist"
redirect_to :action => :list
end
end
end

So it should be pretty obvious what happened. The around_filter allows you to do one responsibility, both before and after every action in the controller. When the method yields, it gives control to one of the actions below. So you're essentially wrapping every action in the with_scope() defined in the filter.

Yay, that's pretty cool. The code's been DRY'd. We're done, right? But you know as well as I, that because there's more text after this sentence, we can actually take it a step further.

I pull out the method inside, served it and fried

Of course, you have more than one ActiveRecord model that's owned by the user, and in this case, there's another one called Post. So instead of repeating ownership_filter method in every ActiveRecord Object, let's pull it out of FriendController into a class of its own, so that other controllers can use it, using some easy meta programming. Note that you now have a require up top and around filter changed.
require 'ownership_filter'

class FriendController < friends =" Friend.find(:all)" friend =" Friend.find(params[:id])" action =""> :list
end
end
end

And this is where we extracted the method to...a file named "ownership_filter.rb" You can put this in your app/controller directory.
class OwnershipFilter
def filter(controller)
model_class_name = controller.controller_name.capitalize.to_sym # => :Friend
model_class = Object.const_get(model_class_name) # => Friend class
model_class.with_scope(:find => { :conditions => ["account_id = ?", controller.session[:user].id] }) do
yield
end
end
end

It actually took me a while to find out how to do this. I knew you could call methods dynamically with send(), but how do you dynamically get a class? Good thing for posts on ruby-talk. So basically, you take the controller's name and you turn it into a symbol :Friend that is used to find the class, using const_get(), that the constant :Friend refers to, namely, the Friend class.

After that, it's the same as before, you call with_scope() with it. So now, you can use this with any of the controllers that you have in the same ways as you did with the friend controller. I haven't tried it yet with the finds pushed down to the ActiveRecords, but I think it should work the same way. Tip!

Update: I saw that the method I described above is actually an anti-pattern according to Jamis Buck. I've posted subsequently on this topic.

3 comments:

  1. great post. very interesting and informative.

    ReplyDelete
  2. Thank you, thank you, thank you.

    You saved my bacon four days before my final project is due.

    ReplyDelete
  3. Cool. Nice to know that I contributed to the worldwide bacon-saving count today. Good luck on your proj~

    ReplyDelete