Heading image for post: Achieving Multitenancy in a Rails App Using CurrentAttributes

Achieving Multitenancy in a Rails App Using CurrentAttributes

Profile picture of Mary Lee

While working with a legacy BBj PRO/5 database for a client, we needed to set up a new CMS with multitenancy requirements. We were dealing with a slew of foreign tables representing the PRO/5 data, and each of the tables had a column for designating which tenant they belonged to. Let's talk about how we leveraged ActiveSupport::CurrentAttributes to solve this problem!

Our Requirements

Our client was dealing with an organization that had multiple children "companies" with different rules, ecommerce settings, and locales. While the companies were being treated as separate entities, our CMS users were meant to be able to easily jump between the companies while doing their work. This meant that we didn't need an extremely strong level of security between the companies, as they were all under the same umbrella, and our users were free to see the data across all of the companies. It also meant that our users needed to be able to jump between tenants easily.

Thinking Through Solutions

The core data for the different companies in our application was represented by the foreign tables. Each of the tables had a column for the company_id present, so we knew we would always be able to determine if a certain record belonged to the company our user was working with. We couldn't realistically make changes to the foreign tables, given the lift that would be within the legacy system.

We knew that we could create the foreign tables in different schemas using partitions of the data based on the company_id column, but given that our security needs for cross-company access were low, it felt like we'd be adding a great deal of complexity when we didn't need to. The same applied to breaking the different company data into separate databases. For that reason, we chose to set up the foreign tables in the public schema, and have them be full, unpartitioned matches of the PRO/5 tables.

Once that decision was made, it came down to figuring out two things: how to determine which company we were looking at for a given request, and how to filter the foreign table data based on the current company. We knew that we needed to implement a solution that would allow us to know application-wide which company we were dealing with. Basically, we needed a global variable, and we realized we were looking at the exact use case for CurrentAttributes in Rails. Our decision was made, and now we had to get to work.

What is CurrentAttributes?

CurrentAttributes is an abstract super class that provides thread-safe, global, per-request attributes in Rails. It eliminates the need to pass around variables throughout your application. While an easy class to abuse, it's incredibly useful for high level variables (like your current user, account, organization, etc) that could be used in every piece of logic that gets executed during a request.

How We Made it Work

To begin, we created our Current class, inheriting from ActiveSupport::CurrentAttributes. We knew that the attribute that our multitenancy would hinge upon was the company_id present in our foreign tables, so that was the variable we set for Current.

class Current < ActiveSupport::CurrentAttributes
  attribute :company_id
end

From there, we needed to figure out how to automatically filter our foreign table-backed models based on the set company id. We chose to create a new application model with a default scope for handling that, and had all of our foreign models inherit from that. In order to ensure we didn't let a request slip through without setting a company, we added a guard clause to raise an error when the company was missing.

class MultitenantRecord < ApplicationRecord
  default_scope -> {
    raise "CompanyNotSpecified: You must set a company_id - #{table_name}" if Current.company_id.blank?

    where(company_id: Current.company_id)
  }
end

With the ability to scope our records, we needed to figure out how to set our current company for each incoming request. We knew that we needed to add something to our user to tell the app which company they were looking at, and also allow the user to change which company they wanted to look at.

We chose to add a company_id column to our users, and added a dropdown in our CMS header to allow them to quickly swap which company they were looking at. Then in our application controller, we were able to set the current company based on our authenticated user.

class ApplicationController < ActionController::Base
  before_action :set_current_company

  private

  def set_current_company
    Current.company_id = current_user&.company_id
  end
end

With those changes, most of our problems were solved. With the current company set in the controller, we were able to use Current for feature flagging in our views, and for swapping out which api key to use when interacting with the Stripe api. It was not all smooth sailing, though. We hit a few hurdles along the way as we started doing testing and exercising the full scope of the application.

Background Jobs

The first thing we noticed once we added the default scopes to our foreign table-based models was a slew of test failures in our jobs. This quickly made sense, as background jobs run in a different process from the request that triggered the job. So unfortunately, we found that we had to pass the company id to every single job that leveraged the foreign models. This was a high touch point change, but low complexity overall.

Request Specs

Once we fixed our job specs, we noticed that we still had a great deal of failures within our request specs. This we struggled to understand. We were dealing with a spec that looked something like like this:

# spec/requests/posts_controller_spec.rb
RSpec.describe PostsController, type: :request do
  before do
    Current.company_id = Company::HASHROCKET
  end

  describe "POST /posts" do
    it "creates a new post" do
      expect {
        post posts_path, params: { title: "New post" }
      }.to change {
        Post.count
      }.by(1)
    end
  end
end

We were getting the guard clause error we added to our default scope during the spec when it tried to run Post.count. We added some puts in the test to debug, and found an interesting behavior.

# spec/requests/posts_controller_spec.rb
RSpec.describe PostsController, type: :request do
  before do
    Current.company_id = Company::HASHROCKET
    puts Current.company_id # => "hashrocket"
  end

  describe "POST /posts" do
    it "creates a new post" do
      expect {
        puts Current.company_id # => "hashrocket"
        post "/posts", params: { title: "New post" }
        puts Current.company_id # => nil
      }.to change {
        puts Current.company_id # => nil
        Post.count
      }.by(1)
    end
  end
end

The issue appeared to be that when the controller finished processing the request and cleaned up CurrentAttributes, it was wiping out the values from the rspec setup. To address this issue, we added a quick helper to our Current class:

class Current < ActiveSupport::CurrentAttributes
  attribute :company_id

  def self.with_company(cid)
    set(company_id: cid) do
      yield
    end
  end
end

Then we used that helper in our tests to resolve the error being raised in the default scope:

# spec/requests/posts_controller_spec.rb
RSpec.describe PostsController, type: :request do
  let(:company_id) { Company::HASHROCKET }

  before do
    Current.company_id = company_id
  end

  describe "POST /posts" do
    it "creates a new post" do
      expect {
        post posts_path, params: { title: "New post" }
      }.to change {
        Current.with_company(company_id) { Post.count }
      }.by(1)
    end
  end
end

With that, our request specs were squared away.

Bottom Line

CurrentAttributes is a powerful tool for handling global variables in a Rails application. It enabled us to quickly set up multitenancy in our CMS, and prevented us from having to pass a company_id variable to every aspect of our application code (with the exception of our background jobs). While our implementation wouldn't be appropriate for all use cases, it worked perfectly for our client and kept us from having a high level of complexity in our code base.

  • Adobe logo
  • Barnes and noble logo
  • Aetna logo
  • Vanderbilt university logo
  • Ericsson logo

We're proud to have launched hundreds of products for clients such as LensRentals.com, Engine Yard, Verisign, ParkWhiz, and Regions Bank, to name a few.

Let's talk about your project