fix(proxy): admin-gate permissions on /key/update and /key/regenerate (LIT-4092) - #31810
Conversation
Greptile SummaryThis PR extends the
Confidence Score: 5/5Safe to merge — the change adds missing guards on two write endpoints without altering any existing behavior for callers who omit the field. The refactor is narrow and mechanically correct: No files require special attention; both changed files are straightforward and well-tested.
|
| Filename | Overview |
|---|---|
| litellm/proxy/management_endpoints/key_management_endpoints.py | Refactors _check_permissions_caller_permission to accept the full request model and use model_fields_set for presence detection; wires the gate into _validate_update_key_data and regenerate_key_fn |
| tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py | Adds nine new tests covering the non-admin rejection matrix (explicit non-empty, explicit empty, explicit null, omit-field) for generate, update, and regenerate paths, plus the enterprise-gate ordering invariant |
Reviews (4): Last reviewed commit: "fix(proxy): admin-gate `permissions` on ..." | Re-trigger Greptile
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Greptile SummaryThis PR adds the
Confidence Score: 4/5Safe to merge for the targeted update and regenerate endpoints; the one non-admin client-visible change (rejecting explicit The guard logic is correct across all explicit/omit/null combinations, the new call sites are placed before any DB work, and the test suite covers every documented attack scenario plus controls. The only concern is a deliberate behavior shift on the create path for non-admins who send an explicit empty Both changed files look solid;
|
| Filename | Overview |
|---|---|
| litellm/proxy/management_endpoints/key_management_endpoints.py | Guard function signature changed to accept full data object for model_fields_set inspection; new call sites added to _validate_update_key_data and regenerate_key_fn in the correct pre-DB position; logic is sound for all explicit/omit combinations. |
| tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py | Eight new mock-only tests covering all attack vectors (non-empty grant, explicit-empty clear, null clear) plus controls (omit-field non-admin pass, admin unrestricted); no real network calls; no existing tests weakened. |
Reviews (2): Last reviewed commit: "fix(proxy): gate `permissions` admin-onl..." | Re-trigger Greptile
72e9551 to
e1afc85
Compare
|
bugbot run |
There was a problem hiding this comment.
✅ 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 e1afc85. Configure here.
e1afc85 to
e4076a3
Compare
|
@greptileai please re-review. The amended commit (e4076a3) adds |
|
bugbot run |
There was a problem hiding this comment.
✅ 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 e4076a3. Configure here.
…te (LIT-4092) The `_check_permissions_caller_permission` helper introduced in #31469 was only wired into `_common_key_generation_helper`. This change wires it into `_validate_update_key_data` and `regenerate_key_fn` so the three write paths share the admin gate, and refactors the helper to accept the full request model so it can key on `"permissions" in data.model_fields_set` rather than truthiness. The presence check keeps the model-level omit default flowing through unchanged while treating any explicit value (including `{}` / `null`) as an admin-only write. In `regenerate_key_fn` the gate is placed before the `premium_user` license check so the rejection is consistent across premium and non-premium deployments. That ordering is pinned by `test_regenerate_key_non_admin_permissions_rejected_before_enterprise_gate` Tests in tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py: - test_update_key_non_admin_permissions_non_empty_rejected - test_update_key_non_admin_permissions_explicit_empty_rejected - test_update_key_non_admin_permissions_explicit_null_rejected - test_update_key_non_admin_omits_permissions_succeeds (control) - test_update_key_admin_can_set_permissions (control) - test_regenerate_key_non_admin_permissions_rejected - test_regenerate_key_non_admin_permissions_explicit_empty_rejected - test_permissions_explicit_empty_rejected_for_non_admin_on_generate - test_regenerate_key_non_admin_permissions_rejected_before_enterprise_gate Mutation-killed against gate removal on either wire, against reverting the helper to a truthiness check, and against reordering the gate past the enterprise-license check
permissions admin-only on /key/update + /key/regenerate (LIT-4092)permissions on /key/update and /key/regenerate (LIT-4092)
|
@greptileai re-review please, force-pushed with reworked docstrings, commit message, and PR body. Same code change, less prose describing the pre-fix behavior. New commit is 79a6301. |
e4076a3 to
79a6301
Compare
|
bugbot run |
There was a problem hiding this comment.
✅ 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 79a6301. Configure here.
…te (LIT-4092) (BerriAI#31810) The `_check_permissions_caller_permission` helper introduced in BerriAI#31469 was only wired into `_common_key_generation_helper`. This change wires it into `_validate_update_key_data` and `regenerate_key_fn` so the three write paths share the admin gate, and refactors the helper to accept the full request model so it can key on `"permissions" in data.model_fields_set` rather than truthiness. The presence check keeps the model-level omit default flowing through unchanged while treating any explicit value (including `{}` / `null`) as an admin-only write. In `regenerate_key_fn` the gate is placed before the `premium_user` license check so the rejection is consistent across premium and non-premium deployments. That ordering is pinned by `test_regenerate_key_non_admin_permissions_rejected_before_enterprise_gate` Tests in tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py: - test_update_key_non_admin_permissions_non_empty_rejected - test_update_key_non_admin_permissions_explicit_empty_rejected - test_update_key_non_admin_permissions_explicit_null_rejected - test_update_key_non_admin_omits_permissions_succeeds (control) - test_update_key_admin_can_set_permissions (control) - test_regenerate_key_non_admin_permissions_rejected - test_regenerate_key_non_admin_permissions_explicit_empty_rejected - test_permissions_explicit_empty_rejected_for_non_admin_on_generate - test_regenerate_key_non_admin_permissions_rejected_before_enterprise_gate Mutation-killed against gate removal on either wire, against reverting the helper to a truthiness check, and against reordering the gate past the enterprise-license check
…ned_destinations Resolve conflicts from staging advancing with the MCP tools/list span work (#31525) and the key permissions admin-gate (#31810): - otel logger.py: keep the PR's multi-span (carrier.spans / emit_fanout) fan-out, adopt staging's _seed_identity_baggage helper in the deferred path - context.py: union ContextVar+Token and TYPE_CHECKING+Mapping imports - key_management_endpoints.py: hoist the regenerate team lookup to the top so both staging's object_permission gate and the PR's logging_exporters gate see it - tests: union the new imports/mocks and keep both sides' added tests
…te (LIT-4092) (BerriAI#31810) The `_check_permissions_caller_permission` helper introduced in BerriAI#31469 was only wired into `_common_key_generation_helper`. This change wires it into `_validate_update_key_data` and `regenerate_key_fn` so the three write paths share the admin gate, and refactors the helper to accept the full request model so it can key on `"permissions" in data.model_fields_set` rather than truthiness. The presence check keeps the model-level omit default flowing through unchanged while treating any explicit value (including `{}` / `null`) as an admin-only write. In `regenerate_key_fn` the gate is placed before the `premium_user` license check so the rejection is consistent across premium and non-premium deployments. That ordering is pinned by `test_regenerate_key_non_admin_permissions_rejected_before_enterprise_gate` Tests in tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py: - test_update_key_non_admin_permissions_non_empty_rejected - test_update_key_non_admin_permissions_explicit_empty_rejected - test_update_key_non_admin_permissions_explicit_null_rejected - test_update_key_non_admin_omits_permissions_succeeds (control) - test_update_key_admin_can_set_permissions (control) - test_regenerate_key_non_admin_permissions_rejected - test_regenerate_key_non_admin_permissions_explicit_empty_rejected - test_permissions_explicit_empty_rejected_for_non_admin_on_generate - test_regenerate_key_non_admin_permissions_rejected_before_enterprise_gate Mutation-killed against gate removal on either wire, against reverting the helper to a truthiness check, and against reordering the gate past the enterprise-license check
Relevant issues
Linear ticket
Resolves LIT-4092
Pre-Submission checklist
@greptileaiand received a Confidence Score of at least 4/5 before requesting a maintainer reviewDelays 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
_check_permissions_caller_permissionwas only wired into_common_key_generation_helper. It is now also invoked from_validate_update_key_dataandregenerate_key_fnso the three write paths share the same admin-only rule for thepermissionsfield on the key modelThe helper is refactored to accept the full request model and key its check on
"permissions" in data.model_fields_setrather than the value's truthiness. The model default (permissions = {}onGenerateRequestBase) is not inmodel_fields_setwhen the field is omitted, so the omit case continues to pass for non-admin callers on all three endpoints. Any explicit value in the request body, including{}ornull, is treated the same as any other write and requires PROXY_ADMINIn
regenerate_key_fnthe gate runs before thepremium_userlicense check. Ordering is pinned bytest_regenerate_key_non_admin_permissions_rejected_before_enterprise_gate, which fails on a mutant that swaps the two gates (the test observes a 500 enterprise error instead of the 403 permissions rejection)Nine tests in
tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.pycover the matrix: non-admin explicit-non-empty, explicit-empty, explicit-null (rejected 403), non-admin omit-field (allowed), and PROXY_ADMIN across every shape (allowed). Seven of the nine fail on the pre-fix HEAD; all nine pass on this commit. Full mapped test file (339 tests) greenScreenshots / Proof of Fix
Live proxy on
localhost:4010against Postgres,LITELLM_LICENSEset to make/key/regeneratereachable. A non-admin key$USER_KEYowned by aninternal_userrole sendspermissionsin the request body/key/updateexplicit non-empty/key/updateexplicit empty/key/regenerateexplicit non-empty/key/regenerateexplicit emptyOrdering: same proxy launched with
LITELLM_LICENSEunset (premium_user=False). Non-admin withpermissionsin the request body gets the 403 rather than the 500 enterprise error, confirming the gate runs before the license checkSame non-premium proxy, same caller, no
permissionsin the body: reaches the license check as expectedControls unchanged: PROXY_ADMIN can still set
permissionsto any value on all three endpoints; a non-admin caller who omitspermissionson/key/updatestill uses the personal-key fast path for unrelated fields; a non-admin caller who omitspermissionson/key/generatestill creates a key with the model defaultOut of scope, tracked separately
_process_single_key_updatewhich does not invoke the helper./key/bulk_updateis admin-only./team/key/bulk_updatebroadcasts viaKeyUpdateFields, anextra="forbid"allowlist that omitspermissions, so the field is currently unreachable through that route; adding it to the allowlist without also invoking the helper would regressNewUserRequestandUpdateUserRequestinherit thepermissionsfield./user/newwithauto_create_key=True(default) can carry the field intogenerate_key_helper_fn; the current helper is invoked from_common_key_generation_helperbut not fromgenerate_key_helper_fndirectly, so the/user/newpath is not covered_check_allowed_routes_caller_permissionuses the truthiness pattern this PR replaced forpermissions. The samemodel_fields_settreatment appliesNote
High Risk
Authorization on virtual-key
permissionsis security-sensitive; the change tightens gates on update/regenerate paths where the bug allowed self-granted capabilities.Overview
Closes a privilege-escalation gap where non-admins could set key
permissions(e.g.get_spend_routes) via/key/updateand/key/regeneratebecause_check_permissions_caller_permissiononly ran on key generation.The helper now takes the full request model and treats
permissionsas “in the body” when it appears inmodel_fields_set, so explicit{},null, or non-empty dicts all require PROXY_ADMIN; omitting the field still allows normal non-admin flows. The same check is wired into_validate_update_key_dataandregenerate_key_fn(before the enterprise license gate on regenerate). Tests cover generate/update/regenerate for omit vs explicit values and admin vs non-admin.Reviewed by Cursor Bugbot for commit 79a6301. Bugbot is set up for automated code reviews on this repo. Configure here.