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.
Files changed (201) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +85 -102
  3. data/.claude/skills/plutonium-app/SKILL.md +574 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +167 -302
  5. data/.claude/skills/plutonium-behavior/SKILL.md +838 -0
  6. data/.claude/skills/plutonium-resource/SKILL.md +1176 -0
  7. data/.claude/skills/plutonium-tenancy/SKILL.md +674 -0
  8. data/.claude/skills/plutonium-testing/SKILL.md +9 -6
  9. data/.claude/skills/plutonium-ui/SKILL.md +900 -0
  10. data/CHANGELOG.md +44 -2
  11. data/Rakefile +2 -1
  12. data/app/assets/plutonium.css +1 -11
  13. data/app/assets/plutonium.js +1010 -1214
  14. data/app/assets/plutonium.js.map +3 -3
  15. data/app/assets/plutonium.min.js +52 -51
  16. data/app/assets/plutonium.min.js.map +3 -3
  17. data/docs/.vitepress/config.ts +38 -29
  18. data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +53 -0
  19. data/docs/.vitepress/theme/components/HomeCta.vue +108 -0
  20. data/docs/.vitepress/theme/components/HomeHero.vue +70 -0
  21. data/docs/.vitepress/theme/components/HomeInTheBox.vue +74 -0
  22. data/docs/.vitepress/theme/components/HomePillars.vue +42 -0
  23. data/docs/.vitepress/theme/components/HomeStopWriting.vue +49 -0
  24. data/docs/.vitepress/theme/components/HomeWalkthrough.vue +111 -0
  25. data/docs/.vitepress/theme/components/SectionLanding.vue +115 -0
  26. data/docs/.vitepress/theme/custom.css +144 -0
  27. data/docs/.vitepress/theme/index.ts +58 -1
  28. data/docs/getting-started/index.md +33 -57
  29. data/docs/getting-started/installation.md +37 -80
  30. data/docs/getting-started/tutorial/02-first-resource.md +17 -8
  31. data/docs/getting-started/tutorial/03-authentication.md +31 -23
  32. data/docs/getting-started/tutorial/05-custom-actions.md +9 -4
  33. data/docs/getting-started/tutorial/06-nested-resources.md +7 -1
  34. data/docs/getting-started/tutorial/07-author-portal.md +8 -0
  35. data/docs/getting-started/tutorial/08-customizing-ui.md +4 -0
  36. data/docs/getting-started/tutorial/index.md +4 -5
  37. data/docs/guides/adding-resources.md +66 -377
  38. data/docs/guides/authentication.md +98 -462
  39. data/docs/guides/authorization.md +124 -370
  40. data/docs/guides/creating-packages.md +93 -298
  41. data/docs/guides/custom-actions.md +126 -441
  42. data/docs/guides/customizing-ui.md +258 -0
  43. data/docs/guides/index.md +49 -52
  44. data/docs/guides/multi-tenancy.md +123 -186
  45. data/docs/guides/nested-resources.md +137 -396
  46. data/docs/guides/search-filtering.md +127 -238
  47. data/docs/guides/testing.md +10 -5
  48. data/docs/guides/theming.md +168 -405
  49. data/docs/guides/troubleshooting.md +5 -3
  50. data/docs/guides/user-invites.md +112 -425
  51. data/docs/guides/user-profile.md +82 -241
  52. data/docs/index.md +10 -219
  53. data/docs/public/asciinema/home-scaffold.cast +305 -0
  54. data/docs/public/images/guides/custom-actions-bulk.png +0 -0
  55. data/docs/public/images/guides/multi-tenancy-dashboard.png +0 -0
  56. data/docs/public/images/guides/multi-tenancy-welcome.png +0 -0
  57. data/docs/public/images/guides/nested-inputs.png +0 -0
  58. data/docs/public/images/guides/nested-resources-tab.png +0 -0
  59. data/docs/public/images/guides/search-filtering-index.png +0 -0
  60. data/docs/public/images/guides/search-filtering-panel.png +0 -0
  61. data/docs/public/images/guides/theming-after.png +0 -0
  62. data/docs/public/images/guides/theming-before.png +0 -0
  63. data/docs/public/images/guides/user-invites-landing.png +0 -0
  64. data/docs/public/images/guides/user-profile-edit.png +0 -0
  65. data/docs/public/images/guides/user-profile-show.png +0 -0
  66. data/docs/public/images/home-index.png +0 -0
  67. data/docs/public/images/home-new.png +0 -0
  68. data/docs/public/images/home-show.png +0 -0
  69. data/docs/public/images/tutorial/02-empty-index.png +0 -0
  70. data/docs/public/images/tutorial/02-index-with-posts.png +0 -0
  71. data/docs/public/images/tutorial/02-new-form-modal.png +0 -0
  72. data/docs/public/images/tutorial/02-new-form.png +0 -0
  73. data/docs/public/images/tutorial/03-create-account.png +0 -0
  74. data/docs/public/images/tutorial/03-login.png +0 -0
  75. data/docs/public/images/tutorial/04-admin-index.png +0 -0
  76. data/docs/public/images/tutorial/05-actions-menu.png +0 -0
  77. data/docs/public/images/tutorial/05-row-actions.png +0 -0
  78. data/docs/public/images/tutorial/06-comments-tab.png +0 -0
  79. data/docs/public/images/tutorial/06-post-with-comments.png +0 -0
  80. data/docs/public/images/tutorial/07-author-dashboard.png +0 -0
  81. data/docs/public/images/tutorial/07-author-portal.png +0 -0
  82. data/docs/public/images/tutorial/08-customized-index.png +0 -0
  83. data/docs/reference/app/generators.md +517 -0
  84. data/docs/reference/app/index.md +158 -0
  85. data/docs/reference/app/packages.md +146 -0
  86. data/docs/reference/app/portals.md +377 -0
  87. data/docs/reference/auth/accounts.md +229 -0
  88. data/docs/reference/auth/index.md +88 -0
  89. data/docs/reference/auth/profile.md +185 -0
  90. data/docs/reference/behavior/controllers.md +395 -0
  91. data/docs/reference/behavior/index.md +22 -0
  92. data/docs/reference/behavior/interactions.md +341 -0
  93. data/docs/reference/behavior/policies.md +417 -0
  94. data/docs/reference/index.md +67 -48
  95. data/docs/reference/resource/actions.md +423 -0
  96. data/docs/reference/resource/definition.md +508 -0
  97. data/docs/reference/resource/index.md +50 -0
  98. data/docs/reference/resource/model.md +348 -0
  99. data/docs/reference/resource/query.md +305 -0
  100. data/docs/reference/tenancy/entity-scoping.md +368 -0
  101. data/docs/reference/tenancy/index.md +36 -0
  102. data/docs/reference/tenancy/invites.md +400 -0
  103. data/docs/reference/tenancy/nested-resources.md +267 -0
  104. data/docs/reference/testing/index.md +287 -0
  105. data/docs/reference/ui/assets.md +400 -0
  106. data/docs/reference/ui/components.md +165 -0
  107. data/docs/reference/ui/displays.md +104 -0
  108. data/docs/reference/ui/forms.md +284 -0
  109. data/docs/reference/ui/index.md +30 -0
  110. data/docs/reference/ui/layouts.md +106 -0
  111. data/docs/reference/ui/pages.md +189 -0
  112. data/docs/reference/ui/tables.md +121 -0
  113. data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md +1648 -0
  114. data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md.tasks.json +109 -0
  115. data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
  116. data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
  117. data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
  118. data/docs/superpowers/specs/2026-05-15-public-pages-overhaul-design.md +263 -0
  119. data/gemfiles/rails_7.gemfile.lock +1 -1
  120. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  121. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  122. data/lib/generators/pu/core/assets/assets_generator.rb +10 -0
  123. data/lib/generators/pu/core/update/update_generator.rb +0 -20
  124. data/lib/generators/pu/invites/install_generator.rb +45 -0
  125. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +1 -0
  126. data/lib/generators/pu/profile/conn_generator.rb +2 -2
  127. data/lib/generators/pu/res/conn/conn_generator.rb +33 -6
  128. data/lib/generators/pu/res/model/templates/model.rb.tt +4 -0
  129. data/lib/generators/pu/rodauth/account_generator.rb +2 -1
  130. data/lib/generators/pu/rodauth/admin_generator.rb +0 -2
  131. data/lib/generators/pu/rodauth/migration_generator.rb +0 -2
  132. data/lib/generators/pu/rodauth/views_generator.rb +0 -2
  133. data/lib/generators/pu/saas/membership/USAGE +4 -1
  134. data/lib/generators/pu/saas/setup_generator.rb +16 -4
  135. data/lib/generators/pu/saas/welcome/templates/app/controllers/welcome_controller.rb.tt +1 -1
  136. data/lib/plutonium/definition/base.rb +1 -1
  137. data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
  138. data/lib/plutonium/helpers/turbo_helper.rb +30 -0
  139. data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
  140. data/lib/plutonium/resource/controller.rb +1 -0
  141. data/lib/plutonium/resource/controllers/crud_actions.rb +23 -5
  142. data/lib/plutonium/resource/controllers/interactive_actions.rb +3 -3
  143. data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
  144. data/lib/plutonium/resource/policy.rb +7 -0
  145. data/lib/plutonium/routing/mapper_extensions.rb +15 -0
  146. data/lib/plutonium/ui/component/methods.rb +5 -0
  147. data/lib/plutonium/ui/form/base.rb +23 -3
  148. data/lib/plutonium/ui/form/components/json.rb +58 -0
  149. data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
  150. data/lib/plutonium/ui/form/components/secure_association.rb +103 -22
  151. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
  152. data/lib/plutonium/ui/form/interaction.rb +1 -1
  153. data/lib/plutonium/ui/form/resource.rb +0 -4
  154. data/lib/plutonium/ui/form/theme.rb +1 -1
  155. data/lib/plutonium/ui/grid/resource.rb +1 -1
  156. data/lib/plutonium/ui/layout/base.rb +1 -0
  157. data/lib/plutonium/ui/page/base.rb +0 -7
  158. data/lib/plutonium/ui/page/edit.rb +1 -1
  159. data/lib/plutonium/ui/page/index.rb +4 -4
  160. data/lib/plutonium/ui/page/new.rb +1 -1
  161. data/lib/plutonium/ui/table/components/filter_form.rb +12 -4
  162. data/lib/plutonium/ui/table/resource.rb +1 -1
  163. data/lib/plutonium/version.rb +1 -1
  164. data/lib/plutonium.rb +8 -0
  165. data/lib/tasks/release.rake +15 -1
  166. data/package.json +13 -10
  167. data/src/css/slim_select.css +4 -0
  168. data/src/js/controllers/form_controller.js +5 -4
  169. data/src/js/controllers/slim_select_controller.js +61 -0
  170. data/src/js/turbo/turbo_actions.js +33 -0
  171. data/yarn.lock +661 -544
  172. metadata +86 -33
  173. data/.claude/skills/plutonium-assets/SKILL.md +0 -512
  174. data/.claude/skills/plutonium-controller/SKILL.md +0 -396
  175. data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
  176. data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
  177. data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
  178. data/.claude/skills/plutonium-forms/SKILL.md +0 -465
  179. data/.claude/skills/plutonium-installation/SKILL.md +0 -331
  180. data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
  181. data/.claude/skills/plutonium-invites/SKILL.md +0 -408
  182. data/.claude/skills/plutonium-model/SKILL.md +0 -440
  183. data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
  184. data/.claude/skills/plutonium-package/SKILL.md +0 -198
  185. data/.claude/skills/plutonium-policy/SKILL.md +0 -456
  186. data/.claude/skills/plutonium-portal/SKILL.md +0 -410
  187. data/.claude/skills/plutonium-views/SKILL.md +0 -651
  188. data/docs/reference/assets/index.md +0 -496
  189. data/docs/reference/controller/index.md +0 -412
  190. data/docs/reference/definition/actions.md +0 -462
  191. data/docs/reference/definition/fields.md +0 -383
  192. data/docs/reference/definition/index.md +0 -326
  193. data/docs/reference/definition/query.md +0 -351
  194. data/docs/reference/generators/index.md +0 -648
  195. data/docs/reference/interaction/index.md +0 -449
  196. data/docs/reference/model/features.md +0 -248
  197. data/docs/reference/model/index.md +0 -218
  198. data/docs/reference/policy/index.md +0 -456
  199. data/docs/reference/portal/index.md +0 -379
  200. data/docs/reference/views/forms.md +0 -411
  201. data/docs/reference/views/index.md +0 -544
@@ -1,48 +1,51 @@
1
1
  # Multi-tenancy
2
2
 
3
- This guide covers isolating data by organization, account, or other entity.
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
- ## Overview
5
+ ## Goal
6
6
 
7
- Multi-tenancy means each tenant (organization, company, account) sees only their own data. Plutonium supports this through:
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
- - **Entity Scoping** - Automatic query filtering via portal configuration
10
- - **Path or Custom Strategies** - Flexible entity resolution
11
- - **Policy Integration** - Authorization automatically respects tenancy
9
+ ## 🚨 Critical
12
10
 
13
- ## Setting Up Multi-tenancy
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
- ### 1. Create the Entity Model
15
+ After login, users with memberships in multiple entities land on a workspace selector:
16
16
 
17
- ```ruby
18
- # app/models/organization.rb
19
- class Organization < ApplicationRecord
20
- include Plutonium::Resource::Record
17
+ ![Workspace selector after login](/images/guides/multi-tenancy-welcome.png)
21
18
 
22
- has_many :users
23
- has_many :posts
24
- end
19
+ Picking one lands them on the entity-scoped dashboard — note the entity slug in the URL:
20
+
21
+ ![Tenant-scoped dashboard](/images/guides/multi-tenancy-dashboard.png)
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
- ### 2. Add Entity Reference to Resources
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
- Resources must have an association path to the entity:
31
+ See [Reference Auth Accounts SaaS setup](/reference/auth/accounts#saas-setup-pu-saas-setup).
30
32
 
31
- ```ruby
32
- # Direct association (preferred)
33
- class Post < ResourceRecord
34
- belongs_to :organization
35
- belongs_to :user
36
- end
33
+ ## Manual setup
37
34
 
38
- # Through association
39
- class Comment < ResourceRecord
40
- belongs_to :post
41
- has_one :organization, through: :post
42
- end
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. Configure the Portal Engine
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
- Routes become: `/organizations/:organization_id/posts`
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
- Entity ID is included in the URL path:
65
+ ### 4. Mount the portal
68
66
 
69
67
  ```ruby
70
- config.after_initialize do
71
- scope_to_entity Organization, strategy: :path
72
- end
68
+ # config/routes.rb
69
+ mount CustomerPortal::Engine, at: "/customer"
73
70
  ```
74
71
 
75
- The user must have access to the organization (via `associated_with` scope).
72
+ URLs now include the entity id: `/customer/organizations/42/posts`.
76
73
 
77
- ### Custom Strategy
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
- # packages/customer_portal/app/controllers/customer_portal/concerns/controller.rb
90
- module CustomerPortal
91
- module Concerns
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
- ## How Entity Scoping Works
83
+ 🚨 Without the `scope:`, the same slug in different orgs would collide.
109
84
 
110
- ### Automatic Query Filtering
85
+ ## Strategies
111
86
 
112
- All resource queries are automatically scoped via `associated_with`:
87
+ ### Path strategy (default)
113
88
 
114
89
  ```ruby
115
- # In a scoped portal
116
- Post.all # Returns only current entity's posts
90
+ scope_to_entity Organization, strategy: :path
91
+ # /organizations/:organization_id/posts
117
92
  ```
118
93
 
119
- ### Helper Methods
120
-
121
- Inside controllers:
94
+ ### Custom param key
122
95
 
123
96
  ```ruby
124
- current_scoped_entity # The current Organization/Account/etc.
125
- scoped_to_entity? # true if scoping is active
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
- ### Model Requirements
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
- class AuditLog < ResourceRecord
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
- ## User Membership Patterns
147
-
148
- ### Single Organization per User
107
+ Then implement the method on the portal's controller concern:
149
108
 
150
109
  ```ruby
151
- class User < ApplicationRecord
152
- belongs_to :organization
153
- end
110
+ module CustomerPortal::Concerns::Controller
111
+ extend ActiveSupport::Concern
112
+ include Plutonium::Portal::Controller
113
+
114
+ private
154
115
 
155
- # Custom strategy
156
- def current_organization
157
- current_user.organization
116
+ def current_organization
117
+ @current_organization ||= Organization.find_by!(subdomain: request.subdomain)
118
+ end
158
119
  end
159
120
  ```
160
121
 
161
- ### Multiple Organizations per User
122
+ ## Three model shapes
162
123
 
163
- ```ruby
164
- class User < ApplicationRecord
165
- has_many :memberships
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
- # Custom strategy with session storage
170
- def current_organization
171
- @current_organization ||=
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
- ### Organization Switcher
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 OrganizationSwitchController < ApplicationController
181
- def update
182
- org = current_user.organizations.find(params[:id])
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
- ## Policy Integration
190
-
191
- Entity scoping is automatic. The base `Plutonium::Resource::Policy` includes:
146
+ ### 3. Grandchild — `has_one :through`
192
147
 
193
148
  ```ruby
194
- relation_scope do |relation|
195
- next relation unless entity_scope
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
- The `entity_scope` context is automatically set to `current_scoped_entity`.
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
- ### Additional Filtering
157
+ Full mechanics: [Reference › Tenancy › Entity scoping › Three model shapes](/reference/tenancy/entity-scoping#three-model-shapes).
204
158
 
205
- Add role-based filtering on top of entity scoping:
159
+ ## Custom scope (when the path is polymorphic or needs SQL control)
206
160
 
207
161
  ```ruby
208
- class PostPolicy < ResourcePolicy
209
- relation_scope do |relation|
210
- relation = super(relation) # Apply entity scoping first
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
- ## Subdomain-Based Tenancy
169
+ Plutonium picks this up **before** trying association detection.
222
170
 
223
- Route to different organizations by subdomain:
224
-
225
- ### Routes
171
+ ## Accessing the scoped entity
226
172
 
227
173
  ```ruby
228
- # config/routes.rb
229
- constraints subdomain: /[a-z]+/ do
230
- mount CustomerPortal::Engine, at: "/"
231
- end
174
+ # Controller / views
175
+ current_scoped_entity
176
+ scoped_to_entity?
177
+
178
+ # Policy
179
+ entity_scope
232
180
  ```
233
181
 
234
- ### Custom Strategy
182
+ ## Policy filtering on top of default
235
183
 
236
184
  ```ruby
237
- # Engine configuration
238
- scope_to_entity Organization, strategy: :current_organization
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
- ## Cross-Tenant Operations
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
- Sometimes admins need to see all data:
192
+ ## Cross-tenant operations super-admin portal
250
193
 
251
- ### Super Admin Portal (No Scoping)
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 = sees everything
200
+ # No scope_to_entity sees all tenants
259
201
  end
260
202
  end
261
203
  ```
262
204
 
263
- ### Conditional Scoping
205
+ This portal's policies see everything. Don't enable public signup here.
264
206
 
265
- ```ruby
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
- When `current_scoped_entity` returns `nil`, scoping is bypassed.
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
- ## Data Isolation Patterns
276
-
277
- ### Shared Database, Scoped Queries (Recommended)
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
- All tenants share tables, queries filter by entity association:
216
+ Override on the controller:
280
217
 
281
218
  ```ruby
282
- scope_to_entity Organization, strategy: :path
219
+ class MatchesController < ::ResourceController
220
+ private
221
+ def scoped_entity_association = :home_team
222
+ end
283
223
  ```
284
224
 
285
- Pros:
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
- Each tenant has separate database schema. This requires additional setup beyond Plutonium's built-in scoping.
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
- - [Authorization](./authorization)
301
- - [Creating Packages](./creating-packages)
302
- - [Authentication](./authentication)
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