Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/management_helpers/access_group_key_sync.py: 70%
40 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1"""
2Reverse sync for the key side of the key <-> access group relationship.
4`litellm_accessgrouptable.assigned_key_ids` and `litellm_verificationtoken.access_group_ids`
5are the two halves of one relationship and BOTH are read: the access group's
6attached-keys view reads the former, and so does the grant check in
7`auth_checks.get_authorized_resources_from_key_access_groups`, which authorizes a
8key only when the group lists the key's token (or the key's team). The access-group
9endpoints maintain both halves already; this module is what the key write paths call
10so an edit from that side is mirrored back.
12Every write is a single guarded statement rather than a read-modify-write. Prisma has no
13atomic scalar-list removal (see `TeamRepository.remove_member`), and the read-modify-write
14it otherwise forces is not safe here: a lost update would put an already revoked token back
15into a group and restore its grants, or drop a grant an admin just made. The guards also
16make each statement idempotent, so a retry cannot duplicate an entry. Each statement covers
17every group the request touches at once, so the size of the caller's id list does not turn
18into a matching number of round trips, and returns the ids it actually moved so only those
19groups are dropped from cache.
21It deliberately lives outside `access_group_endpoints`, which is a lazily
22registered feature router (see `_lazy_features.LAZY_FEATURES`). Importing that
23module eagerly from `key_management_endpoints` would put it in `sys.modules`
24without its router ever being included, which drops its routes from the OpenAPI
25schema.
26"""
28from collections.abc import Sequence
29from typing import Final, Protocol
31from pydantic import BaseModel
33from litellm.proxy._types import (
34 LiteLLM_VerificationToken,
35 RegenerateKeyRequest,
36 UpdateKeyRequest,
37)
38from litellm.proxy.auth.auth_checks import (
39 _delete_cache_access_object, # pyright: ignore[reportPrivateUsage] # the access-group endpoints reach for this same cache primitive
40)
41from litellm.proxy.db.routing_prisma_wrapper import writer_wrapper
42from litellm.repositories.table_repositories import AccessGroupRepository
45class _MovedGroupRow(BaseModel):
46 access_group_id: str
49class _RawExecutor(Protocol):
50 async def query_raw(self, query: str, *args: str | Sequence[str]) -> Sequence[object]: ... 50 ↛ exitline 50 didn't return from function 'query_raw' because
53_ATTACH_KEY_SQL: Final = (
54 'UPDATE "LiteLLM_AccessGroupTable" '
55 'SET "assigned_key_ids" = array_append("assigned_key_ids", $1) '
56 'WHERE "access_group_id" = ANY($2::text[]) AND NOT ($1 = ANY("assigned_key_ids")) '
57 'RETURNING "access_group_id"'
58)
60_DETACH_KEY_SQL: Final = (
61 'UPDATE "LiteLLM_AccessGroupTable" '
62 'SET "assigned_key_ids" = array_remove("assigned_key_ids", $1) '
63 'WHERE "access_group_id" = ANY($2::text[]) AND $1 = ANY("assigned_key_ids") '
64 'RETURNING "access_group_id"'
65)
67_REPOINT_KEY_SQL: Final = (
68 'UPDATE "LiteLLM_AccessGroupTable" '
69 'SET "assigned_key_ids" = array_append(array_remove(array_remove("assigned_key_ids", $1), $2), $2) '
70 'WHERE $1 = ANY("assigned_key_ids") '
71 'RETURNING "access_group_id"'
72)
75def _raw_executor(prisma_client: object) -> _RawExecutor:
76 """Narrow the untyped Prisma client down to the raw-query call this module makes, pinned to the writer."""
77 db: Final = AccessGroupRepository(prisma_client).prisma_client.db # pyright: ignore[reportAny] # untyped Prisma client
78 return writer_wrapper(db) # pyright: ignore[reportAny, reportReturnType] # untyped Prisma client behind the pin
81async def _invalidate_access_group_cache(access_group_id: str) -> None:
82 """
83 Drop an access group entry from both the in-memory and Redis caches.
85 Uses a lazy import of user_api_key_cache and proxy_logging_obj from proxy_server
86 to avoid circular imports, following the same pattern as key_management_endpoints.
87 """
88 from litellm.proxy.proxy_server import proxy_logging_obj, user_api_key_cache
90 await _delete_cache_access_object(
91 access_group_id=access_group_id,
92 user_api_key_cache=user_api_key_cache,
93 proxy_logging_obj=proxy_logging_obj,
94 )
97async def _invalidate_moved_groups(moved_rows: Sequence[object]) -> None:
98 for row in moved_rows: 98 ↛ 99line 98 didn't jump to line 99 because the loop on line 98 never started
99 await _invalidate_access_group_cache(_MovedGroupRow.model_validate(row).access_group_id)
102async def _write_membership(prisma_client: object, sql: str, access_group_ids: frozenset[str], key_token: str) -> None:
103 """Run one guarded membership statement for every listed group, dropping the cache of those it moved."""
104 if not access_group_ids:
105 return
106 await _invalidate_moved_groups(
107 await _raw_executor(prisma_client).query_raw(sql, key_token, sorted(access_group_ids))
108 )
111async def sync_key_access_group_membership(
112 prisma_client: object,
113 key_token: str,
114 previous_access_group_ids: Sequence[str] | None,
115 updated_access_group_ids: Sequence[str] | None,
116) -> None:
117 """Mirror a key-side change to `access_group_ids` onto each access group's `assigned_key_ids`."""
118 previous: Final = frozenset(previous_access_group_ids or ())
119 updated: Final = frozenset(updated_access_group_ids or ())
121 await _write_membership(prisma_client, _ATTACH_KEY_SQL, updated - previous, key_token)
122 await _write_membership(prisma_client, _DETACH_KEY_SQL, previous - updated, key_token)
125async def sync_key_update_access_group_membership(
126 prisma_client: object,
127 key_token: str,
128 data: UpdateKeyRequest | RegenerateKeyRequest,
129 existing_key_row: LiteLLM_VerificationToken,
130) -> None:
131 """
132 Mirror a key UPDATE onto the group side, honouring `exclude_unset` semantics.
134 The key row is written from `model_dump(exclude_unset=True)`, so a request that never
135 mentions `access_group_ids` leaves the key's own list alone and must leave the group's
136 copy alone too. Reading the attribute instead of `model_fields_set` would see None on
137 every unrelated edit and withdraw the token from every group it belongs to.
138 """
139 if "access_group_ids" not in data.model_fields_set:
140 return
141 await sync_key_access_group_membership(
142 prisma_client=prisma_client,
143 key_token=key_token,
144 previous_access_group_ids=existing_key_row.access_group_ids,
145 updated_access_group_ids=data.access_group_ids,
146 )
149async def sync_key_regeneration_access_group_membership(
150 prisma_client: object,
151 previous_key_token: str,
152 new_key_token: str,
153 data: RegenerateKeyRequest | None,
154 existing_key_row: LiteLLM_VerificationToken,
155) -> None:
156 """
157 Re-point every group's copy from the old token to the regenerated one.
159 Regeneration replaces the token, which is the identity `assigned_key_ids` stores, so
160 leaving the old hash behind both points the group at a row that no longer exists and
161 denies the regenerated key the group's grants. The swap is driven by the groups that
162 hold the old token when the statement runs, not by the key row read earlier, so a group
163 edited in between is neither resurrected nor skipped. Removing the new token before
164 appending it keeps a re-run from duplicating it.
165 """
166 await _invalidate_moved_groups(
167 await _raw_executor(prisma_client).query_raw(_REPOINT_KEY_SQL, previous_key_token, new_key_token)
168 )
169 if data is not None:
170 await sync_key_update_access_group_membership(
171 prisma_client=prisma_client,
172 key_token=new_key_token,
173 data=data,
174 existing_key_row=existing_key_row,
175 )