views:

462

answers:

3

I'm working on a Rails (currently 2.3.4) app that makes use of subdomains to isolate independent account sites. To be clear, what I mean is foo.mysite.com should show the foo account' content and bar.mysite.com should show bar's content.

What's the best way to ensure that all model queries are scoped to the current subdomain?

For example, one of my controllers looks something like:

@page = @global_organization.pages.find_by_id(params[:id])

(Note @global_organization is set in the application_controller via subdomain-fu.) When what I would prefer is something like:

@page = Page.find_by_id(params[:id])

where the Page model finds are automatically scoped to the right organization. I've tried using the default_scope directive like this: (in the Page model)

class Page < ActiveRecord::Base
  default_scope :conditions => "organization_id = #{Thread.current[:organization]}"
  # yadda yadda
end

(Again, just to note, the same application_controller sets Thread.current[:organization] to the organization's id for global access.) The problem with this approach is that the default scope gets set on the first request and never changes on subsequent requests to different subdomains.

Three apparent solutions thus far:

1 Use separate vhosts for each subdomain and just run different instances of the app per subdomain (using mod_rails). This approach isn't scalable for this app.

2 Use the original controller approach above. Unfortunately there are quite a number of models in the app and many of the models are a few joins removed from the organization, so this notation quickly becomes cumbersome. What's worse is that this actively requires developers to remember and apply the limitation or risk a significant security problem.

3 Use a before_filter to reset the default scope of the models on each request. Not sure about the performance hit here or how best to select which models to update per-reqeust.

Thoughts? Any other solutions I'm missing? This would seem to be a common enough problem that there's gotta be a best practice. All input appreciated, thanks!

+1  A: 

Have you tried defining the default_scope a lambda? The lambda bit that defines the options get evaluated every time the scope is used.

class Page < ActiveRecord::Base
  default_scope lamdba do 
   {:conditions => "organization_id = #{Thread.current[:organization]}"}
  end
  # yadda yadda
end

It's essentially doing your third option, by working in tandem with your before filter magic. But it's a little more aggressive than that, kicking in on every single find used on the Page model.

If you want this behaviour for all models you could add the default_scope to ActiveRecord::Base, but you mention a few being a couple of joins away. So if you go this route, you'll have to override the default scopes in those models to address the joins.

EmFi
Thanks for the input! Have you tested that code in practice? I just tried it out in the app and while I can confirm the lambda is indeed being evaluated on each find, the default_scope does not apply the returned values. Taking a look at the output SQL, the :conditions appear to be simply ignored. For a variety of other reasons I haven't yet been able to fire up my debugger, but I'll take a peak as soon as it's back up and running.Thoughts on why it might not be working?
qfinder
Honestly I have no idea how the internal server uses threads, so I assumed `Thread.current[:organization]` worked in the model. But I don't know if that would explain why the conditions are ignored. As you're essentially defining the default scope to look for organazation_id = nil.
EmFi
Upon further investigation, it seems this doesn't work yet. There's a patch, but YMMV: https://rails.lighthouseapp.com/projects/8994/tickets/1812-default_scope-cant-take-procs
EmFi
+1  A: 

Be careful going with default scope here, as it will lead you into a false sense of security, particularly when creating records.

I've always used your first example to keep this clear:

@page = @go.pages.find(params[:id])

The biggest reason is because you also want to ensure this association is applied to new records, so your new/create actions will look like the following, ensuring that they are properly scoped to the parent association:

# New
@page = @go.pages.new

# Create
@page = @go.pages.create(params[:page])
bensie
Good point, turns out in this case that because of the join structure there are only a few places where that needs to be enforced on creates, but many places where it needs to be enforced on reads so I still would like to find a way to use the default_scope.
qfinder
Also, have a look at http://github.com/devinterface/authlogic_subdomain_fu_startup_app, which is a sample app for getting started with a subdomain-scoped multi-tenant application. It handles account creation with and initial user (who is also the owner of the account).
bensie
It may seem like extra work / code clutter in the controller, but it's really not. In the end it shows the programmer's intent, which will make it much easier to modify down the road.As for needing the developers to remember and apply the limitation, you should have tests for every action anyway, and one of those tests should ensure that the model is correctly scoped to its parent.
bensie
A: 

You might be better of having a database per account and switching the database connection based on the subdomain.

In addition to the above link if you have a model (in your case Account) that you want to use the default database just include establish connection in the model.

class Account < ActiveRecord::Base

  # Always use shared database
  establish_connection  RAILS_ENV.to_sym
Kris