Skip to content

Multi-organization membership - #376

Open
jhodapp wants to merge 24 commits into
mainfrom
feat/multi-org-membership
Open

Multi-organization membership#376
jhodapp wants to merge 24 commits into
mainfrom
feat/multi-org-membership

Conversation

@jhodapp

@jhodapp jhodapp commented Aug 6, 2026

Copy link
Copy Markdown
Member

Description

Lets a user hold roles in more than one organization under a single account, and gives a SuperAdmin (or an org admin, scoped to people they can already see) a way to find an existing user by email and attach them to another organization with an explicit role.

Driving use case, verified end to end through the UI: take the existing Ehab Bandar in Refactor Group, create a BigTable organization, add him there as org Admin, add Jim as a member with Ehab as his coach, and schedule a BigTable session between them.

The schema already allowed multi-org membership. What was missing was any write path: the only way to gain membership was create_by_organization, which always inserts a new users row, and users.email is globally unique. Multiple emails mapping to one profile is explicitly out of scope.

Pairs with refactor-group/refactor-platform-fe#433. They must land together: the session endpoints gained an organization_id parameter the frontend now sends.

Changes

  • entity_api: membership primitives (create, find_by_user_and_organization, delete_by_user_and_organization, admin/org counts) plus shares_administered_organization, the visibility predicate behind scoped lookup.
  • entity_api: scope_roles_to_organization filters the serialized roles array to the org in scope. Without it, GET /organizations/{id}/users would start disclosing every member's other organizations the moment multi-org membership became possible.
  • domain: attach_to_organization (transactional, with optional coach), remove_from_organization, and lookup_by_email_scoped.
  • web: GET /users?email=, POST and DELETE /organizations/{organization_id}/users/{user_id}/role, gated by a new OrganizationAdminAccess extractor.
  • web: migrated create, resend_invite and delete off protect/ middleware onto the same extractor, closing the UserIsAdmin -> AdminAccess item on Refactor authorization middleware to use Axum FromRequestParts extractor pattern #218.
  • Migration m20260806_000000_user_roles_one_role_per_org: partial unique index on (user_id, organization_id) WHERE organization_id IS NOT NULL.
  • New "added to organization" email, sent only after the transaction commits.
  • organization_id filter on GET /users/{user_id}/coaching_sessions and /counts.
  • Removing a member is refused with a structured 409 when they still have coaching sessions in that organization, rather than failing on the coaching_sessions foreign key.

Breaking changes

Testing Strategy

cargo fmt, cargo clippy --workspace --all-targets -- -D warnings, and:

cargo test -p entity_api -p domain -p web --features "domain/mock,web/mock"

domain 237, entity_api 271, web 144, all green. Never cargo test --workspace --features mock (sea-orm/mock drops DatabaseConnection: Clone).

Verified beyond the suite:

  • Anti-enumeration, live. An org admin looking up a real user outside their scope and a nonexistent address return byte-identical []. The scope check runs unconditionally so query count and timing carry no signal.
  • One role per org, against live Postgres. Caleb-style multi-org membership still allowed, same-org duplicates rejected, global SuperAdmin unaffected.
  • Role scoping, by raw JSON. With a user in two orgs, GET /organizations/{id}/users shows only the in-scope org's role. Invisible from the UI, so it is easy to regress.
  • curl as three personas (regular user, org admin, super admin) across happy and sad paths.
  • Playwright against real Chrome for the full driving use case.
  • Removal against a real database, which is the one thing mocks cannot reach. Removing a member with session history returned a 503 before the guard and returns a structured 409 after; a member with no sessions still removes cleanly.
  • Every new assertion was sabotage-checked: break the guarded behavior, confirm the test fails, revert.

Concerns

  • Bootstrapping dead end. A brand-new organization's admin cannot add anyone to it: they can only see people in orgs they administer, and the new org is empty. The driving use case is unaffected because the SuperAdmin does the adding. Three options are written up in docs/implementation-plans/multi-org-membership.md; none is taken here.
  • The added-to-organization email has never been verified end to end. No ADDED_TO_ORGANIZATION_EMAIL_TEMPLATE_ID is configured and no test covers the call site. The Resend template needs creating, and the id set as a GitHub Actions variable (vars., not secrets.).
  • Cross-org bleed remains in GET /users/{user_id}/actions, plus two latent endpoints. Tracked in Scope remaining user-scoped read endpoints by organization (actions, goals, coaching-relationships) #374, deliberately not widened into this PR.
  • The legacy destructive DELETE .../users/{id} now 409s for multi-org users. That is a small disclosure (the caller learns the member belongs to at least one other org, not which), and much better than silently destroying the account.
  • The migration fails loudly if pre-existing duplicate roles exist rather than auto-deleting rows.

jhodapp added 19 commits August 5, 2026 11:45
Living plan for letting a SuperAdmin (or a scoped org admin) attach an
existing user to another organization with an explicit org role, notify
them by email, and remove them from a single org without destroying the
account.

Also gitignores docs/implementation-plans/handoffs/, which holds the
per-phase implementer handoffs (working docs, not version-controlled).
Adds the entity_api and domain foundation for a user belonging to more than
one organization: attaching an existing user to another organization, removing
them from one organization without deleting their account, and looking a user
up by email under a visibility scope.

Scoped lookup. lookup_by_email_scoped returns 0 or 1 results and never
distinguishes "no such email" from "a real user you may not see": an empty
vector is the only not-found signal, and the visibility check
(shares_administered_organization) runs on every path, including unknown
emails and super-admin requesters. shares_administered_organization always
issues exactly two queries, so query count (and therefore response time)
carries no enumeration signal. The UserLookupResult DTO exposes only id, name
and email.

Role scoping. scope_roles_to_organization is a security control, not a
cosmetic filter. users::Model serializes its roles vector to API clients, so
find_by_organization now drops every role belonging to another organization
(keeping global roles) before returning members. Without it, one
organization's admin would learn which other organizations each member
belongs to.

Guards. user_role::create rejects SuperAdmin before any query (422 rather
than the 500 the entity-level before_save produces), remove_from_organization
refuses to remove an organization's last admin, and the global user delete now
refuses a user who belongs to more than one organization. Single-org delete
behavior is unchanged.

Three new error variants flow entity_api -> domain -> web, all mapping to 409.
Phase 1 landed in b6325c6. Notes the two things phase 2 must not drop:
the visibility check that attach_to_organization deliberately omits, and
dropping organization_count from the multi-org conflict response body.

Also defers the last-admin guard on the global user delete, which needs a
per-administered-org check rather than the single-org one already shipped.
Exposes the Phase 1 multi-org membership primitives over HTTP:

- GET /users?email= returns a narrow DTO array of 0 or 1 elements, scoped
  by domain::user_role::lookup_by_email_scoped.
- POST /organizations/:organization_id/users/:user_id/role attaches an
  existing user to an organization with a role.
- DELETE on the same path removes a membership without deleting the account.

The two organization routes are gated by a new OrganizationAdminAccess
extractor, which evaluates the SuperAdmin/Admin predicate in memory against
the roles AuthenticatedUser already hydrated, so it costs no extra
connection.

attach_to_organization performs no requester check, so the handler runs one
via the new domain::user_role::can_administer_user. A caller who may not see
the target gets 404, not 403: a 403 for "this user exists but is not yours to
see" versus a 404 for "no such user" would tell the caller which user ids are
real, the same enumeration oracle the lookup endpoint is built to avoid.
attach_to_organization already returns NotFound for a genuinely missing user,
so both paths converge on 404.

The 409 for UserBelongsToMultipleOrganizations no longer reports the
organization count. The caller cannot act on it and has no right to know how
many other organizations a member belongs to; the message already says what
to do instead.
Phase 2 landed in 452fcf5. Notes for the frontend phase that success
responses are HTTP 200 with the real code in the ApiResponse envelope while
errors carry real HTTP status codes, and records the false-passing-mock
failure mode phase 2 surfaced.
…cess

Moves the last three organization-user routes (create, resend-invite,
delete) off the `protect::organizations::users` middleware and onto the
`OrganizationAdminAccess` extractor, closing the split introduced when the
role routes were added. Part of issue #218.

The `UserIsNotSelf` predicate that guarded self-deletion lived in the
middleware, so the guard now sits inline at the top of `delete`, matching
the one already in `remove_role`. `OrganizationAdminAccess` is declared
ahead of `OrganizationUserAccess` so a non-admin still gets 403 rather
than a 404 for a user id they may not probe.

`UserIsAdmin` and `UserIsNotSelf` stay in `protect::mod`; `UserIsAdmin`
still has callers, and `UserIsNotSelf` is kept for the routes still to be
migrated.

Behavior is pinned by nine regression tests in
`web/src/controller/organization/user_controller_tests.rs`, written and
passing against the pre-migration wiring and unchanged afterwards.
Phase 2b landed in bc0a355. Notes that resend-invite's regression test can
only assert reachability past the auth gate, not success, so phase 5 must
exercise it manually, and records the harmless delete error-precedence change.
Attaching an existing user to an organization now sends them an
"added to organization" email through Resend, mirroring the welcome
email sent to brand-new users. Delivery is best-effort: failures are
logged and never propagate, so a membership that already committed is
never undone by an email problem, and an unconfigured template id
degrades to a logged warning rather than an empty template id.

OrganizationAdminAccess now carries the organizations::Model it already
fetched for its 404 check instead of discarding it and exposing only the
id. Handlers read organization.id, so this removes a would-be duplicate
query on a path that can already consume three DB connections. Behavior
is unchanged: same 400/403/404 and same statuses everywhere.

ADDED_TO_ORGANIZATION_EMAIL_TEMPLATE_ID is threaded through both compose
files and both deploy workflows as a vars. reference.
Phase 3 landed in 49425d9. Records that deleting the notify call from
attach_role leaves all web tests green, so phase 5 must confirm delivery
manually; the same gap predates this branch on the welcome-email path.
Phase 4 landed in fe 3fb7c5fb. Records the hand-verified wire contract
between the repos, which neither suite can check, and the pre-existing
MemberCard delete bug that would have swallowed the new 409.
The old partial unique index keyed on (user_id, organization_id, role),
so a user could hold both User and Admin in the same organization. Role
resolution then depends on row order, and one of the possible answers is
Admin. The application-level check in attach_to_organization is a
check-then-act: two concurrent grants carrying different roles both see
"no existing role" and both insert.

Replace it with a partial unique index on (user_id, organization_id) and
drop the three-column one, which the new index strictly subsumes: a pair
unique on (user_id, organization_id) is necessarily unique with role
appended, and the (user_id, organization_id) prefix still serves the same
lookups. The global-role index for organization_id IS NULL is untouched.

up() refuses, naming every offending pair, when duplicates already exist
rather than deleting rows and silently changing effective privileges.

entity_api::user_role::create now maps a duplicate-key error naming the
new index to UserAlreadyInOrganization, so the racing writer gets the
same 409 as the sequential path instead of a 500.
Adding a member and assigning their coach were two requests, and the
backend sent the invitation email at the end of the first one. A failed
coach assignment therefore left the person invited into an organization
where their coach was never assigned. A row is recoverable, a sent email
is not.

Both member endpoints now accept an optional coach_id and create the
coaching relationship inside the membership's own transaction, so nothing
commits unless both succeed. The email calls stay where they are, after
the domain call: they cannot be rolled back, so they have to follow the
commit.

Also rejects a user as their own coach in entity_api, which benefits the
pre-existing Assign Coach flow too.
Phase 6 (b798e17) enforces one role per user per organization at the
database level. Phase 7 (056b50e, fe f10347cf) folds the optional coach
assignment into the member transaction so the invitation email is never
sent when the assignment fails.
The handler forwarding coach_id was unpinned: hardcoding None at both call
sites, silently discarding every coach assignment, passed all 644 backend
tests. Each layer was tested in isolation and the seam between them was not.

Also adds the org-admin half of the scoped lookup's condition, which only
had super-admin coverage, and the two documented NotFound paths on
remove_from_organization and attach_to_organization.

All four verified by sabotage: each fails when the behavior it guards is
broken.
Live end-to-end testing surfaced a case no unit test could: the admin of a
freshly created organization cannot add anyone to it, because the lookup only
returns users sharing an organization they already administer, and a new
organization is empty. Records the cause and three options for resolving it.
GET /users/{user_id}/coaching_sessions and its counts sibling filtered only
on the caller being coach or coachee of the relationship, so a user who
belongs to more than one organization saw every organization's sessions
regardless of the selected one. The defect predates multi-org membership but
was unreachable then: nobody could belong to two organizations, so a
user-scoped query was accidentally equivalent to an org-scoped one.

Both queries already join coaching_relationships, so this adds a filter on an
existing join rather than a new one. organization_id is optional on both
params structs; omitting it preserves the previous behavior exactly.
Phase 8 fixed the reported Upcoming/Previous list. Records the four session
hook call sites and the /users/{id}/actions endpoint that still return data
from every organization, ranked for follow-up, plus a correction: the actions
kanban is org-safe via a server-side org-scoped endpoint, not client filtering.
@jhodapp jhodapp self-assigned this Aug 6, 2026
@jhodapp jhodapp changed the title feat: multi-organization membership Multi-organization membership Aug 6, 2026
@jhodapp jhodapp added the enhancement Improves existing functionality or feature label Aug 6, 2026
@jhodapp jhodapp moved this to 🏗 In progress in Refactor Coaching Platform Aug 6, 2026
@jhodapp jhodapp added this to the 1.0.0-beta3 milestone Aug 6, 2026
Removing a member deleted their coaching relationships in that organization.
coaching_sessions references coaching_relationships with NO ACTION, so any
member who had ever had a session scheduled hit a foreign key violation and
got a 503. Verified against the live database, where the endpoint worked
only for members with no history at all.

The relationships that do cascade made it worse: goals and coaching session
series would have been destroyed for third parties, since removing a coach
tears down their coachees' relationships too.

Refuses with a structured 409 naming the relationship and session counts,
mirroring organization_not_empty, and leaves the operator to unwind
deliberately. Members with no sessions are still removed as before.

Also from review:

- Duplicate-key detection matches the constraint name via SqlErr instead of
  substrings of the Postgres message, which is locale and version sensitive.
  The users.email site was the looser of the two: any unique index whose
  name contained "email" was reported as a membership collision.
- Pins the wire shape of all four new 409 bodies. The FE branches on these
  and lands in lockstep. One of them pins a non-disclosure: the multi-org
  conflict deliberately omits the organization count.
- Controllers no longer fabricate domain errors to pick a status code.
  WebErrorKind gains Forbidden and NotFound so the web layer stays in its
  own vocabulary.
- The lookup admin gate now requires an organization on the Admin role,
  matching the SuperAdmin arm beside it.
- Indexes LOWER(email), which the case-insensitive lookup needs and which
  users_email_key cannot serve.
- Drops UserIsNotSelf, orphaned when both call sites moved into handlers.
@jhodapp
jhodapp marked this pull request as ready for review August 7, 2026 16:49
@jhodapp jhodapp moved this from 🏗 In progress to Review in Refactor Coaching Platform Aug 7, 2026
@greptile-apps

greptile-apps Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds multi-organization memberships, scoped user lookup and administration, organization-aware session queries, membership notifications, and database constraints for role and email uniqueness.

  • Adds transactional membership attachment and organization-scoped removal.
  • Introduces organization-admin authorization for membership management.
  • Scopes serialized roles and coaching-session queries to the requested organization.
  • Adds migrations enforcing one role per user and organization and case-insensitive email uniqueness.
  • Updates deployment configuration for the new organization-membership email template.

Confidence Score: 4/5

The PR is not yet safe to merge because account deletion can still erase a membership that a concurrent attachment successfully commits.

The new account-deletion checks read the target user’s organization set without locking it, while attachment uses an independent transaction; a role committed after those checks remains reachable by the later unscoped role deletion.

Files Needing Attention: domain/src/user.rs and domain/src/user_role.rs

Important Files Changed

Filename Overview
domain/src/user.rs Adds multi-organization and last-admin guards to global account deletion, but the membership snapshot remains uncoordinated with concurrent attachment.
domain/src/user_role.rs Adds transactional membership attachment, scoped removal, and visibility helpers with organization-specific behavior.
entity_api/src/user_role.rs Adds membership CRUD, organization counts, visibility queries, and row-locking admin counts.
web/src/extractors/organization_admin_access.rs Adds organization-scoped administrator authorization for membership-management routes.
migration/src/m20260806_000000_user_roles_one_role_per_org.rs Adds the partial unique index enforcing one organization-scoped role per user.
migration/src/m20260807_000000_users_lower_email_index.rs Adds case-insensitive email uniqueness to support reliable existing-user lookup.

Sequence Diagram

sequenceDiagram
    participant Admin
    participant Web
    participant Domain
    participant DB
    participant Email
    Admin->>Web: Attach existing user to organization
    Web->>Web: OrganizationAdminAccess
    Web->>Domain: attach_to_organization
    Domain->>DB: Begin transaction
    Domain->>DB: Validate organization and membership
    Domain->>DB: Insert organization role
    opt Coach supplied
        Domain->>DB: Insert coaching relationship
    end
    Domain->>DB: Commit
    Domain->>Email: Send added-to-organization notification
    Domain-->>Web: Organization-scoped user
    Web-->>Admin: Created
Loading

Reviews (5): Last reviewed commit: "refactor(migration): use execute_unprepa..." | Re-trigger Greptile

Comment thread domain/src/user_role.rs
The guard counted admins and then deleted in the same transaction without
locking. Under read committed two concurrent removals both observe two
admins, both pass, and both commit, leaving the organization with no
administrator.

Counting now takes FOR UPDATE on the rows it counts, so the second removal
waits for the first to commit and then re-reads a count of one. Verified
against Postgres: a second session blocks for the full lock_timeout and is
cancelled "while locking tuple in relation user_roles". Postgres rejects
FOR UPDATE alongside an aggregate, so the rows are counted in Rust.

Also groups imports as standard library, external crates, then crate-local
in the two files this branch reordered.
Comment thread domain/src/user.rs Outdated
Deleting an account drops every role row it holds, but the global delete
only refused multi-organization users. A super admin deleting the sole admin
of an organization left that organization with no administrator, going
around the guard the remove-from-organization path enforces.

Deletion now refuses when the account is the last admin of any organization
it belongs to, reusing the same locked count. The organization-count check
moved inside the transaction so both guards see one consistent snapshot.

Not reachable by an organization admin: passing the org gate means the
organization has another admin, and the not-self guard blocks deleting
yourself. The super admin path is the live one.
Comment thread domain/src/user.rs
jhodapp added 2 commits August 7, 2026 14:37
The driving use case, an admin of two organizations moving a member of one
into the other, had no backend test. Only the super admin path was covered,
and that path short-circuits the visibility probe, so it stays green even
when org admins lose the ability entirely. Confirmed by sabotage: forcing
the visibility predicate to false fails both new tests while the super admin
test still passes.

Also pins the conflict an org admin of a single organization always hits,
since every user they can see is already a member. The graph-level claim
behind that is exercised live rather than here, where the membership graph
is whatever the mock returns.
Matches the 39 other migrations that run plain DDL. Statement::from_string
earns its keep only where a result set is read back, as the one-role-per-org
migration does for its duplicate pre-check.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Improves existing functionality or feature

Projects

Status: Review

Development

Successfully merging this pull request may close these issues.

1 participant