Repository navigation
fix(rbac): resolve lock-order deadlocks between sessions, users, groups and roles - #31
Conversation
A first Basic login held its USERS read guard while taking SESSIONS(write) and then read USERS again inside check_auth. With parking_lot's task-fair RwLock, any USERS writer queued in between deadlocked the login, and a concurrent session check (SESSIONS read -> USERS read) formed a three-way cycle. Admin writes (add_roles, remove_roles, change_password_hash) took USERS(write) -> SESSIONS(write), and querier user deletion took USER_GROUPS(write) -> SESSIONS(write); both are the reverse of check_auth. Document the order SESSIONS -> USERS -> USER_GROUPS -> ROLES -> DEFAULT_ROLE in rbac::map and make every violator follow it: - authorize verifies the password without holding SESSIONS, then re-reads the user under SESSIONS(write) and only tracks the session if the stored hash is unchanged, so racing password changes, role changes and deletions cannot leave a stale session. - add_roles, remove_roles, change_password_hash and put_user take SESSIONS before USERS. - querier delete_user updates a copy of each group with no map guard held, then stores it back. Bounded-wait tests reproduce each cycle deterministically (they timed out before this change) and cover first login, session checks with group permissions and concurrent user writes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`GET /users` and `get_user_internal` render users while holding a USERS read guard, and `to_prism_user` read USERS again through `Users.get_role` and `Users.get_user_groups`. A USERS writer queued in between deadlocked both threads; since `put_user` now holds SESSIONS while writing USERS, that would also stall every session check. Both callers pass the map entry itself, so read its roles and groups directly. Review follow-ups to the lock-order tests: - abort the test process when a deadlock is detected, since the stuck threads keep holding the process-wide maps and would hang every later test; report a panicked worker as a panic, not a deadlock - wait for the user to actually be removed in the deletion test - cover a role change and a same-password reset during a first login Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Review: RBAC lock-order fix
The fix holds up. Four independent reviewers (one GPT-6.1-Sol, three Opus 5.5) found no remaining lock-order inversion and no double acquisition of SESSIONS, USERS, USER_GROUPS, ROLES or DEFAULT_ROLE anywhere in the crate, including callers outside the diff. The authorize re-check is correct: guards are released before check_auth and on continue, and running out of retries returns UnAuthorized. to_prism_user output is unchanged, because both callers pass the map entry itself.
Tests: 12 of the 16 lock_order_tests abort on base bd882956 and all 16 pass at head. That held over 30 runs, 5 more under 2× CPU oversubscription (worst case about 1.2 s for the module against the 20 s deadline), and 3 full lib runs (635 tests). Removing the password_hash == verified_hash re-check makes password_change_during_first_login_leaves_no_stale_session fail, so that test catches the race. No other test module touches the global RBAC maps.
One medium-severity issue and four low-severity ones, each in its own thread below. The main one is that /login still has the stale-session race this PR closes in authorize.
…s unlocked `/login` exchanged Basic credentials for a cookie session using a copy of the user taken without SESSIONS, so a password reset, role removal or deletion landing during its Argon2 check left a session with the old credentials and roles. Move the verify-then-re-read-under-SESSIONS step out of `authorize` into `Users::new_basic_session` and use it from both `authorize` and `exchange_basic_for_cookie`, dropping the redundant unlocked `get_user` + `verify_password` in `oidc::login`. `get_user_tenant_from_basic`, `validate_basic_user_tenant_id` and `mutate_request_with_basic_user` ran Argon2 while holding USERS(read), stalling every queued USERS writer and the readers behind it. They now copy out the candidate users and verify after dropping the guard, with the same results and no extra verifications. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add replacing_a_user_during_first_login_leaves_no_stale_session, which stops put_user between removing the user's sessions and storing the replacement and asserts SESSIONS is still held, so a first login cannot track a session from the old credentials in that gap. It fails when put_user releases SESSIONS between the two steps. Reword the "Reproduction" comments: put_user never held both maps before, so its deadlock test only guards the lock order. Prefix the deadlock abort message with the test thread's name, since libtest prints no result after an abort. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
`cookie_login` called `Users::new_basic_session` directly, so reverting `exchange_basic_for_cookie` to the unlocked get_user + verify_password + new_session path kept every test green. The helper now runs `exchange_basic_for_cookie` (widened to pub(crate)) and the tests check the cookie it returns: the session it names belongs to the right user and tenant, the Basic session /login authorized through is gone, and a rejected or raced login leaves no session behind. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Description
Fixes deadlocks between the process-wide RBAC maps in
src/rbac/map.rs(SESSIONS,USERS,USER_GROUPS,ROLES). These were found when the PR #29 test run hung.parking_lot::RwLockis task-fair: a queued writer blocks new readers, including a second read by a thread that already holds one. Five cycles, each reproduced by a test that timed out before the fix:USERStwice.Users::authorizeheld itsUSERSread guard, tookSESSIONS(write), then readUSERSagain incheck_auth. Any user write queued in between (an OIDC login or refresh, API key creation) hung the login.USERS→SESSIONS), a session check (SESSIONS→USERS) and a queued user write.add_roles/remove_roles/change_password_hashtookUSERS(write) →SESSIONS(write), the reverse ofcheck_auth. This needs only an admin change and any request with a session.delete_userheldUSER_GROUPS(write) while refreshing sessions, the reverse ofcheck_auth.GET /usersandget_user_internal(existed before this PR, found in review) render users while holdingUSERS, andto_prism_userreadUSERSagain.Fix: one lock order, documented in
rbac/map.rs: SESSIONS → USERS → USER_GROUPS → ROLES → DEFAULT_ROLE, with no second guard on a map a thread already holds.authorizechecks the password with no lock held, then takesSESSIONS(write), re-reads the user and creates the session only if the stored password hash is unchanged (otherwise it checks again, up to 3 times). A password change, role change or deletion during a login therefore can't leave a stale session, the same guarantee the old nesting gave by accident. Permissions come from the re-read user.add_roles,remove_roles,change_password_hashandput_usertakeSESSIONSbeforeUSERS. This also closes an existing gap where a login could create a session from the userput_userwas replacing.to_prism_userreads roles and groups from the user record it is given; both callers already pass the map entry, so output is identical.Review follow-ups (in the same PR):
/loginstale session: the cookie exchange built its 1-hour session from an unlocked copy of the user, so a password reset, role removal or deletion during login could leave a stale cookie.authorizeandexchange_basic_for_cookienow shareUsers::new_basic_session, which re-reads the user underSESSIONSand checks the hash before tracking the session.get_user_tenant_from_basic,validate_basic_user_tenant_id,mutate_request_with_basic_user) copy candidates out ofUSERSand run Argon2 with no lock held.delete_userupdates in-memory groups only afterput_metadatasucceeds, removing just that member.No other behavior changes.
Tests:
rbac::lock_order_tests(20 tests). Each test forces the interleaving at lock boundaries, detects a queued writer withRwLock::is_locked_exclusive(), and waits at most 20 s. On a detected deadlock it aborts the test process, because the stuck threads would otherwise hang every later test. Coverage: each cycle above, first Basic login, sessions using group permissions, a password change, role change, same-password reset or deletion during a first login, and a stress test running logins, session checks and user writes concurrently. Before the fix, that stress test deadlocked in 3 of 3 runs.Validation:
cargo test: 640 passed, 0 failed. The lock-order module passed in each of 20 consecutive runs. Each new regression test fails when its fix is reverted.cargo fmt --checkis clean.cargo clippy --all-targets -- -D warningsfails only on existing lints in files this PR doesn't touch (known_schema.rs,staging/reader.rs,streams.rs,handlers/http/mod.rs). Reviewed by me and two GPT-6.1-Sol reviewers, and their findings are addressed: theGET /usersdeadlock, aborting on a detected deadlock, and the coverage gaps.Merge note for PR #29: a trial merge with
feat/api-key-expirationhas no conflicts, but its 4api_key_auth_teststests failed in 4 of 10 runs. Itsinit_auth_mapscreates theapi-key-expiry-adminrole only inside theROLES.get_or_initclosure, which doesn't run if one of these tests initializedROLESfirst. Whichever PR merges second should insert that role withmut_roles()afterget_or_init. Ideally both modules would share one test setup helper and one lock.Not covered: running out of retries in
authorize(three hash changes during one login) has no deterministic test. The order is a convention; debug-build lock-order assertions would be a good follow-up.Write-up: Mayfly wiki, "RBAC lock-order deadlock" under the Team page.
This PR has:
🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need.