#388 Multitenancy with Scopes pro
- Download:
- source codeProject Files in Zip (71.8 KB)
- mp4Full Size H.264 Video (25.9 MB)
- m4vSmaller H.264 Video (12.8 MB)
- webmFull Size VP8 Video (15.9 MB)
- ogvFull Size Theora Video (28.3 MB)
Below is a screenshot from a Rails application which managed a forum about cheese. Users can create cheese-related topics and each topic can have many posts.
Let’s say that another client comes along wanting a forum application that works in the same way but on the topic of chunky bacon. One option is to duplicate the whole Rails application but this would give us another app to deploy and maintain. Instead we’ll turn our application into a multi-tenant app so that it can support multiple clients. This is a good solution if we want to keep the same behaviour but there isn’t much data shared between the clients.
Adding Multi-Tenant Support
There are many different ways to distinguish one client from another but for this application we’ll use subdomains. We’re using the Pow server to serve our app to make it easier to manage subdomains on our development machine. If we visit cheese.forum.dev we want our application to display the topics for the Cheese Forum but if we visit chunkybacon.forum.dev
we should see a different forum with a different set of data. Our app currently looks the same for every subdomain so let’s change that.
First we’ll need a way to keep track of the different tenants. We’ll generate a model called Tenant
(you can call this Account
or something that suits your application better if you want) and give it name
and subdomain
attributes. We’ll also migrate the database to create the table.
$ rails g model tenant name subdomain $ rake db:migrate
Next we’ll open the Rails console and create a couple of tenants.
>> Tenant.create! name: "Cheese", subdomain: "cheese" >> Tenant.create! name: "Chunky Bacon", subdomain: "chunkybacon"
Now that we have two Tenant
records, we need a way to switch between them depending on the subdomain. We’ll do this inside the ApplicationController
where we’ll create a new current_tenant
method in which we’ll find the tenant by their subdomain. (If you want to know more about how subdomains work in Rails they were covered in more detail in episode 123.)
def current_tenant Tenant.find_by_subdomain! request.subdomain end helper_method :current_tenant
This approach is quite flexible as we can fetch the current tenant in any way we want. If, for example, we didn’t want to use subdomains we could set up our application so that a User
belonged to a Tenant
and then use the current user record to get their tenant. Note that we’ve made this method a helper method so that we can use it in the views too.
Next we need to go through our application and find anything that’s specifically about cheese. One case is the header at the top of the page. We can make this dynamic and use the name of the current tenant.
<h1><%= current_tenant.name %> Forum</h1>
If we want, say, different colours for each tenant we can move this attribute into the Tenant
model and reference this throughout the application. When we visit this page now through the chunkybacon
subdomain we’ll see the correct header.
If we visit a subdomain that doesn’t exist we get a RecordNotFound
exception which in production would return a 404
error which is generally what we want. We’ll get the same error if we visit the site with no subdomain. We might want to have a central hub page here instead and episode 123 has more detail on this.
Scoping Data
Next we’ll focus on scoping the data. If we look at the Chunky Bacon forum above we’ll see that it shows the data from the Cheese forum but we want a different set of data in the database specifically for this tenant. There are a number of ways to do this but we’ll use ActiveRecord scopes. We’ll start in the TopicsController
’s index
action which is the page we’ve been viewing. Here we currently just fetch all the topics. Instead we should scope them by the current tenant.
def index @topics = current_tenant.topics end
This assumes that we have a topics
association on the Tenant
model. We don’t have one so let’s set one up.
class Tenant < ActiveRecord::Base attr_accessible :name, :subdomain has_many :topics end
We’ll need a foreign key in the topics
table so we’ll generate a migration to create one then migrate the database again.
$ rails g migration add_tenant_to_topics tenant_id:integer:index $ rake db:migrate
When we reload the Chunky Bacon forum now we don’t see any topics which is what we expect. There are still a lot of holes in our application however. If we visit the page for a specific topic in the Cheese forum while using the chunkybacon
subdomain we’ll be able to view that topic. The same applies to the posts: we can visit a specific post no matter what subdomain we’re using. To fix this for the topics we can go back to the controller and go through the topics
association for the current_tenant
for every single action whenever we show, create or update a topic.
Fixing the posts isn’t quite as easy. We currently fetch them by their id
but we need to make sure that the post belongs to a topic that belongs to the current tenant. As we get more deeply-nested associations this can become pretty complex, especially as our application grows. It’s also easy to forget to go through the association which can leave a big security hole. Alternatively we can use an authorization library such as CanCan to handle the scoping but this isn’t designed for a multi-tenant apps and it won’t solve this problem very well. This is one case where it’s acceptable to use a default scope so that’s what we’ll do.
class Topic < ActiveRecord::Base attr_accessible :name, :content belongs_to :user has_many :posts default_scope { where(tenant_id: Tenant.current_id) } end
Our default scope has a condition where tenant_id
matches the current tenant. We don’t have a current_id
method in the Tenant
model so we’ll add one. We can remove the has_many
association with topics as we don’t need it any more and use a cattr_accessor
, which is provided by ActiveSupport as a way to make a class-level accessor, to get the current_id
.
class Tenant < ActiveRecord::Base attr_accessible :name, :subdomain cattr_accessor :current_id end
The final step here is to modify the ApplicationController
by adding an around filter for scoping the current tenant. We don’t use these very often but they’re basically a way to set up before and after filters at the same time. In it we’ll set the current tenant id then yield to the controller, which will trigger the action and render the view. Finally we use an ensure case to set the current tenant id
back to nil
so that there’s no chance of the id
floating into other requests.
around_filter :scope_current_tenant private def scope_current_tenant Tenant.current_id = current_tenant.id yield ensure Tenant.current_id = nil end
Now we should no longer be able to be able to access topics that don’t belong to the current tenant. When we try this we get a RecordNotFound
exception and if we visit the index
view the topics are filtered for the current tenant.
A useful feature of a default scope is that it applies to newly-created records. If we create a new topic in the Chunky Bacon forum the tenant id
will be added automatically and the topic will be visible in the correct forum. This can easily be applied to any other model. We’ll add a Post
model and one to our User
model as well.
$ rails g migration add_tenant_to_posts tenant_id:integer:index $ rails g migration add_tenant_to_users tenant_id:integer:index $ rake db:migrate
We can now add a default scope to the Post
model.
class Post < ActiveRecord::Base attr_accessible :name, :content belongs_to :topic default_scope { where (tenant_id: Tenant.current_id) } end
And do something similar to User
.
class User < ActiveRecord::Base attr_accessible :email, :password, :password_confirmation has_secure_password validates_uniqueness_of :email has_many :topics default_scope { where (tenant_id: Tenant.current_id) } end
Now if we try to access a specific post we can’t as it doesn’t belong to the current tenant.
When we use a default scope it’s important to remember that it’s applied globally even if we access our application through a Rake task, in our tests or in the console. If we call Topic.count
in the console we’ll see that the SQL query that is run finds all the topics where the tenant_id
is NULL
as this is what the current id
is set to.
>> Topic.count (0.2ms) SELECT COUNT(*) FROM "topics" WHERE "topics"."tenant_id" IS NULL => 4
This command returns 4
as this is the number of topics that existed earlier and which have never had a tenant assigned to them. We’ll do this now and assign all the topics to the “Cheese” tenant.
>> Topic.update_all(tenant_id: 1) SQL (22.0ms) UPDATE "topics" SET "tenant_id" = 1 WHERE "topics"."tenant_id" IS NULL => 4
This update query also only applies to the topics without a tenant which shows that the default scope is fairly global. If we run Topic.count
again now we’ll get zero results as all of the topics now have a tenant_id
set.
If we want to perform a query without a default scope we can do this by calling unscoped
. Calling count
on this will return five results and the SQL statement won’t be run with the extra WHERE Clause.
>> Topic.unscoped.count (0.2ms) SELECT COUNT(*) FROM "topics" => 5
We can change this behaviour easily it we want to. Whenever we add a default scope we can check to see if the current id
exists before executing the where clause.
default_scope { where (tenant_id: Tenant.current_id) if Tenant.current_id }
Now if we’re outside a Rails request and the current id
isn’t set all the records will be fetched and they won’t be scoped at all. That said, the code feels a little more secure without this check in place as it means that we have to explicitly specify a tenant or call unscoped
whenever we work with our application outside a request.
Fixing Some Other Problems
Our multi-tenant support is working well; we can swap between tenants and all the data is scoped for us automatically. That said there are some potential problems that we need to be aware of. One of these can show itself when we need to test the uniqueness of an attribute. In our signup form, for example, a user with the email address foo@example.com
can’t sign up for both the Cheese and Chunky Bacon forums as they’ll be told that their email address has already been taken when they try signing up for the second forum. If we use validates_uniqueness_of
in a model it won’t have the default scope applied to it so we have to add a scope option.
validates_uniqueness_of :email, scope: :tenant_id
Now we can sign up to both forums with the the same email address.
We can find another potential issue in the Tenant model where we call cattr_accessor
for the current_id
attribute. While this is convenient it’s not really thread-safe so we might want to do something like this instead.
class Tenant < ActiveRecord::Base attr_accessible :name, :subdomain def self.current_id=(id) Thread.current[:tenant_id] = id end def self.current_id Thread.current[:tenant_id] end end
Now we have getter and setter methods that use Thread.current
to set the value which is more thread-safe.
With these issues taken care of we now have a solid multi-tenant application using default scopes. Building this functionality from scratch has been quite easy but if you’d rather not do this there are some gems you can use to help you out. There’s a multi-tenancy category on The Ruby Toolbox which has a number of gems that mostly work in a similar way to what we’ve done here by using ActiveRecord scopes. A good option here is the Apartment gem which takes a different approach. This gem splits up each tenant into a separate database which greatly reduces the changes of a bug sharing data between tenants. It also has support for a great Postgresql feature called schemas. This is a way of providing namespaces for tables and while we won’t be covering it here it will be covered in a future episode.