~/journal/2026/01/fizzy-authentication-architecture-three-tier-multi-tenant

深入解析 Fizzy 的认证架构:面向多租户 SaaS 的三层设计

了解 37signals 的 Fizzy 如何通过 Identity → User → Account 模型构建多租户认证,让用户能够干净地隶属于多个组织。

发布
阅读时间
7 分钟
标签
BasecampRailsSystem Design

本页由 AI 从原文翻译。

当 37signals 开源 Fizzy 时,代码库中最有趣的部分之一就是它的认证与多租户设计。Fizzy 没有使用独立的子域名或复杂的 schema 切换 gem,而是采用基于路径的多租户方案,并由三层模型支撑:Identity、User 和 Account。

下面我们来拆解这一架构在数据库模型、Rack 中间件和后台任务中的运作方式。

多租户的挑战

Fizzy 使用 基于 URL 路径的多租户。每个组织(在 Fizzy 中称为“Account”)都有一个唯一的数字标识符,出现在每个 URL 中:

code
https://fizzy.do/1234567/boards/new
https://fizzy.do/1234567/cards/42

那个七位数字就是 Account 的 external_account_id。这种方法对认证有着重要的影响:

  1. 一个人可以属于多个 Account \- 你可能同时在你公司的账户、客户的账户以及一个副业项目的账户中。
  2. 所有数据都必须限定在 Account 范围内 \- 每个看板、卡片和评论都只属于一个租户。
  3. 应用需要知道你在哪个 Account 上下文中操作 \- 同一个 HTTP 请求在认证你的同时,也必须确定你的租户。

一种简单的做法是为每个 Account 创建独立的用户凭据,但这对用户体验很不友好。相反,Fizzy 通过三层模型将 全局认证身份 与 特定于账户的用户资料 分离开来。

三层模型:Identity → User → Account

Fizzy 的认证架构包含三个关键实体:

1\. Identity - 全局认证层

一个 Identity 代表一个人的全局认证凭据。它是唯一存在于多租户边界之外的实体:

ruby
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 也反映了这种独立性:

ruby
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 是多租户的边界。它拥有所有应用数据:

ruby
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 不是你的认证凭证。相反,它是你在特定账户中的成员身份:

ruby
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

这个模式让关系一目了然:

ruby
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 选择停用它们:

ruby
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\. 请求魔法链接

当你访问登录页面并输入邮箱时:

ruby
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
end

send_magic_link 方法会创建一个 MagicLink 记录并通过邮件发送:

ruby
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
end

2\. 使用魔法链接

当你点击邮件中的魔法链接时,它包含一个一次性代码,会被验证:

ruby
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 记录:

ruby
class Session < ApplicationRecord
  belongs_to :identity
end

你的浏览器会收到一个标识你身份的签名会话 cookie。此时,你已在全局完成认证,但尚未在任何账户上下文中操作。

通过请求上下文实现多租户

这正是 Fizzy 架构的亮点所在。一旦你以 Identity 身份完成认证,应用程序需要做到:

  1. 从 URL 路径中提取 Account ID
  2. 在该 Account 内找到与你的 Identity 对应的 User 记录
  3. 在整个请求生命周期中让这两者都可用

这一切由中间件和请求作用域属性来处理。

从 URL 中提取 Account

AccountSlug::Extractor 中间件会拦截每一个请求:

ruby
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,这是一个线程局部的请求属性:

ruby
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

注意这里的级联赋值逻辑:

  1. 当 Current.session 被设置时(在认证过程中),它会自动设置 Current.identity
  2. 当 Current.identity 被设置时,它会通过查找同时匹配 Identity 和当前 Account 的 User 记录来确定合适的 Current.user

这意味着在整个请求过程中,你都可以访问:

  • Current.account \- 租户上下文(从 URL 提取)
  • Current.identity \- 你的全局身份(来自会话)
  • Current.user \- 你的 Account 专属资料(上述两者的结合)

控制器和模型无需依赖注入即可引用这些属性:

ruby
class Cards::CommentsController < ApplicationController
  def create
    @comment = @card.comments.create!(
      comment_params.merge(creator: Current.user)
    )
  end
end

模型中的自动 Account 作用域

由于每个多租户模型都包含 account_id,Fizzy 可以在模型层面强制数据隔离:

ruby
# config/application.rb
module Fizzy
  class Application < Rails::Application
    config.active_record.automatic_scope_inversing = true
  end
end

大多数模型都包含一个 concern:

ruby
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 来解决这个问题:

ruby
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 类中。当一个任务被入队时:

  1. initialize 将 Current.account 捕获为实例变量
  2. serialize 将其作为 GlobalID 存储在任务负载中
  3. deserialize 在任务从队列加载时恢复它
  4. perform_now 在执行时用 Current.with_account 包裹以恢复请求上下文

这意味着你可以放心编写任务,而无需担心租户隔离问题:

ruby
class Event::RelayJob < ApplicationJob
  def perform(event)
    event.relay_now  # Current.account is automatically set
  end
end

该任务会自动以与入队请求相同的 Account 上下文运行。

授权:角色与访问控制

身份验证确定你是谁。授权确定你能做什么。Fizzy 通过两种机制在其身份验证模型之上构建授权:用户角色和 Board 级别的访问记录。

用户角色

每个用户在其 Account 内都有一个角色:

ruby
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 可以限制对特定用户的访问:

ruby
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 应用展示了几个实用的模式:

  1. 将全局 Identity 与租户 Profile 分离:Identity 处理身份验证和会话;User 表示在特定 Account 中的成员资格。
  2. 通过中间件进行基于路径的路由:AccountSlug::Extractor 将租户前缀提取到 SCRIPT_NAME 中,保持路由标准,同时将租户上下文直接嵌入每个 URL。
  3. 线程局部的请求上下文:Current.account 和 Current.user 在控制器和模型作用域中提供对当前租户属性的干净访问。
  4. 后台任务中的上下文保留:ActiveJob 前置操作将 Current.account 序列化为 GlobalID,并在工作进程执行时恢复它。

完整实现可在 Fizzy 的仓库 中获取,为标准 Rails 中的多租户架构提供了清晰的参考。

Epona
作者Epona

There's nothing wrong with having a little fun

x.com/simura_epona

相关文章

正在加载评论…