Multi-tenant applications with the Apartment gem
How many times have you needed to offer the same service to several clients from a single instance of a Rails application? What should you do, and how, so that each client can access only and exclusively its own data, keeping that data 100% confidential?
- The use case
- A 'from scratch' implementation
- Why use it?
- The Apartment gem
- Usage
- Jobs
- The 'Elevators'
- Apartment::Elevators::Domain
- Apartment::Elevators::Subdomain
- Apartment::Elevators::FirstSubdomain
- Apartment::Elevator::HostHash
- Apartment::Elevator::Generic
- Custom middleware
- Final thoughts
- Too many elevators!
- Database adapters
The use case
Imagine building an invoice management service as a SaaS, available at the hypothetical address fatture.example.com.
A new user signs up for the application and the system provides an instance at abcdefg1.fatture.example.com.
Finally, suppose that for some reason it is not possible to spin up a separate instance of the Rails application for each new client.
As a result, we will have a server with a number of domains pointing to a single application whose database contains the data of all the clients. Preventing, by every means, any client from seeing other clients' data becomes a fundamental requirement for confidentiality reasons.
A 'from scratch' implementation
A fairly common solution is to add to every table containing shared data, for example the invoices table, an extra column holding a reference to the domain that specific row belongs to.
class AddDomainOwnerToInvoices < ActiveRecord::Migration[5.0]
def change
add_column :invoices, :domain_owner, :string, null: false
end
endOnce that is done, we can add a dedicated scope to our Invoice model:
class Invoice < ActiveRecord::Base
...
scope :of_owner, ->(domain) { where(domain_owner: domain }
...
endand a helper that gives us the current owner of the request
class ApplicationHelper
...
def current_owner
base_url
end
endFinally, let's look at a possible use in a controller:
class InvoiceController
before_action :load_invoice, only: [:show, :edit, :update, :destroy]
def index
@invoices = Invoice.of_owner(current_owner)
end
def create
@invoice = Invoice.create(
params.require[:invoice].permit([...])
)
end
private
def load_invoice
@invoice = Invoice.of_owner(current_owner).find(params[:id])
end
endWhy use it?
Now imagine dealing with dozens of tables, many of which need a CRUD controller, while others might be used in Jobs. In my view, writing the code becomes tedious, and there is a fairly high chance of forgetting to use the scope in critical places.
We could define a set of shared_examples to use in the model tests, but even then everything depends on whether they are used correctly.
Personally, I feel a bit uneasy about having to manage code like this. If such an oversight were to happen, we would risk exposing sensitive data, and the consequences could be serious.
The Apartment gem
The Apartment gem was created precisely to meet this kind of need.
The idea is this: instead of inventing a mechanism to select only the rows owned by a user, we create replicas of the database, and each user works with a subset of it.
If our SaaS has 2 clients, we would end up with 3 different databases (with sqlite3 we would find 3 .db files in the db/ directory)
If we use PostgreSQL as our database (the one used in this tutorial, ed.), we can use Postgres schemas. For those unfamiliar with them, they are a kind of namespace. We will therefore have a single database with three different schemas:
- "public"."invoices"
- "client1"."invoices"
- "client2"."invoices"
When a request comes in, Apartment points the ActiveRecord connection to the specific database or, with PostgreSQL, prefixes the table_name with the tenant name.
Thanks to this mechanism, every model, with no extra code, will access the table associated with the given client, without any risk.
Now let's see how to configure it in an application
Installation and configuration
To install it, add the following line to your Gemfile:
gem "apartment"
and run
$ bundle install
Now let's define a support table that will hold the information for each client:
class CreateAccounts < ActiveRecord::Migration[5.0]
def change
create_table :accounts do |t|
t.string :name
t.string :domain
end
end
endAn example Account could be
account = Account.create(name: "abcdefg1", domain: "abcdefg1.fatture.example.com") Apartment::Tenant.create(account.name)
Creating the tenant will run ALL the migrations inside the new schema using db/schema.rb. If you manage your database schema in SQL format, you will have to run the migrations by hand.
When the application starts, we can read the various accounts and set up the Apartment tenants.
# config/initializers/apartment.rb
require "apartment/adapters/abstract_adapter"
require "apartment/adapters/postgresql_adapter"
Apartment.configure do |config|
config.excluded_models = %w{Account Delayed::Job}
config.use_schemas = true
config.persistent_schemas = %w{}
config.tenant_names = -> { Account.table_exists? ? Account.pluck(:name) : [] }
endAt this point our database will contain as many replicas of the tables as there are Accounts.
The last step is to tell Apartment how to set the specific tenant for a given HTTP request.
To do this, Apartment provides middleware that runs all the request code within a specific tenant (in Apartment's vocabulary these are called Elevators). In our case we need the subdomain elevator.
# config/application.rb
require 'apartment/elevators/subdomain'
module MyApplication
class Application < Rails::Application
...
config.middleware.use 'Apartment::Elevators::Subdomain'
...
end
endUsage
Front-end
Now the controller can be written without worrying about filtering individual rows: Apartment will route ActiveRecord straight to its tenant
class InvoiceController
before_action :load_invoice, only: [:show, :edit, :update, :destroy]
def index
@invoices = Invoice.all
end
def create
@invoice = Invoice.create(
params.require[:invoice].permit([...])
)
end
private
def load_invoice
@invoice = Invoice.find(params[:id])
end
endJobs
We have seen how Apartment automates tenant selection based on the request URL (you can use the request's domain and/or path).
With jobs (rake tasks, Active Jobs) the situation is slightly different. There is no HTTP request and, consequently, no domain. Apartment gives us two methods to manually select the tenant to use:
Apartment::Teanant.switch(tenant_name) do ... end
which runs the block inside 'tenant_name' and then restores the previous tenant, or
Apartment::Tenant.swtich!(tenant_name) ...
which switches from that point on, until the job completes or another switch is made.
So we need to pass the jobs an additional parameter: the tenant in which the script must run.
class MyJob < ApplicationJob
def perform(tenant, ...)
Apartment::Tenant.switch(tenant) do
# code goes here
end
end
endThe 'Elevators'
Apartment ships with a number of Elevators, each with its own logic for selecting the tenant name. Let's look at them in detail.
Apartment::Elevators::Domain
This elevator parses the full domain of the request, stripping any leading 'www'. A request to www.example.com, example.com, example.it or example.co.uk will run in the scope of the "example" tenant.
Apartment::Elevators::Subdomain
This elevator requires a subdomain in order to run. A request to 'example.com' will raise an exception, while one to 'foo.example.com' will select the "foo" tenant. In the initializer you can configure the length of the base domain (default value 1)
# config/inizializers/apartment.rb Apartment.configure do |config| ... config.tdl_length = 2 ... end
In this case you will need at least a third level to select a tenant: foo.bar.example.com will use the "foo" tenant, while bar.example.com will not resolve to any tenant.
Apartment::Elevators::FirstSubdomain
This does exactly the same job as the Subdomain elevator
Apartment::Elevator::HostHash
This elevator uses no complex logic: it takes as a parameter a hash whose keys are domains and whose values are tenant names
config.middleware.use 'Apartment::Elevators::HostHash', {
'example.com' => 'example_tenant',
'example.it' => 'another_tenant'
}Apartment::Elevator::Generic
If we have special needs, we can turn to the last elevator: Generic. We provide the Elevator with the complete logic to run, starting from the request object.
config.middleware.use Apartment::Elevators::Generic, Proc.new { |request| return computed_tenant_from(request) }Custom middleware
Elevators are middleware in every respect, so you can create your own. In the previous Generic example
class MyCustomElevator < Apartnent::Elevators::Generic
def parse_tenant_nane(request)
computed_tenant_from(request)
end
endthis is the approach I recommend when the Proc in the Generic example becomes too complex.
Final thoughts
In my opinion it is a very useful gem that lets you manage several instances of a service on a single physical instance of the application; on platforms like Heroku, that means a single dyno can serve multiple tenants. I do, however, take issue with a couple of aspects of the gem: the elevators and the handling of adapters other than Postgres.
Too many elevators!
There are five of them, and I found their behavior somewhat confusing.
Domain simply returns the first domain that follows 'www', so www.example.com, www.example.co.uk and example.pippo.pluto.com all map to the "example" tenant.
After reading the source code, I found that Subdomain and FirstSubdomain do exactly the same thing. Also, configuring the top-level domain length gets confusing if you use British (.co.uk) and Turkish (.com.tr) domains alongside other standard domains (.com, .it, .fr, etc.).
Finally, HostHash can be handy for managing two, at most three, static domains.
Given all this, I think a custom middleware makes sense, both for code cleanliness and for flexibility.
Database adapters
As for adapters, PostgreSQL, thanks to schemas, offers a solution I find extremely clean and elegant: many virtual databases allocated within a single cluster, with significant advantages. Instinctively, I don't like having N sqlite3 files, or N different databases on MySQL. That said, I would only use it with Postgres (incidentally, Heroku has no problem with using schemas in its databases).