DEV Community

Jason Meller for Kolide

Posted on • Edited on

A Rails Multi-Tenant Strategy That's ~30 Lines and "Just Works"

When engineering a new SaaS app, how you plan to handle customer data tenancy is usually one of the first decisions you and your team will need to make. If you are writing a Rails app and decide on a multi-tenant strategy (one instance of the app serves many customers), then this article is for you.

I contend that modern Rails has everything you need to build a multi-tenant strategy that scales, is easy for others to use, and can be written in just a handful of lines of code. Kolide (btw we're hiring) has been using this simple strategy since the inception of its product, and it's been one of the most elegant and easiest to understand parts of our code-base.

So before reaching for a gem, consider if the following meets your needs.

The Code

The entire implementation is contained in just two files and requires no additional dependencies.

# app/models/concerns/account_ownable.rb

module AccountOwnable
  extend ActiveSupport::Concern

  included do
    # Account is actually not optional, but we not do want
    # to generate a SELECT query to verify the account is
    # there every time. We get this protection for free
    # because of the `Current.account_or_raise!`     
    # and also through FK constraints.
    belongs_to :account, optional: true
    default_scope { where(account: Current.account_or_raise!) }
  end
end
Enter fullscreen mode Exit fullscreen mode
# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :account, :user

  resets { Time.zone = nil }

  class MissingCurrentAccount < StandardError; end

  def account_or_raise!
    raise Current::MissingCurrentAccount, "You must set an account with Current.account=" unless account

    account
  end

  def user=(user)
    super
    self.account      = user.account
    Time.zone         = user.time_zone
  end
end
Enter fullscreen mode Exit fullscreen mode

That's it.

Using the Code In Practice

To use this code, simply mix-in the concern into any standard ActiveRecord model like so...

class ApiKey < ApplicationRecord
  # assumes table has a column named `account_id`
  include AccountOwnable
end
Enter fullscreen mode Exit fullscreen mode

When a user of ours signs in, all we need to do is simply set Current.user in our authentication controller concern which is mixed into our ApplicationController

# app/controllers/concerns/require_authentication.rb
module RequireAuthentication
  extend ActiveSupport::Concern

  included do
    before_action :ensure_authenticated_user
  end

  def ensure_authenticated_user
    if (user = User.find_by_valid_session(session))
      Current.user = user
    else
      redirect_to signin_path
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

For this small amount of effort we now get the following benefits:

  1. Because of the default_scope, once a user is signed in, data from sensitive models is automatically scoped to their account. We just don't need to think about it, no matter how complicated our query chaining gets.

  2. Again, because of the default_scope creating new records for these AccountOwnable models will automatically set the account_id for us. One less thing to think about.

  3. In situations where we are outside of the standard Rails request/response paradigm (ex: in an ActiveJob) any AccountOwnable models will raise if Current.account is not set. This forces us to constantly think about how we are scoping data for customer needs.

  4. The situations where we need to enumerate through more than one tenant's data at a time are still possible but now require a Model.unscoped which can be easily scanned for in linters requiring engineers to justify their rationale on a per use-case basis.

One thing that became slightly annoying was constantly setting Current.account = in the Rails console. To make that much easier we wrote a simple console command.

# lib/kolide/console.rb
module App
  module Console
    def t(id)
      Current.account = Account.find(id)
      puts "Current account switched to #{Current.account.name} (#{Current.account.id})"
    end
  end
end

# in config/application.rb
console do
  require 'kolide/console'
  Rails::ConsoleMethods.send :include, Kolide::Console
  TOPLEVEL_BINDING.eval('self').extend Kolide::Console # PRY
end
Enter fullscreen mode Exit fullscreen mode

Now we simply run t 1 when we want to switch the tenant with an id of 1. Much better.

In the test suite, you should also reset Current before each spec/test as it's not done for you automatically. For us that was simply a matter of adding...

# spec/spec_helper.rb
config.before(:each) do
  Current.reset
end
Enter fullscreen mode Exit fullscreen mode

Now we don't have to worry about our global state being polluted when running our specs serially in the same process.

Concerns we had that didn't end up being an issue

Kolide has been successfully using this strategy since the inception of our Ruby on Rails SaaS app. While we arrived at this strategy in the first few days of our app's formation, we definitely were less confident in the approach. Here is a list of concerns we held and how they ended up panning out.

Will this approach be acceptable to our customers?

Kolide is a device security company, and since our buyers are likely to be either security engineers or security minded IT staff, the bar we need to meet is much higher than the normal SaaS company. We were nervous that an app-enforced constraint would feel flimsy, despite how well it works in practice.

In reality, we found the opposite. Customers were mostly ambivalent about our app-enforced constraint approach. Why? It's because it's an approach that's common among their other vendors and matches their pre-conceived expectations about how most SaaS software works. Matched expectations = less concern.

In prior iterations of our app where we did extreme things like spin up separate Kubernetes namespaces and DBs for each customer, we found our efforts were paradoxically met with more concern, not less. This concern manifested as additional process, review, and ultimately unnecessary friction as our buyers grappled to bring more and more technical folks into the procurement process to simply understand the unfamiliar architecture.

With our current approach, our development and deployment story is simpler, and simplicity has significant security advantages.

Current.rb is too magical, will multi-threading in production cause someone's default_scope to leak to another request?

There is a lot of consternation in the Rails community when DHH introduced the CurrentAttributes paradigm in Rails 5.2. DHH talks about his rationale for adding this in his Youtube video entitled, "Using globals when the price is right".

Ryan Bigg on the other-hand felt this addition to Rails would cause developers to write a lot of code with unpredictable behavior expressed these views in his blog post entitled, "Rails' CurrentAttributes considered harmful"

After reading more into the original PR...

ActiveSupport::CurrentAttributes provides a thread-isolated attributes singleton #29180

dhh avatar
dhh posted on

Abstract super class that provides a thread-isolated attributes singleton. Primary use case is keeping all the per-request attributes easily available to the whole system.

The following full app-like example demonstrates how to use a Current class to facilitate easy access to the global, per-request attributes without passing them deeply around everywhere:

# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :account, :user
  attribute :request_id, :user_agent, :ip_address 
  
  resets { Time.zone = nil }
  
  def user=(user)
    super
    self.account = user.account
    Time.zone = user.time_zone
  end
end

# app/controllers/concerns/authentication.rb
module Authentication
  extend ActiveSupport::Concern

  included do
    before_action :set_current_authenticated_user
  end

  private
    def set_current_authenticated_user
      Current.user = User.find(cookies.signed[:user_id])
    end
end

# app/controllers/concerns/set_current_request_details.rb
module SetCurrentRequestDetails
  extend ActiveSupport::Concern

  included do
    before_action do
      Current.request_id = request.uuid
      Current.user_agent = request.user_agent
      Current.ip_address = request.ip
    end
  end
end  

class ApplicationController < ActionController::Base
  include Authentication
  include SetCurrentRequestDetails
end

class MessagesController < ApplicationController
  def create
    Current.account.messages.create(message_params)
  end
end

class Message < ApplicationRecord
  belongs_to :creator, default: -> { Current.user }
  after_create { |message| Event.create(record: message) }
end

class Event < ApplicationRecord
  before_create do
    self.request_id = Current.request_id
    self.user_agent = Current.user_agent
    self.ip_address = Current.ip_address
  end
end
Enter fullscreen mode Exit fullscreen mode

A word of caution: It's easy to overdo a global singleton like Current and tangle your model as a result. Current should only be used for a few, top-level globals, like account, user, and request details. The attributes stuck in Current should be used by more or less all actions on all requests. If you start sticking controller-specific attributes in there, you're going to create a mess.

I found a lot of thoughtful consideration for how to make this code truly thread-safe which convinced me to bet big on this approach.

Now over two years later, I can say with confidence that in our app, which serves nearly 1,800 HTTP requests per second across hundreds of different tenants, this code works as described in a real production setting.

Will setting Current.account in asynchronous jobs or in the test suite become tiresome or problematic?

No, the ceremony here is worth it because it forces us to think carefully about how we are acting on our customer's data. Situations where we need to iterate through more than one customer's data are also trivial to achieve through an #each block.

ApiKey.unscoped.find_each do |api_key|
  Current.account = api_key.account
  api_key.convert_to_new_format
end
Enter fullscreen mode Exit fullscreen mode

Closing Thoughts

We are now sharing this simplistic approach because it's worked so well for us at Kolide. I imagine other burgeoning Rails apps considering a multi-tenant strategy will appreciate being able to do this in their own codebase vs offload something so important to an external gem.

We will continue to share our learnings as Kolide grows. In fact prepping for future scale is what I like best about this approach. By marking all of our records with a tenant identifier like account_id we gain the future option of leveraging more sophisticated solutions at the PostgreSQL level like multi-DB sharding or even products like Citus.


I hope you found this post useful. If you found any errors in this guide or suggestions to improve it, please reach out in the comments or hit me up on twitter @jmeller.

Oh, and we're hiring!

Top comments (10)

Collapse
 
bobbymcwho profile image
Bobby McDonald

Should this

# spec/spec_helper.rb
config.before(:all) do
  Current.reset
end
Enter fullscreen mode Exit fullscreen mode

be

# spec/spec_helper.rb
config.before(:each) do
  Current.reset
end
Enter fullscreen mode Exit fullscreen mode

?

Collapse
 
terracatta profile image
Jason Meller

You are correct! Fixed.

Collapse
 
apaciuk profile image
Paul Anthony McGowan • Edited

Awesome, am borrowing (stealing, whatever) it for Rails 7 Jumpstart themes at

github.com/xhostcom/rails-jumpstar...

Muchos Gracias, and may the forces of evil become extremely confused on the way to your house.

Collapse
 
ghepting profile image
Gary Hepting

Nice write up!

Collapse
 
ghiculescu profile image
Alex Ghiculescu

Since github.com/rails/rails/pull/46522 you don't need the optional: true

Collapse
 
bestfriend101 profile image
BestFriend101

Don't forget to check out github.com/ErwinM/acts_as_tenant 👍

Collapse
 
apaciuk profile image
Paul Anthony McGowan

One thing though if the new user has not signed up yet, can not set an account, or account_id, get error You must set an account with Current.account=

Needs workaround for that.

Collapse
 
codingbyharry profile image
Harry

Two possible options to consider:

  1. Create the tenant before the user so that the resource exists
  2. Don't use the concern on the user model and add a belongs_to :account, optional: true. Then probably want validations to ensure an account always exists on update.
Collapse
 
kirantpatil profile image
kirantpatil

Hello,

Any further updates on scaling multi tenant ?

Collapse
 
ashwin47 profile image
Ashwin M

After including the concern i cant even initialise my registration form because it raises Current::MissingCurrentAccount.

Is there a way around it?