Simple Rails Multi-Tenancy

With some recent commits to rails comes the ability to have a default_scope with a lambda that gets evaluated at query time. Combine that with a multi-tenant column scoped database structure (wow, quite the mouthful) and you've got an quick and painless way to partition your tenant data without scattering code everywhere.

Getting Started

Let’s start off with a fresh rails application.

rails new depot && cd depot

Now with our rails application ready, let's create a tenant model to control what tenants are in our application.

rails g model tenant name:string host:string

First we need to add two class methods to the Tenant model to support storing the current tenant for each request. Here we use the current thread's local storage.

app/models/tenant.rb
class Tenant < ActiveRecord::Base
  class << self
    def current
      Thread.current[:current_tenant]
    end

    def current=(tenant)
      Thread.current[:current_tenant] = tenant
    end
  end
end

And while we're at it, we should add an index to the host column otherwise performance will suffer significantly.

db/migrate/XXX_create_tenants.rb
class CreateTenants < ActiveRecord::Migration
  def self.up
    create_table :tenants
    add_index :tenants, :host
  end
end

Next we create our database and migrate

rake db:create db:migrate

And then we fire up a rails console and add a new tenant. What value you set the host attribute to will vary depending on your setup but you are most likely accessing the rails application using localhost.

$ rails c
ruby > Tenant.create(:name => 'Me!', :host => 'localhost')
 => #<Tenant id: 1, name: "Me!", host: "localhost">
ruby >

Scoping our Data

To get our models to scope to only the current tenant, we need an abstract model for all the other models to inherit from (you could monkey-patch ActiveRecord::Base but I would advise against it because you wouldn’t be able to exclude a model from tenant paritioning easily). Because the commits that enable a lambda default_scope haven't made their way into 3-0-stable we have to add them ourselves.

Now to generate our model (--parent is used to stop rails from generating a migration, which aren't useful for abstract models).

rails g model tenant_scoped_model --parent=active_record/base
app/models/tenant_scoped_model.rb
class TenantScopedModel < ActiveRecord::Base
  self.abstract_class = true

  class << self
    protected
      def current_scoped_methods
        last = scoped_methods.last
        last.respond_to?(:call) ? relation.scoping { last.call } : last
      end      
  end

  belongs_to :tenant

  default_scope lambda { where('tenant_id = ?', Tenant.current) }

  before_save { self.tenant = Tenant.current }
end

The last thing we need is a before_filter in ApplicationController that loads the tenant we want to use for this request.

app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_filter do
    @tenant = Tenant.current = Tenant.find_by_host!(request.host)
  end
end

And now we're ready to go.

Sample Model: Products

rails g scaffold product name:string description:text quantity:integer

To scope our Product model based on the current tenant all we have to do is change what class it inherits and add the tenant_id column to the migration (and an index to keep performance nice and high).

app/models/product.rb
class Products < TenantScopedModel
end
db/migrate/XXX_create_products.rb
class CreateProducts < ActiveRecord::Migration
  def self.up
    create_table :products do |t|
      t.references :tenant
    end

    add_index :products, :tenant_id
  end
end

And there you go!

See it in action

For demo purposes I have set up 4 domains: 3 that exist in the tenant table and 1 that does not. All code (including a few improvements, most notably is a custom page for missing tenant) is available in a github repository.

Caveats

  • Calling unscoped bypasses the default_scope; this is both good and bad because you can get all partitioned data from a model regardless of the current tenant.
  • You will get errors in the console when working with a scoped model unless you set Tenant.current.
  • before_save { self.tenant = Tenant.current } is necessary because rails does not automatically set the attributes from our default_scope because it is a lambda.

This is the 14th entry for Ruby Advent Calendar jp-en: 2010. The previous entry was written by sakuro and the next will be written by matschaffer.

Join the conversation

14 Comments

  1. I have tried this approach and seems to working fine. But I have a problem when accessing the model from another controller.

    I have changed the name to AccountScoped instead of TenantScopedModel model.

    Let’s say I want to get the Products so I do on my controller… @products = Products.all
    But this only works fine from the ProductsController … which is really strange.

    It gives me this error from any other controller:

    SQLite3::SQLException: no such table: account_scopeds: SELECT “account_scopeds”.* FROM “account_scopeds” WHERE (account_id = 1)

    Hope you can help me.

    Thanks,
    Leo!

  2. So far I found out that is something with the default_scope, I have moved it to the Products class and it works fine. I will keep reviewing it.

  3. I can’t get this to work in Rails 3.1.0.rc4. The problem seems to be that when the lambda for the default_scope gets evaluated it triggers the computation of the table name in the context of TenantScopedModel instead of in the context of the descendent classes, which results in the wrong table name being memoized for the descendent classes.

    I switched to using the ActiveRecord::Base#default_scope method (i.e. the instance method instead the class method) and everything worked for me.

    The patch to current_scoped_methods is also not needed in 3.1.0.rc4

    1. Correction. Define TenantScopedModel::default_scope (class method):

      def self.default_scope
      where(‘tenant_id = ?’, Tenant.current)
      end

  4. I’m getting the same issue as Leo, when the model (that is tenanted) is access from an other controller, I get a very strange error where Rails is somehow assuming that ‘TenantScopedModel’ is a table in the database …

    SELECT organiser_scoped_models.* FROM organiser_scoped_models WHERE (organiser_id = 15) AND (organiser_scoped_models.event_id = 9)

  5. Hi ,

    do we have to maintain different directories for each tenant in this application ???
    How to manage it with different host name other than localhost in if the application is in Local are network.

    Thanks and Regards,
    Piyush S

  6. What’s the minimum version of rails you need to get this to work?

    It worked beautifully in 3.1, but I tried it in a 3.0.7 app, and I get this: undefined method `includes_values’ for # (NoMethodError) when I try to use a scoped model, for example Model.all.

Leave a comment

Your email address will not be published. Required fields are marked *