このページは原文を AI が翻訳したものです。
37signalsがFizzyをオープンソース化した際、コードベースの中で最も興味深い部分のひとつが認証とマルチテナンシーの設計でした。別々のサブドメインや複雑なスキーマ切り替え用gemを使う代わりに、FizzyはIdentity、User、Accountという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人が複数のAccountに所属できる \- 自分の会社のアカウント、クライアントのアカウント、サイドプロジェクトのアカウントに同時に所属するかもしれません。
- すべてのデータはAccountにスコープされる \- すべてのボード、カード、コメントは正確に1つのテナントに属します。
- アプリケーションは、どの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
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ブラウザには、アイデンティティを識別する署名付きセッションクッキーが送信されます。この時点で、あなたはグローバルに認証されていますが、まだどのアカウントのコンテキストでも操作していません。
リクエストコンテキストによるマルチテナンシー
ここが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に認識させる代わりに、ミドルウェアがそれを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\- アカウント固有のプロフィール(上記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クラスにプリペンドされます。ジョブがエンキューされると:
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は、認証モデルの上に、ユーザーロールとボードレベルのアクセスレコードという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) }
endAccessレコードは、ユーザーにボードの表示と操作の権限を付与します。ボードは「全員アクセス」(Accountの全メンバーに表示)または選択的(明示的なAccessレコードを持つユーザーにのみ表示)のいずれかにできます。
これにより、モデルをシンプルに保ちながら細かい制御が可能になります:Userを介してAccountのメンバーシップを確認し、次にAccessを介してボードへのアクセスを確認します。
まとめ
Fizzyの認証アーキテクチャは、マルチテナントRailsアプリのためのいくつかの実用的なパターンを強調しています:
- グローバルIdentityとテナントプロファイルの分離:
Identityが認証とセッションを処理し、Userが特定のAccount内でのメンバーシップを表します。 - ミドルウェアによるパスベースのルーティング:
AccountSlug::ExtractorがテナントプレフィックスをSCRIPT_NAMEに抽出し、ルートを標準に保ちながら、テナントコンテキストをすべてのURLに直接埋め込みます。 - スレッドローカルなリクエストコンテキスト:
Current.accountとCurrent.userは、コントローラーとモデルスコープ全体で現在のテナント属性へのクリーンなアクセスを提供します。 - バックグラウンドジョブでのコンテキスト保持:ActiveJobは
Current.accountをGlobalIDとしてシリアライズし、ワーカー実行時に復元します。
完全な実装はFizzyのリポジトリで入手でき、標準的なRailsにおけるマルチテナントアーキテクチャのクリーンな参考資料となっています。



コメントを読み込み中…