关于 identity-membership
identity-membership 是面向开发的技能,讲解如何将登录身份与工作区成员关系及租户内用户分开建模。内容涵盖加入流程、授权、加入码和 Rails 应用中的常见多租户问题。
主要功能
- 01分离身份与成员关系记录
- 02一个身份关联多个工作区
- 03按需创建租户内用户
- 04处理成员角色与授权
- 05兑换可分享的加入码
- 06规避常见租户和会话错误
适用场景
- 01设计多工作区账户成员关系
- 02避免跨租户重复身份
- 03实现租户感知的授权
- 04构建加入码邀请流程
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ thinkoodle/ rails-skills --skill identity-membership安装后在 Claude Code 中输入/identity-membership确认已加载。
附带文件reference.md
详细介绍
身份、用户、成员关系
登录的人是一个身份(Identity)。他们可以加入的工作区是一个租户(Tenant,也称 Account)。加入关系是成员关系(Membership)。会话关联的是身份,而不是限定在租户范围内的用户。
这套设计基于 magic-link-auth。在每个租户使用独立 SQLite 数据库的应用中,它还基于 activerecord-tenanted。Fizzy 在单个数据库中采用了相同的划分。
理念
- 身份负责认证。成员关系负责授权。用户是租户内的人。 混淆这几者,就会把一个客户的用户泄漏到另一个分片。
- 一个邮箱,多个工作区。 有人加入第二个租户时,不要为其创建第二个身份。
- 先全局创建成员关系,再在
with_tenant内创建租户内用户。 顺序很重要;没有成员关系的用户就是孤儿记录。 - 加入码可以分享,不是通过邮件发送的魔法链接。 使用不同的密钥和不同的数据表。
记录
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 会跳过租户内用户:全局数据库中的 AccountMembership 就足够了,因为账户成员可以查看所有项目。Cortex/Innkeeper 需要在租户数据库内有一个用户,用于项目分配、评论和审计操作人。
Fizzy 的 User 是同一数据库中的成员关系记录(users.identity_id + account_id)。理念相同,只是不涉及分片。
第 1 步:身份保持全局
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
登录会为此身份创建一个会话(magic-link-auth)。它不会选择租户。
第 2 步:成员关系表示加入
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 步:租户内用户(工作区有人员记录时)
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 的外键——它们位于不同的数据库中。加入时从成员关系复制 role;不要把这两个角色当成彼此独立的事实来源。
第 4 步:用一个方法完成加入
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 并重新查找——两个浏览器标签页可能会同时操作。Herald 的 Account#add_member! 理念相同,只是不涉及租户内用户。
魔法链接验证后,如果 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
如果身份已登录,但在此租户中没有成员关系,则 Current.user 为 nil。这时应返回 403,而不是陷入登录循环。
不要从会话设置 Current.user。会话不知道 URL 选择了哪个租户。
第 6 步:加入码
可分享的 XXXX-XXXX-XXXX(Base58,不含 0/O/I/l)。全局数据表。每个租户/项目只能有一个有效码。
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:)—— 这会创建重复的人;身份才是唯一性边界。 - 仅使用
Current.identity授权 —— 他们可能不是此租户的成员。 - 在成员关系之前创建 User —— 分片中会出现没有全局加入记录的 User。
- 在工作区上使用 Nebula
Member—— 租户模式不同;不要将其照搬到activerecord-tenanted应用中。 - 在魔法链接邮件中发送加入码 —— 这是两种流程,应使用两个模板。
相关内容
- 登录码和会话 Cookie:
magic-link-auth - 分片切换:
activerecord-tenanted
Herald、Cortex/Innkeeper 与 Fizzy 的对比以及测试辅助工具,请参阅 reference.md。