Go to main contentGo to footer
Ruby
|
27 February 17

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

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
end

Once 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 }
 ...
end

and a helper that gives us the current owner of the request

class ApplicationHelper
  ...
  def current_owner
    base_url
  end
end

Finally, 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
end

Why 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
end

An 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) : [] }
end

At 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
end

Usage

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
end

Jobs

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
end

The '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
end

this 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).

footer