Multi-tenancy โ
Isolate data by organization, account, or any other "entity". Plutonium handles the URL strategy, query scoping, form injection, and belongs_to auto-detection automatically.
Goal โ
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.
๐จ Critical โ
- Never bypass
default_relation_scope. Overridingrelation_scopewithwhere(organization: ...)or manual joins triggersverify_default_relation_scope_applied!at runtime. Make suredefault_relation_scope(relation)is called somewhere in the chain โ explicitly here, or viasuper(relation)(the framework'sPlutonium::Resource::Policybase calls it for you). - Always declare an association path from the model to the entity. Direct
belongs_to,has_one :through, or a customassociated_with_<entity>scope. Ifassociated_withcan't resolve, fix the model, not the policy. - Compound uniqueness scoped to the tenant FK.
validates :code, uniqueness: {scope: :organization_id}โ without this, uniqueness leaks across tenants.
After login, users with memberships in multiple entities land on a workspace selector:

Picking one lands them on the entity-scoped dashboard โ note the entity slug in the URL:

Quickest path: pu:saas:setup โ
rails g pu:saas:setup --user Customer --entity OrganizationThis 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.
See Reference โบ Auth โบ Accounts โบ SaaS setup.
Manual setup โ
1. Create the entity model โ
rails g pu:res:scaffold Organization name:string:uniq slug:string:uniq --dest=main_app2. Add the FK to each tenant-scoped resource โ
rails g pu:res:scaffold Post organization:belongs_to title:string content:text --dest=main_app
rails db:prepare3. Scope the portal to the entity โ
# packages/customer_portal/lib/engine.rb
module CustomerPortal
class Engine < Rails::Engine
include Plutonium::Portal::Engine
config.after_initialize do
scope_to_entity Organization, strategy: :path
end
end
endOr pass --scope=Organization to pu:pkg:portal and the engine wires this automatically.
4. Mount the portal โ
# config/routes.rb
mount CustomerPortal::Engine, at: "/customer"URLs now include the entity id as the first path segment after the mount: /customer/42/posts. The underlying param name is organization_scoped (Plutonium suffixes _scoped to avoid a name collision with any belongs_to :organization on child models โ params[:organization_scoped] vs params[:organization]). Pass param_key: to scope_to_entity if you want a different param name.
5. Compound uniqueness โ
class Post < ResourceRecord
belongs_to :organization
validates :slug, uniqueness: {scope: :organization_id}
end๐จ Without the scope:, the same slug in different orgs would collide.
Strategies โ
Path strategy (default) โ
scope_to_entity Organization, strategy: :path
# โ /<mount>/:organization_scoped/posts (request URL: /<mount>/42/posts)Custom param key โ
scope_to_entity Organization, strategy: :path, param_key: :org_id
# โ /<mount>/:org_id/posts (same URL shape, just renames params[:organization_scoped] โ params[:org_id])Subdomain / session / custom โ
scope_to_entity Organization, strategy: :current_organizationThen implement the method on the portal's controller concern:
module CustomerPortal::Concerns::Controller
extend ActiveSupport::Concern
include Plutonium::Portal::Controller
private
def current_organization
@current_organization ||= Organization.find_by!(subdomain: request.subdomain)
end
endThree model shapes โ
How tenant scoping resolves depends on how the model relates to the entity. Three shapes, pick the lightest:
1. Direct belongs_to โ
class Post < ResourceRecord
belongs_to :organization
end
# Post.associated_with(org) โ Post.where(organization: org)Auto-detected. Use when the model naturally has a direct FK to the entity.
2. Join table (belongs_to AND belongs_to) โ
class Membership < ResourceRecord
belongs_to :user
belongs_to :organization # auto-detected
end3. Grandchild โ has_one :through โ
class Post < ResourceRecord
belongs_to :user
has_one :organization, through: :user # โ critical
endAuto-detected via reflect_on_all_associations. Declaring has_one :through is the lightest fix when the path is two hops.
Full mechanics: Reference โบ Tenancy โบ Entity scoping โบ Three model shapes.
Custom scope (when the path is polymorphic or needs SQL control) โ
class Comment < ResourceRecord
scope :associated_with_organization, ->(org) {
joins(task: :project).where(projects: {organization_id: org.id})
}
endPlutonium picks this up before trying association detection.
Accessing the scoped entity โ
# Controller / views
current_scoped_entity
scoped_to_entity?
# Policy
entity_scopePolicy filtering on top of default โ
relation_scope do |relation|
default_relation_scope(relation).where(archived: false)
end๐จ default_relation_scope(relation) must be called somewhere in the chain โ otherwise the runtime verification raises. super(relation) works when extending Plutonium::Resource::Policy directly (its block calls default_relation_scope); call default_relation_scope by name when you're not chaining via super.
Cross-tenant operations โ super-admin portal โ
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.
Multiple associations to the same entity โ
If a model has two belongs_to to the entity class (e.g. Match belongs_to :home_team, :away_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 on the controller:
class MatchesController < ::ResourceController
private
def scoped_entity_association = :home_team
endCommon issues โ
verify_default_relation_scope_applied!raises โ your customrelation_scopedoesn't calldefault_relation_scope(relation). Fix by composing:default_relation_scope(relation).where(...).Could not resolve the association between 'Model' and 'Entity'โ the model has no path to the entity. Fix on the model (declarehas_one :throughor a customassociated_with_<entity>scope). Never paper over withwherein the policy.- Records leak across tenants โ likely a missing compound-uniqueness scope on the model. Add
validates :code, uniqueness: {scope: :organization_id}. - Forms show the entity field anyway โ check
present_scoped_entity?/submit_scoped_entity?on the controller (defaults arefalse). - Want to bypass scoping in one place โ use
skip_default_relation_scope!explicitly, NOT a silentwherebypass.
Related โ
- Reference โบ Tenancy โบ Entity scoping โ full surface
- Reference โบ Behavior โบ Policies โ
relation_scopesyntax - Reference โบ App โบ Portals โ
scope_to_entityengine config - Nested resources โ parent scoping (takes precedence over entity scoping)
- User invites โ invitation-based membership onboarding
