identity-membership について
identity-membershipは、サインイン用のIdentityをワークスペースのMembershipやテナント内Userと分けて設計する開発スキルです。Railsアプリの参加処理、認可、参加コード、マルチテナント特有の注意点を説明します。
主な機能
- 01IdentityとMembershipレコードの分離
- 02複数ワークスペースで同一Identityを使用
- 03必要に応じたテナント内Userの作成
- 04Membershipのロールと認可の管理
- 05共有可能な参加コードの利用
- 06テナントとセッションの典型的な誤りを回避
活用シーン
- 01複数ワークスペースのアカウント設計
- 02テナント間でのIdentity重複を防止
- 03テナント対応の認可を実装
- 04参加コードによる招待フローを構築
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ thinkoodle/ rails-skills --skill identity-membershipインストール後、Claude Code で/identity-membershipを入力して読み込まれたか確認します。
同梱ファイルreference.md
詳細
Identity、User、Membership
サインインする人は Identity です。参加できるワークスペースは Tenant(または Account)です。この参加関係が Membership です。セッションは Identity に紐づき、テナントスコープの User には決して紐づきません。
これは magic-link-auth の上に構築されています。テナントごとに SQLite を使うアプリでは、activerecord-tenanted の上にも構築されています。Fizzy では、同じ分割を単一のデータベースで実現しています。
設計方針
- Identity は認証を行い、Membership は認可を行います。User はテナント内の人物です。 これらを混同すると、ある顧客のユーザーが別のシャードに漏れることになります。
- 1 つのメールアドレスで複数のワークスペースに参加できます。 2 つ目のテナントに参加するときに、2 つ目の Identity を作成しないでください。
- まずグローバルに Membership を作成し、その後
with_tenant内でテナントの User を作成します。 順序が重要です。Membership のない User は孤立したレコードになります。 - 参加コードは共有可能なもので、メールで送るマジックリンクではありません。 秘密情報もテーブルも異なります。
レコード
Identity (global) email, sessions, magic links, access tokens
│
├── Membership (global) identity_id + tenant_id + role
│
└── User (tenanted, Cortex/Innkeeper) identity_id + name + in-tenant role
Herald ではテナントの User を省略します。アカウントのメンバーはすべてのプロジェクトを閲覧できるため、グローバル DB の AccountMembership だけで十分です。Cortex/Innkeeper では、プロジェクトへの割り当て、コメント、監査アクターのために、テナント DB 内に User が必要です。
Fizzy の User は同じデータベース内の Membership 行です(users.identity_id + account_id)。考え方は同じですが、シャードはありません。
ステップ 1:Identity はグローバルに保持する
class Identity < GlobalRecord
has_many :sessions, dependent: :destroy
has_many :magic_links, dependent: :destroy
has_many :memberships, dependent: :destroy
has_many :tenants, through: :memberships
validates :email, presence: true, uniqueness: { case_sensitive: false }
normalizes :email, with: ->(email) { email.downcase.strip }
end
サインインすると、この Identity の Session が作成されます(magic-link-auth)。テナントは選択されません。
ステップ 2:Membership は参加関係
class Membership < GlobalRecord
belongs_to :identity
belongs_to :tenant # or :account
enum :role, { admin: "admin", member: "member" }, default: :member
validates :identity_id, uniqueness: { scope: :tenant_id }
end
「このワークスペースにそもそも参加できるか」を判定するロールは、ここに保持します。より細かな ACL(ボードへのアクセス、プロジェクトへの割り当て)は、テナントのレコードに保持します。
Herald では owner/member を持つ AccountMembership を使います。Innkeeper では viewer も追加します。
ステップ 3:テナントの User(ワークスペースに人物レコードがある場合)
class User < ApplicationRecord
validates :identity_id, presence: true, uniqueness: true
enum :role, { admin: "admin", member: "member" }
def identity
Identity.find_by(id: identity_id)
end
end
identities への FK はありません。データベースが異なるためです。参加時に Membership の role をコピーしてください。2 つのロールを独立した信頼できる情報源として扱わないでください。
ステップ 4:1 つのメソッドで参加させる
def join(tenant, name: nil, role: :member)
was_new = false
GlobalRecord.transaction do
membership = memberships.find_or_create_by!(tenant: tenant) do |record|
record.role = role
was_new = true
end
ApplicationRecord.with_tenant(tenant.external_id) do
User.find_or_create_by!(identity_id: id) do |user|
user.role = membership.role
user.name = name || email.split("@").first.titleize
end
end
end
was_new
end
RecordNotUnique を捕捉して再検索してください。2 つのタブから同時に実行される可能性があります。Herald の Account#add_member! も、テナントの User を使わない点を除けば同じ考え方です。
マジックリンクの検証後、identity.tenants.none? の場合は、テナント作成/新しいプロジェクトの画面に誘導します。テナントがある場合は、最後に利用したテナントまたは選択画面に誘導します。
ステップ 5:Current
# After cookie auth (global):
Current.identity = session.identity
# After TenantScoping:
Current.tenant = tenant
Current.user = User.find_by(identity_id: Current.identity.id)
def require_membership!
redirect_to sign_in_path unless Current.user
end
Identity がサインインしていても、このテナントに Membership がない場合、Current.user は nil になります。これは 403 であり、サインインループではありません。
セッションから Current.user を設定しないでください。セッションは、URL でどのテナントが選択されたかを把握していません。
ステップ 6:参加コード
共有可能な XXXX-XXXX-XXXX(Base58、0/O/I/l は使用しません)です。グローバルテーブルを使います。テナント/プロジェクトごとに有効なコードは 1 つです。
class JoinCode < GlobalRecord
belongs_to :tenant # or :project / :account
def redeem_if
with_lock do
increment!(:usage_count) if active? && yield
end
end
end
引き換え手順:
- コードを使って JoinCode を読み込みます(グローバル)。
- サインインしていない場合は、コードをセッションに保存し、マジックリンクのフローに進ませます。
identity.join(tenant)を実行し、その後redeem_ifを実行します。- 引き換えエンドポイントにレート制限を設定します。
参加コードをマジックリンクのコードとして使わないでください。参加が成功するまで使用回数を増やさないでください。
アンチパターン
- User に
has_many :sessionsを設定する — ログアウト/ログインはワークスペースごとではなく、人ごとに行います。 - テナント内で
find_or_create_by(email:)を使う — 人物の重複を作成します。Identity が一意性の境界です。 Current.identityだけで認可する — このテナントのメンバーとは限りません。- Membership より先に User を作成する — グローバルな参加行のない User がシャード内に作成されます。
- ワークスペースで Nebula の
Memberを使う — テナント構成が異なります。これをactiverecord-tenantedアプリにコピーしないでください。 - マジックリンクのメーラーで参加コードをメール送信する — フローもテンプレートも異なります。
関連項目
- サインインコードとセッション Cookie:
magic-link-auth - シャードの切り替え:
activerecord-tenanted
Herald と Cortex/Innkeeper、Fizzy の違い、およびテストヘルパーについては、reference.md を参照してください。