From c631708df636ff7544bfa75d3e897471da04ba06 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 7 Mar 2026 15:38:31 -0800 Subject: [PATCH 1/4] feat: add opt-in scope_user_search_to_org flag for /user/filter/ui PR #22722 made org-scoping unconditional on /user/filter/ui, which broke team admins who aren't org admins (403 when searching users to add). This makes org-scoping opt-in via a new UI Settings toggle, restoring open search by default. Co-Authored-By: Claude Opus 4.6 --- .../internal_user_endpoints.py | 156 ++++++---- .../proxy_setting_endpoints.py | 6 + .../test_internal_user_endpoints.py | 268 +++++++++++++++++- .../AdminSettings/UISettings/UISettings.tsx | 35 +++ .../common_components/user_search_modal.tsx | 5 + .../src/components/networking.tsx | 13 +- .../src/components/team/TeamInfo.tsx | 1 + 7 files changed, 416 insertions(+), 68 deletions(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 92862ed9dc4..d43efd81386 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -1836,6 +1836,10 @@ async def ui_view_users( user_email: Optional[str] = fastapi.Query( default=None, description="User email in the request parameters" ), + team_id: Optional[str] = fastapi.Query( + default=None, + description="Team ID — used when a team admin searches for users to add to their team", + ), page: int = fastapi.Query( default=1, description="Page number for pagination", ge=1 ), @@ -1847,20 +1851,19 @@ async def ui_view_users( """ Filter users based on partial match of user_id or email with pagination. - - Proxy admins: receive all matching users. - - Organization admins: receive only users in their own organization(s). - - Other roles: access denied (403). + Behaviour depends on the ``scope_user_search_to_org`` UI-setting flag + (stored in the ``litellm_uisettings`` table): - Args: - user_id (Optional[str]): Partial user ID to search for - user_email (Optional[str]): Partial email to search for - page (int): Page number for pagination (starts at 1) - page_size (int): Number of items per page (max 100) - user_api_key_dict (UserAPIKeyAuth): User authentication information - - Returns: - List of matching user records (LiteLLM_UserTableFiltered), scoped by org for org admins. + * **Flag OFF (default):** any authenticated user can search all users. + * **Flag ON:** + - Proxy admins see all users. + - Org admins see only users in their org(s). + - Team admins for an org-bound team see users in that org. + - Others receive a 403. """ + from litellm.proxy.management_endpoints.common_utils import ( + _is_user_team_admin, + ) from litellm.proxy.proxy_server import ( prisma_client, proxy_logging_obj, @@ -1871,51 +1874,84 @@ async def ui_view_users( raise HTTPException(status_code=500, detail={"error": "No db connected"}) try: - # Restrict by caller role: proxy admin sees all; org admin sees only their org(s); others 403 - is_proxy_admin = _user_has_admin_view(user_api_key_dict) - if not is_proxy_admin: - if user_api_key_dict.user_id is None: - raise HTTPException( - status_code=403, - detail={ - "error": "Only proxy admins and organization admins can search users." - }, - ) - try: - caller_user = await get_user_object( - user_id=user_api_key_dict.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 ValueError: - # get_user_object raises ValueError when user not found (user_id_upsert=False) - raise HTTPException( - status_code=403, - detail={ - "error": "Only proxy admins and organization admins can search users." - }, - ) - if caller_user is None: - raise HTTPException( - status_code=403, - detail={ - "error": "Only proxy admins and organization admins can search users." - }, - ) - org_admin_org_ids = [ - m.organization_id - for m in (caller_user.organization_memberships or []) - if m.user_role == LitellmUserRoles.ORG_ADMIN.value - ] - if not org_admin_org_ids: - raise HTTPException( - status_code=403, - detail={ - "error": "Only proxy admins and organization admins can search users." - }, - ) + # Read the scope_user_search_to_org flag from the DB + ui_settings_row = ( + await prisma_client.db.litellm_uisettings.find_unique( + where={"id": "ui_settings"} + ) + ) + scope_flag = False + if ui_settings_row is not None: + settings_json = ui_settings_row.settings or {} # type: ignore[union-attr] + scope_flag = bool(settings_json.get("scope_user_search_to_org", False)) + + org_filter_ids: Optional[List[str]] = None + + if scope_flag: + is_proxy_admin = _user_has_admin_view(user_api_key_dict) + if not is_proxy_admin: + # Try to resolve org admin memberships + caller_user = None + if user_api_key_dict.user_id is not None: + try: + caller_user = await get_user_object( + user_id=user_api_key_dict.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 ValueError: + caller_user = None + + org_admin_org_ids: List[str] = [] + if caller_user is not None: + org_admin_org_ids = [ + m.organization_id + for m in (caller_user.organization_memberships or []) + if m.user_role == LitellmUserRoles.ORG_ADMIN.value + ] + + if org_admin_org_ids: + org_filter_ids = org_admin_org_ids + elif team_id is not None: + # Look up the team to check if it belongs to an org + team_row = await prisma_client.db.litellm_teamtable.find_unique( + where={"team_id": team_id} + ) + if team_row is not None: + team_obj = LiteLLM_TeamTable(**team_row.model_dump()) + if _is_user_team_admin(user_api_key_dict, team_obj): + if team_obj.organization_id: + org_filter_ids = [team_obj.organization_id] + else: + raise HTTPException( + status_code=403, + detail={ + "error": "scope_user_search_to_org is enabled and this team is not part of an organization. Contact your proxy admin to adjust this setting." + }, + ) + else: + raise HTTPException( + status_code=403, + detail={ + "error": "scope_user_search_to_org is enabled. Only proxy admins, organization admins, or team admins can search users." + }, + ) + else: + raise HTTPException( + status_code=403, + detail={ + "error": "scope_user_search_to_org is enabled. Only proxy admins, organization admins, or team admins can search users." + }, + ) + else: + raise HTTPException( + status_code=403, + detail={ + "error": "scope_user_search_to_org is enabled. Only proxy admins, organization admins, or team admins can search users." + }, + ) # Calculate offset for pagination skip = (page - 1) * page_size @@ -1935,10 +1971,10 @@ async def ui_view_users( "mode": "insensitive", # Case-insensitive search } - # Org admins: only users in their org(s) - if not is_proxy_admin: + # Apply org filter when scope_user_search_to_org is ON and caller is not proxy admin + if org_filter_ids is not None: where_conditions["organization_memberships"] = { - "some": {"organization_id": {"in": org_admin_org_ids}} + "some": {"organization_id": {"in": org_filter_ids}} } # Query users with pagination and filters diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 2f7f81a703e..9603f55e489 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -124,6 +124,11 @@ class UISettings(BaseModel): description="If true, team admins are exempt from the vector stores disable restriction (only takes effect when disable_vector_stores_for_internal_users is true).", ) + scope_user_search_to_org: bool = Field( + default=False, + description="If enabled, the user search endpoint (/user/filter/ui) restricts results by organization. When off, any authenticated user can search all users.", + ) + class UISettingsResponse(SettingsResponse): """Response model for UI settings""" @@ -143,6 +148,7 @@ ALLOWED_UI_SETTINGS_FIELDS = { "allow_agents_for_team_admins", "disable_vector_stores_for_internal_users", "allow_vector_stores_for_team_admins", + "scope_user_search_to_org", } # Flags that must be synced from the persisted UISettings into diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index 16b5feb108a..6316d13b98f 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -54,6 +54,12 @@ async def test_ui_view_users_with_null_email(mocker, caplog): mock_prisma_client.db.litellm_usertable.find_many = mock_find_many + # Flag OFF by default — no settings row + async def mock_find_unique_settings(*args, **kwargs): + return None + + mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) # Proxy admin: no org filter, no get_user_object call @@ -63,6 +69,7 @@ async def test_ui_view_users_with_null_email(mocker, caplog): ), user_id="test_user", user_email=None, + team_id=None, page=1, page_size=50, ) @@ -83,6 +90,12 @@ async def test_ui_view_users_proxy_admin_no_org_filter(mocker): return [] mock_prisma_client.db.litellm_usertable.find_many = mock_find_many + + # Flag OFF by default + async def mock_find_unique_settings(*args, **kwargs): + return None + + mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) await ui_view_users( @@ -91,6 +104,7 @@ async def test_ui_view_users_proxy_admin_no_org_filter(mocker): ), user_id=None, user_email="foo", + team_id=None, page=1, page_size=50, ) @@ -99,8 +113,8 @@ async def test_ui_view_users_proxy_admin_no_org_filter(mocker): @pytest.mark.asyncio async def test_ui_view_users_org_admin_filtered_by_org(mocker): """ - Org admin: find_many is called with organization_memberships filter so only users - in the caller's org(s) are returned. + Org admin with scope_user_search_to_org ON: find_many is called with + organization_memberships filter so only users in the caller's org(s) are returned. """ from litellm.proxy._types import LiteLLM_OrganizationMembershipTable @@ -116,6 +130,16 @@ async def test_ui_view_users_org_admin_filtered_by_org(mocker): return [] mock_prisma_client.db.litellm_usertable.find_many = mock_find_many + + # Flag ON + mock_settings_row = mocker.MagicMock() + mock_settings_row.settings = {"scope_user_search_to_org": True} + + async def mock_find_unique_settings(*args, **kwargs): + return mock_settings_row + + mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", mocker.MagicMock()) mocker.patch("litellm.proxy.proxy_server.proxy_logging_obj", mocker.MagicMock()) @@ -143,6 +167,7 @@ async def test_ui_view_users_org_admin_filtered_by_org(mocker): user_api_key_dict=UserAPIKeyAuth(user_id="org-admin", user_role=None), user_id=None, user_email="u", + team_id=None, page=1, page_size=50, ) @@ -153,11 +178,21 @@ async def test_ui_view_users_org_admin_filtered_by_org(mocker): @pytest.mark.asyncio async def test_ui_view_users_non_org_admin_returns_403(mocker): """ - Caller is not proxy admin and not org admin: endpoint returns 403. + Flag ON, caller is not proxy admin and not org admin, no team_id: endpoint returns 403. """ from fastapi import HTTPException mock_prisma_client = mocker.MagicMock() + + # Flag ON + mock_settings_row = mocker.MagicMock() + mock_settings_row.settings = {"scope_user_search_to_org": True} + + async def mock_find_unique_settings(*args, **kwargs): + return mock_settings_row + + mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", mocker.MagicMock()) mocker.patch("litellm.proxy.proxy_server.proxy_logging_obj", mocker.MagicMock()) @@ -179,12 +214,237 @@ async def test_ui_view_users_non_org_admin_returns_403(mocker): user_api_key_dict=UserAPIKeyAuth(user_id="internal_user", user_role=None), user_id=None, user_email="u", + team_id=None, page=1, page_size=50, ) assert exc_info.value.status_code == 403 - assert "Only proxy admins and organization admins" in str(exc_info.value.detail) + assert "scope_user_search_to_org is enabled" in str(exc_info.value.detail) + + +@pytest.mark.asyncio +async def test_ui_view_users_flag_off_internal_user_can_search(mocker): + """ + Flag OFF (default): any authenticated user can search all users without org filtering. + """ + mock_prisma_client = mocker.MagicMock() + + async def mock_find_many(*args, **kwargs): + where = kwargs.get("where") or {} + assert "organization_memberships" not in where + return [] + + mock_prisma_client.db.litellm_usertable.find_many = mock_find_many + + # Flag OFF — no settings row + async def mock_find_unique_settings(*args, **kwargs): + return None + + mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + + response = await ui_view_users( + user_api_key_dict=UserAPIKeyAuth(user_id="internal_user", user_role=None), + user_id=None, + user_email="foo", + team_id=None, + page=1, + page_size=50, + ) + + assert response == [] + + +@pytest.mark.asyncio +async def test_ui_view_users_flag_on_team_admin_org_team(mocker): + """ + Flag ON, team admin for org-bound team: org filter is applied using team's org. + """ + from litellm.proxy._types import LiteLLM_TeamTable, Member + + mock_prisma_client = mocker.MagicMock() + org_id = "org-456" + tid = "team-789" + + async def mock_find_many(*args, **kwargs): + where = kwargs.get("where") or {} + assert "organization_memberships" in where + assert where["organization_memberships"] == { + "some": {"organization_id": {"in": [org_id]}} + } + return [] + + mock_prisma_client.db.litellm_usertable.find_many = mock_find_many + + # Flag ON + mock_settings_row = mocker.MagicMock() + mock_settings_row.settings = {"scope_user_search_to_org": True} + + async def mock_find_unique_settings(*args, **kwargs): + return mock_settings_row + + mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings + + # Team lookup + mock_team_row = mocker.MagicMock() + mock_team_row.model_dump.return_value = { + "team_id": tid, + "team_alias": "test-team", + "organization_id": org_id, + "members_with_roles": [{"user_id": "team-admin-user", "role": "admin"}], + "admins": [], + "members": [], + "blocked": False, + } + + async def mock_find_unique_team(*args, **kwargs): + return mock_team_row + + mock_prisma_client.db.litellm_teamtable.find_unique = mock_find_unique_team + + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", mocker.MagicMock()) + mocker.patch("litellm.proxy.proxy_server.proxy_logging_obj", mocker.MagicMock()) + + # Caller is not org admin + caller_user = mocker.MagicMock() + caller_user.organization_memberships = [] + + async def mock_get_user_object(*args, **kwargs): + return caller_user + + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints.get_user_object", + side_effect=mock_get_user_object, + ) + + response = await ui_view_users( + user_api_key_dict=UserAPIKeyAuth(user_id="team-admin-user", user_role=None), + user_id=None, + user_email="u", + team_id=tid, + page=1, + page_size=50, + ) + + assert response == [] + + +@pytest.mark.asyncio +async def test_ui_view_users_flag_on_team_admin_non_org_team_403(mocker): + """ + Flag ON, team admin for non-org team: returns 403. + """ + from fastapi import HTTPException + + mock_prisma_client = mocker.MagicMock() + tid = "team-no-org" + + # Flag ON + mock_settings_row = mocker.MagicMock() + mock_settings_row.settings = {"scope_user_search_to_org": True} + + async def mock_find_unique_settings(*args, **kwargs): + return mock_settings_row + + mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings + + # Team lookup — no organization_id + mock_team_row = mocker.MagicMock() + mock_team_row.model_dump.return_value = { + "team_id": tid, + "team_alias": "no-org-team", + "organization_id": None, + "members_with_roles": [{"user_id": "team-admin-user", "role": "admin"}], + "admins": [], + "members": [], + "blocked": False, + } + + async def mock_find_unique_team(*args, **kwargs): + return mock_team_row + + mock_prisma_client.db.litellm_teamtable.find_unique = mock_find_unique_team + + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", mocker.MagicMock()) + mocker.patch("litellm.proxy.proxy_server.proxy_logging_obj", mocker.MagicMock()) + + # Caller is not org admin + caller_user = mocker.MagicMock() + caller_user.organization_memberships = [] + + async def mock_get_user_object(*args, **kwargs): + return caller_user + + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints.get_user_object", + side_effect=mock_get_user_object, + ) + + with pytest.raises(HTTPException) as exc_info: + await ui_view_users( + user_api_key_dict=UserAPIKeyAuth( + user_id="team-admin-user", user_role=None + ), + user_id=None, + user_email="u", + team_id=tid, + page=1, + page_size=50, + ) + + assert exc_info.value.status_code == 403 + assert "not part of an organization" in str(exc_info.value.detail) + + +@pytest.mark.asyncio +async def test_ui_view_users_flag_on_non_admin_no_team_id_403(mocker): + """ + Flag ON, non-admin caller without team_id: returns 403. + """ + from fastapi import HTTPException + + mock_prisma_client = mocker.MagicMock() + + # Flag ON + mock_settings_row = mocker.MagicMock() + mock_settings_row.settings = {"scope_user_search_to_org": True} + + async def mock_find_unique_settings(*args, **kwargs): + return mock_settings_row + + mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings + + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", mocker.MagicMock()) + mocker.patch("litellm.proxy.proxy_server.proxy_logging_obj", mocker.MagicMock()) + + # Caller is not org admin + caller_user = mocker.MagicMock() + caller_user.organization_memberships = [] + + async def mock_get_user_object(*args, **kwargs): + return caller_user + + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints.get_user_object", + side_effect=mock_get_user_object, + ) + + with pytest.raises(HTTPException) as exc_info: + await ui_view_users( + user_api_key_dict=UserAPIKeyAuth(user_id="internal_user", user_role=None), + user_id=None, + user_email="u", + team_id=None, + page=1, + page_size=50, + ) + + assert exc_info.value.status_code == 403 + assert "scope_user_search_to_org is enabled" in str(exc_info.value.detail) def test_user_daily_activity_types(): diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx index dfc66d3484d..a22c78c9430 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx @@ -23,6 +23,7 @@ export default function UISettings() { const allowAgentsTeamAdminsProperty = schema?.properties?.allow_agents_for_team_admins; const disableVectorStoresProperty = schema?.properties?.disable_vector_stores_for_internal_users; const allowVectorStoresTeamAdminsProperty = schema?.properties?.allow_vector_stores_for_team_admins; + const scopeUserSearchProperty = schema?.properties?.scope_user_search_to_org; const values = data?.values ?? {}; const isDisabledForInternalUsers = Boolean(values.disable_model_add_for_internal_users); const isDisabledTeamAdminDeleteTeamUser = Boolean(values.disable_team_admin_delete_team_user); @@ -167,6 +168,20 @@ export default function UISettings() { ); }; + const handleToggleScopeUserSearch = (checked: boolean) => { + updateSettings( + { scope_user_search_to_org: checked }, + { + onSuccess: () => { + NotificationManager.success("UI settings updated successfully"); + }, + onError: (error) => { + NotificationManager.fromBackend(error); + }, + }, + ); + }; + return ( {isLoading ? ( @@ -347,6 +362,26 @@ export default function UISettings() { + {/* Scope user search to organization */} + + + + Scope user search to organization + + {scopeUserSearchProperty?.description ?? + "If enabled, the user search endpoint restricts results by organization. When off, any authenticated user can search all users."} + + + + + + {/* Page Visibility for Internal Users */} = ({ @@ -52,6 +53,7 @@ const UserSearchModal: React.FC = ({ { label: "user", value: "user", description: "User role. Can view team info, but not manage it." }, ], defaultRole = "user", + teamId, }) => { const [form] = Form.useForm(); const [userOptions, setUserOptions] = useState([]); @@ -69,6 +71,9 @@ const UserSearchModal: React.FC = ({ try { const params = new URLSearchParams(); params.append(fieldName, searchText); + if (teamId) { + params.append("team_id", teamId); + } if (accessToken == null) { return; } diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 91454d8d8b3..8d1e8ce81cd 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -2473,14 +2473,19 @@ export const allEndUsersCall = async (accessToken: string) => { export const userFilterUICall = async (accessToken: string, params: URLSearchParams) => { try { - let url = proxyBaseUrl ? `${proxyBaseUrl}/user/filter/ui` : `/user/filter/ui`; - + const base = proxyBaseUrl ? `${proxyBaseUrl}/user/filter/ui` : `/user/filter/ui`; + const queryParams = new URLSearchParams(); if (params.get("user_email")) { - url += `?user_email=${params.get("user_email")}`; + queryParams.append("user_email", params.get("user_email")!); } if (params.get("user_id")) { - url += `?user_id=${params.get("user_id")}`; + queryParams.append("user_id", params.get("user_id")!); } + if (params.get("team_id")) { + queryParams.append("team_id", params.get("team_id")!); + } + const qs = queryParams.toString(); + const url = qs ? `${base}?${qs}` : base; const response = await fetch(url, { method: "GET", diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 62208b4186d..7d350b281e4 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -1303,6 +1303,7 @@ const TeamInfoView: React.FC = ({ onCancel={() => setIsAddMemberModalVisible(false)} onSubmit={handleMemberCreate} accessToken={accessToken} + teamId={teamId} /> {/* Delete Member Confirmation Modal */} From af61132a3f4331a2ff352d38ec92b03835a126e8 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 7 Mar 2026 15:56:43 -0800 Subject: [PATCH 2/4] refactor: use DualCache for UI settings reads and get_team_object for team lookup - Add get_ui_settings_cached() helper that reads from DualCache first, falls back to DB, and populates cache on miss. - Update update_ui_settings() to set cache after DB write so subsequent reads see new values immediately. - Replace raw prisma_client.db.litellm_teamtable.find_unique with the existing get_team_object helper which uses DualCache. - Update all tests to mock get_ui_settings_cached and get_team_object instead of raw DB calls. Co-Authored-By: Claude Opus 4.6 --- .../internal_user_endpoints.py | 54 ++++--- .../proxy_setting_endpoints.py | 52 +++++++ .../test_internal_user_endpoints.py | 140 ++++++++---------- 3 files changed, 140 insertions(+), 106 deletions(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index d43efd81386..8d9da86aab2 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -30,7 +30,7 @@ from litellm.proxy.management_endpoints.common_daily_activity import ( get_daily_activity, get_daily_activity_aggregated, ) -from litellm.proxy.auth.auth_checks import get_user_object +from litellm.proxy.auth.auth_checks import get_team_object, get_user_object from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view from litellm.proxy.management_endpoints.key_management_endpoints import ( generate_key_helper_fn, @@ -1869,21 +1869,17 @@ async def ui_view_users( proxy_logging_obj, user_api_key_cache, ) + from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ( + get_ui_settings_cached, + ) if prisma_client is None: raise HTTPException(status_code=500, detail={"error": "No db connected"}) try: - # Read the scope_user_search_to_org flag from the DB - ui_settings_row = ( - await prisma_client.db.litellm_uisettings.find_unique( - where={"id": "ui_settings"} - ) - ) - scope_flag = False - if ui_settings_row is not None: - settings_json = ui_settings_row.settings or {} # type: ignore[union-attr] - scope_flag = bool(settings_json.get("scope_user_search_to_org", False)) + # Read the scope_user_search_to_org flag (cached) + ui_settings = await get_ui_settings_cached() + scope_flag = bool(ui_settings.get("scope_user_search_to_org", False)) org_filter_ids: Optional[List[str]] = None @@ -1915,27 +1911,29 @@ async def ui_view_users( if org_admin_org_ids: org_filter_ids = org_admin_org_ids elif team_id is not None: - # Look up the team to check if it belongs to an org - team_row = await prisma_client.db.litellm_teamtable.find_unique( - where={"team_id": team_id} - ) - if team_row is not None: - team_obj = LiteLLM_TeamTable(**team_row.model_dump()) - if _is_user_team_admin(user_api_key_dict, team_obj): - if team_obj.organization_id: - org_filter_ids = [team_obj.organization_id] - else: - raise HTTPException( - status_code=403, - detail={ - "error": "scope_user_search_to_org is enabled and this team is not part of an organization. Contact your proxy admin to adjust this setting." - }, - ) + # Look up the team via cached helper + try: + team_obj = await get_team_object( + team_id=team_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + except HTTPException: + raise HTTPException( + status_code=403, + detail={ + "error": "scope_user_search_to_org is enabled. Only proxy admins, organization admins, or team admins can search users." + }, + ) + if _is_user_team_admin(user_api_key_dict, team_obj): + if team_obj.organization_id: + org_filter_ids = [team_obj.organization_id] else: raise HTTPException( status_code=403, detail={ - "error": "scope_user_search_to_org is enabled. Only proxy admins, organization admins, or team admins can search users." + "error": "scope_user_search_to_org is enabled and this team is not part of an organization. Contact your proxy admin to adjust this setting." }, ) else: diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 9603f55e489..04b524ebbb7 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -980,6 +980,48 @@ async def get_in_product_nudges(): return InProductNudgeResponse(is_claude_code_enabled=False) +UI_SETTINGS_CACHE_KEY = "ui_settings:settings_dict" + + +async def get_ui_settings_cached() -> Dict[str, Any]: + """ + Return the persisted UI settings dict, using DualCache for reads. + + Cache hit → return cached dict immediately. + Cache miss → read from DB, populate cache, return dict. + """ + from litellm.proxy.proxy_server import prisma_client, user_api_key_cache + + # 1. Try cache + cached = await user_api_key_cache.async_get_cache(key=UI_SETTINGS_CACHE_KEY) + if cached is not None and isinstance(cached, dict): + return cached + + # 2. Fallback to DB + if prisma_client is None: + return {} + + db_record = await prisma_client.db.litellm_uisettings.find_unique( + where={"id": "ui_settings"} + ) + ui_settings: Dict[str, Any] = {} + if db_record and db_record.ui_settings: + raw = db_record.ui_settings + ui_settings = json.loads(raw) if isinstance(raw, str) else dict(raw) + + # Sanitize + ui_settings = { + k: v for k, v in ui_settings.items() if k in ALLOWED_UI_SETTINGS_FIELDS + } + + # 3. Populate cache + await user_api_key_cache.async_set_cache( + key=UI_SETTINGS_CACHE_KEY, value=ui_settings + ) + + return ui_settings + + @router.get( "/get/ui_settings", tags=["UI Settings"], @@ -1108,6 +1150,16 @@ async def update_ui_settings( general_settings.update(_flags_to_sync) + # Invalidate + set DualCache so subsequent reads see the new values immediately + from litellm.proxy.proxy_server import user_api_key_cache + + sanitized = { + k: v for k, v in ui_settings.items() if k in ALLOWED_UI_SETTINGS_FIELDS + } + await user_api_key_cache.async_set_cache( + key=UI_SETTINGS_CACHE_KEY, value=sanitized + ) + return { "message": "UI settings updated successfully", "status": "success", diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index 6316d13b98f..16d16c040a3 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -54,11 +54,11 @@ async def test_ui_view_users_with_null_email(mocker, caplog): mock_prisma_client.db.litellm_usertable.find_many = mock_find_many - # Flag OFF by default — no settings row - async def mock_find_unique_settings(*args, **kwargs): - return None - - mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings + # Flag OFF by default + mocker.patch( + "litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.get_ui_settings_cached", + return_value={}, + ) mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) @@ -92,10 +92,10 @@ async def test_ui_view_users_proxy_admin_no_org_filter(mocker): mock_prisma_client.db.litellm_usertable.find_many = mock_find_many # Flag OFF by default - async def mock_find_unique_settings(*args, **kwargs): - return None - - mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings + mocker.patch( + "litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.get_ui_settings_cached", + return_value={}, + ) mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) await ui_view_users( @@ -132,13 +132,10 @@ async def test_ui_view_users_org_admin_filtered_by_org(mocker): mock_prisma_client.db.litellm_usertable.find_many = mock_find_many # Flag ON - mock_settings_row = mocker.MagicMock() - mock_settings_row.settings = {"scope_user_search_to_org": True} - - async def mock_find_unique_settings(*args, **kwargs): - return mock_settings_row - - mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings + mocker.patch( + "litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.get_ui_settings_cached", + return_value={"scope_user_search_to_org": True}, + ) mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", mocker.MagicMock()) @@ -185,13 +182,10 @@ async def test_ui_view_users_non_org_admin_returns_403(mocker): mock_prisma_client = mocker.MagicMock() # Flag ON - mock_settings_row = mocker.MagicMock() - mock_settings_row.settings = {"scope_user_search_to_org": True} - - async def mock_find_unique_settings(*args, **kwargs): - return mock_settings_row - - mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings + mocker.patch( + "litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.get_ui_settings_cached", + return_value={"scope_user_search_to_org": True}, + ) mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", mocker.MagicMock()) @@ -237,11 +231,11 @@ async def test_ui_view_users_flag_off_internal_user_can_search(mocker): mock_prisma_client.db.litellm_usertable.find_many = mock_find_many - # Flag OFF — no settings row - async def mock_find_unique_settings(*args, **kwargs): - return None - - mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings + # Flag OFF + mocker.patch( + "litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.get_ui_settings_cached", + return_value={}, + ) mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) response = await ui_view_users( @@ -261,7 +255,7 @@ async def test_ui_view_users_flag_on_team_admin_org_team(mocker): """ Flag ON, team admin for org-bound team: org filter is applied using team's org. """ - from litellm.proxy._types import LiteLLM_TeamTable, Member + from litellm.proxy._types import LiteLLM_TeamTableCachedObj mock_prisma_client = mocker.MagicMock() org_id = "org-456" @@ -278,30 +272,26 @@ async def test_ui_view_users_flag_on_team_admin_org_team(mocker): mock_prisma_client.db.litellm_usertable.find_many = mock_find_many # Flag ON - mock_settings_row = mocker.MagicMock() - mock_settings_row.settings = {"scope_user_search_to_org": True} + mocker.patch( + "litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.get_ui_settings_cached", + return_value={"scope_user_search_to_org": True}, + ) - async def mock_find_unique_settings(*args, **kwargs): - return mock_settings_row + # Mock get_team_object + team_obj = LiteLLM_TeamTableCachedObj( + team_id=tid, + team_alias="test-team", + organization_id=org_id, + members_with_roles=[{"user_id": "team-admin-user", "role": "admin"}], + ) - mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings + async def mock_get_team_object(*args, **kwargs): + return team_obj - # Team lookup - mock_team_row = mocker.MagicMock() - mock_team_row.model_dump.return_value = { - "team_id": tid, - "team_alias": "test-team", - "organization_id": org_id, - "members_with_roles": [{"user_id": "team-admin-user", "role": "admin"}], - "admins": [], - "members": [], - "blocked": False, - } - - async def mock_find_unique_team(*args, **kwargs): - return mock_team_row - - mock_prisma_client.db.litellm_teamtable.find_unique = mock_find_unique_team + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints.get_team_object", + side_effect=mock_get_team_object, + ) mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", mocker.MagicMock()) @@ -337,35 +327,32 @@ async def test_ui_view_users_flag_on_team_admin_non_org_team_403(mocker): Flag ON, team admin for non-org team: returns 403. """ from fastapi import HTTPException + from litellm.proxy._types import LiteLLM_TeamTableCachedObj mock_prisma_client = mocker.MagicMock() tid = "team-no-org" # Flag ON - mock_settings_row = mocker.MagicMock() - mock_settings_row.settings = {"scope_user_search_to_org": True} + mocker.patch( + "litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.get_ui_settings_cached", + return_value={"scope_user_search_to_org": True}, + ) - async def mock_find_unique_settings(*args, **kwargs): - return mock_settings_row + # Mock get_team_object — team has no organization_id + team_obj = LiteLLM_TeamTableCachedObj( + team_id=tid, + team_alias="no-org-team", + organization_id=None, + members_with_roles=[{"user_id": "team-admin-user", "role": "admin"}], + ) - mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings + async def mock_get_team_object(*args, **kwargs): + return team_obj - # Team lookup — no organization_id - mock_team_row = mocker.MagicMock() - mock_team_row.model_dump.return_value = { - "team_id": tid, - "team_alias": "no-org-team", - "organization_id": None, - "members_with_roles": [{"user_id": "team-admin-user", "role": "admin"}], - "admins": [], - "members": [], - "blocked": False, - } - - async def mock_find_unique_team(*args, **kwargs): - return mock_team_row - - mock_prisma_client.db.litellm_teamtable.find_unique = mock_find_unique_team + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints.get_team_object", + side_effect=mock_get_team_object, + ) mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", mocker.MagicMock()) @@ -409,13 +396,10 @@ async def test_ui_view_users_flag_on_non_admin_no_team_id_403(mocker): mock_prisma_client = mocker.MagicMock() # Flag ON - mock_settings_row = mocker.MagicMock() - mock_settings_row.settings = {"scope_user_search_to_org": True} - - async def mock_find_unique_settings(*args, **kwargs): - return mock_settings_row - - mock_prisma_client.db.litellm_uisettings.find_unique = mock_find_unique_settings + mocker.patch( + "litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.get_ui_settings_cached", + return_value={"scope_user_search_to_org": True}, + ) mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", mocker.MagicMock()) From c11477fb6fa5de8836bab63665662de054a09fbf Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 7 Mar 2026 16:25:52 -0800 Subject: [PATCH 3/4] fix: add 10-minute TTL to UI settings cache and refresh on GET All async_set_cache calls for UI settings now use a 600s TTL so entries auto-expire in multi-instance deployments without Redis. The GET /ui_settings endpoint also refreshes the cache after reading from DB, ensuring the admin who just toggled the flag sees fresh values propagated to other code paths immediately. Co-Authored-By: Claude Opus 4.6 --- .../ui_crud_endpoints/proxy_setting_endpoints.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 04b524ebbb7..076a2c3bffd 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -981,6 +981,7 @@ async def get_in_product_nudges(): UI_SETTINGS_CACHE_KEY = "ui_settings:settings_dict" +UI_SETTINGS_CACHE_TTL = 600 # 10 minutes async def get_ui_settings_cached() -> Dict[str, Any]: @@ -1014,9 +1015,9 @@ async def get_ui_settings_cached() -> Dict[str, Any]: k: v for k, v in ui_settings.items() if k in ALLOWED_UI_SETTINGS_FIELDS } - # 3. Populate cache + # 3. Populate cache with TTL await user_api_key_cache.async_set_cache( - key=UI_SETTINGS_CACHE_KEY, value=ui_settings + key=UI_SETTINGS_CACHE_KEY, value=ui_settings, ttl=UI_SETTINGS_CACHE_TTL ) return ui_settings @@ -1066,6 +1067,13 @@ async def get_ui_settings(): general_settings.update(_flags_to_sync) + # Refresh DualCache so other code paths (e.g. /user/filter/ui) see fresh values + from litellm.proxy.proxy_server import user_api_key_cache + + await user_api_key_cache.async_set_cache( + key=UI_SETTINGS_CACHE_KEY, value=ui_settings, ttl=UI_SETTINGS_CACHE_TTL + ) + # Build config-like object for schema helper config: Dict[str, Any] = {"litellm_settings": {"ui_settings": ui_settings}} @@ -1157,7 +1165,7 @@ async def update_ui_settings( k: v for k, v in ui_settings.items() if k in ALLOWED_UI_SETTINGS_FIELDS } await user_api_key_cache.async_set_cache( - key=UI_SETTINGS_CACHE_KEY, value=sanitized + key=UI_SETTINGS_CACHE_KEY, value=sanitized, ttl=UI_SETTINGS_CACHE_TTL ) return { From bd9fe1cd63ed5995ae3d7a8643350aae2a920336 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 7 Mar 2026 17:56:49 -0800 Subject: [PATCH 4/4] fix: improve error messages and extract helper to fix C901 complexity - Extract _resolve_org_filter_for_user_search and _resolve_team_org_filter helpers from ui_view_users to bring complexity under the ruff C901 limit. - Fix misleading error when a team admin searches but team has no org: now says "this team is not part of an organization" instead of the generic "only admins can search" message. - Add distinct errors for team-not-found and not-a-team-admin cases. Co-Authored-By: Claude Opus 4.6 --- .../internal_user_endpoints.py | 198 +++++++++++------- 1 file changed, 119 insertions(+), 79 deletions(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 8d9da86aab2..50693850c56 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -1820,6 +1820,118 @@ async def add_internal_user_to_organization( raise Exception(f"Failed to add user to organization: {str(e)}") +async def _resolve_org_filter_for_user_search( + user_api_key_dict: UserAPIKeyAuth, + team_id: Optional[str], + prisma_client: Any, + user_api_key_cache: Any, + proxy_logging_obj: Any, +) -> Optional[List[str]]: + """ + Return a list of org IDs to filter by, or ``None`` for no filter. + + Reads the ``scope_user_search_to_org`` UI-setting flag and applies + role-based access rules when the flag is ON. + """ + from litellm.proxy.management_endpoints.common_utils import ( + _is_user_team_admin, + ) + from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ( + get_ui_settings_cached, + ) + + ui_settings = await get_ui_settings_cached() + if not ui_settings.get("scope_user_search_to_org", False): + return None # flag OFF — no filtering + + if _user_has_admin_view(user_api_key_dict): + return None # proxy admin — see everything + + # Try to resolve org admin memberships + caller_user = None + if user_api_key_dict.user_id is not None: + try: + caller_user = await get_user_object( + user_id=user_api_key_dict.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 ValueError: + caller_user = None + + org_admin_org_ids: List[str] = [] + if caller_user is not None: + org_admin_org_ids = [ + m.organization_id + for m in (caller_user.organization_memberships or []) + if m.user_role == LitellmUserRoles.ORG_ADMIN.value + ] + + if org_admin_org_ids: + return org_admin_org_ids + + if team_id is not None: + return await _resolve_team_org_filter( + user_api_key_dict, team_id, prisma_client, + user_api_key_cache, proxy_logging_obj, + ) + + raise HTTPException( + status_code=403, + detail={ + "error": "scope_user_search_to_org is enabled. Only proxy admins, organization admins, or team admins can search users." + }, + ) + + +async def _resolve_team_org_filter( + user_api_key_dict: UserAPIKeyAuth, + team_id: str, + prisma_client: Any, + user_api_key_cache: Any, + proxy_logging_obj: Any, +) -> List[str]: + """Look up the team and return its org as a filter list, or raise 403.""" + from litellm.proxy.management_endpoints.common_utils import ( + _is_user_team_admin, + ) + + try: + team_obj = await get_team_object( + team_id=team_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + except HTTPException: + raise HTTPException( + status_code=403, + detail={ + "error": f"scope_user_search_to_org is enabled but team '{team_id}' was not found." + }, + ) + + if not _is_user_team_admin(user_api_key_dict, team_obj): + raise HTTPException( + status_code=403, + detail={ + "error": "scope_user_search_to_org is enabled. You must be an admin of this team to search users." + }, + ) + + if team_obj.organization_id: + return [team_obj.organization_id] + + raise HTTPException( + status_code=403, + detail={ + "error": "scope_user_search_to_org is enabled and this team is not part of an organization. Contact your proxy admin to adjust this setting." + }, + ) + + @router.get( "/user/filter/ui", tags=["Internal User management"], @@ -1861,95 +1973,23 @@ async def ui_view_users( - Team admins for an org-bound team see users in that org. - Others receive a 403. """ - from litellm.proxy.management_endpoints.common_utils import ( - _is_user_team_admin, - ) from litellm.proxy.proxy_server import ( prisma_client, proxy_logging_obj, user_api_key_cache, ) - from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ( - get_ui_settings_cached, - ) if prisma_client is None: raise HTTPException(status_code=500, detail={"error": "No db connected"}) try: - # Read the scope_user_search_to_org flag (cached) - ui_settings = await get_ui_settings_cached() - scope_flag = bool(ui_settings.get("scope_user_search_to_org", False)) - - org_filter_ids: Optional[List[str]] = None - - if scope_flag: - is_proxy_admin = _user_has_admin_view(user_api_key_dict) - if not is_proxy_admin: - # Try to resolve org admin memberships - caller_user = None - if user_api_key_dict.user_id is not None: - try: - caller_user = await get_user_object( - user_id=user_api_key_dict.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 ValueError: - caller_user = None - - org_admin_org_ids: List[str] = [] - if caller_user is not None: - org_admin_org_ids = [ - m.organization_id - for m in (caller_user.organization_memberships or []) - if m.user_role == LitellmUserRoles.ORG_ADMIN.value - ] - - if org_admin_org_ids: - org_filter_ids = org_admin_org_ids - elif team_id is not None: - # Look up the team via cached helper - try: - team_obj = await get_team_object( - team_id=team_id, - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - proxy_logging_obj=proxy_logging_obj, - ) - except HTTPException: - raise HTTPException( - status_code=403, - detail={ - "error": "scope_user_search_to_org is enabled. Only proxy admins, organization admins, or team admins can search users." - }, - ) - if _is_user_team_admin(user_api_key_dict, team_obj): - if team_obj.organization_id: - org_filter_ids = [team_obj.organization_id] - else: - raise HTTPException( - status_code=403, - detail={ - "error": "scope_user_search_to_org is enabled and this team is not part of an organization. Contact your proxy admin to adjust this setting." - }, - ) - else: - raise HTTPException( - status_code=403, - detail={ - "error": "scope_user_search_to_org is enabled. Only proxy admins, organization admins, or team admins can search users." - }, - ) - else: - raise HTTPException( - status_code=403, - detail={ - "error": "scope_user_search_to_org is enabled. Only proxy admins, organization admins, or team admins can search users." - }, - ) + org_filter_ids = await _resolve_org_filter_for_user_search( + user_api_key_dict=user_api_key_dict, + team_id=team_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) # Calculate offset for pagination skip = (page - 1) * page_size