activerecord-tenanted 0.6.0 → 0.8.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/lib/active_record/tenanted/associations.rb +32 -0
- data/lib/active_record/tenanted/global_id.rb +7 -2
- data/lib/active_record/tenanted/message_pack.rb +26 -0
- data/lib/active_record/tenanted/patches.rb +12 -2
- data/lib/active_record/tenanted/railtie.rb +9 -1
- data/lib/active_record/tenanted/storage.rb +24 -9
- data/lib/active_record/tenanted/tenant.rb +140 -21
- data/lib/active_record/tenanted/untenanted_connection_pool.rb +3 -0
- data/lib/active_record/tenanted/version.rb +1 -1
- metadata +7 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 96309750320bfc1e4e183b299c46a3ac8e925ae71ae58726146a3823747c48c2
|
|
4
|
+
data.tar.gz: 59324d68cac1e4c1669d1b7c797f21eff549acfed7ffb54ec053057aea1a69ae
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ac643449dfda377954739d2210b2f011e832cffed4599dca4c0aa8a5fe692a87dffca7ed2e719667dd911ddbc52523ac9dae3e4bccd419282d186366ae7cf42e
|
|
7
|
+
data.tar.gz: 36f29134a66ed8619e26e6f12b04e497ec2e2ec1e8db350dfe6c505630394dbddd2a896ce6b11e1ba1956f0d34309bfda4d2951813ac2c7c1f9db622c403f6d2
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module Tenanted
|
|
5
|
+
# Tenant context is checked when an association reads or writes the database, and not when the
|
|
6
|
+
# Association object is created. Serializers create associations to dump and restore their
|
|
7
|
+
# targets without ever going near a connection, and must not be tripped up by the check.
|
|
8
|
+
#
|
|
9
|
+
# `klass` is resolved at both of these seams, so a polymorphic association is checked against
|
|
10
|
+
# the class it actually points at rather than being presumed tenanted.
|
|
11
|
+
module Associations # :nodoc:
|
|
12
|
+
# Every query built on behalf of an association funnels through here, including the ones
|
|
13
|
+
# issued by a collection proxy that has outlived the tenant context it was created in.
|
|
14
|
+
def scope
|
|
15
|
+
ensure_owner_tenant_context_safety
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
# Called by the collection and singular association readers, so that the exception is
|
|
21
|
+
# raised at the call site that made the mistake and not at the eventual query.
|
|
22
|
+
def ensure_klass_exists!
|
|
23
|
+
super
|
|
24
|
+
ensure_owner_tenant_context_safety
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def ensure_owner_tenant_context_safety
|
|
28
|
+
owner.ensure_tenant_context_safety if owner.class.tenanted? && klass&.tenanted?
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -9,10 +9,15 @@ module ActiveRecord
|
|
|
9
9
|
params && params[:tenant]
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
class Locator
|
|
12
|
+
class Locator < ::GlobalID::Locator::UnscopedLocator
|
|
13
13
|
def locate(gid, options = {})
|
|
14
14
|
ensure_tenant_context_safety(gid)
|
|
15
|
-
|
|
15
|
+
super
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def locate_many(gids, options = {})
|
|
19
|
+
gids.each { |gid| ensure_tenant_context_safety(gid) }
|
|
20
|
+
super
|
|
16
21
|
end
|
|
17
22
|
|
|
18
23
|
private
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module Tenanted
|
|
5
|
+
module MessagePack # :nodoc:
|
|
6
|
+
# This patch sees every record in a payload, unlike the Marshal, YAML, and JSON hooks, which
|
|
7
|
+
# are methods on the record and so only ever run for tenanted models.
|
|
8
|
+
module Decoder
|
|
9
|
+
def build_record(entry)
|
|
10
|
+
class_name, attributes_hash, * = entry
|
|
11
|
+
|
|
12
|
+
if ActiveSupport::MessagePack::Extensions.load_class(class_name).tenanted?
|
|
13
|
+
has_tenant = attributes_hash.key?("tenant")
|
|
14
|
+
tenant_name = attributes_hash.delete("tenant")
|
|
15
|
+
|
|
16
|
+
super.tap do |record|
|
|
17
|
+
record.instance_variable_set(:@tenant, tenant_name) if has_tenant
|
|
18
|
+
end
|
|
19
|
+
else
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -24,8 +24,6 @@ module ActiveRecord
|
|
|
24
24
|
end
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
# TODO: This monkey patch shouldn't be necessary after 8.1 lands and the need for a
|
|
28
|
-
# connection is removed. For details see https://github.com/rails/rails/pull/54348
|
|
29
27
|
module Attributes
|
|
30
28
|
extend ActiveSupport::Concern
|
|
31
29
|
|
|
@@ -44,6 +42,18 @@ module ActiveRecord
|
|
|
44
42
|
end
|
|
45
43
|
end
|
|
46
44
|
end
|
|
45
|
+
|
|
46
|
+
class << self
|
|
47
|
+
# This monkey patch isn't necessary after 8.1 removed the need for a connection.
|
|
48
|
+
# For details see https://github.com/rails/rails/pull/54348
|
|
49
|
+
def needs_patch?
|
|
50
|
+
Gem::Requirement.new("~> 8.1.0").satisfied_by?(Gem::Version.new(Rails::VERSION::STRING))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def apply_patch
|
|
54
|
+
ActiveRecord::Base.prepend(self) if needs_patch?
|
|
55
|
+
end
|
|
56
|
+
end
|
|
47
57
|
end
|
|
48
58
|
end
|
|
49
59
|
end
|
|
@@ -75,6 +75,7 @@ module ActiveRecord
|
|
|
75
75
|
ActiveSupport.on_load(:active_record) do
|
|
76
76
|
prepend ActiveRecord::Tenanted::Base
|
|
77
77
|
ActiveRecord::Relation.prepend ActiveRecord::Tenanted::Relation
|
|
78
|
+
ActiveRecord::Associations::Association.prepend ActiveRecord::Tenanted::Associations
|
|
78
79
|
end
|
|
79
80
|
end
|
|
80
81
|
|
|
@@ -93,9 +94,16 @@ module ActiveRecord
|
|
|
93
94
|
config.active_record.check_schema_cache_dump_version = false
|
|
94
95
|
end
|
|
95
96
|
|
|
97
|
+
initializer "active_record_tenanted.message_pack" do
|
|
98
|
+
ActiveSupport.on_load(:message_pack) do
|
|
99
|
+
require "active_record/message_pack"
|
|
100
|
+
ActiveRecord::MessagePack::Decoder.prepend ActiveRecord::Tenanted::MessagePack::Decoder
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
96
104
|
initializer "active_record_tenanted.monkey_patches" do
|
|
97
105
|
ActiveSupport.on_load(:active_record) do
|
|
98
|
-
|
|
106
|
+
ActiveRecord::Tenanted::Patches::Attributes.apply_patch
|
|
99
107
|
ActiveRecord::Tasks::DatabaseTasks.prepend ActiveRecord::Tenanted::Patches::DatabaseTasks
|
|
100
108
|
end
|
|
101
109
|
end
|
|
@@ -17,16 +17,31 @@ module ActiveRecord
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def path_for(key)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
20
|
+
return super unless ActiveRecord::Tenanted.connection_class && key.include?("/")
|
|
21
|
+
|
|
22
|
+
if key.split("/").intersect?(%w[. ..])
|
|
23
|
+
raise ActiveStorage::InvalidKeyError, "key has path traversal segments"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
tenant, key = key.split("/", 2)
|
|
27
|
+
|
|
28
|
+
if tenant.blank? || key.blank?
|
|
29
|
+
raise ActiveStorage::InvalidKeyError, "key has a blank segment"
|
|
29
30
|
end
|
|
31
|
+
|
|
32
|
+
begin
|
|
33
|
+
path = File.expand_path(File.join(root, tenant, folder_for(key), key))
|
|
34
|
+
rescue ArgumentError
|
|
35
|
+
raise ActiveStorage::InvalidKeyError, "key is an invalid string"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
unless path.start_with?(File.expand_path(root) + "/")
|
|
39
|
+
raise ActiveStorage::InvalidKeyError, "key is outside of disk service root"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
path
|
|
43
|
+
rescue Encoding::CompatibilityError
|
|
44
|
+
raise ActiveStorage::InvalidKeyError, "key has incompatible encoding"
|
|
30
45
|
end
|
|
31
46
|
end
|
|
32
47
|
|
|
@@ -9,13 +9,24 @@ module ActiveRecord
|
|
|
9
9
|
prepended do
|
|
10
10
|
attr_reader :tenant
|
|
11
11
|
|
|
12
|
-
before_save :
|
|
12
|
+
before_save :ensure_belongs_to_tenant_safety, prepend: true
|
|
13
|
+
before_save :ensure_tenant_context_safety, prepend: true
|
|
14
|
+
before_destroy :ensure_tenant_context_safety, prepend: true
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
def cache_key
|
|
16
18
|
tenant ? "#{tenant}/#{super}" : super
|
|
17
19
|
end
|
|
18
20
|
|
|
21
|
+
def ==(other)
|
|
22
|
+
super && tenant == other.tenant
|
|
23
|
+
end
|
|
24
|
+
alias eql? ==
|
|
25
|
+
|
|
26
|
+
def hash
|
|
27
|
+
[ super, tenant ].hash
|
|
28
|
+
end
|
|
29
|
+
|
|
19
30
|
def inspect
|
|
20
31
|
return super unless tenant
|
|
21
32
|
|
|
@@ -30,14 +41,114 @@ module ActiveRecord
|
|
|
30
41
|
super(options.merge(tenant: tenant))
|
|
31
42
|
end
|
|
32
43
|
|
|
33
|
-
def
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
44
|
+
def reload(...)
|
|
45
|
+
ensure_tenant_context_safety
|
|
46
|
+
|
|
47
|
+
super
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def valid?(...)
|
|
51
|
+
ensure_tenant_context_safety
|
|
52
|
+
|
|
53
|
+
super
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def delete
|
|
57
|
+
ensure_tenant_context_safety
|
|
58
|
+
|
|
59
|
+
super
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# #update_column delegates here
|
|
63
|
+
def update_columns(...)
|
|
64
|
+
ensure_tenant_context_safety
|
|
65
|
+
|
|
66
|
+
super
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def touch(...)
|
|
70
|
+
ensure_tenant_context_safety
|
|
71
|
+
|
|
72
|
+
super
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# #decrement! delegates here
|
|
76
|
+
def increment!(...)
|
|
77
|
+
ensure_tenant_context_safety
|
|
78
|
+
|
|
79
|
+
super
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# A belongs_to writes its foreign key onto this record, so nothing else notices when the
|
|
83
|
+
# target came from another tenant's database.
|
|
84
|
+
def ensure_belongs_to_tenant_safety
|
|
85
|
+
self.class.reflect_on_all_associations(:belongs_to).each do |reflection|
|
|
86
|
+
# Avoid creating the Association object for a belongs_to that was never touched
|
|
87
|
+
next unless association_cached?(reflection.name)
|
|
88
|
+
|
|
89
|
+
target = association(reflection.name).target
|
|
90
|
+
|
|
91
|
+
if target && target.class.tenanted? && target.tenant != tenant
|
|
92
|
+
raise WrongTenantError,
|
|
93
|
+
"#{self.class} model belongs to tenant #{tenant.inspect}, but its " \
|
|
94
|
+
"#{reflection.name} association belongs to tenant #{target.tenant.inspect}"
|
|
37
95
|
end
|
|
38
96
|
end
|
|
39
97
|
end
|
|
40
98
|
|
|
99
|
+
def ensure_tenant_context_safety
|
|
100
|
+
self_tenant = self.tenant
|
|
101
|
+
current_tenant = self.class.current_tenant
|
|
102
|
+
|
|
103
|
+
if current_tenant.nil?
|
|
104
|
+
raise NoTenantError, "Cannot connect to a tenanted database while untenanted (#{self.class})"
|
|
105
|
+
elsif self_tenant != current_tenant
|
|
106
|
+
raise WrongTenantError,
|
|
107
|
+
"#{self.class} model belongs to tenant #{self_tenant.inspect}, " \
|
|
108
|
+
"but current tenant is #{current_tenant.inspect}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The tenant is an ordinary attribute in serialized snapshots of the record, the same way Job
|
|
113
|
+
# and GlobalId serialize the tenant. Rails's only callers of this method are the serializers
|
|
114
|
+
# (Marshal 7.1 format and MessagePack); it does not feed the persistence write path.
|
|
115
|
+
def attributes_for_database
|
|
116
|
+
super.merge!("tenant" => tenant)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def encode_with(coder)
|
|
120
|
+
super
|
|
121
|
+
coder["tenant"] = tenant
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def init_with(coder, &block)
|
|
125
|
+
super
|
|
126
|
+
|
|
127
|
+
# Psych::Coder does not implement #key?, but exposes the underlying hash as #map
|
|
128
|
+
@tenant = coder.map["tenant"] if coder.map.key?("tenant")
|
|
129
|
+
|
|
130
|
+
self
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def from_json(json, include_root = include_root_in_json)
|
|
134
|
+
hash = ActiveSupport::JSON.decode(json)
|
|
135
|
+
hash = hash.values.first if include_root
|
|
136
|
+
|
|
137
|
+
@tenant = hash.delete("tenant") if hash.key?("tenant")
|
|
138
|
+
|
|
139
|
+
self.attributes = hash
|
|
140
|
+
self
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def marshal_load(state)
|
|
144
|
+
has_tenant = state[0].key?("tenant")
|
|
145
|
+
tenant_name = state[0].delete("tenant")
|
|
146
|
+
|
|
147
|
+
super
|
|
148
|
+
|
|
149
|
+
@tenant = tenant_name if has_tenant
|
|
150
|
+
end
|
|
151
|
+
|
|
41
152
|
alias to_gid to_global_id
|
|
42
153
|
alias to_sgid to_signed_global_id
|
|
43
154
|
|
|
@@ -50,17 +161,10 @@ module ActiveRecord
|
|
|
50
161
|
super
|
|
51
162
|
end
|
|
52
163
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if current_tenant.nil?
|
|
58
|
-
raise NoTenantError, "Cannot connect to a tenanted database while untenanted (#{self.class})"
|
|
59
|
-
elsif self_tenant != current_tenant
|
|
60
|
-
raise WrongTenantError,
|
|
61
|
-
"#{self.class} model belongs to tenant #{self_tenant.inspect}, " \
|
|
62
|
-
"but current tenant is #{current_tenant.inspect}"
|
|
63
|
-
end
|
|
164
|
+
# Presenting the tenant as one more serializable attribute lets ActiveModel apply the
|
|
165
|
+
# `only:` and `except:` options to it, and read it back through the `tenant` reader.
|
|
166
|
+
def attribute_names_for_serialization
|
|
167
|
+
super + [ "tenant" ]
|
|
64
168
|
end
|
|
65
169
|
end
|
|
66
170
|
|
|
@@ -95,9 +199,18 @@ module ActiveRecord
|
|
|
95
199
|
end
|
|
96
200
|
|
|
97
201
|
def current_tenant=(tenant_name)
|
|
98
|
-
|
|
202
|
+
case tenant_name
|
|
203
|
+
when nil
|
|
204
|
+
tenant_name = UNTENANTED_SENTINEL
|
|
205
|
+
when UNTENANTED_SENTINEL
|
|
206
|
+
# no-op
|
|
207
|
+
else
|
|
208
|
+
tenant_name = tenant_name.to_s
|
|
209
|
+
end
|
|
99
210
|
|
|
100
|
-
|
|
211
|
+
run_callbacks :set_current_tenant do
|
|
212
|
+
connection_class_for_self.connecting_to(shard: tenant_name, role: ActiveRecord.writing_role)
|
|
213
|
+
end
|
|
101
214
|
end
|
|
102
215
|
|
|
103
216
|
def tenant_exist?(tenant_name)
|
|
@@ -108,11 +221,13 @@ module ActiveRecord
|
|
|
108
221
|
tenant_name = tenant_name.to_s unless tenant_name == UNTENANTED_SENTINEL
|
|
109
222
|
|
|
110
223
|
if tenant_name == current_tenant
|
|
111
|
-
|
|
224
|
+
run_callbacks :with_tenant, &block
|
|
112
225
|
else
|
|
113
226
|
connection_class_for_self.connected_to(shard: tenant_name, role: ActiveRecord.writing_role) do
|
|
114
|
-
|
|
115
|
-
|
|
227
|
+
run_callbacks :with_tenant do
|
|
228
|
+
prohibit_shard_swapping(prohibit_shard_swapping) do
|
|
229
|
+
log_tenant_tag(tenant_name, &block)
|
|
230
|
+
end
|
|
116
231
|
end
|
|
117
232
|
end
|
|
118
233
|
end
|
|
@@ -255,9 +370,13 @@ module ActiveRecord
|
|
|
255
370
|
self.default_shard = ActiveRecord::Tenanted::Tenant::UNTENANTED_SENTINEL
|
|
256
371
|
|
|
257
372
|
prepend TenantCommon
|
|
373
|
+
extend ActiveSupport::Callbacks
|
|
258
374
|
|
|
259
375
|
cattr_accessor :tenanted_config_name
|
|
260
376
|
cattr_accessor(:tenanted_connection_pools) { LRU.new }
|
|
377
|
+
|
|
378
|
+
define_callbacks :with_tenant
|
|
379
|
+
define_callbacks :set_current_tenant
|
|
261
380
|
end
|
|
262
381
|
|
|
263
382
|
def tenanted?
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: activerecord-tenanted
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mike Dalessio
|
|
@@ -15,28 +15,28 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: 8.1.
|
|
18
|
+
version: 8.1.2.1
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: 8.1.
|
|
25
|
+
version: 8.1.2.1
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: railties
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
29
29
|
requirements:
|
|
30
30
|
- - ">="
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: 8.1.
|
|
32
|
+
version: 8.1.2.1
|
|
33
33
|
type: :runtime
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
37
|
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: 8.1.
|
|
39
|
+
version: 8.1.2.1
|
|
40
40
|
- !ruby/object:Gem::Dependency
|
|
41
41
|
name: zeitwerk
|
|
42
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -69,6 +69,7 @@ files:
|
|
|
69
69
|
- README.md
|
|
70
70
|
- Rakefile
|
|
71
71
|
- lib/active_record/tenanted.rb
|
|
72
|
+
- lib/active_record/tenanted/associations.rb
|
|
72
73
|
- lib/active_record/tenanted/base.rb
|
|
73
74
|
- lib/active_record/tenanted/cable_connection.rb
|
|
74
75
|
- lib/active_record/tenanted/connection_adapter.rb
|
|
@@ -83,6 +84,7 @@ files:
|
|
|
83
84
|
- lib/active_record/tenanted/job.rb
|
|
84
85
|
- lib/active_record/tenanted/lru.rb
|
|
85
86
|
- lib/active_record/tenanted/mailer.rb
|
|
87
|
+
- lib/active_record/tenanted/message_pack.rb
|
|
86
88
|
- lib/active_record/tenanted/mutex.rb
|
|
87
89
|
- lib/active_record/tenanted/patches.rb
|
|
88
90
|
- lib/active_record/tenanted/railtie.rb
|