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 +4 -4
- data/CHANGELOG.md +5 -0
- data/README.md +144 -21
- data/lib/composable/tenant/data_set.rb +6 -15
- data/lib/composable/tenant/gem_version.rb +1 -1
- data/lib/composable/tenant/model_extensions.rb +12 -13
- data/lib/composable/tenant.rb +2 -0
- metadata +5 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7049577e0785c692d57269d0e2fdc86f383b7bd83026bffcba47302c56be6f71
|
|
4
|
+
data.tar.gz: 794bc4933ca811c52e38742ba182e76c6e77339b0066afef1cdb7a96763135ad
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
1
|
+
# composable-tenant
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
7
|
+
## Declare and set a tenant
|
|
6
8
|
|
|
7
|
-
|
|
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
|
-
|
|
37
|
+
## Controlled bypass and context lifetime
|
|
10
38
|
|
|
11
39
|
```ruby
|
|
12
|
-
|
|
40
|
+
Composable::Tenant.without_tenant(:account) do
|
|
41
|
+
# Perform a separately authorized maintenance operation here.
|
|
42
|
+
end
|
|
13
43
|
```
|
|
14
44
|
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
+
## Isolation limits
|
|
20
57
|
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
+
## Set context for a Rails request
|
|
26
68
|
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
101
|
+
## Establish context in a job or script
|
|
34
102
|
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 :
|
|
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(
|
|
17
|
-
tenants[
|
|
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
|
|
@@ -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
|
-
|
|
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 |
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/composable/tenant.rb
CHANGED
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.
|
|
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:
|
|
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: '
|
|
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: '
|
|
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.
|
|
56
|
+
version: 3.2.0
|
|
57
57
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
58
58
|
requirements:
|
|
59
59
|
- - ">="
|