How Multi-Tenancy with acts_as_tenant Works in PropertyWebBuilder
PropertyWebBuilder uses the acts_as_tenant gem to enforce automatic row-level tenant isolation, resolving the current website from incoming request headers or domains and scoping all database queries via a custom RequiresTenant concern in the PwbTenant namespace.
PropertyWebBuilder is an open-source Rails-based real estate CMS that implements multi-tenancy through the acts_as_tenant gem. The architecture isolates data per tenant (website) at the ORM level while supporting cross-tenant operations for administrative tasks.
Tenant Resolution via the SubdomainTenant Concern
Tenant identification happens in app/controllers/concerns/subdomain_tenant.rb through the set_current_website_from_request method. This concern is included in administrative controllers like SiteAdminController and executes as a before_action on every request.
The resolution logic follows a strict priority:
- Header override – Checks for
X-Website-Slugheader to fetch a specificPwb::Website - Host lookup – Calls
Pwb::Website.find_by_host(host)to match custom domains or subdomains - Fallback – Defaults to the first website in the database if no match is found
- Binding – Sets
ActsAsTenant.current_tenantto the resolved website
# app/controllers/concerns/subdomain_tenant.rb
def set_current_website_from_request
slug = request.headers["X-Website-Slug"]
if slug.present?
Pwb::Current.website = Pwb::Website.find_by(slug: slug)
ActsAsTenant.current_tenant = Pwb::Current.website and return if Pwb::Current.website
end
host = request.host.to_s.downcase
Pwb::Current.website = Pwb::Website.find_by_host(host) || Pwb::Website.first
ActsAsTenant.current_tenant = Pwb::Current.website
end
The SiteAdminController explicitly calls set_tenant_from_subdomain to ensure ActsAsTenant.current_tenant is always synchronized with current_website before any tenant-scoped queries execute.
Global ActsAsTenant Configuration
The gem's behavior is configured in config/initializers/acts_as_tenant.rb with require_tenant set to false. This permissive setting allows non-tenant-scoped models to operate without a tenant context, while the PwbTenant:: namespace enforces strict tenant presence through its own concern.
# config/initializers/acts_as_tenant.rb
ActsAsTenant.configure do |config|
config.require_tenant = false # only the PwbTenant:: models require a tenant
end
This configuration creates a hybrid architecture where core models (Pwb::Website, Pwb::User) remain globally accessible, while content models (PwbTenant::SaleListing, PwbTenant::WebsitePhoto) are strictly isolated.
Model-Level Tenant Enforcement with PwbTenant
All tenant-isolated models reside under the PwbTenant namespace and include the RequiresTenant concern from app/models/concerns/pwb_tenant/requires_tenant.rb. This concern injects a default_scope that validates tenant presence before executing queries.
# app/models/concerns/pwb_tenant/requires_tenant.rb
included do
default_scope do
if ActsAsTenant.current_tenant.nil? && !ActsAsTenant.unscoped?
raise ActsAsTenant::Errors::NoTenantSet,
"#{name} requires a tenant to be set. Use Pwb::#{name.demodulize} for cross‑tenant queries, " \
"or set a tenant with ActsAsTenant.with_tenant(website) { … }"
end
all
end
end
Individual models declare acts_as_tenant :website with the specific class name. For example, app/models/pwb_tenant/website_photo.rb inherits from the global Pwb::WebsitePhoto but enforces tenant isolation:
# app/models/pwb_tenant/website_photo.rb
class WebsitePhoto < Pwb::WebsitePhoto
include RequiresTenant
acts_as_tenant :website, class_name: 'Pwb::Website'
end
When a tenant is set, queries like PwbTenant::WebsitePhoto.all automatically generate SQL with WHERE "pwb_website_photos"."website_id" = <current_website_id>, eliminating the risk of cross-tenant data leaks.
Querying and Creating Tenant-Scoped Records
Controllers inheriting from SiteAdminController automatically scope all database interactions to the resolved tenant. Developers never manually append where(website_id: ...) clauses.
Fetching records within the current tenant:
class ListingsController < SiteAdminController
def index
# Automatically limited to the current website
@listings = PwbTenant::SaleListing.order(created_at: :desc)
end
end
Creating new tenant-scoped records:
def create
@listing = PwbTenant::SaleListing.new(listing_params)
@listing.save! # website_id is set automatically via acts_as_tenant
redirect_to @listing
end
Running cross-tenant queries:
Use the base Pwb:: namespace models to bypass tenant scoping for administrative reports or rake tasks:
# Use the non‑tenant version to bypass scoping
Pwb::SaleListing.where('price > ?', 500_000) # no website_id filter
Temporarily switching tenants:
Administrators can scope queries to a different tenant using ActsAsTenant.with_tenant:
ActsAsTenant.with_tenant(other_website) do
PwbTenant::Message.where(read: false) # scoped to `other_website`
end
Summary
- Tenant resolution occurs in
app/controllers/concerns/subdomain_tenant.rbvia headers, domains, or fallback logic - Global configuration in
config/initializers/acts_as_tenant.rbsetsrequire_tenant = falseto allow hybrid tenant/global models - Enforcement happens through
app/models/concerns/pwb_tenant/requires_tenant.rb, which adds a default scope raising errors when tenants are missing - Isolation is automatic for all
PwbTenant::models viaacts_as_tenant :website, injectingwebsite_idfilters into every query - Cross-tenant operations use the base
Pwb::namespace orActsAsTenant.with_tenantblocks for temporary context switching
Frequently Asked Questions
How does PropertyWebBuilder determine which tenant to use for an incoming request?
The SubdomainTenant concern in app/controllers/concerns/subdomain_tenant.rb checks the X-Website-Slug header first, then falls back to matching the request host against Pwb::Website.find_by_host, and finally defaults to the first website in the database if no match is found.
What happens if a PwbTenant model is queried without setting a tenant first?
The RequiresTenant concern raises ActsAsTenant::Errors::NoTenantSet because its default_scope validates that ActsAsTenant.current_tenant is present before executing the query, preventing accidental cross-tenant data exposure.
Can administrators query data across all tenants in PropertyWebBuilder?
Yes. Use the global namespace models (e.g., Pwb::SaleListing instead of PwbTenant::SaleListing) to bypass tenant scoping entirely, or use ActsAsTenant.with_tenant(other_website) { ... } to temporarily scope operations to a specific tenant.
Where is the acts_as_tenant gem configured to allow some models without tenants?
The initializer at config/initializers/acts_as_tenant.rb sets config.require_tenant = false, allowing models outside the PwbTenant namespace to operate without a tenant context while the RequiresTenant concern enforces tenant presence only for namespace-specific models.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →