Skip to content

fix(access groups): sync assigned_key_ids from the key write paths - #36843

Merged
yassin-berriai merged 2 commits into
litellm_internal_stagingfrom
litellm_lit5521_access_group_key_sync
Aug 14, 2026
Merged

fix(access groups): sync assigned_key_ids from the key write paths#36843
yassin-berriai merged 2 commits into
litellm_internal_stagingfrom
litellm_lit5521_access_group_key_sync

Conversation

@yassin-berriai

@yassin-berriai yassin-berriai commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Attaching an access group to a key granted nothing
  • The group's attached-keys list never learned about the key
  • Regenerating or deleting a key left the list pointing at nothing

How it solves it:

  • Key create, update, regenerate and delete now maintain the group's list
  • Regeneration re-points the group from the old token to the new
  • The group's cache entry is dropped on every such write
  • Each direction is one statement, so group count adds no round trips

User Flow

Before: an admin grants a team-restricted model to one key through an access group, the API accepts it, and the key still cannot call the model

  1. They send POST https://litellm-domain/v1/access_group with {"access_group_name": "premium", "access_model_names": ["claude-haiku-4-5"]} and get back 201 with an access_group_id
  2. They send POST https://litellm-domain/team/new for a team allowed only gpt-4o-mini, then POST https://litellm-domain/key/generate on that team, and get back a key
  3. The developer sends POST https://litellm-domain/v1/chat/completions for claude-haiku-4-5 with that key and gets 400 "team not allowed to access model", which is expected at this point
  4. The admin sends POST https://litellm-domain/key/update with {"key": "sk-...", "access_group_ids": ["<id>"]} and gets 200 back showing the group attached
  5. They send GET https://litellm-domain/v1/access_group/ and see "assigned_key_ids": [], so the access group page shows the group attached to no keys at all
  6. The developer retries the same POST https://litellm-domain/v1/chat/completions for claude-haiku-4-5 and gets the identical 400. The grant they were told they had never took effect
  7. Another user on the same team is unaffected either way, because nobody gets the group's models: the per-key grant is inert no matter who holds the key

After: the same steps, and the key can call the model the group grants

  1. Identical
  2. Identical
  3. Identical: still 400 "team not allowed to access model"
  4. Identical: 200 back showing the group attached
  5. GET https://litellm-domain/v1/access_group/ now shows "assigned_key_ids" carrying that key, so the access group page lists it
  6. The developer retries POST https://litellm-domain/v1/chat/completions for claude-haiku-4-5 and gets 200 with a real completion
  7. Only the holder of that one key gains the model. Sending the same POST https://litellm-domain/key/update with "access_group_ids": [] takes it away again, and the group's page stops listing the key

Relevant issues

Linear ticket

Resolves LIT-5521

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • 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 received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to 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

Same proxy, same Postgres, same commands on both legs, against real OpenAI and Anthropic APIs. The team is restricted to one model and the access group grants the other, so the only thing that can change the outcome is whether the group's own list learned about the key.

Before, captured at 262ed530f8 (staging without this commit, grep -c sync_key_access_group_membership returns 0):

$ curl -s -X POST $BASE/v1/access_group -H "$H_AUTH" -H "$H_JSON" \
  -d '{"access_group_name": "lit5521-before-group", "access_model_names": ["lit5521-restricted-model"]}'
{"access_group_id": "0de0ae02-996a-4a28-8b46-c8d7201947da", "access_model_names": ["lit5521-restricted-model"], "assigned_key_ids": []}

$ curl -s -X POST $BASE/team/new -H "$H_AUTH" -H "$H_JSON" \
  -d '{"team_alias": "lit5521-before-team", "models": ["lit5521-baseline-model"]}'
{"team_id": "2e5db877-afb1-4dac-bc1b-94ed50208b2d", "models": ["lit5521-baseline-model"]}

$ curl -s -X POST $BASE/key/generate -H "$H_AUTH" -H "$H_JSON" \
  -d '{"team_id": "2e5db877-afb1-4dac-bc1b-94ed50208b2d", "key_alias": "lit5521-before-key"}'
{"key": "sk-5LHEruUwFYn_Y3d-RFSt2Q", "token": "8350f0efb41d8efa2bbca84c2eb1c703d19ebc8c21f5bbd15209b7f55fb5d62b", "access_group_ids": []}

# CONTROL, real OpenAI call: the key works for the model its team allows
$ curl -s -X POST $BASE/v1/chat/completions -H "Authorization: Bearer $KEY" -H "$H_JSON" \
  -d '{"model": "lit5521-baseline-model", "messages": [{"role": "user", "content": "Say CONTROL-OK and nothing else"}], "max_tokens": 12}'
{"model":"lit5521-baseline-model","content":"CONTROL-OK"}

# the restriction bites before any group is attached
$ curl -s -X POST $BASE/v1/chat/completions -H "Authorization: Bearer $KEY" -H "$H_JSON" \
  -d '{"model": "lit5521-restricted-model", "messages": [{"role": "user", "content": "hi"}], "max_tokens": 12}'
{"error":"team not allowed to access model. This team can only access models=['lit5521-baseline-model']. Tried to access lit5521-restricted-model"}

$ curl -s -X POST $BASE/key/update -H "$H_AUTH" -H "$H_JSON" \
  -d '{"key": "sk-5LHEruUwFYn_Y3d-RFSt2Q", "access_group_ids": ["0de0ae02-996a-4a28-8b46-c8d7201947da"]}'
{"key":"sk-5LHEruUwFYn_Y3d-RFSt2Q","access_group_ids":["0de0ae02-996a-4a28-8b46-c8d7201947da"]}

$ curl -s $BASE/v1/access_group/0de0ae02-996a-4a28-8b46-c8d7201947da -H "$H_AUTH"
{"access_group_id": "0de0ae02-996a-4a28-8b46-c8d7201947da", "assigned_key_ids": []}      <-- the key is missing

$ curl -s -X POST $BASE/v1/chat/completions -H "Authorization: Bearer $KEY" -H "$H_JSON" \
  -d '{"model": "lit5521-restricted-model", "messages": [{"role": "user", "content": "Say GRANTED and nothing else"}], "max_tokens": 12}'
{"error":"team not allowed to access model. This team can only access models=['lit5521-baseline-model']. Tried to access lit5521-restricted-model"}      <-- still denied

After, captured at 7f44000410:

$ curl -s -X POST $BASE/v1/access_group -H "$H_AUTH" -H "$H_JSON" \
  -d '{"access_group_name": "lit5521-after5-group", "access_model_names": ["lit5521-restricted-model"]}'
{"access_group_id": "19be5069-a535-443f-b5fb-38c462e4021b", "access_model_names": ["lit5521-restricted-model"], "assigned_key_ids": []}

$ curl -s -X POST $BASE/team/new -H "$H_AUTH" -H "$H_JSON" \
  -d '{"team_alias": "lit5521-after5-team", "models": ["lit5521-baseline-model"]}'
{"team_id": "363a2af0-8924-4c4e-b895-794c7b2ed96c", "models": ["lit5521-baseline-model"]}

$ curl -s -X POST $BASE/key/generate -H "$H_AUTH" -H "$H_JSON" \
  -d '{"team_id": "363a2af0-8924-4c4e-b895-794c7b2ed96c", "key_alias": "lit5521-after5-key"}'
{"key": "sk--KARi_4H0_B2UQCQxtRvxg", "token": "e302f9ffbbfab1763b076f17bf9643596d4dbfad2446c0752128139587ecd8b8", "access_group_ids": []}

# CONTROL, same real OpenAI call, same result
$ curl -s -X POST $BASE/v1/chat/completions -H "Authorization: Bearer $KEY" -H "$H_JSON" \
  -d '{"model": "lit5521-baseline-model", "messages": [{"role": "user", "content": "Say CONTROL-OK and nothing else"}], "max_tokens": 12}'
{"model":"lit5521-baseline-model","content":"CONTROL-OK"}

# same denial before any group is attached
$ curl -s -X POST $BASE/v1/chat/completions -H "Authorization: Bearer $KEY" -H "$H_JSON" \
  -d '{"model": "lit5521-restricted-model", "messages": [{"role": "user", "content": "hi"}], "max_tokens": 12}'
{"error":"team not allowed to access model. This team can only access models=['lit5521-baseline-model']. Tried to access lit5521-restricted-model"}

$ curl -s -X POST $BASE/key/update -H "$H_AUTH" -H "$H_JSON" \
  -d '{"key": "sk--KARi_4H0_B2UQCQxtRvxg", "access_group_ids": ["19be5069-a535-443f-b5fb-38c462e4021b"]}'
{"key":"sk--KARi_4H0_B2UQCQxtRvxg","access_group_ids":["19be5069-a535-443f-b5fb-38c462e4021b"]}

$ curl -s $BASE/v1/access_group/19be5069-a535-443f-b5fb-38c462e4021b -H "$H_AUTH"
{"access_group_id": "19be5069-a535-443f-b5fb-38c462e4021b",
 "assigned_key_ids": ["e302f9ffbbfab1763b076f17bf9643596d4dbfad2446c0752128139587ecd8b8"]}      <-- the key is there

# real Anthropic call, now allowed
$ curl -s -X POST $BASE/v1/chat/completions -H "Authorization: Bearer $KEY" -H "$H_JSON" \
  -d '{"model": "lit5521-restricted-model", "messages": [{"role": "user", "content": "Say GRANTED and nothing else"}], "max_tokens": 12}'
{"model":"lit5521-restricted-model","content":"GRANTED"}

# detaching withdraws it again
$ curl -s -X POST $BASE/key/update -H "$H_AUTH" -H "$H_JSON" \
  -d '{"key": "sk--KARi_4H0_B2UQCQxtRvxg", "access_group_ids": []}'
{"key":"sk--KARi_4H0_B2UQCQxtRvxg","access_group_ids":[]}

$ curl -s $BASE/v1/access_group/19be5069-a535-443f-b5fb-38c462e4021b -H "$H_AUTH"
{"access_group_id": "19be5069-a535-443f-b5fb-38c462e4021b", "assigned_key_ids": []}

The other three write paths, same proxy and same commit, so the group's list is verified live through the whole key lifecycle rather than only through /key/update:

# create with the group already attached
$ curl -s -X POST $BASE/key/generate -H "$H_AUTH" -H "$H_JSON" \
  -d '{"key_alias": "lit5521-lifecycle-key", "access_group_ids": ["cd451f9d-cca8-4008-b2a4-519bcde5709c"]}'
{"key":"sk-1uIfps2YYXEpAdkPsFjW0Q","token":"0de2591bde17a0b1c6a844bbf6404ee29d05eaa6a8538e394dbafb1633812c6d","access_group_ids":["cd451f9d-cca8-4008-b2a4-519bcde5709c"]}

$ curl -s $BASE/v1/access_group/cd451f9d-cca8-4008-b2a4-519bcde5709c -H "$H_AUTH"
{"assigned_key_ids":["0de2591bde17a0b1c6a844bbf6404ee29d05eaa6a8538e394dbafb1633812c6d"]}

# attaching a group the key already has changes nothing, no duplicate entry
$ curl -s -X POST $BASE/key/update -H "$H_AUTH" -H "$H_JSON" \
  -d '{"key": "sk-1uIfps2YYXEpAdkPsFjW0Q", "access_group_ids": ["cd451f9d-cca8-4008-b2a4-519bcde5709c"]}'
{"access_group_ids":["cd451f9d-cca8-4008-b2a4-519bcde5709c"]}

$ curl -s $BASE/v1/access_group/cd451f9d-cca8-4008-b2a4-519bcde5709c -H "$H_AUTH"
{"assigned_key_ids":["0de2591bde17a0b1c6a844bbf6404ee29d05eaa6a8538e394dbafb1633812c6d"]}

# regenerate: the group follows the new token instead of keeping the old hash
$ curl -s -X POST $BASE/key/sk-1uIfps2YYXEpAdkPsFjW0Q/regenerate -H "$H_AUTH" -H "$H_JSON" -d '{}'
{"key":"sk-vA8eGEhz9UBUNJ7c3LUgHQ","token_id":"3dc3de3e57e585496192941c3bd1ed4afc633f510416baca6afb6d81ff6bb343"}

$ curl -s $BASE/v1/access_group/cd451f9d-cca8-4008-b2a4-519bcde5709c -H "$H_AUTH"
{"assigned_key_ids":["3dc3de3e57e585496192941c3bd1ed4afc633f510416baca6afb6d81ff6bb343"]}

# delete: the token is withdrawn
$ curl -s -X POST $BASE/key/delete -H "$H_AUTH" -H "$H_JSON" -d '{"keys": ["sk-vA8eGEhz9UBUNJ7c3LUgHQ"]}'
{"deleted_keys":["sk-vA8eGEhz9UBUNJ7c3LUgHQ"]}

$ curl -s $BASE/v1/access_group/cd451f9d-cca8-4008-b2a4-519bcde5709c -H "$H_AUTH"
{"assigned_key_ids":[]}

Re-run at 17a1b6ce12, the commit that batches the writes and drops the regeneration snapshot. Same live proxy and Postgres, real Anthropic calls, team restricted to anthropic-haiku-4-5 and the group granting anthropic-sonnet-4-5. Attach still grants on the very next request, a cache-warmed detach still denies on the very next request with no restart, and create, re-attach, regenerate and delete all still behave as above. What is new is the cost and the concurrency case.

Five groups attached and detached in one /key/update each way, with log_statement=all showing one statement per direction carrying all five ids in a single array parameter:

$ curl -s -X POST $BASE/key/update -H "$H_AUTH" -H "$H_JSON" \
  -d '{"key":"sk-sYhPEsYoDwNQm89P89rTnA","access_group_ids":["1971eee9-...","f3dda0ab-...","4f2a1a4d-...","4b52fb94-...","acd37a5f-..."]}'
HTTP 200
1971eee9-... -> ['abf8b3e50f999969b6004f5bef887f1fffc72f3bd012c0d6952b13e0a678d9be']
f3dda0ab-... -> ['abf8b3e50f999969b6004f5bef887f1fffc72f3bd012c0d6952b13e0a678d9be']
4f2a1a4d-... -> ['abf8b3e50f999969b6004f5bef887f1fffc72f3bd012c0d6952b13e0a678d9be']
4b52fb94-... -> ['abf8b3e50f999969b6004f5bef887f1fffc72f3bd012c0d6952b13e0a678d9be']
acd37a5f-... -> ['abf8b3e50f999969b6004f5bef887f1fffc72f3bd012c0d6952b13e0a678d9be']

$ curl -s -X POST $BASE/v1/chat/completions -H "Authorization: Bearer $KEY" -H "$H_JSON" \
  -d '{"model":"anthropic-sonnet-4-5","messages":[{"role":"user","content":"hi"}],"max_tokens":10}'
HTTP 200

$ curl -s -X POST $BASE/key/update -H "$H_AUTH" -H "$H_JSON" -d '{"key":"sk-sYhPEsYoDwNQm89P89rTnA","access_group_ids":[]}'
HTTP 200
# all five back to [], and the next sonnet call is HTTP 403 team_model_access_denied

# postgresql log, one statement each way for all five groups
LOG: execute s135: UPDATE "LiteLLM_AccessGroupTable" SET "assigned_key_ids" = array_append("assigned_key_ids", $1)
       WHERE "access_group_id" = ANY($2::text[]) AND NOT ($1 = ANY("assigned_key_ids")) RETURNING "access_group_id"
DETAIL: parameters: $1 = 'abf8b3e5...', $2 = '{1971eee9-...,4b52fb94-...,4f2a1a4d-...,acd37a5f-...,f3dda0ab-...}'
LOG: execute s53: UPDATE "LiteLLM_AccessGroupTable" SET "assigned_key_ids" = array_remove("assigned_key_ids", $1)
       WHERE "access_group_id" = ANY($2::text[]) AND $1 = ANY("assigned_key_ids") RETURNING "access_group_id"
DETAIL: parameters: $1 = 'abf8b3e5...', $2 = '{1971eee9-...,4b52fb94-...,4f2a1a4d-...,acd37a5f-...,f3dda0ab-...}'

Regeneration against a group edited out of band, run twice with only the helper swapped, so the difference is the fix and nothing else. The key row still lists G1 while the old hash has been moved into G2 directly in Postgres, then the key is regenerated:

# state going in, both legs
G1 (what the key row says) -> {}
G2 (what actually holds the old hash) -> {1b27a3ff...OLD}
key row access_group_ids -> {G1}

# at 17a1b6ce12
$ curl -s -X POST $BASE/key/sk-Y-EpBy0aSiIuJBKV4kLHUQ/regenerate -H "$H_AUTH" -H "$H_JSON" -d '{}'
G1 -> {}                          correct, the revoked group is not resurrected
G2 -> {955fe043...NEW}            correct, the live holder follows the new token

# same script, pre-fix helper from 055f0e6035 copied in, proxy restarted
G1 -> {70cbb873...NEW}            wrong, the snapshot puts the key back into a group that had dropped it
G2 -> {9e34e865...OLD}            wrong, the live holder is stranded on a token that no longer exists

# regenerating with access_group_ids set applies the delta after the repoint
$ curl -s -X POST $BASE/key/sk-d9mE4BW0KI45i4bNPAKORQ/regenerate -H "$H_AUTH" -H "$H_JSON" -d '{"access_group_ids": ["b3d391d3-..."]}'
G2 -> {94178c23...NEWEST}, key row -> {G2}, sonnet HTTP 200, old key HTTP 401

Type

🐛 Bug Fix

Caveats

  • Deriving the list at read time was rejected, see below
  • Cascade deletes of keys via /team/delete and /user/delete stay uncovered
  • Every membership write is raw SQL, so Postgres only
  • /key/bulk_update cannot carry access_group_ids today
  • Redis and multi-worker cache invalidation untested

Review notes

Why the value is recorded rather than derived. The group's list and the key's list are two halves of one handshake, not a denormalized cache: a key is authorized only when it names the group AND the group names the key. Deriving the group's half from "every key naming this group" collapses that to a single side and grants strictly more than today. tests/proxy_unit_tests/test_auth_checks.py pins the case a derivation would break, a key naming a group whose own list excludes that key's token, and asserts denial.

Where the ticket and the code disagree. LIT-5521 says a key detached from the key side keeps receiving the group's grants. It does not: every reader gates on the key's own access_group_ids first, so a detach revokes immediately, and the leftover entry is a stale listing rather than a live grant. The authorization defect runs the other way, on attach, which is what the proof above captures. The detach direction is still fixed here, for the listing and so a regenerated or deleted key stops leaving entries behind.

What is covered and what is not. Every key write path that can change membership is covered: create (which serves /key/generate, /key/service_account/generate and the key /user/new mints), update (one shared helper reached by /key/update and by both bulk routes through _process_single_key_update), regenerate, and delete. Three cascade paths delete key rows in bulk without going through the key delete helper, /team/delete, /user/delete, and the org path behind it, and they still leave stale token hashes. There is no authorization consequence there, the key row is gone so the token cannot authenticate, and deleting the access group already self-heals out-of-sync entries. Worth a follow-up, deliberately not folded in here.

Greptile's non-atomic membership finding, taken. I reproduced it against a real Postgres 16 rather than reasoning about it, and the result changed what the fix should be: two concurrent read-modify-writes on the same row lose one membership even when both run inside a transaction, because under READ COMMITTED the second write is derived from a value read before the first committed. So wrapping the two writes in a transaction would have looked like a fix without being one. The same two writes expressed as a single array_append keep both. Both halves are now single guarded statements, array_append ... WHERE NOT (token = ANY(...)) and array_remove ... WHERE token = ANY(...), which is what TeamRepository.remove_member already tells you to reach for when the read-modify-write matters. It matters here: a lost detach puts an already revoked token back into a group and restores its grants, and a lost attach silently drops a grant an admin just made. The WHERE guards also make each statement idempotent, which a check-then-push cannot be. I measured all three shapes on Postgres 16 before choosing: two concurrent read-modify-writes leave {keyA} and lose the other, two guarded appends of different tokens leave {keyA,keyB}, and two concurrent appends of the SAME token leave {tok} guarded versus {tok,tok} when the check and the append are separate statements. Tests pin both write shapes, so a regression to array replacement in either direction fails, and the mocked executor emulates the guards rather than assuming them. On partial failure the group write throwing after the key row commits leaves exactly the pre-PR state and surfaces as an error to the caller, so nothing is newly corrupted, and delete_access_group already reconciles out-of-sync entries by unioning both sides.

Greptile's revocation-ordering finding, taken. The sync ran before credential-cache invalidation on both delete and regenerate, so a failure inside it would have returned an error with the old key still authenticating from cache. All four call sites now run after invalidation, update and bulk update included, so nothing fallible sits between changing a key and revoking what it had. The cached auth object still carries the key's old access_group_ids, so syncing first meant a failed sync returned an error with the key still authenticating against groups it had just lost. A test pins the ordering.

veria's unbounded-work finding, taken. The caller picks the size of access_group_ids, and one statement per group let a single /key/update hold a worker and a connection for as many sequential writes as the list is long, even for ids that do not exist. Both directions are now set-based, WHERE access_group_id = ANY($2::text[]), so the cost is one statement for the attaches and one for the detaches whatever the list size, with the guards and idempotence unchanged. Each statement returns the ids it actually moved, so cache invalidation stays scoped to the groups that changed rather than every id in the request. A cardinality cap was the other option and it is strictly worse here: it puts an arbitrary number in the request schema, breaks callers legitimately assigning many groups, and would still leave the per-group loop behind it. A test drives 60 attaches and 60 detaches through /key/update and asserts exactly two statements, so a regression to per-group writes fails.

Greptile's regeneration-transition finding, taken. Regeneration was replaying the key row it read before the new token existed, so an access-group edit landing in between was either resurrected, the group had dropped the key and the replay put the new token back, or lost, the group had just attached the key and kept the dead hash. It now swaps in place from whatever the rows hold at write time, SET assigned_key_ids = array_append(array_remove(array_remove(assigned_key_ids, $1), $2), $2) WHERE $1 = ANY(assigned_key_ids), which needs no snapshot and no lock, and the double array_remove keeps a retry from duplicating the new token. A regenerate request that also changes access_group_ids still applies that delta afterwards, since that intent is expressed against the key's own list. The test sets the group rows to disagree with the key row in both directions and asserts the revoked group stays empty while the newly attached one follows the new token.

Consistency with the team-side fix. This mirrors PR #36825 field for field: same helper module shape, same previous/updated delta signature, same cache invalidation, and the same reason for living outside access_group_endpoints.py, which is a lazily registered feature router that must not be imported eagerly. It does not import from that PR's module, so the two can merge in either order.

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

Link to Devin session: https://app.devin.ai/sessions/d4faac79f9a84e0fba54b0c90f2265fc
Requested by: @yassin-berriai

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@greptile-apps

greptile-apps Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR synchronizes the two sides of virtual-key/access-group membership across key creation, updates, deletion, and regeneration.

  • Uses guarded set-based PostgreSQL statements to avoid lost updates and duplicate memberships.
  • Repoints regenerated tokens using live access-group rows rather than replaying a stale snapshot.
  • Invalidates key credentials before fallible membership synchronization and evicts changed access-group cache entries.
  • Adds lifecycle, authorization, idempotency, concurrency-shape, and cache-ordering coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
litellm/proxy/management_helpers/access_group_key_sync.py Implements atomic guarded attach, detach, and regeneration-repoint operations with targeted access-group cache invalidation; the previously reported concurrency defects are addressed.
litellm/proxy/management_endpoints/key_management_endpoints.py Integrates reverse membership synchronization into all covered key lifecycle paths after credential-cache invalidation.
tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py Adds focused mocked coverage for lifecycle synchronization, authorization effects, idempotency, live-state regeneration, exact SQL write shapes, and invalidation ordering.

Reviews (6): Last reviewed commit: "fix(access groups): batch membership wri..." | Re-trigger Greptile

Comment thread litellm/proxy/management_helpers/access_group_key_sync.py Outdated
Comment thread litellm/proxy/management_endpoints/key_management_endpoints.py Outdated
Comment thread litellm/proxy/management_helpers/access_group_key_sync.py Outdated
@veria-ai

veria-ai Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 4 · PR risk: 0/10

@codecov

codecov Bot commented Aug 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.74468% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
.../proxy/management_helpers/access_group_key_sync.py 94.59% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@yassin-berriai
yassin-berriai force-pushed the litellm_lit5521_access_group_key_sync branch from 5764bc3 to 9b39502 Compare August 13, 2026 23:04
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review 9b39502. Took the non-atomic finding: attach now uses Prisma's atomic push. Reasoning is in the description under Review notes.

Comment thread litellm/proxy/management_helpers/access_group_key_sync.py Outdated
@yassin-berriai
yassin-berriai force-pushed the litellm_lit5521_access_group_key_sync branch from 9b39502 to edf0130 Compare August 13, 2026 23:33
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review edf0130. Detach now uses an atomic array_remove, and the sync moved after credential invalidation. Reasoning under Review notes.

Comment thread litellm/proxy/management_helpers/access_group_key_sync.py Outdated
Comment thread litellm/proxy/management_endpoints/key_management_endpoints.py Outdated
@yassin-berriai
yassin-berriai force-pushed the litellm_lit5521_access_group_key_sync branch from edf0130 to 149dcfc Compare August 14, 2026 00:02
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review 149dcfc. Attach is now a guarded array_append, so concurrent identical attaches cannot duplicate. Measured on Postgres 16.

@codspeed-hq

codspeed-hq Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_lit5521_access_group_key_sync (17a1b6c) with litellm_internal_staging (8841cbc)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (86f2f16) during the generation of this report, so 8841cbc was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@yassin-berriai
yassin-berriai force-pushed the litellm_lit5521_access_group_key_sync branch from 055f0e6 to 7f44000 Compare August 14, 2026 00:42
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai please re-review 7f44000. Took the last open finding: the sync now runs after credential invalidation on the update paths too.

Comment thread litellm/proxy/management_helpers/access_group_key_sync.py Outdated
Comment thread litellm/proxy/management_helpers/access_group_key_sync.py Outdated
@devin-ai-integration

Copy link
Copy Markdown
Contributor

@greptileai please re-review 395bdee. Both remaining findings taken: set-based membership writes, and regeneration swaps in place with no snapshot.

@yassin-berriai
yassin-berriai enabled auto-merge (squash) August 14, 2026 01:18
yassin-berriai and others added 2 commits August 14, 2026 02:32
An access group and a key each store their half of the same relationship, and both halves are read: the grant check in auth_checks authorizes a key only when the group's assigned_key_ids lists that key's token. The access-group endpoints maintain both halves, but nothing on the key side ever wrote the group's copy, so attaching a group through /key/generate or /key/update recorded the key's half and left the group's half empty. The 200 came back with the group attached and the key still got none of the group's models.

Adds a reverse sync the key write paths call: create, update (which also covers both bulk update routes), regenerate, and delete. Regeneration replaces the token, so the group's copy is re-pointed from the old hash to the new one. Detaching withdraws the token, and the group's cache entry is invalidated on every write so auth stops reading a stale row.
…from live rows

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration
devin-ai-integration Bot force-pushed the litellm_lit5521_access_group_key_sync branch from 395bdee to 17a1b6c Compare August 14, 2026 02:32
@yassin-berriai
yassin-berriai merged commit efbdb69 into litellm_internal_staging Aug 14, 2026
80 of 81 checks passed
@yassin-berriai
yassin-berriai deleted the litellm_lit5521_access_group_key_sync branch August 14, 2026 02:47
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.

3 participants