Multi-tenancy
This guide covers isolating data by organization, account, or other entity.
Overview
Multi-tenancy means each tenant (organization, company, account) sees only their own data. Plutonium supports this through:
- Entity Scoping - Automatic query filtering via portal configuration
- Path or Custom Strategies - Flexible entity resolution
- Policy Integration - Authorization automatically respects tenancy
Setting Up Multi-tenancy
1. Create the Entity Model
# app/models/organization.rb
class Organization < ApplicationRecord
include Plutonium::Resource::Record
has_many :users
has_many :posts
end2. Add Entity Reference to Resources
Resources must have an association path to the entity:
# Direct association (preferred)
class Post < ResourceRecord
belongs_to :organization
belongs_to :user
end
# Through association
class Comment < ResourceRecord
belongs_to :post
has_one :organization, through: :post
end3. Configure the Portal Engine
# packages/customer_portal/lib/engine.rb
module CustomerPortal
class Engine < Rails::Engine
include Plutonium::Portal::Engine
config.after_initialize do
# Path strategy - entity ID in URL
scope_to_entity Organization, strategy: :path
end
end
endRoutes become: /organizations/:organization_id/posts
Scoping Strategies
Path Strategy (Default)
Entity ID is included in the URL path:
config.after_initialize do
scope_to_entity Organization, strategy: :path
endThe user must have access to the organization (via associated_with scope).
Custom Strategy
Define a method that returns the current entity:
# packages/customer_portal/lib/engine.rb
config.after_initialize do
scope_to_entity Organization, strategy: :current_organization
end# packages/customer_portal/app/controllers/customer_portal/concerns/controller.rb
module CustomerPortal
module Concerns
module Controller
extend ActiveSupport::Concern
include Plutonium::Portal::Controller
include Plutonium::Auth::Rodauth(:user)
private
# Method name must match strategy
def current_organization
@current_organization ||= current_user.organization
end
end
end
endHow Entity Scoping Works
Automatic Query Filtering
All resource queries are automatically scoped via associated_with:
# In a scoped portal
Post.all # Returns only current entity's postsHelper Methods
Inside controllers:
current_scoped_entity # The current Organization/Account/etc.
scoped_to_entity? # true if scoping is active
scoped_entity_class # Organization (the entity class)Model Requirements
Models must have an association path to the scoped entity. Plutonium automatically resolves:
- Direct belongs_to -
Post belongs_to :organization - Through association -
Comment has_one :organization, through: :post - Custom scope - For complex cases, define a named scope:
class AuditLog < ResourceRecord
# When automatic resolution fails, define this scope
scope :associated_with_organization, ->(org) {
joins(:user).where(users: { organization_id: org.id })
}
endUser Membership Patterns
Single Organization per User
class User < ApplicationRecord
belongs_to :organization
end
# Custom strategy
def current_organization
current_user.organization
endMultiple Organizations per User
class User < ApplicationRecord
has_many :memberships
has_many :organizations, through: :memberships
end
# Custom strategy with session storage
def current_organization
@current_organization ||=
current_user.organizations.find_by(id: session[:organization_id]) ||
current_user.organizations.first
endOrganization Switcher
class OrganizationSwitchController < ApplicationController
def update
org = current_user.organizations.find(params[:id])
session[:organization_id] = org.id
redirect_back(fallback_location: root_path)
end
endPolicy Integration
Entity scoping is automatic. The base Plutonium::Resource::Policy includes:
relation_scope do |relation|
next relation unless entity_scope
relation.associated_with(entity_scope)
endThe entity_scope context is automatically set to current_scoped_entity.
Additional Filtering
Add role-based filtering on top of entity scoping:
class PostPolicy < ResourcePolicy
relation_scope do |relation|
relation = super(relation) # Apply entity scoping first
if user.role == "viewer"
relation.where(published: true)
else
relation
end
end
endSubdomain-Based Tenancy
Route to different organizations by subdomain:
Routes
# config/routes.rb
constraints subdomain: /[a-z]+/ do
mount CustomerPortal::Engine, at: "/"
endCustom Strategy
# Engine configuration
scope_to_entity Organization, strategy: :current_organization
# Controller concern
def current_organization
@current_organization ||=
Organization.find_by!(subdomain: request.subdomain)
endCross-Tenant Operations
Sometimes admins need to see all data:
Super Admin Portal (No Scoping)
# packages/super_admin_portal/lib/engine.rb
module SuperAdminPortal
class Engine < Rails::Engine
include Plutonium::Portal::Engine
# No scope_to_entity = sees everything
end
endConditional Scoping
# Custom strategy that returns nil for super admins
def current_organization
return nil if current_user.super_admin?
current_user.organization
endWhen current_scoped_entity returns nil, scoping is bypassed.
Data Isolation Patterns
Shared Database, Scoped Queries (Recommended)
All tenants share tables, queries filter by entity association:
scope_to_entity Organization, strategy: :pathPros:
- Simple setup
- Easy migrations
- Efficient for many small tenants
Cons:
- Risk of data leakage if scoping fails
- Complex queries for cross-tenant reports
Schema-Based Isolation
Each tenant has separate database schema. This requires additional setup beyond Plutonium's built-in scoping.
