The project is in a healthy, maintained state
Build a highly secure, multi-tenant rails app without data leak.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
 Dependencies

Runtime

>= 6.0
 Project Readme

MultiTenantSupport

Test Gem Version

Build a highly secure, multi-tenant rails app without data leak.

Keep your data secure with multi-tenant-support. Prevent most ActiveRecord CRUD methods to action across tenant, ensuring no one can accidentally or intentionally access other tenants' data. This can be crucial for applications handling sensitive information like financial information, intellectual property, and so forth.

  • Prevent most ActiveRecord CRUD methods from acting across tenants.
  • Support Row-level Multitenancy
  • Build on ActiveSupport::CurrentAttributes offered by rails
  • Auto set current tenant through subdomain and domain in controller (overrideable)
  • Support ActiveJob and Sidekiq

This gem was inspired much from acts_as_tenant, multitenant, multitenancy, rails-multitenant, activerecord-firewall, milia.

But it does more than them, and highly focuses on ActiveRecord data leak protection.

What make it differnce on details

It protects data in every scenario in great detail. Currently, you can't find any multi-tenant gems doing a full data leak protect on ActiveRecord. But this gem does it.

Our protection code mainly focus on 5 scenarios:

  • Action by tenant
    • CurrentTenantSupport.current_tenant exists
    • CurrentTenantSupport.allow_read_across_tenant is false (default)
  • Action by wrong tenant
    • CurrentTenantSupport.current_tenant does not match target_record.account
    • CurrentTenantSupport.allow_read_across_tenant is false (default)
  • Action when missing tenant
    • CurrentTenantSupport.current_tenant is nil
    • CurrentTenantSupport.allow_read_across_tenant is false (default)
  • Action by super admin but readonly
    • CurrentTenantSupport.current_tenant is nil
    • CurrentTenantSupport.allow_read_across_tenant is true
  • Action by super admin but want modify on a specific tenant
    • CurrentTenantSupport.current_tenant is nil
    • CurrentTenantSupport.allow_read_across_tenant is true
    • Run code in the block of CurrentTenantSupport.under_tenant

Below are the behaviour of all ActiveRecord CRUD methods under abvove scenarios:

Protect on read

Read By tenant missing tenant super admin super admin(modify on a specific tenant)
count 🍕 🚫 🌎 🍕
first 🍕 🚫 🌎 🍕
last 🍕 🚫 🌎 🍕
where 🍕 🚫 🌎 🍕
find_by 🍕 🚫 🌎 🍕
unscoped 🍕 🚫 🌎 🍕

🍕 scoped ​ ​ ​ 🌎 ​ unscoped ​ ​ ​ ✅ ​ allow ​ ​ ​ 🚫 ​ disallow ​ ​ ​ ⚠️ ​ Not protected


Protect on initialize

Initialize by tenant wrong tenant missing tenant super admin super admin(modify on a specific tenant)
new ✅ ​ 🍕 - 🚫 🚫 ✅ ​ 🍕
build ✅ ​ 🍕 - 🚫 🚫 ✅ ​ 🍕
reload 🚫 🚫

🍕 scoped ​ ​ ​ 🌎 ​ unscoped ​ ​ ​ ✅ ​ allow ​ ​ ​ 🚫 ​ disallow ​ ​ ​ ⚠️ ​ Not protected


Protect on create

create by tenant wrong tenant missing tenant super admin super admin(modify on a specific tenant)
save ✅ ​ 🍕 🚫 🚫 🚫 ✅ ​ 🍕
save! ✅ ​ 🍕 🚫 🚫 🚫 ✅ ​ 🍕
create ✅ ​ 🍕 - 🚫 🚫 ✅ ​ 🍕
create! ✅ ​ 🍕 - 🚫 🚫 ✅ ​ 🍕
insert ✅ ​ 🍕 - 🚫 🚫 ✅ ​ 🍕
insert! ✅ ​ 🍕 - 🚫 🚫 ✅ ​ 🍕
insert_all ✅ ​ 🍕 - 🚫 🚫 ✅ ​ 🍕
insert_all! ✅ ​ 🍕 - 🚫 🚫 ✅ ​ 🍕

🍕 scoped ​ ​ ​ 🌎 ​ unscoped ​ ​ ​ ✅ ​ allow ​ ​ ​ 🚫 ​ disallow ​ ​ ​ ⚠️ ​ Not protected


Protect on tenant assign

Manual assign or update tenant by tenant missing tenant super admin super admin(modify on a specific tenant)
account= 🚫 🚫 🚫 🚫
account_id= 🚫 🚫 🚫 🚫
update(account:) 🚫 🚫 🚫 🚫
update(account_id:) 🚫 🚫 🚫 🚫

🍕 scoped ​ ​ ​ 🌎 ​ unscoped ​ ​ ​ ✅ ​ allow ​ ​ ​ 🚫 ​ disallow ​ ​ ​ ⚠️ ​ Not protected


Protect on update

Update by tenant wrong tenant missing tenant super admin super admin(modify on a specific tenant)
save 🚫 🚫 🚫
save! 🚫 🚫 🚫
update 🚫 🚫 🚫
update_all ✅ ​ 🍕 - 🚫 🚫 ✅ ​ 🍕
update_attribute 🚫 🚫 🚫
update_columns 🚫 🚫 🚫
update_column 🚫 🚫 🚫
upsert_all ⚠️ - 🚫 ⚠️ ⚠️
upsert ⚠️ - 🚫 ⚠️ ⚠️

🍕 scoped ​ ​ ​ 🌎 ​ unscoped ​ ​ ​ ✅ ​ allow ​ ​ ​ 🚫 ​ disallow ​ ​ ​ ⚠️ ​ Not protected


Protect on delete

Delete by tenant wrong tenant missing tenant super admin super admin(modify on a specific tenant)
destroy 🚫 🚫 🚫
destroy! 🚫 🚫 🚫
destroy_all ✅ ​ 🍕 - 🚫 🚫 ✅ ​ 🍕
destroy_by ✅ ​ 🍕 - 🚫 🚫 ✅ ​ 🍕
delete_all ✅ ​ 🍕 - 🚫 🚫 ✅ ​ 🍕
delete_by ✅ ​ 🍕 - 🚫 🚫 ✅ ​ 🍕

🍕 scoped ​ ​ ​ 🌎 ​ unscoped ​ ​ ​ ✅ ​ allow ​ ​ ​ 🚫 ​ disallow ​ ​ ​ ⚠️ ​ Not protected


Installation

  1. Add this line to your application's Gemfile:

    gem 'multi-tenant-support'
  2. And then execute:

    bundle install
    
  3. Add domain and subdomain to your tenant account table (Skip if your rails app already did this)

    rails generate multi_tenant_support:migration YOUR_TENANT_ACCOUNT_TABLE_OR_MODEL_NAME
    
    # Say your tenant account table is "accounts"
    rails generate multi_tenant_support:migration accounts
    
    # You can also run it with the tenant account model name
    # rails generate multi_tenant_support:migration Account
    
    rails db:migrate
    
  4. Create an initializer

    rails generate multi_tenant_support:initializer
    
  5. Set tenant_account_class_name to your tenant account model name in multi_tenant_support.rb

    - config.tenant_account_class_name = 'REPLACE_ME'
    + config.tenant_account_class_name = 'Account'
  6. Set host to your app's domain in multi_tenant_support.rb

    - config.host = 'REPLACE.ME'
    + config.host = 'your-app-domain.com'
  7. Setup for ActiveJob or Sidekiq

    If you are using ActiveJob

    - # require 'multi_tenant_support/active_job'
    + require 'multi_tenant_support/active_job'

    If you are using sidekiq without ActiveJob

    - # require 'multi_tenant_support/sidekiq'
    + require 'multi_tenant_support/sidekiq'
  8. Add belongs_to_tenant to all models which you want to scope under tenant

    class User < ApplicationRecord
      belongs_to_tenant :account
    end

Usage

Get current

Get current tenant through:

MultiTenantSupport.current_tenant

Switch tenant

You can switch to another tenant temporary through:

MultiTenantSupport.under_tenant amazon do
  # Do things under amazon account
end

Set current tenant global

MultiTenantSupport.set_tenant_account(account)

Temp set current tenant to nil

MultiTenantSupport.without_current_tenant do
  # ...
end

3 protection states

  1. MultiTenantSupport.full_protected?
  2. MultiTenantSupport.allow_read_across_tenant?
  3. MultiTenantSupport.unprotected?

Full protection(default)

The default state is full protection. This gem disallow modify record across tenant by default.

If MultiTenantSupport.current_tenant exist, you can only modify those records under this tenant, otherwise, you will get some errors like:

  • MultiTenantSupport::MissingTenantError
  • MultiTenantSupport::ImmutableTenantError
  • MultiTenantSupport::NilTenantError
  • MultiTenantSupport::InvalidTenantAccess
  • ActiveRecord::RecordNotFound

If MultiTenantSupport.current_tenant is missing, you cannot modify or create any tenanted records.

If you switched to other state, you can switch back through:

MultiTenantSupport.turn_on_full_protection

# Or
MultiTenantSupport.turn_on_full_protection do
  # ...
end

Allow read across tenant for super admin

You can turn on the permission to read records across tenant through:

MultiTenantSupport.allow_read_across_tenant

# Or
MultiTenantSupport.allow_read_across_tenant do
  # ...
end

You can put it in a before action in SuperAdmin's controllers

Turn off protection

Sometimes, as a super admin, we need to execute certain maintenatn operations over all tenant records. You can do this through:

MultiTenantSupport.turn_off_protection

# Or
MultiTenantSupport.turn_off_protection do
  # ...
end

Set current tenant acccount in controller by default

This gem has set a before action set_current_tenant_account on ActionController. It search tenant by subdomain or domain. Do remember to skip_before_action :set_current_tenant_account in super admin controllers.

Feel free to override it, if the finder behaviour is not what you want.

Override current tenant finder method if domain/subdomain is not the way you want

You can override find_current_tenant_account in any controller with your own tenant finding strategy. Just make sure this method return the tenat account record or nil.

For example, say you only want to find tenant with domain not subdomain. It's very simple:

class ApplicationController < ActionController::Base
  private

  def find_current_tenant_account
    Account.find_by(domain: request.domain)
  end
end

Then your tenant finding strategy has changed from domain/subdomain to domain only.

upsert_all

Currently, we don't have a good way to protect this method. So please use upser_all carefully.

Unscoped

This gem has override unscoped to prevent the default tenant scope be scoped out. But if you really want to scope out the default tenant scope, you can use unscope_tenant.

Console

Console does not allow read across tenant by default. But you have several ways to change that:

  1. Set allow_read_across_tenant_by_default in the initialize file

    console do |config|
      config.allow_read_across_tenant_by_default = true
    end
  2. Set the environment variable ALLOW_READ_ACROSS_TENANT when call consoel command

    ALLOW_READ_ACROSS_TENANT=1 rails console
  3. Manual change it in console

    $ rails c
    $ irb(main):001:0> MultiTenantSupport.allow_read_across_tenant

Testing

Minitest (Rails default)

# test/test_helper.rb
require 'multi_tenant_support/minitet'

RSpec (with Capybara)

# spec/rails_helper.rb or spec/spec_helper.rb
require 'multi_tenant_support/rspec'

Above code will make sure the MultiTenantSupport.current_tenant won't accidentally be reset during integration and system tests. For example:

With above testing requre code

# Integration test
test "a integration test" do
  host! "apple.example.com"

  assert_no_changes "MultiTenantSupport.current_tenant" do
    get users_path
  end
end

# System test
test "a system test" do
  Capybara.app_host = "http://apple.example.com"

  assert_no_changes "MultiTenantSupport.current_tenant" do
    visit users_path
  end
end

Code Example

Database Schema

create_table "accounts", force: :cascade do |t|
  t.bigint "domain"
  t.bigint "subdomain"
end

create_table "users", force: :cascade do |t|
  t.bigint "account_id"
end

Initializer

# config/initializers/multi_tenant_support.rb

MultiTenantSupport.configure do
  model do |config|
    config.tenant_account_class_name = 'Account'
    config.tenant_account_primary_key = :id
  end

  controller do |config|
    config.current_tenant_account_method = :current_tenant_account
  end

  app do |config|
    config.excluded_subdomains = ['www']
    config.host = 'example.com'
  end

  console do |config|
    config.allow_read_across_tenant_by_default = false
  end
end

Model

class Account < AppplicationRecord
  has_many :users
end

class User < ApplicationRecord
  belongs_to_tenant :account
end

Controler

class UsersController < ApplicationController
  def show
    @user = User.find(params[:id]) # This result is already scope under current_tenant_account
    @you_can_get_account = current_tenant_account
  end
end

ActiveRecord proteced methods

ActiveRecord proteced methods
count 🔒 save 🔒 account= 🔒 upsert ⚠️ (Partial)
first 🔒 save! 🔒 account_id= 🔒 destroy 🔒
last 🔒 create 🔒 update 🔒 destroy! 🔒
where 🔒 create! 🔒 update_all 🔒 destroy_all 🔒
find_by 🔒 insert 🔒 update_attribute 🔒 destroy_by 🔒
reload 🔒 insert! 🔒 update_columns 🔒 delete_all 🔒
new 🔒 insert_all 🔒 update_column 🔒 delete_by 🔒
build 🔒 insert_all! 🔒 upsert_all ⚠️ (Partial) unscoped 🔒

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake test to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/hoppergee/multi_tenant_support.

License

The gem is available as open source under the terms of the MIT License.