rls_multi_tenant 0.2.8 → 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 +4 -4
- data/CHANGELOG.md +57 -0
- data/lib/rls_multi_tenant/concerns/multi_tenant.rb +24 -7
- data/lib/rls_multi_tenant/concerns/tenant_context.rb +53 -29
- data/lib/rls_multi_tenant/middleware/subdomain_tenant_selector.rb +26 -6
- data/lib/rls_multi_tenant/railtie.rb +0 -9
- data/lib/rls_multi_tenant/rls_helper.rb +53 -15
- data/lib/rls_multi_tenant/security_validator.rb +58 -17
- data/lib/rls_multi_tenant/version.rb +1 -1
- data/lib/rls_multi_tenant.rb +4 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8fab0710fc915514adcd0f8ba409b24557b5cafe0f1f0bdc4084b24d25b2fba5
|
|
4
|
+
data.tar.gz: 6359b5afdeb5493f4e56482682d6e82ff4a9e6aafd36adbfdf8998630b715525
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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,43 +5,63 @@ 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
|
-
|
|
15
|
-
|
|
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
|
|
16
24
|
|
|
17
|
-
|
|
18
|
-
Thread.current[:"#{name}_tenant_stack"] ||= []
|
|
25
|
+
"rls.#{column}"
|
|
19
26
|
end
|
|
20
27
|
|
|
21
|
-
# 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.
|
|
22
35
|
def switch(tenant_or_id)
|
|
23
36
|
tenant_id = extract_tenant_id(tenant_or_id)
|
|
24
37
|
validate_tenant_exists!(tenant_id)
|
|
25
38
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
47
|
+
end
|
|
33
48
|
end
|
|
34
49
|
|
|
35
|
-
# 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!.
|
|
36
57
|
def switch!(tenant_or_id)
|
|
37
58
|
tenant_id = extract_tenant_id(tenant_or_id)
|
|
38
59
|
validate_tenant_exists!(tenant_id)
|
|
39
|
-
|
|
60
|
+
apply_tenant_context(tenant_id, local: false)
|
|
40
61
|
end
|
|
41
62
|
|
|
42
63
|
# Reset tenant context
|
|
43
64
|
def reset!
|
|
44
|
-
tenant_stack.clear
|
|
45
65
|
connection.execute format(RESET_TENANT_ID_SQL, tenant_session_var)
|
|
46
66
|
end
|
|
47
67
|
|
|
@@ -49,8 +69,8 @@ module RlsMultiTenant
|
|
|
49
69
|
def current
|
|
50
70
|
return nil unless connection.active?
|
|
51
71
|
|
|
52
|
-
result = connection.execute("
|
|
53
|
-
tenant_id = result.first&.dig(
|
|
72
|
+
result = connection.execute("SELECT current_setting(#{connection.quote(tenant_session_var)}, true) AS tenant_id")
|
|
73
|
+
tenant_id = result.first&.dig('tenant_id')
|
|
54
74
|
|
|
55
75
|
return nil if tenant_id.blank?
|
|
56
76
|
|
|
@@ -61,27 +81,31 @@ module RlsMultiTenant
|
|
|
61
81
|
|
|
62
82
|
private
|
|
63
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
|
+
|
|
64
96
|
def current_tenant_id
|
|
65
97
|
return nil unless connection.active?
|
|
66
98
|
|
|
67
|
-
result = connection.execute("
|
|
68
|
-
result.first&.dig(
|
|
99
|
+
result = connection.execute("SELECT current_setting(#{connection.quote(tenant_session_var)}, true) AS tenant_id")
|
|
100
|
+
result.first&.dig('tenant_id')
|
|
69
101
|
rescue ActiveRecord::StatementInvalid, PG::Error
|
|
70
102
|
nil
|
|
71
103
|
end
|
|
72
104
|
|
|
73
|
-
def restore_tenant_context!
|
|
74
|
-
previous_tenant_id = tenant_stack.pop
|
|
75
|
-
|
|
76
|
-
if previous_tenant_id.present?
|
|
77
|
-
connection.execute format(SET_TENANT_ID_SQL, tenant_session_var, connection.quote(previous_tenant_id))
|
|
78
|
-
else
|
|
79
|
-
connection.execute format(RESET_TENANT_ID_SQL, tenant_session_var)
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
|
-
|
|
83
105
|
def extract_tenant_id(tenant_or_id)
|
|
84
106
|
case tenant_or_id
|
|
107
|
+
when nil
|
|
108
|
+
nil
|
|
85
109
|
when ->(obj) { obj.is_a?(RlsMultiTenant.tenant_class) }
|
|
86
110
|
tenant_or_id.id
|
|
87
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?
|
|
89
|
+
return nil if host.blank?
|
|
88
90
|
|
|
89
|
-
|
|
90
|
-
|
|
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 =
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 #{
|
|
17
|
-
"USING (#{
|
|
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
|
-
|
|
26
|
+
connection.execute(policy_sql)
|
|
20
27
|
|
|
21
|
-
Rails.logger&.info "✅ RLS enabled for table #{table_name} with policy #{
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
39
|
-
"SELECT relrowsecurity FROM pg_class WHERE relname =
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
data/lib/rls_multi_tenant.rb
CHANGED
|
@@ -28,7 +28,10 @@ module RlsMultiTenant
|
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def tenant_class
|
|
31
|
-
|
|
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.
|
|
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:
|
|
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: []
|