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 +4 -4
- data/.rubocop.yml +15 -0
- data/CHANGELOG.md +89 -0
- data/lib/rls_multi_tenant/concerns/multi_tenant.rb +24 -7
- data/lib/rls_multi_tenant/concerns/tenant_context.rb +65 -14
- 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 +48 -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: 4e6d7f69028c0e956fc4d5caf7651dd885c4a9da43bed16a2d32e90c8177c7c9
|
|
4
|
+
data.tar.gz: 5c8d3042cf29d8a21aad002e16db1193d2d0e8b7fea7246e4766116e81d301f9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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,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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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?
|
|
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,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
|
-
|
|
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!(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
|
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.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:
|
|
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: []
|