activerecord-tenanted 0.8.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: 96309750320bfc1e4e183b299c46a3ac8e925ae71ae58726146a3823747c48c2
4
- data.tar.gz: 59324d68cac1e4c1669d1b7c797f21eff549acfed7ffb54ec053057aea1a69ae
3
+ metadata.gz: 92b0d3851167bdcfb0abd394b770ad1d13474c3cf9716caed9f0bf3b9a49a226
4
+ data.tar.gz: 5cb5eee41c5e6723042c536fe04db854519a076e2223ac60bd5df862185978c5
5
5
  SHA512:
6
- metadata.gz: ac643449dfda377954739d2210b2f011e832cffed4599dca4c0aa8a5fe692a87dffca7ed2e719667dd911ddbc52523ac9dae3e4bccd419282d186366ae7cf42e
7
- data.tar.gz: 36f29134a66ed8619e26e6f12b04e497ec2e2ec1e8db350dfe6c505630394dbddd2a896ce6b11e1ba1956f0d34309bfda4d2951813ac2c7c1f9db622c403f6d2
6
+ metadata.gz: 42beaf7932556597872dc01cff858c4f934f1f545e114f3fa0e48890d06d962847a34ec40ac81dab6fa8d3bde253a62ffa41b1645d3a5fe2f07aa2c0f6e670fd
7
+ data.tar.gz: 1a117271fc26dfc6950669a6a2ccf2adac1d017ae00a30d206e540fce3325ff80d5822902b9cc8f35f85359349cb7e5c66dc061397c43ef610c30f3e56b5e7e3
@@ -11,7 +11,7 @@ module ActiveRecord
11
11
  module Associations # :nodoc:
12
12
  # Every query built on behalf of an association funnels through here, including the ones
13
13
  # issued by a collection proxy that has outlived the tenant context it was created in.
14
- def scope
14
+ def scope(...)
15
15
  ensure_owner_tenant_context_safety
16
16
  super
17
17
  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)
@@ -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
@@ -205,7 +205,7 @@ module ActiveRecord
205
205
  when UNTENANTED_SENTINEL
206
206
  # no-op
207
207
  else
208
- tenant_name = tenant_name.to_s
208
+ tenant_name = validated_tenant_name(tenant_name)
209
209
  end
210
210
 
211
211
  run_callbacks :set_current_tenant do
@@ -214,11 +214,14 @@ module ActiveRecord
214
214
  end
215
215
 
216
216
  def tenant_exist?(tenant_name)
217
- 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?
218
221
  end
219
222
 
220
223
  def with_tenant(tenant_name, prohibit_shard_swapping: true, &block)
221
- tenant_name = tenant_name.to_s unless tenant_name == UNTENANTED_SENTINEL
224
+ tenant_name = validated_tenant_name(tenant_name) unless tenant_name == UNTENANTED_SENTINEL
222
225
 
223
226
  if tenant_name == current_tenant
224
227
  run_callbacks :with_tenant, &block
@@ -262,6 +265,8 @@ module ActiveRecord
262
265
  end
263
266
 
264
267
  def destroy_tenant(tenant_name)
268
+ tenant_name = validated_tenant_name(tenant_name)
269
+
265
270
  ActiveRecord::Base.logger.info " DESTROY [tenant=#{tenant_name}] Destroying tenant database"
266
271
 
267
272
  with_tenant(tenant_name, prohibit_shard_swapping: false) do
@@ -336,6 +341,15 @@ module ActiveRecord
336
341
  end
337
342
 
338
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
+
339
353
  def retrieve_connection_pool(strict:)
340
354
  role = current_role
341
355
  shard = current_tenant
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveRecord
4
4
  module Tenanted
5
- VERSION = "0.8.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.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Dalessio
@@ -75,6 +75,7 @@ files:
75
75
  - lib/active_record/tenanted/connection_adapter.rb
76
76
  - lib/active_record/tenanted/console.rb
77
77
  - lib/active_record/tenanted/database_adapter.rb
78
+ - lib/active_record/tenanted/database_adapters/base.rb
78
79
  - lib/active_record/tenanted/database_adapters/sqlite.rb
79
80
  - lib/active_record/tenanted/database_configurations.rb
80
81
  - lib/active_record/tenanted/database_configurations/base_config.rb