plutonium 0.65.0 → 0.66.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 +43 -43
- data/.claude/skills/plutonium-app/SKILL.md +101 -59
- data/.claude/skills/plutonium-async-interactions/SKILL.md +19 -19
- data/.claude/skills/plutonium-auth/SKILL.md +119 -53
- data/.claude/skills/plutonium-behavior/SKILL.md +110 -78
- data/.claude/skills/plutonium-dashboard/SKILL.md +11 -4
- data/.claude/skills/plutonium-kanban/SKILL.md +81 -49
- data/.claude/skills/plutonium-resource/SKILL.md +144 -133
- data/.claude/skills/plutonium-tenancy/SKILL.md +104 -55
- data/.claude/skills/plutonium-testing/SKILL.md +130 -33
- data/.claude/skills/plutonium-ui/SKILL.md +151 -96
- data/.claude/skills/plutonium-wizard/SKILL.md +93 -82
- data/CHANGELOG.md +21 -0
- data/README.md +9 -9
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +1 -1
- data/docs/.vitepress/sync-skills.mjs +6 -3
- data/docs/blog/introducing-plutonium-dashboards.md +4 -5
- data/docs/blog/introducing-plutonium-i18n.md +4 -5
- data/docs/getting-started/installation.md +5 -5
- data/docs/getting-started/tutorial/02-first-resource.md +3 -3
- data/docs/getting-started/tutorial/03-authentication.md +7 -7
- data/docs/getting-started/tutorial/04-authorization.md +21 -4
- data/docs/getting-started/tutorial/05-custom-actions.md +2 -2
- data/docs/getting-started/tutorial/06-nested-resources.md +5 -2
- data/docs/getting-started/tutorial/07-author-portal.md +2 -2
- data/docs/getting-started/tutorial/08-customizing-ui.md +45 -30
- data/docs/getting-started/tutorial/index.md +1 -1
- data/docs/guides/adding-resources.md +10 -7
- data/docs/guides/authentication.md +25 -25
- data/docs/guides/authorization.md +24 -24
- data/docs/guides/creating-packages.md +17 -17
- data/docs/guides/custom-actions.md +32 -32
- data/docs/guides/customizing-ui.md +29 -26
- data/docs/guides/dashboards.md +1 -1
- data/docs/guides/index.md +3 -3
- data/docs/guides/kanban.md +55 -55
- data/docs/guides/multi-tenancy.md +35 -22
- data/docs/guides/nested-resources.md +21 -21
- data/docs/guides/performance.md +3 -3
- data/docs/guides/search-filtering.md +13 -13
- data/docs/guides/testing.md +16 -12
- data/docs/guides/theming.md +32 -17
- data/docs/guides/troubleshooting.md +2 -2
- data/docs/guides/user-invites.md +17 -17
- data/docs/guides/user-profile.md +51 -24
- data/docs/guides/wizards.md +55 -55
- data/docs/reference/app/generators.md +22 -22
- data/docs/reference/app/index.md +15 -18
- data/docs/reference/app/packages.md +8 -8
- data/docs/reference/app/portals.md +75 -29
- data/docs/reference/auth/accounts.md +15 -15
- data/docs/reference/auth/index.md +12 -12
- data/docs/reference/auth/profile.md +67 -29
- data/docs/reference/behavior/async-interactions.md +24 -24
- data/docs/reference/behavior/controllers.md +28 -28
- data/docs/reference/behavior/index.md +5 -5
- data/docs/reference/behavior/interactions.md +44 -44
- data/docs/reference/behavior/policies.md +48 -28
- data/docs/reference/configuration.md +6 -6
- data/docs/reference/dashboard/dsl.md +2 -2
- data/docs/reference/dashboard/index.md +1 -1
- data/docs/reference/generators/lite.md +7 -7
- data/docs/reference/i18n.md +23 -0
- data/docs/reference/index.md +1 -1
- data/docs/reference/kanban/authorization.md +9 -9
- data/docs/reference/kanban/dsl.md +32 -32
- data/docs/reference/kanban/index.md +1 -1
- data/docs/reference/kanban/positioning.md +17 -15
- data/docs/reference/resource/actions.md +51 -51
- data/docs/reference/resource/definition.md +73 -73
- data/docs/reference/resource/export.md +6 -6
- data/docs/reference/resource/index.md +16 -16
- data/docs/reference/resource/model.md +24 -24
- data/docs/reference/resource/positioning.md +78 -76
- data/docs/reference/resource/query.md +13 -13
- data/docs/reference/tenancy/entity-scoping.md +65 -35
- data/docs/reference/tenancy/index.md +11 -11
- data/docs/reference/tenancy/invites.md +20 -20
- data/docs/reference/tenancy/nested-resources.md +13 -13
- data/docs/reference/testing/index.md +116 -22
- data/docs/reference/ui/assets.md +57 -25
- data/docs/reference/ui/components.md +20 -20
- data/docs/reference/ui/displays.md +14 -14
- data/docs/reference/ui/forms.md +35 -35
- data/docs/reference/ui/index.md +17 -15
- data/docs/reference/ui/layouts.md +21 -21
- data/docs/reference/ui/pages.md +22 -22
- data/docs/reference/ui/tables.md +7 -7
- data/docs/reference/wizard/anchoring-resume.md +33 -32
- data/docs/reference/wizard/dsl.md +44 -44
- data/docs/reference/wizard/index.md +6 -6
- data/docs/reference/wizard/one-time.md +18 -18
- data/docs/reference/wizard/registration-launch.md +32 -32
- data/docs/reference/wizard/storage-config.md +23 -23
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/profile/conn_generator.rb +6 -0
- data/lib/plutonium/resource/record/associated_with.rb +23 -2
- data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +7 -1
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/src/css/components.css +10 -10
- metadata +2 -2
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# Entity Scoping
|
|
2
2
|
|
|
3
|
-
Multi-tenant data isolation. Built on three cooperating pieces
|
|
3
|
+
Multi-tenant data isolation. Built on three cooperating pieces (portal, policy, model) that together ensure queries never leak across tenants.
|
|
4
4
|
|
|
5
5
|
## 🚨 Critical
|
|
6
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
|
|
7
|
+
- **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins to the entity triggers `verify_default_relation_scope_applied!`; make sure the chain ends up calling `default_relation_scope(relation)` somewhere (explicitly, or via `super` to a parent that calls it, as `Plutonium::Resource::Policy` does).
|
|
8
8
|
- **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.
|
|
9
|
-
- **Compound uniqueness scoped to the tenant FK
|
|
10
|
-
- **Multiple associations to the same entity class** require overriding `scoped_entity_association` on the controller.
|
|
9
|
+
- **Compound uniqueness scoped to the tenant FK**: `validates :code, uniqueness: {scope: :organization_id}`.
|
|
10
|
+
- **Multiple associations to the same entity class** require overriding `scoped_entity_association` on the controller, plus a custom `associated_with_<entity>` scope on the model.
|
|
11
|
+
- **The entity field can stay in `permitted_attributes_for_*`.** Entity-scoped portals strip it from the form and params and set it from the current entity.
|
|
11
12
|
|
|
12
13
|
## The three pieces
|
|
13
14
|
|
|
@@ -17,16 +18,16 @@ Multi-tenant data isolation. Built on three cooperating pieces — portal, polic
|
|
|
17
18
|
| **Policy** | Applies the scope to every collection query | `default_relation_scope(relation)` (auto-called) |
|
|
18
19
|
| **Model** | Resolves the scope path | Direct `belongs_to`, `has_one :through`, or custom scope |
|
|
19
20
|
|
|
20
|
-
`default_relation_scope` is enforced
|
|
21
|
+
`default_relation_scope` is enforced: if you override `relation_scope` without calling it, `verify_default_relation_scope_applied!` raises at runtime.
|
|
21
22
|
|
|
22
23
|
## `associated_with` resolution
|
|
23
24
|
|
|
24
25
|
`Model.associated_with(entity)` resolves in this order:
|
|
25
26
|
|
|
26
|
-
1. **Custom scope** `associated_with_<entity_name>` (e.g. `associated_with_organization`)
|
|
27
|
-
2. **Direct `belongs_to` to the entity class
|
|
28
|
-
3. **`has_one` / `has_one :through` to the entity class
|
|
29
|
-
4. **Reverse `has_many` from the entity
|
|
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).
|
|
30
31
|
|
|
31
32
|
If none apply:
|
|
32
33
|
|
|
@@ -34,7 +35,7 @@ If none apply:
|
|
|
34
35
|
Could not resolve the association between 'Model' and 'Entity'
|
|
35
36
|
```
|
|
36
37
|
|
|
37
|
-
Fix on the **model
|
|
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.
|
|
38
39
|
|
|
39
40
|
## Three model shapes
|
|
40
41
|
|
|
@@ -52,7 +53,7 @@ class Project < ResourceRecord
|
|
|
52
53
|
end
|
|
53
54
|
|
|
54
55
|
Project.associated_with(org)
|
|
55
|
-
# => Project.where(organization: org)
|
|
56
|
+
# => Project.where(organization: org) # simple WHERE, most efficient
|
|
56
57
|
```
|
|
57
58
|
|
|
58
59
|
Auto-detected. Use this when the model naturally has a direct FK to the entity.
|
|
@@ -121,7 +122,7 @@ end
|
|
|
121
122
|
`Task.associated_with(org)` and `Comment.associated_with(org)` both auto-resolve.
|
|
122
123
|
|
|
123
124
|
::: tip Declaring `has_one :through` is the lightest fix
|
|
124
|
-
For grandchildren, the `has_one :through` on the model is all you need
|
|
125
|
+
For grandchildren, the `has_one :through` on the model is all you need; `associated_with` finds it automatically. No policy override needed.
|
|
125
126
|
:::
|
|
126
127
|
|
|
127
128
|
### When to fall back to a custom scope
|
|
@@ -142,7 +143,7 @@ end
|
|
|
142
143
|
|
|
143
144
|
Plutonium picks this up **before** trying association detection.
|
|
144
145
|
|
|
145
|
-
## `relation_scope
|
|
146
|
+
## `relation_scope`: safe override patterns
|
|
146
147
|
|
|
147
148
|
`default_relation_scope(relation)` does two things:
|
|
148
149
|
|
|
@@ -152,7 +153,7 @@ Plutonium picks this up **before** trying association detection.
|
|
|
152
153
|
### Correct
|
|
153
154
|
|
|
154
155
|
```ruby
|
|
155
|
-
# ✅ Best
|
|
156
|
+
# ✅ Best: don't override at all. The inherited scope already calls default_relation_scope.
|
|
156
157
|
|
|
157
158
|
# ✅ Extra filters on top
|
|
158
159
|
relation_scope do |relation|
|
|
@@ -169,18 +170,18 @@ end
|
|
|
169
170
|
### Wrong
|
|
170
171
|
|
|
171
172
|
```ruby
|
|
172
|
-
# ❌ Manually filtering by entity
|
|
173
|
+
# ❌ Manually filtering by entity: bypasses default_relation_scope
|
|
173
174
|
relation_scope { |r| r.where(organization: current_scoped_entity) }
|
|
174
175
|
|
|
175
|
-
# ❌ Manual joins
|
|
176
|
+
# ❌ Manual joins: same problem
|
|
176
177
|
relation_scope { |r| r.joins(:project).where(projects: {organization_id: current_scoped_entity.id}) }
|
|
177
178
|
|
|
178
|
-
# ❌ Missing default_relation_scope entirely
|
|
179
|
+
# ❌ Missing default_relation_scope entirely: raises at runtime
|
|
179
180
|
relation_scope { |r| r.where(published: true) }
|
|
180
181
|
```
|
|
181
182
|
|
|
182
183
|
::: tip `super` works too
|
|
183
|
-
`Plutonium::Resource::Policy`'s `relation_scope` block calls `default_relation_scope(relation)`, so `super(relation)` from a subclass picks it up and the runtime check passes. Use whichever reads more clearly
|
|
184
|
+
`Plutonium::Resource::Policy`'s `relation_scope` block calls `default_relation_scope(relation)`, so `super(relation)` from a subclass picks it up and the runtime check passes. Use whichever reads more clearly: `super(relation).where(archived: false)` and `default_relation_scope(relation).where(archived: false)` are equivalent when extending the framework base. Call `default_relation_scope` explicitly when you're not chaining via `super` (e.g. replacing the scope entirely).
|
|
184
185
|
:::
|
|
185
186
|
|
|
186
187
|
### Intentionally skipping the scope
|
|
@@ -214,7 +215,7 @@ module CustomerPortal
|
|
|
214
215
|
end
|
|
215
216
|
```
|
|
216
217
|
|
|
217
|
-
Routes become `/<mount>/:organization_scoped/posts` (resolving to `/<mount>/42/posts` at request time
|
|
218
|
+
Routes become `/<mount>/:organization_scoped/posts` (resolving to `/<mount>/42/posts` at request time; the entity id is the first path segment after the mount). The portal extracts `params[:organization_scoped]` and loads the entity automatically.
|
|
218
219
|
|
|
219
220
|
### Custom strategy (subdomain, session, etc.)
|
|
220
221
|
|
|
@@ -238,7 +239,7 @@ The strategy symbol must match a method name on the controller concern.
|
|
|
238
239
|
|
|
239
240
|
### Custom param key
|
|
240
241
|
|
|
241
|
-
The default `param_key` derives from the entity class
|
|
242
|
+
The default `param_key` derives from the entity class, `<singular_route_key>_scoped` (e.g. `:organization_scoped`), to avoid colliding with a `belongs_to :organization` on child models when reading `params[:organization]`. The URL itself just uses the entity id as the first segment after the mount:
|
|
242
243
|
|
|
243
244
|
```
|
|
244
245
|
mount CustomerPortal::Engine, at: "/customer"
|
|
@@ -266,7 +267,7 @@ entity_scope # => current Organization
|
|
|
266
267
|
|
|
267
268
|
## Cross-tenant operations
|
|
268
269
|
|
|
269
|
-
### Super-admin portal
|
|
270
|
+
### Super-admin portal: no scoping
|
|
270
271
|
|
|
271
272
|
Create a separate portal without `scope_to_entity`:
|
|
272
273
|
|
|
@@ -275,7 +276,7 @@ module SuperAdminPortal
|
|
|
275
276
|
class Engine < Rails::Engine
|
|
276
277
|
include Plutonium::Portal::Engine
|
|
277
278
|
|
|
278
|
-
# No scope_to_entity
|
|
279
|
+
# No scope_to_entity: sees all tenants
|
|
279
280
|
end
|
|
280
281
|
end
|
|
281
282
|
```
|
|
@@ -293,9 +294,25 @@ class PostPolicy < ResourcePolicy
|
|
|
293
294
|
end
|
|
294
295
|
```
|
|
295
296
|
|
|
297
|
+
## The entity field in permitted attributes
|
|
298
|
+
|
|
299
|
+
Listing the entity association in the policy is fine, and usually what you want:
|
|
300
|
+
|
|
301
|
+
```ruby
|
|
302
|
+
def permitted_attributes_for_create
|
|
303
|
+
[:organization, :name]
|
|
304
|
+
end
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
In an entity-scoped portal the controller drops the entity field from both the rendered form/display and the accepted params, then sets it from `current_scoped_entity` on create and update. The same policy then works unchanged in an unscoped portal (e.g. admin), where `organization` stays a normal, selectable input. So you don't need a portal-specific policy just to remove the entity field, and a forged `organization_id` in the request is overwritten.
|
|
308
|
+
|
|
309
|
+
What gets removed is the portal's param key plus the scoping association and its `_id` (`organization_scoped`, `organization_scoped_id`, `organization`, `organization_id`). Other associations to the same class are untouched. To show the entity field anyway, override `present_scoped_entity?` on the controller (and `submit_scoped_entity?` to let users change it; it defaults to `present_scoped_entity?`).
|
|
310
|
+
|
|
296
311
|
## Multiple associations to the same entity class
|
|
297
312
|
|
|
298
|
-
Example: `Match belongs_to :home_team, :away_team` both pointing at `Team`.
|
|
313
|
+
Example: `Match belongs_to :home_team, :away_team` both pointing at `Team`. Two pieces of code pick "the" entity association, and both refuse to guess. Configure each one.
|
|
314
|
+
|
|
315
|
+
The **controller** raises on any scoped page that renders fields:
|
|
299
316
|
|
|
300
317
|
```
|
|
301
318
|
Match has multiple associations to Competition::Team: home_team, away_team.
|
|
@@ -303,7 +320,7 @@ Plutonium cannot auto-detect which one to use for entity scoping.
|
|
|
303
320
|
Override `scoped_entity_association` in your controller to specify the association.
|
|
304
321
|
```
|
|
305
322
|
|
|
306
|
-
Override on the controller:
|
|
323
|
+
Override on the portal controller:
|
|
307
324
|
|
|
308
325
|
```ruby
|
|
309
326
|
class MatchesController < ::ResourceController
|
|
@@ -312,6 +329,19 @@ class MatchesController < ::ResourceController
|
|
|
312
329
|
end
|
|
313
330
|
```
|
|
314
331
|
|
|
332
|
+
The **model** (`associated_with`) raises `Plutonium::Resource::Record::AssociatedWith::AmbiguousAssociationError` for the same reason, so define the scope it asks for:
|
|
333
|
+
|
|
334
|
+
```ruby
|
|
335
|
+
class Match < ResourceRecord
|
|
336
|
+
belongs_to :home_team, class_name: "Competition::Team"
|
|
337
|
+
belongs_to :away_team, class_name: "Competition::Team"
|
|
338
|
+
|
|
339
|
+
scope :associated_with_competition_team, ->(team) { where(home_team: team) }
|
|
340
|
+
end
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
With both in place, the policy can permit `[:home_team, :away_team, ...]`: in the scoped portal `home_team` is stripped and forced to the current entity, while `away_team` stays a select the user fills in.
|
|
344
|
+
|
|
315
345
|
## `param_key` differs from association name
|
|
316
346
|
|
|
317
347
|
Plutonium matches by **class**, not param key:
|
|
@@ -320,7 +350,7 @@ Plutonium matches by **class**, not param key:
|
|
|
320
350
|
# Portal config
|
|
321
351
|
scope_to_entity Competition::Team, param_key: :team
|
|
322
352
|
|
|
323
|
-
# Model
|
|
353
|
+
# Model: association name differs from param_key, but Plutonium finds by class
|
|
324
354
|
class Match < ApplicationRecord
|
|
325
355
|
belongs_to :competition_team # ← Plutonium auto-detects this
|
|
326
356
|
end
|
|
@@ -348,20 +378,20 @@ class Property < ResourceRecord
|
|
|
348
378
|
end
|
|
349
379
|
```
|
|
350
380
|
|
|
351
|
-
Without the scope, uniqueness leaks across tenants
|
|
381
|
+
Without the scope, uniqueness leaks across tenants: Org A and Org B could collide on the same code.
|
|
352
382
|
|
|
353
383
|
## Gotchas
|
|
354
384
|
|
|
355
|
-
- **Policy tries to filter by entity directly.** Wrong
|
|
356
|
-
- **Multiple associations to the same entity class.** Override `scoped_entity_association
|
|
357
|
-
- **`param_key` differs from association name.** Fine
|
|
385
|
+
- **Policy tries to filter by entity directly.** Wrong, it bypasses `default_relation_scope`. Add the association path to the model instead.
|
|
386
|
+
- **Multiple associations to the same entity class.** Override `scoped_entity_association` on the controller and add an `associated_with_<entity>` scope on the model.
|
|
387
|
+
- **`param_key` differs from association name.** Fine: Plutonium finds the association by class.
|
|
358
388
|
- **Forgetting compound uniqueness.** A unique constraint on `:code` alone leaks across tenants.
|
|
359
|
-
- **"Temporary" `where` bypass for debugging.** Use `skip_default_relation_scope!` explicitly
|
|
389
|
+
- **"Temporary" `where` bypass for debugging.** Use `skip_default_relation_scope!` explicitly; never leave a `where` bypass in code.
|
|
360
390
|
|
|
361
391
|
## Related
|
|
362
392
|
|
|
363
|
-
- [Nested resources](./nested-resources)
|
|
364
|
-
- [Invites](./invites)
|
|
365
|
-
- [Resource › Model](/reference/resource/model)
|
|
366
|
-
- [Behavior › Policy](/reference/behavior/policies)
|
|
367
|
-
- [App › Portals](/reference/app/portals)
|
|
393
|
+
- [Nested resources](./nested-resources): parent scoping takes precedence over entity scoping
|
|
394
|
+
- [Invites](./invites): membership-based onboarding
|
|
395
|
+
- [Resource › Model](/reference/resource/model): `associated_with`, model conventions
|
|
396
|
+
- [Behavior › Policy](/reference/behavior/policies): `relation_scope` syntax
|
|
397
|
+
- [App › Portals](/reference/app/portals): `scope_to_entity` engine config
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Three closely-coupled concerns:
|
|
4
4
|
|
|
5
|
-
1. **[Entity scoping](./entity-scoping)
|
|
6
|
-
2. **[Nested resources](./nested-resources)
|
|
7
|
-
3. **[Invites](./invites)
|
|
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
8
|
|
|
9
9
|
## How entity scoping fits together
|
|
10
10
|
|
|
@@ -20,17 +20,17 @@ Configure the portal once. The policy and model conventions then carry tenancy a
|
|
|
20
20
|
|
|
21
21
|
## 🚨 Critical (applies to all three sub-pages)
|
|
22
22
|
|
|
23
|
-
- **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins triggers `verify_default_relation_scope_applied!`. Make sure `default_relation_scope(relation)` is called somewhere in the chain
|
|
23
|
+
- **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins triggers `verify_default_relation_scope_applied!`. Make sure `default_relation_scope(relation)` is called somewhere in the chain, explicitly here or via `super` to a parent policy (e.g., a package base) that calls it.
|
|
24
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
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
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}
|
|
28
|
-
- **Invite email must match the accepting user's email.** Security feature
|
|
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
29
|
|
|
30
30
|
## Related
|
|
31
31
|
|
|
32
|
-
- [Behavior › Policy](/reference/behavior/policies)
|
|
33
|
-
- [Resource › Model](/reference/resource/model)
|
|
34
|
-
- [App › Portals](/reference/app/portals)
|
|
35
|
-
- [Guides › Multi-tenancy](/guides/multi-tenancy)
|
|
36
|
-
- [Guides › User invites](/guides/user-invites)
|
|
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
|
|
@@ -4,10 +4,10 @@ Token-based email invitations for multi-tenant onboarding. Integrates with Rodau
|
|
|
4
4
|
|
|
5
5
|
## 🚨 Critical
|
|
6
6
|
|
|
7
|
-
- **Invite email must match the accepting user's email.** Security feature
|
|
8
|
-
- **Entity scoping applies to invites
|
|
7
|
+
- **Invite email must match the accepting user's email.** Security feature; don't disable `enforce_email?` lightly.
|
|
8
|
+
- **Entity scoping applies to invites.** Invites are automatically filtered to the current entity (their model has `belongs_to :entity`).
|
|
9
9
|
- **Invitables must implement `on_invite_accepted`.** Without it, the invitable never learns about the new user.
|
|
10
|
-
- **A single app can have multiple invite flows
|
|
10
|
+
- **A single app can have multiple invite flows.** Run `pu:invites:install` once per flow with different `--entity-model` / `--user-model` / `--invite-model`.
|
|
11
11
|
|
|
12
12
|
## Prerequisites
|
|
13
13
|
|
|
@@ -17,7 +17,7 @@ Before installing invites, you need:
|
|
|
17
17
|
2. An entity model (Organization, Company, Team, …)
|
|
18
18
|
3. A membership model linking users to entities
|
|
19
19
|
|
|
20
|
-
The fastest path is `pu:saas:setup
|
|
20
|
+
The fastest path is `pu:saas:setup`, which creates all three plus the SaaS portal, profile, welcome flow, and invites in one shot:
|
|
21
21
|
|
|
22
22
|
```bash
|
|
23
23
|
rails g pu:saas:setup --user Customer --entity Organization
|
|
@@ -41,7 +41,7 @@ rails generate pu:invites:install
|
|
|
41
41
|
| `--enforce-domain` | `false` | Require invited email domain to match entity domain |
|
|
42
42
|
|
|
43
43
|
::: info Roles come from the membership model
|
|
44
|
-
The role list is read from the membership model's `enum :role
|
|
44
|
+
The role list is read from the membership model's `enum :role`; there is no `--roles=` flag on `pu:invites:install`. Set roles when generating the membership model (`pu:saas:membership --roles=...`) or edit its enum directly. **Index 0 is the most privileged** (typically `owner`, which the invite UI excludes from selectable choices); new invitees default to the second role.
|
|
45
45
|
:::
|
|
46
46
|
|
|
47
47
|
Example with custom models:
|
|
@@ -166,7 +166,7 @@ configure do
|
|
|
166
166
|
end
|
|
167
167
|
```
|
|
168
168
|
|
|
169
|
-
## Invitables
|
|
169
|
+
## Invitables: app models notified on accept
|
|
170
170
|
|
|
171
171
|
An "invitable" is an app model that triggers invitations and gets notified when one is accepted. Examples: `Tenant`, `TeamMember`, `ProjectCollaborator`.
|
|
172
172
|
|
|
@@ -200,14 +200,14 @@ end
|
|
|
200
200
|
```
|
|
201
201
|
|
|
202
202
|
::: warning Without `on_invite_accepted`
|
|
203
|
-
The invitable never learns about the new user
|
|
203
|
+
The invitable never learns about the new user; the invite is consumed but your app doesn't update its state.
|
|
204
204
|
:::
|
|
205
205
|
|
|
206
206
|
## Multiple invite flows
|
|
207
207
|
|
|
208
208
|
A single app can have several independent invite flows side-by-side (e.g. one for inviting customers to organizations, another for inviting funders to projects). Run `pu:invites:install` once per flow.
|
|
209
209
|
|
|
210
|
-
**Default name derivation:** when `--invite-model` is omitted, the class is `<EntityModel><UserModel>Invite`. So with the defaults (`--entity-model=Organization --user-model=User`) the generated class is `Invites::OrganizationUserInvite
|
|
210
|
+
**Default name derivation:** when `--invite-model` is omitted, the class is `<EntityModel><UserModel>Invite`. So with the defaults (`--entity-model=Organization --user-model=User`) the generated class is `Invites::OrganizationUserInvite`; there is no literal `UserInvite` default. Single-flow apps don't need `--invite-model`.
|
|
211
211
|
|
|
212
212
|
```bash
|
|
213
213
|
rails g pu:invites:install \
|
|
@@ -288,7 +288,7 @@ Override views in your package:
|
|
|
288
288
|
|
|
289
289
|
### Per-invitable templates
|
|
290
290
|
|
|
291
|
-
When you generate an invitable with `--email-templates`, you get per-invitable mailer views
|
|
291
|
+
When you generate an invitable with `--email-templates`, you get per-invitable mailer views, useful for differentiating "Join as a team member" from "Join as a project collaborator".
|
|
292
292
|
|
|
293
293
|
### Custom validation
|
|
294
294
|
|
|
@@ -319,7 +319,7 @@ Requires the invited email's domain to match the entity's domain.
|
|
|
319
319
|
|
|
320
320
|
### Custom roles
|
|
321
321
|
|
|
322
|
-
Roles are defined on the membership model, not on the invites generator. Set them at membership generation time (ordering matters
|
|
322
|
+
Roles are defined on the membership model, not on the invites generator. Set them at membership generation time (ordering matters: **index 0 is the most privileged**, typically `owner`):
|
|
323
323
|
|
|
324
324
|
```bash
|
|
325
325
|
rails g pu:saas:membership --user Customer --entity Organization --roles=admin,editor,viewer
|
|
@@ -366,11 +366,11 @@ entity.user_invites.pending
|
|
|
366
366
|
|
|
367
367
|
### Token security
|
|
368
368
|
|
|
369
|
-
Tokens use `SecureRandom.urlsafe_base64(32)
|
|
369
|
+
Tokens use `SecureRandom.urlsafe_base64(32)`: 256 bits, URL-safe. Stored hashed in the DB; raw token shown only at creation (in the email).
|
|
370
370
|
|
|
371
371
|
### Email validation
|
|
372
372
|
|
|
373
|
-
`enforce_email?` is `true` by default. The accepting user's email must match the invited email
|
|
373
|
+
`enforce_email?` is `true` by default. The accepting user's email must match the invited email, which prevents account hijacking via invite forwarding.
|
|
374
374
|
|
|
375
375
|
To allow any email (NOT recommended):
|
|
376
376
|
|
|
@@ -387,14 +387,14 @@ Use Rack::Attack or similar to throttle:
|
|
|
387
387
|
|
|
388
388
|
## Common issues
|
|
389
389
|
|
|
390
|
-
- **"Invitation not found or expired"
|
|
391
|
-
- **Email mismatch error
|
|
392
|
-
- **Rodauth redirect after login doesn't go to `/welcome
|
|
393
|
-
- **`on_invite_accepted` not called
|
|
390
|
+
- **"Invitation not found or expired"**: token expired (default 1 week), invite cancelled, or no longer in `pending` state.
|
|
391
|
+
- **Email mismatch error**: the accepting user's email doesn't match the invited email. `enforce_email?` is enforcing the match (this is intentional security).
|
|
392
|
+
- **Rodauth redirect after login doesn't go to `/welcome`**: check the `login_redirect "/welcome"` line in the rodauth plugin's `configure` block.
|
|
393
|
+
- **`on_invite_accepted` not called**: ensure the invitable model `include Plutonium::Invites::Concerns::Invitable` and defines `on_invite_accepted`.
|
|
394
394
|
|
|
395
395
|
## Related
|
|
396
396
|
|
|
397
|
-
- [Entity scoping](./entity-scoping)
|
|
398
|
-
- [Auth](/reference/auth/)
|
|
399
|
-
- [Behavior › Interactions](/reference/behavior/interactions)
|
|
400
|
-
- [Guides › User invites](/guides/user-invites)
|
|
397
|
+
- [Entity scoping](./entity-scoping): how invites are filtered to the current entity
|
|
398
|
+
- [Auth](/reference/auth/): Rodauth account configuration
|
|
399
|
+
- [Behavior › Interactions](/reference/behavior/interactions): `cancel_invite_interaction`, `resend_invite_interaction`
|
|
400
|
+
- [Guides › User invites](/guides/user-invites): task-oriented walkthrough
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Nested Resources
|
|
2
2
|
|
|
3
|
-
Plutonium auto-generates nested routes from `has_many` and `has_one` associations on a registered parent. No manual route wiring
|
|
3
|
+
Plutonium auto-generates nested routes from `has_many` and `has_one` associations on a registered parent. No manual route wiring: `belongs_to` on the child plus `register_resource` for both is enough.
|
|
4
4
|
|
|
5
5
|
## 🚨 Critical
|
|
6
6
|
|
|
7
7
|
- **One level only.** Grandparent → parent → child nested routes are NOT supported. Use top-level routes for deeper relationships.
|
|
8
8
|
- **Parent scoping beats entity scoping.** When a parent is present, `default_relation_scope` scopes via the parent, NOT via `entity_scope`. Don't double-scope.
|
|
9
|
-
- **Named custom routes.** When adding member/collection routes on a nested resource, always pass `as
|
|
9
|
+
- **Named custom routes.** When adding member/collection routes on a nested resource, always pass `as:`; otherwise `resource_url_for` will fail.
|
|
10
10
|
- **The parent is authorized for `:read?`** before `current_parent` returns. The child policy receives the parent in its context.
|
|
11
11
|
|
|
12
12
|
## Setup
|
|
@@ -102,13 +102,13 @@ current_nested_association # association name (e.g. :properties)
|
|
|
102
102
|
parent_input_param # form param / association name (e.g. :company)
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
-
Each nested route carries the key of its own registration, so the parent class and association are **read from the route** rather than reconstructed from the URL. That is what lets a resource registered `singular: true` act as a parent at all
|
|
105
|
+
Each nested route carries the key of its own registration, so the parent class and association are **read from the route** rather than reconstructed from the URL. That is what lets a resource registered `singular: true` act as a parent at all: it contributes no id parameter, so there is nothing in the path to infer from.
|
|
106
106
|
|
|
107
107
|
## Parent vs entity scoping
|
|
108
108
|
|
|
109
|
-
When a parent is present, **parent scoping wins**: `default_relation_scope` scopes via the parent association, NOT `entity_scope`. The parent was already authorized and entity-scoped during its own authorization
|
|
109
|
+
When a parent is present, **parent scoping wins**: `default_relation_scope` scopes via the parent association, NOT `entity_scope`. The parent was already authorized and entity-scoped during its own authorization; double-scoping is redundant.
|
|
110
110
|
|
|
111
|
-
In the child's policy, just call `default_relation_scope
|
|
111
|
+
In the child's policy, just call `default_relation_scope`, which handles both cases:
|
|
112
112
|
|
|
113
113
|
```ruby
|
|
114
114
|
class PropertyPolicy < ResourcePolicy
|
|
@@ -186,7 +186,7 @@ class PropertyPolicy < ResourcePolicy
|
|
|
186
186
|
end
|
|
187
187
|
```
|
|
188
188
|
|
|
189
|
-
The parent is authorized for `:read?` before `current_parent` returns
|
|
189
|
+
The parent is authorized for `:read?` before `current_parent` returns; children inherit the parent's access requirements.
|
|
190
190
|
|
|
191
191
|
## Parameter handling
|
|
192
192
|
|
|
@@ -234,7 +234,7 @@ class PropertiesController < ::ResourceController
|
|
|
234
234
|
end
|
|
235
235
|
```
|
|
236
236
|
|
|
237
|
-
Conditional
|
|
237
|
+
Conditional: show parent only when accessed standalone:
|
|
238
238
|
|
|
239
239
|
```ruby
|
|
240
240
|
def present_parent?
|
|
@@ -273,7 +273,7 @@ end
|
|
|
273
273
|
Generates `/companies/:company_id/nested_properties/:id/analytics`, etc.
|
|
274
274
|
|
|
275
275
|
::: warning Always pass `as:`
|
|
276
|
-
Without `as:`, `resource_url_for(property, parent: company, action: :analytics)` fails
|
|
276
|
+
Without `as:`, `resource_url_for(property, parent: company, action: :analytics)` fails; there's no named route to look up.
|
|
277
277
|
:::
|
|
278
278
|
|
|
279
279
|
## Compound uniqueness
|
|
@@ -318,8 +318,8 @@ For deeper hierarchies, use top-level routes plus association tabs on the show p
|
|
|
318
318
|
|
|
319
319
|
## Related
|
|
320
320
|
|
|
321
|
-
- [Entity scoping](./entity-scoping)
|
|
322
|
-
- [Invites](./invites)
|
|
323
|
-
- [Behavior › Policy](/reference/behavior/policies)
|
|
324
|
-
- [Behavior › Controllers](/reference/behavior/controllers)
|
|
325
|
-
- [App › Portals](/reference/app/portals)
|
|
321
|
+
- [Entity scoping](./entity-scoping): what happens when no parent is present
|
|
322
|
+
- [Invites](./invites): membership-based onboarding
|
|
323
|
+
- [Behavior › Policy](/reference/behavior/policies): `relation_scope`, parent context
|
|
324
|
+
- [Behavior › Controllers](/reference/behavior/controllers): `current_parent`, presentation hooks
|
|
325
|
+
- [App › Portals](/reference/app/portals): `register_resource` and custom member/collection routes
|