このページは原文を AI が翻訳したものです。

37signalsがFizzyをオープンソース化した際、コードベースの中で最も興味深い部分のひとつが認証とマルチテナンシーの設計でした。別々のサブドメインや複雑なスキーマ切り替え用gemを使う代わりに、FizzyはIdentityUserAccountという3層モデルに支えられたパスベースのマルチテナンシーを採用しています。

ここでは、データベースモデル、Rackミドルウェア、バックグラウンドジョブにわたるアーキテクチャの仕組みを詳しく解説します。

マルチテナンシーの課題

FizzyはURLパスベースのマルチテナンシーを採用しています。各組織(Fizzyでは「Account」と呼びます)には一意の数値IDが割り当てられ、それがすべてのURLに現れます:

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

その7桁の数字がAccountのexternal_account_idです。このアプローチは認証に重要な影響を及ぼします:

  1. 1人が複数のAccountに所属できる \- 自分の会社のアカウント、クライアントのアカウント、サイドプロジェクトのアカウントに同時に所属するかもしれません。
  2. すべてのデータはAccountにスコープされる \- すべてのボード、カード、コメントは正確に1つのテナントに属します。
  3. アプリケーションは、どのAccountコンテキストで操作しているかを把握する必要がある \- 認証を行う同じHTTPリクエストが、テナントも決定しなければなりません。

単純なアプローチとしては、Accountごとに別々のユーザー資格情報を作る方法がありますが、それはユーザー体験として劣ります。その代わりにFizzyは、グローバルな認証アイデンティティアカウント固有のユーザープロフィールを3層モデルで分離しています。

3層モデル:Identity → User → Account

Fizzyの認証アーキテクチャには3つの主要なエンティティがあります:

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にアクセスできます

データベーススキーマはこの独立性を反映しています:

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スラッグになります。この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] のユニークインデックスは重要な制約を強制します:1つのアイデンティティは、アカウントごとに最大1つのユーザーしか持てません。これにより重複メンバーシップを防ぎつつ、同じ人物(アイデンティティ)が複数のアカウントで異なるユーザープロフィールを持つことを可能にします。

また、identity_id がNULL許容である点にも注目してください。これにより「孤立した」ユーザー、つまり別のフローで参加した人が後から紐付けられるプレースホルダーレコードが可能になります。

この設計が機能する理由

この3層構造にはいくつかの利点があります:

アカウント間シングルサインオン:アイデンティティで一度認証すれば、システムはURL内のアカウントに基づいて使用するユーザープロフィールを解決します。

柔軟なメンバーシップモデル:同じ人物が以下のようになれます:

  • 自分の会社のアカウントではオーナー
  • クライアントのアカウントではメンバー
  • サイドプロジェクトのアカウントでは管理者

アカウントの分離:すべての機密データ(ボード、カード、ウェブフック)はアカウントに属し、アイデンティティに直接属することはありません。これによりテナント分離が簡単になります。

優雅なユーザー管理:アイデンティティが削除された場合、すべてのアカウントにわたってそのユーザーレコードを削除するか非アクティブ化するかを選択できます。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
end

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

2\. マジックリンクの消費

メール内のマジックリンクをクリックすると、ワンタイムコードが含まれており、それが検証されます:

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

ブラウザには、アイデンティティを識別する署名付きセッションクッキーが送信されます。この時点で、あなたはグローバルに認証されていますが、まだどのアカウントのコンテキストでも操作していません。

リクエストコンテキストによるマルチテナンシー

ここがFizzyのアーキテクチャの真骨頂です。Identityとして認証されると、アプリケーションは次のことを行う必要があります。

  1. URLパスからAccount IDを抽出する
  2. そのAccount内でIdentityに対応するUserレコードを見つける
  3. リクエストのライフサイクル全体で両方を利用可能にする

これはミドルウェアとリクエストスコープの属性によって処理されます。

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に認識させる代わりに、ミドルウェアがそれを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

カスケード式の代入ロジックに注目してください:

  1. Current.sessionが設定されると(認証時)、自動的にCurrent.identityが設定されます
  2. Current.identityが設定されると、Identityと現在のAccountの両方に一致するUserレコードを探して、適切なCurrent.userを検索します

つまり、リクエスト全体を通じて次のものにアクセスできます:

  • Current.account \- テナントコンテキスト(URLから抽出)
  • Current.identity \- グローバルなあなたの識別情報(セッションから)
  • Current.user \- アカウント固有のプロフィール(上記2つの結合)

コントローラとモデルは、依存性注入なしでこれらを参照できます:

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

モデルでの自動アカウントスコーピング

すべてのマルチテナントモデルがaccount_idを含むため、Fizzyはモデルレベルでデータ分離を強制できます:

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

ほとんどのモデルはコンサーンを含みます:

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コンテキストをどうやって保持するのでしょうか?

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クラスにプリペンドされます。ジョブがエンキューされると:

  1. initializeCurrent.accountをインスタンス変数としてキャプチャします
  2. serializeがそれをGlobalIDとしてジョブペイロードに保存します
  3. deserializeがジョブがキューからロードされたときにそれを復元します
  4. perform_nowが実行をCurrent.with_accountでラップしてリクエストコンテキストを復元します

つまり、テナンシーを気にせずにジョブを書くことができます:

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

ジョブは、それをキューに投入したリクエストと同じAccountコンテキストで自動的に実行されます。

認可:ロールとアクセス制御

認証はあなたが誰であるかを決定します。認可は何ができるかを決定します。Fizzyは、認証モデルの上に、ユーザーロールとボードレベルのアクセスレコードという2つのメカニズムを通じて認可を構築しています。

ユーザーロール

各ユーザーは、自分の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

ロールは階層的です:

  • オーナー:Accountの完全な管理権限
  • 管理者:ユーザーとボードを管理できます(オーナーも管理者です)
  • メンバー:標準的なアクセス権限
  • システム:内部自動化用ユーザー

覚えておいてください:ロールはAccountごとに設定されます。あなたのIdentityは、あるAccountではオーナーでも、別のAccountではメンバーになることがあります。

ボードレベルのアクセス

Account内では、ボードが特定のユーザーへのアクセスを制限できます:

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レコードは、ユーザーにボードの表示と操作の権限を付与します。ボードは「全員アクセス」(Accountの全メンバーに表示)または選択的(明示的なAccessレコードを持つユーザーにのみ表示)のいずれかにできます。

これにより、モデルをシンプルに保ちながら細かい制御が可能になります:Userを介してAccountのメンバーシップを確認し、次にAccessを介してボードへのアクセスを確認します。

まとめ

Fizzyの認証アーキテクチャは、マルチテナントRailsアプリのためのいくつかの実用的なパターンを強調しています:

  1. グローバルIdentityとテナントプロファイルの分離Identityが認証とセッションを処理し、Userが特定のAccount内でのメンバーシップを表します。
  2. ミドルウェアによるパスベースのルーティングAccountSlug::ExtractorがテナントプレフィックスをSCRIPT_NAMEに抽出し、ルートを標準に保ちながら、テナントコンテキストをすべてのURLに直接埋め込みます。
  3. スレッドローカルなリクエストコンテキストCurrent.accountCurrent.userは、コントローラーとモデルスコープ全体で現在のテナント属性へのクリーンなアクセスを提供します。
  4. バックグラウンドジョブでのコンテキスト保持:ActiveJobはCurrent.accountをGlobalIDとしてシリアライズし、ワーカー実行時に復元します。

完全な実装はFizzyのリポジトリで入手でき、標準的なRailsにおけるマルチテナントアーキテクチャのクリーンな参考資料となっています。

Epona
執筆Epona

There's nothing wrong with having a little fun

x.com/simura_epona

コメントを読み込み中…