multi-tenant-setup
Skill211 starsChanged 5 months agoMITView on GitHub
Implements URL-based multi-tenancy with account scoping, membership patterns, and data isolation following 37signals patterns. Use when setting up multi-tenant architecture, account isolation, membership management, or when user mentions multi-tenancy, accounts, or tenant separation. WHEN NOT: For basic model setup without tenancy (use model-patterns), for auth/session setup (use auth-setup).
In this repository17 other files it gives its agents.
- AGENTS.mdAGENTS.md
- .claude/CLAUDE.mdCLAUDE.md
- .opencode/CLAUDE.mdCLAUDE.md
- api-patternsSkill
- auth-setupSkill
- caching-patternsSkill
- concern-patternsSkill
- crud-patternsSkill
- event-trackingSkill
- job-patternsSkill
- mailer-patternsSkill
- migration-patternsSkill
- model-patternsSkill
- state-recordsSkill
- stimulus-patternsSkill
- testing-patternsSkill
- turbo-patternsSkill
The file
We split this skill into the rules it gives an agent
8 rules. About 0 are common, 4 are distinctive, and 4 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈4
Distinctive · estimate
This author's own choices. Worth a look.
4
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 21
All queries scoped through Current.account
- Distinctive: line 23
No default scopes (explicit scoping preferred)
- Distinctive: line 24
No Apartment gem, no subdomain routing, no schema separation
- Very specific: line 410
Use URL-based multi-tenancy (/:account_id/...)
- Very specific: line 411
Put account_id on every table
- Distinctive: line 414
Use UUIDs for all primary keys
- Distinctive: line 425
Use schema-based multi-tenancy (Apartment gem)
- Very specific: line 426
Use default_scope for account filtering
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Multi-Tenant Setup
Philosophy: URL-Based Multi-Tenancy, Not Subdomain or Schema
- URL-based:
app.myapp.com/123/boards/456(account_id in path) account_idon every table (no foreign key constraints)Current.accountset from URL params for all requests- All queries scoped through
Current.account - UUIDs everywhere (prevents enumeration attacks)
- No default scopes (explicit scoping preferred)
- No Apartment gem, no subdomain routing, no schema separation
Project Knowledge
Stack: URL-based multi-tenancy (/accounts/:account_id/...), Current
attributes for account/user context, UUIDs for all primary keys, single
database with single schema.
Auth: Custom passwordless with Current.user, users can belong to multiple
accounts, account membership controls access.
Commands:
rails generate model Account name:string
rails generate model Membership user:references account:references role:integer
rails generate migration AddAccountToCards account:referencesPattern 1: Account Model and Memberships
See @references/membership-patterns.md for full details.
# app/models/account.rb
class Account < ApplicationRecord
has_many :memberships, dependent: :destroy
has_many :users, through: :memberships
has_many :boards, dependent: :destroy
has_many :cards, dependent: :destroy
validates :name, presence: true, length: { maximum: 100 }
def member?(user)
users.exists?(user.id)
end
def add_member(user, role: :member)
memberships.find_or_create_by!(user: user) do |membership|
membership.role = role
end
end
def owner
memberships.owner.first&.user
end
end
# app/models/membership.rb
class Membership < ApplicationRecord
belongs_to :user
belongs_to :account
enum :role, { member: 0, admin: 1, owner: 2 }
validates :user_id, uniqueness: { scope: :account_id }
validates :role, presence: true
scope :active, -> { where(active: true) }
end
# app/models/user.rb
class User < ApplicationRecord
has_many :memberships, dependent: :destroy
has_many :accounts, through: :memberships
def member_of?(account)
accounts.exists?(account.id)
end
def role_in(account)
memberships.find_by(account: account)&.role
end
def admin_of?(account)
memberships.find_by(account: account)&.admin? ||
memberships.find_by(account: account)&.owner?
end
endPattern 2: Current Attributes for Request Context
# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
attribute :user, :account, :membership
delegate :admin?, :owner?, to: :membership, allow_nil: true, prefix: true
def member?
membership.present?
end
def can_edit?(resource)
return false unless member?
return true if membership_admin? || membership_owner?
resource.respond_to?(:creator) && resource.creator == user
end
end
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :authenticate_user!
before_action :set_current_account
before_action :set_current_membership
before_action :ensure_account_member
private
def set_current_account
if params[:account_id]
Current.account = current_user.accounts.find(params[:account_id])
end
rescue ActiveRecord::RecordNotFound
redirect_to accounts_path, alert: "Account not found or access denied"
end
def set_current_membership
if Current.account
Current.membership = current_user.memberships.find_by(
account: Current.account
)
end
end
def ensure_account_member
return unless Current.account
unless Current.member?
redirect_to accounts_path, alert: "You don't have access"
end
end
def require_admin!
unless Current.membership_admin?
redirect_to account_path(Current.account), alert: "Admin access required"
end
end
endPattern 3: URL-Based Routing
# config/routes.rb
Rails.application.routes.draw do
# Auth (no account context)
resource :session, only: [:new, :create, :destroy]
# Account selection
resources :accounts, only: [:index, :new, :create]
# All routes within account context
scope "/:account_id" do
resource :account, only: [:show, :edit, :update, :destroy]
resources :memberships, only: [:index, :create, :destroy]
resources :boards do
resources :cards do
resources :comments, only: [:create, :destroy]
resource :closure, only: [:create, :destroy]
end
end
resources :activities, only: [:index]
root "dashboards#show", as: :account_root
end
root "accounts#index"
endPath helpers:
account_boards_path(@account) # => /123/boards
account_board_path(@account, @board) # => /123/boards/456
account_board_cards_path(@account, @board) # => /123/boards/456/cardsPattern 4: Account-Scoped Models
See @references/account-scoping.md for full details.
# app/models/concerns/account_scoped.rb
module AccountScoped
extend ActiveSupport::Concern
included do
belongs_to :account
validates :account_id, presence: true
before_validation :set_account_from_current, on: :create
scope :for_account, ->(account) { where(account: account) }
end
private
def set_account_from_current
self.account ||= Current.account
end
end
# Usage
class Board < ApplicationRecord
include AccountScoped
belongs_to :creator, class_name: "User"
has_many :cards, dependent: :destroy
validates :name, presence: true
end
# Child models inherit account from parent
class Card < ApplicationRecord
include AccountScoped
belongs_to :board
validate :account_matches_board
private
def set_account_from_current
self.account ||= board&.account || Current.account
end
def account_matches_board
if board && account_id != board.account_id
errors.add(:account_id, "must match board's account")
end
end
endPattern 5: Account-Scoped Controllers
class BoardsController < ApplicationController
def index
@boards = Current.account.boards.includes(:creator).recent
end
def show
@board = Current.account.boards.find(params[:id])
end
def create
@board = Current.account.boards.build(board_params)
@board.creator = Current.user
if @board.save
redirect_to account_board_path(Current.account, @board)
else
render :new, status: :unprocessable_entity
end
end
private
def board_params
params.require(:board).permit(:name, :description)
end
end
class CardsController < ApplicationController
before_action :set_board
def create
@card = @board.cards.build(card_params)
@card.creator = Current.user
@card.account = Current.account # Explicit setting
if @card.save
redirect_to account_board_card_path(Current.account, @board, @card)
else
render :new, status: :unprocessable_entity
end
end
private
def set_board
@board = Current.account.boards.find(params[:board_id])
end
endPattern 6: Account Switching
class AccountsController < ApplicationController
skip_before_action :set_current_account, only: [:index, :new, :create]
skip_before_action :ensure_account_member, only: [:index, :new, :create]
def index
@accounts = current_user.accounts.order(:name)
if @accounts.size == 1
redirect_to account_root_path(@accounts.first)
end
end
def create
@account = Account.new(account_params)
if @account.save
@account.add_member(current_user, role: :owner)
redirect_to account_root_path(@account)
else
render :new, status: :unprocessable_entity
end
end
endPattern 7: Data Isolation and Security
# app/models/concerns/account_isolation.rb
module AccountIsolation
extend ActiveSupport::Concern
included do
validate :validate_account_consistency, on: :create
end
private
def validate_account_consistency
self.class.reflect_on_all_associations(:belongs_to).each do |assoc|
next if assoc.name == :account
related = send(assoc.name)
next unless related
if related.respond_to?(:account_id) && related.account_id != account_id
errors.add(assoc.name, "must belong to the same account")
end
end
end
end
# Controller security
module AccountSecurity
extend ActiveSupport::Concern
included do
rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
end
private
def record_not_found
redirect_to account_root_path(Current.account),
alert: "Resource not found"
end
endPattern 8: Account Migrations
class AddAccountToCards < ActiveRecord::Migration[8.0]
def change
add_reference :cards, :account, type: :uuid, null: true
reversible do |dir|
dir.up do
execute <<-SQL
UPDATE cards
SET account_id = boards.account_id
FROM boards
WHERE cards.board_id = boards.id
SQL
change_column_null :cards, :account_id, false
end
end
add_index :cards, [:account_id, :created_at]
add_index :cards, [:account_id, :board_id]
end
endBoundaries
Always
- Use URL-based multi-tenancy (
/:account_id/...) - Put
account_idon every table - Scope all queries through
Current.account - Validate account consistency across associations
- Use UUIDs for all primary keys
- Set
Current.accountfrom URL inApplicationController - Double-scope nested resources (through account AND parent)
Ask First
- Whether to use account slugs vs numeric IDs in URLs
- Cross-account reference patterns (integrations, webhooks)
- Account creation flow and initial setup
Never
- Use subdomain-based multi-tenancy
- Use schema-based multi-tenancy (Apartment gem)
- Use
default_scopefor account filtering - Set
Current.accountfrom user's default account (use URL) - Skip
account_idon any table - Allow cross-account data access without explicit authorization
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

