Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/management_helpers/access_group_team_sync.py: 83%
46 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 team side of the team <-> access group relationship.
4`litellm_accessgrouptable.assigned_team_ids` and `litellm_teamtable.access_group_ids`
5are two copies of the same relationship, and both are read: the access group's
6attached-teams view reads the former, and so does the key-side grant check in
7`auth_checks.get_authorized_resources_from_key_access_groups`. The access-group
8endpoints maintain both copies already; this module is what the team write paths
9call so an edit from that side is mirrored back.
11It deliberately lives outside `access_group_endpoints`, which is a lazily
12registered feature router (see `_lazy_features.LAZY_FEATURES`). Importing that
13module eagerly from `team_endpoints` would put it in `sys.modules` without its
14router ever being included, which drops its routes from the OpenAPI schema.
15"""
17import asyncio
18from collections.abc import Mapping, Sequence
19from typing import Final, Protocol
21from pydantic import BaseModel, TypeAdapter
23from litellm.proxy.auth.auth_checks import _delete_cache_access_object
25# hashtext collisions only cost two unrelated teams a little serialization, and the
26# lock is never taken by the access-group endpoints as a SELECT ... FOR UPDATE row lock,
27# so it cannot join their access-group-then-team lock order to form a cycle. team_endpoints
28# reuses this exact statement to serialize /team/member_add and /team/delete against each
29# other and against this mirror, rather than defining a second, divergent lock on the same key.
30TEAM_ADVISORY_LOCK_SQL: Final = "SELECT pg_advisory_xact_lock(hashtext($1)) IS NULL AS locked"
32_READ_TEAM_SQL: Final = 'SELECT access_group_ids FROM "LiteLLM_TeamTable" WHERE team_id = $1'
34# The groups the team is on either side of the reconcile, so the cache step is driven by
35# desired state rather than by which rows this attempt happened to change. A retry after a
36# failed invalidation finds the same set even though its statements are already no-ops.
37_AFFECTED_SQL: Final = """
38SELECT access_group_id FROM "LiteLLM_AccessGroupTable"
39WHERE access_group_id = ANY($2::TEXT[])
40 OR $1 = ANY(COALESCE(assigned_team_ids, ARRAY[]::TEXT[]))
41"""
43_ATTACH_SQL: Final = """
44UPDATE "LiteLLM_AccessGroupTable"
45SET assigned_team_ids = array_append(COALESCE(assigned_team_ids, ARRAY[]::TEXT[]), $1)
46WHERE access_group_id = ANY($2::TEXT[])
47 AND NOT ($1 = ANY(COALESCE(assigned_team_ids, ARRAY[]::TEXT[])))
48RETURNING access_group_id
49"""
51_DETACH_SQL: Final = """
52UPDATE "LiteLLM_AccessGroupTable"
53SET assigned_team_ids = array_remove(assigned_team_ids, $1)
54WHERE $1 = ANY(COALESCE(assigned_team_ids, ARRAY[]::TEXT[]))
55 AND NOT (access_group_id = ANY($2::TEXT[]))
56RETURNING access_group_id
57"""
60class _AffectedGroup(BaseModel):
61 access_group_id: str
64class _TeamGroups(BaseModel):
65 access_group_ids: tuple[str, ...] | None = None
68_AffectedGroups: Final = TypeAdapter(tuple[_AffectedGroup, ...])
69_TeamRows: Final = TypeAdapter(tuple[_TeamGroups, ...])
72class AccessGroupSyncTx(Protocol):
73 async def query_raw(self, query: str, *args: object) -> Sequence[Mapping[str, object]]: ... 73 ↛ exitline 73 didn't return from function 'query_raw' because
76class _Transaction(Protocol):
77 async def __aenter__(self) -> AccessGroupSyncTx: ... 77 ↛ exitline 77 didn't return from function '__aenter__' because
79 async def __aexit__(self, *exc_info: object) -> None: ... 79 ↛ exitline 79 didn't return from function '__aexit__' because
82class _PrismaDb(Protocol):
83 def tx(self) -> _Transaction: ... 83 ↛ exitline 83 didn't return from function 'tx' because
86class _PrismaClient(Protocol):
87 @property
88 def db(self) -> _PrismaDb: ... 88 ↛ exitline 88 didn't return from function 'db' because
91async def invalidate_access_group_cache(access_group_id: str) -> None:
92 """
93 Drop an access group entry from both the in-memory and Redis caches.
95 Uses a lazy import of user_api_key_cache and proxy_logging_obj from proxy_server
96 to avoid circular imports, following the same pattern as key_management_endpoints.
97 """
98 from litellm.proxy.proxy_server import proxy_logging_obj, user_api_key_cache
100 await _delete_cache_access_object(
101 access_group_id=access_group_id,
102 user_api_key_cache=user_api_key_cache,
103 proxy_logging_obj=proxy_logging_obj,
104 )
107async def invalidate_access_group_caches(access_group_ids: Sequence[str]) -> None:
108 """
109 Drop every given access group from the caches, then raise if any drop failed.
111 Every entry is attempted even when one raises, so a single unreachable cache cannot
112 leave the rest of the reconciled groups serving a grant the admin revoked.
113 """
114 outcomes: Final = await asyncio.gather(
115 *(invalidate_access_group_cache(access_group_id) for access_group_id in access_group_ids),
116 return_exceptions=True,
117 )
118 for outcome in outcomes: 118 ↛ 119line 118 didn't jump to line 119 because the loop on line 118 never started
119 if isinstance(outcome, BaseException):
120 raise outcome
123async def reconcile_team_access_group_membership(tx: AccessGroupSyncTx, team_id: str) -> tuple[str, ...]:
124 """
125 Reconcile every access group's `assigned_team_ids` against the team's own
126 `access_group_ids`, and return the groups whose cache the caller has to drop once the
127 transaction commits.
129 Call this inside the transaction that writes the team row, or after that row is
130 written or deleted: a team with no row reconciles to an empty set, which detaches it
131 from every group.
133 The team row is read here rather than passed in, under an advisory lock held for the
134 rest of the transaction. That is what makes concurrent writes to the same team
135 converge, since each mirror reconciles against the row as the transaction sees it
136 instead of against the snapshot its own caller happened to see. It also means a retry
137 heals a sync that failed partway, where a before/after delta would compute nothing.
139 Both mirror statements are set-based and mutate the array inside the statement, so a
140 concurrent write for a different team cannot be lost the way a read-modify-write of
141 the whole array can, and the pair commits together or not at all.
142 """
143 await tx.query_raw(TEAM_ADVISORY_LOCK_SQL, team_id)
144 team_rows: Final = _TeamRows.validate_python(await tx.query_raw(_READ_TEAM_SQL, team_id))
145 desired: Final = (team_rows[0].access_group_ids or ()) if team_rows else ()
146 affected: Final = _AffectedGroups.validate_python(await tx.query_raw(_AFFECTED_SQL, team_id, desired))
147 await tx.query_raw(_ATTACH_SQL, team_id, desired)
148 await tx.query_raw(_DETACH_SQL, team_id, desired)
149 return tuple(group.access_group_id for group in affected)
152async def sync_team_access_group_membership(prisma_client: _PrismaClient, team_id: str) -> None:
153 """Reconcile the mirror for an already committed team write, in its own transaction."""
154 async with prisma_client.db.tx() as tx:
155 affected: Final = await reconcile_team_access_group_membership(tx, team_id)
157 await invalidate_access_group_caches(affected)