Repository navigation
security(users): stop /users/:id writes and login from crossing the tenant boundary (#807) - #832
Merged
Merged
Conversation
…nt (#807) PATCH /users/:id/status, PATCH /users/:id/role and DELETE /users/:id wrote the users row itself. An admin of one organization could deactivate a person in every organization they belong to, or delete the account everywhere, and the role route reported success for a write no session ever reads. No live consumer exists: Settings uses /organization/members/:memberId, and the useUsers() hook that called these routes is imported nowhere. So the routes are removed rather than reimplemented. POST /users also dropped its in-handler admin check, which read the global users.role_id. The RequireRole("admin") route guard already authorizes it from the session's role in the active organization. Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
…rew access (#807) Login only tried the user's default organization. If that organization deactivated or revoked the membership, the sign-in was refused outright, even when another organization still granted access. So an admin of A could lock a person out of B just by A being their default. When the default membership no longer grants access, login now picks the earliest-joined active membership in another active organization. With no such membership the refusal is unchanged. Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
…s alone (#807) Drives the real membership route in tenant A, then signs the person in through the real login use case: they land in B with B's role, and the users row and the B membership are unchanged. Also proves users.role_id = admin grants nothing, and pins the three removed /users/:id routes out of the router. Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
useUsers() was imported nowhere and was the only client of the removed /users/:id status, role and delete routes. Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
This was referenced Sep 29, 2026
…d} routes (#807) TestNoStaleDecisions failed: /api/v1/users/{id} no longer matches a live route. The /api/v1/users/* entry now covers only the avatar read, so it cites the avatar's cross-tenant test. Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
Member
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #807
What changed
Criterion 1: removed, not reimplemented.
PATCH /users/:id/status,PATCH /users/:id/roleandDELETE /users/:idare gone fromcmd/server/main.go, along with their handlers ininternal/handler/user_handler.go. No live consumer exists. Settings uses/organization/members/:memberId/{role,status}, and the only client,useUsers(), was imported nowhere.A second route to the same lockout, fixed in login. Sign-in only ever tried the user's default organization. If that organization deactivated or revoked the membership, sign-in was refused, even when another organization still granted access. So an admin of A could still lock someone out of B through the legitimate membership route, just by A being their default.
internal/application/auth/login.gonow falls back to the earliest-joined active membership in another active organization. When there is none, the refusal is unchanged.Criterion 3.
POST /usersdropped its in-handlerusers.role_idcheck. TheRequireRole("admin")guard already authorizes it from the session's role in the active organization.GET /usershad already been cleaned the same way.Criterion 4.
useUsers()andAdminUserwere removed fromfrontend/src/features/settings/adminData.ts.Criterion 6.
docs/openapi.yamlnever listed the three routes (it has only/users/me,/users/me/avatarand/users/{id}/avatar), so it needed no change.authz_route_coverage_test.gogainsTestProtectedRoutes_GlobalAccountWritesStayRemoved, which fails if any of the three is mounted again under any guard. I checked that it fails against the oldmain.go.Verification
Cross-tenant test (criterion 2) and the
users.role_idtest (criterion 3). This is the real membership route, the real login use case and the same database:With the login fix reverted, the same test fails with
sign-in of dual@both.io refused: your access to this organization has been revoked.Wider suite:
Live run on Postgres 16 (throwaway containers). One person is
userin A (their default) androotin B.On
master, with an A admin whoseusers.role_idpoints at a role namedadmin:On this branch:
Frontend:
npx eslintandnpx prettier --checkare clean onadminData.ts.Honest remainders
npm run type-check, is not green, and the cause is onmaster, not this branch. Master itself fails on two missing imports from the Premium visual overhaul — RareUI + transitions.dev #751 merges:MembersView.tsx(487,30): Cannot find name 'DeleteButton'andCreateRiskModal.tsx(241,16): Cannot find name 'ScrollProgress'. With this PR's change stashed, the same two errors appear. This PR adds none. I did not fix them here because they belong to Premium visual overhaul — RareUI + transitions.dev #751.Admin, and the handlers compared againstadmin. So on a default install these routes answered 403, or 500 for the seeded root admin (nilRole). The defect was reachable wherever a lowercaseadminrole row exists, as shown above. It was latent, not dead.Success,NotFound(a foreign id and an invented id give byte-identical 404s) andUnauthorized(a plain member gets 403) inorganization_member_e2e_test.go. The login fallback addsTestLoginFallback_Success_…,_NotFound_…and_Unauthorized_…./teams. The seven/teamshandlers still contain ausers.role_idcheck. It is never reached today, because they resolve the caller by token id and 404 first. Removing it would switch on a feature nobody has reviewed for tenant isolation. Tracked in security(teams): /teams handlers still read the global users.role_id and resolve the caller by token id #830.