Sitelet https://github.com/ErwinM/acts_as_tenant
Skip to content

Repository files navigation

Acts As Tenant

Build Status Gem Version

Row-level multitenancy for Ruby on Rails apps.

This gem was born out of our own need for a fail-safe and out-of-the-way manner to add multi-tenancy to our Rails app through a shared database strategy, that integrates (near) seamless with Rails.

acts_as_tenant adds the ability to scope models to a tenant. Tenants are represented by a tenant model, such as Account. acts_as_tenant will help you set the current tenant on each request and ensures all 'tenant models' are always properly scoped to the current tenant: when viewing, searching and creating.

In addition, acts_as_tenant:

  • sets the current tenant using the subdomain or allows you to pass in the current tenant yourself
  • protects against various types of nastiness directed at circumventing the tenant scoping
  • adds a method to validate uniqueness to a tenant, validates_uniqueness_to_tenant
  • sets up a helper method containing the current tenant

Note: acts_as_tenant was introduced in this blog post.

Row-level vs schema multitenancy

What's the difference?

Row-level multitenancy each model must have a tenant ID column on it. This makes it easy to filter records for each tenant using your standard database columns and indexes. ActsAsTenant uses row-level multitenancy.

Schema multitenancy uses database schemas to handle multitenancy. For this approach, your database has multiple schemas and each schema contains your database tables. Schemas require migrations to be run against each tenant and generally makes it harder to scale as you add more tenants. The Apartment gem uses schema multitenancy.

🎬 Walkthrough

Want to see how it works? Check out the ActsAsTenant walkthrough video:

ActsAsTenant Walkthrough Video

Installation

To use it, add it to your Gemfile:

gem 'acts_as_tenant'

Getting started

There are two steps in adding multi-tenancy to your app with acts_as_tenant:

  1. setting the current tenant and
  2. scoping your models.

Setting the current tenant

There are three ways to set the current tenant:

  1. by using the subdomain to lookup the current tenant,
  2. by setting the current tenant in the controller, and
  3. by setting the current tenant for a block.

Looking Up Tenants

By Subdomain to lookup the current tenant

class ApplicationController < ActionController::Base
  set_current_tenant_by_subdomain(:account, :subdomain)
end

This tells acts_as_tenant to use the last subdomain to identify the current tenant. In addition, it tells acts_as_tenant that tenants are represented by the Account model and this model has a column named 'subdomain' which can be used to lookup the Account using the actual subdomain. If ommitted, the parameters will default to the values used above.

By default, the last subdomain will be used for lookup. Pass in subdomain_lookup: :first to use the first subdomain instead.

By Domain to lookup the current tenant

class ApplicationController < ActionController::Base
  set_current_tenant_by_subdomain_or_domain(:account, :subdomain, :domain)
end

You can locate the tenant using set_current_tenant_by_subdomain_or_domain( :account, :subdomain, :domain ) which will check for a subdomain and fallback to domain.

By default, the last subdomain will be used for lookup. Pass in subdomain_lookup: :first to use the first subdomain instead.

Manually using before_action

class ApplicationController < ActionController::Base
  set_current_tenant_through_filter
  before_action :your_method_that_finds_the_current_tenant

  def your_method_that_finds_the_current_tenant
    current_account = Account.find_it
    set_current_tenant(current_account)
  end
end

Setting the current_tenant yourself, requires you to declare set_current_tenant_through_filter at the top of your application_controller to tell acts_as_tenant that you are going to use a before_action to setup the current tenant. Next you should actually setup that before_action to fetch the current tenant and pass it to acts_as_tenant by using set_current_tenant(current_tenant) in the before_action.

If you are setting the tenant in a specific controller (except application_controller), it should to be included AT THE TOP of the file.

class MembersController < ActionController::Base
  set_current_tenant_through_filter
  before_action :set_tenant
  before_action :set_member, only: [:show, :edit, :update, :destroy]

  def set_tenant
    set_current_tenant(current_user.account)
  end
end

This allows the tenant to be set before any other code runs so everything is within the current tenant.

Setting the current tenant for a block

ActsAsTenant.with_tenant(current_account) do
  # Current tenant is set for all code in this block
end

This approach is useful when running background processes for a specified tenant. For example, by putting this in your worker's run method, any code in this block will be scoped to the current tenant. All methods that set the current tenant are thread safe.

Note: If the current tenant is not set by one of these methods, Acts_as_tenant will be unable to apply the proper scope to your models. So make sure you use one of the two methods to tell acts_as_tenant about the current tenant.

Disabling tenant checking for a block

ActsAsTenant.without_tenant do
  # Tenant checking is disabled for all code in this block
end

This is useful in shared routes such as admin panels or internal dashboards when require_tenant option is enabled throughout the app.

Allowing tenant updating for a block

ActsAsTenant.with_mutable_tenant do
  # Tenant updating is enabled for all code in this block
end

This will allow you to change the tenant of a model. This feature is useful for admin screens, where it is ok to allow certain users to change the tenant on existing models in specific cases.

Require tenant to be set always

If you want to require the tenant to be set at all times, you can configure acts_as_tenant to raise an error when a query is made without a tenant available. See below under configuration options.

Scoping your models

class AddAccountToProjects < ActiveRecord::Migration
  def up
    add_column :projects, :account_id, :integer
    add_index  :projects, :account_id
  end
end

class Project < ActiveRecord::Base
  acts_as_tenant(:account)
end

acts_as_tenant requires each scoped model to have a column in its schema linking it to a tenant. Adding acts_as_tenant to your model declaration will scope that model to the current tenant BUT ONLY if a current tenant has been set.

Some examples to illustrate this behavior:

# This manually sets the current tenant for testing purposes. In your app this is handled by the gem.
ActsAsTenant.current_tenant = Account.find(3)

# All searches are scoped by the tenant, the following searches will only return objects
# where account_id == 3
Project.all =>  # all projects with account_id => 3
Project.tasks.all #  => all tasks with account_id => 3

# New objects are scoped to the current tenant
@project = Project.new(:name => 'big project')    # => <#Project id: nil, name: 'big project', :account_id: 3>

# It will not allow the creation of objects outside the current_tenant scope
@project.account_id = 2
@project.save                                     # => false

# It will not allow association with objects outside the current tenant scope
# Assuming the Project with ID: 2 does not belong to Account with ID: 3
@task = Task.new  # => <#Task id: nil, name: nil, project_id: nil, :account_id: 3>

Acts_as_tenant uses Rails' default_scope method to scope models. Rails 3.1 changed the way default_scope works in a good way. A user defined default_scope should integrate seamlessly with the one added by acts_as_tenant.

Because the scoping comes from default_scope, queries built from the model are scoped when a tenant is set. This includes update_all, delete_all and destroy_all, and insert_all and upsert_all set the current tenant. These are not scoped or checked:

  • Any query when no tenant is set, unless require_tenant is enabled
  • Queries that remove the default scope, such as unscoped
  • Raw SQL, such as find_by_sql, connection.execute and exec_query
  • update_column and update_columns, which skip the check that prevents changing a record's tenant

New records are assigned the current tenant unless they already have one. A record assigned to a different tenant fails validation, so to create records for another tenant, wrap them in ActsAsTenant.with_tenant(other_tenant) { ... }.

belongs_to associations are validated against the current tenant regardless of whether they are declared before or after acts_as_tenant.

When no tenant is set (for example in an admin panel or inside without_tenant), belongs_to associations to tenanted models are validated to belong to the same tenant as the record. Associated records without a tenant, such as global records, are allowed.

Validating attribute uniqueness

If you need to validate for uniqueness, chances are that you want to scope this validation to a tenant. You can do so by using:

validates_uniqueness_to_tenant :name, :email

All options available to Rails' own validates_uniqueness_of are also available to this method.

Custom foreign_key

You can explicitly specifiy a foreign_key for AaT to use should the key differ from the default:

acts_as_tenant(:account, :foreign_key => 'accountID') # by default AaT expects account_id

Custom primary_key

You can also explicitly specifiy a primary_key for AaT to use should the key differ from the default:

acts_as_tenant(:account, :primary_key => 'primaryID') # by default AaT expects id

Has and belongs to many

You can scope a model that is part of a HABTM relationship by using the through option.

class Organisation < ActiveRecord::Base
  has_many :organisations_users
  has_many :users, through: :organisations_users
end

class User < ActiveRecord::Base
  has_many :organisations_users
  acts_as_tenant :organisation, through: :organisations_users
end

class OrganisationsUser < ActiveRecord::Base
  belongs_to :user
  acts_as_tenant :organisation
end

Configuration options

An initializer can be created to control (currently one) option in ActsAsTenant. Defaults are shown below with sample overrides following. In config/initializers/acts_as_tenant.rb:

ActsAsTenant.configure do |config|
  config.require_tenant = false # true

  # Customize the query for loading the tenant in background jobs
  config.job_scope = ->{ all }
end
  • config.require_tenant when set to true will raise an ActsAsTenant::Errors::NoTenantSet error whenever a query is made without a tenant set.

config.require_tenant can also be assigned a lambda that is evaluated at run time. The lambda doesn't have access to the request, so store what it needs in CurrentAttributes, which Rails resets after each request. For example, to not require a tenant under /admin:

# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :request
end

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action { Current.request = request }
end

# config/initializers/acts_as_tenant.rb
ActsAsTenant.configure do |config|
  config.require_tenant = lambda do
    !Current.request&.path&.start_with?("/admin/")
  end
end

Outside of a request, such as in jobs or the console, Current.request is nil and a tenant is required.

The lambda can also optionally receive the relation being queried as an argument. This is useful for finer control over tenant requirements.

For example, if you wanted to require the tenant for every model except User, you could do the following:

ActsAsTenant.configure do |config|
  config.require_tenant = lambda do |relation|
    relation.klass.name != "User"
  end
end

ActsAsTenant.should_require_tenant? is used to determine if a tenant is required in the current context, either by evaluating the lambda provided, or by returning the boolean value assigned to config.require_tenant. It accepts the relation as an optional argument, which is passed to the lambda as nil when omitted.

With require_tenant enabled, ActiveStorage can raise NoTenantSet when it generates a preview or variant. After processing, Rails touches the attachment records, which loads their tenant-scoped models, and ActiveStorage requests don't set a tenant. Blobs are looked up by signed IDs, so it's safe to run these requests without a tenant:

# config/initializers/acts_as_tenant.rb
Rails.application.config.to_prepare do
  ActiveStorage::Representations::BaseController.prepend_around_action do |_controller, action|
    ActsAsTenant.without_tenant(&action)
  end
end

When using config.require_tenant alongside the rails console, a nice quality of life tweak is to set the tenant in the console session in your initializer script. For example in config/initializers/acts_as_tenant.rb:

Rails.application.configure do
  if Rails.env.development? && defined?(Rails::Console)
    # set the current_tenant during console startup and after calling reload!
    # note: reload! calls the to_prepare callback twice
    ActiveSupport::Reloader.to_prepare do
      puts ">>> Setting ActsAsTenant.current_tenant = Account.first"
      ActsAsTenant.current_tenant = Account.first
    end
  end
end

Tenant change hook

config.tenant_change_hook is called with the new tenant whenever current_tenant is set, and with nil when Rails resets it at the end of a request or job. For example, to use Postgres row-level security:

ActsAsTenant.configure do |config|
  config.tenant_change_hook = lambda do |tenant|
    if tenant
      ActiveRecord::Base.connection.execute(ActiveRecord::Base.sanitize_sql_array(["SET rls.account_id = ?", tenant.id]))
    else
      ActiveRecord::Base.connection.execute("RESET rls.account_id")
    end
  end
end

Always handle nil, otherwise the setting stays on the database connection and the next request using it runs as the previous tenant. The hook isn't called for default_tenant or test_tenant.

belongs_to options

acts_as_tenant :account includes the belongs_to relationship. So when using acts_as_tenant on a model, do not add belongs_to :account alongside acts_as_tenant :account:

class User < ActiveRecord::Base
  acts_as_tenant(:account) # YES
  belongs_to :account # REDUNDANT
end

You can add the following belongs_to options to acts_as_tenant: :foreign_key, :class_name, :inverse_of, :optional, :primary_key, :counter_cache, :polymorphic, :touch

Example: acts_as_tenant(:account, counter_cache: true)

ActionCable

The controller helpers aren't available in ActionCable channels. Instead, find the tenant when the connection is made and set it around each command (subscribe, unsubscribe, and channel actions) with around_command (Rails 7.1+):

module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_account
    around_command :set_current_tenant

    def connect
      self.current_account = Account.find_by(subdomain: request.subdomain) || reject_unauthorized_connection
    end

    private

    def set_current_tenant(&block)
      ActsAsTenant.with_tenant(current_account, &block)
    end
  end
end

Setting the tenant in a channel's before_subscribe won't work, because Rails resets current_tenant after the subscription is created and before each channel action runs. Blocks passed to stream_from also run outside of commands, so wrap their contents in ActsAsTenant.with_tenant(current_account) { ... } if they query tenant-scoped models.

Background Processing libraries

ActsAsTenant supports

  • ActiveJob - ActsAsTenant will automatically save the current tenant in ActiveJob arguments and set it when the job runs.

  • Sidekiq Add the following code to config/initializers/acts_as_tenant.rb:

require 'acts_as_tenant/sidekiq'

The tenant is set while the job runs, but not in sidekiq_retries_exhausted or death handlers, because Sidekiq calls them outside the middleware. The tenant is saved in the job hash, so you can set it yourself:

sidekiq_retries_exhausted do |job, exception|
  tenant = Account.find_by(id: job.dig("acts_as_tenant", "id"))

  ActsAsTenant.with_tenant(tenant) do
    # ...
  end
end

Testing

If you set the current_tenant in your tests, make sure to clean up the tenant after each test by calling ActsAsTenant.current_tenant = nil. Integration tests are more difficult: manually setting the current_tenant value will not survive across multiple requests, even if they take place within the same test. This can result in undesired boilerplate to set the desired tenant. Moreover, the efficacy of the test can be compromised because the set current_tenant value will carry over into the request-response cycle.

To address this issue, ActsAsTenant provides for a test_tenant value that can be set to allow for setup and post-request expectation testing. It should be used in conjunction with middleware that clears out this value while an integration test is processing. test_tenant is only intended for request/integration tests; in model and unit tests, set current_tenant or use with_tenant instead. A typical Rails and RSpec setup might look like:

# test.rb
require_dependency 'acts_as_tenant/test_tenant_middleware'

Rails.application.configure do
  config.middleware.use ActsAsTenant::TestTenantMiddleware
end
# spec_helper.rb
config.before(:suite) do |example|
  # Make the default tenant globally available to the tests
  $default_account = Account.create!
end

config.before(:each) do |example|
  if example.metadata[:type] == :request
    # Set the `test_tenant` value for integration tests
    ActsAsTenant.test_tenant = $default_account
  else
    # Otherwise just use current_tenant
    ActsAsTenant.current_tenant = $default_account
  end
end

config.after(:each) do |example|
  # Clear any tenancy that might have been set
  ActsAsTenant.current_tenant = nil
  ActsAsTenant.test_tenant = nil
end

Jobs performed inline

Rails resets CurrentAttributes, including current_tenant, after it performs a job inline (rails/rails#49227). The job runs with the tenant it was enqueued with, but the test loses its tenant afterwards when the job is performed inside a perform_enqueued_jobs block or with the :inline adapter. Call perform_enqueued_jobs without a block instead:

ActsAsTenant.current_tenant = account
ProcessOrderJob.perform_later(order)
perform_enqueued_jobs

expect(ActsAsTenant.current_tenant).to eq(account)

perform_now also keeps the tenant.

Bug reports & suggested improvements

If you have found a bug or want to suggest an improvement, please use our issue tracked at:

github.com/ErwinM/acts_as_tenant/issues

If you want to contribute, fork the project, code your improvements and make a pull request on Github. When doing so, please don't forget to add tests. If your contribution is fixing a bug it would be perfect if you could also submit a failing test, illustrating the issue.

Contributing to this gem

We use the Appraisal gem to run tests against supported versions of Rails to test for compatibility against them all. StandardRb also helps keep code formatted cleanly.

  1. Fork the repo
  2. Make changes
  3. Run test suite with bundle exec appraisal
  4. Run bundle exec standardrb to standardize code formatting
  5. Submit a PR

Author & Credits

acts_as_tenant is written by Erwin Matthijssen & Chris Oliver.

This gem was inspired by Ryan Sonnek's Multitenant gem and its use of default_scope.

License

Copyright (c) 2011 Erwin Matthijssen, released under the MIT license

About

Easy multi-tenancy for Rails in a shared database setup.

Resources

Stars

1.7k stars

Watchers

38 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages