diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index b3e9a8ea245..d797d9c7e0a 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -653,6 +653,8 @@ class LiteLLMRoutes(enum.Enum): "/model/update", "/model/delete", "/user/daily/activity", + "/user/available_roles", # read-only role metadata; any authenticated user may read + "/user/list", # org admins checked in endpoint; non-admins get 403 "/model/{model_id}/update", "/prompt/list", "/prompt/info", diff --git a/litellm/proxy/agent_endpoints/endpoints.py b/litellm/proxy/agent_endpoints/endpoints.py index 252db29a9da..646e6d59c39 100644 --- a/litellm/proxy/agent_endpoints/endpoints.py +++ b/litellm/proxy/agent_endpoints/endpoints.py @@ -106,6 +106,7 @@ async def get_agents( health_check: bool = Query( False, description="When true, performs a GET request to each agent's URL. Agents with reachable URLs (HTTP status < 500) and agents without a URL are returned; unreachable agents are filtered out.", + ), user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # Used for auth ): """ diff --git a/litellm/proxy/auth/auth_checks_organization.py b/litellm/proxy/auth/auth_checks_organization.py index e96a5c61fc0..50efe137209 100644 --- a/litellm/proxy/auth/auth_checks_organization.py +++ b/litellm/proxy/auth/auth_checks_organization.py @@ -144,19 +144,32 @@ def _user_is_org_admin( user_object: Optional[LiteLLM_UserTable] = None, ) -> bool: """ - Helper function to check if user is an org admin for the passed organization_id - """ - if request_data.get("organization_id", None) is None: - return False + Helper function to check if user is an org admin for any of the passed organizations. + Checks both: + - `organization_id` (singular string) — legacy callers + - `organizations` (list of strings) — used by /user/new + """ if user_object is None: return False if user_object.organization_memberships is None: return False + # Collect candidate org IDs from both fields + candidate_org_ids: List[str] = [] + singular = request_data.get("organization_id", None) + if singular is not None: + candidate_org_ids.append(singular) + orgs_list = request_data.get("organizations", None) + if isinstance(orgs_list, list): + candidate_org_ids.extend(orgs_list) + + if not candidate_org_ids: + return False + for _membership in user_object.organization_memberships: - if _membership.organization_id == request_data.get("organization_id", None): + if _membership.organization_id in candidate_org_ids: if _membership.user_role == LitellmUserRoles.ORG_ADMIN.value: return True diff --git a/litellm/proxy/management_endpoints/common_utils.py b/litellm/proxy/management_endpoints/common_utils.py index 5a2af0b37c7..e22f4e1b672 100644 --- a/litellm/proxy/management_endpoints/common_utils.py +++ b/litellm/proxy/management_endpoints/common_utils.py @@ -41,6 +41,46 @@ def _is_user_team_admin( return False +async def _is_user_org_admin_for_team( + user_api_key_dict: UserAPIKeyAuth, team_obj: LiteLLM_TeamTable +) -> bool: + """ + Check if user is an org admin for the team's organization. + + Returns True if: + - The team belongs to an organization, AND + - The user has org_admin role in that organization + """ + if not team_obj.organization_id or not user_api_key_dict.user_id: + return False + + from litellm.proxy.auth.auth_checks import get_user_object + from litellm.proxy.proxy_server import ( + prisma_client, + proxy_logging_obj, + user_api_key_cache, + ) + + 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, + ) + if caller_user is None: + return False + + for m in caller_user.organization_memberships or []: + if ( + m.organization_id == team_obj.organization_id + and m.user_role == LitellmUserRoles.ORG_ADMIN.value + ): + return True + + return False + + def _team_member_has_permission( user_api_key_dict: UserAPIKeyAuth, team_obj: LiteLLM_TeamTable, diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 92862ed9dc4..06338a33e4b 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, @@ -1469,6 +1469,72 @@ def _validate_sort_params( return order_by +async def _authorize_user_list_request( + user_api_key_dict: UserAPIKeyAuth, + organization_ids: Optional[str], + prisma_client: Any, + user_api_key_cache: Any, + proxy_logging_obj: Any, +) -> Optional[str]: + """ + Authorize the /user/list request and return the (possibly scoped) organization_ids string. + + - Proxy admins: returns organization_ids unchanged (may be None). + - Org admins: returns comma-separated org IDs scoped to their allowed orgs. + - Others: raises 403. + """ + if _user_has_admin_view(user_api_key_dict): + return organization_ids + + if user_api_key_dict.user_id is None: + raise HTTPException( + status_code=403, + detail={"error": "Only proxy admins and organization admins can list 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: + raise HTTPException( + status_code=403, + detail={"error": "Only proxy admins and organization admins can list users."}, + ) + if caller_user is None: + raise HTTPException( + status_code=403, + detail={"error": "Only proxy admins and organization admins can list users."}, + ) + + allowed_org_ids = [ + m.organization_id + for m in (caller_user.organization_memberships or []) + if m.user_role == LitellmUserRoles.ORG_ADMIN.value + ] + if not allowed_org_ids: + raise HTTPException( + status_code=403, + detail={"error": "Only proxy admins and organization admins can list users."}, + ) + + # If client also sent organization_ids, intersect with allowed orgs + if organization_ids: + requested = set(oid.strip() for oid in organization_ids.split(",") if oid.strip()) + intersection = list(requested & set(allowed_org_ids)) + if not intersection: + raise HTTPException( + status_code=403, + detail={"error": "You do not have org_admin access to the requested organization(s)."}, + ) + allowed_org_ids = intersection + + return ",".join(allowed_org_ids) + + @router.get( "/user/list", tags=["Internal User management"], @@ -1502,6 +1568,11 @@ async def get_users( sort_order: str = fastapi.Query( default="asc", description="Sort order ('asc' or 'desc')" ), + organization_ids: Optional[str] = fastapi.Query( + default=None, + description="Filter users by organization membership. Comma-separated list of org IDs.", + ), + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """ Get a paginated list of users with filtering and sorting options. @@ -1530,7 +1601,11 @@ async def get_users( sort_order: Optional[str] Sort order ('asc' or 'desc') """ - from litellm.proxy.proxy_server import prisma_client + from litellm.proxy.proxy_server import ( + prisma_client, + proxy_logging_obj, + user_api_key_cache, + ) if prisma_client is None: raise HTTPException( @@ -1538,6 +1613,15 @@ async def get_users( detail={"error": f"No db connected. prisma client={prisma_client}"}, ) + # Server-side authorization: proxy admins see all, org admins see only their org(s) + organization_ids = await _authorize_user_list_request( + user_api_key_dict=user_api_key_dict, + organization_ids=organization_ids, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + # Calculate skip and take for pagination skip = (page - 1) * page_size @@ -1576,6 +1660,13 @@ async def get_users( "in": sso_id_list, } + if organization_ids: + org_id_list = [oid.strip() for oid in organization_ids.split(",") if oid.strip()] + if org_id_list: + where_conditions["organization_memberships"] = { + "some": {"organization_id": {"in": org_id_list}} + } + ## Filter any none fastapi.Query params - e.g. where_conditions: {'user_email': {'contains': Query(None), 'mode': 'insensitive'}, 'teams': {'has': Query(None)}} where_conditions = {k: v for k, v in where_conditions.items() if v is not None} @@ -1820,6 +1911,115 @@ 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.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"], @@ -1836,6 +2036,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,19 +2051,15 @@ 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.proxy_server import ( prisma_client, @@ -1871,51 +2071,13 @@ 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." - }, - ) + 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 @@ -1935,10 +2097,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/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 39983cc6e0e..633de86aa6e 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -70,6 +70,7 @@ from litellm.proxy.auth.auth_checks import ( ) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.management_endpoints.common_utils import ( + _is_user_org_admin_for_team, _is_user_team_admin, _set_object_metadata_field, _team_member_has_permission, @@ -1649,6 +1650,9 @@ async def _validate_team_member_add_permissions( and not _is_user_team_admin( user_api_key_dict=user_api_key_dict, team_obj=complete_team_data ) + and not await _is_user_org_admin_for_team( + user_api_key_dict=user_api_key_dict, team_obj=complete_team_data + ) and not _is_available_team( team_id=complete_team_data.team_id, user_api_key_dict=user_api_key_dict, @@ -2121,13 +2125,16 @@ async def team_member_delete( ) existing_team_row = LiteLLM_TeamTable(**_existing_team_row.model_dump()) - ## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN + ## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN OR ORG ADMIN if ( user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value and not _is_user_team_admin( user_api_key_dict=user_api_key_dict, team_obj=existing_team_row ) + and not await _is_user_org_admin_for_team( + user_api_key_dict=user_api_key_dict, team_obj=existing_team_row + ) ): raise HTTPException( status_code=403, @@ -2280,13 +2287,16 @@ async def team_member_update( ) existing_team_row = LiteLLM_TeamTable(**_existing_team_row.model_dump()) - ## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN + ## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN OR ORG ADMIN if ( user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value and not _is_user_team_admin( user_api_key_dict=user_api_key_dict, team_obj=existing_team_row ) + and not await _is_user_org_admin_for_team( + user_api_key_dict=user_api_key_dict, team_obj=existing_team_row + ) ): raise HTTPException( status_code=403, @@ -2760,7 +2770,7 @@ async def _persist_deleted_team_records( prisma_client=prisma_client, ) -def validate_membership( +async def validate_membership( user_api_key_dict: UserAPIKeyAuth, team_table: LiteLLM_TeamTable ): if ( @@ -2795,17 +2805,26 @@ def validate_membership( }, ) - if user_api_key_dict.user_id not in [ + # Check direct team membership + if user_api_key_dict.user_id in [ m.user_id for m in team_table.members_with_roles ]: - raise HTTPException( - status_code=403, - detail={ - "error": "User={} not authorized to access this team={}".format( - user_api_key_dict.user_id, team_table.team_id - ) - }, - ) + return + + # Check if user is an org admin for the team's organization + if await _is_user_org_admin_for_team( + user_api_key_dict=user_api_key_dict, team_obj=team_table + ): + return + + raise HTTPException( + status_code=403, + detail={ + "error": "User={} not authorized to access this team={}".format( + user_api_key_dict.user_id, team_table.team_id + ) + }, + ) def _unfurl_all_proxy_models( @@ -2896,7 +2915,7 @@ async def team_info( status_code=status.HTTP_404_NOT_FOUND, detail={"message": f"Team not found, passed team id: {team_id}."}, ) - validate_membership( + await validate_membership( user_api_key_dict=user_api_key_dict, team_table=LiteLLM_TeamTable(**team_info.model_dump()), ) @@ -3362,6 +3381,101 @@ async def list_team_v2( } +async def _authorize_and_filter_teams( + user_api_key_dict: UserAPIKeyAuth, + user_id: Optional[str], + prisma_client: Any, + user_api_key_cache: Any, + proxy_logging_obj: Any, +) -> list: + """ + Authorize the /team/list request and return filtered teams. + + - Proxy admins: all teams (or filtered by user_id if provided). + - Org admins: teams from their orgs + teams they are direct members of. + - Own query (user_id matches caller): teams the user is a member of. + - Others: 401. + """ + is_proxy_admin = _user_has_admin_view(user_api_key_dict) + allowed_org_ids: Optional[List[str]] = None + + if not is_proxy_admin: + is_own_query = ( + user_id is not None + and user_api_key_dict.user_id is not None + and user_api_key_dict.user_id == user_id + ) + + # Check if user is an org admin (even for own queries, so they see org teams) + if user_api_key_dict.user_id is not None: + 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, + ) + if caller_user is not None: + allowed_org_ids = [ + m.organization_id + for m in (caller_user.organization_memberships or []) + if m.user_role == LitellmUserRoles.ORG_ADMIN.value + ] + if not allowed_org_ids: + allowed_org_ids = None + + if allowed_org_ids is None and not is_own_query: + 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 allowed_org_ids is not None: + # Org admin: query DB for teams in their orgs + org_teams = await prisma_client.db.litellm_teamtable.find_many( + where={"organization_id": {"in": allowed_org_ids}}, + include={"litellm_model_table": True}, + ) + if not user_id: + return list(org_teams) + # Also include teams the user is a direct member of (outside their orgs) + seen_team_ids = {team.team_id for team in org_teams} + all_teams = list(org_teams) + # Prisma doesn't support filtering JSON array fields, so we fetch by membership separately + member_teams = await prisma_client.db.litellm_teamtable.find_many( + where={"team_id": {"not_in": list(seen_team_ids)}} if seen_team_ids else {}, + include={"litellm_model_table": True}, + ) + for team in member_teams: + if team.members_with_roles and any( + m.get("user_id") == user_id for m in team.members_with_roles + ): + all_teams.append(team) + return all_teams + elif user_id: + # Regular user: fetch all and filter by membership (Prisma can't filter JSON arrays) + response = await prisma_client.db.litellm_teamtable.find_many( + include={"litellm_model_table": True} + ) + return [ + team + for team in response + if team.members_with_roles + and any(m.get("user_id") == user_id for m in team.members_with_roles) + ] + else: + # Proxy admin: all teams + return list( + await prisma_client.db.litellm_teamtable.find_many( + include={"litellm_model_table": True} + ) + ) + + @router.get( "/team/list", tags=["team management"], dependencies=[Depends(user_api_key_auth)] ) @@ -3384,19 +3498,11 @@ async def list_team( - user_id: str - Optional. If passed will only return teams that the user_id is a member of. - organization_id: str - Optional. If passed will only return teams that belong to the organization_id. Pass 'default_organization' to get all teams without organization_id. """ - from litellm.proxy.proxy_server import prisma_client - - 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 - ) - }, - ) + from litellm.proxy.proxy_server import ( + prisma_client, + proxy_logging_obj, + user_api_key_cache, + ) if prisma_client is None: raise HTTPException( @@ -3404,27 +3510,14 @@ async def list_team( detail={"error": CommonProxyErrors.db_not_connected_error.value}, ) - response = await prisma_client.db.litellm_teamtable.find_many( - include={ - "litellm_model_table": True, - } + filtered_response = await _authorize_and_filter_teams( + user_api_key_dict=user_api_key_dict, + user_id=user_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, ) - filtered_response = [] - if user_id: - # Get user object to access their teams array - for team in response: - if team.members_with_roles: - for member in team.members_with_roles: - if ( - "user_id" in member - and member["user_id"] is not None - and member["user_id"] == user_id - ): - filtered_response.append(team) - else: - filtered_response = response - _team_ids = [team.team_id for team in filtered_response] returned_tm = await get_all_team_memberships( prisma_client, _team_ids, user_id=user_id @@ -3652,12 +3745,15 @@ async def team_model_add( team_obj = LiteLLM_TeamTable(**team_row.model_dump()) - # Authorization check - only proxy admin or team admin can add models + # Authorization check - only proxy admin, team admin, or org admin can add models if ( user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value and not _is_user_team_admin( user_api_key_dict=user_api_key_dict, team_obj=team_obj ) + and not await _is_user_org_admin_for_team( + user_api_key_dict=user_api_key_dict, team_obj=team_obj + ) ): raise HTTPException( status_code=403, @@ -3720,12 +3816,15 @@ async def team_model_delete( team_obj = LiteLLM_TeamTable(**team_row.model_dump()) - # Authorization check - only proxy admin or team admin can remove models + # Authorization check - only proxy admin, team admin, or org admin can remove models if ( user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value and not _is_user_team_admin( user_api_key_dict=user_api_key_dict, team_obj=team_obj ) + and not await _is_user_org_admin_for_team( + user_api_key_dict=user_api_key_dict, team_obj=team_obj + ) ): raise HTTPException( status_code=403, @@ -3770,7 +3869,7 @@ async def team_member_permissions( if prisma_client is None: raise HTTPException(status_code=500, detail={"error": "No db connected"}) - ## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN + ## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN OR ORG ADMIN existing_team_row = await get_team_object( team_id=team_id, prisma_client=prisma_client, @@ -3789,6 +3888,9 @@ async def team_member_permissions( and not _is_user_team_admin( user_api_key_dict=user_api_key_dict, team_obj=complete_team_data ) + and not await _is_user_org_admin_for_team( + user_api_key_dict=user_api_key_dict, team_obj=complete_team_data + ) and not _is_available_team( team_id=complete_team_data.team_id, user_api_key_dict=user_api_key_dict, @@ -3838,7 +3940,7 @@ async def update_team_member_permissions( if prisma_client is None: raise HTTPException(status_code=500, detail={"error": "No db connected"}) - ## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN + ## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN OR ORG ADMIN existing_team_row = await get_team_object( team_id=data.team_id, prisma_client=prisma_client, @@ -3857,6 +3959,9 @@ async def update_team_member_permissions( and not _is_user_team_admin( user_api_key_dict=user_api_key_dict, team_obj=complete_team_data ) + and not await _is_user_org_admin_for_team( + user_api_key_dict=user_api_key_dict, team_obj=complete_team_data + ) and not _is_available_team( team_id=complete_team_data.team_id, user_api_key_dict=user_api_key_dict, diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 2f7f81a703e..076a2c3bffd 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 @@ -974,6 +980,49 @@ async def get_in_product_nudges(): return InProductNudgeResponse(is_claude_code_enabled=False) +UI_SETTINGS_CACHE_KEY = "ui_settings:settings_dict" +UI_SETTINGS_CACHE_TTL = 600 # 10 minutes + + +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 with TTL + await user_api_key_cache.async_set_cache( + key=UI_SETTINGS_CACHE_KEY, value=ui_settings, ttl=UI_SETTINGS_CACHE_TTL + ) + + return ui_settings + + @router.get( "/get/ui_settings", tags=["UI Settings"], @@ -1018,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}} @@ -1102,6 +1158,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, ttl=UI_SETTINGS_CACHE_TTL + ) + return { "message": "UI settings updated successfully", "status": "success", diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index 44c02f8b167..2f9d27568e3 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -5279,7 +5279,7 @@ async def get_available_models_for_user( user_api_key_cache=user_api_key_cache, proxy_logging_obj=proxy_logging_obj, ) - validate_membership(user_api_key_dict=user_api_key_dict, team_table=team_object) + await validate_membership(user_api_key_dict=user_api_key_dict, team_table=team_object) team_models = team_object.models team_models = get_team_models( diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index f1e96f3e660..c16ee783797 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -1190,3 +1190,105 @@ def test_non_admin_non_team_admin_cannot_access_config_update_but_can_attempt_re request_data={}, ) assert "Only proxy admin can be used to generate" in str(exc_info.value) + + +@pytest.mark.parametrize( + "user_role", + [ + LitellmUserRoles.INTERNAL_USER.value, + LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value, + LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, + ], +) +def test_available_roles_accessible_to_non_admin_users(user_role): + """ + /user/available_roles is read-only role metadata that any authenticated user + (including org admins and team admins) needs when inviting users. It should + pass the route check for all non-proxy-admin roles without requiring an + organization_id in the request body. + """ + user_obj = LiteLLM_UserTable( + user_id="test_user", + user_email="test@example.com", + user_role=user_role, + ) + valid_token = UserAPIKeyAuth( + user_id="test_user", + user_role=user_role, + ) + request = MagicMock(spec=Request) + request.query_params = {} + + # Should not raise — /user/available_roles is in self_managed_routes + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=user_role, + route="/user/available_roles", + request=request, + valid_token=valid_token, + request_data={}, + ) + + +# ── _user_is_org_admin tests ────────────────────────────────────────────────── + +from datetime import datetime + +from litellm.proxy._types import LiteLLM_OrganizationMembershipTable +from litellm.proxy.auth.auth_checks_organization import _user_is_org_admin + + +def _make_org_admin_user(org_id: str) -> LiteLLM_UserTable: + membership = LiteLLM_OrganizationMembershipTable( + user_id="org-admin-user", + organization_id=org_id, + user_role=LitellmUserRoles.ORG_ADMIN.value, + created_at=datetime(2024, 1, 1), + updated_at=datetime(2024, 1, 1), + ) + return LiteLLM_UserTable( + user_id="org-admin-user", + user_role=LitellmUserRoles.INTERNAL_USER.value, + organization_memberships=[membership], + ) + + +def test_user_is_org_admin_with_organizations_list(): + """Org admin can be identified via the `organizations` list field (used by /user/new).""" + user_obj = _make_org_admin_user("org-1") + assert _user_is_org_admin({"organizations": ["org-1"]}, user_obj) is True + + +def test_user_is_org_admin_with_singular_organization_id(): + """Backward-compat: org admin can still be identified via singular `organization_id`.""" + user_obj = _make_org_admin_user("org-1") + assert _user_is_org_admin({"organization_id": "org-1"}, user_obj) is True + + +def test_user_is_org_admin_organizations_list_wrong_org(): + """Non-member of the requested org is not considered an org admin for it.""" + user_obj = _make_org_admin_user("org-2") + assert _user_is_org_admin({"organizations": ["org-1"]}, user_obj) is False + + +def test_user_is_org_admin_no_org_fields(): + """Returns False when neither `organization_id` nor `organizations` is in the request.""" + user_obj = _make_org_admin_user("org-1") + assert _user_is_org_admin({}, user_obj) is False + + +def test_non_org_admin_with_organizations_list(): + """A regular internal user is not an org admin even if they are a member of the org.""" + membership = LiteLLM_OrganizationMembershipTable( + user_id="regular-user", + organization_id="org-1", + user_role=LitellmUserRoles.INTERNAL_USER.value, + created_at=datetime(2024, 1, 1), + updated_at=datetime(2024, 1, 1), + ) + user_obj = LiteLLM_UserTable( + user_id="regular-user", + user_role=LitellmUserRoles.INTERNAL_USER.value, + organization_memberships=[membership], + ) + assert _user_is_org_admin({"organizations": ["org-1"]}, user_obj) is False 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..fa00fe614aa 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 + 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) # 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 + 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( @@ -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,13 @@ 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 + 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()) mocker.patch("litellm.proxy.proxy_server.proxy_logging_obj", mocker.MagicMock()) @@ -143,6 +164,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 +175,18 @@ 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 + 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()) mocker.patch("litellm.proxy.proxy_server.proxy_logging_obj", mocker.MagicMock()) @@ -179,12 +208,227 @@ 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 + 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( + 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_TeamTableCachedObj + + 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 + mocker.patch( + "litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.get_ui_settings_cached", + return_value={"scope_user_search_to_org": True}, + ) + + # 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"}], + ) + + async def mock_get_team_object(*args, **kwargs): + return team_obj + + 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()) + 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 + from litellm.proxy._types import LiteLLM_TeamTableCachedObj + + mock_prisma_client = mocker.MagicMock() + tid = "team-no-org" + + # Flag ON + mocker.patch( + "litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.get_ui_settings_cached", + return_value={"scope_user_search_to_org": True}, + ) + + # 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"}], + ) + + async def mock_get_team_object(*args, **kwargs): + return team_obj + + 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()) + 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 + 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()) + 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(): @@ -256,8 +500,9 @@ async def test_get_users_includes_timestamps(mocker): mock_get_user_key_counts, ) - # Call get_users function directly - response = await get_users(page=1, page_size=1) + # Call get_users function directly with proxy admin auth + admin_key = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN) + response = await get_users(page=1, page_size=1, user_api_key_dict=admin_key, organization_ids=None) print("user /list response: ", response) @@ -1179,8 +1424,10 @@ async def test_get_users_user_id_partial_match(mocker): mock_get_user_key_counts, ) + admin_key = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN) + captured_where_conditions.clear() - await get_users(user_ids="test-user", page=1, page_size=1) + await get_users(user_ids="test-user", page=1, page_size=1, user_api_key_dict=admin_key, organization_ids=None) assert "user_id" in captured_where_conditions assert "contains" in captured_where_conditions["user_id"] @@ -1188,7 +1435,7 @@ async def test_get_users_user_id_partial_match(mocker): assert captured_where_conditions["user_id"]["mode"] == "insensitive" captured_where_conditions.clear() - await get_users(user_ids="user1,user2,user3", page=1, page_size=1) + await get_users(user_ids="user1,user2,user3", page=1, page_size=1, user_api_key_dict=admin_key, organization_ids=None) assert "user_id" in captured_where_conditions assert "in" in captured_where_conditions["user_id"] diff --git a/tests/test_litellm/proxy/management_endpoints/test_org_admin_team_access.py b/tests/test_litellm/proxy/management_endpoints/test_org_admin_team_access.py new file mode 100644 index 00000000000..ac51462cee9 --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/test_org_admin_team_access.py @@ -0,0 +1,282 @@ +""" +Tests for org admin access to team management endpoints. + +Covers: +- _is_user_org_admin_for_team helper +- validate_membership allowing org admins +- _user_is_org_admin route-level check (no privilege escalation) +""" + +import os +import sys +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +sys.path.insert(0, os.path.abspath("../../../")) + +from litellm.proxy._types import ( + LiteLLM_OrganizationMembershipTable, + LiteLLM_TeamTable, + LiteLLM_UserTable, + LitellmUserRoles, + Member, + UserAPIKeyAuth, +) + +_NOW = datetime.now(timezone.utc) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _make_team(team_id="team-1", organization_id="org-1") -> LiteLLM_TeamTable: + return LiteLLM_TeamTable( + team_id=team_id, + team_alias="Test Team", + organization_id=organization_id, + members_with_roles=[ + Member(user_id="direct-member", role="user"), + Member(user_id="team-admin", role="admin"), + ], + ) + + +def _make_user_key( + user_id="org-admin-user", role=LitellmUserRoles.INTERNAL_USER.value +) -> UserAPIKeyAuth: + return UserAPIKeyAuth(user_id=user_id, user_role=role) + + +def _make_membership(user_id, org_id, role="org_admin"): + return LiteLLM_OrganizationMembershipTable( + user_id=user_id, + organization_id=org_id, + user_role=role, + created_at=_NOW, + updated_at=_NOW, + ) + + +def _make_caller_user( + user_id="org-admin-user", org_id="org-1", org_role="org_admin" +) -> LiteLLM_UserTable: + return LiteLLM_UserTable( + user_id=user_id, + organization_memberships=[_make_membership(user_id, org_id, org_role)], + ) + + +def _patch_org_admin_deps(get_user_return): + """Context manager that patches the lazy imports inside _is_user_org_admin_for_team.""" + return ( + patch("litellm.proxy.auth.auth_checks.get_user_object", new_callable=AsyncMock, return_value=get_user_return), + patch("litellm.proxy.proxy_server.prisma_client", MagicMock(), create=True), + patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock(), create=True), + patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock(), create=True), + ) + + +# --------------------------------------------------------------------------- +# _is_user_org_admin_for_team +# --------------------------------------------------------------------------- + + +class TestIsUserOrgAdminForTeam: + """Tests for the reusable _is_user_org_admin_for_team helper.""" + + @pytest.mark.asyncio + async def test_org_admin_for_teams_org_returns_true(self): + from litellm.proxy.management_endpoints.common_utils import ( + _is_user_org_admin_for_team, + ) + + team = _make_team(organization_id="org-1") + key = _make_user_key(user_id="org-admin-user") + caller = _make_caller_user(user_id="org-admin-user", org_id="org-1") + + p1, p2, p3, p4 = _patch_org_admin_deps(caller) + with p1, p2, p3, p4: + result = await _is_user_org_admin_for_team(user_api_key_dict=key, team_obj=team) + assert result is True + + @pytest.mark.asyncio + async def test_org_admin_different_org_returns_false(self): + from litellm.proxy.management_endpoints.common_utils import ( + _is_user_org_admin_for_team, + ) + + team = _make_team(organization_id="org-1") + key = _make_user_key(user_id="other-admin") + caller = _make_caller_user(user_id="other-admin", org_id="org-2") + + p1, p2, p3, p4 = _patch_org_admin_deps(caller) + with p1, p2, p3, p4: + result = await _is_user_org_admin_for_team(user_api_key_dict=key, team_obj=team) + assert result is False + + @pytest.mark.asyncio + async def test_team_without_org_returns_false(self): + from litellm.proxy.management_endpoints.common_utils import ( + _is_user_org_admin_for_team, + ) + + team = _make_team(organization_id=None) + key = _make_user_key() + result = await _is_user_org_admin_for_team(user_api_key_dict=key, team_obj=team) + assert result is False + + @pytest.mark.asyncio + async def test_org_member_not_admin_returns_false(self): + from litellm.proxy.management_endpoints.common_utils import ( + _is_user_org_admin_for_team, + ) + + team = _make_team(organization_id="org-1") + key = _make_user_key(user_id="regular") + caller = _make_caller_user(user_id="regular", org_id="org-1", org_role="user") + + p1, p2, p3, p4 = _patch_org_admin_deps(caller) + with p1, p2, p3, p4: + result = await _is_user_org_admin_for_team(user_api_key_dict=key, team_obj=team) + assert result is False + + @pytest.mark.asyncio + async def test_no_user_id_returns_false(self): + from litellm.proxy.management_endpoints.common_utils import ( + _is_user_org_admin_for_team, + ) + + team = _make_team(organization_id="org-1") + key = _make_user_key(user_id=None) + result = await _is_user_org_admin_for_team(user_api_key_dict=key, team_obj=team) + assert result is False + + +# --------------------------------------------------------------------------- +# validate_membership +# --------------------------------------------------------------------------- + + +class TestValidateMembership: + """Tests for validate_membership with org admin support.""" + + @pytest.mark.asyncio + async def test_proxy_admin_allowed(self): + from litellm.proxy.management_endpoints.team_endpoints import validate_membership + + team = _make_team() + key = _make_user_key(user_id="admin", role=LitellmUserRoles.PROXY_ADMIN.value) + await validate_membership(user_api_key_dict=key, team_table=team) + + @pytest.mark.asyncio + async def test_direct_team_member_allowed(self): + from litellm.proxy.management_endpoints.team_endpoints import validate_membership + + team = _make_team() + key = _make_user_key(user_id="direct-member") + await validate_membership(user_api_key_dict=key, team_table=team) + + @pytest.mark.asyncio + async def test_org_admin_for_team_org_allowed(self): + from litellm.proxy.management_endpoints.team_endpoints import validate_membership + + team = _make_team(organization_id="org-1") + key = _make_user_key(user_id="org-admin-user") + caller = _make_caller_user(user_id="org-admin-user", org_id="org-1") + + p1, p2, p3, p4 = _patch_org_admin_deps(caller) + with p1, p2, p3, p4: + await validate_membership(user_api_key_dict=key, team_table=team) + + @pytest.mark.asyncio + async def test_non_member_non_org_admin_rejected(self): + from fastapi import HTTPException + from litellm.proxy.management_endpoints.team_endpoints import validate_membership + + team = _make_team(organization_id="org-1") + key = _make_user_key(user_id="random-user") + caller = _make_caller_user(user_id="random-user", org_id="org-2", org_role="user") + + p1, p2, p3, p4 = _patch_org_admin_deps(caller) + with p1, p2, p3, p4: + with pytest.raises(HTTPException) as exc_info: + await validate_membership(user_api_key_dict=key, team_table=team) + assert exc_info.value.status_code == 403 + + @pytest.mark.asyncio + async def test_team_key_matches_team_allowed(self): + from litellm.proxy.management_endpoints.team_endpoints import validate_membership + + team = _make_team(team_id="team-1") + key = UserAPIKeyAuth(team_id="team-1", user_role=LitellmUserRoles.INTERNAL_USER.value) + await validate_membership(user_api_key_dict=key, team_table=team) + + +# --------------------------------------------------------------------------- +# _user_is_org_admin (route-level) — no privilege escalation +# --------------------------------------------------------------------------- + + +class TestUserIsOrgAdminRouteCheck: + """ + Verify that _user_is_org_admin does NOT grant blanket access + when no organization_id is in the request body. + """ + + def test_no_candidate_org_ids_returns_false(self): + from litellm.proxy.auth.auth_checks_organization import _user_is_org_admin + + user = LiteLLM_UserTable( + user_id="org-admin-user", + organization_memberships=[_make_membership("org-admin-user", "org-1")], + ) + result = _user_is_org_admin(request_data={}, user_object=user) + assert result is False, "Must NOT grant blanket access when no org in request" + + def test_matching_org_id_returns_true(self): + from litellm.proxy.auth.auth_checks_organization import _user_is_org_admin + + user = LiteLLM_UserTable( + user_id="org-admin-user", + organization_memberships=[_make_membership("org-admin-user", "org-1")], + ) + result = _user_is_org_admin(request_data={"organization_id": "org-1"}, user_object=user) + assert result is True + + def test_non_matching_org_id_returns_false(self): + from litellm.proxy.auth.auth_checks_organization import _user_is_org_admin + + user = LiteLLM_UserTable( + user_id="org-admin-user", + organization_memberships=[_make_membership("org-admin-user", "org-1")], + ) + result = _user_is_org_admin(request_data={"organization_id": "org-99"}, user_object=user) + assert result is False + + def test_organizations_list_field(self): + from litellm.proxy.auth.auth_checks_organization import _user_is_org_admin + + user = LiteLLM_UserTable( + user_id="org-admin-user", + organization_memberships=[_make_membership("org-admin-user", "org-1")], + ) + result = _user_is_org_admin( + request_data={"organizations": ["org-1"]}, user_object=user + ) + assert result is True + + def test_none_user_object_returns_false(self): + from litellm.proxy.auth.auth_checks_organization import _user_is_org_admin + + result = _user_is_org_admin(request_data={}, user_object=None) + assert result is False + + def test_user_list_in_self_managed_routes(self): + """Verify /user/list is in self_managed_routes so org admins can reach it.""" + from litellm.proxy._types import LiteLLMRoutes + + assert "/user/list" in LiteLLMRoutes.self_managed_routes.value diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index b6ac974e2cf..4d949cfbe69 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -1000,6 +1000,7 @@ async def test_validate_team_member_add_permissions_non_admin(): team = MagicMock(spec=LiteLLM_TeamTable) team.team_id = "test-team-123" team.members_with_roles = [] + team.organization_id = None # Mock the helper functions to return False with patch( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx index 88bdf3cdda0..fcad42d3a75 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx @@ -278,6 +278,12 @@ const TeamsView: React.FC = ({ accessToken={accessToken} is_team_admin={is_team_admin(teams?.find((team) => team.team_id === selectedTeamId))} is_proxy_admin={userRole == "Admin"} + is_org_admin={(() => { + const team = teams?.find((t) => t.team_id === selectedTeamId); + if (!team?.organization_id || !organizations || !userID) return false; + const org = organizations.find((o) => o.organization_id === team.organization_id); + return org?.members?.some((m: any) => m.user_id === userID && m.user_role === "org_admin") ?? false; + })()} userModels={userModels} editTeam={editTeam} premiumUser={premiumUser} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/page.tsx index 5ab6920b283..9874dd48865 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/page.tsx @@ -3,13 +3,38 @@ import ViewUserDashboard from "@/components/view_users"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import useTeams from "@/app/(dashboard)/hooks/useTeams"; -import { useState } from "react"; +import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; +import { isProxyAdminRole } from "@/utils/roles"; +import { useState, useMemo } from "react"; +import { Organization } from "@/components/networking"; const UsersPage = () => { const { accessToken, userRole, userId, token } = useAuthorized(); const [keys, setKeys] = useState([]); const { teams } = useTeams(); + const { data: organizations, isLoading: isOrgsLoading } = useOrganizations(); + + // Three states: + // - undefined: org data still loading (non-proxy-admin) — query should wait + // - null: proxy admin or no org filtering needed — query runs unfiltered + // - Array<{organization_id, organization_alias}>: org admin orgs — query runs filtered + const orgAdminOrgIds = useMemo((): Array<{organization_id: string, organization_alias: string}> | null | undefined => { + if (!userId || !userRole) return null; + // Proxy admins see all users — no org filtering + if (isProxyAdminRole(userRole)) return null; + + // Still loading org data — signal "not ready yet" + if (isOrgsLoading || !organizations) return undefined; + + const adminOrgs = organizations + .filter((org: Organization) => + org.members?.some((member) => member.user_id === userId && member.user_role === "org_admin") + ) + .map((org: Organization) => ({ organization_id: org.organization_id, organization_alias: org.organization_alias })); + + return adminOrgs.length > 0 ? adminOrgs : null; + }, [userId, organizations, userRole, isOrgsLoading]); return ( { userID={userId} teams={teams as any} setKeys={setKeys} + orgAdminOrgIds={orgAdminOrgIds} /> ); }; diff --git a/ui/litellm-dashboard/src/components/CreateUserButton.test.tsx b/ui/litellm-dashboard/src/components/CreateUserButton.test.tsx index 829b73734dc..9a4659da9d3 100644 --- a/ui/litellm-dashboard/src/components/CreateUserButton.test.tsx +++ b/ui/litellm-dashboard/src/components/CreateUserButton.test.tsx @@ -10,6 +10,7 @@ vi.mock("./networking", () => ({ userCreateCall: vi.fn(), modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }), invitationCreateCall: vi.fn(), + organizationMemberAddCall: vi.fn(), getProxyUISettings: vi.fn().mockResolvedValue({ PROXY_BASE_URL: null, PROXY_LOGOUT_URL: null, @@ -23,9 +24,14 @@ vi.mock("./bulk_create_users_button", () => ({ default: () =>
Bulk Create Users
, })); +vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ + useOrganizations: vi.fn().mockReturnValue({ data: [], isLoading: false }), +})); + const mockUserCreateCall = vi.mocked(networking.userCreateCall); const mockInvitationCreateCall = vi.mocked(networking.invitationCreateCall); const mockGetProxyUISettings = vi.mocked(networking.getProxyUISettings); +const mockOrganizationMemberAddCall = vi.mocked(networking.organizationMemberAddCall); const mockNotificationsManager = vi.mocked(NotificationsManager); const createQueryClient = () => @@ -261,4 +267,83 @@ describe("CreateUserButton", { timeout: 20000 }, () => { expect(mockNotificationsManager.success).toHaveBeenCalledWith("API user Created"); }); }); + + it("should send organizations list in POST body when organizations are selected", async () => { + const { useOrganizations } = await import("@/app/(dashboard)/hooks/organizations/useOrganizations"); + vi.mocked(useOrganizations).mockReturnValue({ + data: [{ organization_id: "org-1", organization_alias: "My Org" }], + isLoading: false, + } as any); + + const user = userEvent.setup(); + mockUserCreateCall.mockResolvedValue({ data: { user_id: "org-user" } }); + mockInvitationCreateCall.mockResolvedValue({ + id: "inv-org", + user_id: "org-user", + has_user_setup_sso: false, + } as any); + + renderWithProviders( + , + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument(); + }); + await user.click(screen.getByRole("button", { name: /\+ invite user/i })); + + const dialog = screen.getByRole("dialog", { name: /invite user/i }); + await user.type(within(dialog).getByLabelText(/user email/i), "org@example.com"); + await user.click(within(dialog).getByRole("combobox", { name: /global proxy role/i })); + await user.click(screen.getByText("User")); + + // Select org from the dropdown + const orgSelect = within(dialog).getByRole("combobox", { name: /organization/i }); + await user.click(orgSelect); + await user.click(screen.getByText("My Org (org-1)")); + + await user.click(within(dialog).getByRole("button", { name: /invite user/i })); + + await waitFor(() => { + expect(mockUserCreateCall).toHaveBeenCalledWith("token", null, expect.objectContaining({ + organizations: ["org-1"], + })); + }); + }); + + it("should not call organizationMemberAddCall after user creation", async () => { + const { useOrganizations } = await import("@/app/(dashboard)/hooks/organizations/useOrganizations"); + vi.mocked(useOrganizations).mockReturnValue({ + data: [{ organization_id: "org-1", organization_alias: "My Org" }], + isLoading: false, + } as any); + + const user = userEvent.setup(); + mockUserCreateCall.mockResolvedValue({ data: { user_id: "no-member-add-user" } }); + mockInvitationCreateCall.mockResolvedValue({ + id: "inv-nma", + user_id: "no-member-add-user", + has_user_setup_sso: false, + } as any); + + renderWithProviders( + , + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /\+ invite user/i })).toBeInTheDocument(); + }); + await user.click(screen.getByRole("button", { name: /\+ invite user/i })); + + const dialog = screen.getByRole("dialog", { name: /invite user/i }); + await user.type(within(dialog).getByLabelText(/user email/i), "nomemberadd@example.com"); + await user.click(within(dialog).getByRole("combobox", { name: /global proxy role/i })); + await user.click(screen.getByText("User")); + await user.click(within(dialog).getByRole("button", { name: /invite user/i })); + + await waitFor(() => { + expect(mockUserCreateCall).toHaveBeenCalled(); + }); + expect(mockOrganizationMemberAddCall).not.toHaveBeenCalled(); + }); }); diff --git a/ui/litellm-dashboard/src/components/CreateUserButton.tsx b/ui/litellm-dashboard/src/components/CreateUserButton.tsx index d463ced08f6..c7c195835d0 100644 --- a/ui/litellm-dashboard/src/components/CreateUserButton.tsx +++ b/ui/litellm-dashboard/src/components/CreateUserButton.tsx @@ -1,15 +1,9 @@ import { InfoCircleOutlined, UserAddOutlined } from "@ant-design/icons"; import { useQueryClient } from "@tanstack/react-query"; -import { - Accordion, - AccordionBody, - AccordionHeader, - Button as Button2, - SelectItem, - TextInput, -} from "@tremor/react"; +import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; +import { Accordion, AccordionBody, AccordionHeader, Button as Button2, SelectItem, TextInput } from "@tremor/react"; import { Alert, Button, Form, Input, Modal, Select, Select as Select2, Space, Tooltip, Typography } from "antd"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import BulkCreateUsers from "./bulk_create_users_button"; import TeamDropdown from "./common_components/team_dropdown"; import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key"; @@ -55,7 +49,13 @@ interface UISettings { } export const CreateUserButton: React.FC = ({ - userID, accessToken, teams, possibleUIRoles, onUserCreated, isEmbedded = false }) => { + userID, + accessToken, + teams, + possibleUIRoles, + onUserCreated, + isEmbedded = false, +}) => { const queryClient = useQueryClient(); const [uiSettings, setUISettings] = useState(null); const [form] = Form.useForm(); @@ -65,6 +65,15 @@ export const CreateUserButton: React.FC = ({ const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] = useState(false); const [invitationLinkData, setInvitationLinkData] = useState(null); const [baseUrl, setBaseUrl] = useState(null); + const { data: organizations = [] } = useOrganizations(); + + // Derive teams from the user's organizations, falling back to the teams prop + const availableTeams = useMemo(() => { + const orgTeams = organizations.flatMap((org) => org.teams || []); + if (orgTeams.length > 0) return orgTeams; + return teams || []; + }, [organizations, teams]); + useEffect(() => { const fetchData = async () => { try { @@ -98,7 +107,13 @@ export const CreateUserButton: React.FC = ({ form.resetFields(); }; - const handleCreate = async (formValues: { user_id: string; models?: string[]; user_role: string }) => { + const handleCreate = async (formValues: { + user_id: string; + models?: string[]; + user_role: string; + organization_ids?: string[]; + organizations?: string[]; + }) => { try { NotificationsManager.info("Making API Call"); if (!isEmbedded) { @@ -107,6 +122,10 @@ export const CreateUserButton: React.FC = ({ if ((!formValues.models || formValues.models.length === 0) && formValues.user_role !== "proxy_admin") { formValues.models = ["no-default-models"]; } + if (formValues.organization_ids) { + formValues.organizations = formValues.organization_ids; + delete formValues.organization_ids; + } const response = await userCreateCall(accessToken, null, formValues); await queryClient.invalidateQueries({ queryKey: ["userList"] }); setApiuser(true); @@ -161,8 +180,8 @@ export const CreateUserButton: React.FC = ({ message="Email invitations" description={ <> - New users receive an email invite only when an email integration (SMTP, Resend, or SendGrid) is configured. - {" "} + New users receive an email invite only when an email integration (SMTP, Resend, or SendGrid) is + configured.{" "} Learn how to set up email notifications @@ -192,7 +211,7 @@ export const CreateUserButton: React.FC = ({ @@ -228,8 +247,8 @@ export const CreateUserButton: React.FC = ({ message="Email invitations" description={ <> - New users receive an email invite only when an email integration (SMTP, Resend, or SendGrid) is configured. - {" "} + New users receive an email invite only when an email integration (SMTP, Resend, or SendGrid) is + configured.{" "} Learn how to set up email notifications @@ -259,11 +278,10 @@ export const CreateUserButton: React.FC = ({ {possibleUIRoles && Object.entries(possibleUIRoles).map(([role, { ui_label, description }]) => ( - - {ui_label} - + {ui_label} - {" - "}{description} + {" - "} + {description} ))} @@ -276,7 +294,21 @@ export const CreateUserButton: React.FC = ({ name="team_id" help="If selected, user will be added as a 'user' role to the team." > - + + + + + @@ -317,7 +349,9 @@ export const CreateUserButton: React.FC = ({
- +
@@ -331,4 +365,4 @@ export const CreateUserButton: React.FC = ({ )} ); -}; \ No newline at end of file +}; 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/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index fa35a566deb..d01fc06bc05 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -443,8 +443,8 @@ const Sidebar: React.FC = ({ setPage, defaultSelectedKey, collapse children: item.children ? filterItemsByRole(item.children) : undefined, })) .filter((item) => { - // Special handling for organizations menu item - allow org_admins - if (item.key === "organizations") { + // Special handling for organizations and users menu items - allow org_admins + if (item.key === "organizations" || item.key === "users") { const hasRoleAccess = !item.roles || item.roles.includes(userRole) || isOrgAdmin; if (!hasRoleAccess) return false; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 0cd94d9895c..3ec877f03f4 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1109,6 +1109,7 @@ export const userListCall = async ( sso_user_id: string | null = null, sortBy: string | null = null, sortOrder: "asc" | "desc" | null = null, + organizationIds: string[] | null = null, ) => { /** * Get all available teams on proxy @@ -1156,6 +1157,10 @@ export const userListCall = async ( queryParams.append("sort_order", sortOrder); } + if (organizationIds && organizationIds.length > 0) { + queryParams.append("organization_ids", organizationIds.join(",")); + } + const queryString = queryParams.toString(); if (queryString) { url += `?${queryString}`; @@ -2478,14 +2483,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.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx index 1e9f724d751..fb149458f61 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx @@ -34,6 +34,7 @@ vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ useOrganization: vi.fn(), + useOrganizations: vi.fn().mockReturnValue({ data: [], isLoading: false }), })); vi.mock("@/app/(dashboard)/hooks/users/useCurrentUser", () => ({ diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 62208b4186d..d2ce79580da 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -1,4 +1,5 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import UserSearchModal from "@/components/common_components/user_search_modal"; import { getGuardrailsList, @@ -122,6 +123,7 @@ export interface TeamInfoProps { accessToken: string | null; is_team_admin: boolean; is_proxy_admin: boolean; + is_org_admin?: boolean; userModels: string[]; editTeam: boolean; premiumUser?: boolean; @@ -156,6 +158,7 @@ const TeamInfoView: React.FC = ({ accessToken, is_team_admin, is_proxy_admin, + is_org_admin = false, userModels, editTeam, premiumUser = false, @@ -180,9 +183,18 @@ const TeamInfoView: React.FC = ({ const [isDeleting, setIsDeleting] = useState(false); const [isTeamSaving, setIsTeamSaving] = useState(false); const [organization, setOrganization] = useState(null); - const { userRole } = useAuthorized(); + const { userRole, userId } = useAuthorized(); + const { data: userOrganizations = [] } = useOrganizations(); - const canEditTeam = is_team_admin || is_proxy_admin; + // Check if user is org admin for this team's organization + const isOrgAdminForTeam = useMemo(() => { + const teamOrgId = teamData?.team_info?.organization_id; + if (!teamOrgId || !userId) return false; + const org = userOrganizations.find((o) => o.organization_id === teamOrgId); + return org?.members?.some((m: any) => m.user_id === userId && m.user_role === "org_admin") ?? false; + }, [teamData, userOrganizations, userId]); + + const canEditTeam = is_team_admin || is_proxy_admin || is_org_admin || isOrgAdminForTeam; const visibleTabs = useMemo(() => getTeamInfoVisibleTabs(canEditTeam), [canEditTeam]); const defaultTabKey = useMemo( () => getTeamInfoDefaultTab(editTeam, canEditTeam), @@ -1303,6 +1315,7 @@ const TeamInfoView: React.FC = ({ onCancel={() => setIsAddMemberModalVisible(false)} onSubmit={handleMemberCreate} accessToken={accessToken} + teamId={teamId} /> {/* Delete Member Confirmation Modal */} diff --git a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx index c72320d08a7..652d1dbcd91 100644 --- a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx @@ -148,7 +148,7 @@ export default function TeamMemberTab({ roleTooltip="This role applies only to this team and is independent from the user's proxy-level role." extraColumns={extraColumns} showDeleteForMember={() => - isProxyAdmin || (isUserTeamAdmin && !disableTeamAdminDeleteTeamUser) + isProxyAdmin || (canEditTeam && !isUserTeamAdmin) || (isUserTeamAdmin && !disableTeamAdminDeleteTeamUser) } /> ); diff --git a/ui/litellm-dashboard/src/components/view_users.tsx b/ui/litellm-dashboard/src/components/view_users.tsx index 576a1a84be3..f4c821fb01e 100644 --- a/ui/litellm-dashboard/src/components/view_users.tsx +++ b/ui/litellm-dashboard/src/components/view_users.tsx @@ -16,7 +16,7 @@ import { import OnboardingModal, { InvitationLink } from "./onboarding_link"; import { updateExistingKeys } from "@/utils/dataUtils"; -import { isAdminRole } from "@/utils/roles"; +import { isAdminRole, isProxyAdminRole } from "@/utils/roles"; import { useDebouncedState } from "@tanstack/react-pacer/debouncer"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Typography } from "antd"; @@ -39,6 +39,7 @@ interface ViewUserDashboardProps { userID: string | null; teams: any[] | null; setKeys: React.Dispatch>; + orgAdminOrgIds?: Array<{organization_id: string, organization_alias: string}> | null; } interface FilterState { @@ -69,7 +70,8 @@ const initialFilters: FilterState = { sort_order: "desc", }; -const ViewUserDashboard: React.FC = ({ accessToken, token, userRole, userID, teams }) => { +const ViewUserDashboard: React.FC = ({ accessToken, token, userRole, userID, teams, orgAdminOrgIds }) => { + const isProxyAdmin = userRole ? isProxyAdminRole(userRole) : false; const queryClient = useQueryClient(); const [currentPage, setCurrentPage] = useState(1); const [editModalVisible, setEditModalVisible] = useState(false); @@ -245,7 +247,7 @@ const ViewUserDashboard: React.FC = ({ accessToken, toke }; const userListQuery = useQuery({ - queryKey: ["userList", { debouncedFilter: debouncedFilters, currentPage }], + queryKey: ["userList", { debouncedFilter: debouncedFilters, currentPage, orgAdminOrgIds }], queryFn: async () => { if (!accessToken) throw new Error("Access token required"); @@ -260,6 +262,7 @@ const ViewUserDashboard: React.FC = ({ accessToken, toke debouncedFilters.sso_user_id || null, debouncedFilters.sort_by, debouncedFilters.sort_order, + orgAdminOrgIds ? orgAdminOrgIds.map((o) => o.organization_id) : null, ); }, enabled: Boolean(accessToken && token && userRole && userID), @@ -303,15 +306,17 @@ const ViewUserDashboard: React.FC = ({ accessToken, toke <> - + {isProxyAdmin && ( + + )} - {selectionMode && ( + {isProxyAdmin && selectionMode && ( @@ -321,61 +326,93 @@ const ViewUserDashboard: React.FC = ({ accessToken, toke - setActiveTab(index === 0 ? "users" : "settings")}> - - Users - Default User Settings - + {isProxyAdmin ? ( + setActiveTab(index === 0 ? "users" : "settings")}> + + Users + Default User Settings + - - - { - setSelectedUser(user); - setEditModalVisible(true); - }} - handleDelete={handleDelete} - handleResetPassword={handleResetPassword} - enableSelection={selectionMode} - selectedUsers={selectedUsers} - onSelectionChange={handleSelectionChange} - filters={filters} - updateFilters={updateFilters} - initialFilters={initialFilters} - teams={teams} - userListResponse={userListResponse} - currentPage={currentPage} - handlePageChange={handlePageChange} - /> - - - - {!userID || !userRole || !accessToken ? ( -
- -
- ) : ( - + + { + setSelectedUser(user); + setEditModalVisible(true); + }} + handleDelete={handleDelete} + handleResetPassword={handleResetPassword} + enableSelection={selectionMode} + selectedUsers={selectedUsers} + onSelectionChange={handleSelectionChange} + filters={filters} + updateFilters={updateFilters} + initialFilters={initialFilters} + teams={teams} + userListResponse={userListResponse} + currentPage={currentPage} + handlePageChange={handlePageChange} /> - )} - -
-
+ + + + {!userID || !userRole || !accessToken ? ( +
+ +
+ ) : ( + + )} +
+ +
+ ) : ( + { + setSelectedUser(user); + setEditModalVisible(true); + }} + handleDelete={handleDelete} + handleResetPassword={handleResetPassword} + enableSelection={false} + selectedUsers={[]} + onSelectionChange={handleSelectionChange} + filters={filters} + updateFilters={updateFilters} + initialFilters={initialFilters} + teams={teams} + userListResponse={userListResponse} + currentPage={currentPage} + handlePageChange={handlePageChange} + /> + )} {/* Existing Modals */}