mcpField
I
identity-membership

identity-membership

github.com/thinkoodle開発7158 回インストール7 週間前に更新
リポジトリ

identity-membership について

identity-membershipは、サインイン用のIdentityをワークスペースのMembershipやテナント内Userと分けて設計する開発スキルです。Railsアプリの参加処理、認可、参加コード、マルチテナント特有の注意点を説明します。

主な機能

  1. 01IdentityとMembershipレコードの分離
  2. 02複数ワークスペースで同一Identityを使用
  3. 03必要に応じたテナント内Userの作成
  4. 04Membershipのロールと認可の管理
  5. 05共有可能な参加コードの利用
  6. 06テナントとセッションの典型的な誤りを回避

活用シーン

  1. 01複数ワークスペースのアカウント設計
  2. 02テナント間でのIdentity重複を防止
  3. 03テナント対応の認可を実装
  4. 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 では、同じ分割を単一のデータベースで実現しています。

設計方針

  1. Identity は認証を行い、Membership は認可を行います。User はテナント内の人物です。 これらを混同すると、ある顧客のユーザーが別のシャードに漏れることになります。
  2. 1 つのメールアドレスで複数のワークスペースに参加できます。 2 つ目のテナントに参加するときに、2 つ目の Identity を作成しないでください。
  3. まずグローバルに Membership を作成し、その後 with_tenant 内でテナントの User を作成します。 順序が重要です。Membership のない User は孤立したレコードになります。
  4. 参加コードは共有可能なもので、メールで送るマジックリンクではありません。 秘密情報もテーブルも異なります。

レコード

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

引き換え手順:

  1. コードを使って JoinCode を読み込みます(グローバル)。
  2. サインインしていない場合は、コードをセッションに保存し、マジックリンクのフローに進ませます。
  3. identity.join(tenant) を実行し、その後 redeem_if を実行します。
  4. 引き換えエンドポイントにレート制限を設定します。

参加コードをマジックリンクのコードとして使わないでください。参加が成功するまで使用回数を増やさないでください。

アンチパターン

  1. User に has_many :sessions を設定する — ログアウト/ログインはワークスペースごとではなく、人ごとに行います。
  2. テナント内で find_or_create_by(email:) を使う — 人物の重複を作成します。Identity が一意性の境界です。
  3. Current.identity だけで認可する — このテナントのメンバーとは限りません。
  4. Membership より先に User を作成する — グローバルな参加行のない User がシャード内に作成されます。
  5. ワークスペースで Nebula の Member を使う — テナント構成が異なります。これを activerecord-tenanted アプリにコピーしないでください。
  6. マジックリンクのメーラーで参加コードをメール送信する — フローもテンプレートも異なります。

関連項目

  • サインインコードとセッション Cookie:magic-link-auth
  • シャードの切り替え:activerecord-tenanted

Herald と Cortex/Innkeeper、Fizzy の違い、およびテストヘルパーについては、reference.md を参照してください。

コメント