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 +7 -0
- data/CHANGELOG.md +32 -0
- data/MIT-LICENSE +20 -0
- data/README.md +214 -0
- data/Rakefile +8 -0
- data/lib/generators/tenant_kit/install/install_generator.rb +69 -0
- data/lib/generators/tenant_kit/install/templates/initializer.rb.tt +17 -0
- data/lib/generators/tenant_kit/install/templates/tenant_migration.rb.tt +14 -0
- data/lib/generators/tenant_kit/install/templates/tenant_model.rb.tt +3 -0
- data/lib/generators/tenant_kit/migration/migration_generator.rb +39 -0
- data/lib/generators/tenant_kit/migration/templates/add_tenant_reference.rb.tt +6 -0
- data/lib/tasks/tenant_kit_tasks.rake +4 -0
- data/lib/tenant_kit/configuration.rb +45 -0
- data/lib/tenant_kit/controller_concern.rb +86 -0
- data/lib/tenant_kit/current.rb +16 -0
- data/lib/tenant_kit/errors.rb +10 -0
- data/lib/tenant_kit/job.rb +55 -0
- data/lib/tenant_kit/model.rb +99 -0
- data/lib/tenant_kit/railtie.rb +35 -0
- data/lib/tenant_kit/testing.rb +34 -0
- data/lib/tenant_kit/version.rb +3 -0
- data/lib/tenant_kit.rb +73 -0
- metadata +114 -0
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
|
+
[](https://github.com/wintan1418/tenant_kit/actions/workflows/ci.yml)
|
|
4
|
+
[](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,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,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,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
|
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: []
|