Skip to content

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. Overriding relation_scope with where(organization: ...) or manual joins to the entity triggers verify_default_relation_scope_applied!; make sure the chain ends up calling default_relation_scope(relation) somewhere (explicitly, or via super to a parent that calls it, as Plutonium::Resource::Policy does).
  • Fix the MODEL, not the policy. If associated_with can't resolve, declare an association path (belongs_to, has_one :through) OR a custom associated_with_<entity> scope on the model. Never paper over it with a where in 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_association on the controller, plus a custom associated_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 โ€‹

PieceRoleWhere
PortalDeclares the entity class and resolution strategyscope_to_entity Organization, strategy: :path in the engine
PolicyApplies the scope to every collection querydefault_relation_scope(relation) (auto-called)
ModelResolves the scope pathDirect 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:

  1. Custom scope associated_with_<entity_name> (e.g. associated_with_organization): highest priority, full SQL control.
  2. Direct belongs_to to the entity class: WHERE <entity>_id = ?, most efficient.
  3. has_one / has_one :through to the entity class: JOIN + WHERE, auto-detected via reflect_on_all_associations.
  4. Reverse has_many from 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) โ€‹

ruby
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 efficient

Auto-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:

ruby
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:

ruby
class ProjectMember < ResourceRecord
  belongs_to :project
  belongs_to :user
  has_one :organization, through: :project   # โ† enables auto-scoping
end

Now ProjectMember.associated_with(org) resolves via the has_one :through.

Shape 3: Grandchild (multi-hop via has_one :through) โ€‹

ruby
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
end

Task.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).
ruby
class Comment < ResourceRecord
  scope :associated_with_organization, ->(org) do
    joins(task: :project).where(projects: {organization_id: org.id})
  end
end

Plutonium picks this up before trying association detection.

relation_scope: safe override patterns โ€‹

default_relation_scope(relation) does two things:

  1. If a parent is present (nested resource), scopes via the parent association.
  2. Otherwise, applies relation.associated_with(entity_scope).

Correct โ€‹

ruby
# โœ… 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)
end

Wrong โ€‹

ruby
# โŒ 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:

ruby
relation_scope do |relation|
  skip_default_relation_scope!
  relation
end

Before 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) โ€‹

ruby
module CustomerPortal
  class Engine < Rails::Engine
    include Plutonium::Portal::Engine

    config.after_initialize do
      scope_to_entity Organization, strategy: :path
    end
  end
end

Routes 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.) โ€‹

ruby
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_organization

The 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):

ruby
scope_to_entity Organization, strategy: :path, param_key: :org_id
# โ†’ params[:org_id] instead of params[:organization_scoped]

Accessing the scoped entity โ€‹

ruby
# Controller / views
current_scoped_entity     # => current Organization
scoped_to_entity?         # => true / false

# Policy
entity_scope              # => current Organization

Cross-tenant operations โ€‹

Super-admin portal: no scoping โ€‹

Create a separate portal without scope_to_entity:

ruby
module SuperAdminPortal
  class Engine < Rails::Engine
    include Plutonium::Portal::Engine

    # No scope_to_entity: sees all tenants
  end
end

This portal's policies see everything. Don't enable public signup here.

Conditional scoping โ€‹

ruby
class PostPolicy < ResourcePolicy
  relation_scope do |relation|
    return default_relation_scope(relation).where(category: :public) if user.guest?
    default_relation_scope(relation)
  end
end

The entity field in permitted attributes โ€‹

Listing the entity association in the policy is fine, and usually what you want:

ruby
def permitted_attributes_for_create
  [:organization, :name]
end

In 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:

ruby
class MatchesController < ::ResourceController
  private
  def scoped_entity_association = :home_team
end

The model (associated_with) raises Plutonium::Resource::Record::AssociatedWith::AmbiguousAssociationError for the same reason, so define the scope it asks for:

ruby
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) }
end

With 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:

ruby
# 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
end

How the pieces fit together โ€‹

  1. An admin opens /organizations/42/projects.
  2. Portal's scope_to_entity Organization, strategy: :path extracts 42, loads the Organization, sets current_scoped_entity.
  3. The controller calls the policy. The policy's inherited relation_scope calls default_relation_scope(relation).
  4. default_relation_scope has no parent (top-level nested-from-portal), so it calls relation.associated_with(current_scoped_entity).
  5. Project.associated_with(org) resolves via the direct belongs_to :organization โ†’ Project.where(organization: org).
  6. 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:

ruby
class Property < ResourceRecord
  belongs_to :organization
  validates :code, uniqueness: {scope: :organization_id}    # โ† critical
end

Without 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_association on the controller and add an associated_with_<entity> scope on the model.
  • param_key differs from association name. Fine: Plutonium finds the association by class.
  • Forgetting compound uniqueness. A unique constraint on :code alone leaks across tenants.
  • "Temporary" where bypass for debugging. Use skip_default_relation_scope! explicitly; never leave a where bypass in code.

Released under the MIT License.