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!โ make sure the chain ends up callingdefault_relation_scope(relation)somewhere (explicitly, or viasuperto a parent that calls it โPlutonium::Resource::Policydoes). - 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) }super works too
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).
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 /<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.
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 โ
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:
mount CustomerPortal::Engine, at: "/customer"
# โ /customer/:organization_scoped/posts (route definition)
# โ /customer/42/posts (request URL)Override when you want a different param name (e.g. for readability when reading params in custom controllers):
scope_to_entity Organization, strategy: :path, param_key: :org_id
# โ params[:org_id] instead of params[:organization_scoped]Accessing 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. - 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
