Repository navigation
Reject protected role assignments for API keys and querier RBAC - #67
Conversation
Expected RED until fixed: - apikeys validate_roles_rejects_internal_role and validate_roles_rejects_super_admin_privilege_role: validate_roles only checks that roles exist, so an internal role or one holding SuperAdmin is accepted (Ok(())) instead of RBACError::ProtectedRole. - apikeys tenant_admin_cannot_create_super_admin_api_key: a tenant admin (Admin, not SuperAdmin) reaches create_api_key with role super-admin or a SuperAdmin user role and gets past the role check. A duplicate key name stops the vulnerable handler with 409 before storage; the fix should return 400 ProtectedRole first. - querier_role rejected_super_admin_update_leaves_live_role_unchanged: put writes the body into the live roles map before the SuperAdmin check, so the request returns SuperAdminPrivilege but the in-memory role now holds SuperAdmin. Expected GREEN (guards): - apikeys validate_roles_accepts_ordinary_role. - role put_tests rejected_super_admin_update_leaves_live_role_unchanged: the standalone handler validates the body before touching any state. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Cover Internal and SuperAdmin roles on querier user creation and role addition, plus Internal role PUT rejection without live-map mutation. The assignment tests currently reach PARSEABLE and terminate on CLI parsing instead of rejecting before storage. The Internal PUT test currently returns SuperAdminPrivilege rather than ProtectedRole. Co-Authored-By: GPT-6.1 Sol <[email protected]>
The previous Internal-role fixture also held SuperAdmin, so rejecting only SuperAdmin privileges could incorrectly satisfy both regressions. Use an Internal role with Admin privileges for this test while preserving the realistic super-admin fixture for the handler test. Confirmed RED: validation still returns Ok(()). Co-Authored-By: GPT-6.1 Sol <[email protected]>
Reject Internal and SuperAdmin roles with ProtectedRole after checking role existence. API key creation validates before duplicate-key detection and metadata access. Apply the same checks to querier user creation and role addition; validate creation under UPDATE_LOCK before reading metadata. Co-Authored-By: GPT-6.1 Sol <[email protected]>
Reject Internal bodies and SuperAdmin privileges before metadata access. Inspect existing roles through a read lock and insert into the live map only after metadata persistence succeeds. Serialize the local update with UPDATE_LOCK as standalone PUT does, releasing it before cluster synchronization. Co-Authored-By: GPT-6.1 Sol <[email protected]>
The RBAC regression fixtures initialize ROLES without the middleware test role. The middleware helper previously installed its role only inside get_or_init, yielding four repeatable 403 failures in the full suite. Insert that fixture role independently of global initialization. Confirmed RED twice (870 passed, 4 failed, 2 ignored) and GREEN for all 19 RBAC/authentication tests together. Co-Authored-By: GPT-6.1 Sol <[email protected]>
…ation Resolve conflicts with #60: keep both the privilege filter tests and the role escalation tests in apikeys.rs, take main's middleware test setup, and have the role escalation tests hold map::TEST_SERIAL via #60's helpers. Co-Authored-By: Claude Opus 5.5 <[email protected]>
These tests insert into USERS and ROLES, which rbac::lock_order_tests watch for queued writers. Hold map::TEST_SERIAL like the other modules' tests do. Co-Authored-By: Claude Opus 5.5 <[email protected]>
VibeSWE
left a comment
There was a problem hiding this comment.
Adversarial review of PR #67 (at 36af50e5)
Three independent reviewers: two Sol 6.1 (authorization completeness; concurrency and state consistency) and one Opus 5.5 (regressions, HTTP contract, merge correctness). The Opus test-quality reviewer failed twice for infrastructure reasons, so its mutation testing was run directly instead (results below). Every finding was checked against the code at the PR head before posting.
Verdict: no regressions and no bypass of the new checks. Four small in-scope items (threads below) plus a description update. The bigger problems found are all pre-existing and are listed separately.
Findings in this PR
| Severity | Finding |
|---|---|
| Low | The "existing role is internal" check in querier_role::put has no test; removing it passes every test (thread) |
| Low | The querier user endpoints and API key creation still skip standalone's protected-user checks (thread) |
| Low | add_roles_to_user on Query nodes checks unknown roles before protected roles, the reverse of standalone (thread) |
| Nit | The querier tests catch a regression by crashing the test binary, not by failing an assertion (thread) |
| Low | The PR description is out of date after the merge with #60, and doesn't say that super-admin callers are now also rejected |
Description details:
- The "Middleware test fix" bullet now belongs to #60 (
middleware.rsis no longer in this diff). - The merge-conflict note is resolved (
9187a42d). - The verification numbers predate the merge.
- Not stated: a real super-admin creating a key with
roles: ["super-admin"]now gets 400, just as standalonepost_useralready does. Existing keys holding these roles keep working.
Mutation testing
| Mutation | Result |
|---|---|
| API keys: remove the protected-role check | ❌ caught by 3 role_escalation tests |
| API keys: keep only the SuperAdmin half / only the Internal half | ❌ caught (rejects_internal_role / rejects_super_admin_privilege_role + handler test) |
Querier post_user: remove the check |
❌ caught, but only by the test binary exiting with code 2 (see Nit thread) |
Querier add_roles_to_user: remove the check / keep only SuperAdmin |
❌ caught, same way |
querier_role::put: remove the internal-body check |
❌ caught (rejected_internal_update_leaves_live_role_unchanged) |
querier_role::put: remove the SuperAdmin-body check |
❌ caught, by the test binary exiting with code 2 |
querier_role::put: write the live map before the checks (the original bug) |
❌ caught by both querier role tests |
querier_role::put: remove the existing-internal-role check |
✅ survived (thread) |
Problems that predate this PR (not blocking; follow-ups)
- High: the ingestor sync endpoints accept protected roles from ordinary admins.
IntraClusterRequestlets every request through whenCLUSTER_SECRETis unset (middleware.rs:796), and nothing outside tests ever sets it in this fork.- That leaves
PATCH /user/{id}/role/sync/addandPUT /role/{name}/syncon ingestors behind only the ordinaryPutUserRoles/PutRoleauthorization. Neither handler rejects internal or SuperAdmin roles (ingestor_rbac.rs:107,ingestor_role.rs:52). - Fixing this properly means enforcing a cluster secret, which changes deployment requirements, so it needs its own decision.
- Medium: querier role DELETE never takes
UPDATE_LOCK(querier_role.rs:146); standalone does (role.rs:155). It can overwrite a concurrentpost_user's metadata write. - Medium: role syncs to ingestors can be applied out of order.
putreleasesUPDATE_LOCKbeforesync_role_update, and receivers install whatever arrives, so two quick PUTs can leave ingestors on the older role. Failed syncs are only logged. - Medium: a staging-write failure in
put_metadataleaves durable metadata changed while memory and sessions keep the old state. - Low: querier
post_gen_passwordskips the protected-user check that standalone has (rbac.rs:243), and it reads metadata before taking the lock. It's Low because nothing in this fork createsprotectedusers. - Nit:
frontend/src/lib/demo.tsstill accepts internal and SuperAdmin roles for keys and users, so demo mode allows what the backend now rejects.
Checked and fine
- HTTP contract:
- Every new rejection returns 400.
- API key check order is unknown role → protected → 409 duplicate → storage.
- 401/403 still come before any of these checks.
- The frontend's role picker already hides internal roles.
querier_role::putvs standalonerole::put: same body checks and errors, and the live map is written only afterput_metadata.- Locks and concurrency:
UPDATE_LOCKis held through persistence, publication and session refresh, then dropped before the sync.- No re-entrant
UPDATE_LOCKacquisition, and no parking_lot guard held across an.await. - Session refresh follows the documented SESSIONS → USERS → ROLES order.
- Tenants: validation uses the authenticated tenant, and the tenant header can't redirect it.
- Merge
9187a42d: it keeps #60's filter and tests, the role escalation tests, and main's middleware setup, with nothing dropped or duplicated.36af50e5only addsTEST_SERIALguards. - Local run: fmt and clippy are clean, and
cargo test --libpassed 1005 tests in each of two runs.
Cover an ordinary user-typed update targeting an internal role, including the protected-role error, HTTP 400, and unchanged live role. Co-Authored-By: GPT-6.1 Sol <[email protected]>
Explain that removing a protected-role check reaches storage and initializes PARSEABLE, causing clap to exit the test binary with code 2. Co-Authored-By: GPT-6.1 Sol <[email protected]>
…PI keys Querier post_user and add_roles_to_user, and apikeys::validate_roles, do not yet reject roles held by protected users or protected target users, and querier add_roles_to_user reports missing roles before protected ones. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…PI keys Querier post_user and add_roles_to_user, and API key role validation, now reject roles held by protected users, and add_roles_to_user rejects a protected target user. add_roles_to_user runs the protected checks before the unknown-role check, matching standalone. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…y_protected_user Co-Authored-By: Claude Opus 5.5 <[email protected]>
Completes the port of the standalone protected-user checks to the Query node's user endpoints. Co-Authored-By: Claude Opus 5.5 <[email protected]>
VibeSWE
left a comment
There was a problem hiding this comment.
Round 2 adversarial review (at ad4b09d2, fixes now in 8988e955)
One Sol 6.1 and one Opus 5.5 reviewer read the four fix commits against the whole PR. Neither found anything blocking. Both confirmed:
validate_rolesdrops its ROLES guard before the helper takes USERS;- querier
add_roles_to_usernow checks in the same order as standalone; - every new rejection is a 400, and every new map-writing test holds
TEST_SERIAL.
Findings, all fixed before posting:
| Severity | Finding | Fix |
|---|---|---|
| Low | Dropping user.protected && from the new helper, or mishandling the default tenant, passed every test |
7c4630f9: helper tests for unprotected holders, disjoint roles, other tenants, and no tenant = default tenant. Both mutations now fail |
| Low | Querier delete_user and remove_roles_from_user still lacked standalone's protected-user check |
8988e955, with red tests first |
| Nit | Querier post_user returns its 400s before reading storage; standalone reads metadata first |
Kept: it was already so for the existing checks and is the safer order |
Mutation runs, all caught by assertions: removing the protected-holder check in validate_roles; disabling the Internal/SuperAdmin, protected-holder and protected-target checks in querier add_roles_to_user; the two helper mutations above.
Local at 8988e955: fmt and clippy -- -D warnings clean; cargo test --lib 1018 passed, 0 failed.
Summary
Fixes privilege-escalation paths found in the review of #60. All of them existed before #60 and were fixed test-first: each has a failing test committed before its fix.
POST /api/prism/v1/apikeys→validate_rolesonly checked that each role exists. The native-user path inrbac.rsrejects roles that areRoleType::Internalor hold SuperAdmin, and roles held by a protected user; this path didn't. A tenant admin could create a key withroles: ["super-admin"], which carriesAction::SuperAdmin.validate_rolesnow returnsRBACError::ProtectedRole(400) for those roles. This runs after the unknown-role check and before the duplicate-name check and any storage access.modal/query/querier_rbac.rs:post_userandadd_roles_to_usernow reject internal, SuperAdmin and protected-user-held roles withProtectedRole(400).add_roles_to_user,remove_roles_from_useranddelete_userreject a protected target user withProtectedUser(400).add_roles_to_userruns these checks before the unknown-role check, in the same order as standalone, so both servers answer a mixed request the same way.post_userthe update lock is now taken before validation.querier_role::putwrote*role = bodyinto the live role map before thedeny_super_admin()check, so a rejected PUT that added SuperAdmin still changed the in-memory role that authorization reads. It also never rejected a body markedinternal. It now works like standalonerole::put:put_metadatasucceeds;UPDATE_LOCK, which Standalone already did, and releases it before the cluster sync.The protected-user checks use a new
Users::any_role_held_by_protected_userhelper (rbac/mod.rs). It takes the USERS lock, so the API key path releases its ROLES guard first, per the lock order.Behaviour change: a real super-admin creating a key with
roles: ["super-admin"]now gets 400, just as standalonepost_useralready does. Existing keys holding these roles keep working.Tests
validate_rolesrejects internal roles, SuperAdmin roles and roles held by a protected user as separate cases, and still accepts ordinary roles. A handler-level test has a tenant admin try to create a super-admin key.rbac::map::TEST_SERIAL.PARSEABLEfrom the test argv). The module comments explain this.Verification (at
8988e955)cargo fmt --check✅cargo clippy -- -D warnings✅cargo test --lib(P_BUILD_NO_UI=1): 1018 passed, 0 failed, 2 ignoredvalidate_roles,add_roles_to_userand the helper is caught by an assertion when removed or reordered.Notes
super-adminrole only exists in the default tenant. In other tenants, the API key escalation needs a role holding SuperAdmin to exist there, and the querier role-update bug was how such a role could get into memory.CLUSTER_SECRETis unset; querier role DELETE doesn't takeUPDATE_LOCK; role syncs can apply out of order; a staging-write failure input_metadataleaves memory stale; querierpost_gen_passwordlacks the protected-user check; demo mode (frontend/src/lib/demo.ts) is fixed in a separate PR.🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need.