MultiTenantSupport
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
existsCurrentTenantSupport.allow_read_across_tenant
is false (default)
- Action by wrong tenant
CurrentTenantSupport.current_tenant
does not matchtarget_record.account
CurrentTenantSupport.allow_read_across_tenant
is false (default)
- Action when missing tenant
CurrentTenantSupport.current_tenant
is nilCurrentTenantSupport.allow_read_across_tenant
is false (default)
- Action by super admin but readonly
CurrentTenantSupport.current_tenant
is nilCurrentTenantSupport.allow_read_across_tenant
is true
- Action by super admin but want modify on a specific tenant
CurrentTenantSupport.current_tenant
is nilCurrentTenantSupport.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
Add this line to your application's Gemfile:
gem 'multi-tenant-support'
And then execute:
bundle install
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
Create an initializer
rails generate multi_tenant_support:initializer
Set
tenant_account_class_name
to your tenant account model name inmulti_tenant_support.rb
- config.tenant_account_class_name = 'REPLACE_ME' + config.tenant_account_class_name = 'Account'
Set
host
to your app's domain inmulti_tenant_support.rb
- config.host = 'REPLACE.ME' + config.host = 'your-app-domain.com'
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'
Add
belongs_to_tenant
to all models which you want to scope under tenantclass 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
MultiTenantSupport.full_protected?
MultiTenantSupport.allow_read_across_tenant?
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:
Set
allow_read_across_tenant_by_default
in the initialize fileconsole do |config| config.allow_read_across_tenant_by_default = true end
Set the environment variable
ALLOW_READ_ACROSS_TENANT
when call consoel commandALLOW_READ_ACROSS_TENANT=1 rails console
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.