Follow-up to epic #530 (Phase 1.6 — Multi-farm tenancy). Deliberately out of scope for that epic.
What #530 ships instead
Email is unique within a farm (#532). A person who works for two farms has two separate user rows and logs in twice, entering a different farm code each time. There is no switcher.
What this issue would add
One human, one session, many farms.
- An
AccountMembership join table: user ↔ account ↔ roles. Today ApplicationUser.AccountId is a single scalar column (src/Cluckwork.Infrastructure/Identity/ApplicationUser.cs:6-8) and the JWT's account_id is a straight passthrough of it (JwtTokenService.cs:36) — nothing ever chooses an account.
- Account selection at login, or a token-exchange endpoint that swaps
account_id without re-authenticating.
- An account switcher in the SPA, which currently never decodes
account_id at all (web/src/auth/claims.ts).
Why it is not cheap
Role assignments would move off AspNetUserRoles, which has no account column — roles are global singleton rows shared by every account (AspNetRoles, unique on NormalizedName, no AccountId). Membership roles would have to live in the new table instead, and every IsInRole path re-derived from it.
Three security mechanisms assume one user means one account and each needs re-examining per membership rather than per user:
Plus: what a switch does to an in-flight idempotency key, and whether audit rows record the human or the membership.
Worth revisiting when
A real customer has staff shared across farms, or an agronomist/consultant persona appears. Until then the two-logins answer is honest and costs nothing to reverse — the membership table can be added later without undoing #532, since a composite username is still a valid membership identity.
Follow-up to epic #530 (Phase 1.6 — Multi-farm tenancy). Deliberately out of scope for that epic.
What #530 ships instead
Email is unique within a farm (#532). A person who works for two farms has two separate user rows and logs in twice, entering a different farm code each time. There is no switcher.
What this issue would add
One human, one session, many farms.
AccountMembershipjoin table: user ↔ account ↔ roles. TodayApplicationUser.AccountIdis a single scalar column (src/Cluckwork.Infrastructure/Identity/ApplicationUser.cs:6-8) and the JWT'saccount_idis a straight passthrough of it (JwtTokenService.cs:36) — nothing ever chooses an account.account_idwithout re-authenticating.account_idat all (web/src/auth/claims.ts).Why it is not cheap
Role assignments would move off
AspNetUserRoles, which has no account column — roles are global singleton rows shared by every account (AspNetRoles, unique onNormalizedName, noAccountId). Membership roles would have to live in the new table instead, and everyIsInRolepath re-derived from it.Three security mechanisms assume one user means one account and each needs re-examining per membership rather than per user:
CredentialEpoch. Is a revocation global to the human, or per membership?InMemoryStepUpGrantRegistrykeys byuserId/jti, which is incidentally tenant-safe today only because a user id belongs to exactly one account.FlockScopeGuardreads roles as an authorization input (fix(seed): seeded audit events carry "(unresolved)" as the actor, now visible on record History columns #500), so a switch changes what the same human is allowed to write mid-session.Plus: what a switch does to an in-flight idempotency key, and whether audit rows record the human or the membership.
Worth revisiting when
A real customer has staff shared across farms, or an agronomist/consultant persona appears. Until then the two-logins answer is honest and costs nothing to reverse — the membership table can be added later without undoing #532, since a composite username is still a valid membership identity.