npx skills add ...
npx skills add thinkoodle/rails-skills --skill identity-membership
Expert guidance for the Identity vs User split and tenant memberships in Rails. Use when adding users, workspaces, accounts, memberships, roles, join codes, invites, Current.identity, Current.user, or connecting magic-link auth to a tenant. Complements magic-link-auth and activerecord-tenanted. Not Nebula workspace members.
npx skills add thinkoodle/rails-skills --skill identity-membership
A person who signs in is an Identity. A workspace they can enter is a Tenant (or Account). The join is Membership. Sessions hang on Identity, never on the tenant-scoped User.
This sits on top of magic-link-auth. In SQLite-per-tenant apps it also sits on activerecord-tenanted. Fizzy uses the same split in a single database.
with_tenant. Order matters; a User without a Membership is an orphan.Herald skips the tenanted User: AccountMembership on the global DB is enough because account members can see every project. Cortex/Innkeeper need a User inside the tenant DB for project assignments, comments, and audit actor.
Fizzy's User is the membership row in the same database (users.identity_id + account_id). Same idea, no shard.
Sign-in creates a Session for this Identity (magic-link-auth). It does not pick a tenant.
Roles live here for "can they enter this workspace at all." Finer ACL (board access, project assignments) lives on tenanted records.
Herald: AccountMembership with owner / member. Innkeeper adds viewer.
No FK to identities — different database. Copy role from Membership at join time; don't treat the two roles as independent sources of truth.
Rescue RecordNotUnique and re-find — two tabs will race. Herald's Account#add_member! is the same idea without a tenanted User.
After magic-link verify, if identity.tenants.none?, send them to create-tenant / new-project. If they have tenants, send them to the last one or a picker.
Current.user is nil when the Identity is signed in but has no Membership in this tenant. That's a 403, not a sign-in loop.
Don't set Current.user from the session. The session doesn't know which tenant the URL selected.
Shareable XXXX-XXXX-XXXX (Base58, no 0/O/I/l). Global table. One active code per tenant/project.
Redeem:
identity.join(tenant) then redeem_if.Don't use a magic-link code as an invite. Don't increment usage until join succeeds.
has_many :sessions on User — logout/login is per person, not per workspace.find_or_create_by(email:) inside a tenant — creates duplicate people; Identity is the uniqueness boundary.Current.identity only — they might not be a member of this tenant.Member on workspace — different tenancy; don't copy that into an activerecord-tenanted app.magic-link-authactiverecord-tenantedSee reference.md for Herald vs Cortex/Innkeeper vs Fizzy, and test helpers.
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 }
endclass User < ApplicationRecord
validates :identity_id, presence: true, uniqueness: true
enum :role, { admin: "admin", member: "member" }
def identity
Identity.find_by(id: identity_id)
end
enddef 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# 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
endclass JoinCode < GlobalRecord
belongs_to :tenant # or :project / :account
def redeem_if
with_lock do
increment!(:usage_count) if active? && yield
end
end
end