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
data/docs/guides/index.md
CHANGED
|
@@ -1,52 +1,32 @@
|
|
|
1
1
|
# Guides
|
|
2
2
|
|
|
3
|
-
Task-oriented
|
|
3
|
+
Task-oriented walkthroughs. Each guide shows you how to accomplish a specific task, step-by-step, with links to [Reference](/reference/) for the concepts.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## I want to…
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
- [Adding Resources](./adding-resources) - Create and connect resources to portals
|
|
12
|
-
- [Creating Packages](./creating-packages) - Organize code into feature and portal packages
|
|
13
|
-
|
|
14
|
-
### Authentication & Authorization
|
|
15
|
-
|
|
16
|
-
- [Authentication](./authentication) - Set up user authentication with Rodauth
|
|
17
|
-
- [Authorization](./authorization) - Implement policies for access control
|
|
18
|
-
|
|
19
|
-
### Features
|
|
20
|
-
|
|
21
|
-
- [Custom Actions](./custom-actions) - Add interactive actions with Interactions
|
|
22
|
-
- [Nested Resources](./nested-resources) - Parent/child resource relationships
|
|
23
|
-
- [Multi-tenancy](./multi-tenancy) - Scope data to organizations or accounts
|
|
24
|
-
- [Search and Filtering](./search-filtering) - Implement search, filters, and scopes
|
|
25
|
-
- [User Invites](./user-invites) - Invitation system for multi-tenant apps
|
|
26
|
-
|
|
27
|
-
### Customization
|
|
28
|
-
|
|
29
|
-
- [Theming](./theming) - Customize colors, styles, and branding
|
|
30
|
-
|
|
31
|
-
### Help
|
|
32
|
-
|
|
33
|
-
- [Troubleshooting](./troubleshooting) - Common issues and solutions
|
|
34
|
-
|
|
35
|
-
## Finding What You Need
|
|
36
|
-
|
|
37
|
-
| I want to... | Guide |
|
|
38
|
-
|--------------|-------|
|
|
39
|
-
| Add a new model to my app | [Adding Resources](./adding-resources) |
|
|
7
|
+
| Task | Guide |
|
|
8
|
+
|---|---|
|
|
9
|
+
| Add a new model to my app | [Adding resources](./adding-resources) |
|
|
10
|
+
| Organize code across multiple teams | [Creating packages](./creating-packages) |
|
|
40
11
|
| Protect pages with login | [Authentication](./authentication) |
|
|
41
12
|
| Control who can edit what | [Authorization](./authorization) |
|
|
42
|
-
| Add a "Publish" button | [Custom
|
|
43
|
-
| Show comments under posts | [Nested
|
|
13
|
+
| Add a "Publish" button | [Custom actions](./custom-actions) |
|
|
14
|
+
| Show comments under posts | [Nested resources](./nested-resources) |
|
|
44
15
|
| Separate data by company | [Multi-tenancy](./multi-tenancy) |
|
|
45
|
-
| Add search to a list | [Search and
|
|
46
|
-
| Invite users to an
|
|
47
|
-
|
|
|
16
|
+
| Add search to a list | [Search and filtering](./search-filtering) |
|
|
17
|
+
| Invite users to an organization | [User invites](./user-invites) |
|
|
18
|
+
| Add a profile / account-settings page | [User profile](./user-profile) |
|
|
19
|
+
| Customize colors, branding, styles | [Theming](./theming) |
|
|
20
|
+
| Write tests for resources | [Testing](./testing) |
|
|
48
21
|
| Fix a confusing error | [Troubleshooting](./troubleshooting) |
|
|
49
22
|
|
|
50
|
-
##
|
|
23
|
+
## Guides vs Reference
|
|
24
|
+
|
|
25
|
+
- **Guides** = "how do I do X?" — step-by-step, opinionated, narrative.
|
|
26
|
+
- **[Reference](/reference/)** = "what does X do?" — exhaustive option lookup, concept catalog.
|
|
27
|
+
|
|
28
|
+
When a guide says "see Reference › Foo", it's pointing to the catalog page for the full option/method/DSL list.
|
|
29
|
+
|
|
30
|
+
## New to Plutonium?
|
|
51
31
|
|
|
52
|
-
|
|
32
|
+
Start with the [Tutorial](/getting-started/tutorial/) — an 8-step walkthrough that builds a blog with auth, authorization, custom actions, nested resources, and a customer portal.
|
|
@@ -1,48 +1,43 @@
|
|
|
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. Always call `default_relation_scope(relation)` explicitly — not `super`.
|
|
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
|
+
## Quickest path: `pu:saas:setup`
|
|
16
16
|
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
class Organization < ApplicationRecord
|
|
20
|
-
include Plutonium::Resource::Record
|
|
21
|
-
|
|
22
|
-
has_many :users
|
|
23
|
-
has_many :posts
|
|
24
|
-
end
|
|
17
|
+
```bash
|
|
18
|
+
rails g pu:saas:setup --user Customer --entity Organization
|
|
25
19
|
```
|
|
26
20
|
|
|
27
|
-
|
|
21
|
+
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
22
|
|
|
29
|
-
|
|
23
|
+
See [Reference › Auth › Accounts › SaaS setup](/reference/auth/accounts#saas-setup-pu-saas-setup).
|
|
30
24
|
|
|
31
|
-
|
|
32
|
-
# Direct association (preferred)
|
|
33
|
-
class Post < ResourceRecord
|
|
34
|
-
belongs_to :organization
|
|
35
|
-
belongs_to :user
|
|
36
|
-
end
|
|
25
|
+
## Manual setup
|
|
37
26
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
end
|
|
27
|
+
### 1. Create the entity model
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
rails g pu:res:scaffold Organization name:string:uniq slug:string:uniq --dest=main_app
|
|
43
31
|
```
|
|
44
32
|
|
|
45
|
-
###
|
|
33
|
+
### 2. Add the FK to each tenant-scoped resource
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
rails g pu:res:scaffold Post organization:belongs_to title:string content:text --dest=main_app
|
|
37
|
+
rails db:migrate
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### 3. Scope the portal to the entity
|
|
46
41
|
|
|
47
42
|
```ruby
|
|
48
43
|
# packages/customer_portal/lib/engine.rb
|
|
@@ -51,252 +46,186 @@ module CustomerPortal
|
|
|
51
46
|
include Plutonium::Portal::Engine
|
|
52
47
|
|
|
53
48
|
config.after_initialize do
|
|
54
|
-
# Path strategy - entity ID in URL
|
|
55
49
|
scope_to_entity Organization, strategy: :path
|
|
56
50
|
end
|
|
57
51
|
end
|
|
58
52
|
end
|
|
59
53
|
```
|
|
60
54
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
## Scoping Strategies
|
|
55
|
+
Or pass `--scope=Organization` to `pu:pkg:portal` and the engine wires this automatically.
|
|
64
56
|
|
|
65
|
-
###
|
|
66
|
-
|
|
67
|
-
Entity ID is included in the URL path:
|
|
57
|
+
### 4. Mount the portal
|
|
68
58
|
|
|
69
59
|
```ruby
|
|
70
|
-
config.
|
|
71
|
-
|
|
72
|
-
end
|
|
60
|
+
# config/routes.rb
|
|
61
|
+
mount CustomerPortal::Engine, at: "/customer"
|
|
73
62
|
```
|
|
74
63
|
|
|
75
|
-
|
|
64
|
+
URLs now include the entity id: `/customer/organizations/42/posts`.
|
|
76
65
|
|
|
77
|
-
###
|
|
78
|
-
|
|
79
|
-
Define a method that returns the current entity:
|
|
66
|
+
### 5. Compound uniqueness
|
|
80
67
|
|
|
81
68
|
```ruby
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
end
|
|
86
|
-
```
|
|
87
|
-
|
|
88
|
-
```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
|
|
69
|
+
class Post < ResourceRecord
|
|
70
|
+
belongs_to :organization
|
|
71
|
+
validates :slug, uniqueness: {scope: :organization_id}
|
|
105
72
|
end
|
|
106
73
|
```
|
|
107
74
|
|
|
108
|
-
|
|
75
|
+
🚨 Without the `scope:`, the same slug in different orgs would collide.
|
|
109
76
|
|
|
110
|
-
|
|
77
|
+
## Strategies
|
|
111
78
|
|
|
112
|
-
|
|
79
|
+
### Path strategy (default)
|
|
113
80
|
|
|
114
81
|
```ruby
|
|
115
|
-
|
|
116
|
-
|
|
82
|
+
scope_to_entity Organization, strategy: :path
|
|
83
|
+
# → /organizations/:organization_id/posts
|
|
117
84
|
```
|
|
118
85
|
|
|
119
|
-
###
|
|
120
|
-
|
|
121
|
-
Inside controllers:
|
|
86
|
+
### Custom param key
|
|
122
87
|
|
|
123
88
|
```ruby
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
scoped_entity_class # Organization (the entity class)
|
|
89
|
+
scope_to_entity Organization, strategy: :path, param_key: :org_id
|
|
90
|
+
# → /orgs/:org_id/posts
|
|
127
91
|
```
|
|
128
92
|
|
|
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:
|
|
93
|
+
### Subdomain / session / custom
|
|
136
94
|
|
|
137
95
|
```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
|
|
96
|
+
scope_to_entity Organization, strategy: :current_organization
|
|
144
97
|
```
|
|
145
98
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
### Single Organization per User
|
|
99
|
+
Then implement the method on the portal's controller concern:
|
|
149
100
|
|
|
150
101
|
```ruby
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
102
|
+
module CustomerPortal::Concerns::Controller
|
|
103
|
+
extend ActiveSupport::Concern
|
|
104
|
+
include Plutonium::Portal::Controller
|
|
105
|
+
|
|
106
|
+
private
|
|
154
107
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
108
|
+
def current_organization
|
|
109
|
+
@current_organization ||= Organization.find_by!(subdomain: request.subdomain)
|
|
110
|
+
end
|
|
158
111
|
end
|
|
159
112
|
```
|
|
160
113
|
|
|
161
|
-
|
|
114
|
+
## Three model shapes
|
|
162
115
|
|
|
163
|
-
|
|
164
|
-
class User < ApplicationRecord
|
|
165
|
-
has_many :memberships
|
|
166
|
-
has_many :organizations, through: :memberships
|
|
167
|
-
end
|
|
116
|
+
How tenant scoping resolves depends on how the model relates to the entity. Three shapes, pick the lightest:
|
|
168
117
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
118
|
+
### 1. Direct `belongs_to`
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
class Post < ResourceRecord
|
|
122
|
+
belongs_to :organization
|
|
174
123
|
end
|
|
124
|
+
# Post.associated_with(org) → Post.where(organization: org)
|
|
175
125
|
```
|
|
176
126
|
|
|
177
|
-
|
|
127
|
+
Auto-detected. Use when the model naturally has a direct FK to the entity.
|
|
128
|
+
|
|
129
|
+
### 2. Join table (`belongs_to` AND `belongs_to`)
|
|
178
130
|
|
|
179
131
|
```ruby
|
|
180
|
-
class
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
session[:organization_id] = org.id
|
|
184
|
-
redirect_back(fallback_location: root_path)
|
|
185
|
-
end
|
|
132
|
+
class Membership < ResourceRecord
|
|
133
|
+
belongs_to :user
|
|
134
|
+
belongs_to :organization # auto-detected
|
|
186
135
|
end
|
|
187
136
|
```
|
|
188
137
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
Entity scoping is automatic. The base `Plutonium::Resource::Policy` includes:
|
|
138
|
+
### 3. Grandchild — `has_one :through`
|
|
192
139
|
|
|
193
140
|
```ruby
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
relation.associated_with(entity_scope)
|
|
141
|
+
class Post < ResourceRecord
|
|
142
|
+
belongs_to :user
|
|
143
|
+
has_one :organization, through: :user # ← critical
|
|
198
144
|
end
|
|
199
145
|
```
|
|
200
146
|
|
|
201
|
-
|
|
147
|
+
Auto-detected via `reflect_on_all_associations`. Declaring `has_one :through` is the lightest fix when the path is two hops.
|
|
202
148
|
|
|
203
|
-
|
|
149
|
+
Full mechanics: [Reference › Tenancy › Entity scoping › Three model shapes](/reference/tenancy/entity-scoping#three-model-shapes).
|
|
204
150
|
|
|
205
|
-
|
|
151
|
+
## Custom scope (when the path is polymorphic or needs SQL control)
|
|
206
152
|
|
|
207
153
|
```ruby
|
|
208
|
-
class
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
if user.role == "viewer"
|
|
213
|
-
relation.where(published: true)
|
|
214
|
-
else
|
|
215
|
-
relation
|
|
216
|
-
end
|
|
217
|
-
end
|
|
154
|
+
class Comment < ResourceRecord
|
|
155
|
+
scope :associated_with_organization, ->(org) {
|
|
156
|
+
joins(task: :project).where(projects: {organization_id: org.id})
|
|
157
|
+
}
|
|
218
158
|
end
|
|
219
159
|
```
|
|
220
160
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
Route to different organizations by subdomain:
|
|
161
|
+
Plutonium picks this up **before** trying association detection.
|
|
224
162
|
|
|
225
|
-
|
|
163
|
+
## Accessing the scoped entity
|
|
226
164
|
|
|
227
165
|
```ruby
|
|
228
|
-
#
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
166
|
+
# Controller / views
|
|
167
|
+
current_scoped_entity
|
|
168
|
+
scoped_to_entity?
|
|
169
|
+
|
|
170
|
+
# Policy
|
|
171
|
+
entity_scope
|
|
232
172
|
```
|
|
233
173
|
|
|
234
|
-
|
|
174
|
+
## Policy filtering on top of default
|
|
235
175
|
|
|
236
176
|
```ruby
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
# Controller concern
|
|
241
|
-
def current_organization
|
|
242
|
-
@current_organization ||=
|
|
243
|
-
Organization.find_by!(subdomain: request.subdomain)
|
|
177
|
+
relation_scope do |relation|
|
|
178
|
+
default_relation_scope(relation).where(archived: false)
|
|
244
179
|
end
|
|
245
180
|
```
|
|
246
181
|
|
|
247
|
-
|
|
182
|
+
🚨 `default_relation_scope(relation)` must be called explicitly — not `super`. Bypassing it raises at runtime.
|
|
248
183
|
|
|
249
|
-
|
|
184
|
+
## Cross-tenant operations — super-admin portal
|
|
250
185
|
|
|
251
|
-
|
|
186
|
+
Create a separate portal **without** `scope_to_entity`:
|
|
252
187
|
|
|
253
188
|
```ruby
|
|
254
|
-
# packages/super_admin_portal/lib/engine.rb
|
|
255
189
|
module SuperAdminPortal
|
|
256
190
|
class Engine < Rails::Engine
|
|
257
191
|
include Plutonium::Portal::Engine
|
|
258
|
-
# No scope_to_entity
|
|
192
|
+
# No scope_to_entity — sees all tenants
|
|
259
193
|
end
|
|
260
194
|
end
|
|
261
195
|
```
|
|
262
196
|
|
|
263
|
-
|
|
197
|
+
This portal's policies see everything. Don't enable public signup here.
|
|
264
198
|
|
|
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
|
-
```
|
|
272
|
-
|
|
273
|
-
When `current_scoped_entity` returns `nil`, scoping is bypassed.
|
|
199
|
+
## Multiple associations to the same entity
|
|
274
200
|
|
|
275
|
-
|
|
201
|
+
If a model has two `belongs_to` to the entity class (e.g. `Match belongs_to :home_team, :away_team`), Plutonium raises:
|
|
276
202
|
|
|
277
|
-
|
|
203
|
+
```
|
|
204
|
+
Match has multiple associations to Competition::Team: home_team, away_team.
|
|
205
|
+
Plutonium cannot auto-detect which one to use for entity scoping.
|
|
206
|
+
```
|
|
278
207
|
|
|
279
|
-
|
|
208
|
+
Override on the controller:
|
|
280
209
|
|
|
281
210
|
```ruby
|
|
282
|
-
|
|
211
|
+
class MatchesController < ::ResourceController
|
|
212
|
+
private
|
|
213
|
+
def scoped_entity_association = :home_team
|
|
214
|
+
end
|
|
283
215
|
```
|
|
284
216
|
|
|
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
|
|
217
|
+
## Common issues
|
|
295
218
|
|
|
296
|
-
|
|
219
|
+
- **`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(...)`.
|
|
220
|
+
- **`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.
|
|
221
|
+
- **Records leak across tenants** — likely a missing compound-uniqueness scope on the model. Add `validates :code, uniqueness: {scope: :organization_id}`.
|
|
222
|
+
- **Forms show the entity field anyway** — check `present_scoped_entity?` / `submit_scoped_entity?` on the controller (defaults are `false`).
|
|
223
|
+
- **Want to bypass scoping in one place** — use `skip_default_relation_scope!` explicitly, NOT a silent `where` bypass.
|
|
297
224
|
|
|
298
225
|
## Related
|
|
299
226
|
|
|
300
|
-
- [
|
|
301
|
-
- [
|
|
302
|
-
- [
|
|
227
|
+
- [Reference › Tenancy › Entity scoping](/reference/tenancy/entity-scoping) — full surface
|
|
228
|
+
- [Reference › Behavior › Policies](/reference/behavior/policies) — `relation_scope` syntax
|
|
229
|
+
- [Reference › App › Portals](/reference/app/portals) — `scope_to_entity` engine config
|
|
230
|
+
- [Nested resources](./nested-resources) — parent scoping (takes precedence over entity scoping)
|
|
231
|
+
- [User invites](./user-invites) — invitation-based membership onboarding
|