fix(proxy): /key/bulk_update writes only the fields each item carries - #41949
Conversation
A bulk item that carried only tags reached the DB with max_budget, team_id, and budget_id as explicit nulls, wiping the key's budget and detaching it from its team. The per-key update is now built from the fields the item actually set, so a field left out keeps its value and an explicit null still clears it, the same as /key/update. Items carrying a field the bulk path cannot apply (object_permission and the like) are rejected with 422 instead of being silently dropped.
|
I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".
|
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
… drop a test helper docstring
|
bugbot run |
… /key/update does
|
bugbot run |
|
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 dc02e5f. Configure here.
TLDR
Problem this solves:
/key/bulk_updateitem withoutmax_budgetset the key'smax_budgetto nullteam_idandbudget_id: bulk tagging a team key detached it from the teamobject_permission) got 200, the field was dropped, and the budget was wipedHow it solves it:
nullincluded, is applied exactly as/key/updateapplies itobject_permission, applied the same way/key/updateapplies itobject_permissionis checked against the key's team the way/key/updatechecks it, so a team key cannot be bulk-granted an MCP server, search tool, or vector store its team does not allow; such an item lands underfailed_updateswith the same reason/key/updatereturnsUser Flow
Before: an admin who bulk-tags a budgeted key finds its budget wiped, and a team key detached from its team
{"key_alias": "team-a-key", "max_budget": 100}and get back a key with"max_budget": 100.0{"keys": [{"key": "<that key>", "tags": ["team-a"]}]}and get HTTP 200 with the key undersuccessful_updates, itskey_infoalready showing"max_budget": null"max_budget": nullnext to"metadata": {"tags": ["team-a"]}: the budget is gone"team_id": null, so the key is no longer the team'sobject_permissiongets HTTP 200 as well; the permission is never applied and that key's budget is wiped toofailed_updates, where the same body on POST https://litellm-domain/key/update is refused with HTTP 403After: the same bulk tag call changes only the tags, and a bulk item can grant an object permission
{"key_alias": "team-a-key", "max_budget": 100}and get back a key with"max_budget": 100.0{"keys": [{"key": "<that key>", "tags": ["team-a"]}]}and get HTTP 200 with the key undersuccessful_updates, itskey_infoshowing"max_budget": 100.0"max_budget": 100.0next to"metadata": {"tags": ["team-a"]}"team_id"object_permissiongets HTTP 200, and GET https://litellm-domain/key/info?key= shows it underobject_permissionwith"max_budget": 100.0still in placefailed_updatescarrying the same "not allowed by team" reason POST https://litellm-domain/key/update returns as HTTP 403, and GET https://litellm-domain/key/info?key= shows the key unchangedRelevant issues
Affected release
Linear ticket
Resolves LIT-7685
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*,make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more@greptileaito re-request a review after pushing changes)Delays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
Screenshots / Proof of Fix
Shared setup for both sides, run the way a multi-pod deployment runs: two proxy instances (
AandB) booted from the same checkout withpython litellm/proxy/proxy_cli.py --config config.yaml --port $PORT --num_workers 2 --use_prisma_db_push, each with 2 uvicorn workers, both pointed at one fresh Postgres database, and every request alternated between the two instances (the key is generated on one instance, bulk-updated on the other, and read back on the first).LITELLM_MASTER_KEY=sk-lit7685-master,OPENAI_API_KEYandLITELLM_LICENSEare set (tags on a key are an enterprise field, so the license is load-bearing), no Redis, and thisconfig.yaml:The observed outputs below are the raw responses reduced to the relevant fields with
python3 -c 'import json,sys; ...', the way the run printed them.AUTHstands for-H 'Authorization: Bearer sk-lit7685-master' -H 'Content-Type: application/json',$Aand$Bfor the two instances'http://127.0.0.1:<port>base URLs.Before (5fc510a)
Instance A on port 54387, instance B on port 57234, 2 workers each.
Ticket flow: bulk item with only tags
curl -s $A/key/generate $AUTH -d '{"key_alias":"team-a-key-25731","max_budget":100}'{"key_alias": "team-a-key-25731", "max_budget": 100.0, "team_id": null, "budget_id": null}curl -s -w '\nHTTP %{http_code}\n' $B/key/bulk_update $AUTH -d '{"keys":[{"key":"'$KEY'","tags":["team-a"]}]}'HTTP 200{"total_requested": 1, "successful_keys": ["sk-Ai77kcvad..."], "successful_max_budget": [null], "failed": []}curl -s "$A/key/info?key=$KEY" $AUTH{"key_alias": "team-a-key-25731", "max_budget": null, "team_id": null, "budget_id": null, "metadata": {"tags": ["team-a"]}}Control: single /key/update with only tags
curl -s $B/key/generate $AUTH -d '{"key_alias":"single-update-25731","max_budget":100}'{"key_alias": "single-update-25731", "max_budget": 100.0}curl -s -o /dev/null -w 'HTTP %{http_code}\n' $A/key/update $AUTH -d '{"key":"'$KEY2'","tags":["team-a"]}'HTTP 200curl -s "$B/key/info?key=$KEY2" $AUTH{"key_alias": "single-update-25731", "max_budget": 100.0, "metadata": {"tags": ["team-a"]}}Control: bulk item with only max_budget
curl -s $A/key/generate $AUTH -d '{"key_alias":"bulk-budget-only-25731","max_budget":100,"tags":["keep-me"]}'{"key_alias": "bulk-budget-only-25731", "max_budget": 100.0, "tags": null}curl -s -o /dev/null -w 'HTTP %{http_code}\n' $B/key/bulk_update $AUTH -d '{"keys":[{"key":"'$KEY3'","max_budget":50}]}'HTTP 200curl -s "$A/key/info?key=$KEY3" $AUTH{"key_alias": "bulk-budget-only-25731", "max_budget": 50.0, "metadata": {"tags": ["keep-me"]}}Team key: bulk item with only tags
curl -s $A/team/new $AUTH -d '{"team_alias":"lit7685-team-25731"}'team_id 3fe52997-308b-4b19-b316-09345408c8aacurl -s $B/key/generate $AUTH -d '{"key_alias":"team-key-25731","max_budget":100,"team_id":"'$TEAM'"}'{"key_alias": "team-key-25731", "max_budget": 100.0, "team_id": "3fe52997-308b-4b19-b316-09345408c8aa"}curl -s -w '\nHTTP %{http_code}\n' $A/key/bulk_update $AUTH -d '{"keys":[{"key":"'$KEY4'","tags":["team-a"]}]}'HTTP 200 successful: 1 failed: []curl -s "$B/key/info?key=$KEY4" $AUTH{"key_alias": "team-key-25731", "max_budget": null, "team_id": null, "metadata": {"tags": ["team-a"]}}Bulk item carrying object_permission
curl -s $B/key/generate $AUTH -d '{"key_alias":"objperm-25731","max_budget":100}'{"key_alias": "objperm-25731", "max_budget": 100.0}curl -s -w '\nHTTP %{http_code}\n' $A/key/bulk_update $AUTH -d '{"keys":[{"key":"'$KEY5'","object_permission":{"vector_stores":["vs-1"]}}]}'HTTP 200{"successful_keys": ["sk--q1CCOH3e..."], "successful_max_budget": [null], "failed": []}curl -s "$B/key/info?key=$KEY5" $AUTH{"key_alias": "objperm-25731", "max_budget": null, "object_permission_id": null, "object_permission": null}Team key: bulk item granting a search tool outside the team allowlist
curl -s $A/team/new $AUTH -d '{"team_alias":"lit7685-scoped-25731","object_permission":{"search_tools":["team-search"]}}'team_id fc5c3674-434e-41b2-ae30-87eae4fd585bcurl -s $B/key/generate $AUTH -d '{"key_alias":"scoped-bulk-25731","max_budget":100,"team_id":"'$TEAM6'"}'and the same withkey_aliasscoped-single-25731{"key_alias": "scoped-bulk-25731", "max_budget": 100.0, "team_id": "fc5c3674-434e-41b2-ae30-87eae4fd585b"}{"key_alias": "scoped-single-25731", "max_budget": 100.0, "team_id": "fc5c3674-434e-41b2-ae30-87eae4fd585b"}curl -s -w '\nHTTP %{http_code}\n' $A/key/bulk_update $AUTH -d '{"keys":[{"key":"'$KEY6'","object_permission":{"search_tools":["other-search"]}}]}'HTTP 200{"successful_max_budget": [null], "failed": []}curl -s -w '\nHTTP %{http_code}\n' $B/key/update $AUTH -d '{"key":"'$KEY7'","object_permission":{"search_tools":["other-search"]}}'HTTP 403{"message": "{'error': \"Key requests search tools not allowed by team 'fc5c3674-434e-41b2-ae30-87eae4fd585b': ['other-search']. Team allows: ['team-search'].\"}", "type": "auth_error", "param": "None", "code": "403"}curl -s "$A/key/info?key=$KEY6" $AUTHand the same for$KEY7{"key_alias": "scoped-bulk-25731", "max_budget": null, "team_id": null, "search_tools": null}{"key_alias": "scoped-single-25731", "max_budget": 100.0, "team_id": "fc5c3674-434e-41b2-ae30-87eae4fd585b", "search_tools": null}After (df6a222)
Run at df6a222. The tip dc02e5f since then changes only the unit test file (a stubbed team lookup in the sibling policy tests), so this leg stands at the tip. Instance A on port 34759, instance B on port 29679, 2 workers each.
Ticket flow: bulk item with only tags
curl -s $A/key/generate $AUTH -d '{"key_alias":"team-a-key-26743","max_budget":100}'{"key_alias": "team-a-key-26743", "max_budget": 100.0, "team_id": null, "budget_id": null}curl -s -w '\nHTTP %{http_code}\n' $B/key/bulk_update $AUTH -d '{"keys":[{"key":"'$KEY'","tags":["team-a"]}]}'HTTP 200{"total_requested": 1, "successful_keys": ["sk-oUTvz6SIq..."], "successful_max_budget": [100.0], "failed": []}curl -s "$A/key/info?key=$KEY" $AUTH{"key_alias": "team-a-key-26743", "max_budget": 100.0, "team_id": null, "budget_id": null, "metadata": {"tags": ["team-a"]}}Control: single /key/update with only tags
curl -s $B/key/generate $AUTH -d '{"key_alias":"single-update-26743","max_budget":100}'{"key_alias": "single-update-26743", "max_budget": 100.0}curl -s -o /dev/null -w 'HTTP %{http_code}\n' $A/key/update $AUTH -d '{"key":"'$KEY2'","tags":["team-a"]}'HTTP 200curl -s "$B/key/info?key=$KEY2" $AUTH{"key_alias": "single-update-26743", "max_budget": 100.0, "metadata": {"tags": ["team-a"]}}Control: bulk item with only max_budget
curl -s $A/key/generate $AUTH -d '{"key_alias":"bulk-budget-only-26743","max_budget":100,"tags":["keep-me"]}'{"key_alias": "bulk-budget-only-26743", "max_budget": 100.0, "tags": null}curl -s -o /dev/null -w 'HTTP %{http_code}\n' $B/key/bulk_update $AUTH -d '{"keys":[{"key":"'$KEY3'","max_budget":50}]}'HTTP 200curl -s "$A/key/info?key=$KEY3" $AUTH{"key_alias": "bulk-budget-only-26743", "max_budget": 50.0, "metadata": {"tags": ["keep-me"]}}Team key: bulk item with only tags
curl -s $A/team/new $AUTH -d '{"team_alias":"lit7685-team-26743"}'team_id 6374dbe9-08d7-4428-a963-c393ad22a152curl -s $B/key/generate $AUTH -d '{"key_alias":"team-key-26743","max_budget":100,"team_id":"'$TEAM'"}'{"key_alias": "team-key-26743", "max_budget": 100.0, "team_id": "6374dbe9-08d7-4428-a963-c393ad22a152"}curl -s -w '\nHTTP %{http_code}\n' $A/key/bulk_update $AUTH -d '{"keys":[{"key":"'$KEY4'","tags":["team-a"]}]}'HTTP 200 successful: 1 failed: []curl -s "$B/key/info?key=$KEY4" $AUTH{"key_alias": "team-key-26743", "max_budget": 100.0, "team_id": "6374dbe9-08d7-4428-a963-c393ad22a152", "metadata": {"tags": ["team-a"]}}Bulk item carrying object_permission
curl -s $B/key/generate $AUTH -d '{"key_alias":"objperm-26743","max_budget":100}'{"key_alias": "objperm-26743", "max_budget": 100.0}curl -s -w '\nHTTP %{http_code}\n' $A/key/bulk_update $AUTH -d '{"keys":[{"key":"'$KEY5'","object_permission":{"vector_stores":["vs-1"]}}]}'HTTP 200{"successful_keys": ["sk-PJpAv7pMM..."], "successful_max_budget": [100.0], "failed": []}curl -s "$B/key/info?key=$KEY5" $AUTH{"key_alias": "objperm-26743", "max_budget": 100.0, "object_permission_id": "893fd61d-f62b-4cf5-a87a-497aaef15da1", "object_permission": {"object_permission_id": "893fd61d-f62b-4cf5-a87a-497aaef15da1", "mcp_servers": [], "mcp_access_groups": [], "mcp_tool_permissions": null, "vector_stores": ["vs-1"], "agents": [], "agent_access_groups": [], "models": [], "blocked_tools": [], "mcp_toolsets": [], "search_tools": [], "mcp_tool_search_enabled": null, "skills": [], "teams": null, "projects": null, "verification_tokens": null, "organizations": null, "users": null, "end_users": null, "agents_table": null}}Team key: bulk item granting a search tool outside the team allowlist
curl -s $A/team/new $AUTH -d '{"team_alias":"lit7685-scoped-26743","object_permission":{"search_tools":["team-search"]}}'team_id 85b23254-7946-4021-b687-19320539f9e0curl -s $B/key/generate $AUTH -d '{"key_alias":"scoped-bulk-26743","max_budget":100,"team_id":"'$TEAM6'"}'and the same withkey_aliasscoped-single-26743{"key_alias": "scoped-bulk-26743", "max_budget": 100.0, "team_id": "85b23254-7946-4021-b687-19320539f9e0"}{"key_alias": "scoped-single-26743", "max_budget": 100.0, "team_id": "85b23254-7946-4021-b687-19320539f9e0"}curl -s -w '\nHTTP %{http_code}\n' $A/key/bulk_update $AUTH -d '{"keys":[{"key":"'$KEY6'","object_permission":{"search_tools":["other-search"]}}]}'HTTP 200{"successful_max_budget": [], "failed": ["Key requests search tools not allowed by team '85b23254-7946-4021-b687-19320539f9e0': ['other-search']. Team allows: ['team-search']."]}curl -s -w '\nHTTP %{http_code}\n' $B/key/update $AUTH -d '{"key":"'$KEY7'","object_permission":{"search_tools":["other-search"]}}'HTTP 403{"message": "{'error': \"Key requests search tools not allowed by team '85b23254-7946-4021-b687-19320539f9e0': ['other-search']. Team allows: ['team-search'].\"}", "type": "auth_error", "param": "None", "code": "403"}curl -s "$A/key/info?key=$KEY6" $AUTHand the same for$KEY7{"key_alias": "scoped-bulk-26743", "max_budget": 100.0, "team_id": "85b23254-7946-4021-b687-19320539f9e0", "search_tools": null}{"key_alias": "scoped-single-26743", "max_budget": 100.0, "team_id": "85b23254-7946-4021-b687-19320539f9e0", "search_tools": null}Before, the bulk item in the last case came back as a success with the permission dropped and the key's
max_budgetandteam_idnulled, while/key/updaterefused the same body with a 403. After, the bulk item lands underfailed_updatescarrying the same reason and the key is left as it was.Observed next to the fix, on both sides, left alone by this PR:
/key/generateechoes"tags": nullalthough the tags are storedteam_idDependents driven live, base vs head (df6a222)
Run at df6a222; the tip dc02e5f since then changes only the unit test file, so this run stands at the tip. Same two-instance, two-worker topology per side, fresh database per side, and both key hooks loaded from the config so the readers of the per-key request are observed rather than reasoned about:
custom_key_updaterecordsdata.model_fields_setand allows,custom_key_policyrecords the effective key and denies an update whose effectivemax_budgetis nullmax_budgetset to null, policy denied, tags not written["key", "tags"], tags written, budget keptkeyteam_id: nullon a team keymax_budget: nullobject_permissiontwice on one keyobject_permissionas123,[],"", 5 KB stringtags: nullandobject_permission: null/key/updatesearch_toolsoutside the team allowlist, then inside itfailed_updateswith the "not allowed by team" reason, key untouched,custom_key_policynever reached; inside: one permission row, budget and team keptNot driven:
/team/key/bulk_updatealready builds its request withexclude_unset, the Admin UI has no caller of/key/bulk_update, and nomaincommit since the merge base touches either changed fileType
🐛 Bug Fix
Caveats (if any)
Low
/key/updateobject_permissionwith a non-object value (a number, a list, a string) now fails the whole batch with 422; before, the value was ignored/key/update; accepting the field is the option the ticket offeredcustom_key_updatenow sees only the fields a bulk item carries inmodel_fields_set, andcustom_key_policysees an effective key that keeps the omitted fieldsmax_budget: nullno longer fires on items that left the field outtags: nullandobject_permission: nullon an item are left in place rather than cleared, as on/key/updatecustom_key_updateruns before the team-scope check onobject_permission, where/key/updatevalidates first and then calls the hookfailed_updateswith HTTP 200 for the batch, where/key/updatereturns HTTP 403/key/bulk_update; the reason string is the one/key/updatereturnsgoogle_generate_content_endpoint_testingis a Vertex 429 quota hit and the Bedrockinvalid beta flage2e tests inproxy_e2e_anthropic_messages_testsfail the same way on main's scheduled pipeline 89873 (12:09 UTC today), andtest_models_by_providerinlitellm_utils_testingfailed on main's pipelines 89818 and 89834 until main's 044f88e registered thetranscribeprovider, which this branch's merge base predates, so the merge commit picks the fix upFinal Attestation