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 <noreply@anthropic.com>
This commit is contained in:
yuneng-jiang 2026-03-07 15:38:31 -08:00
parent cfd0e2cf99
commit c631708df6
7 changed files with 416 additions and 68 deletions

View File

@ -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

View File

@ -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

View File

@ -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():

View File

@ -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 (
<Card title="UI Settings">
{isLoading ? (
@ -347,6 +362,26 @@ export default function UISettings() {
<Divider />
{/* Scope user search to organization */}
<Space align="start" size="middle">
<Switch
checked={Boolean(values.scope_user_search_to_org)}
disabled={isUpdating}
loading={isUpdating}
onChange={handleToggleScopeUserSearch}
aria-label={scopeUserSearchProperty?.description ?? "Scope user search to organization"}
/>
<Space direction="vertical" size={4}>
<Typography.Text strong>Scope user search to organization</Typography.Text>
<Typography.Text type="secondary">
{scopeUserSearchProperty?.description ??
"If enabled, the user search endpoint restricts results by organization. When off, any authenticated user can search all users."}
</Typography.Text>
</Space>
</Space>
<Divider />
{/* Page Visibility for Internal Users */}
<PageVisibilitySettings
enabledPagesInternalUsers={values.enabled_ui_pages_internal_users}

View File

@ -35,6 +35,7 @@ interface UserSearchModalProps {
title?: string;
roles?: Role[];
defaultRole?: string;
teamId?: string;
}
const UserSearchModal: React.FC<UserSearchModalProps> = ({
@ -52,6 +53,7 @@ const UserSearchModal: React.FC<UserSearchModalProps> = ({
{ label: "user", value: "user", description: "User role. Can view team info, but not manage it." },
],
defaultRole = "user",
teamId,
}) => {
const [form] = Form.useForm<FormValues>();
const [userOptions, setUserOptions] = useState<UserOption[]>([]);
@ -69,6 +71,9 @@ const UserSearchModal: React.FC<UserSearchModalProps> = ({
try {
const params = new URLSearchParams();
params.append(fieldName, searchText);
if (teamId) {
params.append("team_id", teamId);
}
if (accessToken == null) {
return;
}

View File

@ -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",

View File

@ -1303,6 +1303,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
onCancel={() => setIsAddMemberModalVisible(false)}
onSubmit={handleMemberCreate}
accessToken={accessToken}
teamId={teamId}
/>
{/* Delete Member Confirmation Modal */}