rls_multi_tenant 0.2.9 → 0.3.1

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: 4e6d7f69028c0e956fc4d5caf7651dd885c4a9da43bed16a2d32e90c8177c7c9
4
+ data.tar.gz: 5c8d3042cf29d8a21aad002e16db1193d2d0e8b7fea7246e4766116e81d301f9
5
5
  SHA512:
6
- metadata.gz: 896ae1cd8155bc3c2c47064776a1780459e0f2a29c5f307e186514b0a3a2be21ff682a940e6c09e989b6a9033f230fdf872865b02f8152d34af07888f041175c
7
- data.tar.gz: d40bd80268219f951db636be6f8e05eefa9ab82c172ed4085eb2e18597e94cf3cc902d76bf70fb803c497e2a8c629e4c13982c9fcd67e2311a61e90fd1b5b226
6
+ metadata.gz: c35648de09e3f93962337d09fa699edc696a822924e19b046c1a2d6ae47663f4759f8e4c0541f1001c71f11f3b975827a7048e685082838f184c6f2becd33d9c
7
+ data.tar.gz: f7148ffd7576d6cc20ca513cfa92c6d21c9890dbbcacd9fcd9c42b755deba2ee584d0de8c88e644d019370944451d4797e2cba72e1c09e89866b3b33a779ff6d
data/.rubocop.yml CHANGED
@@ -36,6 +36,10 @@ Metrics/BlockLength:
36
36
  Metrics/MethodLength:
37
37
  Max: 25
38
38
 
39
+ Metrics/ModuleLength:
40
+ Exclude:
41
+ - 'spec/**/*'
42
+
39
43
  RSpec/ExampleLength:
40
44
  Max: 20
41
45
 
@@ -51,5 +55,16 @@ RSpec/DescribeClass:
51
55
  RSpec/DescribeMethod:
52
56
  Enabled: false
53
57
 
58
+ # The integration suite uses plain ActiveRecord models (no ApplicationRecord)
59
+ # and scenario-named spec files rather than one-file-per-class.
60
+ Rails/ApplicationRecord:
61
+ Exclude:
62
+ - 'spec/**/*'
63
+
64
+ RSpec/SpecFilePathFormat:
65
+ Exclude:
66
+ - 'spec/integration/**/*'
67
+ - 'spec/generators/**/*'
68
+
54
69
  Gemspec/DevelopmentDependencies:
55
70
  Enabled: false
data/CHANGELOG.md ADDED
@@ -0,0 +1,89 @@
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.1] - 2026-06-04
9
+
10
+ Corrects two bugs in the 0.3.0 security work, both uncovered by a new
11
+ real-PostgreSQL integration suite.
12
+
13
+ ### Fixed
14
+
15
+ - **Security validator no longer rejects valid roles.** 0.3.0 used `pg_has_role`
16
+ to flag BYPASSRLS inherited through role membership, but PostgreSQL does not
17
+ inherit the SUPERUSER or BYPASSRLS role attributes through membership — only
18
+ privileges (GRANTs). A role that merely belongs to a BYPASSRLS group does not
19
+ bypass RLS unless it explicitly `SET ROLE`s to it. The validator now checks
20
+ only `current_user`'s own `rolsuper` / `rolbypassrls`, which is what actually
21
+ governs RLS enforcement. Verified empirically against PostgreSQL 16.
22
+ - **Tenant context restore no longer masks an aborted-transaction error.** When
23
+ a `switch` block hit a database error that aborts the transaction (e.g. a
24
+ WITH CHECK policy violation), the ensure clause ran `SET LOCAL` to restore the
25
+ previous tenant and raised `PG::InFailedSqlTransaction`, hiding the original
26
+ error. The restore now ignores `ActiveRecord::StatementInvalid`; the rollback
27
+ clears the `SET LOCAL` anyway.
28
+
29
+ ### Internal
30
+
31
+ - Added a real-PostgreSQL integration suite (provisions privileged/unprivileged
32
+ roles, schema and the RLS policy) covering tenant isolation, WITH CHECK
33
+ enforcement, context lifecycle and the security validator; the suite
34
+ auto-skips when no database is reachable.
35
+ - Replaced mocked-only and tautological specs with behavior-driven unit specs,
36
+ real generator invocations, SimpleCov coverage, per-example configuration
37
+ isolation, randomized order, and a CI Ruby×Rails matrix with a Postgres
38
+ service.
39
+
40
+ ## [0.3.0] - 2026-06-04
41
+
42
+ Security-focused release. Hardens tenant isolation and SQL handling.
43
+
44
+ ### Breaking Changes
45
+
46
+ - **`TenantContext.switch` now runs its block inside a database transaction.**
47
+ The block form previously used a session-level `SET`; it now uses `SET LOCAL`
48
+ wrapped in a transaction so PostgreSQL guarantees the tenant context is cleared
49
+ when the transaction ends, preventing it from leaking onto pooled connections.
50
+
51
+ Because the subdomain middleware wraps each request in `switch`, the entire
52
+ request now executes within a single transaction. Observable consequences:
53
+ - `after_commit` callbacks fire at the end of the request instead of per-save.
54
+ - An unrescued error inside the block rolls back all of the request's writes.
55
+ - Application-level `transaction` blocks become savepoints (`requires_new`).
56
+ - The connection is held for the duration of the request.
57
+
58
+ If you relied on per-save commit semantics, review those code paths. The
59
+ permanent `switch!` (session-level `SET`) still exists for the console and
60
+ single-tenant scripts, but is now documented as unsafe on pooled connections —
61
+ always pair it with `reset!`.
62
+
63
+ ### Security
64
+
65
+ - **Cross-tenant context leak fixed** — `switch` uses `SET LOCAL` in a
66
+ transaction; the context can no longer survive on a pooled connection if
67
+ cleanup fails (crash, killed thread, lost connection).
68
+ - **SUPERUSER and inherited BYPASSRLS now rejected** — `SecurityValidator`
69
+ inspects the actually connected role (`current_user`), rejects `rolsuper`
70
+ (superusers bypass RLS unconditionally), and evaluates the effective
71
+ `BYPASSRLS` privilege across inherited roles via `pg_has_role`.
72
+ - **SQL identifier injection hardened** — `RlsHelper` and `TenantContext` quote
73
+ table/column/policy identifiers (`quote_table_name` / `quote_column_name`),
74
+ validate identifiers against a strict pattern, and pass catalog literals and
75
+ `current_setting` arguments through `connection.quote`.
76
+ - **Cross-tenant assignment blocked** — `MultiTenant#set_tenant_id` raises when
77
+ an explicit `tenant_id` differs from the active context and always pins the
78
+ value to the current tenant, adding an application-layer guard on top of
79
+ database enforcement.
80
+
81
+ ### Fixed
82
+
83
+ - `tenant_class` is resolved on every call instead of being memoized, avoiding a
84
+ stale class object across development code reloads and reconfiguration.
85
+ - Removed the railtie initializer that reset configuration to defaults on every
86
+ boot and could clobber a host application's settings depending on load order.
87
+ - Subdomain extraction now handles IPv6 hosts (`::1`, `[::1]`) and `host:port`
88
+ forms via `IPAddr`, instead of treating them as domains and producing a bogus
89
+ 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
+ restore_tenant_context(previous_tenant_id)
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,33 @@ module RlsMultiTenant
55
81
 
56
82
  private
57
83
 
84
+ # Restore the previous context on the way out of a `switch` block.
85
+ # If the block aborted the transaction (e.g. a policy violation), any
86
+ # further statement raises until rollback; that rollback will clear the
87
+ # SET LOCAL anyway, so the failure is safe to ignore and we must not let
88
+ # it mask the original error escaping the block.
89
+ def restore_tenant_context(previous_tenant_id)
90
+ apply_tenant_context(previous_tenant_id, local: true)
91
+ rescue ActiveRecord::StatementInvalid
92
+ nil
93
+ end
94
+
95
+ # Apply (or clear, when tenant_id is blank) the tenant context.
96
+ # local: true uses SET LOCAL (transaction-scoped); false uses session SET.
97
+ def apply_tenant_context(tenant_id, local:)
98
+ if tenant_id.blank?
99
+ reset_sql = local ? RESET_LOCAL_TENANT_ID_SQL : RESET_TENANT_ID_SQL
100
+ connection.execute format(reset_sql, tenant_session_var)
101
+ else
102
+ set_sql = local ? SET_LOCAL_TENANT_ID_SQL : SET_TENANT_ID_SQL
103
+ connection.execute format(set_sql, tenant_session_var, connection.quote(tenant_id))
104
+ end
105
+ end
106
+
58
107
  def current_tenant_id
59
108
  return nil unless connection.active?
60
109
 
61
- result = connection.execute("SELECT current_setting('#{tenant_session_var}', true) AS tenant_id")
110
+ result = connection.execute("SELECT current_setting(#{connection.quote(tenant_session_var)}, true) AS tenant_id")
62
111
  result.first&.dig('tenant_id')
63
112
  rescue ActiveRecord::StatementInvalid, PG::Error
64
113
  nil
@@ -66,6 +115,8 @@ module RlsMultiTenant
66
115
 
67
116
  def extract_tenant_id(tenant_or_id)
68
117
  case tenant_or_id
118
+ when nil
119
+ nil
69
120
  when ->(obj) { obj.is_a?(RlsMultiTenant.tenant_class) }
70
121
  tenant_or_id.id
71
122
  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,64 @@
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!(role, 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). Only this role's own
29
+ # attributes determine whether RLS is enforced: PostgreSQL does NOT
30
+ # inherit SUPERUSER or BYPASSRLS through role membership, so checking the
31
+ # current role is both correct and sufficient. Using current_user also
32
+ # avoids interpolating a config value into SQL.
33
+ def current_role
34
+ ActiveRecord::Base.connection.execute(<<~SQL.squish).first
35
+ SELECT current_user AS username, rolsuper, rolbypassrls
36
+ FROM pg_roles WHERE rolname = current_user
37
+ SQL
38
+ end
39
+
40
+ # A SUPERUSER bypasses RLS unconditionally, regardless of the rolbypassrls
41
+ # flag, so it must be rejected explicitly.
42
+ def ensure_not_superuser!(role, username)
43
+ return unless truthy?(role && role['rolsuper'])
44
+
45
+ raise SecurityError, "Database user '#{username}' is a SUPERUSER. " \
46
+ 'Superusers bypass Row Level Security entirely. You must use a ' \
47
+ 'non-privileged, non-superuser role for RLS Multi-tenant.'
48
+ end
49
+
50
+ def ensure_no_bypassrls!(role, username)
51
+ return unless truthy?(role && role['rolbypassrls'])
52
+
53
+ raise SecurityError, "Database user '#{username}' has BYPASSRLS privilege. " \
54
+ 'In order to use RLS Multi-tenant, you must use a non-privileged user ' \
55
+ 'without BYPASSRLS privilege.'
56
+ end
57
+
58
+ # PostgreSQL boolean columns may come back as true/false or 't'/'f'
59
+ # depending on the adapter/decoder configuration.
60
+ def truthy?(value)
61
+ TRUTHY_VALUES.include?(value)
62
+ end
32
63
  end
33
64
  end
34
65
  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.1'
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.1
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: []