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 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 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:

  • 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.