plutonium 0.50.0 → 0.52.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 +574 -0
- data/.claude/skills/plutonium-auth/SKILL.md +167 -302
- 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 +674 -0
- data/.claude/skills/plutonium-testing/SKILL.md +9 -6
- data/.claude/skills/plutonium-ui/SKILL.md +900 -0
- data/CHANGELOG.md +44 -2
- data/Rakefile +2 -1
- data/app/assets/plutonium.css +1 -11
- data/app/assets/plutonium.js +1010 -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 +38 -29
- data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +53 -0
- data/docs/.vitepress/theme/components/HomeCta.vue +108 -0
- data/docs/.vitepress/theme/components/HomeHero.vue +70 -0
- data/docs/.vitepress/theme/components/HomeInTheBox.vue +74 -0
- data/docs/.vitepress/theme/components/HomePillars.vue +42 -0
- data/docs/.vitepress/theme/components/HomeStopWriting.vue +49 -0
- data/docs/.vitepress/theme/components/HomeWalkthrough.vue +111 -0
- data/docs/.vitepress/theme/components/SectionLanding.vue +115 -0
- data/docs/.vitepress/theme/custom.css +144 -0
- data/docs/.vitepress/theme/index.ts +58 -1
- data/docs/getting-started/index.md +33 -57
- data/docs/getting-started/installation.md +37 -80
- data/docs/getting-started/tutorial/02-first-resource.md +17 -8
- data/docs/getting-started/tutorial/03-authentication.md +31 -23
- data/docs/getting-started/tutorial/05-custom-actions.md +9 -4
- data/docs/getting-started/tutorial/06-nested-resources.md +7 -1
- data/docs/getting-started/tutorial/07-author-portal.md +8 -0
- data/docs/getting-started/tutorial/08-customizing-ui.md +4 -0
- data/docs/getting-started/tutorial/index.md +4 -5
- data/docs/guides/adding-resources.md +66 -377
- data/docs/guides/authentication.md +98 -462
- data/docs/guides/authorization.md +124 -370
- data/docs/guides/creating-packages.md +93 -298
- data/docs/guides/custom-actions.md +126 -441
- data/docs/guides/customizing-ui.md +258 -0
- data/docs/guides/index.md +49 -52
- data/docs/guides/multi-tenancy.md +123 -186
- data/docs/guides/nested-resources.md +137 -396
- data/docs/guides/search-filtering.md +127 -238
- data/docs/guides/testing.md +10 -5
- data/docs/guides/theming.md +168 -405
- data/docs/guides/troubleshooting.md +5 -3
- data/docs/guides/user-invites.md +112 -425
- data/docs/guides/user-profile.md +82 -241
- data/docs/index.md +10 -219
- data/docs/public/asciinema/home-scaffold.cast +305 -0
- data/docs/public/images/guides/custom-actions-bulk.png +0 -0
- data/docs/public/images/guides/multi-tenancy-dashboard.png +0 -0
- data/docs/public/images/guides/multi-tenancy-welcome.png +0 -0
- data/docs/public/images/guides/nested-inputs.png +0 -0
- data/docs/public/images/guides/nested-resources-tab.png +0 -0
- data/docs/public/images/guides/search-filtering-index.png +0 -0
- data/docs/public/images/guides/search-filtering-panel.png +0 -0
- data/docs/public/images/guides/theming-after.png +0 -0
- data/docs/public/images/guides/theming-before.png +0 -0
- data/docs/public/images/guides/user-invites-landing.png +0 -0
- data/docs/public/images/guides/user-profile-edit.png +0 -0
- data/docs/public/images/guides/user-profile-show.png +0 -0
- data/docs/public/images/home-index.png +0 -0
- data/docs/public/images/home-new.png +0 -0
- data/docs/public/images/home-show.png +0 -0
- data/docs/public/images/tutorial/02-empty-index.png +0 -0
- data/docs/public/images/tutorial/02-index-with-posts.png +0 -0
- data/docs/public/images/tutorial/02-new-form-modal.png +0 -0
- data/docs/public/images/tutorial/02-new-form.png +0 -0
- data/docs/public/images/tutorial/03-create-account.png +0 -0
- data/docs/public/images/tutorial/03-login.png +0 -0
- data/docs/public/images/tutorial/04-admin-index.png +0 -0
- data/docs/public/images/tutorial/05-actions-menu.png +0 -0
- data/docs/public/images/tutorial/05-row-actions.png +0 -0
- data/docs/public/images/tutorial/06-comments-tab.png +0 -0
- data/docs/public/images/tutorial/06-post-with-comments.png +0 -0
- data/docs/public/images/tutorial/07-author-dashboard.png +0 -0
- data/docs/public/images/tutorial/07-author-portal.png +0 -0
- data/docs/public/images/tutorial/08-customized-index.png +0 -0
- 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 +229 -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 +67 -48
- 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 +368 -0
- data/docs/reference/tenancy/index.md +36 -0
- data/docs/reference/tenancy/invites.md +400 -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 +121 -0
- data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md +1648 -0
- data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md.tasks.json +109 -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/docs/superpowers/specs/2026-05-15-public-pages-overhaul-design.md +263 -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/assets/assets_generator.rb +10 -0
- data/lib/generators/pu/core/update/update_generator.rb +0 -20
- data/lib/generators/pu/invites/install_generator.rb +45 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +1 -0
- data/lib/generators/pu/profile/conn_generator.rb +2 -2
- data/lib/generators/pu/res/conn/conn_generator.rb +33 -6
- data/lib/generators/pu/res/model/templates/model.rb.tt +4 -0
- data/lib/generators/pu/rodauth/account_generator.rb +2 -1
- data/lib/generators/pu/rodauth/admin_generator.rb +0 -2
- data/lib/generators/pu/rodauth/migration_generator.rb +0 -2
- data/lib/generators/pu/rodauth/views_generator.rb +0 -2
- data/lib/generators/pu/saas/membership/USAGE +4 -1
- data/lib/generators/pu/saas/setup_generator.rb +16 -4
- data/lib/generators/pu/saas/welcome/templates/app/controllers/welcome_controller.rb.tt +1 -1
- 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 +30 -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 +23 -5
- data/lib/plutonium/resource/controllers/interactive_actions.rb +3 -3
- 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 +5 -0
- data/lib/plutonium/ui/form/base.rb +23 -3
- 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 +103 -22
- data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
- data/lib/plutonium/ui/form/interaction.rb +1 -1
- data/lib/plutonium/ui/form/resource.rb +0 -4
- data/lib/plutonium/ui/form/theme.rb +1 -1
- 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/edit.rb +1 -1
- data/lib/plutonium/ui/page/index.rb +4 -4
- data/lib/plutonium/ui/page/new.rb +1 -1
- data/lib/plutonium/ui/table/components/filter_form.rb +12 -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 +13 -10
- data/src/css/slim_select.css +4 -0
- data/src/js/controllers/form_controller.js +5 -4
- data/src/js/controllers/slim_select_controller.js +61 -0
- data/src/js/turbo/turbo_actions.js +33 -0
- data/yarn.lock +661 -544
- metadata +86 -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
|
@@ -1,48 +1,51 @@
|
|
|
1
1
|
# Multi-tenancy
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Isolate data by organization, account, or any other "entity". Plutonium handles the URL strategy, query scoping, form injection, and `belongs_to` auto-detection automatically.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Goal
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Each tenant sees only their own records. Queries are filtered, forms inject the tenant on create, URLs include the tenant id, and policies receive the tenant for authorization.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
- **Path or Custom Strategies** - Flexible entity resolution
|
|
11
|
-
- **Policy Integration** - Authorization automatically respects tenancy
|
|
9
|
+
## 🚨 Critical
|
|
12
10
|
|
|
13
|
-
|
|
11
|
+
- **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins triggers `verify_default_relation_scope_applied!` at runtime. 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.
|
|
12
|
+
- **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.
|
|
13
|
+
- **Compound uniqueness scoped to the tenant FK.** `validates :code, uniqueness: {scope: :organization_id}` — without this, uniqueness leaks across tenants.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
After login, users with memberships in multiple entities land on a workspace selector:
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
# app/models/organization.rb
|
|
19
|
-
class Organization < ApplicationRecord
|
|
20
|
-
include Plutonium::Resource::Record
|
|
17
|
+

|
|
21
18
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
Picking one lands them on the entity-scoped dashboard — note the entity slug in the URL:
|
|
20
|
+
|
|
21
|
+

|
|
22
|
+
|
|
23
|
+
## Quickest path: `pu:saas:setup`
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
rails g pu:saas:setup --user Customer --entity Organization
|
|
25
27
|
```
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
This **meta-generator** creates the user + entity + membership trio AND runs `pu:saas:portal`, `pu:profile:setup`, `pu:saas:welcome`, and `pu:invites:install` in one shot. The portal is fully wired for entity scoping.
|
|
28
30
|
|
|
29
|
-
|
|
31
|
+
See [Reference › Auth › Accounts › SaaS setup](/reference/auth/accounts#saas-setup-pu-saas-setup).
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
# Direct association (preferred)
|
|
33
|
-
class Post < ResourceRecord
|
|
34
|
-
belongs_to :organization
|
|
35
|
-
belongs_to :user
|
|
36
|
-
end
|
|
33
|
+
## Manual setup
|
|
37
34
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
35
|
+
### 1. Create the entity model
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
rails g pu:res:scaffold Organization name:string:uniq slug:string:uniq --dest=main_app
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### 2. Add the FK to each tenant-scoped resource
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
rails g pu:res:scaffold Post organization:belongs_to title:string content:text --dest=main_app
|
|
45
|
+
rails db:migrate
|
|
43
46
|
```
|
|
44
47
|
|
|
45
|
-
### 3.
|
|
48
|
+
### 3. Scope the portal to the entity
|
|
46
49
|
|
|
47
50
|
```ruby
|
|
48
51
|
# packages/customer_portal/lib/engine.rb
|
|
@@ -51,252 +54,186 @@ module CustomerPortal
|
|
|
51
54
|
include Plutonium::Portal::Engine
|
|
52
55
|
|
|
53
56
|
config.after_initialize do
|
|
54
|
-
# Path strategy - entity ID in URL
|
|
55
57
|
scope_to_entity Organization, strategy: :path
|
|
56
58
|
end
|
|
57
59
|
end
|
|
58
60
|
end
|
|
59
61
|
```
|
|
60
62
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
## Scoping Strategies
|
|
64
|
-
|
|
65
|
-
### Path Strategy (Default)
|
|
63
|
+
Or pass `--scope=Organization` to `pu:pkg:portal` and the engine wires this automatically.
|
|
66
64
|
|
|
67
|
-
|
|
65
|
+
### 4. Mount the portal
|
|
68
66
|
|
|
69
67
|
```ruby
|
|
70
|
-
config.
|
|
71
|
-
|
|
72
|
-
end
|
|
68
|
+
# config/routes.rb
|
|
69
|
+
mount CustomerPortal::Engine, at: "/customer"
|
|
73
70
|
```
|
|
74
71
|
|
|
75
|
-
|
|
72
|
+
URLs now include the entity id: `/customer/organizations/42/posts`.
|
|
76
73
|
|
|
77
|
-
###
|
|
78
|
-
|
|
79
|
-
Define a method that returns the current entity:
|
|
80
|
-
|
|
81
|
-
```ruby
|
|
82
|
-
# packages/customer_portal/lib/engine.rb
|
|
83
|
-
config.after_initialize do
|
|
84
|
-
scope_to_entity Organization, strategy: :current_organization
|
|
85
|
-
end
|
|
86
|
-
```
|
|
74
|
+
### 5. Compound uniqueness
|
|
87
75
|
|
|
88
76
|
```ruby
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
module Controller
|
|
93
|
-
extend ActiveSupport::Concern
|
|
94
|
-
include Plutonium::Portal::Controller
|
|
95
|
-
include Plutonium::Auth::Rodauth(:user)
|
|
96
|
-
|
|
97
|
-
private
|
|
98
|
-
|
|
99
|
-
# Method name must match strategy
|
|
100
|
-
def current_organization
|
|
101
|
-
@current_organization ||= current_user.organization
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
end
|
|
77
|
+
class Post < ResourceRecord
|
|
78
|
+
belongs_to :organization
|
|
79
|
+
validates :slug, uniqueness: {scope: :organization_id}
|
|
105
80
|
end
|
|
106
81
|
```
|
|
107
82
|
|
|
108
|
-
|
|
83
|
+
🚨 Without the `scope:`, the same slug in different orgs would collide.
|
|
109
84
|
|
|
110
|
-
|
|
85
|
+
## Strategies
|
|
111
86
|
|
|
112
|
-
|
|
87
|
+
### Path strategy (default)
|
|
113
88
|
|
|
114
89
|
```ruby
|
|
115
|
-
|
|
116
|
-
|
|
90
|
+
scope_to_entity Organization, strategy: :path
|
|
91
|
+
# → /organizations/:organization_id/posts
|
|
117
92
|
```
|
|
118
93
|
|
|
119
|
-
###
|
|
120
|
-
|
|
121
|
-
Inside controllers:
|
|
94
|
+
### Custom param key
|
|
122
95
|
|
|
123
96
|
```ruby
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
scoped_entity_class # Organization (the entity class)
|
|
97
|
+
scope_to_entity Organization, strategy: :path, param_key: :org_id
|
|
98
|
+
# → /orgs/:org_id/posts
|
|
127
99
|
```
|
|
128
100
|
|
|
129
|
-
###
|
|
130
|
-
|
|
131
|
-
Models must have an association path to the scoped entity. Plutonium automatically resolves:
|
|
132
|
-
|
|
133
|
-
1. **Direct belongs_to** - `Post belongs_to :organization`
|
|
134
|
-
2. **Through association** - `Comment has_one :organization, through: :post`
|
|
135
|
-
3. **Custom scope** - For complex cases, define a named scope:
|
|
101
|
+
### Subdomain / session / custom
|
|
136
102
|
|
|
137
103
|
```ruby
|
|
138
|
-
|
|
139
|
-
# When automatic resolution fails, define this scope
|
|
140
|
-
scope :associated_with_organization, ->(org) {
|
|
141
|
-
joins(:user).where(users: { organization_id: org.id })
|
|
142
|
-
}
|
|
143
|
-
end
|
|
104
|
+
scope_to_entity Organization, strategy: :current_organization
|
|
144
105
|
```
|
|
145
106
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
### Single Organization per User
|
|
107
|
+
Then implement the method on the portal's controller concern:
|
|
149
108
|
|
|
150
109
|
```ruby
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
110
|
+
module CustomerPortal::Concerns::Controller
|
|
111
|
+
extend ActiveSupport::Concern
|
|
112
|
+
include Plutonium::Portal::Controller
|
|
113
|
+
|
|
114
|
+
private
|
|
154
115
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
116
|
+
def current_organization
|
|
117
|
+
@current_organization ||= Organization.find_by!(subdomain: request.subdomain)
|
|
118
|
+
end
|
|
158
119
|
end
|
|
159
120
|
```
|
|
160
121
|
|
|
161
|
-
|
|
122
|
+
## Three model shapes
|
|
162
123
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
has_many :organizations, through: :memberships
|
|
167
|
-
end
|
|
124
|
+
How tenant scoping resolves depends on how the model relates to the entity. Three shapes, pick the lightest:
|
|
125
|
+
|
|
126
|
+
### 1. Direct `belongs_to`
|
|
168
127
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
current_user.organizations.find_by(id: session[:organization_id]) ||
|
|
173
|
-
current_user.organizations.first
|
|
128
|
+
```ruby
|
|
129
|
+
class Post < ResourceRecord
|
|
130
|
+
belongs_to :organization
|
|
174
131
|
end
|
|
132
|
+
# Post.associated_with(org) → Post.where(organization: org)
|
|
175
133
|
```
|
|
176
134
|
|
|
177
|
-
|
|
135
|
+
Auto-detected. Use when the model naturally has a direct FK to the entity.
|
|
136
|
+
|
|
137
|
+
### 2. Join table (`belongs_to` AND `belongs_to`)
|
|
178
138
|
|
|
179
139
|
```ruby
|
|
180
|
-
class
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
session[:organization_id] = org.id
|
|
184
|
-
redirect_back(fallback_location: root_path)
|
|
185
|
-
end
|
|
140
|
+
class Membership < ResourceRecord
|
|
141
|
+
belongs_to :user
|
|
142
|
+
belongs_to :organization # auto-detected
|
|
186
143
|
end
|
|
187
144
|
```
|
|
188
145
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
Entity scoping is automatic. The base `Plutonium::Resource::Policy` includes:
|
|
146
|
+
### 3. Grandchild — `has_one :through`
|
|
192
147
|
|
|
193
148
|
```ruby
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
relation.associated_with(entity_scope)
|
|
149
|
+
class Post < ResourceRecord
|
|
150
|
+
belongs_to :user
|
|
151
|
+
has_one :organization, through: :user # ← critical
|
|
198
152
|
end
|
|
199
153
|
```
|
|
200
154
|
|
|
201
|
-
|
|
155
|
+
Auto-detected via `reflect_on_all_associations`. Declaring `has_one :through` is the lightest fix when the path is two hops.
|
|
202
156
|
|
|
203
|
-
|
|
157
|
+
Full mechanics: [Reference › Tenancy › Entity scoping › Three model shapes](/reference/tenancy/entity-scoping#three-model-shapes).
|
|
204
158
|
|
|
205
|
-
|
|
159
|
+
## Custom scope (when the path is polymorphic or needs SQL control)
|
|
206
160
|
|
|
207
161
|
```ruby
|
|
208
|
-
class
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
if user.role == "viewer"
|
|
213
|
-
relation.where(published: true)
|
|
214
|
-
else
|
|
215
|
-
relation
|
|
216
|
-
end
|
|
217
|
-
end
|
|
162
|
+
class Comment < ResourceRecord
|
|
163
|
+
scope :associated_with_organization, ->(org) {
|
|
164
|
+
joins(task: :project).where(projects: {organization_id: org.id})
|
|
165
|
+
}
|
|
218
166
|
end
|
|
219
167
|
```
|
|
220
168
|
|
|
221
|
-
|
|
169
|
+
Plutonium picks this up **before** trying association detection.
|
|
222
170
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
### Routes
|
|
171
|
+
## Accessing the scoped entity
|
|
226
172
|
|
|
227
173
|
```ruby
|
|
228
|
-
#
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
174
|
+
# Controller / views
|
|
175
|
+
current_scoped_entity
|
|
176
|
+
scoped_to_entity?
|
|
177
|
+
|
|
178
|
+
# Policy
|
|
179
|
+
entity_scope
|
|
232
180
|
```
|
|
233
181
|
|
|
234
|
-
|
|
182
|
+
## Policy filtering on top of default
|
|
235
183
|
|
|
236
184
|
```ruby
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
# Controller concern
|
|
241
|
-
def current_organization
|
|
242
|
-
@current_organization ||=
|
|
243
|
-
Organization.find_by!(subdomain: request.subdomain)
|
|
185
|
+
relation_scope do |relation|
|
|
186
|
+
default_relation_scope(relation).where(archived: false)
|
|
244
187
|
end
|
|
245
188
|
```
|
|
246
189
|
|
|
247
|
-
|
|
190
|
+
🚨 `default_relation_scope(relation)` must be called somewhere in the chain — otherwise the runtime verification raises. Calling it explicitly here is safest; `super` works only if the parent policy also calls it.
|
|
248
191
|
|
|
249
|
-
|
|
192
|
+
## Cross-tenant operations — super-admin portal
|
|
250
193
|
|
|
251
|
-
|
|
194
|
+
Create a separate portal **without** `scope_to_entity`:
|
|
252
195
|
|
|
253
196
|
```ruby
|
|
254
|
-
# packages/super_admin_portal/lib/engine.rb
|
|
255
197
|
module SuperAdminPortal
|
|
256
198
|
class Engine < Rails::Engine
|
|
257
199
|
include Plutonium::Portal::Engine
|
|
258
|
-
# No scope_to_entity
|
|
200
|
+
# No scope_to_entity — sees all tenants
|
|
259
201
|
end
|
|
260
202
|
end
|
|
261
203
|
```
|
|
262
204
|
|
|
263
|
-
|
|
205
|
+
This portal's policies see everything. Don't enable public signup here.
|
|
264
206
|
|
|
265
|
-
|
|
266
|
-
# Custom strategy that returns nil for super admins
|
|
267
|
-
def current_organization
|
|
268
|
-
return nil if current_user.super_admin?
|
|
269
|
-
current_user.organization
|
|
270
|
-
end
|
|
271
|
-
```
|
|
207
|
+
## Multiple associations to the same entity
|
|
272
208
|
|
|
273
|
-
|
|
209
|
+
If a model has two `belongs_to` to the entity class (e.g. `Match belongs_to :home_team, :away_team`), Plutonium raises:
|
|
274
210
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
211
|
+
```
|
|
212
|
+
Match has multiple associations to Competition::Team: home_team, away_team.
|
|
213
|
+
Plutonium cannot auto-detect which one to use for entity scoping.
|
|
214
|
+
```
|
|
278
215
|
|
|
279
|
-
|
|
216
|
+
Override on the controller:
|
|
280
217
|
|
|
281
218
|
```ruby
|
|
282
|
-
|
|
219
|
+
class MatchesController < ::ResourceController
|
|
220
|
+
private
|
|
221
|
+
def scoped_entity_association = :home_team
|
|
222
|
+
end
|
|
283
223
|
```
|
|
284
224
|
|
|
285
|
-
|
|
286
|
-
- Simple setup
|
|
287
|
-
- Easy migrations
|
|
288
|
-
- Efficient for many small tenants
|
|
289
|
-
|
|
290
|
-
Cons:
|
|
291
|
-
- Risk of data leakage if scoping fails
|
|
292
|
-
- Complex queries for cross-tenant reports
|
|
293
|
-
|
|
294
|
-
### Schema-Based Isolation
|
|
225
|
+
## Common issues
|
|
295
226
|
|
|
296
|
-
|
|
227
|
+
- **`verify_default_relation_scope_applied!` raises** — your custom `relation_scope` doesn't call `default_relation_scope(relation)`. Fix by composing: `default_relation_scope(relation).where(...)`.
|
|
228
|
+
- **`Could not resolve the association between 'Model' and 'Entity'`** — the model has no path to the entity. Fix on the **model** (declare `has_one :through` or a custom `associated_with_<entity>` scope). Never paper over with `where` in the policy.
|
|
229
|
+
- **Records leak across tenants** — likely a missing compound-uniqueness scope on the model. Add `validates :code, uniqueness: {scope: :organization_id}`.
|
|
230
|
+
- **Forms show the entity field anyway** — check `present_scoped_entity?` / `submit_scoped_entity?` on the controller (defaults are `false`).
|
|
231
|
+
- **Want to bypass scoping in one place** — use `skip_default_relation_scope!` explicitly, NOT a silent `where` bypass.
|
|
297
232
|
|
|
298
233
|
## Related
|
|
299
234
|
|
|
300
|
-
- [
|
|
301
|
-
- [
|
|
302
|
-
- [
|
|
235
|
+
- [Reference › Tenancy › Entity scoping](/reference/tenancy/entity-scoping) — full surface
|
|
236
|
+
- [Reference › Behavior › Policies](/reference/behavior/policies) — `relation_scope` syntax
|
|
237
|
+
- [Reference › App › Portals](/reference/app/portals) — `scope_to_entity` engine config
|
|
238
|
+
- [Nested resources](./nested-resources) — parent scoping (takes precedence over entity scoping)
|
|
239
|
+
- [User invites](./user-invites) — invitation-based membership onboarding
|