composable-tenant 0.0.13 → 0.0.14

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: a8dae1b135ec29fef3b9f8d961933c87f57030dd82ea6d119f01142967359aac
4
- data.tar.gz: 7bad5b0d57d941208138b48ac7a5fa1d754b44cf0dae3325a2290ef69685f229
3
+ metadata.gz: 7049577e0785c692d57269d0e2fdc86f383b7bd83026bffcba47302c56be6f71
4
+ data.tar.gz: 794bc4933ca811c52e38742ba182e76c6e77339b0066afef1cdb7a96763135ad
5
5
  SHA512:
6
- metadata.gz: e76174a1bd05a6ef778669c5f531d1a1f9d7c1109af0b099d12d107872f7ac7e9268816434f33320cea01167ff068ae5398a1043d655585a9c6e980223ea8f2b
7
- data.tar.gz: 15dd17c6aad45aa9c486115c5080f7a093d2ab6be5c807a2f02409bcf362935f3b4151a0d22f917a69409b0a3c93b703463d0e4ad6fd6248041846582e50b0d9
6
+ metadata.gz: 2cfe843b1e4e2b0afff899b9cdaf75a6f4a24fd8b2a55bcc4499b8746e120b0e99fe21611f450ca7df95ce7e03e1f16aaaf4d2db0de14d23e40feb42ffe64dbd
7
+ data.tar.gz: b30665d45519db78abe3862d2482260aa26e2fc53287bdfb9daee7594d94e95be9ca2001812ed9c4fbc4832a7237e1466e47287a67466608c2da29ee87b90df7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  ## [Unreleased]
2
2
 
3
+ - Reject immutable tenant changes before modifying object state.
4
+ - Use declared execution-context defaults and restore nested bypasses after exceptions.
5
+ - Document authorization and SQL/bulk-operation boundaries.
6
+ - Add complete coverage of isolated queries, writes, associations and context.
7
+
3
8
  ## [0.1.0] - 2022-06-10
4
9
 
5
10
  - Initial release
data/README.md CHANGED
@@ -1,43 +1,166 @@
1
- # Composable::Tenant
1
+ # composable-tenant
2
2
 
3
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/composable/tenant`. To experiment with that code, run `bin/console` for an interactive prompt.
3
+ ActiveRecord tenant scopes and execution-local tenant context. Requires Ruby
4
+ 3.2+ and ActiveRecord 7.2+. Install `gem "composable-tenant"` and require
5
+ `composable/tenant`; it loads ActiveRecord and installs the model extension.
4
6
 
5
- TODO: Delete this and the text above, and describe your gem
7
+ ## Declare and set a tenant
6
8
 
7
- ## Installation
9
+ ```ruby
10
+ require "composable/tenant"
11
+
12
+ class Article < ActiveRecord::Base
13
+ tenant :account
14
+ end
15
+
16
+ # Account and Article tables belong to the application.
17
+ # Composable::Tenant.set_tenant(:account, authorized_account)
18
+ # Article.where(name: "Example")
19
+ ```
20
+
21
+ `tenant :account` declares a `belongs_to` association and a default scope on
22
+ `account_id`, using the current account's `id`. Association options such as
23
+ `class_name`, `foreign_key`, `primary_key`, `optional` and a scope are supported.
24
+ Association validation is optional by default because the creation callback sets
25
+ the foreign key from context.
26
+
27
+ `Composable::Tenant.set_tenant(:account, record)` sets context and returns the
28
+ record. Normal queries without a required tenant raise `NoTenantSet`; creation
29
+ also requires context. New records receive the current tenant during validation,
30
+ replacing a caller-supplied foreign key. Context does not prove that the current
31
+ user is authorized for that account: the application must make that decision.
32
+
33
+ Persisted tenant foreign keys and association setters are immutable. A rejected
34
+ assignment raises `TenantIsImmutable` before changing the foreign key or cached
35
+ association. Assigning the existing identifier remains valid after type casting.
8
36
 
9
- Add this line to your application's Gemfile:
37
+ ## Controlled bypass and context lifetime
10
38
 
11
39
  ```ruby
12
- gem 'composable-tenant'
40
+ Composable::Tenant.without_tenant(:account) do
41
+ # Perform a separately authorized maintenance operation here.
42
+ end
13
43
  ```
14
44
 
15
- And then execute:
45
+ `without_tenant` bypasses the named scopes and creation assignment for the duration
46
+ of the block. Nested blocks combine exclusions and restore the previous state,
47
+ even after exceptions. Never use it as the normal path for user-facing queries.
16
48
 
17
- $ bundle install
49
+ `Composable::Tenant::DataSet.tenant(:account)` reads current context.
50
+ `DataSet.reset` clears all tenant values and exclusions. Rails executor boundaries
51
+ reset CurrentAttributes around requests and jobs; scripts and custom workers must
52
+ use executor boundaries or reset in an ensure block. Merely restoring an
53
+ application's own `Current.account` does not automatically restore this gem's
54
+ context unless the application's integration sets it again.
18
55
 
19
- Or install it yourself as:
56
+ ## Isolation limits
20
57
 
21
- $ gem install composable-tenant
58
+ Default scopes cover ordinary ActiveRecord queries and instance update/delete
59
+ constraints. `unscoped`, raw SQL and APIs bypassing model setters/callbacks can
60
+ bypass parts of this protection. Bulk inserts and direct foreign-key writes need
61
+ explicit authorization and validation. This is not database row-level security.
62
+ Use database constraints/RLS where isolation must survive arbitrary SQL.
22
63
 
23
- ## Usage
64
+ The tests exercise two tenants, blocked writes, missing context, custom keys,
65
+ nested bypasses, exceptions and thread reset. See [testing](../docs/testing.md).
24
66
 
25
- TODO: Write usage instructions here
67
+ ## Set context for a Rails request
26
68
 
27
- ## Development
69
+ The application supplies authentication and account membership checks. Resolve an
70
+ account through an authorized association, then set the gem's tenant context:
28
71
 
29
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
72
+ ```ruby
73
+ class ApplicationController < ActionController::Base
74
+ before_action :set_account_context
75
+
76
+ private
77
+
78
+ def set_account_context
79
+ account = current_user.accounts.find(params[:account_id])
80
+ Composable::Tenant.set_tenant(:account, account)
81
+ end
82
+ end
83
+
84
+ class ArticlesController < ApplicationController
85
+ def index
86
+ @articles = Article.order(:name) # Scoped to the selected account.
87
+ end
88
+
89
+ def create
90
+ @article = Article.create!(params.require(:article).permit(:name))
91
+ # account_id is assigned from the context during validation.
92
+ end
93
+ end
94
+ ```
30
95
 
31
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
96
+ Use this pattern on controllers whose routes include an account identifier.
97
+ Authentication and account selection controllers may need a different base or
98
+ callback policy. Rails resets the CurrentAttributes context at executor
99
+ boundaries; the selected account is not carried into a later request or job.
32
100
 
33
- ## Contributing
101
+ ## Establish context in a job or script
34
102
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/jairovm/composables. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/jairovm/composables/tree/main/CODE_OF_CONDUCT.md).
103
+ Pass an account identifier to a job rather than relying on the enqueueing
104
+ request's context. In this application example, `Account` itself is not tenanted:
36
105
 
37
- ## License
106
+ ```ruby
107
+ class CreateArticleJob < ApplicationJob
108
+ def perform(account_id, name)
109
+ account = Account.find(account_id)
110
+ Composable::Tenant.set_tenant(:account, account)
111
+ Article.create!(name: name)
112
+ end
113
+ end
114
+ ```
38
115
 
39
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
116
+ Authorize which account and inputs may be enqueued in the application. For a
117
+ standalone script outside a Rails executor, explicitly reset after work:
118
+
119
+ ```ruby
120
+ begin
121
+ Composable::Tenant.set_tenant(:account, authorized_account)
122
+ article = Article.create!(name: "Example")
123
+ article.account_id # => authorized_account.id
124
+ ensure
125
+ Composable::Tenant::DataSet.reset
126
+ end
127
+ ```
40
128
 
41
- ## Code of Conduct
129
+ Use this reset pattern only when the script owns the entire context lifetime.
130
+ Calling `reset` inside an existing request would also clear its other tenant
131
+ contexts.
132
+
133
+ ## Use a custom foreign key
134
+
135
+ Assume `documents.organization_id` references `accounts.id`:
136
+
137
+ ```ruby
138
+ class Document < ActiveRecord::Base
139
+ tenant :workspace, class_name: "Account", foreign_key: :organization_id
140
+ end
141
+
142
+ Composable::Tenant.set_tenant(:workspace, authorized_account)
143
+ document = Document.create!(title: "Example")
144
+ document.organization_id # => authorized_account.id
145
+
146
+ # On this persisted document, both assignments raise TenantIsImmutable:
147
+ # document.organization_id = another_account.id
148
+ # document.workspace = another_account
149
+ ```
150
+
151
+ The context key is the association name (`workspace`), not the model name or
152
+ foreign key. A model declaring multiple tenants requires each applicable context.
153
+
154
+ ## Perform a maintenance query
155
+
156
+ ```ruby
157
+ count = Composable::Tenant.without_tenant(:account) do
158
+ Article.count # All accounts; only for an authorized maintenance operation.
159
+ end
160
+
161
+ # The previous exclusions are restored, including when the block raises.
162
+ ```
42
163
 
43
- Everyone interacting in the Composable::Tenant project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/jairovm/composables/tree/main/CODE_OF_CONDUCT.md).
164
+ Bypass does not make persisted tenant assignments mutable. When you temporarily
165
+ change the current tenant yourself, `without_tenant` does not restore that tenant
166
+ value; it only restores its own exclusions.
@@ -1,26 +1,17 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Composable
2
4
  module Tenant
3
5
  class DataSet < ActiveSupport::CurrentAttributes
4
- attribute :excluded_tenants
5
-
6
- resets { tenants.clear }
7
-
8
- def excluded_tenants
9
- super || Set.new
10
- end
6
+ attribute :tenants, default: -> { {} }
7
+ attribute :excluded_tenants, default: -> { Set.new }
11
8
 
12
9
  def tenant=(array)
13
10
  tenants[array.first.to_sym] = array.last
14
11
  end
15
12
 
16
- def tenant(tenant)
17
- tenants[tenant.to_sym]
18
- end
19
-
20
- private
21
-
22
- def tenants
23
- @tenants ||= {}
13
+ def tenant(name)
14
+ tenants[name.to_sym]
24
15
  end
25
16
  end
26
17
  end
@@ -10,7 +10,7 @@ module Composable
10
10
  module VERSION
11
11
  MAJOR = 0
12
12
  MINOR = 0
13
- TINY = 13
13
+ TINY = 14
14
14
  PRE = nil
15
15
 
16
16
  STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")
@@ -2,7 +2,7 @@ module Composable
2
2
  module Tenant
3
3
  def self.without_tenant(*tenants)
4
4
  old_excluded_tenants = Composable::Tenant::DataSet.excluded_tenants
5
- Composable::Tenant::DataSet.excluded_tenants = Set.new(tenants.map(&:to_sym))
5
+ Composable::Tenant::DataSet.excluded_tenants = old_excluded_tenants | Set.new(tenants.map(&:to_sym))
6
6
  yield
7
7
  ensure
8
8
  Composable::Tenant::DataSet.excluded_tenants = old_excluded_tenants
@@ -40,27 +40,26 @@ module Composable
40
40
  before_validation lambda { |record|
41
41
  return if Composable::Tenant::DataSet.excluded_tenants.include?(tenant.to_sym)
42
42
 
43
- record.send("#{foreign_key}=", Composable::Tenant::DataSet.tenant(tenant).send(primary_key))
43
+ current_tenant = Composable::Tenant::DataSet.tenant(tenant)
44
+ raise Composable::Tenant::NoTenantSet.new(tenant: tenant) unless current_tenant
45
+
46
+ record.public_send("#{foreign_key}=", current_tenant.public_send(primary_key))
44
47
  }, on: :create
45
48
 
46
49
  # Rewrite the accessors to make tenant immutable
47
50
  to_include = Module.new do
48
- define_method "#{foreign_key}=" do |integer|
49
- write_attribute(foreign_key, integer)
50
- raise Composable::Tenant::TenantIsImmutable.new(method_name: "#{foreign_key}=") if send("#{foreign_key}_changed?") &&
51
- persisted? &&
52
- !send("#{foreign_key}_was").nil?
51
+ define_method "#{foreign_key}=" do |value|
52
+ cast_value = self.class.type_for_attribute(foreign_key.to_s).cast(value)
53
+ if persisted? && cast_value != attribute_in_database(foreign_key)
54
+ raise Composable::Tenant::TenantIsImmutable.new(method_name: "#{foreign_key}=")
55
+ end
53
56
 
54
- integer
57
+ write_attribute(foreign_key, cast_value)
55
58
  end
56
59
 
57
60
  define_method "#{tenant}=" do |model|
61
+ public_send("#{foreign_key}=", model&.public_send(primary_key))
58
62
  super(model)
59
- raise Composable::Tenant::TenantIsImmutable.new(method_name: "#{tenant}=") if send("#{foreign_key}_changed?") &&
60
- persisted? &&
61
- !send("#{foreign_key}_was").nil?
62
-
63
- model
64
63
  end
65
64
  end
66
65
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "set"
4
+ require "active_record"
3
5
  require_relative "tenant/version"
4
6
 
5
7
  module Composable
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: composable-tenant
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.13
4
+ version: 0.0.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jairo Vazquez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-08-09 00:00:00.000000000 Z
10
+ date: 2026-10-07 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activerecord
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '6.1'
18
+ version: '7.2'
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: '6.1'
25
+ version: '7.2'
26
26
  description: Tenant composable object to scope ActiveRecord queries
27
27
  email:
28
28
  - jairovm20@gmail.com
@@ -53,7 +53,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
53
53
  requirements:
54
54
  - - ">="
55
55
  - !ruby/object:Gem::Version
56
- version: 2.7.0
56
+ version: 3.2.0
57
57
  required_rubygems_version: !ruby/object:Gem::Requirement
58
58
  requirements:
59
59
  - - ">="