mecha_tenant enforces closed-by-default multi-tenant isolation in Rails applications. It combines a YAML model registry, explicit tenant_scope query helpers, static Prism AST controller analysis, and behavioral IDOR route probing.
Add this line to your application's Gemfile:
gem "mecha_tenant"Or if developing locally:
gem "mecha_tenant", path: "~/code/mecha_tenant"And then execute:
bundle installClassify every ApplicationRecord model as either tenant (with an explicit ownership description) or global:
tenant_root: "User"
debug_colorization:
tenant: :light_purple
global: :light_cyan
skipped: :yellow
enforced: :green
violation: :light_red
unmapped: :dark_grey
models:
- model: "User"
classification: "tenant"
ownership: "self (id == current_user.id)"
- model: "EmailMessage"
classification: "tenant"
ownership: "email_account.user_id"
- model: "SvcOption"
classification: "global"
notes: "Global service configuration options"Create config/initializers/mecha_tenant.rb to define tenant scopes for each tenant-classified model and include the controller concern:
# config/initializers/mecha_tenant.rb
require "mecha_tenant"
MechaTenant.configure do |config|
config.config_path = Rails.root.join("config/mecha_tenant.yml")
config.tenant_method = :current_user
config.base_model_class = "ApplicationRecord"
config.controller_paths = [Rails.root.join("app/controllers")]
config.define_scope "User" do |user|
User.where(id: user.id)
end
config.define_scope "EmailMessage" do |user|
EmailMessage.where(email_account_id: user.email_accounts.select(:id))
end
end
ActiveSupport.on_load(:action_controller_base) do
include MechaTenant::Controller
endIn controllers, query tenant models via tenant_scope(Model) or through associations on current_user. Direct naked model references (e.g. EmailMessage.find(params[:id])) are flagged by the static AST auditor unless explicitly exempted:
class EmailsController < ApplicationController
def show
@message = tenant_scope(EmailMessage).find(params[:id])
end
endFor admin or public endpoints that intentionally operate without tenant scoping, declare skip_tenant_enforcement! with a mandatory reason::
class Admin::DashboardsController < ApplicationController
skip_tenant_enforcement! only: [:show], reason: "Admin global dashboard metrics across all tenants"
def show
@total_users = User.count
end
endmecha_tenant automatically registers Rake tasks via its Railtie:
# Print all models and controller actions with their tenancy status
bin/rails mecha_tenant
# Statically verify model registry parity and controller AST scoping
bin/rails mecha_tenant:verify:static
# Run behavioral cross-tenant scope and IDOR route matrix tests
bin/rails mecha_tenant:verify:behavioral
# Run both static and behavioral verification
bin/rails mecha_tenant:verifymecha_tenant ships with MechaTenant::MatrixTest, which dynamically generates an individual Minitest test method for every registered tenant model (scope isolation) and every enforced parameterized :id route (cross-tenant IDOR protection) so they run in parallel across all workers.
In your consuming Rails application's test/test_helper.rb:
MechaTenant.configuration.configure_test_matrix(
setup: -> {
# Create @victim, @attacker, and @victim_records hash mapping model names to victim instances
},
context: -> {
{
victim: @victim,
attacker: @attacker,
victim_records: @victim_records,
ui_auth: ->(session) { login_as(session, @attacker); {} },
api_auth: ->(_session) { { "Authorization" => "Bearer #{@attacker_jwt}" } }
}
}
)
require "mecha_tenant/matrix_test" unless ENV["MECHA_MATRIX_STANDALONE"] == "1"To run the gem's standalone test suite:
bundle exec rake testCopyright 2026 Jeff Lunt. Released under the MIT License.