rails-tenantify 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 +23 -0
- data/LICENSE +21 -0
- data/README.md +135 -0
- data/lib/tenantify/configuration.rb +13 -0
- data/lib/tenantify/controller.rb +66 -0
- data/lib/tenantify/current.rb +10 -0
- data/lib/tenantify/errors.rb +9 -0
- data/lib/tenantify/job.rb +51 -0
- data/lib/tenantify/middleware/sidekiq.rb +40 -0
- data/lib/tenantify/railtie.rb +34 -0
- data/lib/tenantify/resolvers/header.rb +18 -0
- data/lib/tenantify/resolvers/subdomain.rb +19 -0
- data/lib/tenantify/scoped.rb +126 -0
- data/lib/tenantify/switcher.rb +15 -0
- data/lib/tenantify/test_helpers.rb +26 -0
- data/lib/tenantify/version.rb +5 -0
- data/lib/tenantify.rb +83 -0
- metadata +163 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0c660926719f56e3805e0d1622366992096727f7cd25e409b6a95fb9c15152cb
|
|
4
|
+
data.tar.gz: 3f0c9b560f399bfb374518a575a14c36e3b0489b35ffd3c5e948eb8c6c321335
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: '083c79279cb7edd789d63e134b1a0e0f9d11e2f616468e759b545d6c417675486f25b7efd71f1fb80e15e613c9b46a079480d1791a9816548785bb96873d1d95'
|
|
7
|
+
data.tar.gz: 4dc62b900ccaced8c5ee4b652fb750275eda1ca92fcf876632f4efe87b8bdaddf6c4e29384d34bd1e41fee61791748c6c362d630f92cc6ecc658cbbc51261c4f
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [0.1.0] - 2026-06-01
|
|
6
|
+
|
|
7
|
+
Published as **`rails-tenantify`** on RubyGems (`gem "rails-tenantify"`). The name `tenantify` is already used by an unrelated gem from 2016.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `Tenantify::Scoped` model concern with `belongs_to_tenant` macro and default scope
|
|
12
|
+
- Automatic tenant assignment on create and immutability validation on update
|
|
13
|
+
- Cross-tenant `belongs_to` association validation
|
|
14
|
+
- Bulk-write protection for `update_all`, `delete_all`, and `destroy_all`
|
|
15
|
+
- `Tenantify::Controller` with `set_tenant_by` for `:subdomain` and `:header` resolvers
|
|
16
|
+
- Pluggable resolvers under `Tenantify::Resolvers`
|
|
17
|
+
- Thread-local `Tenantify.current_tenant` via `ActiveSupport::CurrentAttributes`
|
|
18
|
+
- `Tenantify.switch_to` and `Tenantify.without_tenant` block helpers
|
|
19
|
+
- Tenant override auditing (`:log`, `:raise`, or `:ignore`)
|
|
20
|
+
- `Tenantify::Job` concern for ActiveJob tenant serialization and restoration
|
|
21
|
+
- Sidekiq client/server middleware for native Sidekiq workers
|
|
22
|
+
- `Tenantify::TestHelpers` for RSpec and Minitest
|
|
23
|
+
- Configuration DSL via `Tenantify.configure`
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Syed Ghani
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# rails-tenantify
|
|
2
|
+
|
|
3
|
+
**Modern row-level multi-tenancy for Rails 7+ / Ruby 3.1+**
|
|
4
|
+
|
|
5
|
+
The RubyGems package is [`rails-tenantify`](https://rubygems.org/gems/rails-tenantify). The library is required as `tenantify` (same pattern as `rails-persona` → `persona`).
|
|
6
|
+
|
|
7
|
+
[](https://github.com/sghani001/rails-tenantify/actions/workflows/ci.yml)
|
|
8
|
+
|
|
9
|
+
Tenantify is a maintained alternative to `acts_as_tenant`: automatic model scoping, controller resolution, background-job context, bulk-write guards, and test helpers — built for Rails 7 and 8.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
Add to your `Gemfile`:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem "rails-tenantify"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
bundle install
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Create `config/initializers/tenantify.rb`:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
Tenantify.configure do |config|
|
|
27
|
+
config.tenant_model = "Organization"
|
|
28
|
+
config.on_tenant_not_found = :raise # :raise, :redirect, :null_tenant
|
|
29
|
+
config.audit_overrides = :log # :log, :raise, :ignore
|
|
30
|
+
end
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
### Models
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
class Project < ApplicationRecord
|
|
39
|
+
include Tenantify::Scoped
|
|
40
|
+
belongs_to_tenant :organization
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
- Adds a `default_scope` for the current tenant
|
|
45
|
+
- Sets the tenant foreign key on create
|
|
46
|
+
- Validates the tenant cannot change after create
|
|
47
|
+
- Validates associated records belong to the same tenant
|
|
48
|
+
|
|
49
|
+
### Controllers
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
class ApplicationController < ActionController::Base
|
|
53
|
+
include Tenantify::Controller
|
|
54
|
+
|
|
55
|
+
set_tenant_by :subdomain
|
|
56
|
+
# set_tenant_by :header, header: "X-Tenant-ID"
|
|
57
|
+
end
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Resolvers live under `Tenantify::Resolvers` (`Subdomain`, `Header`). JWT and custom-domain resolvers are planned for upcoming releases.
|
|
61
|
+
|
|
62
|
+
### Background jobs
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
class ReportJob < ApplicationJob
|
|
66
|
+
def perform
|
|
67
|
+
Tenantify.current_tenant # restored from enqueue time
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`Tenantify::Job` is included automatically for ActiveJob. Sidekiq workers get tenant metadata via middleware when Sidekiq is present.
|
|
73
|
+
|
|
74
|
+
### Switching context
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
Tenantify.switch_to(organization) do
|
|
78
|
+
Project.all # scoped to organization
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
Tenantify.without_tenant do
|
|
82
|
+
Project.delete_all # bypasses bulk-write protection
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Tests
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
RSpec.configure do |config|
|
|
90
|
+
config.include Tenantify::TestHelpers
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
with_tenant(organization) do
|
|
94
|
+
Project.create!(name: "Demo")
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Bulk-write protection
|
|
99
|
+
|
|
100
|
+
`update_all`, `delete_all`, and `destroy_all` on tenant-scoped models raise `Tenantify::TenantMismatchError` unless the relation is already scoped to the current tenant, or you use `Tenantify.without_tenant`.
|
|
101
|
+
|
|
102
|
+
## Errors
|
|
103
|
+
|
|
104
|
+
| Error | When |
|
|
105
|
+
|-------|------|
|
|
106
|
+
| `Tenantify::TenantNotFoundError` | Resolver cannot find a tenant |
|
|
107
|
+
| `Tenantify::TenantMismatchError` | Unsafe bulk write without tenant scope |
|
|
108
|
+
| `Tenantify::TenantOverrideError` | Unsafe `current_tenant=` when `audit_overrides` is `:raise` |
|
|
109
|
+
|
|
110
|
+
## Roadmap
|
|
111
|
+
|
|
112
|
+
| Version | Focus |
|
|
113
|
+
|---------|--------|
|
|
114
|
+
| **0.1.0** | Core scoping, subdomain/header resolvers, ActiveJob, Sidekiq, test helpers |
|
|
115
|
+
| **0.2.0** | GoodJob, Solid Queue |
|
|
116
|
+
| **0.3.0** | JWT resolver, API improvements |
|
|
117
|
+
| **0.4.0** | Custom domains, Active Storage |
|
|
118
|
+
| **1.0.0** | Stable API, full docs |
|
|
119
|
+
|
|
120
|
+
See [CHANGELOG.md](CHANGELOG.md) for release notes.
|
|
121
|
+
|
|
122
|
+
## Development
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
bundle install
|
|
126
|
+
bundle exec rspec
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Contributing
|
|
130
|
+
|
|
131
|
+
Bug reports and pull requests are welcome at [github.com/sghani001/rails-tenantify](https://github.com/sghani001/rails-tenantify).
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tenantify
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :tenant_model, :on_tenant_not_found, :audit_overrides
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@tenant_model = nil
|
|
9
|
+
@on_tenant_not_found = :raise
|
|
10
|
+
@audit_overrides = :log
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tenantify
|
|
4
|
+
module Controller
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
RESOLVERS = {
|
|
8
|
+
subdomain: Resolvers::Subdomain,
|
|
9
|
+
header: Resolvers::Header
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
class_methods do
|
|
13
|
+
def set_tenant_by(resolver_type, **options)
|
|
14
|
+
before_action(**options.slice(:only, :except, :if, :unless)) do
|
|
15
|
+
resolve_and_set_tenant(resolver_type, options)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def resolve_and_set_tenant(resolver_type, options)
|
|
23
|
+
resolver_class = Tenantify::Controller::RESOLVERS[resolver_type]
|
|
24
|
+
raise ArgumentError, "Unknown Tenantify resolver type: #{resolver_type}" unless resolver_class
|
|
25
|
+
|
|
26
|
+
resolver = build_resolver(resolver_class, resolver_type, options)
|
|
27
|
+
tenant = resolver.call(request)
|
|
28
|
+
|
|
29
|
+
if tenant
|
|
30
|
+
Tenantify.current_tenant = tenant
|
|
31
|
+
else
|
|
32
|
+
handle_tenant_not_found(options)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def build_resolver(resolver_class, resolver_type, options)
|
|
37
|
+
case resolver_type
|
|
38
|
+
when :subdomain
|
|
39
|
+
resolver_class.new(
|
|
40
|
+
exclude: options[:exclude] || %w[www],
|
|
41
|
+
attribute: options[:attribute] || :subdomain
|
|
42
|
+
)
|
|
43
|
+
when :header
|
|
44
|
+
resolver_class.new(header: options[:header] || "X-Tenant-ID")
|
|
45
|
+
else
|
|
46
|
+
resolver_class.new
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def handle_tenant_not_found(options)
|
|
51
|
+
behavior = Tenantify.configuration.on_tenant_not_found
|
|
52
|
+
|
|
53
|
+
case behavior
|
|
54
|
+
when :raise
|
|
55
|
+
raise TenantNotFoundError, "Tenant could not be resolved for request to #{request.url}"
|
|
56
|
+
when :redirect
|
|
57
|
+
redirect_path = options[:fallback] || "/"
|
|
58
|
+
redirect_to(redirect_path)
|
|
59
|
+
when :null_tenant
|
|
60
|
+
Tenantify.current_tenant = nil
|
|
61
|
+
else
|
|
62
|
+
raise TenantNotFoundError, "Tenant could not be resolved for request to #{request.url}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module Tenantify
|
|
6
|
+
module Job
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
attr_accessor :tenant_id
|
|
11
|
+
|
|
12
|
+
def serialize
|
|
13
|
+
super.merge("tenant_id" => tenant_id || Tenantify.current_tenant_id)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def deserialize(job_data)
|
|
17
|
+
super(job_data)
|
|
18
|
+
self.tenant_id = job_data["tenant_id"]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
around_perform do |_job, block|
|
|
22
|
+
if tenant_id
|
|
23
|
+
tenant = Tenantify.tenant_class.find_by(id: tenant_id)
|
|
24
|
+
if tenant
|
|
25
|
+
Tenantify.switch_to(tenant, &block)
|
|
26
|
+
else
|
|
27
|
+
log_missing_tenant(tenant_id)
|
|
28
|
+
block.call
|
|
29
|
+
end
|
|
30
|
+
else
|
|
31
|
+
block.call
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def log_missing_tenant(tenant_id)
|
|
39
|
+
message = "[Tenantify] ActiveJob #{self.class.name} could not restore tenant #{tenant_id}"
|
|
40
|
+
if defined?(Rails) && Rails.logger
|
|
41
|
+
Rails.logger.warn(message)
|
|
42
|
+
else
|
|
43
|
+
warn(message)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
ActiveSupport.on_load(:active_job) do
|
|
50
|
+
include Tenantify::Job
|
|
51
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tenantify
|
|
4
|
+
module Middleware
|
|
5
|
+
class SidekiqClient
|
|
6
|
+
def call(_worker_class, job, _queue, _redis_pool = nil)
|
|
7
|
+
job["tenant_id"] ||= Tenantify.current_tenant_id if Tenantify.current_tenant_id
|
|
8
|
+
yield
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class SidekiqServer
|
|
13
|
+
def call(_worker, job, _queue)
|
|
14
|
+
tenant_id = job["tenant_id"]
|
|
15
|
+
if tenant_id
|
|
16
|
+
tenant = Tenantify.tenant_class.find_by(id: tenant_id)
|
|
17
|
+
if tenant
|
|
18
|
+
Tenantify.switch_to(tenant) { yield }
|
|
19
|
+
else
|
|
20
|
+
log_missing_tenant(tenant_id)
|
|
21
|
+
yield
|
|
22
|
+
end
|
|
23
|
+
else
|
|
24
|
+
yield
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def log_missing_tenant(tenant_id)
|
|
31
|
+
message = "[Tenantify] Sidekiq job could not restore tenant #{tenant_id}"
|
|
32
|
+
if defined?(Rails) && Rails.logger
|
|
33
|
+
Rails.logger.warn(message)
|
|
34
|
+
else
|
|
35
|
+
warn(message)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module Tenantify
|
|
6
|
+
class Railtie < Rails::Railtie
|
|
7
|
+
initializer "tenantify.action_controller" do
|
|
8
|
+
ActiveSupport.on_load(:action_controller) do
|
|
9
|
+
include Tenantify::Controller
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
initializer "tenantify.sidekiq" do
|
|
14
|
+
next unless defined?(Sidekiq)
|
|
15
|
+
|
|
16
|
+
require_relative "middleware/sidekiq"
|
|
17
|
+
|
|
18
|
+
Sidekiq.configure_client do |config|
|
|
19
|
+
config.client_middleware do |chain|
|
|
20
|
+
chain.add Tenantify::Middleware::SidekiqClient
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
Sidekiq.configure_server do |config|
|
|
25
|
+
config.client_middleware do |chain|
|
|
26
|
+
chain.add Tenantify::Middleware::SidekiqClient
|
|
27
|
+
end
|
|
28
|
+
config.server_middleware do |chain|
|
|
29
|
+
chain.add Tenantify::Middleware::SidekiqServer
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tenantify
|
|
4
|
+
module Resolvers
|
|
5
|
+
class Header
|
|
6
|
+
def initialize(header: "X-Tenant-ID")
|
|
7
|
+
@header = header
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(request)
|
|
11
|
+
tenant_id = request.headers[@header]
|
|
12
|
+
return nil if tenant_id.blank?
|
|
13
|
+
|
|
14
|
+
Tenantify.tenant_class.find_by(id: tenant_id)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tenantify
|
|
4
|
+
module Resolvers
|
|
5
|
+
class Subdomain
|
|
6
|
+
def initialize(exclude: %w[www], attribute: :subdomain)
|
|
7
|
+
@exclude = Array(exclude)
|
|
8
|
+
@attribute = attribute
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(request)
|
|
12
|
+
subdomain = request.subdomain
|
|
13
|
+
return nil if subdomain.blank? || @exclude.include?(subdomain)
|
|
14
|
+
|
|
15
|
+
Tenantify.tenant_class.find_by(@attribute => subdomain)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tenantify
|
|
4
|
+
module Scoped
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
class_methods do
|
|
8
|
+
def belongs_to_tenant(association_name, **options)
|
|
9
|
+
class_attribute :tenant_association_name, instance_accessor: false
|
|
10
|
+
self.tenant_association_name = association_name
|
|
11
|
+
|
|
12
|
+
belongs_to association_name, **options
|
|
13
|
+
|
|
14
|
+
default_scope lambda {
|
|
15
|
+
if Tenantify.tenant_scoped? && Tenantify.current_tenant
|
|
16
|
+
fk = reflect_on_association(association_name)&.foreign_key || "#{association_name}_id"
|
|
17
|
+
where(fk => Tenantify.current_tenant.id)
|
|
18
|
+
else
|
|
19
|
+
all
|
|
20
|
+
end
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
before_validation :set_tenant_automatically, on: :create
|
|
24
|
+
validate :validate_tenant_not_changed, on: :update
|
|
25
|
+
validate :validate_cross_tenant_associations
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def tenant_scoped?
|
|
29
|
+
tenant_association_name.present?
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def set_tenant_automatically
|
|
36
|
+
association_name = self.class.tenant_association_name
|
|
37
|
+
return unless association_name
|
|
38
|
+
|
|
39
|
+
fk = self.class.reflect_on_association(association_name)&.foreign_key || "#{association_name}_id"
|
|
40
|
+
return unless send(fk).nil? && Tenantify.current_tenant
|
|
41
|
+
|
|
42
|
+
send("#{association_name}=", Tenantify.current_tenant)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def validate_tenant_not_changed
|
|
46
|
+
association_name = self.class.tenant_association_name
|
|
47
|
+
return unless association_name
|
|
48
|
+
|
|
49
|
+
fk = self.class.reflect_on_association(association_name)&.foreign_key || "#{association_name}_id"
|
|
50
|
+
return unless send("#{fk}_changed?") && !send("#{fk}_was").nil?
|
|
51
|
+
|
|
52
|
+
errors.add(fk, "cannot be changed after creation")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def validate_cross_tenant_associations
|
|
56
|
+
association_name = self.class.tenant_association_name
|
|
57
|
+
return unless association_name
|
|
58
|
+
|
|
59
|
+
self.class.reflect_on_all_associations(:belongs_to).each do |assoc|
|
|
60
|
+
next if assoc.name == association_name
|
|
61
|
+
|
|
62
|
+
associated_class = assoc.klass
|
|
63
|
+
next unless associated_class.respond_to?(:tenant_scoped?) && associated_class.tenant_scoped?
|
|
64
|
+
|
|
65
|
+
associated_record = send(assoc.name)
|
|
66
|
+
next if associated_record.nil?
|
|
67
|
+
|
|
68
|
+
my_fk = self.class.reflect_on_association(association_name)&.foreign_key || "#{association_name}_id"
|
|
69
|
+
assoc_fk = associated_class.reflect_on_association(associated_class.tenant_association_name)&.foreign_key ||
|
|
70
|
+
"#{associated_class.tenant_association_name}_id"
|
|
71
|
+
|
|
72
|
+
my_tenant_id = send(my_fk)
|
|
73
|
+
assoc_tenant_id = associated_record.send(assoc_fk)
|
|
74
|
+
|
|
75
|
+
if my_tenant_id && assoc_tenant_id && my_tenant_id != assoc_tenant_id
|
|
76
|
+
errors.add(assoc.name, "belongs to a different tenant")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
module RelationExtension
|
|
83
|
+
def update_all(updates)
|
|
84
|
+
check_tenant_scope!
|
|
85
|
+
super
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def delete_all
|
|
89
|
+
check_tenant_scope!
|
|
90
|
+
super
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def destroy_all
|
|
94
|
+
check_tenant_scope!
|
|
95
|
+
super
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def check_tenant_scope!
|
|
101
|
+
return unless klass.respond_to?(:tenant_scoped?) && klass.tenant_scoped?
|
|
102
|
+
return unless Tenantify.tenant_scoped?
|
|
103
|
+
|
|
104
|
+
if Tenantify.current_tenant.nil?
|
|
105
|
+
raise TenantMismatchError,
|
|
106
|
+
"Bulk operation attempted on tenant-scoped model #{klass.name} without an active tenant context"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
fk = klass.reflect_on_association(klass.tenant_association_name)&.foreign_key ||
|
|
110
|
+
"#{klass.tenant_association_name}_id"
|
|
111
|
+
|
|
112
|
+
where_hash = where_values_hash
|
|
113
|
+
tenant_id = Tenantify.current_tenant.id
|
|
114
|
+
scoped_to_tenant = where_hash[fk.to_s] == tenant_id || where_hash[fk.to_sym] == tenant_id
|
|
115
|
+
|
|
116
|
+
return if scoped_to_tenant
|
|
117
|
+
|
|
118
|
+
raise TenantMismatchError,
|
|
119
|
+
"Bulk operation bypassed tenant scope for #{klass.name}. Use Tenantify.without_tenant if this was intentional."
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
ActiveSupport.on_load(:active_record) do
|
|
125
|
+
ActiveRecord::Relation.prepend(Tenantify::RelationExtension)
|
|
126
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tenantify
|
|
4
|
+
module Switcher
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def switch_to(tenant, &block)
|
|
8
|
+
Tenantify.switch_to(tenant, &block)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def without_tenant(&block)
|
|
12
|
+
Tenantify.without_tenant(&block)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tenantify
|
|
4
|
+
module TestHelpers
|
|
5
|
+
def with_tenant(tenant)
|
|
6
|
+
Tenantify.switch_to(tenant) do
|
|
7
|
+
yield
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def without_tenant
|
|
12
|
+
Tenantify.without_tenant do
|
|
13
|
+
yield
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.set_tenant(tenant)
|
|
18
|
+
Tenantify.current_tenant = tenant
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.clear_tenant
|
|
22
|
+
Tenantify.current_tenant = nil
|
|
23
|
+
Tenantify::Current.reset
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/tenantify.rb
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
require "active_support/core_ext"
|
|
5
|
+
|
|
6
|
+
require_relative "tenantify/version"
|
|
7
|
+
require_relative "tenantify/errors"
|
|
8
|
+
require_relative "tenantify/current"
|
|
9
|
+
require_relative "tenantify/configuration"
|
|
10
|
+
require_relative "tenantify/resolvers/subdomain"
|
|
11
|
+
require_relative "tenantify/resolvers/header"
|
|
12
|
+
require_relative "tenantify/scoped"
|
|
13
|
+
require_relative "tenantify/controller"
|
|
14
|
+
require_relative "tenantify/job"
|
|
15
|
+
require_relative "tenantify/switcher"
|
|
16
|
+
require_relative "tenantify/test_helpers"
|
|
17
|
+
require_relative "tenantify/railtie" if defined?(Rails)
|
|
18
|
+
|
|
19
|
+
module Tenantify
|
|
20
|
+
class << self
|
|
21
|
+
def configuration
|
|
22
|
+
@configuration ||= Configuration.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def configure
|
|
26
|
+
yield(configuration)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def current_tenant
|
|
30
|
+
Current.tenant
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def current_tenant=(tenant)
|
|
34
|
+
if Current.tenant && tenant && Current.tenant != tenant
|
|
35
|
+
message = "Unsafe tenant override attempted: changing tenant from #{Current.tenant.id} to #{tenant.id}"
|
|
36
|
+
case configuration.audit_overrides
|
|
37
|
+
when :raise
|
|
38
|
+
raise TenantOverrideError, message
|
|
39
|
+
when :log
|
|
40
|
+
if defined?(Rails) && Rails.logger
|
|
41
|
+
Rails.logger.warn("[Tenantify] #{message}")
|
|
42
|
+
else
|
|
43
|
+
warn("[Tenantify] #{message}")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
Current.tenant = tenant
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def current_tenant_id
|
|
51
|
+
current_tenant&.id
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def tenant_scoped?
|
|
55
|
+
!Current.tenant_scope_disabled
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def switch_to(tenant, &block)
|
|
59
|
+
old_tenant = Current.tenant
|
|
60
|
+
Current.tenant = tenant
|
|
61
|
+
yield
|
|
62
|
+
ensure
|
|
63
|
+
Current.tenant = old_tenant
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def without_tenant(&block)
|
|
67
|
+
old_disabled = Current.tenant_scope_disabled
|
|
68
|
+
Current.tenant_scope_disabled = true
|
|
69
|
+
yield
|
|
70
|
+
ensure
|
|
71
|
+
Current.tenant_scope_disabled = old_disabled
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def tenant_class
|
|
75
|
+
class_name = configuration.tenant_model
|
|
76
|
+
unless class_name
|
|
77
|
+
raise Tenantify::Error, "tenant_model is not configured. Define it in Tenantify.configure."
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
class_name.constantize
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rails-tenantify
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Syed M. Ghani
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-06-01 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: activesupport
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '7.0'
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: '9'
|
|
23
|
+
type: :runtime
|
|
24
|
+
prerelease: false
|
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
26
|
+
requirements:
|
|
27
|
+
- - ">="
|
|
28
|
+
- !ruby/object:Gem::Version
|
|
29
|
+
version: '7.0'
|
|
30
|
+
- - "<"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '9'
|
|
33
|
+
- !ruby/object:Gem::Dependency
|
|
34
|
+
name: activerecord
|
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '7.0'
|
|
40
|
+
- - "<"
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '9'
|
|
43
|
+
type: :runtime
|
|
44
|
+
prerelease: false
|
|
45
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '7.0'
|
|
50
|
+
- - "<"
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '9'
|
|
53
|
+
- !ruby/object:Gem::Dependency
|
|
54
|
+
name: activejob
|
|
55
|
+
requirement: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - ">="
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '7.0'
|
|
60
|
+
- - "<"
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: '9'
|
|
63
|
+
type: :development
|
|
64
|
+
prerelease: false
|
|
65
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
66
|
+
requirements:
|
|
67
|
+
- - ">="
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: '7.0'
|
|
70
|
+
- - "<"
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '9'
|
|
73
|
+
- !ruby/object:Gem::Dependency
|
|
74
|
+
name: rspec
|
|
75
|
+
requirement: !ruby/object:Gem::Requirement
|
|
76
|
+
requirements:
|
|
77
|
+
- - "~>"
|
|
78
|
+
- !ruby/object:Gem::Version
|
|
79
|
+
version: '3.12'
|
|
80
|
+
type: :development
|
|
81
|
+
prerelease: false
|
|
82
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
83
|
+
requirements:
|
|
84
|
+
- - "~>"
|
|
85
|
+
- !ruby/object:Gem::Version
|
|
86
|
+
version: '3.12'
|
|
87
|
+
- !ruby/object:Gem::Dependency
|
|
88
|
+
name: sqlite3
|
|
89
|
+
requirement: !ruby/object:Gem::Requirement
|
|
90
|
+
requirements:
|
|
91
|
+
- - ">="
|
|
92
|
+
- !ruby/object:Gem::Version
|
|
93
|
+
version: '1.4'
|
|
94
|
+
- - "<"
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '3'
|
|
97
|
+
type: :development
|
|
98
|
+
prerelease: false
|
|
99
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - ">="
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '1.4'
|
|
104
|
+
- - "<"
|
|
105
|
+
- !ruby/object:Gem::Version
|
|
106
|
+
version: '3'
|
|
107
|
+
description: |
|
|
108
|
+
Tenantify provides row-level multi-tenancy for Rails 7+ applications: model scoping,
|
|
109
|
+
controller tenant resolution, ActiveJob and Sidekiq context propagation, bulk-write
|
|
110
|
+
protection, and RSpec helpers — a maintained alternative to acts_as_tenant.
|
|
111
|
+
email:
|
|
112
|
+
- syedghani001@gmail.com
|
|
113
|
+
executables: []
|
|
114
|
+
extensions: []
|
|
115
|
+
extra_rdoc_files: []
|
|
116
|
+
files:
|
|
117
|
+
- CHANGELOG.md
|
|
118
|
+
- LICENSE
|
|
119
|
+
- README.md
|
|
120
|
+
- lib/tenantify.rb
|
|
121
|
+
- lib/tenantify/configuration.rb
|
|
122
|
+
- lib/tenantify/controller.rb
|
|
123
|
+
- lib/tenantify/current.rb
|
|
124
|
+
- lib/tenantify/errors.rb
|
|
125
|
+
- lib/tenantify/job.rb
|
|
126
|
+
- lib/tenantify/middleware/sidekiq.rb
|
|
127
|
+
- lib/tenantify/railtie.rb
|
|
128
|
+
- lib/tenantify/resolvers/header.rb
|
|
129
|
+
- lib/tenantify/resolvers/subdomain.rb
|
|
130
|
+
- lib/tenantify/scoped.rb
|
|
131
|
+
- lib/tenantify/switcher.rb
|
|
132
|
+
- lib/tenantify/test_helpers.rb
|
|
133
|
+
- lib/tenantify/version.rb
|
|
134
|
+
homepage: https://github.com/sghani001/rails-tenantify
|
|
135
|
+
licenses:
|
|
136
|
+
- MIT
|
|
137
|
+
metadata:
|
|
138
|
+
homepage_uri: https://github.com/sghani001/rails-tenantify
|
|
139
|
+
source_code_uri: https://github.com/sghani001/rails-tenantify
|
|
140
|
+
documentation_uri: https://github.com/sghani001/rails-tenantify#readme
|
|
141
|
+
changelog_uri: https://github.com/sghani001/rails-tenantify/blob/main/CHANGELOG.md
|
|
142
|
+
rubygems_mfa_required: 'true'
|
|
143
|
+
post_install_message:
|
|
144
|
+
rdoc_options: []
|
|
145
|
+
require_paths:
|
|
146
|
+
- lib
|
|
147
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
148
|
+
requirements:
|
|
149
|
+
- - ">="
|
|
150
|
+
- !ruby/object:Gem::Version
|
|
151
|
+
version: 3.1.0
|
|
152
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
153
|
+
requirements:
|
|
154
|
+
- - ">="
|
|
155
|
+
- !ruby/object:Gem::Version
|
|
156
|
+
version: '0'
|
|
157
|
+
requirements: []
|
|
158
|
+
rubygems_version: 3.5.3
|
|
159
|
+
signing_key:
|
|
160
|
+
specification_version: 4
|
|
161
|
+
summary: Modern multi-tenancy for Rails — row-level tenant scoping with jobs, controllers,
|
|
162
|
+
and tests.
|
|
163
|
+
test_files: []
|