Using acts_as_tenant for Multi-tenant Postgres with Rails

Since its launch, Ruby on Rails has been a preferred open source framework for small-team B2B SaaS companies. Ruby on Rails uses a conventions-over-configuration mantra. This approach reduces common technical choices, thus elevating decisions. With this approach, the developers get an ORM (ActiveRecord), templating engine (ERB), helper methods (like number_to_currency), controller (ActiveController), directory setup defaults (app/{models,controllers,views}), authentication methods (has_secure_password), and more.

Multi-tenant is the backbone of B2B SaaS products, yet core-Rails remains un-opinionated on multi-tenant implementations. Through the years, there has been many different Ruby gem implementations for multi-tenant. Many of these gems were built for complicated situations — either adapting to scaling needs or regulated industries that require physical separation of data. Many of these gems required deep integration with your Rails application code.

Enter acts_as_tenant

With all that as context, the acts_as_tenant gem is super simple. acts_as_tenant has recently released version 1.0 after 12 years of development — so it’s not new. The gem implements multi-tenant best-practices by augmenting Rails’ ActiveRecord ORM:

  • protects developers from building queries that return other tenant’s records
  • requires a tenant_id on the tables for models specific to a tenant
  • adds the tenant_id scope to the query
  • includes ActionController, ActiveRecord, ActiveJob helpers to insert new records with the scoped tenant

Acts_as_tenant is built for row-level multi-tenancy, and that is it. So, no need to manage multiple databases or schemas for data structures — it keeps it simple. One of the best things I can say about acts_as_tenant is that it can be implemented by an existing application code-base. Too many times, with the older multi-tenant gems, the implementation was invasive, and thus required complex refactoring.

What it’s not: acts_as_tenant is not for account-based sharding — either schema-based or multi-cluster based sharding. It’s purely for multi-tenant safety.

For the paranoid

I have built a few multi-tenant apps in industries with data regulation (think finance and education). I am overly cautious when building multi-tenant apps — so this guardrail is my favorite.

To enforce the tenant_id on every ActiveRecord query within an application, add the following to a initializer file in config/initializers/acts_as_tenant.rb:

ActsAsTenant.configure do |config|
	config.require_tenant = true
end

Having worked in a few multi-tenant apps where showing data to another customer is consequential, I wish acts_as_tenant had an enforcing requirement of a tenant_id for queries. One of the apps I wrote required high-performance, large-scale data loads. We had an intermittent bug where people would be assigned to the incorrect tenant. After tracking down the bug, we found the incident in the implementation of multiple external_ids:

-- bug code
SELECT
  *
FROM people
WHERE tenant_id = %1 AND external_id = $2 OR other_external_id = $2;

-- correct code
SELECT
  *
FROM people
WHERE tenant_id = %1 AND (external_id = $2 OR other_external_id = $2);

The lesson: wrap your OR statements in parenthesis. The bug code interpreted as:

(tenant_id = %1 AND external_id = $2) OR other_external_id = $2;

When using acts_as_tenant, you can avoid this bug when using ActiveRecord models. Below, you’ll see that ActiveRecord encapsulates the following:

active record output

Remember, if you choose to use raw SQL, you’ll need to keep your guard up.

Testing from rails new app

To install from a new rails application, do the following:

  1. Run rails new multi-tenant-app
  2. Decide on your application’s tenant model: typically Organization or Account or Team or School. Use the underscore version of the name with _id appended as your tenant id for all columns, such as organization_id or account_id or team_id or school_id. Below, we will use the tenant name Account.
  3. Add gem "acts_as_tenant" to Gemfile, and run bundle install.
  4. Create some models:
rails g model Account name:string
rails g model User email:string account_id:integer
rails g model Post content:string user_id:integer account_id:integer

rails db:create && rails db:migrate
  1. Add the following to app/models/account.rb
class Account < ApplicationRecord

  has_many :users
  has_many :posts

end
  1. Add the following to app/models/post.rb:
class Post < ApplicationRecord

  belongs_to :user
  acts_as_tenant :account

end
  1. Add the following to app/models/user.rb:
class User < ApplicationRecord
  acts_as_tenant :account
  validates_uniqueness_to_tenant :email
end
  1. Now, let’s experiment with the Rails REPL:
rails console

Then, you can run the following commands:

first_account = Account.create!(name: "First Account")
last_account = Account.create!(name: "Last Account")

ActsAsTenant.with_tenant(first_account) do
  user = User.create!(email: "test@example.com")
  post = Post.create!(user: user, content: "Lorem Ipsum")
end

ActsAsTenant.with_tenant(first_account) do
  Post.first.content # -> "Lorem Ipsum"
end

ActsAsTenant.with_tenant(last_account) do
  Post.first.nil? # -> true because we did not create a tenant
end

Post.first.content # -> "Lorem Ipsum"

ActsAsTenant.configure do |config|
  config.require_tenant = true
end

Post.first.content # -> ActsAsTenant::Errors::NoTenantSet (ActsAsTenant::Errors::NoTenantSet)

When looking at the queries that are run by ActiveRecord, you’ll see it automatically appends the account_id to the User and Post that are created. Later, after we set require_tenant, you’ll see that the next command fails with an error.

  1. From the terminal, we explicitly used with_tenant. acts_as_tenant has helpers for the controller as well. Depending on how your authentication systems and tenancy work, you can use domains, subdomains, or implicit tenancy based on the authenticated user. From here, you’ll need to implement something like:
class ApplicationController < ActionController::Base
  set_current_tenant_through_filter
  before_action :require_authentication
  before_action :set_tenant

  def require_authentication
    current_user || redirect_to(new_session_path)
  end

  def current_user
    @current_user ||= if session[:user_id].present?
			User.find(session[:user_id])
    end
  end

  def current_acount
    @current_account ||= current_user.try(:account)
  end

  def set_tenant
    set_current_tenant(current_account)
  end
end

Implementation of proper authentications are complex, so this is simply for example. The code specific to acts_as_tenant are set_current_tenant_through_filter and before_action :set_tenant and def set_tenant.

Migrating to acts_as_tenant

If you have an existing codebase that would benefit from acts_as_tenant, the migration is a process and can be broken into multiple steps:

  1. Add a tenant_id column to each affected model - this step can be quite complicated. It requires data migrations and data updates. The method of updating columns will be dependent on the size of your database.
  2. Add the acts_as_tenant gem, but do not set require_tenant yet
  3. Define the tenancy for your ApplicationController using either domains, subdomains, or filter
  4. Define the tenancy for your Action Job
  5. Define tenancy for your models

Taking a measured approach to migrating, you can deploy each of the steps above independently. And, you can deploy each model change independently of the entire change.

Removing acts_as_tenant

The best thing I can say about a library is: you can migrate away from it if it does not work for you. Because acts_as_tenant is not a deep integration as past multi-tenant libraries, it is possible to move away from acts_as_tenant.

Summary

Back in the 2009-ish era, Ruby on Rails and “The Cloud” grew up together when cloud-SaaS and social networks took off. Back then, the maximum performance of network attached storage was 100 IOPs and size maxed out at 1TB. The IOPs strangled database performance, and 1TB was an unbreakable limitation (if you did not RAID early). I started my career in that era. Due to infrastructure limitations, multi-tenant databases would start to see issues when an application hit as little as 50 requests per second. In this era, RAM was expensive and disk performance was not available. Because of this, “sharding” was talked about at all the conferences.

Side note: also, data was suddenly available everywhere, and there were business models that stored massive amounts of data hoping to figure out a business model later.

Now, in 2023, RAM is plentiful and IOPs are available. Scaling the database can be punted to 10s of thousands of requests per second.

Why do I say all this? Because now, we can approach multi-tenant apps and scaling more practically. Multi-tenant can focus on data-security and coding-practically instead of scaling. You may not ever get to the point of needing distributed data stores, but a solid multi-tenant implementation creates foundational success for your application.

The old multi-tenant Ruby Gems were for scalability. acts_as_tenant is built for practicality.

Avatar for Christopher Winslett

Written by

Christopher Winslett

December 20, 2023 More by this author