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 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
+ [![CI](https://github.com/sghani001/rails-tenantify/actions/workflows/ci.yml/badge.svg)](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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/current_attributes"
4
+
5
+ module Tenantify
6
+ class Current < ActiveSupport::CurrentAttributes
7
+ attribute :tenant
8
+ attribute :tenant_scope_disabled
9
+ end
10
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tenantify
4
+ class Error < StandardError; end
5
+
6
+ class TenantNotFoundError < Error; end
7
+ class TenantMismatchError < Error; end
8
+ class TenantOverrideError < Error; end
9
+ 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tenantify
4
+ VERSION = "0.1.0"
5
+ 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: []