Skip to content

fix(rbac): resolve lock-order deadlocks between sessions, users, groups and roles - #31

Merged
VibeSWE merged 6 commits into
mainfrom
fix/rbac-session-lock-order
Oct 13, 2026
Merged

VibeSWE merged 6 commits into
mainfrom
fix/rbac-session-lock-order

Conversation

@VibeSWE

@VibeSWE VibeSWE commented Oct 12, 2026 •

Copy link
Copy Markdown

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::RwLock is 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:

  1. First Basic login reads USERS twice. Users::authorize held its USERS read guard, took SESSIONS (write), then read USERS again in check_auth. Any user write queued in between (an OIDC login or refresh, API key creation) hung the login.
  2. The reported three-way cycle: login (USERS → SESSIONS), a session check (SESSIONS → USERS) and a queued user write.
  3. add_roles / remove_roles / change_password_hash took USERS (write) → SESSIONS (write), the reverse of check_auth. This needs only an admin change and any request with a session.
  4. Querier delete_user held USER_GROUPS (write) while refreshing sessions, the reverse of check_auth.
  5. GET /users and get_user_internal (existed before this PR, found in review) render users while holding USERS, and to_prism_user read USERS again.

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.

  • authorize checks the password with no lock held, then takes SESSIONS (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_hash and put_user take SESSIONS before USERS. This also closes an existing gap where a login could create a session from the user put_user was replacing.
  • Querier group removal updates a copy of each group with no guard held, then writes it back.
  • to_prism_user reads 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):

  • /login stale 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. authorize and exchange_basic_for_cookie now share Users::new_basic_session, which re-reads the user under SESSIONS and checks the hash before tracking the session.
  • Basic tenant lookups (get_user_tenant_from_basic, validate_basic_user_tenant_id, mutate_request_with_basic_user) copy candidates out of USERS and run Argon2 with no lock held.
  • Querier delete_user updates in-memory groups only after put_metadata succeeds, 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 with RwLock::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 --check is clean. cargo clippy --all-targets -- -D warnings fails 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: the GET /users deadlock, aborting on a detected deadlock, and the coverage gaps.

Merge note for PR #29: a trial merge with feat/api-key-expiration has no conflicts, but its 4 api_key_auth_tests tests failed in 4 of 10 runs. Its init_auth_maps creates the api-key-expiry-admin role only inside the ROLES.get_or_init closure, which doesn't run if one of these tests initialized ROLES first. Whichever PR merges second should insert that role with mut_roles() after get_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:

  • been tested to ensure log ingestion and log query works. (Unit tests only; not exercised against a running server.)
  • added comments explaining the "why" and the intent of the code wherever would not be obvious for an unfamiliar reader.
  • added documentation for new or modified features or behaviors. (No user-facing behavior change.)

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

VibeSWE and others added 2 commits October 12, 2026 18:04
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>

@comment-clanker comment-clanker Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/rbac/mod.rs Outdated
Comment thread src/rbac/mod.rs
Comment thread src/rbac/mod.rs
Comment thread src/handlers/http/modal/query/querier_rbac.rs
Comment thread src/rbac/mod.rs
VibeSWE and others added 4 commits October 12, 2026 18:55
…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>
@VibeSWE
VibeSWE merged commit 25655b1 into main Oct 13, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant