activerecord-tenanted 0.7.0 → 0.9.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: e1981c6ea91e6ef43e674e0f6d3bba9eebe8b1852f0745df774cf571c03d3c54
4
- data.tar.gz: 4f8d4c87cdb079ec884aa3391384cae1134f025204aacbe2c4ad22a6ae204f7b
3
+ metadata.gz: 92b0d3851167bdcfb0abd394b770ad1d13474c3cf9716caed9f0bf3b9a49a226
4
+ data.tar.gz: 5cb5eee41c5e6723042c536fe04db854519a076e2223ac60bd5df862185978c5
5
5
  SHA512:
6
- metadata.gz: c3258b8a167dd07e0f8f63ce0307429cd626761b4e26bb6ef3279063c119b595660613498667eafbcf5ea3eedc2f0d5603d395f14e8ec05ab1fdf9300bf2c5dc
7
- data.tar.gz: 11e72266e807ae48685454eadbed040b586d1ddc6bc284954ae36267afc2c2ef0ae3d19dbc67408bb7d3705fb352319d2a4e8268cdf520e02910725c742f41c4
6
+ metadata.gz: 42beaf7932556597872dc01cff858c4f934f1f545e114f3fa0e48890d06d962847a34ec40ac81dab6fa8d3bde253a62ffa41b1645d3a5fe2f07aa2c0f6e670fd
7
+ data.tar.gz: 1a117271fc26dfc6950669a6a2ccf2adac1d017ae00a30d206e540fce3325ff80d5822902b9cc8f35f85359349cb7e5c66dc061397c43ef610c30f3e56b5e7e3
@@ -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
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Tenanted
5
+ module DatabaseAdapters # :nodoc:
6
+ class Base
7
+ attr_reader :db_config
8
+
9
+ def initialize(db_config)
10
+ @db_config = db_config
11
+ end
12
+
13
+ # The Active Storage disk service interpolates the tenant name into a filesystem path, so a
14
+ # valid name must be a single, safe path segment whatever the database allows.
15
+ def valid_tenant_name?(tenant_name)
16
+ raise "#{self.class} must implement #{__method__}"
17
+ end
18
+
19
+ def validate_tenant_name(tenant_name)
20
+ unless valid_tenant_name?(tenant_name)
21
+ raise BadTenantNameError, "Invalid tenant name: #{tenant_name.inspect}"
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -6,7 +6,7 @@ module ActiveRecord
6
6
  #
7
7
  # TODO: This still feels to me like it's not _quite_ right. I think we could further refactor this by:
8
8
  #
9
- # 1. Moving tenant_databases and validate_tenant_name to BaseConfig, and subclassing it for
9
+ # 1. Moving tenant_databases and valid_tenant_name? to BaseConfig, and subclassing it for
10
10
  # each database
11
11
  # 2. Moving create_database, drop_database, database_exist?, database_ready?,
12
12
  # acquire_ready_lock, ensure_database_directory_exists, and database_path to the SQLite
@@ -14,30 +14,41 @@ module ActiveRecord
14
14
  # 3. Moving test_workerize and path_for to be SQLite connection adapter class methods,
15
15
  # possibly into Rails
16
16
  #
17
- class SQLite
18
- attr_reader :db_config
19
-
20
- def initialize(db_config)
21
- @db_config = db_config
22
- end
17
+ class SQLite < Base
18
+ # A tenant name is interpolated into the database path as a single path segment, and that
19
+ # path may be a URI (see #path_for), so a name is limited to the RFC 3986 unreserved
20
+ # characters and may not begin with a dot or a tilde. A leading dot either traverses out of
21
+ # the tenant directory or hides the database from the glob in #tenant_databases; a leading
22
+ # tilde is expanded by File.expand_path when the name begins a relative path; "%" is
23
+ # percent-decoded by SQLite when it opens a URI filename; "?" and "#" truncate the path; and
24
+ # other punctuation is either reserved in a URI or makes URI.parse raise.
25
+ TENANT_NAME_PATTERN = /\A[A-Za-z0-9_-][A-Za-z0-9._~-]*\z/
23
26
 
24
27
  def tenant_databases
25
- glob = path_for(db_config.database_for("*"))
26
- scanner = Regexp.new(path_for(db_config.database_for("(.+)")))
28
+ glob = path_for(db_config.database_pattern_for("*"))
29
+ scanner = Regexp.new(path_for(db_config.database_pattern_for("(.+)")))
27
30
 
28
31
  Dir.glob(glob).filter_map do |path|
29
32
  result = path.scan(scanner).flatten.first
33
+
30
34
  if result.nil?
31
35
  Rails.logger.warn "ActiveRecord::Tenanted: Cannot parse tenant name from filename #{path.inspect}"
36
+ next
32
37
  end
38
+
39
+ unless valid_tenant_name?(result)
40
+ Rails.logger.warn "ActiveRecord::Tenanted: Skipping database with an invalid tenant name #{result.inspect} in #{path.inspect}"
41
+ next
42
+ end
43
+
33
44
  result
34
45
  end
35
46
  end
36
47
 
37
- def validate_tenant_name(tenant_name)
38
- if tenant_name.match?(%r{[/'"`]})
39
- raise BadTenantNameError, "Tenant name contains an invalid character: #{tenant_name.inspect}"
40
- end
48
+ def valid_tenant_name?(tenant_name)
49
+ tenant_name.encoding.ascii_compatible? &&
50
+ tenant_name.valid_encoding? &&
51
+ TENANT_NAME_PATTERN.match?(tenant_name)
41
52
  end
42
53
 
43
54
  def create_database
@@ -27,7 +27,13 @@ module ActiveRecord
27
27
 
28
28
  config_adapter.validate_tenant_name(tenant_name)
29
29
 
30
- db = sprintf(database, tenant: tenant_name)
30
+ database_pattern_for(tenant_name)
31
+ end
32
+
33
+ # Interpolates a glob or a regular expression into the database path. The pattern is not a
34
+ # tenant name and is not validated as one.
35
+ def database_pattern_for(pattern) # :nodoc:
36
+ db = sprintf(database, tenant: pattern)
31
37
 
32
38
  if test_worker_id
33
39
  db = config_adapter.test_workerize(db, test_worker_id)
@@ -41,10 +47,12 @@ module ActiveRecord
41
47
  end
42
48
 
43
49
  def new_tenant_config(tenant_name)
50
+ database = database_for(tenant_name) # validates the tenant name before it is used below
51
+
44
52
  config_name = "#{name}_#{tenant_name}"
45
53
  config_hash = configuration_hash.dup.tap do |hash|
46
54
  hash[:tenant] = tenant_name
47
- hash[:database] = database_for(tenant_name)
55
+ hash[:database] = database
48
56
  hash[:tenanted_config_name] = name
49
57
  end
50
58
  Tenanted::DatabaseConfigurations::TenantConfig.new(env_name, config_name, config_hash)
@@ -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
@@ -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,6 +94,13 @@ 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
@@ -10,6 +10,12 @@ module ActiveRecord
10
10
  raise NoTenantError, "Cannot access Active Storage Disk service without a tenant"
11
11
  end
12
12
 
13
+ # The tenant is read from the connection context, which Rails' own `connected_to` can
14
+ # set without going through this gem's tenant API, so the name is checked here too. A
15
+ # shard set that way may be a Symbol or an Integer.
16
+ tenant = tenant.to_s
17
+ klass.tenanted_root_config.config_adapter.validate_tenant_name(tenant)
18
+
13
19
  sprintf(@root, tenant: tenant)
14
20
  else
15
21
  super
@@ -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
 
@@ -101,7 +205,7 @@ module ActiveRecord
101
205
  when UNTENANTED_SENTINEL
102
206
  # no-op
103
207
  else
104
- tenant_name = tenant_name.to_s
208
+ tenant_name = validated_tenant_name(tenant_name)
105
209
  end
106
210
 
107
211
  run_callbacks :set_current_tenant do
@@ -110,11 +214,14 @@ module ActiveRecord
110
214
  end
111
215
 
112
216
  def tenant_exist?(tenant_name)
113
- tenanted_root_config.new_tenant_config(tenant_name).config_adapter.database_ready?
217
+ root_config = tenanted_root_config
218
+ return false unless root_config.config_adapter.valid_tenant_name?(tenant_name.to_s)
219
+
220
+ root_config.new_tenant_config(tenant_name).config_adapter.database_ready?
114
221
  end
115
222
 
116
223
  def with_tenant(tenant_name, prohibit_shard_swapping: true, &block)
117
- tenant_name = tenant_name.to_s unless tenant_name == UNTENANTED_SENTINEL
224
+ tenant_name = validated_tenant_name(tenant_name) unless tenant_name == UNTENANTED_SENTINEL
118
225
 
119
226
  if tenant_name == current_tenant
120
227
  run_callbacks :with_tenant, &block
@@ -158,6 +265,8 @@ module ActiveRecord
158
265
  end
159
266
 
160
267
  def destroy_tenant(tenant_name)
268
+ tenant_name = validated_tenant_name(tenant_name)
269
+
161
270
  ActiveRecord::Base.logger.info " DESTROY [tenant=#{tenant_name}] Destroying tenant database"
162
271
 
163
272
  with_tenant(tenant_name, prohibit_shard_swapping: false) do
@@ -232,6 +341,15 @@ module ActiveRecord
232
341
  end
233
342
 
234
343
  private
344
+ # A tenant name reaches the filesystem through the Active Storage disk service as well as
345
+ # through the database path, and the disk service reads it from the tenant context rather
346
+ # than from a database configuration. Validate it as the context is set.
347
+ def validated_tenant_name(tenant_name)
348
+ tenant_name = tenant_name.to_s
349
+ tenanted_root_config.config_adapter.validate_tenant_name(tenant_name)
350
+ tenant_name
351
+ end
352
+
235
353
  def retrieve_connection_pool(strict:)
236
354
  role = current_role
237
355
  shard = current_tenant
@@ -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.7.0"
5
+ VERSION = "0.9.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.7.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Dalessio
@@ -69,11 +69,13 @@ 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
75
76
  - lib/active_record/tenanted/console.rb
76
77
  - lib/active_record/tenanted/database_adapter.rb
78
+ - lib/active_record/tenanted/database_adapters/base.rb
77
79
  - lib/active_record/tenanted/database_adapters/sqlite.rb
78
80
  - lib/active_record/tenanted/database_configurations.rb
79
81
  - lib/active_record/tenanted/database_configurations/base_config.rb
@@ -83,6 +85,7 @@ files:
83
85
  - lib/active_record/tenanted/job.rb
84
86
  - lib/active_record/tenanted/lru.rb
85
87
  - lib/active_record/tenanted/mailer.rb
88
+ - lib/active_record/tenanted/message_pack.rb
86
89
  - lib/active_record/tenanted/mutex.rb
87
90
  - lib/active_record/tenanted/patches.rb
88
91
  - lib/active_record/tenanted/railtie.rb