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, asPlutonium::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, plus a customassociated_with_<entity>scope on the model. - The entity field can stay in
permitted_attributes_for_*. Entity-scoped portals strip it from the form and params and set it from the current entity.
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
endThe entity field in permitted attributes โ
Listing the entity association in the policy is fine, and usually what you want:
def permitted_attributes_for_create
[:organization, :name]
endIn an entity-scoped portal the controller drops the entity field from both the rendered form/display and the accepted params, then sets it from current_scoped_entity on create and update. The same policy then works unchanged in an unscoped portal (e.g. admin), where organization stays a normal, selectable input. So you don't need a portal-specific policy just to remove the entity field, and a forged organization_id in the request is overwritten.
What gets removed is the portal's param key plus the scoping association and its _id (organization_scoped, organization_scoped_id, organization, organization_id). Other associations to the same class are untouched. To show the entity field anyway, override present_scoped_entity? on the controller (and submit_scoped_entity? to let users change it; it defaults to present_scoped_entity?).
Multiple associations to the same entity class โ
Example: Match belongs_to :home_team, :away_team both pointing at Team. Two pieces of code pick "the" entity association, and both refuse to guess. Configure each one.
The controller raises on any scoped page that renders fields:
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 portal controller:
class MatchesController < ::ResourceController
private
def scoped_entity_association = :home_team
endThe model (associated_with) raises Plutonium::Resource::Record::AssociatedWith::AmbiguousAssociationError for the same reason, so define the scope it asks for:
class Match < ResourceRecord
belongs_to :home_team, class_name: "Competition::Team"
belongs_to :away_team, class_name: "Competition::Team"
scope :associated_with_competition_team, ->(team) { where(home_team: team) }
endWith both in place, the policy can permit [:home_team, :away_team, ...]: in the scoped portal home_team is stripped and forced to the current entity, while away_team stays a select the user fills in.
param_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, it bypasses
default_relation_scope. Add the association path to the model instead. - Multiple associations to the same entity class. Override
scoped_entity_associationon the controller and add anassociated_with_<entity>scope on the model. 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
