fix(proxy): centralize key mutation authorization (LIT-4072) - #31543
fix(proxy): centralize key mutation authorization (LIT-4072)#31543yucheng-berri wants to merge 5 commits into
Conversation
Six write paths each enforced subsets of the key-mutation policy
inline at their own call sites. The drift produced real bypasses on
the previous PR — non-admin self-grant on permissions, NaN smuggled
into budget_limits, CLI session token treating its own absent budget
as unlimited delegation authority, and bulk paths skipping checks
the single path enforced.
Collapse the policy into one helper, authorize_key_mutation, that:
- validates numeric hygiene (NaN / inf / None / wrong type) on every
submitted budget value, for every caller including proxy admin,
because a NaN at rest disables downstream enforcement
- diffs the incoming request against the existing key (None on
create) and only enforces the delegation ceiling on values that
actually changed; UI prefills that round-trip unchanged are
allowed even if they sit above the caller's current ceiling
- gates permissions as proxy-admin-only with two semantics: create
paths reject only non-empty submissions (preserving the empty {}
default as the legitimate non-admin shape), update / regenerate
/ bulk paths reject any explicit presence in model_fields_set so
{} and null cannot clear an admin-set capability
- requires key-admin authority (proxy admin / team admin / org
admin) for any budget-field change on update-like paths; the
personal-key-owner and team-member-with-grant fast paths apply
to non-budget changes only
- hard-rejects a CLI session token with no team and an explicit
budget instead of treating max_budget=None as unlimited authority
- exempts proxy admin and the UI team-admin sentinel session from
the delegation ceiling
Wired into the six write handlers as the single authorization
entrypoint, replacing the inline checks in _common_key_generation_helper
(/key/generate + /key/service-account/generate),
_validate_update_key_data (/key/update), _process_single_key_update
(/key/bulk_update + /team/key/bulk_update), and regenerate_key_fn
(/key/regenerate). Also drops the {} mutable default on
generate_key_helper_fn's permissions param and normalizes at the
json.dumps site.
33 new policy-helper tests, mutation-killed: 20 of 33 fail when
the helper is replaced with a no-op. One pre-existing bulk-update
test was encoding the buggy behavior; switched to a non-budget
field to assert the legitimate semantic.
Supersedes #31469.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Add PermissionsDict (TypedDict, total=False) to litellm/proxy/_types.py
next to LiteLLM_ObjectPermissionBase. Names the keys the proxy actually
reads off the dict: get_spend_routes (gates global spend routes in
route_checks) and enable_llm_guard_check (gates the enterprise LLM-guard
callback). Other keys are still valid at runtime because guardrail_helpers
iterates the dict as {guardrail_name: should_run}, so total=False keeps
user-defined keys passing without a schema bump.
Propagate to internal helpers that hold the dict:
- _check_permissions_field in the policy helper
- generate_key_helper_fn
- VerificationTokenRepository.build_data / _build_update_data / _build_create_data
The wire-Pydantic model field on GenerateRequestBase stays as
Optional[dict] = {} to preserve external compatibility, matching the
precedent #31471 set for LiteLLM_ObjectPermissionBase vs ObjectPermissionDict.
Adds test_permissions_param_is_typed_with_permissionsdict to lock the
typing in place.
|
Single coherent commit implementing the centralized policy from LIT-4072, plus a follow-up that types the permissions field with PermissionsDict mirroring the ObjectPermissionDict pattern in #31471. Supersedes the now-closed #31469. Live verify ran end-to-end on the attack matrix; 337 tests pass. @greptileai please review |
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 2 · PR risk: 0/10 |
Greptile SummaryThis PR centralizes key mutation authorization for key-management write paths. The main changes are:
Confidence Score: 4/5The authorization-sensitive changes are focused and backed by a broad helper test matrix, with no outstanding code issues identified. The implementation consolidates duplicated policy checks into a single helper and updates the relevant write paths and tests, reducing drift while preserving expected database shapes. No specific files require follow-up from this review.
What T-Rex did
Reviews (4): Last reviewed commit: "fix: honor existing key team for ui auth..." | Re-trigger Greptile |
Greptile SummaryThis PR centralizes key mutation authorization across the key management write paths. The main changes are:
Confidence Score: 3/5The change improves centralized authorization, but one update path still appears to allow explicit budget-cap removal by actors who should not be able to make budget changes. The modified policy is well covered in many paths, but the null update case needs attention before this is safe to merge. litellm/proxy/management_helpers/key_mutation_authz.py
What T-Rex did
|
… paths Veria-ai on PR #31543: two bypasses inside _check_budget_admin_authority. 1. is_budget_change treated max_budget as a budget change only when data.max_budget was non-null AND differed from the existing value. A non-admin owner sending max_budget=null to CLEAR the existing cap fell through to the personal-key fast path. Key off model_fields_set so any explicit max_budget presence with a different value (including null) trips the gate; an unchanged resubmit stays on the fast path. 2. temp_budget_increase / temp_budget_expiry were missing entirely. _update_key_budget_with_temp_budget_increase adds the bump to the stored max_budget at request time, so a non-admin sneaking a M temp bump silently inflated the effective cap. Add both to the budget-change detection and apply the delegation ceiling to the effective max_budget (existing + temp_increase) so an admin caller can't push a key past their own authority either. NaN hygiene also applies to temp_budget_increase. Five regression tests, four mutation-killed (the unchanged-resubmit counter-test pins the fast-path semantic and intentionally passes both before and after).
|
@veria-ai you were right on both counts. Pushed a2ef846. Part 1 (max_budget=null bypass): Part 2 (temp_budget_increase / temp_budget_expiry): both added to 342 tests pass; 4 of 5 new tests mutation-killed against the prior commit (the fifth is a counter-test that pins unchanged-resubmit semantics). @greptileai please re-review HEAD a2ef846 |
|
@veria-ai your review at 02:04 UTC was on commit b270366, which predated my fix at 02:14 UTC. The "Budget mutation bypass" you flagged at |
|
bugbot run |
Veria-ai on PR #31543 after the prior fix: a team / org admin who passes _check_key_admin_access can still send max_budget=null to remove an existing finite cap on a team key. The delegation-ceiling check in _check_delegation_ceiling was gated by 'data.max_budget is not None', so an explicit null never reached the comparison and the cap was silently removed. Re-derive max_budget_changed from model_fields_set so an explicit null counts as a change. When the new value is null and an existing cap was set, treat that as effectively unbounded and reject it against the caller's delegation ceiling. Proxy admin remains exempt via the upstream short-circuit (rule 5). Counterparts pin the semantics: a no-op clear (existing already null) passes; a proxy admin clear passes.
|
@veria-ai you were right again. Pushed c8583e3. The ceiling check was using Now derives 345 tests pass; mutation-killed against the prior commit. @veria-ai please re-review HEAD c8583e3 |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
Bugbot Autofix resolved 1 of the 2 issues found in the latest run.
- ✅ Fixed: UI session misses key team
- The UI team-admin exemption now resolves the target team from the request body or the existing key row, covering regenerate payloads that omit team_id.
You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit a2ef846. Configure here.
|
|
||
| max_budget_changed = data.max_budget is not None and ( | ||
| existing_key_row is None or data.max_budget != existing_key_row.max_budget | ||
| ) |
There was a problem hiding this comment.
Null max_budget skips ceiling
High Severity
_check_delegation_ceiling treats an explicit max_budget clear as unchanged because it keys off data.max_budget is not None, while _check_budget_admin_authority correctly treats the same payload as a budget change via model_fields_set. A bounded team or org admin who passes the admin gate can remove a key’s cap without any delegation check, effectively granting unlimited spend authority beyond their own max_budget.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit a2ef846. Configure here.
There was a problem hiding this comment.
Bugbot Autofix determined this is a false positive.
Current code already treats explicit max_budget clears as changed and rejects finite-ceiling callers who would remove an existing cap.
You can send follow-ups to the cloud agent here.
|
|
||
| if _is_proxy_admin(user_api_key_dict): | ||
| return | ||
| if _is_ui_team_admin_session(user_api_key_dict, data.team_id): |
There was a problem hiding this comment.
UI session misses key team
Medium Severity
The UI team-admin session carve-out in _check_delegation_ceiling only looks at data.team_id, not the target key’s team when the body omits it. Handlers such as /key/regenerate still load team_table from existing_key_row.team_id, but the sentinel session loses Rule 5 exemption and can be blocked by the delegation ceiling on legitimate team-key edits.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit a2ef846. Configure here.
|
|
|
Closing to revert to the focused scope. The centralized helper grew the same way #31469 did — every bot finding was real but each follow-up pushed the PR further from the original VERIA-392 ticket. Resetting #31469 to its first commit (just the /key/generate gates) and filing the deferred findings as separate Linear tickets so each lands as its own focused PR. |


Relevant issues
Supersedes #31469. That PR proved the original VERIA-392 finding on
/key/generatewas just the visible edge of a broader policy drift across six write paths. The iterative review on it surfaced real edge cases (NaN bypass, CLI session token reading None as unlimited, explicit-empty permissions clearing admin-set capabilities, unchanged prefilled values failing the ceiling, bulk paths skipping the gate) but the resulting 17-commit history was unreviewable.This PR ships the same security outcome as a single coherent change.
Linear ticket
Resolves LIT-4072
Pre-Submission checklist
@greptileaiand received a Confidence Score of at least 4/5 before requesting a maintainer reviewType
🐛 Bug Fix
Changes
Six write paths each enforced subsets of the key-mutation policy inline at their own call sites:
/key/generate,/key/service-account/generate,/key/update,/key/regenerate,/key/bulk_update,/team/key/bulk_update. The drift produced real bypassesThis PR collapses the policy into one helper,
authorize_key_mutation, that diffs the request against the existing key and enforces three guards by changed fieldRule 1 —
permissionsis proxy-admin-only. On create paths, only a non-empty dict trips the gate (preserving the empty{}default as the legitimate non-admin shape). On update / regenerate / bulk paths, any explicit presence inmodel_fields_settrips the gate, so{}andnullcannot clear an admin-set capability such asenable_llm_guard_checkRule 2 — on update-like paths, any budget-field change (max_budget / spend / budget_limits) requires key-admin authority (proxy admin, team admin of the key's team, or org admin of that team's org). Personal-key-owner and team-member-with-grant fast paths apply to non-budget changes only
Rule 3 — every write path enforces numeric hygiene first (NaN / inf / None / wrong-type rejected with 400 before any comparison) and then the delegation ceiling. Hygiene applies to all callers, including proxy admins, because a NaN at rest disables downstream enforcement (
spend > NaNis always False)Rule 4 — ceiling derived from caller.max_budget; falls back to team budget when the caller is a CLI session token in a team context. A CLI session token with no team and any submitted budget is hard-rejected rather than treated as unlimited delegation authority
Rule 5 —
delegation_ceiling=Noneafter rule 4 means no ceiling configured (admin granted unlimited delegation); proxy admins and the UI team-admin sentinel session (UI_SESSION_TOKEN_TEAM_ID) are exempt from ceiling enforcementRule 6 — on update-like paths, the ceiling is enforced only on values that actually changed against the existing key row. A team admin with
max_budget=$100whose UI prefills the existing$1Mcap while editing an unrelated field passes; raising it to$2Mis rejectedRule 7 —
generate_key_helper_fn'spermissions: Optional[PermissionsDict] = {}mutable default is replaced withNoneand normalized at thejson.dumpscall site so the DB shape is preservedThe helper lives in
litellm/proxy/management_helpers/key_mutation_authz.pyas a single file with one public entrypoint. The six handlers each call it once, in the position where they already haveexisting_key_row(None on create) andteam_tableresolvedScreenshots / Proof of Fix
Setup: admin creates user
alice-newprwith roleinternal_user,max_budget=10, models["gpt-3.5-turbo"], and a personal key for her/key/regenerateis enterprise-gated upstream, so the proxy returns the existing enterprise-license error before the helper runs in this non-premium dev setup; the helper still fires on premium33 new policy-helper tests in
tests/test_litellm/proxy/management_helpers/test_key_mutation_authz.pycover the rule matrix end to end. Mutation-killed: 20 of 33 tests fail when the helper is replaced with a no-op. One pre-existing bulk-update test was encoding the buggy "team member with grant can bulk-edit budget" behavior; its update payload was switched to a non-budget field (tpm_limit) to assert the legitimate semantic336 tests pass across the new helper module and the existing key-management endpoint tests
Note
High Risk
Touches authentication-adjacent key management and closes known authorization bypasses; behavior changes on bulk budget edits and permissions writes for non-admins.
Overview
Centralizes proxy key-write authorization in
authorize_key_mutation(key_mutation_authz.py) and wires it into generate, update, bulk update, and regenerate instead of scattered inline checks.Policy enforced in one place: proxy-admin-only
permissions(including blocking non-admins from clearing capabilities via{}/nullon updates); budget fields on update paths require key-admin authority while personal-owner / team-member fast paths stay limited to non-budget edits; finite-number validation on budgets; delegation ceiling against the caller (with CLI session-token and UI team-admin carve-outs); ceiling and admin gates only on changed budget values on updates (includingtemp_budget_increaseeffective cap).Supporting changes:
PermissionsDicttyping;permissionsdefaultNonewith{}preserved at JSON serialization; large dedicated test matrix plus bulk team test adjusted to non-budget field.Reviewed by Cursor Bugbot for commit a2ef846. Bugbot is set up for automated code reviews on this repo. Configure here.