tenant_kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: baea79f8ebb8b315e622d9689827ea30ca07aaaf8cd2469b70d251536e522d78
4
+ data.tar.gz: 10a2c85992094410f5528e34f85a760665ab472e36e31d9484a1c1b17d9e27c1
5
+ SHA512:
6
+ metadata.gz: 2d115221d0aeb8b976ecf44b5fbb48554f9a4ed6654b515c0536058d586533824d08052419dbd56a98865b2510cd4a5b6d2457c5a5da0c9ecc5d83bb8d612469
7
+ data.tar.gz: 617c84f92e8475f428fe49ca7f41121e7bfb0eee030825c2641873eb75e8c91876b53aa34ea5f0d928856f3355d072b494620f3d048722bcc1b6dbe7a48fc2bc
data/CHANGELOG.md ADDED
@@ -0,0 +1,32 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is based on
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-07-01
10
+
11
+ Initial release: row-level (shared-schema) multi-tenancy for Rails.
12
+
13
+ ### Added
14
+ - `belongs_to_tenant` macro: declares the tenant association, scopes all reads
15
+ to the current tenant via `default_scope`, auto-assigns the tenant on create
16
+ through `before_validation`, and validates tenant presence.
17
+ - `TenantKit::Current` (`ActiveSupport::CurrentAttributes`) as the request-scoped
18
+ holder of the current tenant.
19
+ - `TenantKit.with_tenant` / `TenantKit.without_tenant` scoping controls.
20
+ - Strict mode (`config.require_tenant`, on by default): querying a tenant-scoped
21
+ model with no current tenant raises `TenantKit::NoTenantSet`.
22
+ - `validates_uniqueness_to_tenant`: uniqueness scoped to the tenant column.
23
+ - Controller resolution helpers: `set_current_tenant_by_subdomain`,
24
+ `set_current_tenant_by_domain`, `set_current_tenant_by_header`, and
25
+ `set_current_tenant_through_filter`.
26
+ - Background-job tenant propagation (`config.propagate_to_jobs`): the tenant's
27
+ GlobalID is captured at enqueue and re-established around `perform`, surviving
28
+ ActiveJob serialization (Solid Queue included).
29
+ - Generators: `tenant_kit:install` and `tenant_kit:migration`.
30
+
31
+ [Unreleased]: https://github.com/wintan1418/tenant_kit/compare/v0.1.0...HEAD
32
+ [0.1.0]: https://github.com/wintan1418/tenant_kit/releases/tag/v0.1.0
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2026 wintan1418
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,214 @@
1
+ # TenantKit
2
+
3
+ [![CI](https://github.com/wintan1418/tenant_kit/actions/workflows/ci.yml/badge.svg)](https://github.com/wintan1418/tenant_kit/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ Row-level (shared-schema) multi-tenancy for Rails. One database, a tenant
7
+ foreign key on every owned table, automatic query scoping, and background-job
8
+ tenant propagation — built on `ActiveSupport::CurrentAttributes`, **strict by
9
+ default**.
10
+
11
+ ## Why
12
+
13
+ Most Rails SaaS apps are multi-tenant. The row-level approach — a shared schema
14
+ with a tenant foreign key — is the simplest to operate and works cleanly with
15
+ every Rails default (Solid Queue / Cache / Cable, standard migrations,
16
+ connection pooling). TenantKit gives you that with safety rails:
17
+
18
+ - It **refuses** to run tenant-scoped queries when no tenant is set, rather than
19
+ silently returning another tenant's data.
20
+ - It **carries the current tenant into background jobs**, so async work can't
21
+ leak across tenants.
22
+ - It keeps the escape hatch **loud and greppable**: `TenantKit.without_tenant`.
23
+
24
+ Row-level isn't the only strategy — see [Why row-level](#why-row-level-and-not-the-others).
25
+
26
+ ## Requirements
27
+
28
+ - Ruby `>= 3.3`
29
+ - Rails `>= 7.2` (primary target: 8.x)
30
+
31
+ ## Installation
32
+
33
+ ```ruby
34
+ # Gemfile
35
+ gem "tenant_kit"
36
+ ```
37
+
38
+ ```bash
39
+ bundle install
40
+ rails g tenant_kit:install
41
+ ```
42
+
43
+ The installer writes `config/initializers/tenant_kit.rb` and, unless it already
44
+ exists, scaffolds the tenant model (`Account`) plus its migration. Pass
45
+ `--skip-tenant-model` if you already have one.
46
+
47
+ ## Quick start
48
+
49
+ ```ruby
50
+ # app/models/account.rb — the tenant model (does NOT call belongs_to_tenant)
51
+ class Account < ApplicationRecord
52
+ end
53
+
54
+ # app/models/project.rb — an owned model
55
+ class Project < ApplicationRecord
56
+ belongs_to_tenant
57
+ validates_uniqueness_to_tenant :slug
58
+ end
59
+ ```
60
+
61
+ Add the tenant column to owned tables with the migration generator:
62
+
63
+ ```bash
64
+ rails g tenant_kit:migration Project
65
+ # => db/migrate/XXXX_add_account_to_projects.rb
66
+ # add_reference :projects, :account, null: false, foreign_key: true, index: true
67
+ ```
68
+
69
+ Resolve the tenant per request in your controller:
70
+
71
+ ```ruby
72
+ # app/controllers/application_controller.rb
73
+ class ApplicationController < ActionController::Base
74
+ set_current_tenant_by_subdomain(:account, :subdomain)
75
+ end
76
+ ```
77
+
78
+ Now `Project.all` returns only the current tenant's projects, and
79
+ `Project.create!(name: "X")` auto-assigns the current tenant.
80
+
81
+ ## The current tenant
82
+
83
+ ```ruby
84
+ TenantKit::Current.tenant # => #<Account ...> or nil
85
+
86
+ TenantKit.with_tenant(account) do
87
+ Project.count # scoped to `account`
88
+ end
89
+
90
+ TenantKit.without_tenant do
91
+ Project.count # every tenant's projects
92
+ end
93
+ ```
94
+
95
+ `with_tenant` and `without_tenant` always restore the previous state, even when
96
+ the block raises.
97
+
98
+ ## Controller resolution helpers
99
+
100
+ Auto-included into `ActionController::Base` and `ActionController::API`.
101
+
102
+ ```ruby
103
+ set_current_tenant_by_subdomain(:account, :subdomain) # tenant.subdomain == request.subdomain
104
+ set_current_tenant_by_domain(:account, :domain) # tenant.domain == request.host
105
+ set_current_tenant_by_header("X-Tenant-Id") # for APIs (matches tenant.id)
106
+
107
+ # ...or fully custom:
108
+ set_current_tenant_through_filter
109
+ before_action :find_tenant
110
+ def find_tenant
111
+ self.current_tenant = Account.find_by!(slug: params[:account_slug])
112
+ end
113
+ ```
114
+
115
+ `current_tenant` is also exposed as a view helper.
116
+
117
+ ## Background jobs
118
+
119
+ When `config.propagate_to_jobs` is on (the default), every `ActiveJob` captures
120
+ the current tenant at enqueue time and re-establishes it around `perform`. It
121
+ works with any queue adapter — the tenant's GlobalID is folded into the job's
122
+ serialized payload, so it survives Solid Queue too.
123
+
124
+ ```ruby
125
+ TenantKit.with_tenant(account) do
126
+ ReportJob.perform_later # runs later, still scoped to `account`
127
+ end
128
+ ```
129
+
130
+ Set `config.raise_on_missing_job_tenant = true` to make a job that was enqueued
131
+ with no tenant raise at perform instead of running unscoped.
132
+
133
+ ## Configuration
134
+
135
+ ```ruby
136
+ # config/initializers/tenant_kit.rb
137
+ TenantKit.configure do |config|
138
+ config.tenant_class = "Account" # the tenant model
139
+ config.tenant_column = "account_id" # FK on owned tables
140
+ config.require_tenant = true # strict: raise when unscoped
141
+ config.propagate_to_jobs = true # carry tenant into ActiveJob
142
+ config.raise_on_missing_job_tenant = false # job enqueued with no tenant
143
+ end
144
+ ```
145
+
146
+ ## Testing
147
+
148
+ ```ruby
149
+ # spec/rails_helper.rb
150
+ require "tenant_kit/testing"
151
+
152
+ RSpec.configure do |config|
153
+ config.include TenantKit::Testing
154
+ config.after { TenantKit::Current.reset }
155
+ end
156
+ ```
157
+
158
+ ```ruby
159
+ it "scopes to the tenant" do
160
+ as_tenant(account) do
161
+ expect(Project.count).to eq(0)
162
+ end
163
+ end
164
+ ```
165
+
166
+ ## Gotchas (read this)
167
+
168
+ - **`unscoped` bypasses tenant scoping.** Avoid it on tenant-owned models unless
169
+ you mean it. Use `without_tenant` instead — explicit and greppable.
170
+ - **Action Cable / Turbo Streams are not auto-scoped.** Include the tenant in
171
+ stream names — `stream_for [current_account, record]` — so broadcasts never
172
+ cross tenants.
173
+ - **Console and seeds have no request, so no current tenant.** Wrap tenant work
174
+ in `TenantKit.with_tenant(account) { ... }` or `TenantKit.without_tenant { }`.
175
+ - **Unique constraints must include the tenant column** at the database level:
176
+ `add_index :projects, [:account_id, :slug], unique: true`. Pair it with
177
+ `validates_uniqueness_to_tenant :slug` in the model.
178
+ - **Lead composite indexes with the tenant column:**
179
+ `add_index :projects, [:account_id, :status]`.
180
+
181
+ ## Why row-level (and not the others)
182
+
183
+ | Strategy | Isolation | Ops cost | Verdict |
184
+ |---|---|---|---|
185
+ | **Row-level (shared schema)** | Good (with discipline) | Low — one DB, one migration path | **Chosen** |
186
+ | Schema-per-tenant | Strong | High — migrations across N schemas, connection switching | Not in v1 |
187
+ | Database-per-tenant | Strongest | Highest — provision + migrate N databases | Not in v1 |
188
+
189
+ Row-level works cleanly with every Rails default and has exactly one migration
190
+ path. With strict scoping and database constraints it is safe enough for the
191
+ overwhelming majority of B2B SaaS.
192
+
193
+ ## Roadmap (not in v1)
194
+
195
+ - Automatic Action Cable stream scoping
196
+ - Solid Cache tenant-aware caching helpers
197
+ - Schema-per-tenant / database-per-tenant modes
198
+
199
+ ## Development
200
+
201
+ ```bash
202
+ bin/setup # or: bundle install
203
+ bundle exec rspec # run the suite against spec/dummy
204
+ bundle exec rubocop
205
+ ```
206
+
207
+ ## Contributing
208
+
209
+ Bug reports and pull requests are welcome at
210
+ <https://github.com/wintan1418/tenant_kit>.
211
+
212
+ ## License
213
+
214
+ Released under the [MIT License](MIT-LICENSE).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require "bundler/setup"
2
+
3
+ require "bundler/gem_tasks"
4
+
5
+ require "rspec/core/rake_task"
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,69 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module TenantKit
5
+ module Generators
6
+ # Installs TenantKit into a host app: writes the initializer and, unless the
7
+ # tenant model already exists, scaffolds it plus a migration.
8
+ #
9
+ # rails g tenant_kit:install
10
+ class InstallGenerator < Rails::Generators::Base
11
+ include ActiveRecord::Generators::Migration
12
+
13
+ source_root File.expand_path("templates", __dir__)
14
+
15
+ class_option :skip_tenant_model, type: :boolean, default: false,
16
+ desc: "Do not create the tenant model or its migration"
17
+
18
+ # Writes config/initializers/tenant_kit.rb.
19
+ def create_initializer
20
+ template "initializer.rb.tt", "config/initializers/tenant_kit.rb"
21
+ end
22
+
23
+ # Creates the tenant model and its migration, unless it already exists or
24
+ # --skip-tenant-model was passed.
25
+ def create_tenant_model
26
+ return if options[:skip_tenant_model]
27
+ return if tenant_model_exists?
28
+
29
+ template "tenant_model.rb.tt", "app/models/#{tenant_singular}.rb"
30
+ migration_template "tenant_migration.rb.tt",
31
+ "db/migrate/create_#{tenant_plural}.rb"
32
+ end
33
+
34
+ # Prints post-install guidance.
35
+ def show_readme
36
+ say ""
37
+ say "TenantKit installed.", :green
38
+ say " 1. Review config/initializers/tenant_kit.rb"
39
+ say " 2. Add `belongs_to_tenant` to each tenant-owned model"
40
+ say " 3. Add the tenant column to owned tables: rails g tenant_kit:migration Project"
41
+ say " 4. Resolve the tenant in ApplicationController, e.g."
42
+ say " set_current_tenant_by_subdomain(:#{tenant_singular}, :subdomain)"
43
+ say ""
44
+ end
45
+
46
+ private
47
+
48
+ def tenant_class_name
49
+ TenantKit.config.tenant_class
50
+ end
51
+
52
+ def tenant_singular
53
+ tenant_class_name.underscore
54
+ end
55
+
56
+ def tenant_plural
57
+ tenant_singular.pluralize
58
+ end
59
+
60
+ def migration_version
61
+ "#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}"
62
+ end
63
+
64
+ def tenant_model_exists?
65
+ File.exist?(File.expand_path("app/models/#{tenant_singular}.rb", destination_root))
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,17 @@
1
+ TenantKit.configure do |config|
2
+ # The model that represents a tenant.
3
+ config.tenant_class = "<%= tenant_class_name %>"
4
+
5
+ # The foreign-key column on tenant-owned tables.
6
+ config.tenant_column = "<%= tenant_singular %>_id"
7
+
8
+ # Strict mode: raise TenantKit::NoTenantSet when a tenant-scoped query runs
9
+ # with no current tenant (and not inside TenantKit.without_tenant).
10
+ config.require_tenant = true
11
+
12
+ # Carry the current tenant into ActiveJob background jobs.
13
+ config.propagate_to_jobs = true
14
+
15
+ # Raise if a job is performed with no captured tenant.
16
+ config.raise_on_missing_job_tenant = false
17
+ end
@@ -0,0 +1,14 @@
1
+ class Create<%= tenant_plural.camelize %> < ActiveRecord::Migration[<%= migration_version %>]
2
+ def change
3
+ create_table :<%= tenant_plural %> do |t|
4
+ t.string :name, null: false
5
+ t.string :subdomain
6
+ t.string :domain
7
+
8
+ t.timestamps
9
+ end
10
+
11
+ add_index :<%= tenant_plural %>, :subdomain, unique: true
12
+ add_index :<%= tenant_plural %>, :domain, unique: true
13
+ end
14
+ end
@@ -0,0 +1,3 @@
1
+ class <%= tenant_class_name %> < ApplicationRecord
2
+ # The tenant model. It does NOT call belongs_to_tenant.
3
+ end
@@ -0,0 +1,39 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module TenantKit
5
+ module Generators
6
+ # Generates a migration adding the tenant reference to an owned table, with a
7
+ # tenant-leading index and a NOT NULL foreign key.
8
+ #
9
+ # rails g tenant_kit:migration Project
10
+ class MigrationGenerator < Rails::Generators::NamedBase
11
+ include ActiveRecord::Generators::Migration
12
+
13
+ source_root File.expand_path("templates", __dir__)
14
+
15
+ # Writes db/migrate/add_<tenant>_to_<table>.rb.
16
+ def create_migration_file
17
+ migration_template "add_tenant_reference.rb.tt",
18
+ "db/migrate/add_#{tenant_reference}_to_#{table_name}.rb"
19
+ end
20
+
21
+ private
22
+
23
+ # @return [String] pluralized, snake_case table name for the owned model.
24
+ def table_name
25
+ name.tableize
26
+ end
27
+
28
+ # @return [String] the tenant association name derived from the configured
29
+ # tenant column (e.g. "account_id" => "account").
30
+ def tenant_reference
31
+ TenantKit.config.tenant_column.to_s.sub(/_id\z/, "")
32
+ end
33
+
34
+ def migration_version
35
+ "#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}"
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,6 @@
1
+ class Add<%= tenant_reference.camelize %>To<%= table_name.camelize %> < ActiveRecord::Migration[<%= migration_version %>]
2
+ def change
3
+ add_reference :<%= table_name %>, :<%= tenant_reference %>,
4
+ null: false, foreign_key: true, index: true
5
+ end
6
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :tenant_kit do
3
+ # # Task goes here
4
+ # end
@@ -0,0 +1,45 @@
1
+ module TenantKit
2
+ # Holds the gem's configuration. Access the singleton via {TenantKit.config}
3
+ # and set values in +config/initializers/tenant_kit.rb+ through
4
+ # {TenantKit.configure}.
5
+ #
6
+ # @example
7
+ # TenantKit.configure do |config|
8
+ # config.tenant_class = "Account"
9
+ # config.tenant_column = "account_id"
10
+ # end
11
+ class Configuration
12
+ # @return [String] name of the model that represents a tenant (e.g. "Account").
13
+ attr_accessor :tenant_class
14
+
15
+ # @return [String] foreign-key column on tenant-owned tables (e.g. "account_id").
16
+ attr_accessor :tenant_column
17
+
18
+ # @return [Boolean] strict mode: raise {NoTenantSet} when a tenant-scoped
19
+ # query runs with no current tenant (and not inside +without_tenant+).
20
+ attr_accessor :require_tenant
21
+
22
+ # @return [Boolean] carry the current tenant into ActiveJob background jobs.
23
+ attr_accessor :propagate_to_jobs
24
+
25
+ # @return [Boolean] raise if a job is performed with no captured tenant
26
+ # (only consulted when {#propagate_to_jobs} is true).
27
+ attr_accessor :raise_on_missing_job_tenant
28
+
29
+ def initialize
30
+ @tenant_class = "Account"
31
+ @tenant_column = "account_id"
32
+ @require_tenant = true
33
+ @propagate_to_jobs = true
34
+ @raise_on_missing_job_tenant = false
35
+ end
36
+
37
+ # Resolves {#tenant_class} to the actual constant, lazily so it works with
38
+ # Rails autoloading / reloading.
39
+ #
40
+ # @return [Class] the tenant model class.
41
+ def tenant_model
42
+ tenant_class.to_s.constantize
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,86 @@
1
+ module TenantKit
2
+ # Mixed into +ActionController::Base+ and +ActionController::API+ (via the
3
+ # railtie). Provides class-level helpers that install a +before_action+ to
4
+ # resolve and set the current tenant for each request. +CurrentAttributes+
5
+ # resets the tenant automatically between requests.
6
+ module ControllerConcern
7
+ extend ActiveSupport::Concern
8
+
9
+ class_methods do
10
+ # Resolves the tenant by request subdomain (+tenant.<field> == request.subdomain+).
11
+ #
12
+ # @param tenant_name [Symbol, nil] tenant model name (underscored). Defaults
13
+ # to the configured tenant class.
14
+ # @param field [Symbol] the tenant column to match against. Default +:subdomain+.
15
+ # @return [void]
16
+ def set_current_tenant_by_subdomain(tenant_name = nil, field = :subdomain)
17
+ model = TenantKit::ControllerConcern.tenant_model_for(tenant_name)
18
+ before_action do
19
+ self.current_tenant = model.find_by(field => request.subdomain)
20
+ end
21
+ end
22
+
23
+ # Resolves the tenant by request host (+tenant.<field> == request.host+).
24
+ #
25
+ # @param tenant_name [Symbol, nil] tenant model name (underscored).
26
+ # @param field [Symbol] the tenant column to match against. Default +:domain+.
27
+ # @return [void]
28
+ def set_current_tenant_by_domain(tenant_name = nil, field = :domain)
29
+ model = TenantKit::ControllerConcern.tenant_model_for(tenant_name)
30
+ before_action do
31
+ self.current_tenant = model.find_by(field => request.host)
32
+ end
33
+ end
34
+
35
+ # Resolves the tenant by an HTTP request header — for APIs.
36
+ #
37
+ # @param header [String] the header name, e.g. +"X-Tenant-Id"+.
38
+ # @param tenant_name [Symbol, nil] tenant model name (underscored).
39
+ # @param field [Symbol] the tenant column the header value maps to. Default +:id+.
40
+ # @return [void]
41
+ def set_current_tenant_by_header(header, tenant_name = nil, field = :id)
42
+ model = TenantKit::ControllerConcern.tenant_model_for(tenant_name)
43
+ before_action do
44
+ value = request.headers[header]
45
+ self.current_tenant = value && model.find_by(field => value)
46
+ end
47
+ end
48
+
49
+ # Declares that the tenant is resolved by a custom +before_action+ the host
50
+ # app defines. A no-op marker for readability — assign via +current_tenant=+
51
+ # inside your own filter.
52
+ #
53
+ # @return [void]
54
+ def set_current_tenant_through_filter
55
+ # Intentionally empty: current_tenant= is always available.
56
+ end
57
+ end
58
+
59
+ # Resolves the tenant model class for the given (optional) name.
60
+ #
61
+ # @param tenant_name [Symbol, String, nil]
62
+ # @return [Class]
63
+ def self.tenant_model_for(tenant_name)
64
+ return TenantKit.config.tenant_model if tenant_name.nil?
65
+
66
+ tenant_name.to_s.camelize.constantize
67
+ end
68
+
69
+ included do
70
+ helper_method :current_tenant if respond_to?(:helper_method)
71
+ end
72
+
73
+ # @return [Object, nil] the current tenant for this request.
74
+ def current_tenant
75
+ TenantKit::Current.tenant
76
+ end
77
+
78
+ # Sets the current tenant for this request.
79
+ #
80
+ # @param tenant [Object, nil]
81
+ # @return [Object, nil]
82
+ def current_tenant=(tenant)
83
+ TenantKit::Current.tenant = tenant
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,16 @@
1
+ require "active_support/current_attributes"
2
+
3
+ module TenantKit
4
+ # Request-scoped holder for the current tenant, built on
5
+ # +ActiveSupport::CurrentAttributes+ so state is automatically reset between
6
+ # requests (and between jobs) — never a raw thread-local, which would bleed
7
+ # across requests on a reused thread.
8
+ class Current < ActiveSupport::CurrentAttributes
9
+ # @return [Object, nil] the current tenant record.
10
+ attribute :tenant
11
+
12
+ # Internal flag toggled by {TenantKit.without_tenant} to disable scoping.
13
+ # @return [Boolean, nil]
14
+ attribute :scoping_disabled
15
+ end
16
+ end
@@ -0,0 +1,10 @@
1
+ module TenantKit
2
+ # Base class for all TenantKit errors.
3
+ class Error < StandardError; end
4
+
5
+ # Raised when a tenant-scoped model is queried with no current tenant set,
6
+ # while strict mode (+config.require_tenant+) is enabled and execution is not
7
+ # inside a {TenantKit.without_tenant} block. Signals a would-be cross-tenant
8
+ # read rather than silently returning another tenant's data.
9
+ class NoTenantSet < Error; end
10
+ end
@@ -0,0 +1,55 @@
1
+ require "global_id"
2
+
3
+ module TenantKit
4
+ # Mixed into +ActiveJob::Base+ (via the railtie, when
5
+ # +config.propagate_to_jobs+ is true) so a job runs under the same tenant it
6
+ # was enqueued under — even though it executes later, in another process, with
7
+ # no request context.
8
+ #
9
+ # The tenant's GlobalID is captured at enqueue time and folded into the job's
10
+ # serialized payload (not just an in-memory attribute), so it survives any
11
+ # ActiveJob queue adapter — Solid Queue included — and is re-established around
12
+ # +perform+.
13
+ module Job
14
+ extend ActiveSupport::Concern
15
+
16
+ included do
17
+ # @return [String, nil] the enqueued tenant's GlobalID URI.
18
+ attr_accessor :tenant_kit_gid
19
+
20
+ around_enqueue do |job, block|
21
+ job.tenant_kit_gid ||= TenantKit::Current.tenant&.to_global_id&.to_s
22
+ block.call
23
+ end
24
+
25
+ around_perform do |job, block|
26
+ if job.tenant_kit_gid
27
+ tenant = GlobalID::Locator.locate(job.tenant_kit_gid)
28
+ TenantKit.with_tenant(tenant) { block.call }
29
+ elsif TenantKit.config.raise_on_missing_job_tenant
30
+ raise TenantKit::NoTenantSet, "Job #{job.class} enqueued without a tenant"
31
+ else
32
+ block.call
33
+ end
34
+ end
35
+ end
36
+
37
+ # Folds the captured tenant GlobalID into the serialized job payload so it
38
+ # round-trips through the queue adapter.
39
+ #
40
+ # @return [Hash]
41
+ def serialize
42
+ super.merge("tenant_kit_gid" => tenant_kit_gid)
43
+ end
44
+
45
+ # Restores the captured tenant GlobalID when the job is deserialized for
46
+ # execution.
47
+ #
48
+ # @param job_data [Hash]
49
+ # @return [void]
50
+ def deserialize(job_data)
51
+ super
52
+ self.tenant_kit_gid = job_data["tenant_kit_gid"]
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,99 @@
1
+ module TenantKit
2
+ # Mixed into every +ActiveRecord::Base+ (via the railtie). Provides the
3
+ # +belongs_to_tenant+ macro that turns a plain model into a tenant-owned one:
4
+ # it declares the tenant association, scopes all queries to the current tenant,
5
+ # auto-assigns the tenant on create, and validates the tenant's presence.
6
+ module Model
7
+ extend ActiveSupport::Concern
8
+
9
+ # Extended onto each tenant-owned model. Building a record evaluates the
10
+ # strict +default_scope+ via +scope_for_create+; without this guard,
11
+ # +Model.new+ with no current tenant would raise instead of letting presence
12
+ # validation report the missing tenant. We disable scoping only for the build
13
+ # itself — the subsequent +save+ (and its validations) runs normally.
14
+ module BuildGuard
15
+ def new(*args, **kwargs, &block)
16
+ TenantKit.without_tenant { super }
17
+ end
18
+ end
19
+
20
+ class_methods do
21
+ # Declares this model as tenant-owned.
22
+ #
23
+ # @param association [Symbol, nil] the tenant association name. Defaults to
24
+ # the underscored {TenantKit::Configuration#tenant_class} (e.g. +:account+).
25
+ # @param options [Hash] forwarded to +belongs_to+ (+:class_name+,
26
+ # +:foreign_key+, +:optional+, +:inverse_of+, ...).
27
+ # @option options [String] :foreign_key overrides
28
+ # {TenantKit::Configuration#tenant_column}.
29
+ # @return [void]
30
+ #
31
+ # @example Default tenant
32
+ # class Project < ApplicationRecord
33
+ # belongs_to_tenant
34
+ # end
35
+ #
36
+ # @example Explicit association
37
+ # class Invoice < ApplicationRecord
38
+ # belongs_to_tenant :account, class_name: "Account", foreign_key: "account_id"
39
+ # end
40
+ def belongs_to_tenant(association = nil, **options)
41
+ assoc = (association || TenantKit.config.tenant_class.underscore).to_sym
42
+ fk = (options[:foreign_key] || TenantKit.config.tenant_column).to_s
43
+
44
+ self._tenant_kit_association = assoc
45
+ self._tenant_kit_foreign_key = fk
46
+
47
+ extend TenantKit::Model::BuildGuard
48
+
49
+ belongs_to assoc, **options.slice(:class_name, :foreign_key, :optional, :inverse_of)
50
+
51
+ # Scope every read to the current tenant. Strict by default: querying with
52
+ # no tenant (and not inside without_tenant) raises rather than leaking.
53
+ default_scope do
54
+ if TenantKit.scoping_active?
55
+ where(fk => TenantKit::Current.tenant.public_send(:id))
56
+ elsif TenantKit.config.require_tenant && !TenantKit.scoping_disabled?
57
+ raise TenantKit::NoTenantSet, "No current tenant set for #{name}"
58
+ else
59
+ all
60
+ end
61
+ end
62
+
63
+ # Auto-assign the tenant on new records via before_validation — never via
64
+ # default_scope create behavior, which is a known foot-gun.
65
+ before_validation do
66
+ if TenantKit::Current.tenant && public_send(assoc).nil?
67
+ public_send("#{assoc}=", TenantKit::Current.tenant)
68
+ end
69
+ end
70
+
71
+ validates assoc, presence: true, unless: -> { TenantKit.scoping_disabled? }
72
+ end
73
+
74
+ # Validates that +attrs+ are unique within the current tenant, allowing the
75
+ # same value to reappear under a different tenant. The tenant foreign key is
76
+ # always folded into the uniqueness scope, so this must be called after
77
+ # {#belongs_to_tenant}.
78
+ #
79
+ # @param attrs [Array<Symbol>] attributes to check.
80
+ # @param opts [Hash] forwarded to the underlying uniqueness validation
81
+ # (+:scope+, +:case_sensitive+, +:message+, +:conditions+, ...).
82
+ # @return [void]
83
+ def validates_uniqueness_to_tenant(*attrs, **opts)
84
+ unless _tenant_kit_foreign_key
85
+ raise ArgumentError, "call belongs_to_tenant before validates_uniqueness_to_tenant"
86
+ end
87
+
88
+ scope = (Array(opts[:scope]) + [ _tenant_kit_foreign_key.to_sym ]).uniq
89
+ validates_uniqueness_of(*attrs, **opts.merge(scope: scope))
90
+ end
91
+ end
92
+
93
+ included do
94
+ # The tenant association name and foreign key, set by belongs_to_tenant.
95
+ class_attribute :_tenant_kit_association, instance_accessor: false, default: nil
96
+ class_attribute :_tenant_kit_foreign_key, instance_accessor: false, default: nil
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,35 @@
1
+ # Ensure GlobalID is fully wired (GlobalID.app set, GlobalID::Identification
2
+ # included into ActiveRecord::Base) — job propagation depends on it, and a host
3
+ # app that doesn't otherwise use GlobalID (e.g. a --minimal app) won't have its
4
+ # railtie loaded. Registering it here, at gem-load time, lets Rails run its
5
+ # initializers during boot exactly as a full app would.
6
+ require "global_id/railtie"
7
+
8
+ module TenantKit
9
+ # Wires TenantKit into a Rails application on boot. Each concern is included
10
+ # only if it has been required, so the gem boots correctly at every build
11
+ # milestone. Hooks are guarded with +defined?+ so a partially-built gem never
12
+ # crashes the host app.
13
+ class Railtie < ::Rails::Railtie
14
+ initializer "tenant_kit.active_record" do
15
+ ActiveSupport.on_load(:active_record) do
16
+ include TenantKit::Model if defined?(TenantKit::Model)
17
+ end
18
+ end
19
+
20
+ initializer "tenant_kit.action_controller" do
21
+ ActiveSupport.on_load(:action_controller_base) do
22
+ include TenantKit::ControllerConcern if defined?(TenantKit::ControllerConcern)
23
+ end
24
+ ActiveSupport.on_load(:action_controller_api) do
25
+ include TenantKit::ControllerConcern if defined?(TenantKit::ControllerConcern)
26
+ end
27
+ end
28
+
29
+ initializer "tenant_kit.active_job" do
30
+ ActiveSupport.on_load(:active_job) do
31
+ include TenantKit::Job if defined?(TenantKit::Job) && TenantKit.config.propagate_to_jobs
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,34 @@
1
+ module TenantKit
2
+ # Opt-in test helpers. Require and include where you need them — they are not
3
+ # loaded by default.
4
+ #
5
+ # @example RSpec
6
+ # require "tenant_kit/testing"
7
+ # RSpec.configure do |config|
8
+ # config.include TenantKit::Testing
9
+ # config.after { TenantKit::Current.reset }
10
+ # end
11
+ #
12
+ # it "does tenant work" do
13
+ # as_tenant(account) { expect(Project.count).to eq(0) }
14
+ # end
15
+ module Testing
16
+ # Runs the block scoped to +tenant+ (alias for {TenantKit.with_tenant}).
17
+ #
18
+ # @param tenant [Object]
19
+ # @yield the block to run under +tenant+.
20
+ # @return [Object] the block's return value.
21
+ def as_tenant(tenant, &block)
22
+ TenantKit.with_tenant(tenant, &block)
23
+ end
24
+
25
+ # Runs the block with tenant scoping disabled (alias for
26
+ # {TenantKit.without_tenant}).
27
+ #
28
+ # @yield the block to run unscoped.
29
+ # @return [Object] the block's return value.
30
+ def without_tenant(&block)
31
+ TenantKit.without_tenant(&block)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,3 @@
1
+ module TenantKit
2
+ VERSION = "0.1.0"
3
+ end
data/lib/tenant_kit.rb ADDED
@@ -0,0 +1,73 @@
1
+ require "active_support"
2
+
3
+ require "tenant_kit/version"
4
+ require "tenant_kit/errors"
5
+ require "tenant_kit/configuration"
6
+ require "tenant_kit/current"
7
+ require "tenant_kit/model"
8
+ require "tenant_kit/controller_concern"
9
+ require "tenant_kit/job"
10
+ require "tenant_kit/railtie" if defined?(Rails::Railtie)
11
+
12
+ # TenantKit — row-level (shared-schema) multi-tenancy for Rails.
13
+ #
14
+ # The module itself exposes configuration and the sanctioned ways to move in and
15
+ # out of tenant scope: {with_tenant} and {without_tenant}. Everything else hangs
16
+ # off {TenantKit::Current} (the request-scoped current tenant) and the
17
+ # {TenantKit::Model} macro +belongs_to_tenant+.
18
+ module TenantKit
19
+ class << self
20
+ # @return [TenantKit::Configuration] the singleton configuration object.
21
+ def config
22
+ @config ||= Configuration.new
23
+ end
24
+
25
+ # Yields the configuration for editing, typically from an initializer.
26
+ #
27
+ # @yieldparam config [TenantKit::Configuration]
28
+ # @return [TenantKit::Configuration]
29
+ def configure
30
+ yield config
31
+ config
32
+ end
33
+
34
+ # Runs the block with +tenant+ as the current tenant, restoring the previous
35
+ # tenant afterwards (even on error). The sanctioned way to switch tenants.
36
+ #
37
+ # @param tenant [Object] the tenant record to scope to.
38
+ # @yield the block to run under +tenant+.
39
+ # @return [Object] the block's return value.
40
+ def with_tenant(tenant)
41
+ previous = Current.tenant
42
+ Current.tenant = tenant
43
+ yield
44
+ ensure
45
+ Current.tenant = previous
46
+ end
47
+
48
+ # Runs the block with tenant scoping disabled — the single, greppable escape
49
+ # hatch for admin tools, seeds, migrations, and the console. Restores the
50
+ # previous state afterwards (even on error).
51
+ #
52
+ # @yield the block to run unscoped.
53
+ # @return [Object] the block's return value.
54
+ def without_tenant
55
+ was = Current.scoping_disabled
56
+ Current.scoping_disabled = true
57
+ yield
58
+ ensure
59
+ Current.scoping_disabled = was
60
+ end
61
+
62
+ # @return [Boolean] true when a tenant is set and scoping is not disabled —
63
+ # i.e. queries should be filtered to the current tenant.
64
+ def scoping_active?
65
+ Current.tenant.present? && !Current.scoping_disabled
66
+ end
67
+
68
+ # @return [Boolean] true when inside a {without_tenant} block.
69
+ def scoping_disabled?
70
+ !!Current.scoping_disabled
71
+ end
72
+ end
73
+ end
metadata ADDED
@@ -0,0 +1,114 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tenant_kit
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - wintan1418
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activesupport
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.2'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.2'
40
+ - !ruby/object:Gem::Dependency
41
+ name: railties
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '7.2'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '7.2'
54
+ description: 'TenantKit makes a Rails app multi-tenant using the row-level / shared-schema
55
+ strategy: one database, a tenant_id foreign key on owned tables, automatic query
56
+ scoping via ActiveSupport::CurrentAttributes, auto-assignment of the current tenant,
57
+ and first-class tenant propagation into ActiveJob background jobs. It is strict
58
+ by default: querying a tenant-scoped model with no tenant set raises rather than
59
+ leaking another tenant''s data.'
60
+ email:
61
+ - wintan1418@gmail.com
62
+ executables: []
63
+ extensions: []
64
+ extra_rdoc_files: []
65
+ files:
66
+ - CHANGELOG.md
67
+ - MIT-LICENSE
68
+ - README.md
69
+ - Rakefile
70
+ - lib/generators/tenant_kit/install/install_generator.rb
71
+ - lib/generators/tenant_kit/install/templates/initializer.rb.tt
72
+ - lib/generators/tenant_kit/install/templates/tenant_migration.rb.tt
73
+ - lib/generators/tenant_kit/install/templates/tenant_model.rb.tt
74
+ - lib/generators/tenant_kit/migration/migration_generator.rb
75
+ - lib/generators/tenant_kit/migration/templates/add_tenant_reference.rb.tt
76
+ - lib/tasks/tenant_kit_tasks.rake
77
+ - lib/tenant_kit.rb
78
+ - lib/tenant_kit/configuration.rb
79
+ - lib/tenant_kit/controller_concern.rb
80
+ - lib/tenant_kit/current.rb
81
+ - lib/tenant_kit/errors.rb
82
+ - lib/tenant_kit/job.rb
83
+ - lib/tenant_kit/model.rb
84
+ - lib/tenant_kit/railtie.rb
85
+ - lib/tenant_kit/testing.rb
86
+ - lib/tenant_kit/version.rb
87
+ homepage: https://github.com/wintan1418/tenant_kit
88
+ licenses:
89
+ - MIT
90
+ metadata:
91
+ homepage_uri: https://github.com/wintan1418/tenant_kit
92
+ source_code_uri: https://github.com/wintan1418/tenant_kit/tree/main
93
+ changelog_uri: https://github.com/wintan1418/tenant_kit/blob/main/CHANGELOG.md
94
+ bug_tracker_uri: https://github.com/wintan1418/tenant_kit/issues
95
+ rubygems_mfa_required: 'true'
96
+ rdoc_options: []
97
+ require_paths:
98
+ - lib
99
+ required_ruby_version: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '3.3'
104
+ required_rubygems_version: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: '0'
109
+ requirements: []
110
+ rubygems_version: 3.6.9
111
+ specification_version: 4
112
+ summary: Row-level (shared-schema) multi-tenancy for Rails, strict by default, with
113
+ background-job tenant propagation.
114
+ test_files: []