plutonium 0.50.0 → 0.51.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 +4 -4
- data/.claude/skills/plutonium/SKILL.md +85 -102
- data/.claude/skills/plutonium-app/SKILL.md +572 -0
- data/.claude/skills/plutonium-auth/SKILL.md +163 -300
- data/.claude/skills/plutonium-behavior/SKILL.md +838 -0
- data/.claude/skills/plutonium-resource/SKILL.md +1176 -0
- data/.claude/skills/plutonium-tenancy/SKILL.md +655 -0
- data/.claude/skills/plutonium-testing/SKILL.md +6 -5
- data/.claude/skills/plutonium-ui/SKILL.md +900 -0
- data/CHANGELOG.md +27 -2
- data/Rakefile +2 -1
- data/app/assets/plutonium.css +1 -11
- data/app/assets/plutonium.js +1009 -1214
- data/app/assets/plutonium.js.map +3 -3
- data/app/assets/plutonium.min.js +52 -51
- data/app/assets/plutonium.min.js.map +3 -3
- data/docs/.vitepress/config.ts +37 -27
- data/docs/getting-started/index.md +22 -29
- data/docs/getting-started/installation.md +37 -80
- data/docs/getting-started/tutorial/index.md +4 -5
- data/docs/guides/adding-resources.md +66 -377
- data/docs/guides/authentication.md +94 -463
- data/docs/guides/authorization.md +124 -370
- data/docs/guides/creating-packages.md +94 -296
- data/docs/guides/custom-actions.md +121 -441
- data/docs/guides/index.md +22 -42
- data/docs/guides/multi-tenancy.md +116 -187
- data/docs/guides/nested-resources.md +103 -431
- data/docs/guides/search-filtering.md +123 -240
- data/docs/guides/testing.md +5 -4
- data/docs/guides/theming.md +157 -407
- data/docs/guides/troubleshooting.md +5 -3
- data/docs/guides/user-invites.md +106 -425
- data/docs/guides/user-profile.md +76 -243
- data/docs/index.md +1 -1
- data/docs/reference/app/generators.md +517 -0
- data/docs/reference/app/index.md +158 -0
- data/docs/reference/app/packages.md +146 -0
- data/docs/reference/app/portals.md +377 -0
- data/docs/reference/auth/accounts.md +230 -0
- data/docs/reference/auth/index.md +88 -0
- data/docs/reference/auth/profile.md +185 -0
- data/docs/reference/behavior/controllers.md +395 -0
- data/docs/reference/behavior/index.md +22 -0
- data/docs/reference/behavior/interactions.md +341 -0
- data/docs/reference/behavior/policies.md +417 -0
- data/docs/reference/index.md +56 -49
- data/docs/reference/resource/actions.md +423 -0
- data/docs/reference/resource/definition.md +508 -0
- data/docs/reference/resource/index.md +50 -0
- data/docs/reference/resource/model.md +348 -0
- data/docs/reference/resource/query.md +305 -0
- data/docs/reference/tenancy/entity-scoping.md +361 -0
- data/docs/reference/tenancy/index.md +36 -0
- data/docs/reference/tenancy/invites.md +393 -0
- data/docs/reference/tenancy/nested-resources.md +267 -0
- data/docs/reference/testing/index.md +287 -0
- data/docs/reference/ui/assets.md +400 -0
- data/docs/reference/ui/components.md +165 -0
- data/docs/reference/ui/displays.md +104 -0
- data/docs/reference/ui/forms.md +284 -0
- data/docs/reference/ui/index.md +30 -0
- data/docs/reference/ui/layouts.md +106 -0
- data/docs/reference/ui/pages.md +189 -0
- data/docs/reference/ui/tables.md +117 -0
- data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
- data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
- data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/core/update/update_generator.rb +0 -20
- data/lib/generators/pu/invites/install_generator.rb +1 -0
- data/lib/plutonium/definition/base.rb +1 -1
- data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
- data/lib/plutonium/helpers/turbo_helper.rb +11 -0
- data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
- data/lib/plutonium/resource/controller.rb +1 -0
- data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
- data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
- data/lib/plutonium/resource/policy.rb +7 -0
- data/lib/plutonium/routing/mapper_extensions.rb +15 -0
- data/lib/plutonium/ui/component/methods.rb +4 -0
- data/lib/plutonium/ui/form/base.rb +6 -2
- data/lib/plutonium/ui/form/components/json.rb +58 -0
- data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
- data/lib/plutonium/ui/form/components/secure_association.rb +98 -22
- data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
- data/lib/plutonium/ui/form/resource.rb +0 -4
- data/lib/plutonium/ui/grid/resource.rb +1 -1
- data/lib/plutonium/ui/layout/base.rb +1 -0
- data/lib/plutonium/ui/page/base.rb +0 -7
- data/lib/plutonium/ui/page/index.rb +4 -4
- data/lib/plutonium/ui/table/resource.rb +1 -1
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium.rb +8 -0
- data/lib/tasks/release.rake +15 -1
- data/package.json +10 -10
- data/src/css/slim_select.css +4 -0
- data/src/js/controllers/slim_select_controller.js +61 -0
- data/src/js/turbo/turbo_actions.js +33 -0
- data/yarn.lock +553 -543
- metadata +44 -33
- data/.claude/skills/plutonium-assets/SKILL.md +0 -512
- data/.claude/skills/plutonium-controller/SKILL.md +0 -396
- data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
- data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
- data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
- data/.claude/skills/plutonium-forms/SKILL.md +0 -465
- data/.claude/skills/plutonium-installation/SKILL.md +0 -331
- data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
- data/.claude/skills/plutonium-invites/SKILL.md +0 -408
- data/.claude/skills/plutonium-model/SKILL.md +0 -440
- data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
- data/.claude/skills/plutonium-package/SKILL.md +0 -198
- data/.claude/skills/plutonium-policy/SKILL.md +0 -456
- data/.claude/skills/plutonium-portal/SKILL.md +0 -410
- data/.claude/skills/plutonium-views/SKILL.md +0 -651
- data/docs/reference/assets/index.md +0 -496
- data/docs/reference/controller/index.md +0 -412
- data/docs/reference/definition/actions.md +0 -462
- data/docs/reference/definition/fields.md +0 -383
- data/docs/reference/definition/index.md +0 -326
- data/docs/reference/definition/query.md +0 -351
- data/docs/reference/generators/index.md +0 -648
- data/docs/reference/interaction/index.md +0 -449
- data/docs/reference/model/features.md +0 -248
- data/docs/reference/model/index.md +0 -218
- data/docs/reference/policy/index.md +0 -456
- data/docs/reference/portal/index.md +0 -379
- data/docs/reference/views/forms.md +0 -411
- data/docs/reference/views/index.md +0 -544
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# Entity Scoping
|
|
2
|
+
|
|
3
|
+
Multi-tenant data isolation. Built on three cooperating pieces — portal, policy, model — that together ensure queries never leak across tenants.
|
|
4
|
+
|
|
5
|
+
## 🚨 Critical
|
|
6
|
+
|
|
7
|
+
- **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins to the entity triggers `verify_default_relation_scope_applied!`. Always call `default_relation_scope(relation)` explicitly.
|
|
8
|
+
- **Don't rely on `super`** inside `relation_scope` — call `default_relation_scope(relation)` by name.
|
|
9
|
+
- **Fix the MODEL, not the policy.** If `associated_with` can't resolve, declare an association path (`belongs_to`, `has_one :through`) OR a custom `associated_with_<entity>` scope on the model. Never paper over it with a `where` in the policy.
|
|
10
|
+
- **Compound uniqueness scoped to the tenant FK** — `validates :code, uniqueness: {scope: :organization_id}`.
|
|
11
|
+
- **Multiple associations to the same entity class** require overriding `scoped_entity_association` on the controller.
|
|
12
|
+
|
|
13
|
+
## The three pieces
|
|
14
|
+
|
|
15
|
+
| Piece | Role | Where |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| **Portal** | Declares the entity class and resolution strategy | `scope_to_entity Organization, strategy: :path` in the engine |
|
|
18
|
+
| **Policy** | Applies the scope to every collection query | `default_relation_scope(relation)` (auto-called) |
|
|
19
|
+
| **Model** | Resolves the scope path | Direct `belongs_to`, `has_one :through`, or custom scope |
|
|
20
|
+
|
|
21
|
+
`default_relation_scope` is enforced — if you override `relation_scope` without calling it, `verify_default_relation_scope_applied!` raises at runtime.
|
|
22
|
+
|
|
23
|
+
## `associated_with` resolution
|
|
24
|
+
|
|
25
|
+
`Model.associated_with(entity)` resolves in this order:
|
|
26
|
+
|
|
27
|
+
1. **Custom scope** `associated_with_<entity_name>` (e.g. `associated_with_organization`) — highest priority, full SQL control.
|
|
28
|
+
2. **Direct `belongs_to` to the entity class** — `WHERE <entity>_id = ?`, most efficient.
|
|
29
|
+
3. **`has_one` / `has_one :through` to the entity class** — JOIN + WHERE, auto-detected via `reflect_on_all_associations`.
|
|
30
|
+
4. **Reverse `has_many` from the entity** — JOIN required, logs a warning (less efficient).
|
|
31
|
+
|
|
32
|
+
If none apply:
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
Could not resolve the association between 'Model' and 'Entity'
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Fix on the **model** — either declare an association path (`belongs_to`, `has_one :through`) OR define a custom `associated_with_<entity>` scope. Never work around this by overriding `relation_scope` in the policy.
|
|
39
|
+
|
|
40
|
+
## Three model shapes
|
|
41
|
+
|
|
42
|
+
The `associated_with` resolver handles three common shapes. Pick the lightest that fits.
|
|
43
|
+
|
|
44
|
+
### Shape 1: Direct child (`belongs_to` the entity)
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
class Organization < ResourceRecord
|
|
48
|
+
has_many :projects
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class Project < ResourceRecord
|
|
52
|
+
belongs_to :organization
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
Project.associated_with(org)
|
|
56
|
+
# => Project.where(organization: org) — simple WHERE, most efficient
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Auto-detected. Use this when the model naturally has a direct FK to the entity.
|
|
60
|
+
|
|
61
|
+
### Shape 2: Join table (membership-style)
|
|
62
|
+
|
|
63
|
+
A join table linking users to entities, where the entity is reachable via one of the `belongs_to`:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
class User < ResourceRecord
|
|
67
|
+
has_many :memberships
|
|
68
|
+
has_many :organizations, through: :memberships
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
class Organization < ResourceRecord
|
|
72
|
+
has_many :memberships
|
|
73
|
+
has_many :users, through: :memberships
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
class Membership < ResourceRecord
|
|
77
|
+
belongs_to :user
|
|
78
|
+
belongs_to :organization # ← auto-detection finds :organization via belongs_to
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
Membership.associated_with(org)
|
|
82
|
+
# => Membership.where(organization: org)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
If the join table is itself a parent and the scoped target is two hops away, add `has_one :through`:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
class ProjectMember < ResourceRecord
|
|
89
|
+
belongs_to :project
|
|
90
|
+
belongs_to :user
|
|
91
|
+
has_one :organization, through: :project # ← enables auto-scoping
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Now `ProjectMember.associated_with(org)` resolves via the `has_one :through`.
|
|
96
|
+
|
|
97
|
+
### Shape 3: Grandchild (multi-hop via `has_one :through`)
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
class Organization < ResourceRecord
|
|
101
|
+
has_many :projects
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
class Project < ResourceRecord
|
|
105
|
+
belongs_to :organization
|
|
106
|
+
has_many :tasks
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
class Task < ResourceRecord
|
|
110
|
+
belongs_to :project
|
|
111
|
+
has_one :organization, through: :project # ← critical
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Deeper
|
|
115
|
+
class Comment < ResourceRecord
|
|
116
|
+
belongs_to :task
|
|
117
|
+
has_one :project, through: :task
|
|
118
|
+
has_one :organization, through: :project # ← enables auto-scoping at 3 hops
|
|
119
|
+
end
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`Task.associated_with(org)` and `Comment.associated_with(org)` both auto-resolve.
|
|
123
|
+
|
|
124
|
+
::: tip Declaring `has_one :through` is the lightest fix
|
|
125
|
+
For grandchildren, the `has_one :through` on the model is all you need — `associated_with` finds it automatically. No policy override needed.
|
|
126
|
+
:::
|
|
127
|
+
|
|
128
|
+
### When to fall back to a custom scope
|
|
129
|
+
|
|
130
|
+
Use a custom `associated_with_<entity>` scope when:
|
|
131
|
+
|
|
132
|
+
- The path is polymorphic.
|
|
133
|
+
- The path needs conditional logic.
|
|
134
|
+
- You want explicit SQL for performance (e.g. avoid a multi-join chain).
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
class Comment < ResourceRecord
|
|
138
|
+
scope :associated_with_organization, ->(org) do
|
|
139
|
+
joins(task: :project).where(projects: {organization_id: org.id})
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Plutonium picks this up **before** trying association detection.
|
|
145
|
+
|
|
146
|
+
## `relation_scope` — safe override patterns
|
|
147
|
+
|
|
148
|
+
`default_relation_scope(relation)` does two things:
|
|
149
|
+
|
|
150
|
+
1. If a **parent** is present (nested resource), scopes via the parent association.
|
|
151
|
+
2. Otherwise, applies `relation.associated_with(entity_scope)`.
|
|
152
|
+
|
|
153
|
+
### Correct
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
# ✅ Best — don't override at all. The inherited scope already calls default_relation_scope.
|
|
157
|
+
|
|
158
|
+
# ✅ Extra filters on top
|
|
159
|
+
relation_scope do |relation|
|
|
160
|
+
default_relation_scope(relation).where(archived: false)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# ✅ Role-based
|
|
164
|
+
relation_scope do |relation|
|
|
165
|
+
relation = default_relation_scope(relation)
|
|
166
|
+
user.admin? ? relation : relation.where(author: user)
|
|
167
|
+
end
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Wrong
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
# ❌ Manually filtering by entity — bypasses default_relation_scope
|
|
174
|
+
relation_scope { |r| r.where(organization: current_scoped_entity) }
|
|
175
|
+
|
|
176
|
+
# ❌ Manual joins — same problem
|
|
177
|
+
relation_scope { |r| r.joins(:project).where(projects: {organization_id: current_scoped_entity.id}) }
|
|
178
|
+
|
|
179
|
+
# ❌ Missing default_relation_scope entirely — raises at runtime
|
|
180
|
+
relation_scope { |r| r.where(published: true) }
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
::: danger Don't use `super`
|
|
184
|
+
`super` inside `relation_scope` is unreliable — its semantics depend on how ActionPolicy's DSL registered the scope. Call `default_relation_scope(relation)` by name.
|
|
185
|
+
:::
|
|
186
|
+
|
|
187
|
+
### Intentionally skipping the scope
|
|
188
|
+
|
|
189
|
+
Rare, but possible:
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
relation_scope do |relation|
|
|
193
|
+
skip_default_relation_scope!
|
|
194
|
+
relation
|
|
195
|
+
end
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Before reaching for this, consider a separate, unscoped portal.
|
|
199
|
+
|
|
200
|
+
## Portal entity strategies
|
|
201
|
+
|
|
202
|
+
The portal declares how the current entity is resolved from the request.
|
|
203
|
+
|
|
204
|
+
### Path strategy (most common)
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
module CustomerPortal
|
|
208
|
+
class Engine < Rails::Engine
|
|
209
|
+
include Plutonium::Portal::Engine
|
|
210
|
+
|
|
211
|
+
config.after_initialize do
|
|
212
|
+
scope_to_entity Organization, strategy: :path
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Routes become `/organizations/:organization_id/posts`. The portal extracts `params[:organization_id]` and loads the entity automatically.
|
|
219
|
+
|
|
220
|
+
### Custom strategy (subdomain, session, etc.)
|
|
221
|
+
|
|
222
|
+
```ruby
|
|
223
|
+
module CustomerPortal::Concerns::Controller
|
|
224
|
+
extend ActiveSupport::Concern
|
|
225
|
+
include Plutonium::Portal::Controller
|
|
226
|
+
|
|
227
|
+
private
|
|
228
|
+
|
|
229
|
+
def current_organization
|
|
230
|
+
@current_organization ||= Organization.find_by!(subdomain: request.subdomain)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Engine
|
|
235
|
+
scope_to_entity Organization, strategy: :current_organization
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
The strategy symbol must match a method name on the controller concern.
|
|
239
|
+
|
|
240
|
+
### Custom param key
|
|
241
|
+
|
|
242
|
+
When the param name differs from the entity model name:
|
|
243
|
+
|
|
244
|
+
```ruby
|
|
245
|
+
scope_to_entity Organization, strategy: :path, param_key: :org_id
|
|
246
|
+
# → /orgs/:org_id/posts
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Accessing the scoped entity
|
|
250
|
+
|
|
251
|
+
```ruby
|
|
252
|
+
# Controller / views
|
|
253
|
+
current_scoped_entity # => current Organization
|
|
254
|
+
scoped_to_entity? # => true / false
|
|
255
|
+
|
|
256
|
+
# Policy
|
|
257
|
+
entity_scope # => current Organization
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
## Cross-tenant operations
|
|
261
|
+
|
|
262
|
+
### Super-admin portal — no scoping
|
|
263
|
+
|
|
264
|
+
Create a separate portal without `scope_to_entity`:
|
|
265
|
+
|
|
266
|
+
```ruby
|
|
267
|
+
module SuperAdminPortal
|
|
268
|
+
class Engine < Rails::Engine
|
|
269
|
+
include Plutonium::Portal::Engine
|
|
270
|
+
|
|
271
|
+
# No scope_to_entity — sees all tenants
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
This portal's policies see everything. Don't enable public signup here.
|
|
277
|
+
|
|
278
|
+
### Conditional scoping
|
|
279
|
+
|
|
280
|
+
```ruby
|
|
281
|
+
class PostPolicy < ResourcePolicy
|
|
282
|
+
relation_scope do |relation|
|
|
283
|
+
return default_relation_scope(relation).where(category: :public) if user.guest?
|
|
284
|
+
default_relation_scope(relation)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
## Multiple associations to the same entity class
|
|
290
|
+
|
|
291
|
+
Example: `Match belongs_to :home_team, :away_team` both pointing at `Team`. Plutonium raises:
|
|
292
|
+
|
|
293
|
+
```
|
|
294
|
+
Match has multiple associations to Competition::Team: home_team, away_team.
|
|
295
|
+
Plutonium cannot auto-detect which one to use for entity scoping.
|
|
296
|
+
Override `scoped_entity_association` in your controller to specify the association.
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Override on the controller:
|
|
300
|
+
|
|
301
|
+
```ruby
|
|
302
|
+
class MatchesController < ::ResourceController
|
|
303
|
+
private
|
|
304
|
+
def scoped_entity_association = :home_team
|
|
305
|
+
end
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
## `param_key` differs from association name
|
|
309
|
+
|
|
310
|
+
Plutonium matches by **class**, not param key:
|
|
311
|
+
|
|
312
|
+
```ruby
|
|
313
|
+
# Portal config
|
|
314
|
+
scope_to_entity Competition::Team, param_key: :team
|
|
315
|
+
|
|
316
|
+
# Model — association name differs from param_key, but Plutonium finds by class
|
|
317
|
+
class Match < ApplicationRecord
|
|
318
|
+
belongs_to :competition_team # ← Plutonium auto-detects this
|
|
319
|
+
end
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
## How the pieces fit together
|
|
323
|
+
|
|
324
|
+
1. An admin opens `/organizations/42/projects`.
|
|
325
|
+
2. Portal's `scope_to_entity Organization, strategy: :path` extracts `42`, loads the `Organization`, sets `current_scoped_entity`.
|
|
326
|
+
3. The controller calls the policy. The policy's inherited `relation_scope` calls `default_relation_scope(relation)`.
|
|
327
|
+
4. `default_relation_scope` has no parent (top-level nested-from-portal), so it calls `relation.associated_with(current_scoped_entity)`.
|
|
328
|
+
5. `Project.associated_with(org)` resolves via the direct `belongs_to :organization` → `Project.where(organization: org)`.
|
|
329
|
+
6. Only that organization's projects render. Records from other orgs are invisible.
|
|
330
|
+
|
|
331
|
+
Any model that can't be reached from the entity via these rules MUST declare a `has_one :through` or a custom scope.
|
|
332
|
+
|
|
333
|
+
## Compound uniqueness
|
|
334
|
+
|
|
335
|
+
Always scope tenant-affecting uniqueness constraints:
|
|
336
|
+
|
|
337
|
+
```ruby
|
|
338
|
+
class Property < ResourceRecord
|
|
339
|
+
belongs_to :organization
|
|
340
|
+
validates :code, uniqueness: {scope: :organization_id} # ← critical
|
|
341
|
+
end
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
Without the scope, uniqueness leaks across tenants — Org A and Org B could collide on the same code.
|
|
345
|
+
|
|
346
|
+
## Gotchas
|
|
347
|
+
|
|
348
|
+
- **Policy tries to filter by entity directly.** Wrong — bypasses `default_relation_scope`. Add the association path to the model instead.
|
|
349
|
+
- **`super` inside `relation_scope`.** Unreliable. Use `default_relation_scope(relation)` explicitly.
|
|
350
|
+
- **Multiple associations to the same entity class.** Override `scoped_entity_association`.
|
|
351
|
+
- **`param_key` differs from association name.** Fine — Plutonium finds the association by class.
|
|
352
|
+
- **Forgetting compound uniqueness.** A unique constraint on `:code` alone leaks across tenants.
|
|
353
|
+
- **"Temporary" `where` bypass for debugging.** Use `skip_default_relation_scope!` explicitly — never leave a `where` bypass in code.
|
|
354
|
+
|
|
355
|
+
## Related
|
|
356
|
+
|
|
357
|
+
- [Nested resources](./nested-resources) — parent scoping takes precedence over entity scoping
|
|
358
|
+
- [Invites](./invites) — membership-based onboarding
|
|
359
|
+
- [Resource › Model](/reference/resource/model) — `associated_with`, model conventions
|
|
360
|
+
- [Behavior › Policy](/reference/behavior/policies) — `relation_scope` syntax
|
|
361
|
+
- [App › Portals](/reference/app/portals) — `scope_to_entity` engine config
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Tenancy Reference
|
|
2
|
+
|
|
3
|
+
Three closely-coupled concerns:
|
|
4
|
+
|
|
5
|
+
1. **[Entity scoping](./entity-scoping)** — every record belongs to a tenant; queries filter automatically.
|
|
6
|
+
2. **[Nested resources](./nested-resources)** — parent/child URLs; parent scoping takes precedence over entity scoping.
|
|
7
|
+
3. **[Invites](./invites)** — onboarding users into a tenant's membership.
|
|
8
|
+
|
|
9
|
+
## How entity scoping fits together
|
|
10
|
+
|
|
11
|
+
Three cooperating pieces:
|
|
12
|
+
|
|
13
|
+
| Piece | Role |
|
|
14
|
+
|---|---|
|
|
15
|
+
| **Portal** | Declares the entity class and how to resolve it from the request (`scope_to_entity Organization, strategy: :path`). |
|
|
16
|
+
| **Policy** | `default_relation_scope(relation)` calls `relation.associated_with(entity_scope)` on every collection query. Enforced via `verify_default_relation_scope_applied!`. |
|
|
17
|
+
| **Model** | `associated_with(entity)` resolves via custom scope, direct association, or `has_one :through`. |
|
|
18
|
+
|
|
19
|
+
Configure the portal once. The policy and model conventions then carry tenancy automatically.
|
|
20
|
+
|
|
21
|
+
## 🚨 Critical (applies to all three sub-pages)
|
|
22
|
+
|
|
23
|
+
- **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins triggers `verify_default_relation_scope_applied!`. Always call `default_relation_scope(relation)` explicitly — not `super`.
|
|
24
|
+
- **Always declare an association path from the model to the entity.** Direct `belongs_to`, `has_one :through`, or a custom `associated_with_<entity>` scope. If `associated_with` can't resolve, fix the **model**, not the policy.
|
|
25
|
+
- **Parent scoping beats entity scoping.** When a parent is present (nested resource), `default_relation_scope` scopes via the parent, not via `entity_scope`. Don't double-scope.
|
|
26
|
+
- **One level of nesting only.** Grandparent → parent → child nested routes are NOT supported. Use top-level routes for deeper relationships.
|
|
27
|
+
- **Compound uniqueness scoped to the tenant FK.** `validates :code, uniqueness: {scope: :organization_id}` — without this, uniqueness leaks across tenants.
|
|
28
|
+
- **Invite email must match the accepting user's email.** Security feature — don't disable `enforce_email?` lightly.
|
|
29
|
+
|
|
30
|
+
## Related
|
|
31
|
+
|
|
32
|
+
- [Behavior › Policy](/reference/behavior/policies) — `relation_scope` syntax
|
|
33
|
+
- [Resource › Model](/reference/resource/model) — model layer (associations, `has_cents`, SGID)
|
|
34
|
+
- [App › Portals](/reference/app/portals) — `scope_to_entity` engine config
|
|
35
|
+
- [Guides › Multi-tenancy](/guides/multi-tenancy) — task-oriented walkthrough
|
|
36
|
+
- [Guides › User invites](/guides/user-invites) — invitation setup recipe
|