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:

  1. Header override – Checks for X-Website-Slug header to fetch a specific Pwb::Website
  2. Host lookup – Calls Pwb::Website.find_by_host(host) to match custom domains or subdomains
  3. Fallback – Defaults to the first website in the database if no match is found
  4. Binding – Sets ActsAsTenant.current_tenant to 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

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →