[Feature] /v2/team/list: Add org admin access control, members_count, and indexes#23938
Conversation
… and indexes Add org admin support to /v2/team/list so org admins can list teams within their organizations instead of getting 401. Also enrich the response with members_count and add missing indexes. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR ports org admin access control from the legacy Key points from this review:
Confidence Score: 3/5
|
| Filename | Overview |
|---|---|
| litellm/proxy/management_endpoints/team_endpoints.py | Added org admin access control to list_team_v2, including _get_org_admin_org_ids helper and updated _build_team_list_where_conditions. Several existing threads flag open issues: exception swallowing, extra DB lookup per non-admin request, org scope gaps when user_id is combined with org admin path, and behavioral divergence from legacy endpoint. |
| litellm/types/proxy/management_endpoints/team_endpoints.py | Added TeamListItem extending LiteLLM_TeamTable with a members_count field. Simple, clean model addition. |
| tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py | Updated existing tests to mock the new get_user_object dependency, and added three new org admin tests. The call_count-based mock dispatch in test_list_team_v2_org_admin_with_user_id_returns_user_teams is fragile; keying on user_id kwarg is more robust. |
| litellm/proxy/schema.prisma | Added @@index directives on organization_id, team_alias, and created_at to LiteLLM_TeamTable. Additive, backward-compatible schema change. |
| schema.prisma | Same index additions as litellm/proxy/schema.prisma, applied to the root schema copy. Consistent across all three schema locations. |
| litellm-proxy-extras/litellm_proxy_extras/schema.prisma | Same @@index additions applied to the third schema copy. Consistent change across all three Prisma schema files. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[list_team_v2 called] --> B{is_proxy_admin?}
B -- Yes --> F[Build where conditions\nno user_id injected]
B -- No --> C[_get_org_admin_org_ids\ncalls get_user_object for caller]
C --> D{org_admin_org_ids\nnot None?}
D -- Yes --> E{organization_id\nfilter provided?}
E -- Yes, not in org_ids --> ERR403[Raise 403]
E -- No / in org_ids --> F
D -- No --> G{allowed_route_check\npasses?}
G -- No --> ERR401[Raise 401]
G -- Yes --> H[Auto-inject caller user_id\nif not provided]
H --> F
F --> I[_build_team_list_where_conditions]
I --> J{user_id provided?}
J -- Yes --> K[get_user_object for user_id]
K --> L{user has teams?}
L -- No --> M[Return None\nearly exit → empty result]
L -- Yes --> N{org_admin_org_ids AND\nno organization_id?}
J -- No --> N
N -- Yes --> O[Inject org_id IN org_admin_org_ids]
N -- No --> P[Keep existing filters]
O --> Q[Query DB + build TeamListItem\nwith members_count]
P --> Q
M --> R[Return empty paginated response]
Q --> S[Return paginated response]
Last reviewed commit: "fix: document org sc..."
| if organization_id: | ||
| where_conditions["organization_id"] = organization_id | ||
| # If a list is passed (org admin viewing multiple orgs), use IN | ||
| if isinstance(organization_id, list): | ||
| where_conditions["organization_id"] = {"in": organization_id} |
There was a problem hiding this comment.
Dead code —
organization_id can never be a list
organization_id is declared as Optional[str] (a FastAPI Query parameter), so the isinstance(organization_id, list) branch is unreachable. The comment above it ("If a list is passed …") suggests this was written for a future or hypothetical calling convention that does not exist. This dead branch adds maintenance confusion and should be removed.
| if organization_id: | |
| where_conditions["organization_id"] = organization_id | |
| # If a list is passed (org admin viewing multiple orgs), use IN | |
| if isinstance(organization_id, list): | |
| where_conditions["organization_id"] = {"in": organization_id} | |
| where_conditions["organization_id"] = organization_id |
| @@ -1,3 +1,4 @@ | |||
| from datetime import datetime | |||
There was a problem hiding this comment.
datetime is imported but not referenced anywhere in this file. TeamListItem does not declare any datetime fields, and the parent class LiteLLM_TeamTable handles its own datetime imports internally.
| from datetime import datetime | |
| from typing import Any, Dict, List, Optional, Union |
| if not is_proxy_admin: | ||
| # First check if the standard route check passes (user querying own data) | ||
| passes_route_check = allowed_route_check_inside_route( | ||
| user_api_key_dict=user_api_key_dict, requested_user_id=user_id | ||
| ) | ||
|
|
||
| if user_id is None and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: | ||
| user_id = user_api_key_dict.user_id | ||
| if not passes_route_check: | ||
| # Standard check failed — see if the caller is an org admin | ||
| if user_api_key_dict.user_id: | ||
| org_admin_org_ids = await _get_org_admin_org_ids( | ||
| user_id=user_api_key_dict.user_id, | ||
| prisma_client=prisma_client, | ||
| user_api_key_cache=user_api_key_cache, | ||
| proxy_logging_obj=proxy_logging_obj, | ||
| ) | ||
|
|
||
| if org_admin_org_ids is None: | ||
| # Not an org admin either — reject | ||
| raise HTTPException( | ||
| status_code=401, | ||
| detail={ | ||
| "error": "Only admin users can query all teams/other teams. Your user role={}".format( | ||
| user_api_key_dict.user_role | ||
| ) | ||
| }, | ||
| ) | ||
|
|
||
| if org_admin_org_ids is not None: | ||
| # Org admin: validate org_id filter if provided | ||
| if organization_id and organization_id not in org_admin_org_ids: | ||
| raise HTTPException( | ||
| status_code=403, | ||
| detail={ | ||
| "error": "You can only view teams within your organizations." | ||
| }, | ||
| ) | ||
| verbose_proxy_logger.debug( | ||
| "list_team_v2: org admin access for user=%s, org_ids=%s", | ||
| user_api_key_dict.user_id, | ||
| org_admin_org_ids, | ||
| ) | ||
| elif not is_proxy_admin: | ||
| # Regular user — auto-inject caller's user_id | ||
| if user_id is None: | ||
| user_id = user_api_key_dict.user_id |
There was a problem hiding this comment.
Org admin querying own
user_id silently downgrades to regular-user scope
When an org admin calls the endpoint with user_id=<their own ID>, allowed_route_check_inside_route returns True (because user_id == user_api_key_dict.user_id). This short-circuits before the org admin lookup runs, so org_admin_org_ids stays None. The code then falls into the elif not is_proxy_admin: branch and injects the caller's user_id, giving the org admin only their direct team memberships — not all teams in their organisation.
Compare this with the legacy _authorize_and_filter_teams (line 3568) which explicitly comments:
# Check if user is an org admin (even for own queries, so they see org teams)
and always fetches allowed_org_ids regardless of is_own_query.
The fix is to move the org admin DB lookup before the passes_route_check short-circuit, then use org_admin_org_ids is None and not passes_route_check as the rejection condition. This mirrors the legacy endpoint's intent and ensures org admins always see org-scoped teams even when querying with their own user_id.
- Fix org admin own-query regression: always check org admin status before the standard route check so own-queries see all org teams - Clear user_id when org admin is detected so org scope replaces user-membership scope - Remove dead isinstance(organization_id, list) branch - Remove unused datetime import - Remove orphaned _convert_teams_to_response helper Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
| if org_admin_org_ids is not None: | ||
| # Org admin: validate org_id filter if provided | ||
| if organization_id and organization_id not in org_admin_org_ids: | ||
| raise HTTPException( | ||
| status_code=403, | ||
| detail={ | ||
| "error": "You can only view teams within your organizations." | ||
| }, | ||
| ) | ||
| # Org admin scope replaces user_id scope — the org filter | ||
| # already returns all teams in their orgs, so we don't also | ||
| # restrict by the user's direct team memberships. | ||
| user_id = None |
There was a problem hiding this comment.
Behavioral divergence from legacy endpoint: org admin direct memberships are dropped
The legacy _authorize_and_filter_teams explicitly returns org teams + any teams the org admin is a direct member of (even teams whose organization_id is not one of theirs), as documented in its docstring:
Org admins: teams from their orgs + teams they are direct members of.
The new code unconditionally sets user_id = None and scopes results purely to org_admin_org_ids. This means if an org admin is a direct member of a team that belongs to a different org (or no org), that team will be visible on /team/list but missing from /v2/team/list.
Because this PR is framed as a UI migration from the legacy endpoint, this silent drop in scope could cause org admins to see fewer teams in the new UI without any explanation. If the narrower scope is intentional, it should be documented in the PR description and in the endpoint docstring.
| # Org admin scope replaces user_id scope — the org filter | ||
| # already returns all teams in their orgs, so we don't also | ||
| # restrict by the user's direct team memberships. | ||
| user_id = None | ||
| verbose_proxy_logger.debug( | ||
| "list_team_v2: org admin access for user=%s, org_ids=%s", | ||
| user_api_key_dict.user_id, | ||
| org_admin_org_ids, | ||
| ) |
There was a problem hiding this comment.
Caller-supplied
user_id query param is silently discarded for org admins
When an org admin passes ?user_id=X to filter teams by a specific user, the code unconditionally sets user_id = None and returns all org-scoped teams instead. There is no error, no warning log, and no documentation in the endpoint's docstring explaining that user_id is ignored for org admins. A caller who legitimately wants to filter teams by user within their org will receive unexpected, unfiltered results.
At minimum, add a note to the docstring, or consider logging a debug message when a non-None user_id is discarded:
if user_id is not None:
verbose_proxy_logger.debug(
"list_team_v2: org admin user_id filter (%s) ignored; using org scope instead",
user_id,
)
user_id = None| try: | ||
| caller_user = await get_user_object( | ||
| user_id=user_id, | ||
| prisma_client=prisma_client, | ||
| user_api_key_cache=user_api_key_cache, | ||
| user_id_upsert=False, | ||
| proxy_logging_obj=proxy_logging_obj, | ||
| ) | ||
| except Exception: | ||
| return None |
There was a problem hiding this comment.
Silent exception swallowing demotes org admins without any trace
The bare except Exception: return None silently discards any error from get_user_object (DB timeout, connection error, unexpected schema, etc.). When that happens the function returns None, org_admin_org_ids stays None, and the org admin falls through to the allowed_route_check_inside_route branch — producing a 401 or narrowed scope with no log entry to diagnose why.
Note that the legacy _authorize_and_filter_teams (line 3544) does not wrap get_user_object in a try/except — it lets the error propagate, which is the safer behaviour.
At minimum, log the caught exception before returning None so the problem is surfaced in the proxy logs:
except Exception as e:
verbose_proxy_logger.warning(
"_get_org_admin_org_ids: failed to fetch user=%s: %s",
user_id,
str(e),
)
return None- _get_org_admin_org_ids: catch only ValueError (user not found) instead of bare Exception — DB errors now propagate as 500s instead of silently demoting org admins to regular users - _build_team_list_where_conditions: return None (not a sentinel string) when user has no team memberships; list_team_v2 short-circuits to empty response without hitting the DB - Org admin + team_id + user_id: use exact team_id match with org scope instead of OR expansion that effectively ignored the team_id filter - Org admin + user_id (no team_id): OR(org teams, direct memberships) now matches legacy _authorize_and_filter_teams behaviour Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
| members_with_roles = team_dict.get("members_with_roles") | ||
| if not isinstance(members_with_roles, list): | ||
| members_with_roles = [] | ||
| team_dict["members_with_roles"] = members_with_roles | ||
| members_count = len(members_with_roles) | ||
| team_list.append(TeamListItem(**team_dict, members_count=members_count)) |
There was a problem hiding this comment.
members_with_roles fully loaded just to compute a count
Every team's members_with_roles JSON blob is fetched from the database and deserialized into Python memory purely to call len() on it. For large deployments where teams have hundreds or thousands of members this needlessly inflates the query payload.
If a lightweight count is needed, consider storing members_count as a denormalized integer column on LiteLLM_TeamTable that is incremented/decremented on add/remove operations, or use a Prisma _count aggregate in the select clause, so the wire payload stays small regardless of team size.
| if not is_proxy_admin: | ||
| # Always check org admin status so that even own-queries see | ||
| # the full set of organisation teams, not just direct memberships. | ||
| if user_api_key_dict.user_id: | ||
| org_admin_org_ids = await _get_org_admin_org_ids( | ||
| user_id=user_api_key_dict.user_id, | ||
| prisma_client=prisma_client, | ||
| user_api_key_cache=user_api_key_cache, | ||
| proxy_logging_obj=proxy_logging_obj, | ||
| ) |
There was a problem hiding this comment.
Extra DB/cache lookup added to every non-admin request
_get_org_admin_org_ids (which calls get_user_object) is now invoked unconditionally for every non-proxy-admin call to /v2/team/list, even for plain regular users who will never be org admins. While get_user_object has a cache layer, a cache miss still issues a DB query and the added cache-hit overhead is on every request.
Consider short-circuiting the check using a cheaper signal first — e.g. checking user_api_key_dict.user_role against LitellmUserRoles.ORG_ADMIN before calling into _get_org_admin_org_ids. If the JWT/key auth already has the role set, no extra DB call is needed.
| elif org_admin_org_ids is not None: | ||
| # Org admin with user_id filter: show teams in their orgs | ||
| # OR teams the user is a direct member of (matches legacy | ||
| # _authorize_and_filter_teams behaviour). | ||
| # When team_id is also provided, the exact match is already in | ||
| # where_conditions and the org scope just needs to be added — | ||
| # no OR expansion needed. | ||
| if team_id is not None: | ||
| where_conditions["organization_id"] = {"in": org_admin_org_ids} | ||
| elif user_team_ids: | ||
| org_condition: Dict[str, Any] = {"organization_id": {"in": org_admin_org_ids}} | ||
| where_conditions["OR"] = [org_condition, {"team_id": {"in": user_team_ids}}] | ||
| else: | ||
| where_conditions["organization_id"] = {"in": org_admin_org_ids} |
There was a problem hiding this comment.
Explicit
organization_id filter silently widened when team_id + user_id are also set
When an org admin calls the endpoint with ?organization_id=org1&user_id=user1&team_id=team1, the following happens:
- Line 3271 sets
where_conditions["organization_id"] = "org1"(the caller's explicit filter). - Line 3297–3298 then overwrites it with
{"in": org_admin_org_ids}, which may includeorg1,org2,org3, etc.
The result is that the query searches all of the admin's organizations instead of just org1, directly contradicting the caller's filter. The access-control check (line 3416) guarantees org1 is valid, but it does not prevent the scope from being silently expanded.
The fix is to honour the already-set organization_id value when team_id is also present:
if team_id is not None:
# team_id is already in where_conditions; keep the
# user-supplied organization_id if present, otherwise
# scope to all admin orgs.
if "organization_id" not in where_conditions:
where_conditions["organization_id"] = {"in": org_admin_org_ids}| user_object = await prisma_client.db.litellm_usertable.find_unique( | ||
| where={"user_id": user_id} | ||
| ) |
There was a problem hiding this comment.
Direct DB query violates helper-function rule
prisma_client.db.litellm_usertable.find_unique is a raw Prisma call in the request path. The repository policy requires all DB access to go through the canonical helper functions (get_user_object, get_team_object, etc.) so that caching, metrics, and circuit-breaking are applied uniformly.
The get_user_object helper already exists, supports caching via user_api_key_cache, and is used correctly by _get_org_admin_org_ids just above. The same helper should be used here instead of calling Prisma directly, which would require threading user_api_key_cache and proxy_logging_obj into _build_team_list_where_conditions — both are already available in list_team_v2.
Rule Used: What: In critical path of request, there should be... (source)
- Replace raw find_unique with get_user_object in _build_team_list_where_conditions for cache/metrics consistency - Remove over-complex OR clause for org admin + user_id: when user_id is provided, filter by that user's direct team memberships (same as regular users) since the access control gate already verified the org admin's authority - Preserve caller-supplied organization_id instead of overwriting with org_admin_org_ids - Update test mock to match get_user_object call path Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
| if org_admin_org_ids is not None: | ||
| # Org admin: validate org_id filter if provided | ||
| if organization_id and organization_id not in org_admin_org_ids: | ||
| raise HTTPException( | ||
| status_code=403, | ||
| detail={ | ||
| "error": "You can only view teams within your organizations." | ||
| }, | ||
| ) | ||
| verbose_proxy_logger.debug( | ||
| "list_team_v2: org admin access for user=%s, org_ids=%s, user_id_filter=%s", | ||
| user_api_key_dict.user_id, | ||
| org_admin_org_ids, | ||
| user_id, | ||
| ) | ||
| else: | ||
| # Not an org admin — fall back to standard route check | ||
| if not allowed_route_check_inside_route( | ||
| user_api_key_dict=user_api_key_dict, requested_user_id=user_id | ||
| ): | ||
| raise HTTPException( | ||
| status_code=401, | ||
| detail={ | ||
| "error": "Only admin users can query all teams/other teams. Your user role={}".format( | ||
| user_api_key_dict.user_role | ||
| ) | ||
| }, | ||
| ) | ||
| # Regular user — auto-inject caller's user_id | ||
| if user_id is None: | ||
| user_id = user_api_key_dict.user_id |
There was a problem hiding this comment.
No unit tests cover the new org-admin access control paths
This PR introduces two new code paths that are exercised only through the org-admin branch:
- org admin with a valid
organization_idfilter → 200 - org admin with an invalid
organization_idfilter → 403 - org admin without any filter → org-scoped teams
The test diff shows only updates to test_list_team_v2_security_check_non_admin_user_own_teams to accommodate the new get_user_object mocking; no new test functions covering these paths appear in the diff. The PR description says these scenarios were "manually verified via unit tests," but since these tests aren't in the repo they won't protect against regressions.
At minimum, the following cases should have dedicated tests:
- An org admin whose
organization_idfilter is within their orgs gets200with org-scoped results. - An org admin whose
organization_idfilter is from a different org gets403. - A user with no org memberships is rejected with
401(verifying the org-admin path doesn't accidentally grant access).
| if organization_id: | ||
| where_conditions["organization_id"] = organization_id | ||
| elif org_admin_org_ids is not None and not user_id: | ||
| # Org admin without explicit org or user filter: scope to their orgs | ||
| where_conditions["organization_id"] = {"in": org_admin_org_ids} |
There was a problem hiding this comment.
Org scope is silently dropped when org admin provides
user_id + no organization_id
When an org admin calls the endpoint with ?user_id=<anyone> but no organization_id, the condition:
elif org_admin_org_ids is not None and not user_id:
where_conditions["organization_id"] = {"in": org_admin_org_ids}evaluates to False (because user_id is truthy), so no organisation filter is injected. The resulting query then returns all teams of the specified user — from any organisation on the platform — without validating that those teams belong to the org admin's organisations.
The legacy _authorize_and_filter_teams is also permissive here (it returns org-teams ∪ user's-member-teams), so this isn't a regression relative to the legacy endpoint. However, the endpoint docstring and the PR's stated invariant ("Org admins can query teams within their organizations") both imply a narrower scope than what is actually enforced.
If the intention is to allow an org admin to look up teams for an arbitrary user, this should be explicitly documented in the docstring. If the intention is strict org-scoping, consider applying both filters simultaneously:
elif org_admin_org_ids is not None:
conditions = {"organization_id": {"in": org_admin_org_ids}}
if not user_id:
where_conditions.update(conditions)
# user_id path will further intersect via team_id IN user_team_ids- Document intentional legacy-matching behavior: when user_id is provided to an org admin, no org filter is applied (returns all of that user's teams across all orgs, same as legacy endpoint) - Fix two existing security tests to properly patch user_api_key_cache, proxy_logging_obj, and get_user_object instead of relying on incidental error handling - Add three new org admin test cases: - Org admin sees org-scoped teams (200 with correct where clause) - Org admin rejected when filtering by other org (403) - Org admin with user_id filter returns target user's teams Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
| call_count = 0 | ||
|
|
||
| async def mock_get_user_object(**kwargs): | ||
| nonlocal call_count | ||
| call_count += 1 | ||
| # First call: org admin lookup in list_team_v2 | ||
| # Second call: target user lookup in _build_team_list_where_conditions | ||
| if call_count == 1: | ||
| return mock_org_admin | ||
| return mock_target_user |
There was a problem hiding this comment.
Fragile call-order dependency in mock
The call_count counter ties the test's correctness to the exact invocation order of get_user_object across two separate internal functions (_get_org_admin_org_ids and _build_team_list_where_conditions). If either function is refactored to reorder, add, or cache the call, the mock silently returns the wrong user without any assertion failure, making bugs very hard to diagnose.
A more robust pattern is to use side_effect keyed on the user_id kwarg so the mock is order-independent:
async def mock_get_user_object(**kwargs):
uid = kwargs.get("user_id")
if uid == "org_admin_user":
return mock_org_admin
if uid == "target_user":
return mock_target_user
return None| except ValueError: | ||
| raise HTTPException( | ||
| status_code=404, | ||
| detail={"error": f"User not found, passed user_id={user_id}"}, | ||
| ) |
There was a problem hiding this comment.
Narrowed exception scope changes observable error behavior
The old code caught except Exception and converted all get_user_object failures into a 404 User not found. The new code only catches ValueError. This means any non-ValueError exception from get_user_object (DB connection timeout, prisma_client being None inside the helper, unexpected schema error, etc.) will now propagate up as an unhandled 500 instead of a 404.
While semantically more correct (a DB timeout is not a "user not found"), this is a observable behavior change for API callers who previously received a 404 and may now receive a 500. If the only exception get_user_object ever raises for a missing user is indeed ValueError, then a comment to that effect would clarify the intent. If other exceptions are possible for the "user does not exist" case, they should also be caught:
except (ValueError, SomeOtherExpectedError):
raise HTTPException(
status_code=404,
detail={"error": f"User not found, passed user_id={user_id}"},
)d2e77e7
into
litellm_yj_march_17_2026
Relevant issues
N/A - internal improvement to support UI migration to /v2/team/list
Summary
Problem
The existing
/v2/team/listendpoint is missing org admin access control. Org admins get a 401 when trying to list teams, even though the legacy/team/listendpoint supports them. The response also lacksmembers_count, forcing the UI to compute it client-side or fetch full membership data.Fix
_authorize_and_filter_teams(legacy endpoint) intolist_team_v2. Org admins can now list teams scoped to their organizations.TeamListItemresponse model with amembers_countfield computed frommembers_with_roles.@@indexdirectives onLiteLLM_TeamTablefororganization_id,team_alias, andcreated_at(the deleted team table already had these).Changes
Access control flow in
list_team_v2now: proxy admin → standard route check → org admin check → reject. The org admin check usesget_user_objectto look up organization memberships and auto-injects anorganization_id IN [...]filter into the Prisma query._build_team_list_where_conditionswas extended with anorg_admin_org_idsparameter. Schema indexes were added to all 3 copies ofschema.prisma.Testing
test_team_endpoints.pypassmembers_countcomputed correctly,members_with_roles=Nonehandled safely, pagination metadata correctType
🆕 New Feature