acts_as_tenant 1.0.1 → 2.0.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: a88c52c6d95ce10c987f520154321ae80f94def7f671e85b9a05c4c26c2bac48
4
- data.tar.gz: 788a2f46cd8b2cf84cfbc8c46f6938358bac793b3c3af415c3f18df1ba9729b1
3
+ metadata.gz: 8891ee294c7263049d6bb44c00b66231976574ca467fbf88496510a422b751d5
4
+ data.tar.gz: 580108fc430c92f9f124841243e1ac2d070c7a899e011e153d94543283ff938f
5
5
  SHA512:
6
- metadata.gz: 23f45a6a9f267804d7e39a90a0ba31e23eceefc8408a3f9643b86c99edeebe057f9824d36cd52d8536b664253816758351594757cb898a7fe4b93f0e09a4eb26
7
- data.tar.gz: 4e1894df9535e14745dccbd83749cb9e56b2bfcca41bbf43565cb0121c778d44b3da25db27e3753173ffc93dfda95b4ff60242bcd845a47f3e6a9adfd1a1a4a5
6
+ metadata.gz: 5bfef7a385bdbb1d9264ef13a1f521d1538262858480c3c3869c3167a9761bba1c6a5fd02008948d04452c87acf49405eb0aa71630213f2d9a13982c32856605
7
+ data.tar.gz: ee45c099a0e26311071f3de62317bc9425e8c9f7deb3273220d65c446f0dbdb549840551ec903a6202a75777d2f491f3a7f1808bfa08c6432844ef48da0d1da5
data/README.md CHANGED
@@ -158,14 +158,14 @@ Scoping your models
158
158
  -------------------
159
159
 
160
160
  ```ruby
161
- class AddAccountToUsers < ActiveRecord::Migration
161
+ class AddAccountToProjects < ActiveRecord::Migration
162
162
  def up
163
- add_column :users, :account_id, :integer
164
- add_index :users, :account_id
163
+ add_column :projects, :account_id, :integer
164
+ add_index :projects, :account_id
165
165
  end
166
166
  end
167
167
 
168
- class User < ActiveRecord::Base
168
+ class Project < ActiveRecord::Base
169
169
  acts_as_tenant(:account)
170
170
  end
171
171
  ```
@@ -197,6 +197,19 @@ Project.tasks.all # => all tasks with account_id => 3
197
197
 
198
198
  Acts_as_tenant uses Rails' `default_scope` method to scope models. Rails 3.1 changed the way `default_scope` works in a good way. A user defined `default_scope` should integrate seamlessly with the one added by `acts_as_tenant`.
199
199
 
200
+ Because the scoping comes from `default_scope`, queries built from the model are scoped when a tenant is set. This includes `update_all`, `delete_all` and `destroy_all`, and `insert_all` and `upsert_all` set the current tenant. These are **not** scoped or checked:
201
+
202
+ * Any query when no tenant is set, unless `require_tenant` is enabled
203
+ * Queries that remove the default scope, such as `unscoped`
204
+ * Raw SQL, such as `find_by_sql`, `connection.execute` and `exec_query`
205
+ * `update_column` and `update_columns`, which skip the check that prevents changing a record's tenant
206
+
207
+ New records are assigned the current tenant unless they already have one. A record assigned to a different tenant fails validation, so to create records for another tenant, wrap them in `ActsAsTenant.with_tenant(other_tenant) { ... }`.
208
+
209
+ `belongs_to` associations are validated against the current tenant regardless of whether they are declared before or after `acts_as_tenant`.
210
+
211
+ When no tenant is set (for example in an admin panel or inside `without_tenant`), `belongs_to` associations to tenanted models are validated to belong to the same tenant as the record. Associated records without a tenant, such as global records, are allowed.
212
+
200
213
  ### Validating attribute uniqueness
201
214
 
202
215
  If you need to validate for uniqueness, chances are that you want to scope this validation to a tenant. You can do so by using:
@@ -259,47 +272,89 @@ ActsAsTenant.configure do |config|
259
272
  end
260
273
  ```
261
274
 
262
- * `config.require_tenant` when set to true will raise an ActsAsTenant::NoTenant error whenever a query is made without a tenant set.
275
+ * `config.require_tenant` when set to true will raise an `ActsAsTenant::Errors::NoTenantSet` error whenever a query is made without a tenant set.
263
276
 
264
- `config.require_tenant` can also be assigned a lambda that is evaluated at run time. For example:
277
+ `config.require_tenant` can also be assigned a lambda that is evaluated at run time. The lambda doesn't have access to the request, so store what it needs in `CurrentAttributes`, which Rails resets after each request. For example, to not require a tenant under `/admin`:
265
278
 
266
279
  ```ruby
280
+ # app/models/current.rb
281
+ class Current < ActiveSupport::CurrentAttributes
282
+ attribute :request
283
+ end
284
+
285
+ # app/controllers/application_controller.rb
286
+ class ApplicationController < ActionController::Base
287
+ before_action { Current.request = request }
288
+ end
289
+
290
+ # config/initializers/acts_as_tenant.rb
267
291
  ActsAsTenant.configure do |config|
268
292
  config.require_tenant = lambda do
269
- if $request_env.present?
270
- return false if $request_env["REQUEST_PATH"].start_with?("/admin/")
271
- end
293
+ !Current.request&.path&.start_with?("/admin/")
272
294
  end
273
295
  end
274
296
  ```
275
297
 
276
- `ActsAsTenant.should_require_tenant?` is used to determine if a tenant is required in the current context, either by evaluating the lambda provided, or by returning the boolean value assigned to `config.require_tenant`.
298
+ Outside of a request, such as in jobs or the console, `Current.request` is `nil` and a tenant is required.
277
299
 
278
- When using `config.require_tenant` alongside the `rails console`, a nice quality of life tweak is to set the tenant in the console session in your initializer script. For example in `config/initializers/acts_as_tenant.rb`:
300
+ The lambda can also optionally receive the relation being queried as an argument. This is useful for finer control over tenant requirements.
301
+
302
+ For example, if you wanted to require the tenant for every model except `User`, you could do the following:
279
303
 
280
304
  ```ruby
281
- SET_TENANT_PROC = lambda do
282
- if defined?(Rails::Console)
283
- puts "> ActsAsTenant.current_tenant = Account.first"
284
- ActsAsTenant.current_tenant = Account.first
305
+ ActsAsTenant.configure do |config|
306
+ config.require_tenant = lambda do |relation|
307
+ relation.klass.name != "User"
285
308
  end
286
309
  end
310
+ ```
287
311
 
312
+ `ActsAsTenant.should_require_tenant?` is used to determine if a tenant is required in the current context, either by evaluating the lambda provided, or by returning the boolean value assigned to `config.require_tenant`. It accepts the relation as an optional argument, which is passed to the lambda as `nil` when omitted.
313
+
314
+ With `require_tenant` enabled, ActiveStorage can raise `NoTenantSet` when it generates a preview or variant. After processing, Rails touches the attachment records, which loads their tenant-scoped models, and ActiveStorage requests don't set a tenant. Blobs are looked up by signed IDs, so it's safe to run these requests without a tenant:
315
+
316
+ ```ruby
317
+ # config/initializers/acts_as_tenant.rb
318
+ Rails.application.config.to_prepare do
319
+ ActiveStorage::Representations::BaseController.prepend_around_action do |_controller, action|
320
+ ActsAsTenant.without_tenant(&action)
321
+ end
322
+ end
323
+ ```
324
+
325
+ When using `config.require_tenant` alongside the `rails console`, a nice quality of life tweak is to set the tenant in the console session in your initializer script. For example in `config/initializers/acts_as_tenant.rb`:
326
+
327
+ ```ruby
288
328
  Rails.application.configure do
289
- if Rails.env.development?
290
- # Set the tenant to the first account in development on load
291
- config.after_initialize do
292
- SET_TENANT_PROC.call
329
+ if Rails.env.development? && defined?(Rails::Console)
330
+ # set the current_tenant during console startup and after calling reload!
331
+ # note: reload! calls the to_prepare callback twice
332
+ ActiveSupport::Reloader.to_prepare do
333
+ puts ">>> Setting ActsAsTenant.current_tenant = Account.first"
334
+ ActsAsTenant.current_tenant = Account.first
293
335
  end
336
+ end
337
+ end
338
+ ```
339
+
340
+ ### Tenant change hook
341
+
342
+ `config.tenant_change_hook` is called with the new tenant whenever `current_tenant` is set, and with `nil` when Rails resets it at the end of a request or job. For example, to use Postgres row-level security:
294
343
 
295
- # Reset the tenant after calling 'reload!' in the console
296
- ActiveSupport::Reloader.to_complete do
297
- SET_TENANT_PROC.call
344
+ ```ruby
345
+ ActsAsTenant.configure do |config|
346
+ config.tenant_change_hook = lambda do |tenant|
347
+ if tenant
348
+ ActiveRecord::Base.connection.execute(ActiveRecord::Base.sanitize_sql_array(["SET rls.account_id = ?", tenant.id]))
349
+ else
350
+ ActiveRecord::Base.connection.execute("RESET rls.account_id")
298
351
  end
299
352
  end
300
353
  end
301
354
  ```
302
355
 
356
+ Always handle `nil`, otherwise the setting stays on the database connection and the next request using it runs as the previous tenant. The hook isn't called for `default_tenant` or `test_tenant`.
357
+
303
358
  belongs_to options
304
359
  ------------------
305
360
 
@@ -318,6 +373,32 @@ You can add the following `belongs_to` options to `acts_as_tenant`:
318
373
 
319
374
  Example: `acts_as_tenant(:account, counter_cache: true)`
320
375
 
376
+ ActionCable
377
+ -----------
378
+
379
+ The controller helpers aren't available in ActionCable channels. Instead, find the tenant when the connection is made and set it around each command (subscribe, unsubscribe, and channel actions) with `around_command` (Rails 7.1+):
380
+
381
+ ```ruby
382
+ module ApplicationCable
383
+ class Connection < ActionCable::Connection::Base
384
+ identified_by :current_account
385
+ around_command :set_current_tenant
386
+
387
+ def connect
388
+ self.current_account = Account.find_by(subdomain: request.subdomain) || reject_unauthorized_connection
389
+ end
390
+
391
+ private
392
+
393
+ def set_current_tenant(&block)
394
+ ActsAsTenant.with_tenant(current_account, &block)
395
+ end
396
+ end
397
+ end
398
+ ```
399
+
400
+ Setting the tenant in a channel's `before_subscribe` won't work, because Rails resets `current_tenant` after the subscription is created and before each channel action runs. Blocks passed to `stream_from` also run outside of commands, so wrap their contents in `ActsAsTenant.with_tenant(current_account) { ... }` if they query tenant-scoped models.
401
+
321
402
  Background Processing libraries
322
403
  -------------------------------
323
404
 
@@ -332,6 +413,18 @@ Add the following code to `config/initializers/acts_as_tenant.rb`:
332
413
  require 'acts_as_tenant/sidekiq'
333
414
  ```
334
415
 
416
+ The tenant is set while the job runs, but not in `sidekiq_retries_exhausted` or death handlers, because Sidekiq calls them outside the middleware. The tenant is saved in the job hash, so you can set it yourself:
417
+
418
+ ```ruby
419
+ sidekiq_retries_exhausted do |job, exception|
420
+ tenant = Account.find_by(id: job.dig("acts_as_tenant", "id"))
421
+
422
+ ActsAsTenant.with_tenant(tenant) do
423
+ # ...
424
+ end
425
+ end
426
+ ```
427
+
335
428
  - DelayedJob - [acts_as_tenant-delayed_job](https://github.com/nunommc/acts_as_tenant-delayed_job)
336
429
 
337
430
  Testing
@@ -339,7 +432,7 @@ Testing
339
432
 
340
433
  If you set the `current_tenant` in your tests, make sure to clean up the tenant after each test by calling `ActsAsTenant.current_tenant = nil`. Integration tests are more difficult: manually setting the `current_tenant` value will not survive across multiple requests, even if they take place within the same test. This can result in undesired boilerplate to set the desired tenant. Moreover, the efficacy of the test can be compromised because the set `current_tenant` value will carry over into the request-response cycle.
341
434
 
342
- To address this issue, ActsAsTenant provides for a `test_tenant` value that can be set to allow for setup and post-request expectation testing. It should be used in conjunction with middleware that clears out this value while an integration test is processing. A typical Rails and RSpec setup might look like:
435
+ To address this issue, ActsAsTenant provides for a `test_tenant` value that can be set to allow for setup and post-request expectation testing. It should be used in conjunction with middleware that clears out this value while an integration test is processing. `test_tenant` is only intended for request/integration tests; in model and unit tests, set `current_tenant` or use `with_tenant` instead. A typical Rails and RSpec setup might look like:
343
436
 
344
437
  ```ruby
345
438
  # test.rb
@@ -372,6 +465,21 @@ config.after(:each) do |example|
372
465
  ActsAsTenant.test_tenant = nil
373
466
  end
374
467
  ```
468
+
469
+ ### Jobs performed inline
470
+
471
+ Rails resets `CurrentAttributes`, including `current_tenant`, after it performs a job inline ([rails/rails#49227](https://github.com/rails/rails/issues/49227)). The job runs with the tenant it was enqueued with, but the test loses its tenant afterwards when the job is performed inside a `perform_enqueued_jobs` block or with the `:inline` adapter. Call `perform_enqueued_jobs` without a block instead:
472
+
473
+ ```ruby
474
+ ActsAsTenant.current_tenant = account
475
+ ProcessOrderJob.perform_later(order)
476
+ perform_enqueued_jobs
477
+
478
+ expect(ActsAsTenant.current_tenant).to eq(account)
479
+ ```
480
+
481
+ `perform_now` also keeps the tenant.
482
+
375
483
  Bug reports & suggested improvements
376
484
  ------------------------------------
377
485
 
@@ -1,13 +1,30 @@
1
1
  module ActsAsTenant
2
2
  module ActiveJobExtensions
3
+ def self.prepended(base)
4
+ # Resolve the tenant inside the perform callbacks so `rescue_from`, `retry_on`
5
+ # and `discard_on` can handle a tenant that no longer exists.
6
+ base.before_perform do
7
+ ActsAsTenant.current_tenant = GlobalID::Locator.locate(@acts_as_tenant_global_id) if @acts_as_tenant_global_id
8
+ end
9
+ end
10
+
3
11
  def serialize
4
- super.merge("current_tenant" => ActsAsTenant.current_tenant&.to_global_id&.to_s)
12
+ # A deserialized job keeps the tenant it was enqueued with, e.g. when it is retried.
13
+ tenant_global_id = defined?(@acts_as_tenant_global_id) ? @acts_as_tenant_global_id : ActsAsTenant.current_tenant&.to_global_id&.to_s
14
+ super.merge("current_tenant" => tenant_global_id)
5
15
  end
6
16
 
7
17
  def deserialize(job_data)
8
- tenant_global_id = job_data.delete("current_tenant")
9
- ActsAsTenant.current_tenant = tenant_global_id ? GlobalID::Locator.locate(tenant_global_id) : nil
18
+ @acts_as_tenant_global_id = job_data.delete("current_tenant")
10
19
  super
11
20
  end
21
+
22
+ def perform_now
23
+ return super unless defined?(@acts_as_tenant_global_id)
24
+
25
+ # Start without a tenant so a job enqueued without one never inherits the thread's,
26
+ # and restore the thread's tenant once the job is done.
27
+ ActsAsTenant.with_tenant(nil) { super }
28
+ end
12
29
  end
13
30
  end
@@ -1,6 +1,7 @@
1
1
  module ActsAsTenant
2
2
  class Configuration
3
3
  attr_writer :require_tenant, :pkey
4
+ attr_accessor :tenant_change_hook
4
5
 
5
6
  def require_tenant
6
7
  @require_tenant ||= false
@@ -1,8 +1,6 @@
1
1
  module ActsAsTenant
2
2
  module ControllerExtensions
3
3
  module Filter
4
- extend ActiveSupport::Concern
5
-
6
4
  private
7
5
 
8
6
  def set_current_tenant(current_tenant_object)
@@ -11,7 +11,7 @@ module ActsAsTenant
11
11
  private
12
12
 
13
13
  def find_tenant_by_subdomain
14
- if (subdomain = request.subdomains.send(subdomain_lookup))
14
+ if (subdomain = request.subdomains.public_send(subdomain_lookup))
15
15
  ActsAsTenant.current_tenant = tenant_class.where(tenant_column => subdomain.downcase).first
16
16
  end
17
17
  end
@@ -11,7 +11,7 @@ module ActsAsTenant
11
11
  private
12
12
 
13
13
  def find_tenant_by_subdomain_or_domain
14
- subdomain = request.subdomains.send(subdomain_lookup)
14
+ subdomain = request.subdomains.public_send(subdomain_lookup)
15
15
  query = subdomain.present? ? {tenant_primary_column => subdomain.downcase} : {tenant_second_column => request.domain.downcase}
16
16
  ActsAsTenant.current_tenant = tenant_class.where(query).first
17
17
  end
@@ -15,7 +15,6 @@ module ActsAsTenant
15
15
  self.subdomain_lookup = subdomain_lookup
16
16
  end
17
17
 
18
- # 01/27/2014 Christian Yerena / @preth00nker
19
18
  # this method adds the possibility of use the domain as a possible second argument to find
20
19
  # the current_tenant.
21
20
  def set_current_tenant_by_subdomain_or_domain(tenant = :account, primary_column = :subdomain, second_column = :domain, subdomain_lookup: :last)
@@ -28,7 +27,7 @@ module ActsAsTenant
28
27
  end
29
28
 
30
29
  # This method sets up a method that allows manual setting of the current_tenant. This method should
31
- # be used in a before_action. In addition, a helper is setup that returns the current_tenant
30
+ # be used in a before_action.
32
31
  def set_current_tenant_through_filter
33
32
  include Filter
34
33
  end
@@ -5,35 +5,48 @@ module ActsAsTenant
5
5
  class_methods do
6
6
  def acts_as_tenant(tenant = :account, scope = nil, **options)
7
7
  ActsAsTenant.set_tenant_klass(tenant)
8
- ActsAsTenant.mutable_tenant!(false)
9
8
 
10
9
  ActsAsTenant.add_global_record_model(self) if options[:has_global_records]
11
10
 
12
11
  # Create the association
13
12
  valid_options = options.slice(:foreign_key, :class_name, :inverse_of, :optional, :primary_key, :counter_cache, :polymorphic, :touch)
14
- fkey = valid_options[:foreign_key] || ActsAsTenant.fkey
15
13
  pkey = valid_options[:primary_key] || ActsAsTenant.pkey
16
- polymorphic_type = valid_options[:foreign_type] || ActsAsTenant.polymorphic_type
17
14
  belongs_to tenant, scope, **valid_options
18
15
 
19
- default_scope lambda {
20
- if ActsAsTenant.should_require_tenant? && ActsAsTenant.current_tenant.nil? && !ActsAsTenant.unscoped?
21
- raise ActsAsTenant::Errors::NoTenantSet
16
+ tenant_reflection = reflect_on_association(tenant)
17
+ fkey = tenant_reflection.foreign_key.to_sym
18
+ polymorphic_type = tenant_reflection.foreign_type&.to_sym
19
+
20
+ # Polymorphic tenants are stored with polymorphic_name, like Rails does. Records saved before
21
+ # that used the class name, which differs for STI tenants, so match both when scoping.
22
+ # An OR is used because Rails 6.0 would assign an IN condition to new records as their type.
23
+ polymorphic_condition = lambda do |current_tenant|
24
+ polymorphic_name = current_tenant.class.polymorphic_name
25
+ class_name = current_tenant.class.name
26
+
27
+ if polymorphic_name == class_name
28
+ {polymorphic_type => polymorphic_name}
29
+ else
30
+ arel_table[polymorphic_type].eq(polymorphic_name).or(arel_table[polymorphic_type].eq(class_name))
22
31
  end
32
+ end
33
+
34
+ default_scope lambda {
35
+ current_tenant = ActsAsTenant.current_tenant
23
36
 
24
- if ActsAsTenant.current_tenant
25
- keys = [ActsAsTenant.current_tenant.send(pkey)].compact
37
+ if current_tenant
38
+ keys = [current_tenant.public_send(pkey)].compact
26
39
  keys.push(nil) if options[:has_global_records]
27
40
 
28
- if options[:through]
29
- query_criteria = {options[:through] => {fkey.to_sym => keys}}
30
- query_criteria[polymorphic_type.to_sym] = ActsAsTenant.current_tenant.class.to_s if options[:polymorphic]
31
- joins(options[:through]).where(query_criteria)
41
+ relation = if options[:through]
42
+ joins(options[:through]).where(options[:through] => {fkey => keys})
32
43
  else
33
- query_criteria = {fkey.to_sym => keys}
34
- query_criteria[polymorphic_type.to_sym] = ActsAsTenant.current_tenant.class.to_s if options[:polymorphic]
35
- where(query_criteria)
44
+ where(fkey => keys)
36
45
  end
46
+
47
+ options[:polymorphic] ? relation.where(polymorphic_condition.call(current_tenant)) : relation
48
+ elsif !ActsAsTenant.unscoped? && ActsAsTenant.should_require_tenant?(self)
49
+ raise ActsAsTenant::Errors::NoTenantSet
37
50
  else
38
51
  all
39
52
  end
@@ -43,61 +56,112 @@ module ActsAsTenant
43
56
  # - new instances should have the tenant set
44
57
  # - validate that associations belong to the tenant, currently only for belongs_to
45
58
  #
59
+ # New records without a tenant get the current tenant. A record assigned to
60
+ # another tenant keeps it and fails the validation below.
46
61
  before_validation proc { |m|
47
- if ActsAsTenant.current_tenant
48
- if options[:polymorphic]
49
- m.send("#{fkey}=".to_sym, ActsAsTenant.current_tenant.class.to_s) if m.send(fkey.to_s).nil?
50
- m.send("#{polymorphic_type}=".to_sym, ActsAsTenant.current_tenant.class.to_s) if m.send(polymorphic_type.to_s).nil?
62
+ if (current_tenant = ActsAsTenant.current_tenant)
63
+ m.public_send(:"#{fkey}=", current_tenant.public_send(pkey)) if m.public_send(fkey).nil?
64
+ m.public_send(:"#{polymorphic_type}=", current_tenant.class.polymorphic_name) if options[:polymorphic] && m.public_send(polymorphic_type).nil?
65
+ end
66
+ }, on: :create
67
+
68
+ # Returns [tenant id, tenant class] for a record, or nil if it has no tenant.
69
+ # Classes are normalized with polymorphic_name so STI tenants compare equal.
70
+ tenant_identity = lambda do |model|
71
+ reflection = model.class.reflect_on_association(tenant)
72
+ id = model.read_attribute(reflection.foreign_key) if reflection
73
+
74
+ if id
75
+ type = if reflection.polymorphic?
76
+ stored_type = model.read_attribute(reflection.foreign_type)
77
+ stored_type&.safe_constantize&.polymorphic_name || stored_type
51
78
  else
52
- m.send "#{fkey}=".to_sym, ActsAsTenant.current_tenant.send(pkey)
79
+ reflection.klass.polymorphic_name
53
80
  end
81
+ [id, type]
54
82
  end
55
- }, on: :create
83
+ end
84
+
85
+ # Without a current tenant the association lookup isn't scoped, so compare the tenants directly
86
+ tenant_mismatch = lambda do |record, associated|
87
+ if ActsAsTenant.current_tenant.nil? && associated.class.respond_to?(:scoped_by_tenant?)
88
+ record_tenant = tenant_identity.call(record)
89
+ associated_tenant = tenant_identity.call(associated)
56
90
 
57
- polymorphic_foreign_keys = reflect_on_all_associations(:belongs_to).select { |a|
58
- a.options[:polymorphic]
59
- }.map { |a| a.foreign_key }
60
-
61
- reflect_on_all_associations(:belongs_to).each do |a|
62
- unless a == reflect_on_association(tenant) || polymorphic_foreign_keys.include?(a.foreign_key)
63
- validates_each a.foreign_key.to_sym do |record, attr, value|
64
- next if value.nil?
65
- next unless record.will_save_change_to_attribute?(attr)
66
-
67
- primary_key = if a.respond_to?(:active_record_primary_key)
68
- a.active_record_primary_key
69
- else
70
- a.primary_key
71
- end.to_sym
72
- scope = a.scope || ->(relation) { relation }
73
- record.errors.add attr, "association is invalid [ActsAsTenant]" unless a.klass.class_eval(&scope).where(primary_key => value).any?
91
+ record_tenant && associated_tenant && record_tenant != associated_tenant
92
+ end
93
+ end
94
+
95
+ # Records must belong to the current tenant, matching what the default scope would find
96
+ validate do |record|
97
+ current_tenant = ActsAsTenant.current_tenant
98
+ next unless current_tenant
99
+
100
+ tenant_attributes = [fkey, polymorphic_type].compact
101
+ next unless record.new_record? || tenant_attributes.any? { |attr| record.will_save_change_to_attribute?(attr) }
102
+
103
+ record_tenant = tenant_identity.call(record)
104
+ next if record_tenant.nil?
105
+
106
+ record_id, record_type = record_tenant
107
+ matches = record_id.to_s == current_tenant.public_send(pkey).to_s
108
+ matches &&= record_type == current_tenant.class.polymorphic_name if options[:polymorphic]
109
+
110
+ record.errors.add(fkey, "must be the current tenant [ActsAsTenant]") unless matches
111
+ end
112
+
113
+ # Associations are looked up at validation time so belongs_to associations
114
+ # declared after acts_as_tenant are validated too
115
+ validate do |record|
116
+ associations = record.class.reflect_on_all_associations(:belongs_to)
117
+ polymorphic_foreign_keys = associations.select(&:polymorphic?).map(&:foreign_key)
118
+
119
+ associations.each do |a|
120
+ attr = a.foreign_key.to_sym
121
+ next unless record.will_save_change_to_attribute?(attr)
122
+ next if a.name == tenant.to_sym || polymorphic_foreign_keys.include?(a.foreign_key)
123
+
124
+ value = record.read_attribute_for_validation(attr)
125
+ next if value.nil?
126
+
127
+ relation = a.scope ? a.klass.class_eval(&a.scope) : a.klass
128
+ associated = relation.find_by(a.association_primary_key => value)
129
+
130
+ if associated.nil? || tenant_mismatch.call(record, associated)
131
+ record.errors.add attr, "association is invalid [ActsAsTenant]"
74
132
  end
75
133
  end
76
134
  end
77
135
 
78
- # Dynamically generate the following methods:
79
- # - Rewrite the accessors to make tenant immutable
80
- # - Add an override to prevent unnecessary db hits
81
- # - Add a helper method to verify if a model has been scoped by AaT
136
+ # Tenant writers raise if the tenant changes on a persisted record
82
137
  to_include = Module.new {
83
- define_method "#{fkey}=" do |integer|
84
- write_attribute(fkey.to_s, integer)
85
- raise ActsAsTenant::Errors::TenantIsImmutable if !ActsAsTenant.mutable_tenant? && tenant_modified?
138
+ define_method :"#{fkey}=" do |integer|
139
+ write_attribute(fkey, integer)
140
+ raise_if_tenant_changed
86
141
  integer
87
142
  end
88
143
 
89
- define_method "#{ActsAsTenant.tenant_klass}=" do |model|
144
+ define_method :"#{tenant}=" do |model|
90
145
  super(model)
91
- raise ActsAsTenant::Errors::TenantIsImmutable if !ActsAsTenant.mutable_tenant? && tenant_modified?
146
+ raise_if_tenant_changed
92
147
  model
93
148
  end
94
149
 
150
+ define_method :raise_if_tenant_changed do
151
+ raise ActsAsTenant::Errors::TenantIsImmutable if !ActsAsTenant.mutable_tenant? && tenant_modified?
152
+ end
153
+ private :raise_if_tenant_changed
154
+
95
155
  define_method :tenant_modified? do
96
156
  will_save_change_to_attribute?(fkey) && persisted? && attribute_in_database(fkey).present?
97
157
  end
98
158
  }
99
159
  include to_include
100
160
 
161
+ # Stored per model since ActsAsTenant.tenant_klass is whichever model called acts_as_tenant last
162
+ class_attribute :acts_as_tenant_foreign_key, instance_accessor: false
163
+ self.acts_as_tenant_foreign_key = fkey
164
+
101
165
  class << self
102
166
  def scoped_by_tenant?
103
167
  true
@@ -105,10 +169,11 @@ module ActsAsTenant
105
169
  end
106
170
  end
107
171
 
108
- def validates_uniqueness_to_tenant(fields, args = {})
172
+ def validates_uniqueness_to_tenant(*fields)
173
+ args = fields.extract_options!
109
174
  raise ActsAsTenant::Errors::ModelNotScopedByTenant unless respond_to?(:scoped_by_tenant?)
110
175
 
111
- fkey = reflect_on_association(ActsAsTenant.tenant_klass).foreign_key
176
+ fkey = acts_as_tenant_foreign_key
112
177
 
113
178
  validation_args = args.deep_dup
114
179
  validation_args[:scope] = if args[:scope]
@@ -118,25 +183,26 @@ module ActsAsTenant
118
183
  end
119
184
 
120
185
  # validating within tenant scope
121
- validates_uniqueness_of(fields, validation_args)
186
+ validates_uniqueness_of(*fields, validation_args)
122
187
 
123
188
  if ActsAsTenant.models_with_global_records.include?(self)
124
189
  arg_if = args.delete(:if)
125
190
  arg_condition = args.delete(:conditions)
191
+ arg_if_passes = ->(instance) { arg_if.blank? || arg_if.call(instance) }
126
192
 
127
193
  # if tenant is not set (instance is global) - validating globally
128
194
  global_validation_args = args.merge(
129
- if: ->(instance) { instance[fkey].blank? && (arg_if.blank? || arg_if.call(instance)) }
195
+ if: ->(instance) { instance[fkey].blank? && arg_if_passes.call(instance) }
130
196
  )
131
- validates_uniqueness_of(fields, global_validation_args)
197
+ validates_uniqueness_of(*fields, global_validation_args)
132
198
 
133
199
  # if tenant is set (instance is not global) and records can be global - validating within records with blank tenant
134
- blank_tenant_validation_args = args.merge({
200
+ blank_tenant_validation_args = args.merge(
135
201
  conditions: -> { arg_condition.blank? ? where(fkey => nil) : arg_condition.call.where(fkey => nil) },
136
- if: ->(instance) { instance[fkey].present? && (arg_if.blank? || arg_if.call(instance)) }
137
- })
202
+ if: ->(instance) { instance[fkey].present? && arg_if_passes.call(instance) }
203
+ )
138
204
 
139
- validates_uniqueness_of(fields, blank_tenant_validation_args)
205
+ validates_uniqueness_of(*fields, blank_tenant_validation_args)
140
206
  end
141
207
  end
142
208
  end
@@ -10,12 +10,8 @@ module ActsAsTenant::Sidekiq
10
10
  include Sidekiq::ClientMiddleware if sidekiq_7_and_up?
11
11
 
12
12
  def call(worker_class, msg, queue, redis_pool)
13
- if ActsAsTenant.current_tenant.present?
14
- msg["acts_as_tenant"] ||=
15
- {
16
- "class" => ActsAsTenant.current_tenant.class.name,
17
- "id" => ActsAsTenant.current_tenant.id
18
- }
13
+ if (tenant = ActsAsTenant.current_tenant)
14
+ msg["acts_as_tenant"] ||= {"class" => tenant.class.name, "id" => tenant.id}
19
15
  end
20
16
 
21
17
  yield
@@ -31,9 +27,7 @@ module ActsAsTenant::Sidekiq
31
27
  klass = msg["acts_as_tenant"]["class"].constantize
32
28
  id = msg["acts_as_tenant"]["id"]
33
29
  account = klass.class_eval(&ActsAsTenant.configuration.job_scope).find(id)
34
- ActsAsTenant.with_tenant account do
35
- yield
36
- end
30
+ ActsAsTenant.with_tenant(account) { yield }
37
31
  else
38
32
  yield
39
33
  end
@@ -52,9 +46,7 @@ Sidekiq.configure_server do |config|
52
46
  chain.add ActsAsTenant::Sidekiq::Client
53
47
  end
54
48
  config.server_middleware do |chain|
55
- if defined?(Sidekiq::Middleware::Server::RetryJobs)
56
- chain.insert_before Sidekiq::Middleware::Server::RetryJobs, ActsAsTenant::Sidekiq::Server
57
- elsif defined?(Sidekiq::Batch::Server)
49
+ if defined?(Sidekiq::Batch::Server)
58
50
  chain.insert_before Sidekiq::Batch::Server, ActsAsTenant::Sidekiq::Server
59
51
  else
60
52
  chain.add ActsAsTenant::Sidekiq::Server
@@ -1,3 +1,3 @@
1
1
  module ActsAsTenant
2
- VERSION = "1.0.1"
2
+ VERSION = "2.0.0"
3
3
  end
@@ -12,10 +12,16 @@ module ActsAsTenant
12
12
  @@configuration = nil
13
13
  @@tenant_klass = nil
14
14
  @@models_with_global_records = []
15
- @@mutable_tenant = false
16
15
 
17
16
  class Current < ActiveSupport::CurrentAttributes
18
- attribute :current_tenant, :acts_as_tenant_unscoped
17
+ attribute :current_tenant, :acts_as_tenant_unscoped, :acts_as_tenant_mutable
18
+
19
+ # Rails resets attributes directly at the end of a request or job, bypassing the writer below
20
+ resets { ActsAsTenant.configuration.tenant_change_hook&.call(nil) }
21
+
22
+ def current_tenant=(tenant)
23
+ super.tap { ActsAsTenant.configuration.tenant_change_hook&.call(tenant) }
24
+ end
19
25
  end
20
26
 
21
27
  class << self
@@ -93,59 +99,41 @@ module ActsAsTenant
93
99
  end
94
100
 
95
101
  def self.mutable_tenant!(toggle)
96
- @@mutable_tenant = toggle
102
+ Current.acts_as_tenant_mutable = toggle
97
103
  end
98
104
 
99
105
  def self.mutable_tenant?
100
- @@mutable_tenant
106
+ !!Current.acts_as_tenant_mutable
101
107
  end
102
108
 
103
109
  def self.with_tenant(tenant, &block)
104
- if block.nil?
105
- raise ArgumentError, "block required"
106
- end
110
+ raise ArgumentError, "block required" if block.nil?
107
111
 
108
- old_tenant = current_tenant
109
- self.current_tenant = tenant
110
- value = block.call
111
- value
112
- ensure
113
- self.current_tenant = old_tenant
112
+ Current.set(current_tenant: tenant, &block)
114
113
  end
115
114
 
116
115
  def self.without_tenant(&block)
117
- if block.nil?
118
- raise ArgumentError, "block required"
119
- end
116
+ raise ArgumentError, "block required" if block.nil?
120
117
 
121
- old_tenant = current_tenant
122
118
  old_test_tenant = test_tenant
123
- old_unscoped = unscoped
124
-
125
- self.current_tenant = nil
126
119
  self.test_tenant = nil
127
- self.unscoped = true
128
- value = block.call
129
- value
130
- ensure
131
- self.current_tenant = old_tenant
132
- self.test_tenant = old_test_tenant
133
- self.unscoped = old_unscoped
120
+ begin
121
+ Current.set(current_tenant: nil, acts_as_tenant_unscoped: true, &block)
122
+ ensure
123
+ self.test_tenant = old_test_tenant
124
+ end
134
125
  end
135
126
 
136
127
  def self.with_mutable_tenant(&block)
137
- ActsAsTenant.mutable_tenant!(true)
138
- without_tenant(&block)
139
- ensure
140
- ActsAsTenant.mutable_tenant!(false)
128
+ Current.set(acts_as_tenant_mutable: true) { without_tenant(&block) }
141
129
  end
142
130
 
143
- def self.should_require_tenant?
144
- if configuration.require_tenant.respond_to?(:call)
145
- !!configuration.require_tenant.call
146
- else
147
- !!configuration.require_tenant
148
- end
131
+ def self.should_require_tenant?(relation = nil)
132
+ config = configuration.require_tenant
133
+ return !!config unless config.respond_to?(:call)
134
+
135
+ arity = config.respond_to?(:arity) ? config.arity : config.method(:call).arity
136
+ arity.zero? ? !!config.call : !!config.call(relation)
149
137
  end
150
138
  end
151
139
 
metadata CHANGED
@@ -1,15 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: acts_as_tenant
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Erwin Matthijssen
8
8
  - Chris Oliver
9
- autorequire:
10
9
  bindir: bin
11
10
  cert_chain: []
12
- date: 2023-12-14 00:00:00.000000000 Z
11
+ date: 1980-01-02 00:00:00.000000000 Z
13
12
  dependencies:
14
13
  - !ruby/object:Gem::Dependency
15
14
  name: rails
@@ -51,9 +50,10 @@ files:
51
50
  - lib/acts_as_tenant/test_tenant_middleware.rb
52
51
  - lib/acts_as_tenant/version.rb
53
52
  homepage: https://github.com/ErwinM/acts_as_tenant
54
- licenses: []
55
- metadata: {}
56
- post_install_message:
53
+ licenses:
54
+ - MIT
55
+ metadata:
56
+ rubygems_mfa_required: 'true'
57
57
  rdoc_options: []
58
58
  require_paths:
59
59
  - lib
@@ -68,8 +68,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
68
68
  - !ruby/object:Gem::Version
69
69
  version: '0'
70
70
  requirements: []
71
- rubygems_version: 3.4.22
72
- signing_key:
71
+ rubygems_version: 4.0.21
73
72
  specification_version: 4
74
73
  summary: Add multi-tenancy to Rails applications using a shared db strategy
75
74
  test_files: []