rls_multi_tenant 0.2.9 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a289abc220420817eb9b817ea2b40cdc44021491fe362f05d2d0dd982ffb21ab
4
- data.tar.gz: 65c2f9f291a39026ac829f7252298eb527cd0d25c79a4ead1bc461a978d4d3df
3
+ metadata.gz: 8fab0710fc915514adcd0f8ba409b24557b5cafe0f1f0bdc4084b24d25b2fba5
4
+ data.tar.gz: 6359b5afdeb5493f4e56482682d6e82ff4a9e6aafd36adbfdf8998630b715525
5
5
  SHA512:
6
- metadata.gz: 896ae1cd8155bc3c2c47064776a1780459e0f2a29c5f307e186514b0a3a2be21ff682a940e6c09e989b6a9033f230fdf872865b02f8152d34af07888f041175c
7
- data.tar.gz: d40bd80268219f951db636be6f8e05eefa9ab82c172ed4085eb2e18597e94cf3cc902d76bf70fb803c497e2a8c629e4c13982c9fcd67e2311a61e90fd1b5b226
6
+ metadata.gz: a5079b1e2b6d73d30197ffd65042468301bc80afb36a573f2f6edcbf9663a320dca71354828f839bdbcac1d35ee72fcc70bd060ef9ee379ffe028740f99ac2f7
7
+ data.tar.gz: 89af2331b8b0f91f844e2a994e40f7d5162ea739a744d41a0e3d02675b5ca89a5c3d1f53f79b6ab0f8ab07e17d229893b937bb4fb2c2c2d4e2499ee504788dd5
data/CHANGELOG.md ADDED
@@ -0,0 +1,57 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.3.0] - 2026-06-04
9
+
10
+ Security-focused release. Hardens tenant isolation and SQL handling.
11
+
12
+ ### Breaking Changes
13
+
14
+ - **`TenantContext.switch` now runs its block inside a database transaction.**
15
+ The block form previously used a session-level `SET`; it now uses `SET LOCAL`
16
+ wrapped in a transaction so PostgreSQL guarantees the tenant context is cleared
17
+ when the transaction ends, preventing it from leaking onto pooled connections.
18
+
19
+ Because the subdomain middleware wraps each request in `switch`, the entire
20
+ request now executes within a single transaction. Observable consequences:
21
+ - `after_commit` callbacks fire at the end of the request instead of per-save.
22
+ - An unrescued error inside the block rolls back all of the request's writes.
23
+ - Application-level `transaction` blocks become savepoints (`requires_new`).
24
+ - The connection is held for the duration of the request.
25
+
26
+ If you relied on per-save commit semantics, review those code paths. The
27
+ permanent `switch!` (session-level `SET`) still exists for the console and
28
+ single-tenant scripts, but is now documented as unsafe on pooled connections —
29
+ always pair it with `reset!`.
30
+
31
+ ### Security
32
+
33
+ - **Cross-tenant context leak fixed** — `switch` uses `SET LOCAL` in a
34
+ transaction; the context can no longer survive on a pooled connection if
35
+ cleanup fails (crash, killed thread, lost connection).
36
+ - **SUPERUSER and inherited BYPASSRLS now rejected** — `SecurityValidator`
37
+ inspects the actually connected role (`current_user`), rejects `rolsuper`
38
+ (superusers bypass RLS unconditionally), and evaluates the effective
39
+ `BYPASSRLS` privilege across inherited roles via `pg_has_role`.
40
+ - **SQL identifier injection hardened** — `RlsHelper` and `TenantContext` quote
41
+ table/column/policy identifiers (`quote_table_name` / `quote_column_name`),
42
+ validate identifiers against a strict pattern, and pass catalog literals and
43
+ `current_setting` arguments through `connection.quote`.
44
+ - **Cross-tenant assignment blocked** — `MultiTenant#set_tenant_id` raises when
45
+ an explicit `tenant_id` differs from the active context and always pins the
46
+ value to the current tenant, adding an application-layer guard on top of
47
+ database enforcement.
48
+
49
+ ### Fixed
50
+
51
+ - `tenant_class` is resolved on every call instead of being memoized, avoiding a
52
+ stale class object across development code reloads and reconfiguration.
53
+ - Removed the railtie initializer that reset configuration to defaults on every
54
+ boot and could clobber a host application's settings depending on load order.
55
+ - Subdomain extraction now handles IPv6 hosts (`::1`, `[::1]`) and `host:port`
56
+ forms via `IPAddr`, instead of treating them as domains and producing a bogus
57
+ subdomain.
@@ -17,14 +17,31 @@ module RlsMultiTenant
17
17
 
18
18
  def set_tenant_id
19
19
  current_tenant = RlsMultiTenant.tenant_class.current
20
+ ensure_tenant_context!(current_tenant)
20
21
 
21
- if current_tenant && send(RlsMultiTenant.tenant_id_column).blank?
22
- send("#{RlsMultiTenant.tenant_id_column}=", current_tenant.id)
23
- elsif current_tenant.nil?
24
- raise RlsMultiTenant::Error,
25
- "Cannot create #{self.class.name} without tenant context. " \
26
- 'This model requires a tenant context. '
27
- end
22
+ column = RlsMultiTenant.tenant_id_column
23
+ ensure_same_tenant!(send(column), current_tenant)
24
+ send("#{column}=", current_tenant.id)
25
+ end
26
+
27
+ def ensure_tenant_context!(current_tenant)
28
+ return unless current_tenant.nil?
29
+
30
+ raise RlsMultiTenant::Error,
31
+ "Cannot create #{self.class.name} without tenant context. " \
32
+ 'This model requires a tenant context. '
33
+ end
34
+
35
+ # Reject an explicit tenant_id that points at a different tenant than the
36
+ # active context. The database (FORCE RLS + WITH CHECK) is the real
37
+ # guard, but failing fast here prevents silently building a record that
38
+ # the policy will reject, and blocks cross-tenant writes at the
39
+ # application layer as defense in depth.
40
+ def ensure_same_tenant!(assigned, current_tenant)
41
+ return if assigned.blank? || assigned.to_s == current_tenant.id.to_s
42
+
43
+ raise RlsMultiTenant::Error,
44
+ "Cannot assign #{self.class.name} to a different tenant than the current context."
28
45
  end
29
46
  end
30
47
  end
@@ -5,33 +5,59 @@ module RlsMultiTenant
5
5
  module TenantContext
6
6
  extend ActiveSupport::Concern
7
7
 
8
+ # PostgreSQL unquoted identifier pattern, used to validate the configured
9
+ # tenant id column before embedding it in a GUC name.
10
+ IDENTIFIER_PATTERN = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
11
+
8
12
  SET_TENANT_ID_SQL = 'SET %s = %s'
13
+ SET_LOCAL_TENANT_ID_SQL = 'SET LOCAL %s = %s'
9
14
  RESET_TENANT_ID_SQL = 'RESET %s'
15
+ RESET_LOCAL_TENANT_ID_SQL = 'SET LOCAL %s TO DEFAULT'
10
16
 
11
17
  # rubocop:disable Metrics/BlockLength
12
18
  class_methods do
13
19
  def tenant_session_var
14
- "rls.#{RlsMultiTenant.tenant_id_column}"
20
+ column = RlsMultiTenant.tenant_id_column.to_s
21
+ unless column.match?(IDENTIFIER_PATTERN)
22
+ raise ConfigurationError, "Invalid tenant_id_column: #{RlsMultiTenant.tenant_id_column.inspect}"
23
+ end
24
+
25
+ "rls.#{column}"
15
26
  end
16
27
 
17
- # Switch tenant context for a block
28
+ # Switch tenant context for a block.
29
+ #
30
+ # Uses SET LOCAL inside a transaction so PostgreSQL itself scopes the
31
+ # tenant context to the transaction and guarantees it is cleared when
32
+ # the transaction ends — even if the block raises or the process dies
33
+ # mid-request. This prevents the context from leaking onto a pooled
34
+ # connection and being reused by a different tenant's request.
18
35
  def switch(tenant_or_id)
19
- previous_tenant_id = current_tenant_id
20
- switch!(tenant_or_id)
21
- yield
22
- ensure
23
- begin
24
- switch!(previous_tenant_id)
25
- rescue StandardError => _e
26
- reset!
36
+ tenant_id = extract_tenant_id(tenant_or_id)
37
+ validate_tenant_exists!(tenant_id)
38
+
39
+ connection.transaction(requires_new: true) do
40
+ previous_tenant_id = current_tenant_id
41
+ apply_tenant_context(tenant_id, local: true)
42
+ begin
43
+ yield
44
+ ensure
45
+ apply_tenant_context(previous_tenant_id, local: true)
46
+ end
27
47
  end
28
48
  end
29
49
 
30
- # Switch tenant context permanently (until reset)
50
+ # Switch tenant context permanently (until reset).
51
+ #
52
+ # WARNING: this sets a session-level variable that persists on the
53
+ # connection until reset! is called. On a pooled connection it can leak
54
+ # to subsequent requests. Prefer the block form `switch` whenever
55
+ # possible; only use switch! for the console or single-tenant scripts,
56
+ # and always pair it with reset!.
31
57
  def switch!(tenant_or_id)
32
58
  tenant_id = extract_tenant_id(tenant_or_id)
33
59
  validate_tenant_exists!(tenant_id)
34
- connection.execute format(SET_TENANT_ID_SQL, tenant_session_var, connection.quote(tenant_id))
60
+ apply_tenant_context(tenant_id, local: false)
35
61
  end
36
62
 
37
63
  # Reset tenant context
@@ -43,7 +69,7 @@ module RlsMultiTenant
43
69
  def current
44
70
  return nil unless connection.active?
45
71
 
46
- result = connection.execute("SELECT current_setting('#{tenant_session_var}', true) AS tenant_id")
72
+ result = connection.execute("SELECT current_setting(#{connection.quote(tenant_session_var)}, true) AS tenant_id")
47
73
  tenant_id = result.first&.dig('tenant_id')
48
74
 
49
75
  return nil if tenant_id.blank?
@@ -55,10 +81,22 @@ module RlsMultiTenant
55
81
 
56
82
  private
57
83
 
84
+ # Apply (or clear, when tenant_id is blank) the tenant context.
85
+ # local: true uses SET LOCAL (transaction-scoped); false uses session SET.
86
+ def apply_tenant_context(tenant_id, local:)
87
+ if tenant_id.blank?
88
+ reset_sql = local ? RESET_LOCAL_TENANT_ID_SQL : RESET_TENANT_ID_SQL
89
+ connection.execute format(reset_sql, tenant_session_var)
90
+ else
91
+ set_sql = local ? SET_LOCAL_TENANT_ID_SQL : SET_TENANT_ID_SQL
92
+ connection.execute format(set_sql, tenant_session_var, connection.quote(tenant_id))
93
+ end
94
+ end
95
+
58
96
  def current_tenant_id
59
97
  return nil unless connection.active?
60
98
 
61
- result = connection.execute("SELECT current_setting('#{tenant_session_var}', true) AS tenant_id")
99
+ result = connection.execute("SELECT current_setting(#{connection.quote(tenant_session_var)}, true) AS tenant_id")
62
100
  result.first&.dig('tenant_id')
63
101
  rescue ActiveRecord::StatementInvalid, PG::Error
64
102
  nil
@@ -66,6 +104,8 @@ module RlsMultiTenant
66
104
 
67
105
  def extract_tenant_id(tenant_or_id)
68
106
  case tenant_or_id
107
+ when nil
108
+ nil
69
109
  when ->(obj) { obj.is_a?(RlsMultiTenant.tenant_class) }
70
110
  tenant_or_id.id
71
111
  when String, Integer
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'ipaddr'
4
+
3
5
  module RlsMultiTenant
4
6
  module Middleware
5
7
  class SubdomainTenantSelector
@@ -84,13 +86,13 @@ module RlsMultiTenant
84
86
  end
85
87
 
86
88
  def extract_subdomain(host)
87
- return nil if host.blank? || ip_host?(host)
89
+ return nil if host.blank?
88
90
 
89
- # Remove port if present
90
- host = host.split(':').first
91
+ hostname = extract_hostname(host)
92
+ return nil if hostname.blank? || ip_host?(hostname)
91
93
 
92
94
  # Split by dots and get the first part (subdomain)
93
- parts = host.split('.')
95
+ parts = hostname.split('.')
94
96
 
95
97
  # Handle localhost development (e.g., foo.localhost:3000) or standard domains (e.g., foo.example.com)
96
98
  return unless (parts.length == 2 && parts.last == 'localhost') || parts.length >= 3
@@ -98,8 +100,26 @@ module RlsMultiTenant
98
100
  parts.first
99
101
  end
100
102
 
101
- def ip_host?(host)
102
- !/^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/.match(host).nil?
103
+ # Strip an optional :port and IPv6 brackets, returning the bare hostname or
104
+ # IP literal. Handles host, host:port, ipv4, ipv4:port, [ipv6], [ipv6]:port
105
+ # and bare ipv6.
106
+ def extract_hostname(host)
107
+ if host.start_with?('[') # [ipv6] or [ipv6]:port
108
+ host[/\A\[(.+?)\]/, 1]
109
+ elsif host.count(':') > 1 # bare ipv6 (no port)
110
+ host
111
+ else # hostname or ipv4, optionally with :port
112
+ host.split(':').first
113
+ end
114
+ end
115
+
116
+ def ip_host?(hostname)
117
+ return false if hostname.blank?
118
+
119
+ IPAddr.new(hostname)
120
+ true
121
+ rescue IPAddr::InvalidAddressError
122
+ false
103
123
  end
104
124
 
105
125
  def excluded_subdomain?(subdomain)
@@ -10,15 +10,6 @@ module RlsMultiTenant
10
10
  require 'rls_multi_tenant/generators/model/model_generator'
11
11
  end
12
12
 
13
- initializer 'rls_multi_tenant.configure' do |_app|
14
- # Configure the gem
15
- RlsMultiTenant.configure do |config|
16
- config.tenant_class_name = 'Tenant'
17
- config.tenant_id_column = :tenant_id
18
- config.enable_security_validation = true
19
- end
20
- end
21
-
22
13
  initializer 'rls_multi_tenant.security_validation', after: :load_config_initializers do |app|
23
14
  if RlsMultiTenant.enable_security_validation
24
15
  app.config.after_initialize do
@@ -2,41 +2,50 @@
2
2
 
3
3
  module RlsMultiTenant
4
4
  module RlsHelper
5
+ # PostgreSQL unquoted identifier: starts with a letter or underscore,
6
+ # followed by letters, digits or underscores.
7
+ IDENTIFIER_PATTERN = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
8
+
5
9
  class << self
6
10
  # Enable RLS on a table with a policy
7
11
  def enable_rls_for_table(table_name, tenant_column: RlsMultiTenant.tenant_id_column)
12
+ table = quoted_table(table_name)
13
+ column = quoted_column(tenant_column)
14
+ policy = quoted_policy_name(table_name)
15
+
8
16
  # Enable RLS
9
- ActiveRecord::Base.connection.execute("ALTER TABLE #{table_name} ENABLE ROW LEVEL SECURITY, FORCE ROW LEVEL SECURITY")
17
+ connection.execute("ALTER TABLE #{table} ENABLE ROW LEVEL SECURITY, FORCE ROW LEVEL SECURITY")
10
18
 
11
19
  # Create policy (drop if exists first)
12
- policy_name = "#{table_name}_app_user"
13
- ActiveRecord::Base.connection.execute("DROP POLICY IF EXISTS #{policy_name} ON #{table_name}")
20
+ connection.execute("DROP POLICY IF EXISTS #{policy} ON #{table}")
14
21
 
15
- tenant_session_var = "rls.#{RlsMultiTenant.tenant_id_column}"
16
- policy_sql = "CREATE POLICY #{policy_name} ON #{table_name} " \
17
- "USING (#{tenant_column} = NULLIF(current_setting('#{tenant_session_var}', TRUE), '')::uuid)"
22
+ tenant_session_var = "rls.#{validated_identifier(RlsMultiTenant.tenant_id_column)}"
23
+ policy_sql = "CREATE POLICY #{policy} ON #{table} " \
24
+ "USING (#{column} = NULLIF(current_setting(#{connection.quote(tenant_session_var)}, TRUE), '')::uuid)"
18
25
 
19
- ActiveRecord::Base.connection.execute(policy_sql)
26
+ connection.execute(policy_sql)
20
27
 
21
- Rails.logger&.info "✅ RLS enabled for table #{table_name} with policy #{policy_name}"
28
+ Rails.logger&.info "✅ RLS enabled for table #{table_name} with policy #{table_name}_app_user"
22
29
  end
23
30
 
24
31
  # Disable RLS on a table
25
32
  def disable_rls_for_table(table_name)
33
+ table = quoted_table(table_name)
34
+ policy = quoted_policy_name(table_name)
35
+
26
36
  # Drop policy
27
- policy_name = "#{table_name}_app_user"
28
- ActiveRecord::Base.connection.execute("DROP POLICY IF EXISTS #{policy_name} ON #{table_name}")
37
+ connection.execute("DROP POLICY IF EXISTS #{policy} ON #{table}")
29
38
 
30
39
  # Disable RLS
31
- ActiveRecord::Base.connection.execute("ALTER TABLE #{table_name} DISABLE ROW LEVEL SECURITY, NO FORCE ROW LEVEL SECURITY")
40
+ connection.execute("ALTER TABLE #{table} DISABLE ROW LEVEL SECURITY, NO FORCE ROW LEVEL SECURITY")
32
41
 
33
42
  Rails.logger&.info "✅ RLS disabled for table #{table_name}"
34
43
  end
35
44
 
36
45
  # Check if RLS is enabled on a table
37
46
  def rls_enabled?(table_name)
38
- result = ActiveRecord::Base.connection.execute(
39
- "SELECT relrowsecurity FROM pg_class WHERE relname = '#{table_name}'"
47
+ result = connection.execute(
48
+ "SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname = #{connection.quote(table_name.to_s)}"
40
49
  ).first
41
50
 
42
51
  result&.dig('relrowsecurity') == true && result&.dig('relforcerowsecurity') == true
@@ -44,10 +53,39 @@ module RlsMultiTenant
44
53
 
45
54
  # Get all RLS policies for a table
46
55
  def rls_policies(table_name)
47
- ActiveRecord::Base.connection.execute(
48
- "SELECT policyname, permissive, roles, cmd, qual FROM pg_policies WHERE tablename = '#{table_name}'"
56
+ connection.execute(
57
+ 'SELECT policyname, permissive, roles, cmd, qual FROM pg_policies ' \
58
+ "WHERE tablename = #{connection.quote(table_name.to_s)}"
49
59
  )
50
60
  end
61
+
62
+ private
63
+
64
+ def connection
65
+ ActiveRecord::Base.connection
66
+ end
67
+
68
+ def quoted_table(table_name)
69
+ connection.quote_table_name(validated_identifier(table_name))
70
+ end
71
+
72
+ def quoted_column(column_name)
73
+ connection.quote_column_name(validated_identifier(column_name))
74
+ end
75
+
76
+ def quoted_policy_name(table_name)
77
+ connection.quote_column_name("#{validated_identifier(table_name)}_app_user")
78
+ end
79
+
80
+ # Guard against SQL injection through identifiers (table/column names),
81
+ # which cannot be passed as bind parameters and must be embedded in the
82
+ # statement text.
83
+ def validated_identifier(identifier)
84
+ value = identifier.to_s
85
+ return value if value.match?(IDENTIFIER_PATTERN)
86
+
87
+ raise ArgumentError, "Invalid SQL identifier: #{identifier.inspect}"
88
+ end
51
89
  end
52
90
  end
53
91
  end
@@ -2,33 +2,74 @@
2
2
 
3
3
  module RlsMultiTenant
4
4
  class SecurityValidator
5
+ TRUTHY_VALUES = [true, 't'].freeze
6
+
5
7
  class << self
6
8
  def validate_database_user!
7
9
  return unless RlsMultiTenant.enable_security_validation
8
10
 
9
11
  begin
10
- # Get the current database configuration
11
- db_config = ActiveRecord::Base.connection_db_config
12
- username = db_config.configuration_hash[:username]
13
-
14
- # Check if the user has bypassrls privilege
15
- bypassrls_check = ActiveRecord::Base.connection.execute(
16
- "SELECT rolbypassrls FROM pg_roles WHERE rolname = '#{username}'"
17
- ).first
18
-
19
- if bypassrls_check && bypassrls_check['rolbypassrls']
20
- raise SecurityError, "Database user '#{username}' has BYPASSRLS privilege. " \
21
- 'In order to use RLS Multi-tenant, you must use a non-privileged user ' \
22
- 'without BYPASSRLS privilege.'
23
- end
24
-
25
- # Log the security check result
26
- Rails.logger&.info "✅ RLS Multi-tenant security check passed: Using user '#{username}' without BYPASSRLS privilege"
12
+ role = current_role
13
+ username = role && role['username']
14
+
15
+ ensure_not_superuser!(role, username)
16
+ ensure_no_bypassrls!(username)
17
+
18
+ Rails.logger&.info " RLS Multi-tenant security check passed: Using user '#{username}' " \
19
+ 'without SUPERUSER or BYPASSRLS privileges'
27
20
  rescue StandardError => e
28
21
  Rails.logger&.error "❌ RLS Multi-tenant security check failed: #{e.message}"
29
22
  raise e
30
23
  end
31
24
  end
25
+
26
+ private
27
+
28
+ # Inspect the actual connected role (current_user), not the configured
29
+ # username, so the check reflects what PostgreSQL really enforces and
30
+ # avoids interpolating a config value into SQL.
31
+ def current_role
32
+ ActiveRecord::Base.connection.execute(<<~SQL.squish).first
33
+ SELECT current_user AS username, rolsuper, rolbypassrls
34
+ FROM pg_roles WHERE rolname = current_user
35
+ SQL
36
+ end
37
+
38
+ # A SUPERUSER bypasses RLS unconditionally, regardless of the rolbypassrls
39
+ # flag, so it must be rejected explicitly.
40
+ def ensure_not_superuser!(role, username)
41
+ return unless truthy?(role && role['rolsuper'])
42
+
43
+ raise SecurityError, "Database user '#{username}' is a SUPERUSER. " \
44
+ 'Superusers bypass Row Level Security entirely. You must use a ' \
45
+ 'non-privileged, non-superuser role for RLS Multi-tenant.'
46
+ end
47
+
48
+ # BYPASSRLS can also be inherited through role membership, so check the
49
+ # effective privilege across every role the user is a member of.
50
+ def ensure_no_bypassrls!(username)
51
+ return unless bypassrls_effective?
52
+
53
+ raise SecurityError, "Database user '#{username}' has BYPASSRLS privilege " \
54
+ '(directly or through an inherited role). ' \
55
+ 'In order to use RLS Multi-tenant, you must use a non-privileged user ' \
56
+ 'without BYPASSRLS privilege.'
57
+ end
58
+
59
+ def bypassrls_effective?
60
+ result = ActiveRecord::Base.connection.execute(<<~SQL.squish).first
61
+ SELECT bool_or(rolbypassrls) AS bypass
62
+ FROM pg_roles WHERE pg_has_role(current_user, oid, 'USAGE')
63
+ SQL
64
+
65
+ truthy?(result && result['bypass'])
66
+ end
67
+
68
+ # PostgreSQL boolean columns may come back as true/false or 't'/'f'
69
+ # depending on the adapter/decoder configuration.
70
+ def truthy?(value)
71
+ TRUTHY_VALUES.include?(value)
72
+ end
32
73
  end
33
74
  end
34
75
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RlsMultiTenant
4
- VERSION = '0.2.9'
4
+ VERSION = '0.3.0'
5
5
  end
@@ -28,7 +28,10 @@ module RlsMultiTenant
28
28
  end
29
29
 
30
30
  def tenant_class
31
- @tenant_class ||= tenant_class_name.constantize
31
+ # Resolve on every call rather than memoizing: a memoized class object
32
+ # goes stale across code reloads in development and after the tenant
33
+ # class name is reconfigured, which can break associations.
34
+ tenant_class_name.constantize
32
35
  end
33
36
 
34
37
  def tenant_id_column
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rls_multi_tenant
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.9
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Coding Ways
@@ -158,6 +158,7 @@ extra_rdoc_files: []
158
158
  files:
159
159
  - ".rspec"
160
160
  - ".rubocop.yml"
161
+ - CHANGELOG.md
161
162
  - README.md
162
163
  - lib/rls_multi_tenant.rb
163
164
  - lib/rls_multi_tenant/concerns/multi_tenant.rb
@@ -204,7 +205,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
204
205
  - !ruby/object:Gem::Version
205
206
  version: '0'
206
207
  requirements: []
207
- rubygems_version: 3.6.9
208
+ rubygems_version: 4.0.8
208
209
  specification_version: 4
209
210
  summary: Rails gem for PostgreSQL Row Level Security (RLS) multi-tenant applications
210
211
  test_files: []