Skip to content

One user in many farms: membership table + account switcher #539

Closed as not planned

Description

@mforce

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.

Activity

  1. mforce commented on Aug 16, 2026

    @mforce
    OwnerAuthor

    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 stock AspNetUserRoles(UserId, RoleId) join, since AspNetRoles has 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. DisabledAt and 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:

    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.

  2. mforce commented on Aug 25, 2026

    @mforce
    OwnerAuthor

    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:

    • Per-farm refresh cookies coexist (cluckwork_rt_{accountId:N}, AuthCookies.cs:26), X-Cluckwork-Account selects which one refresh reads (AuthEndpoints.cs:325-337), the server already enumerates the browser's farms (AuthCookies.RefreshCookieAccounts, AuthCookies.cs:90), and it already returns Auth.FarmSelectionRequired when 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.
    • So a session switcher — "switch between sessions this device holds" — needs no schema change at all.
    • This issue is the other product: one human, one identity, many farms. It reverses epic EPIC: Phase 1.6 — Multi-farm tenancy #530's decision 3.

    #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:29 leaves mustChangePassword false per #339; IdentityProvider.cs:1024,1098 clear 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.

  3. mforce commented on Sep 13, 2026

    @mforce
    OwnerAuthor

    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 and AspNetRoles has 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions