Project

tenant_kit

0.0
The project is in a healthy, maintained state
TenantKit makes a Rails app multi-tenant using the row-level / shared-schema strategy: one database, a tenant_id foreign key on owned tables, automatic query scoping via ActiveSupport::CurrentAttributes, auto-assignment of the current tenant, and first-class tenant propagation into ActiveJob background jobs. It is strict by default: querying a tenant-scoped model with no tenant set raises rather than leaking another tenant's data.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

 Project Readme
TenantKit — row-level multi-tenancy for Rails, strict by default

One database. A tenant foreign key on every owned table. Automatic query scoping. No cross-tenant leaks — not even in your jobs.

Gem Version Downloads CI Ruby Rails License: MIT


class Project < ApplicationRecord
  belongs_to_tenant                      # ← scoped, auto-assigned, validated
  validates_uniqueness_to_tenant :slug   # ← unique *per tenant*
end

TenantKit.with_tenant(acme) { Project.count }   # → only Acme's projects
Project.count                                   # → raises 💥 NoTenantSet (no silent leaks)

✨ Why TenantKit

Most Rails SaaS apps are multi-tenant. The row-level approach — a shared schema with a tenant foreign key — is the simplest to operate and works cleanly with every Rails default (Solid Queue / Cache / Cable, standard migrations, connection pooling). TenantKit gives you that, with safety rails most tenancy gems leave off:

What you get
🔒 Strict by default Queries with no current tenant raise instead of silently returning another tenant's rows.
⚙️ Automatic scoping belongs_to_tenant scopes every read, auto-assigns the tenant on create, and validates its presence.
📨 Job propagation The current tenant rides into ActiveJob via GlobalID and is restored around perform — survives Solid Queue.
🧭 Request resolution One-liners to resolve the tenant by subdomain, domain, header, or your own filter.
🔁 Unique-per-tenant validates_uniqueness_to_tenant folds the tenant into the uniqueness scope.
🚪 Loud escape hatch TenantKit.without_tenant { } — explicit, greppable, auditable in review.
🪶 Featherweight Zero runtime deps beyond Rails. Built on ActiveSupport::CurrentAttributes — never a raw thread-local.

📖 Table of contents

  • Requirements
  • Installation
  • Quick start
  • The current tenant
  • Controller resolution
  • Background jobs
  • Configuration
  • Testing
  • Gotchas
  • Why default_scope?
  • Why row-level?
  • Roadmap
  • Development & contributing

📋 Requirements

  • Ruby >= 3.3
  • Rails >= 7.2 (primary target: 8.x)

💎 Installation

# Gemfile
gem "tenant_kit"
bundle install
rails g tenant_kit:install

The installer writes config/initializers/tenant_kit.rb and, unless one already exists, scaffolds the tenant model (Account) plus its migration. Pass --skip-tenant-model if you already have one.

🚀 Quick start

1. Mark your owned models. The tenant model itself does not call belongs_to_tenant.

# app/models/account.rb — the tenant
class Account < ApplicationRecord
end

# app/models/project.rb — owned by a tenant
class Project < ApplicationRecord
  belongs_to_tenant
  validates_uniqueness_to_tenant :slug
end

2. Add the tenant column with the migration generator:

rails g tenant_kit:migration Project
# => add_reference :projects, :account, null: false, foreign_key: true, index: true

3. Resolve the tenant per request:

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  set_current_tenant_by_subdomain(:account, :subdomain)
end

That's it. Project.all now returns only the current tenant's projects, and Project.create!(name: "X") auto-assigns the current tenant. 🎉

🧭 The current tenant

TenantKit::Current.tenant            # => #<Account ...> or nil

TenantKit.with_tenant(account) do
  Project.count                      # scoped to `account`
end

TenantKit.without_tenant do
  Project.count                      # every tenant's projects
end

with_tenant and without_tenant always restore the previous state — even when the block raises.

🎯 Controller resolution

Auto-included into ActionController::Base and ActionController::API.

set_current_tenant_by_subdomain(:account, :subdomain)  # tenant.subdomain == request.subdomain
set_current_tenant_by_domain(:account, :domain)        # tenant.domain    == request.host
set_current_tenant_by_header("X-Tenant-Id")            # for APIs (matches tenant.id)

# …or fully custom:
set_current_tenant_through_filter
before_action :find_tenant

def find_tenant
  self.current_tenant = Account.find_by!(slug: params[:account_slug])
end

current_tenant is also exposed as a view helper.

📨 Background jobs

With config.propagate_to_jobs on (the default), every ActiveJob captures the current tenant at enqueue time and re-establishes it around perform. The tenant's GlobalID is folded into the job's serialized payload, so it survives any queue adapter — Solid Queue included.

TenantKit.with_tenant(account) do
  ReportJob.perform_later      # runs later, in another process, still scoped to `account`
end

Set config.raise_on_missing_job_tenant = true to make a job that was enqueued with no tenant raise at perform instead of running unscoped.

⚙️ Configuration

# config/initializers/tenant_kit.rb
TenantKit.configure do |config|
  config.tenant_class                = "Account"     # the tenant model
  config.tenant_column               = "account_id"  # FK on owned tables
  config.require_tenant              = true          # strict: raise when unscoped
  config.propagate_to_jobs           = true          # carry tenant into ActiveJob
  config.raise_on_missing_job_tenant = false         # job enqueued with no tenant
end

🧪 Testing

# spec/rails_helper.rb
require "tenant_kit/testing"

RSpec.configure do |config|
  config.include TenantKit::Testing
  config.after { TenantKit::Current.reset }
end
it "scopes to the tenant" do
  as_tenant(account) do
    expect(Project.count).to eq(0)
  end
end

⚠️ Gotchas (read this)

These are the sharp edges of any row-level tenancy setup. TenantKit makes them safe — as long as you know they exist.

  • unscoped bypasses tenant scoping. Avoid it on owned models unless you mean it. Reach for without_tenant instead — explicit and greppable.
  • Action Cable / Turbo Streams are not auto-scoped. Put the tenant in stream names — stream_for [current_account, record] — so broadcasts never cross tenants.
  • Console & seeds have no request, so no current tenant. Wrap work in TenantKit.with_tenant(account) { … } or TenantKit.without_tenant { }.
  • Unique constraints must include the tenant column at the DB level: add_index :projects, [:account_id, :slug], unique: true. Pair it with validates_uniqueness_to_tenant :slug.
  • Lead composite indexes with the tenant column: add_index :projects, [:account_id, :status].

🧷 Why default_scope?

default_scope is rightly treated as an anti-pattern in ordinary application code: it applies to every query, leaks into associations and joins, and can seed attribute defaults on .new. So why does TenantKit use it?

Because tenancy is a security boundary, not a convenience filter, and the two options fail in opposite directions:

  • Explicit scoping fails open. If a developer forgets to call the scope, the query silently returns every tenant's rows. The cost of a mistake is a cross-tenant data leak.
  • default_scope fails closed. If a developer forgets, the query raises NoTenantSet instead of leaking. The cost of a mistake is a loud error in development, not a quiet breach in production.

For the thing being protected — one tenant seeing another's data — failing closed is the safer default. It's the same call acts_as_tenant makes, for the same reason.

TenantKit also defuses the two edges that make default_scope painful:

  • No surprising .new defaults. The tenant is assigned in a before_validation, not by the scope, and .new is wrapped so a built record never inherits scope values. Project.new behaves exactly as you'd expect.
  • No unscoped sledgehammer. Instead of unscoped (which drops every condition on the model), you reach for without_tenant { }, which lifts only the tenant condition — explicit and greppable.

If you prefer opt-in scoping at the call site, an explicit mode is on the roadmap. Open an issue if you want it.

🤔 Why row-level (and not the others)?

Strategy Isolation Ops cost Verdict
Row-level (shared schema) Good (with discipline) 🟢 Low — one DB, one migration path Chosen
Schema-per-tenant Strong 🟠 High — migrations across N schemas, connection switching ❌ Not in v1
Database-per-tenant Strongest 🔴 Highest — provision + migrate N databases ❌ Not in v1

Row-level works cleanly with every Rails default and has exactly one migration path. With strict scoping and database constraints it is safe enough for the overwhelming majority of B2B SaaS.

🗺️ Roadmap

Not in v1 — tracked for future releases:

  • Optional explicit scoping mode (opt in at the call site, no default_scope)
  • Automatic Action Cable stream scoping
  • Solid Cache tenant-aware caching helpers
  • Schema-per-tenant / database-per-tenant modes

🛠️ Development

bin/setup           # or: bundle install
bundle exec rspec   # run the suite against spec/dummy
bundle exec rubocop # lint

🤝 Contributing

Bug reports and pull requests are welcome at github.com/wintan1418/tenant_kit. Open an issue to discuss anything substantial before you build it.

📄 License

Released under the MIT License.


⬆ back to top

Built with ❤️ for Rails SaaS teams.