Open-source project
ErwinM/acts_as_tenant avatar
ErwinM/acts_as_tenant

acts_as_tenant: row-level Rails scoping that fails closed when the tenant is missing

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

1,713 stars270 forksRubyMIT

At a glance

What is it?
acts_as_tenant scopes Rails models to a current tenant set per request, per block or per subdomain, and it can raise instead of querying unfiltered when no tenant is set. That last option is the difference between a comfortable setup and a safe one, and it is a configuration flag.
Who is it for?
Adopt acts_as_tenant when your tenants share a database and you want Rails-native scoping rather than a schema-per-tenant arrangement, and turn on the require_tenant option from day one so a missing tenant raises instead of returning every tenant's rows.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 16 days ago.
What is it written in?
Mainly Ruby, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on November 6, 2026, and from our analysis. They are not legal advice.

From daily snapshots

Star history

  • ErwinM/acts_as_tenant1,713 ★ · +3
Star history
Repository2026-10-162026-11-06
ErwinM/acts_as_tenant1.7k1.7k

3 Stars in the last 21 days, measured from the daily GitHub snapshot. Compare with other projects

Editorial analysis

Shared database, tenant_id on every model that matters

acts_as_tenant adds the ability to scope models to a tenant in a Ruby on Rails application. Tenants are represented by a tenant model such as Account, and the gem helps set the current tenant on each request and ensures tenant models stay scoped when viewing, searching and creating.

The strategy is row-level multitenancy, and the README is explicit about the alternative it is rejecting. With row-level, each model carries a tenant ID column, which makes filtering records per tenant ordinary column and index work. With schema multitenancy, each tenant gets its own database schema with its own copy of the tables, migrations must run against every tenant, and scaling gets harder as tenants are added. The README points at the Apartment gem as the schema approach; acts_as_tenant is the row-level one.

That choice is the whole architecture. It is cheap to add a tenant and cheap to migrate, and it means every query in your app now depends on a piece of global state being set correctly.

The tenant_id column and its index are yours to add

Scoping a model takes two things: the column, and the declaration. The migration is ordinary, and the index is not optional in practice:

ruby
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

The `acts_as_tenant(:account)` call names the association the scope keys on. Since every scoped query now filters on that column, the index is what keeps tenant filtering from turning into a table scan as the table grows.

Installation is a single Gemfile line:

ruby
gem 'acts_as_tenant'

The gem is MIT licensed and the repository carries a MIT-LICENSE file, an Appraisals file, gemfiles/ for testing against multiple dependency sets, and a spec/ directory, which is the standard shape for a gem that has to work across a range of Rails versions.

Three ways to set the tenant, and each has a failure mode

The README frames the whole setup as two steps: set the current tenant, and scope the models. The first step has three routes.

By subdomain is the declarative one, and it is one class macro:

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

That tells the gem to use the last subdomain to identify the tenant, that tenants are Account records, and that the Account model has a subdomain column to look up. If you omit the parameters they default to those values. By default the last subdomain is used, and `subdomain_lookup: :first` switches to the first, which matters for a domain like `admin.example.com` where position carries meaning.

There is a fallback variant, `set_current_tenant_by_subdomain_or_domain(:account, :subdomain, :domain)`, which checks for a subdomain and falls back to the domain. That is the one to reach for when the same application serves tenant subdomains and a bare apex domain.

The manual route is a filter, and it is the most explicit:

ruby
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

Declaring `set_current_tenant_through_filter` is what tells acts_as_tenant you will supply the tenant yourself. Order matters more than it looks: when you do this in a specific controller rather than the application controller, the README says the declaration has to be at the top of the file, so the tenant is set before any other code runs.

with_tenant and without_tenant are the block forms background jobs need

Not every code path is a controller. Background workers have no request, so the block form exists:

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

The README suggests this for the run method of a worker, so everything the job touches is scoped. It also states that all methods that set the current tenant are thread safe, which matters because a worker pool running several jobs in different threads is exactly where a global current-tenant variable would otherwise become a race.

The inverse block turns scoping off deliberately:

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

That is for shared routes such as an admin panel or an internal dashboard when require_tenant is enabled app-wide, since without it those routes would raise for want of a tenant.

And a third block relaxes a different rule, allowing a model's tenant to be changed:

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

The README positions this for admin screens where it is acceptable for certain users to move an existing record to another tenant. It is the one block here that relaxes a protection rather than adding one, so it deserves the same scrutiny as the code it wraps.

If no tenant is set, nothing is scoped, and that is the default trap

The README states the failure mode in a note that deserves more attention than its placement: if the current tenant is not set by one of the documented methods, acts_as_tenant cannot apply the proper scope to your models. In other words, an unset tenant is not an error by default, it is an unscoped query.

What that means in practice is that a controller action reached without a tenant filter returns rows across every tenant. That is the classic multi-tenant data leak, and the gem's protection is a configuration flag rather than a default.

The option is to require the tenant to be set at all times, which makes acts_as_tenant raise an error when a query runs without a tenant available. Combined with `without_tenant` for the routes that legitimately have no tenant, this inverts the default from fail-open to fail-closed. Any application running this gem should be configured that way from the first commit, because retro-fitting it turns every previously silent unscoped path into an exception.

The README also lists protection against various types of nastiness directed at circumventing the tenant scoping, which is the other half of the story, and the walkthrough video linked from the repository is the fastest way to see the whole flow in one sitting.

Uniqueness needs its own validator, scoped to the tenant

Beyond scoping, the gem adds a method to validate uniqueness within a tenant, `validates_uniqueness_to_tenant`. Standard Rails uniqueness validation is global across the table, so without something like this two tenants cannot both have a Project called the same thing.

That constraint is also a correctness issue rather than a nicety, because the database index backing a global uniqueness check would reject a legitimate second tenant. Scoping the validation to the tenant matches both the data model and the index you would want.

The gem also sets up a helper method containing the current tenant, so application code can ask what it is operating on rather than reaching into instance state.

A 2.x line moving fast, with three releases in ten days

The release history is dense. v2.0.2 was tagged on 2026-09-28, v2.1.0 on 2026-10-06 and v2.2.0 on 2026-10-07, and the last push to the repository was on 2026-10-16.

Three releases inside ten days across a major version boundary is worth planning for. A 2.0 line usually carries breaking changes to the scoping API, and a rapid patch sequence on top of it means the notes in CHANGELOG.md matter more than usual. Pin the version in your Gemfile rather than accepting whatever resolves, and read the changelog between 2.0.2 and whichever 2.x you adopt.

The repository supports multi-Rails testing through the Appraisals file and gemfiles/ directory, with Rakefile and spec/ alongside, and a.standard.yml for the Ruby formatter. The project also documents its origin in a blog post included in the repository, which is a reasonable place to find the reasoning behind the design if you want more than the README.

Editorial conclusion

Adopt acts_as_tenant when your tenants share a database and you want Rails-native scoping rather than a schema-per-tenant arrangement, and turn on the require_tenant option from day one so a missing tenant raises instead of returning every tenant's rows. Do not adopt it for workloads where you expect thousands of tenants, since the README notes that schema approaches make migrations harder to run as tenants multiply and row-level keeps that cost down but concentrates it in a tenant_id column and index. Verify first which models are actually declared acts_as_tenant, because a model that is not declared is not scoped and will not raise.

Frequently asked questions

How do I add acts_as_tenant to a Rails application?

Add `gem 'acts_as_tenant'` to the Gemfile, add a tenant id column with an index to each scoped table, and declare `acts_as_tenant(:account)` on the model, for example a migration that runs `add_column :projects, :account_id, :integer` followed by `add_index :projects, :account_id`.

How do I set the current tenant in acts_as_tenant?

Three ways: `set_current_tenant_by_subdomain(:account, :subdomain)` for subdomain lookup, `set_current_tenant_by_subdomain_or_domain` to fall back to the domain, or `set_current_tenant_through_filter` with a before_action calling `set_current_tenant`. For background work use `ActsAsTenant.with_tenant(current_account) do ... end`.

What happens if no tenant is set in acts_as_tenant?

The README warns that acts_as_tenant cannot apply the proper scope to your models if the current tenant is not set, which means unscoped queries. To avoid that, configure acts_as_tenant to require the tenant to be set at all times so a query without one raises an error, and use `ActsAsTenant.without_tenant` for routes that legitimately have no tenant.

How does acts_as_tenant differ from the Apartment gem?

acts_as_tenant uses row-level multitenancy, where each model has a tenant id column and queries filter on it. Apartment uses schema multitenancy, where each tenant has its own database schema, migrations must run against every tenant, and it is harder to scale as tenants are added.

How do I allow an admin screen to change a record's tenant?

Wrap the operation in `ActsAsTenant.with_mutable_tenant do ... end`. The README describes this as allowing a model's tenant to be changed, useful for admin screens where certain users may move an existing model to another tenant in specific cases.

Official sources

  1. ErwinM/acts_as_tenant on GitHub
  2. Issues
  3. License: MIT
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/erwinm-acts-as-tenant.svg)](https://hysenlabs.com/projects/erwinm-acts-as-tenant)

Star history chart for your README

markdown
[![Star history](https://hysenlabs.com/chart/erwinm-acts-as-tenant.svg)](https://hysenlabs.com/projects/erwinm-acts-as-tenant)

Was this useful?