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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 62c3b9d7f37db44de60c7559e4b10e1407171ab29aa874e2687853e8acc38bb1
4
- data.tar.gz: 27b2ee871ff4ce67b60480e83694cf6a2aeac504b3ab39f96ea0eb243b5f3a47
3
+ metadata.gz: 96309750320bfc1e4e183b299c46a3ac8e925ae71ae58726146a3823747c48c2
4
+ data.tar.gz: 59324d68cac1e4c1669d1b7c797f21eff549acfed7ffb54ec053057aea1a69ae
5
5
  SHA512:
6
- metadata.gz: 7eff54730cbc865dd8ecb1169e146194b0edceaed0527059ed110537aedd57901a5a5af5b85c522bfebcf50714c468984f91ac4f5c8e40077b2e737497eac550
7
- data.tar.gz: fafa6f206173a7057e090407f5cf6698bd7f1d34885509793568f1e76baedbe61b7eeea980493b5af0df30c3f5bd20400c799eebecf62785f458e925afbee57f
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
- gid.model_class.find(gid.model_id)
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
- prepend ActiveRecord::Tenanted::Patches::Attributes
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
- if ActiveRecord::Tenanted.connection_class
21
- # TODO: this is brittle if the key isn't tenanted ... errors in folder_for:
22
- #
23
- # NoMethodError undefined method '[]' for nil (NoMethodError) [ key[0..1], key[2..3] ].join("/")
24
- #
25
- tenant, key = key.split("/", 2)
26
- File.join(root, tenant, folder_for(key), key)
27
- else
28
- super
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 :ensure_tenant_context_safety
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 association(name)
34
- super.tap do |assoc|
35
- if assoc.reflection.polymorphic? || assoc.reflection.klass.tenanted?
36
- ensure_tenant_context_safety
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
- def ensure_tenant_context_safety
54
- self_tenant = self.tenant
55
- current_tenant = self.class.current_tenant
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
- tenant_name = tenant_name.to_s unless tenant_name == UNTENANTED_SENTINEL
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
- connection_class_for_self.connecting_to(shard: tenant_name, role: ActiveRecord.writing_role)
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
- yield
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
- prohibit_shard_swapping(prohibit_shard_swapping) do
115
- log_tenant_tag(tenant_name, &block)
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?
@@ -32,6 +32,9 @@ module ActiveRecord
32
32
  db_config.max_connections
33
33
  end
34
34
 
35
+ def clear_query_cache
36
+ end
37
+
35
38
  def lease_connection(...)
36
39
  raise Tenanted::NoTenantError, "Cannot connect to a tenanted database while untenanted (#{@model})."
37
40
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveRecord
4
4
  module Tenanted
5
- VERSION = "0.6.0"
5
+ VERSION = "0.8.0"
6
6
  end
7
7
  end
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.6.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.beta
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.beta
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.beta
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.beta
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