Skip to content

fix(proxy): admin-gate permissions on /user/new and /user/update (LIT-4138) - #31998

Merged
yucheng-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_lit_4138_user_new_permissions_gate
Jul 3, 2026
Merged

fix(proxy): admin-gate permissions on /user/new and /user/update (LIT-4138)#31998
yucheng-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_lit_4138_user_new_permissions_gate

Conversation

@yucheng-berri

@yucheng-berri yucheng-berri commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

Linear ticket

Resolves LIT-4138

Pre-Submission checklist

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Type

Bug Fix

Changes

NewUserRequest and UpdateUserRequest inherit permissions from GenerateRequestBase. /user/new passes the field through _update_internal_new_user_params and into generate_key_helper_fn, which persists it on the auto-created key at key_management_endpoints.py:3654. The route allowlist accepts an org admin caller on /user/new when the request body names an organization the caller holds org_admin membership in (_user_is_org_admin in auth_checks_organization.py:135), so a legitimate org admin can mint a key with proxy-wide capabilities such as get_spend_routes that go far beyond their org

This PR wires the existing _check_permissions_caller_permission helper into new_user (after the admin-role check and before generate_key_helper_fn) and into _update_single_user_helper (after the DB-connected check and before the Prisma write). The helper is the same presence-based check introduced in LIT-4092; it keys on data.model_fields_set, so an omitted permissions flows through the model default and an explicit {} / null from a non-admin trips the gate

_update_single_user_helper is shared by /user/update and /user/bulk_update, so both paths inherit the gate. /user/update today rejects permissions at the Prisma layer (the LiteLLM_UserTable schema has no permissions column, so the DB errors before persistence); the gate now returns a consistent 403 upstream and forecloses a future migration silently reopening the class

Six new tests in test_internal_user_endpoints.py cover non-admin explicit-non-empty and explicit-empty on both new_user and _update_single_user_helper, plus the omit-default and admin controls. The four attack-vector tests fail on the pre-fix HEAD and pass on this commit. Full mapped test file (79 tests) green

Screenshots / Proof of Fix

Live proxy on localhost:4010 against Postgres, LITELLM_LICENSE set so get_spend_routes propagation is available. Setup: admin creates user rca-user-b, mints them a key, creates an organization, and adds the user to it with role=org_admin

Attack path on the unfixed HEAD

$ curl -sS -X POST http://localhost:4010/user/new \
    -H "Authorization: Bearer $ORG_ADMIN_KEY" -H "Content-Type: application/json" \
    -d "{\"user_id\":\"rca-victim-b\",\"user_role\":\"internal_user\",\"organization_id\":\"$B_ORG\",\"permissions\":{\"get_spend_routes\":true}}"
{"key":"sk-...fXNkFA","user_id":"rca-victim-b","permissions":{"get_spend_routes":true}, ...}

200 OK; the newly-minted key has get_spend_routes: true written to the DB, granting it proxy-wide /global/spend/* read access

Same call on this commit

$ curl -sS -X POST http://localhost:4010/user/new \
    -H "Authorization: Bearer $ORG_ADMIN_KEY" -H "Content-Type: application/json" \
    -d "{\"user_id\":\"rca-victim-b2\",\"user_role\":\"internal_user\",\"organization_id\":\"$B_ORG\",\"permissions\":{\"get_spend_routes\":true}}"
{"error":{"message":"{'error': 'Only proxy admins can set `permissions`.'}","code":"403"}}

Explicit empty on this commit

$ curl -sS -X POST http://localhost:4010/user/new \
    -H "Authorization: Bearer $ORG_ADMIN_KEY" -H "Content-Type: application/json" \
    -d "{\"user_id\":\"rca-victim-b3\",\"user_role\":\"internal_user\",\"organization_id\":\"$B_ORG\",\"permissions\":{}}"
{"error":{"message":"{'error': 'Only proxy admins can set `permissions`.'}","code":"403"}}

Control: org admin /user/new without a permissions field still succeeds on the fixed proxy

$ curl -sS -X POST http://localhost:4010/user/new \
    -H "Authorization: Bearer $ORG_ADMIN_KEY" -H "Content-Type: application/json" \
    -d "{\"user_id\":\"rca-victim-b4\",\"user_role\":\"internal_user\",\"organization_id\":\"$B_ORG\"}"
user_id: rca-victim-b4 / key ends: Uyg8mQ

Proxy admin (master key) can still write any permissions value on /user/new

$ curl -sS -X POST http://localhost:4010/user/new \
    -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
    -d '{"user_id":"admin-4138-key1","user_role":"internal_user","permissions":{"get_spend_routes":true}}'
user_id: admin-4138-key1 / key ends: Z9J7bw / permissions: {'get_spend_routes': True}

Out of scope, tracked separately

The /user/update path has a pre-existing behavior where an explicit permissions value returns 400 at the Prisma layer because the LiteLLM_UserTable schema has no permissions column. That is not changed here. The helper is still wired into _update_single_user_helper so a future schema addition (or an alternate persistence path for permissions at the user level) cannot silently reopen the class


Note

High Risk
This is an authorization fix in user/key management: incorrect behavior previously allowed elevated key capabilities; the change is narrow but touches security-sensitive proxy admin boundaries.

Overview
Closes a privilege-escalation path where org admins (and other non–proxy-admins) could pass permissions on /user/new and have those values land on the auto-created API key (e.g. get_spend_routes).

/user/new and _update_single_user_helper (shared by /user/update and /user/bulk_update) now call the existing _check_permissions_caller_permission helper before user/key persistence. The check is presence-based (model_fields_set): omitting permissions is allowed; sending any explicit value (including {}) as a non-admin returns 403. Proxy admins are unchanged.

The key-management error text is generalized to “set permissions” (not only “on a key”). Six tests cover reject/allow cases for create and update paths.

Reviewed by Cursor Bugbot for commit c78170b. Bugbot is set up for automated code reviews on this repo. Configure here.

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@greptile-apps

greptile-apps Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR wires the existing _check_permissions_caller_permission helper into the /user/new and /user/update (and /user/bulk_update) paths, and updates the shared error message to remove a key-specific locative so it reads correctly from both user and key endpoints.

  • new_user now calls the gate after the admin-role check and before generate_key_helper_fn, preventing non-admins from writing permissions onto auto-created keys.
  • _update_single_user_helper now calls the gate after the DB-connection check and before the Prisma write, covering both /user/update and /user/bulk_update through the shared helper.
  • Six new unit tests in test_internal_user_endpoints.py cover non-admin explicit-non-empty, explicit-empty, omit-default, and admin controls on both entry points.

Confidence Score: 5/5

Safe to merge — the gate is inserted at the correct positions in both user-creation and user-update paths, relies on the already-tested model_fields_set presence check, and all new tests are properly mocked with no real network calls.

The change is narrowly scoped: two call sites added, one error string trimmed. Both NewUserRequest and UpdateUserRequest inherit from GenerateRequestBase, so the helper's type contract holds. The early-return logic correctly distinguishes omitted-field (default {} is falsy, not in model_fields_set) from explicit-empty-field (in model_fields_set), which the four attack-vector tests verify directly. No pre-existing guards are loosened and no new code paths are introduced.

No files require special attention.

Important Files Changed

Filename Overview
litellm/proxy/management_endpoints/internal_user_endpoints.py Adds _check_permissions_caller_permission at the correct points in new_user and _update_single_user_helper; placement and logic are sound.
litellm/proxy/management_endpoints/key_management_endpoints.py One-line error-message change: removes "on a key" locative so the message is correct when raised from user endpoints. No logic change.
tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py Six new properly-mocked tests covering attack vectors (non-empty, explicit-empty) and controls (omit, admin) for both new_user and _update_single_user_helper.

Reviews (3): Last reviewed commit: "fix(proxy): admin-gate `permissions` on ..." | Re-trigger Greptile

@codecov

codecov Bot commented Jul 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR wires an existing presence-based permissions gate (_check_permissions_caller_permission) into new_user and _update_single_user_helper, closing a path where non-admin callers could set permissions on newly created or updated users.

  • new_user: gate inserted after the admin-role check and before generate_key_helper_fn, so explicit permissions in the request body triggers a 403 for any caller that is not PROXY_ADMIN.
  • _update_single_user_helper: gate inserted before the Prisma write; because both /user/update and /user/bulk_update share this helper, both endpoints inherit the check automatically.
  • Six new mock-only tests verify the four attack-vector paths (non-empty and explicit-empty permissions on both endpoints) as well as the omit-default and admin bypass controls.

Confidence Score: 4/5

Safe to merge; the change is narrowly scoped to two call sites and delegates entirely to a helper already proven in the key-management flow.

The production change is two identical five-line call-site additions. The helper itself is unchanged and covered by existing key-endpoint tests. The new tests are mock-only, exercise all four attack vectors, and distinguish the omit-default from the explicit-empty case via model_fields_set. The only open item is a cosmetic mismatch in the error message wording.

litellm/proxy/management_endpoints/key_management_endpoints.py — the _check_permissions_caller_permission error message says 'on a key' and is now surfaced to callers of user-management endpoints.

Important Files Changed

Filename Overview
litellm/proxy/management_endpoints/internal_user_endpoints.py Adds _check_permissions_caller_permission to new_user (after the admin-role gate) and _update_single_user_helper (before the Prisma write); both placements are correct and the shared helper covers /user/update and /user/bulk_update.
tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py Six new async mock-only tests cover non-admin rejection for non-empty and explicit-empty permissions, pass-through for omitted permissions, and admin bypass on both new_user and _update_single_user_helper.

Reviews (2): Last reviewed commit: "fix(proxy): admin-gate `permissions` on ..." | Re-trigger Greptile

…IT-4138)

`NewUserRequest` and `UpdateUserRequest` inherit `permissions` from
`GenerateRequestBase`. `/user/new` passes the field into
`generate_key_helper_fn` which persists it on the auto-created key,
so an org admin who lands on `/user/new` (the route allowlist accepts
org_admin callers when the request body names an org where they hold
that membership) can mint a key with proxy-wide capabilities such as
`get_spend_routes`.

This wires the existing `_check_permissions_caller_permission` helper
into `new_user` and `_update_single_user_helper`. The helper's presence
check keys on `data.model_fields_set`, so an omitted field flows
through untouched and an explicit `{}` / `null` from a non-admin is
rejected 403 the same as any other value.

`_update_single_user_helper` is shared by `/user/update` and
`/user/bulk_update`, so both paths inherit the gate.

Tests in
`tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py`:

- test_new_user_non_admin_permissions_non_empty_rejected
- test_new_user_non_admin_permissions_explicit_empty_rejected
- test_new_user_non_admin_omits_permissions_succeeds (control)
- test_new_user_admin_can_set_permissions (control)
- test_update_single_user_non_admin_permissions_rejected
- test_update_single_user_non_admin_permissions_explicit_empty_rejected

The four attack-vector tests fail on the pre-fix HEAD and pass on this
commit. Full mapped test file (79 tests) green.
@yucheng-berri
yucheng-berri force-pushed the litellm_lit_4138_user_new_permissions_gate branch from 5bf71a4 to c78170b Compare July 2, 2026 23:46
@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai re-review, amended commit (c78170b) drops the "on a key" locative from the shared error message per your comment. The helper is now called from both key and user endpoints, so "Only proxy admins can set permissions." reads correctly in both contexts. Zero logic change.

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit c78170b. Configure here.

@mateo-berri mateo-berri left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM; thanks!

@yucheng-berri
yucheng-berri merged commit 6dcbac8 into litellm_internal_staging Jul 3, 2026
127 checks passed
@yucheng-berri
yucheng-berri deleted the litellm_lit_4138_user_new_permissions_gate branch July 3, 2026 05:08
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.

2 participants