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 +4 -4
- data/.rubocop.yml +16 -0
- data/CHANGELOG.md +43 -0
- data/README.md +23 -0
- data/lib/rls_multi_tenant/concerns/tenant_context.rb +41 -1
- data/lib/rls_multi_tenant/security_validator.rb +8 -18
- data/lib/rls_multi_tenant/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9380863d6f5f1f68de3a4beaa1569cd6ad94bb5536604e0a444822628b946feb
|
|
4
|
+
data.tar.gz: eecebe1cecb827ed406b161f566de19d3effb7e8a96d73ebd497209b158e1c8b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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)
|
|
29
|
-
#
|
|
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
|
-
|
|
49
|
-
|
|
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)
|