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:
ruby 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
ruby - config.tenant_account_class_name = 'REPLACE_ME' + config.tenant_account_class_name = 'Account'
-
Set
host
to your appβs domain inmulti_tenant_support.rb
ruby - config.host = 'REPLACE.ME' + config.host = 'your-app-domain.com'
-
Setup for ActiveJob or Sidekiq
If you are using ActiveJob
ruby - # require 'multi_tenant_support/active_job' + require 'multi_tenant_support/active_job'
If you are using sidekiq without ActiveJob
ruby - # require 'multi_tenant_support/sidekiq' + require 'multi_tenant_support/sidekiq'
-
Add
belongs_to_tenant
to all models which you want to scope under tenantruby class User < ApplicationRecord belongs_to_tenant :account end
Usage
Get current
Get current tenant through:
ruby
MultiTenantSupport.current_tenant
Switch tenant
You can switch to another tenant temporary through:
ruby
MultiTenantSupport.under_tenant amazon do
# Do things under amazon account
end
Set current tenant global
ruby
MultiTenantSupport.set_tenant_account(account)
Temp set current tenant to nil
ruby
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:
```ruby 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:
```ruby 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:
```ruby 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:
```ruby 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 fileruby console do |config| config.allow_read_across_tenant_by_default = true end
-
Set the environment variable
ALLOW_READ_ACROSS_TENANT
when call consoel commandbash ALLOW_READ_ACROSS_TENANT=1 rails console
-
Manual change it in console
ruby $ rails c $ irb(main):001:0> MultiTenantSupport.allow_read_across_tenant
Testing
### Minitest (Rails default)
ruby
# test/test_helper.rb
require 'multi_tenant_support/minitet'
### RSpec (with Capybara)
ruby
# 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
```ruby # 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
```ruby 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
```ruby # 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
```ruby class Account < AppplicationRecord has_many :users end
class User < ApplicationRecord belongs_to_tenant :account end ```
Controler
ruby
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.