Skip to Content
Course content

165: Building a Multi-Tenant SaaS with Rails

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Imagine you're running a high-end apartment complex. You have one massive building (your application) and one plumbing system (your database). Every tenant has their own unit, their own furniture, and their own private life. Even though they all live under one roof and share the same foundation, the tenant in 4A should never accidentally walk into 4B and start rearranging the furniture. The walls are what provide that isolation. If a wall fails, you've got a massive privacy breach.

In a multi-tenant SaaS, your "building" is your Rails app, and your "apartments" are your customers. The "walls" are the logic you implement to ensure that when a user from Company A logs in, they can't see a single row of data belonging to Company B. If you forget a where(company_id: current_company.id) in just one controller action, you've effectively knocked down a wall and let a stranger into a customer's private living room.

Identifying the Tenant from the Request

First, we need to figure out who is knocking on the door. The most common way to do this in SaaS is via subdomains (e.g., apple.yourtool.com vs microsoft.yourtool.com). I prefer this over a simple dropdown menu because it feels more professional and makes session management cleaner.

To handle this without repeating yourself in every controller, we use a Current object. Rails provides ActiveSupport::CurrentAttributes for exactly this purpose—it's a thread-isolated way to store the current tenant so it's accessible anywhere in your app without passing variables through every single method.

# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :tenant
end

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action :set_tenant

  private

  def set_tenant
    # We look up the company based on the subdomain of the request
    subdomain = request.subdomains.first
    Current.tenant = Company.find_by!(subdomain: subdomain)
  rescue ActiveRecord::RecordNotFound
    render plain: "Company not found", status: :not_found
  end
end

Building the Walls with Row-Level Isolation

Now that we know who the tenant is, we have to enforce the isolation. You could manually add .where(company_id: Current.tenant.id) to every query, but let's be honest: you'll forget it eventually. I've seen senior devs do it. It's a ticking time bomb.

Instead, we want a pattern that makes isolation the default. While some people use default_scope, I generally advise against it because it's a pain to bypass when you're writing admin scripts. A better approach is to create a concern that handles the scoping for you.

# app/models/concerns/tenantable.rb
module Tenantable
  extend ActiveSupport::Concern

  included do
    belongs_to :company
    # This ensures that whenever we create a record, 
    # it's automatically linked to the current tenant.
    before_validation :set_company, on: :create
  end

  class_methods do
    def for_current_tenant
      where(company_id: Current.tenant.id)
    end
  end

  private

  def set_company
    self.company_id ||= Current.tenant&.id
  end
end

# Now apply it to your models
class Project < ApplicationRecord
  include Tenantable
end

class Task < ApplicationRecord
  include Tenantable
end

Now, in your controllers, instead of querying Project.all, you use Project.for_current_tenant. It's a small mental shift, but it makes it explicit that you are operating within a tenant's boundary.

Handling the "Cross-Tenant" Leak

Here is where things get tricky. What happens when you, the super-admin, need to see all projects across all companies to run a report? If you've locked everything down too tightly, you're stuck. This is why I advocate for the for_current_tenant method over a hard-coded default_scope. When you're in your admin dashboard, you simply call Project.all, and you have the full picture without fighting the framework.

One last tip: always add a database-level constraint. If you're using PostgreSQL, adding a NOT NULL constraint on company_id ensures that no "orphan" data ever exists in your system. Orphan data is the SaaS equivalent of a ghost in the machine—it's data that doesn't belong to anyone, but still takes up space and potentially leaks into searches.




📋 Practical Task

Exercise: Implementing a Tenant-Isolated Project Tracker

You are tasked with adding multi-tenancy to an existing Project Tracker application. The app currently allows any user to see any project, which is a major security flaw.

Your requirements:

  • Create a Company model with a subdomain string column.
  • Implement a Current attributes class to hold the current tenant.
  • Implement a Tenantable concern that adds a belongs_to :company relationship and a for_current_tenant class method.
  • Update the Project model to include the Tenantable concern.
  • Modify the ProjectsController#index action so it only returns projects belonging to the company identified by the request's subdomain.
  • Ensure that when a new Project is created, it is automatically assigned to the Current.tenant.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.