Identity, User, 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.
Philosophy
- Identity authenticates. Membership authorizes. User is the in-tenant person. Mixing these is how you leak one customer's users into another shard.
- One email, many workspaces. Don't create a second Identity when someone joins a second tenant.
- Create Membership globally, then the tenanted User inside
with_tenant. Order matters; a User without a Membership is an orphan. - Join codes are shareable, not emailed magic links. Different secret, different table.
The records
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 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.
Step 1: Identity stays global
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
Sign-in creates a Session for this Identity (magic-link-auth). It does not pick a tenant.
Step 2: Membership is the join
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
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.
Step 3: Tenanted User (when the workspace has a person record)
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
No FK to identities — different database. Copy role from Membership at join time; don't treat the two roles as independent sources of truth.
Step 4: Join in one method
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
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.
Step 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 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.
Step 6: Join codes
Shareable XXXX-XXXX-XXXX (Base58, no 0/O/I/l). Global table. One active code per tenant/project.
class JoinCode < GlobalRecord
belongs_to :tenant # or :project / :account
def redeem_if
with_lock do
increment!(:usage_count) if active? && yield
end
end
end
Redeem:
- Load JoinCode by code (global).
- If not signed in, stash the code in the session and send them through magic-link.
identity.join(tenant)thenredeem_if.- Rate-limit the redeem endpoint.
Don't use a magic-link code as an invite. Don't increment usage until join succeeds.
Anti-Patterns
has_many :sessionson 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.- Authorizing with
Current.identityonly — they might not be a member of this tenant. - Creating User before Membership — User in a shard with no global join row.
- Nebula
Memberon workspace — different tenancy; don't copy that into anactiverecord-tenantedapp. - Emailing the join code in the magic-link mailer — two flows, two templates.
Related
- Sign-in codes and session cookies:
magic-link-auth - Shard switching:
activerecord-tenanted
See reference.md for Herald vs Cortex/Innkeeper vs Fizzy, and test helpers.