rls_multi_tenant 0.3.0 → 0.4.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: 8fab0710fc915514adcd0f8ba409b24557b5cafe0f1f0bdc4084b24d25b2fba5
4
- data.tar.gz: 6359b5afdeb5493f4e56482682d6e82ff4a9e6aafd36adbfdf8998630b715525
3
+ metadata.gz: 9380863d6f5f1f68de3a4beaa1569cd6ad94bb5536604e0a444822628b946feb
4
+ data.tar.gz: eecebe1cecb827ed406b161f566de19d3effb7e8a96d73ebd497209b158e1c8b
5
5
  SHA512:
6
- metadata.gz: a5079b1e2b6d73d30197ffd65042468301bc80afb36a573f2f6edcbf9663a320dca71354828f839bdbcac1d35ee72fcc70bd060ef9ee379ffe028740f99ac2f7
7
- data.tar.gz: 89af2331b8b0f91f844e2a994e40f7d5162ea739a744d41a0e3d02675b5ca89a5c3d1f53f79b6ab0f8ab07e17d229893b937bb4fb2c2c2d4e2499ee504788dd5
6
+ metadata.gz: faeb27cf2559a330bf13294cea3b114d017cb77d9c6e9af95acba26572bbf2016f6ffe2f8fb0dc67b1e731205f34f4ff0c69a4a592a91d6ec5d8b2c68127a211
7
+ data.tar.gz: 3fb38ae302313ed9845e86f3120c267ae88ad3e8eea793355f36db359ad316f6eaaac11805d21696a0b940ce54ba755cf5b4c5fdb7741066f1bd5b60fc236726
data/.rubocop.yml CHANGED
@@ -36,6 +36,11 @@ Metrics/BlockLength:
36
36
  Metrics/MethodLength:
37
37
  Max: 25
38
38
 
39
+ Metrics/ModuleLength:
40
+ Max: 120
41
+ Exclude:
42
+ - 'spec/**/*'
43
+
39
44
  RSpec/ExampleLength:
40
45
  Max: 20
41
46
 
@@ -51,5 +56,16 @@ RSpec/DescribeClass:
51
56
  RSpec/DescribeMethod:
52
57
  Enabled: false
53
58
 
59
+ # The integration suite uses plain ActiveRecord models (no ApplicationRecord)
60
+ # and scenario-named spec files rather than one-file-per-class.
61
+ Rails/ApplicationRecord:
62
+ Exclude:
63
+ - 'spec/**/*'
64
+
65
+ RSpec/SpecFilePathFormat:
66
+ Exclude:
67
+ - 'spec/integration/**/*'
68
+ - 'spec/generators/**/*'
69
+
54
70
  Gemspec/DevelopmentDependencies:
55
71
  Enabled: false
data/CHANGELOG.md CHANGED
@@ -5,6 +5,49 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.0] - 2026-06-04
9
+
10
+ ### Added
11
+
12
+ - **Apartment-style switching by a unique field.** `Tenant.switch_by(value)`
13
+ resolves the tenant by a unique column (defaulting to the configured
14
+ `subdomain_field`) and switches context for the block; pass `attribute:` to
15
+ use another column (e.g. `switch_by('acme', attribute: :slug)`). A permanent
16
+ `switch_by!` variant is also available. Both raise `RlsMultiTenant::Error`
17
+ when no tenant matches. Instance-level delegators are provided too.
18
+
19
+ ## [0.3.1] - 2026-06-04
20
+
21
+ Corrects two bugs in the 0.3.0 security work, both uncovered by a new
22
+ real-PostgreSQL integration suite.
23
+
24
+ ### Fixed
25
+
26
+ - **Security validator no longer rejects valid roles.** 0.3.0 used `pg_has_role`
27
+ to flag BYPASSRLS inherited through role membership, but PostgreSQL does not
28
+ inherit the SUPERUSER or BYPASSRLS role attributes through membership — only
29
+ privileges (GRANTs). A role that merely belongs to a BYPASSRLS group does not
30
+ bypass RLS unless it explicitly `SET ROLE`s to it. The validator now checks
31
+ only `current_user`'s own `rolsuper` / `rolbypassrls`, which is what actually
32
+ governs RLS enforcement. Verified empirically against PostgreSQL 16.
33
+ - **Tenant context restore no longer masks an aborted-transaction error.** When
34
+ a `switch` block hit a database error that aborts the transaction (e.g. a
35
+ WITH CHECK policy violation), the ensure clause ran `SET LOCAL` to restore the
36
+ previous tenant and raised `PG::InFailedSqlTransaction`, hiding the original
37
+ error. The restore now ignores `ActiveRecord::StatementInvalid`; the rollback
38
+ clears the `SET LOCAL` anyway.
39
+
40
+ ### Internal
41
+
42
+ - Added a real-PostgreSQL integration suite (provisions privileged/unprivileged
43
+ roles, schema and the RLS policy) covering tenant isolation, WITH CHECK
44
+ enforcement, context lifecycle and the security validator; the suite
45
+ auto-skips when no database is reachable.
46
+ - Replaced mocked-only and tautological specs with behavior-driven unit specs,
47
+ real generator invocations, SimpleCov coverage, per-example configuration
48
+ isolation, randomized order, and a CI Ruby×Rails matrix with a Postgres
49
+ service.
50
+
8
51
  ## [0.3.0] - 2026-06-04
9
52
 
10
53
  Security-focused release. Hardens tenant isolation and SQL handling.
data/README.md CHANGED
@@ -152,6 +152,29 @@ Tenant.reset! # Reset context
152
152
  current_tenant = Tenant.current
153
153
  ```
154
154
 
155
+ #### Switching by a unique field (Apartment-style)
156
+
157
+ Instead of a tenant object or id, switch by any unique column. The attribute
158
+ defaults to the configured `subdomain_field`:
159
+
160
+ ```ruby
161
+ # Switch by subdomain (default field) for a block
162
+ Tenant.switch_by("company-a") do
163
+ User.create!(name: "User from Company A")
164
+ end
165
+
166
+ # Switch by another unique column
167
+ Tenant.switch_by("acme-inc", attribute: :slug) do
168
+ # ...
169
+ end
170
+
171
+ # Permanent variant (until reset!)
172
+ Tenant.switch_by!("company-a")
173
+ Tenant.reset!
174
+ ```
175
+
176
+ `switch_by` raises `RlsMultiTenant::Error` when no tenant matches the value.
177
+
155
178
  ### Automatic Subdomain-Based Tenant Switching
156
179
 
157
180
  The gem includes middleware that automatically switches tenants based on the request subdomain. This is enabled by default and works seamlessly with your tenant model.
@@ -42,7 +42,7 @@ module RlsMultiTenant
42
42
  begin
43
43
  yield
44
44
  ensure
45
- apply_tenant_context(previous_tenant_id, local: true)
45
+ restore_tenant_context(previous_tenant_id)
46
46
  end
47
47
  end
48
48
  end
@@ -60,6 +60,20 @@ module RlsMultiTenant
60
60
  apply_tenant_context(tenant_id, local: false)
61
61
  end
62
62
 
63
+ # Switch tenant context for a block, identifying the tenant by a unique
64
+ # field (Apartment-style). Defaults to the configured subdomain field.
65
+ #
66
+ # Tenant.switch_by('acme') { ... } # by subdomain
67
+ # Tenant.switch_by('acme', attribute: :slug) { } # by another column
68
+ def switch_by(value, attribute: RlsMultiTenant.subdomain_field, &block)
69
+ switch(find_tenant_by!(attribute, value), &block)
70
+ end
71
+
72
+ # Permanent variant of switch_by (until reset). Same caveats as switch!.
73
+ def switch_by!(value, attribute: RlsMultiTenant.subdomain_field)
74
+ switch!(find_tenant_by!(attribute, value))
75
+ end
76
+
63
77
  # Reset tenant context
64
78
  def reset!
65
79
  connection.execute format(RESET_TENANT_ID_SQL, tenant_session_var)
@@ -81,6 +95,26 @@ module RlsMultiTenant
81
95
 
82
96
  private
83
97
 
98
+ # Look up a tenant by a unique attribute, raising if none matches.
99
+ def find_tenant_by!(attribute, value)
100
+ tenant = RlsMultiTenant.tenant_class.find_by(attribute => value)
101
+ return tenant if tenant
102
+
103
+ raise RlsMultiTenant::Error,
104
+ "#{RlsMultiTenant.tenant_class_name} with #{attribute}=#{value.inspect} not found"
105
+ end
106
+
107
+ # Restore the previous context on the way out of a `switch` block.
108
+ # If the block aborted the transaction (e.g. a policy violation), any
109
+ # further statement raises until rollback; that rollback will clear the
110
+ # SET LOCAL anyway, so the failure is safe to ignore and we must not let
111
+ # it mask the original error escaping the block.
112
+ def restore_tenant_context(previous_tenant_id)
113
+ apply_tenant_context(previous_tenant_id, local: true)
114
+ rescue ActiveRecord::StatementInvalid
115
+ nil
116
+ end
117
+
84
118
  # Apply (or clear, when tenant_id is blank) the tenant context.
85
119
  # local: true uses SET LOCAL (transaction-scoped); false uses session SET.
86
120
  def apply_tenant_context(tenant_id, local:)
@@ -131,8 +165,14 @@ module RlsMultiTenant
131
165
  self.class.switch(tenant_or_id, &block)
132
166
  end
133
167
 
168
+ def switch_by(value, attribute: RlsMultiTenant.subdomain_field, &block)
169
+ self.class.switch_by(value, attribute: attribute, &block)
170
+ end
171
+
134
172
  delegate :switch!, to: :class
135
173
 
174
+ delegate :switch_by!, to: :class
175
+
136
176
  delegate :reset!, to: :class
137
177
  end
138
178
  end
@@ -13,7 +13,7 @@ module RlsMultiTenant
13
13
  username = role && role['username']
14
14
 
15
15
  ensure_not_superuser!(role, username)
16
- ensure_no_bypassrls!(username)
16
+ ensure_no_bypassrls!(role, username)
17
17
 
18
18
  Rails.logger&.info "✅ RLS Multi-tenant security check passed: Using user '#{username}' " \
19
19
  'without SUPERUSER or BYPASSRLS privileges'
@@ -25,8 +25,10 @@ module RlsMultiTenant
25
25
 
26
26
  private
27
27
 
28
- # Inspect the actual connected role (current_user), not the configured
29
- # username, so the check reflects what PostgreSQL really enforces and
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
30
32
  # avoids interpolating a config value into SQL.
31
33
  def current_role
32
34
  ActiveRecord::Base.connection.execute(<<~SQL.squish).first
@@ -45,26 +47,14 @@ module RlsMultiTenant
45
47
  'non-privileged, non-superuser role for RLS Multi-tenant.'
46
48
  end
47
49
 
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?
50
+ def ensure_no_bypassrls!(role, username)
51
+ return unless truthy?(role && role['rolbypassrls'])
52
52
 
53
- raise SecurityError, "Database user '#{username}' has BYPASSRLS privilege " \
54
- '(directly or through an inherited role). ' \
53
+ raise SecurityError, "Database user '#{username}' has BYPASSRLS privilege. " \
55
54
  'In order to use RLS Multi-tenant, you must use a non-privileged user ' \
56
55
  'without BYPASSRLS privilege.'
57
56
  end
58
57
 
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
58
  # PostgreSQL boolean columns may come back as true/false or 't'/'f'
69
59
  # depending on the adapter/decoder configuration.
70
60
  def truthy?(value)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RlsMultiTenant
4
- VERSION = '0.3.0'
4
+ VERSION = '0.4.0'
5
5
  end
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.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Coding Ways