当 37signals 开源 Fizzy 时,代码库中最有趣的部分之一就是它的认证与多租户设计。Fizzy 没有使用独立的子域名或复杂的 schema 切换 gem,而是采用基于路径的多租户方案,并由三层模型支撑:Identity、User 和 Account。
下面我们来拆解这一架构在数据库模型、Rack 中间件和后台任务中的运作方式。
多租户的挑战
Fizzy 使用 基于 URL 路径的多租户。每个组织(在 Fizzy 中称为“Account”)都有一个唯一的数字标识符,出现在每个 URL 中:
https://fizzy.do/1234567/boards/new
https://fizzy.do/1234567/cards/42那个七位数字就是 Account 的 external_account_id。这种方法对认证有着重要的影响:
- 一个人可以属于多个 Account \- 你可能同时在你公司的账户、客户的账户以及一个副业项目的账户中。
- 所有数据都必须限定在 Account 范围内 \- 每个看板、卡片和评论都只属于一个租户。
- 应用需要知道你在哪个 Account 上下文中操作 \- 同一个 HTTP 请求在认证你的同时,也必须确定你的租户。
一种简单的做法是为每个 Account 创建独立的用户凭据,但这对用户体验很不友好。相反,Fizzy 通过三层模型将 全局认证身份 与 特定于账户的用户资料 分离开来。
三层模型:Identity → User → Account
Fizzy 的认证架构包含三个关键实体:
1\. Identity - 全局认证层
一个 Identity 代表一个人的全局认证凭据。它是唯一存在于多租户边界之外的实体:
class Identity < ApplicationRecord
has_many :access_tokens, dependent: :destroy
has_many :magic_links, dependent: :destroy
has_many :sessions, dependent: :destroy
has_many :users, dependent: :nullify
has_many :accounts, through: :users
validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }
normalizes :email_address, with: ->(value) { value.strip.downcase.presence }
end关键特征:
- 基于邮箱 :每个 Identity 都与一个唯一的邮箱地址绑定
- 与租户无关 :Identity 不属于任何 Account
- 认证的持有者 :会话、魔法链接和访问令牌都属于 Identity
- 通往 Account 的门户 :通过
has_many :users关系,一个 Identity 可以访问多个 Account
数据库 schema 也反映了这种独立性:
create_table "identities" do |t|
t.string "email_address", null: false
t.boolean "staff", default: false, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["email_address"], unique: true
end注意这里没有 account_id 外键。Identity 是真正全局的。
2\. 账户——租户
Account 是多租户的边界。它拥有所有应用数据:
class Account < ApplicationRecord
has_many :users, dependent: :destroy
has_many :boards, dependent: :destroy
has_many :cards, dependent: :destroy
has_many :webhooks, dependent: :destroy
has_many :tags, dependent: :destroy
has_many :columns, dependent: :destroy
before_create :assign_external_account_id
def slug
"/#{AccountSlug.encode(external_account_id)}"
end
end每个账户在创建时都会获得一个唯一的 external_account_id(7 位以上的数字),这个数字会成为其 URL 的 slug。这个 ID 是整个应用的租户判别标识。
3\. 用户——身份与账户之间的桥梁
这里就是架构变得有趣的地方。User 不是你的认证凭证。相反,它是你在特定账户中的成员身份:
class User < ApplicationRecord
belongs_to :account
belongs_to :identity, optional: true
validates :name, presence: true
enum :role, %i[ owner admin member system ].index_by(&:itself)
end这个模式让关系一目了然:
create_table "users" do |t|
t.uuid "account_id", null: false
t.uuid "identity_id"
t.string "name", null: false
t.string "role", default: "member", null: false
t.boolean "active", default: true, null: false
t.datetime "verified_at"
t.index ["account_id", "identity_id"], unique: true
end[account_id, identity_id] 上的唯一索引强制执行了一个关键约束:一个身份在每个账户中最多只能有一个用户。这防止了重复成员身份,同时允许同一个人(身份)在多个账户中拥有不同的用户资料。
还要注意 identity_id 是可空的。这允许存在“孤儿”用户——占位记录,可以在某人通过不同流程加入时稍后认领。
为什么这种设计有效
这种三层分离带来了几个好处:
跨账户单点登录:你只需用身份认证一次,然后系统会根据 URL 中的账户解析该使用哪个用户资料。
灵活的成员身份模型:同一个人可以是:
- 公司账户中的所有者
- 客户账户中的成员
- 副业项目账户中的管理员
账户隔离:所有敏感数据(看板、卡片、webhook)都属于账户,从不直接属于身份。这让租户隔离变得简单直接。
优雅的用户管理:当身份被删除时,你可以选择删除或停用其所有账户中的用户记录。Fizzy 选择停用它们:
class Identity < ApplicationRecord
before_destroy :deactivate_users, prepend: true
private
def deactivate_users
users.find_each(&:deactivate)
end
end
class User < ApplicationRecord
def deactivate
transaction do
accesses.destroy_all
update! active: false, identity: nil
end
end
end这样既保留了历史数据(评论、创建的卡片),又撤销了访问权限。
无密码认证流程
Fizzy 使用魔法链接认证,这与基于身份的模型完美契合。流程如下:
1\. 请求魔法链接
当你访问登录页面并输入邮箱时:
class SessionsController < ApplicationController
def create
if identity = Identity.find_by(email_address: email_address)
sign_in identity
elsif Account.accepting_signups?
sign_up
else
redirect_to_fake_session_magic_link email_address
end
end
private
def sign_in(identity)
redirect_to_session_magic_link identity.send_magic_link
end
endsend_magic_link 方法会创建一个 MagicLink 记录并通过邮件发送:
class Identity < ApplicationRecord
def send_magic_link(**attributes)
magic_links.create!(attributes).tap do |magic_link|
MagicLinkMailer.sign_in_instructions(magic_link).deliver_later
end
end
end2\. 使用魔法链接
当你点击邮件中的魔法链接时,它包含一个一次性代码,会被验证:
class Sessions::MagicLinksController < ApplicationController
def create
if magic_link = MagicLink.consume(code)
authenticate magic_link
else
invalid_code
end
end
private
def authenticate(magic_link)
if ActiveSupport::SecurityUtils.secure_compare(
email_address_pending_authentication || "",
magic_link.identity.email_address
)
sign_in magic_link
else
email_address_mismatch
end
end
def sign_in(magic_link)
clear_pending_authentication_token
start_new_session_for magic_link.identity
redirect_to after_sign_in_url(magic_link)
end
end注意认证过程创建了一个属于 Identity 的 Session 记录:
class Session < ApplicationRecord
belongs_to :identity
end你的浏览器会收到一个标识你身份的签名会话 cookie。此时,你已在全局完成认证,但尚未在任何账户上下文中操作。
通过请求上下文实现多租户
这正是 Fizzy 架构的亮点所在。一旦你以 Identity 身份完成认证,应用程序需要做到:
- 从 URL 路径中提取 Account ID
- 在该 Account 内找到与你的 Identity 对应的 User 记录
- 在整个请求生命周期中让这两者都可用
这一切由中间件和请求作用域属性来处理。
从 URL 中提取 Account
AccountSlug::Extractor 中间件会拦截每一个请求:
module AccountSlug
PATTERN = /(\d{7,})/
PATH_INFO_MATCH = /\A(\/#{AccountSlug::PATTERN})/
class Extractor
def call(env)
request = ActionDispatch::Request.new(env)
if request.path_info =~ PATH_INFO_MATCH
# Yanks the prefix off PATH_INFO and move it to SCRIPT_NAME
request.engine_script_name = request.script_name = $1
request.path_info = $'.empty? ? "/" : $'
# Stash the account's external ID
env["fizzy.external_account_id"] = AccountSlug.decode($2)
end
if env["fizzy.external_account_id"]
account = Account.find_by(external_account_id: env["fizzy.external_account_id"])
Current.with_account(account) do
@app.call env
end
else
Current.without_account do
@app.call env
end
end
end
end
def self.decode(slug) slug.to_i end
def self.encode(id) "%07d" % id end
end这里很巧妙:中间件并没有让 Rails 感知路由中的 account 前缀,而是把它从 PATH_INFO 移到 SCRIPT_NAME。对 Rails 而言,应用就像是“挂载”在 /1234567 上,因此所有路由辅助方法都会自动包含这个前缀。
例如,当你在 Account 上下文中调用 card_path(@card) 时,Rails 会自动生成 /1234567/cards/42。
当前请求属性
中间件设置了 Current.account,这是一个线程局部的请求属性:
class Current < ActiveSupport::CurrentAttributes
attribute :session, :user, :identity, :account
attribute :http_method, :request_id, :user_agent, :ip_address, :referrer
def session=(value)
super(value)
if value.present?
self.identity = session.identity
end
end
def identity=(identity)
super(identity)
if identity.present?
self.user = identity.users.find_by(account: account)
end
end
end注意这里的级联赋值逻辑:
- 当
Current.session被设置时(在认证过程中),它会自动设置Current.identity - 当
Current.identity被设置时,它会通过查找同时匹配 Identity 和当前 Account 的 User 记录来确定合适的Current.user
这意味着在整个请求过程中,你都可以访问:
Current.account\- 租户上下文(从 URL 提取)Current.identity\- 你的全局身份(来自会话)Current.user\- 你的 Account 专属资料(上述两者的结合)
控制器和模型无需依赖注入即可引用这些属性:
class Cards::CommentsController < ApplicationController
def create
@comment = @card.comments.create!(
comment_params.merge(creator: Current.user)
)
end
end模型中的自动 Account 作用域
由于每个多租户模型都包含 account_id,Fizzy 可以在模型层面强制数据隔离:
# config/application.rb
module Fizzy
class Application < Rails::Application
config.active_record.automatic_scope_inversing = true
end
end大多数模型都包含一个 concern:
module MultiTenantable
extend ActiveSupport::Concern
included do
belongs_to :account
default_scope { where(account: Current.account) }
end
end这意味着像 Card.find(params[:id]) 这样的查询会自动限定在 Current.account 范围内。你不可能意外访问到其他租户的数据。
后台任务与 Account 上下文
后台任务给多租户应用带来了一个挑战:当任务在入队请求之后几分钟或几小时才执行时,如何保留 Account 上下文?
Fizzy 通过自动序列化和恢复 Current.account 来解决这个问题:
module FizzyActiveJobExtensions
extend ActiveSupport::Concern
prepended do
attr_reader :account
end
def initialize(...)
super
@account = Current.account
end
def serialize
super.merge({ "account" => @account&.to_gid })
end
def deserialize(job_data)
super
if _account = job_data.fetch("account", nil)
@account = GlobalID::Locator.locate(_account)
end
end
def perform_now
if account.present?
Current.with_account(account) { super }
else
super
end
end
end
ActiveSupport.on_load(:active_job) do
prepend FizzyActiveJobExtensions
end这个扩展被前置到所有 ActiveJob 类中。当一个任务被入队时:
initialize将Current.account捕获为实例变量serialize将其作为 GlobalID 存储在任务负载中deserialize在任务从队列加载时恢复它perform_now在执行时用Current.with_account包裹以恢复请求上下文
这意味着你可以放心编写任务,而无需担心租户隔离问题:
class Event::RelayJob < ApplicationJob
def perform(event)
event.relay_now # Current.account is automatically set
end
end该任务会自动以与入队请求相同的 Account 上下文运行。
授权:角色与访问控制
身份验证确定你是谁。授权确定你能做什么。Fizzy 通过两种机制在其身份验证模型之上构建授权:用户角色和 Board 级别的访问记录。
用户角色
每个用户在其 Account 内都有一个角色:
module User::Role
extend ActiveSupport::Concern
included do
enum :role, %i[ owner admin member system ].index_by(&:itself)
scope :owner, -> { where(active: true, role: :owner) }
scope :admin, -> { where(active: true, role: %i[ owner admin ]) }
scope :member, -> { where(active: true, role: :member) }
scope :active, -> { where(active: true, role: %i[ owner admin member ]) }
def admin?
super || owner?
end
end
def can_change?(other)
(admin? && !other.owner?) || other == self
end
def can_administer?(other)
admin? && !other.owner? && other != self
end
def can_administer_board?(board)
admin? || board.creator == self
end
end角色是分层的:
- Owner:对 Account 拥有完全控制权
- Admin:可以管理用户和 Boards(Owner 也是 Admin)
- Member:标准访问权限
- System:内部自动化用户
请记住:角色是按 Account 划分的。你的 Identity 可能在一个 Account 中是 Owner,而在另一个 Account 中是 Member。
Board 级别的访问
在 Account 内,Boards 可以限制对特定用户的访问:
class Access < ApplicationRecord
belongs_to :account, default: -> { user.account }
belongs_to :board, touch: true
belongs_to :user, touch: true
enum :involvement, %i[ access_only watching ].index_by(&:itself)
scope :ordered_by_recently_accessed, -> { order(accessed_at: :desc) }
end一条 Access 记录授予用户查看和与 Board 交互的权限。Boards 可以是“全员可访问”(对所有 Account 成员可见)或选择性访问(仅对拥有显式 Access 记录的用户可见)。
这提供了细粒度的控制,同时保持了模型的简洁:先通过 User 检查 Account 成员资格,再通过 Access 检查 Board 访问权限。
总结
Fizzy 的身份验证架构为多租户 Rails 应用展示了几个实用的模式:
- 将全局 Identity 与租户 Profile 分离:
Identity处理身份验证和会话;User表示在特定Account中的成员资格。 - 通过中间件进行基于路径的路由:
AccountSlug::Extractor将租户前缀提取到SCRIPT_NAME中,保持路由标准,同时将租户上下文直接嵌入每个 URL。 - 线程局部的请求上下文:
Current.account和Current.user在控制器和模型作用域中提供对当前租户属性的干净访问。 - 后台任务中的上下文保留:ActiveJob 前置操作将
Current.account序列化为 GlobalID,并在工作进程执行时恢复它。
完整实现可在 Fizzy 的仓库 中获取,为标准 Rails 中的多租户架构提供了清晰的参考。

正在加载评论…