Repository navigation
Description
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
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 selection at login, or a token-exchange endpoint that swaps
account_idwithout re-authenticating. - An account switcher in the SPA, which currently never decodes
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 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:
- Credential epoch revocation (Credential epoch: per-request revocation check, deployed inert ahead of the user-admin mutations #364) — a fresh DB read per authenticated request binds the token to the user's
CredentialEpoch. Is a revocation global to the human, or per membership? - Step-up grants (Require recent step-up authentication for privileged user administration #308) —
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.
Activity
mforce commentedon Aug 16, 2026
Update after the epic #530 design review (2026-08-16) — this issue was considered and explicitly rejected for Phase 1.6, and the reason is worth recording here so it is not re-litigated from scratch.
The membership model was the owner's own first instinct: keep the username as the login, add a table linking users to farms. It was rejected once the ownership question was traced through the code.
Three things live on the user row, not on any farm: the password hash,
DisabledAt, and the roles — the last via the stockAspNetUserRoles(UserId, RoleId)join, sinceAspNetRoleshas no account column at all.So with one shared row across farms, today's Set password button (UserEndpoints.cs:47) becomes a cross-tenant escalation: Farm A's owner resets Carlos's password, thereby learns it, and logs into Farm B as him.
DisabledAtand the role have the same shape — Farm A disabling Carlos locks him out of Farm B, and he cannot be a Manager at one farm and a Worker at the other.Making the model safe would mean the membership table carrying role and active-state (straightforward work), plus a policy for who controls the credential (not straightforward). Three options were priced: farm admins keep control only while the person belongs to one farm; farm admins never control passwords for anyone; or separate rows. The middle one is uniform but removes a workflow that works today and replaces it with nothing — there is no email/SMTP reset path in this application, which is exactly why #265 break-glass recovery exists as a CLI verb.
Phase 1.6 therefore ships separate user rows per farm (epic decision 3): same email, different rows, different passwords. A person at two farms logs in twice with different farm codes.
What this issue would still buy, if revisited: one session across farms, an account switcher, and a supported way to correlate one human across farms — which separate rows deliberately do not provide.
What it would still have to solve, and none of it got easier:
AspNetUserRoles, with everyIsInRolepath re-derived from the membership;userIdtoday, which is incidentally tenant-safe only because a user id belongs to exactly one account;FlockScopeGuardreading roles as an authorization input (fix(seed): seeded audit events carry "(unresolved)" as the actor, now visible on record History columns #500), so a mid-session switch changes what the same human may write;Worth revisiting when a real customer has staff shared across farms, or a consultant/agronomist persona appears. Until then the two-logins answer is honest and cheap to reverse: a membership table can be added later without undoing the separate-row model.
mforce commentedon Aug 25, 2026
Cross-reference: #602 was filed for the SPA farm switcher, and it is deliberately kept separate from this issue.
The distinction matters because the mechanics are already mostly shipped and it would be easy to build the wrong one by accident:
cluckwork_rt_{accountId:N},AuthCookies.cs:26),X-Cluckwork-Accountselects which one refresh reads (AuthEndpoints.cs:325-337), the server already enumerates the browser's farms (AuthCookies.RefreshCookieAccounts,AuthCookies.cs:90), and it already returnsAuth.FarmSelectionRequiredwhen several are present (AuthEndpoints.cs:145,352). SPA: per-farm brand palette, keyed by slug so it survives pre-paint #586 shipped the device-local farm-code roster on top of that.#602 also records a constraint this issue should answer before it is specced: on the HTTP path an administrator-set password is not temporary (
CreateUserHandler.cs:29leavesmustChangePasswordfalse per #339;IdentityProvider.cs:1024,1098clear it). Under separate user rows that is contained to one farm. Under the shared identity this issue proposes, it would not be — which is precisely the coupling decision 3 exists to prevent.mforce commentedon Sep 13, 2026
Closing as won't-fix, on epic #530's own record.
Decision 3 (Email identity) chose separate user rows per farm, and lists "A shared user row + membership table (#539)" as the rejected alternative. The epic body gives the reason at length: the password,
DisabledAt, lockout counters and roles all live on the user row andAspNetRoleshas no account column, so one shared row would let Farm A's owner learn the password a person uses at Farm B. Separate rows remove that by construction.The accepted consequences are stated as product contract in #530: no global "this person left the company" operation, per-farm MFA, and no supported way to correlate one human across farms.
Reopen only by reopening decision 3.