Entity Scoping
Multi-tenant data isolation. Built on three cooperating pieces — portal, policy, model — that together ensure queries never leak across tenants.
🚨 Critical
- Never bypass
default_relation_scope. Overridingrelation_scopewithwhere(organization: ...)or manual joins to the entity triggersverify_default_relation_scope_applied!. Always calldefault_relation_scope(relation)explicitly. - Don't rely on
superinsiderelation_scope— calldefault_relation_scope(relation)by name. - Fix the MODEL, not the policy. If
associated_withcan't resolve, declare an association path (belongs_to,has_one :through) OR a customassociated_with_<entity>scope on the model. Never paper over it with awherein the policy. - Compound uniqueness scoped to the tenant FK —
validates :code, uniqueness: {scope: :organization_id}. - Multiple associations to the same entity class require overriding
scoped_entity_associationon the controller.
The three pieces
| Piece | Role | Where |
|---|---|---|
| Portal | Declares the entity class and resolution strategy | scope_to_entity Organization, strategy: :path in the engine |
| Policy | Applies the scope to every collection query | default_relation_scope(relation) (auto-called) |
| Model | Resolves the scope path | Direct belongs_to, has_one :through, or custom scope |
default_relation_scope is enforced — if you override relation_scope without calling it, verify_default_relation_scope_applied! raises at runtime.
associated_with resolution
Model.associated_with(entity) resolves in this order:
- Custom scope
associated_with_<entity_name>(e.g.associated_with_organization) — highest priority, full SQL control. - Direct
belongs_toto the entity class —WHERE <entity>_id = ?, most efficient. has_one/has_one :throughto the entity class — JOIN + WHERE, auto-detected viareflect_on_all_associations.- Reverse
has_manyfrom the entity — JOIN required, logs a warning (less efficient).
If none apply:
Could not resolve the association between 'Model' and 'Entity'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.
Three model shapes
The associated_with resolver handles three common shapes. Pick the lightest that fits.
Shape 1: Direct child (belongs_to the entity)
class Organization < ResourceRecord
has_many :projects
end
class Project < ResourceRecord
belongs_to :organization
end
Project.associated_with(org)
# => Project.where(organization: org) — simple WHERE, most efficientAuto-detected. Use this when the model naturally has a direct FK to the entity.
Shape 2: Join table (membership-style)
A join table linking users to entities, where the entity is reachable via one of the belongs_to:
class User < ResourceRecord
has_many :memberships
has_many :organizations, through: :memberships
end
class Organization < ResourceRecord
has_many :memberships
has_many :users, through: :memberships
end
class Membership < ResourceRecord
belongs_to :user
belongs_to :organization # ← auto-detection finds :organization via belongs_to
end
Membership.associated_with(org)
# => Membership.where(organization: org)If the join table is itself a parent and the scoped target is two hops away, add has_one :through:
class ProjectMember < ResourceRecord
belongs_to :project
belongs_to :user
has_one :organization, through: :project # ← enables auto-scoping
endNow ProjectMember.associated_with(org) resolves via the has_one :through.
Shape 3: Grandchild (multi-hop via has_one :through)
class Organization < ResourceRecord
has_many :projects
end
class Project < ResourceRecord
belongs_to :organization
has_many :tasks
end
class Task < ResourceRecord
belongs_to :project
has_one :organization, through: :project # ← critical
end
# Deeper
class Comment < ResourceRecord
belongs_to :task
has_one :project, through: :task
has_one :organization, through: :project # ← enables auto-scoping at 3 hops
endTask.associated_with(org) and Comment.associated_with(org) both auto-resolve.
Declaring has_one :through is the lightest fix
For grandchildren, the has_one :through on the model is all you need — associated_with finds it automatically. No policy override needed.
When to fall back to a custom scope
Use a custom associated_with_<entity> scope when:
- The path is polymorphic.
- The path needs conditional logic.
- You want explicit SQL for performance (e.g. avoid a multi-join chain).
class Comment < ResourceRecord
scope :associated_with_organization, ->(org) do
joins(task: :project).where(projects: {organization_id: org.id})
end
endPlutonium picks this up before trying association detection.
relation_scope — safe override patterns
default_relation_scope(relation) does two things:
- If a parent is present (nested resource), scopes via the parent association.
- Otherwise, applies
relation.associated_with(entity_scope).
Correct
# ✅ Best — don't override at all. The inherited scope already calls default_relation_scope.
# ✅ Extra filters on top
relation_scope do |relation|
default_relation_scope(relation).where(archived: false)
end
# ✅ Role-based
relation_scope do |relation|
relation = default_relation_scope(relation)
user.admin? ? relation : relation.where(author: user)
endWrong
# ❌ Manually filtering by entity — bypasses default_relation_scope
relation_scope { |r| r.where(organization: current_scoped_entity) }
# ❌ Manual joins — same problem
relation_scope { |r| r.joins(:project).where(projects: {organization_id: current_scoped_entity.id}) }
# ❌ Missing default_relation_scope entirely — raises at runtime
relation_scope { |r| r.where(published: true) }Don't use super
super inside relation_scope is unreliable — its semantics depend on how ActionPolicy's DSL registered the scope. Call default_relation_scope(relation) by name.
Intentionally skipping the scope
Rare, but possible:
relation_scope do |relation|
skip_default_relation_scope!
relation
endBefore reaching for this, consider a separate, unscoped portal.
Portal entity strategies
The portal declares how the current entity is resolved from the request.
Path strategy (most common)
module CustomerPortal
class Engine < Rails::Engine
include Plutonium::Portal::Engine
config.after_initialize do
scope_to_entity Organization, strategy: :path
end
end
endRoutes become /organizations/:organization_id/posts. The portal extracts params[:organization_id] and loads the entity automatically.
Custom strategy (subdomain, session, etc.)
module CustomerPortal::Concerns::Controller
extend ActiveSupport::Concern
include Plutonium::Portal::Controller
private
def current_organization
@current_organization ||= Organization.find_by!(subdomain: request.subdomain)
end
end
# Engine
scope_to_entity Organization, strategy: :current_organizationThe strategy symbol must match a method name on the controller concern.
Custom param key
When the param name differs from the entity model name:
scope_to_entity Organization, strategy: :path, param_key: :org_id
# → /orgs/:org_id/postsAccessing the scoped entity
# Controller / views
current_scoped_entity # => current Organization
scoped_to_entity? # => true / false
# Policy
entity_scope # => current OrganizationCross-tenant operations
Super-admin portal — no scoping
Create a separate portal without scope_to_entity:
module SuperAdminPortal
class Engine < Rails::Engine
include Plutonium::Portal::Engine
# No scope_to_entity — sees all tenants
end
endThis portal's policies see everything. Don't enable public signup here.
Conditional scoping
class PostPolicy < ResourcePolicy
relation_scope do |relation|
return default_relation_scope(relation).where(category: :public) if user.guest?
default_relation_scope(relation)
end
endMultiple associations to the same entity class
Example: Match belongs_to :home_team, :away_team both pointing at Team. Plutonium raises:
Match has multiple associations to Competition::Team: home_team, away_team.
Plutonium cannot auto-detect which one to use for entity scoping.
Override `scoped_entity_association` in your controller to specify the association.Override on the controller:
class MatchesController < ::ResourceController
private
def scoped_entity_association = :home_team
endparam_key differs from association name
Plutonium matches by class, not param key:
# Portal config
scope_to_entity Competition::Team, param_key: :team
# Model — association name differs from param_key, but Plutonium finds by class
class Match < ApplicationRecord
belongs_to :competition_team # ← Plutonium auto-detects this
endHow the pieces fit together
- An admin opens
/organizations/42/projects. - Portal's
scope_to_entity Organization, strategy: :pathextracts42, loads theOrganization, setscurrent_scoped_entity. - The controller calls the policy. The policy's inherited
relation_scopecallsdefault_relation_scope(relation). default_relation_scopehas no parent (top-level nested-from-portal), so it callsrelation.associated_with(current_scoped_entity).Project.associated_with(org)resolves via the directbelongs_to :organization→Project.where(organization: org).- Only that organization's projects render. Records from other orgs are invisible.
Any model that can't be reached from the entity via these rules MUST declare a has_one :through or a custom scope.
Compound uniqueness
Always scope tenant-affecting uniqueness constraints:
class Property < ResourceRecord
belongs_to :organization
validates :code, uniqueness: {scope: :organization_id} # ← critical
endWithout the scope, uniqueness leaks across tenants — Org A and Org B could collide on the same code.
Gotchas
- Policy tries to filter by entity directly. Wrong — bypasses
default_relation_scope. Add the association path to the model instead. superinsiderelation_scope. Unreliable. Usedefault_relation_scope(relation)explicitly.- Multiple associations to the same entity class. Override
scoped_entity_association. param_keydiffers from association name. Fine — Plutonium finds the association by class.- Forgetting compound uniqueness. A unique constraint on
:codealone leaks across tenants. - "Temporary"
wherebypass for debugging. Useskip_default_relation_scope!explicitly — never leave awherebypass in code.
Related
- Nested resources — parent scoping takes precedence over entity scoping
- Invites — membership-based onboarding
- Resource › Model —
associated_with, model conventions - Behavior › Policy —
relation_scopesyntax - App › Portals —
scope_to_entityengine config
