Merge pull request #23095 from BerriAI/litellm_org_admin_add_user_e2e

[Feature] Org Admin Access to Team Management Endpoints
This commit is contained in:
yuneng-jiang 2026-03-07 23:29:38 -08:00 committed by GitHub
commit 5acb25fd9e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1494 additions and 221 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

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
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"]

View file

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

View file

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

View file

@ -278,6 +278,12 @@ const TeamsView: React.FC<TeamProps> = ({
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}

View file

@ -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<null | any[]>([]);
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 (
<ViewUserDashboard
@ -20,6 +45,7 @@ const UsersPage = () => {
userID={userId}
teams={teams as any}
setKeys={setKeys}
orgAdminOrgIds={orgAdminOrgIds}
/>
);
};

View file

@ -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: () => <div data-testid="bulk-create-users">Bulk Create Users</div>,
}));
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(
<CreateUserButton {...defaultProps} possibleUIRoles={{ proxy_user: { ui_label: "User", description: "" } }} />,
);
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(
<CreateUserButton {...defaultProps} possibleUIRoles={{ proxy_user: { ui_label: "User", description: "" } }} />,
);
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();
});
});

View file

@ -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<CreateuserProps> = ({
userID, accessToken, teams, possibleUIRoles, onUserCreated, isEmbedded = false }) => {
userID,
accessToken,
teams,
possibleUIRoles,
onUserCreated,
isEmbedded = false,
}) => {
const queryClient = useQueryClient();
const [uiSettings, setUISettings] = useState<UISettings | null>(null);
const [form] = Form.useForm();
@ -65,6 +65,15 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] = useState(false);
const [invitationLinkData, setInvitationLinkData] = useState<InvitationLink | null>(null);
const [baseUrl, setBaseUrl] = useState<string | null>(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<CreateuserProps> = ({
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<CreateuserProps> = ({
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<CreateuserProps> = ({
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.{" "}
<Link href="https://docs.litellm.ai/docs/proxy/email" target="_blank">
Learn how to set up email notifications
</Link>
@ -192,7 +211,7 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
</Form.Item>
<Form.Item label="Team" name="team_id">
<Select placeholder="Select Team" style={{ width: "100%" }}>
<TeamDropdown teams={teams} />
<TeamDropdown teams={availableTeams} />
</Select>
</Form.Item>
@ -228,8 +247,8 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
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.{" "}
<Link href="https://docs.litellm.ai/docs/proxy/email" target="_blank">
Learn how to set up email notifications
</Link>
@ -259,11 +278,10 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
{possibleUIRoles &&
Object.entries(possibleUIRoles).map(([role, { ui_label, description }]) => (
<SelectItem key={role} value={role} title={ui_label}>
<Text>
{ui_label}
</Text>
<Text>{ui_label}</Text>
<Text type="secondary">
{" - "}{description}
{" - "}
{description}
</Text>
</SelectItem>
))}
@ -276,7 +294,21 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
name="team_id"
help="If selected, user will be added as a 'user' role to the team."
>
<TeamDropdown teams={teams} />
<TeamDropdown teams={availableTeams} />
</Form.Item>
<Form.Item
label="Organization"
name="organization_ids"
help="The user will be added to the selected organization(s)."
>
<Select mode="multiple" placeholder="Select Organization" style={{ width: "100%" }}>
{organizations.map((org) => (
<Option key={org.organization_id} value={org.organization_id}>
{org.organization_alias} ({org.organization_id})
</Option>
))}
</Select>
</Form.Item>
<Form.Item label="Metadata" name="metadata">
@ -317,7 +349,9 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
</AccordionBody>
</Accordion>
<div style={{ textAlign: "right", marginTop: "10px" }}>
<Button type="primary" icon={<UserAddOutlined />} htmlType="submit">Invite User</Button>
<Button type="primary" icon={<UserAddOutlined />} htmlType="submit">
Invite User
</Button>
</div>
</Form>
</Modal>
@ -331,4 +365,4 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
)}
</div>
);
};
};

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

@ -11,7 +11,7 @@ import {
Badge,
Text,
} from "@tremor/react";
import { Modal, Alert, Tooltip, Skeleton } from "antd";
import { Modal, Alert, Tooltip, Skeleton, Switch } from "antd";
import { CheckCircleOutlined } from "@ant-design/icons";
import { getAgentsList, deleteAgentCall, keyListCall } from "./networking";
import AddAgentForm from "./agents/add_agent_form";

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

@ -443,8 +443,8 @@ const Sidebar: React.FC<SidebarProps> = ({ 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;

View file

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

View file

@ -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", () => ({

View file

@ -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<TeamInfoProps> = ({
accessToken,
is_team_admin,
is_proxy_admin,
is_org_admin = false,
userModels,
editTeam,
premiumUser = false,
@ -180,9 +183,18 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
const [isDeleting, setIsDeleting] = useState(false);
const [isTeamSaving, setIsTeamSaving] = useState(false);
const [organization, setOrganization] = useState<Organization | null>(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<TeamInfoProps> = ({
onCancel={() => setIsAddMemberModalVisible(false)}
onSubmit={handleMemberCreate}
accessToken={accessToken}
teamId={teamId}
/>
{/* Delete Member Confirmation Modal */}

View file

@ -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)
}
/>
);

View file

@ -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<React.SetStateAction<object[] | null>>;
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<ViewUserDashboardProps> = ({ accessToken, token, userRole, userID, teams }) => {
const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({ 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<ViewUserDashboardProps> = ({ 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<ViewUserDashboardProps> = ({ 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<ViewUserDashboardProps> = ({ accessToken, toke
<>
<CreateUserButton userID={userID} accessToken={accessToken} teams={teams} possibleUIRoles={possibleUIRoles} />
<Button
onClick={handleToggleSelectionMode}
variant={selectionMode ? "primary" : "secondary"}
className="flex items-center"
>
{selectionMode ? "Cancel Selection" : "Select Users"}
</Button>
{isProxyAdmin && (
<Button
onClick={handleToggleSelectionMode}
variant={selectionMode ? "primary" : "secondary"}
className="flex items-center"
>
{selectionMode ? "Cancel Selection" : "Select Users"}
</Button>
)}
{selectionMode && (
{isProxyAdmin && selectionMode && (
<Button onClick={handleBulkEdit} disabled={selectedUsers.length === 0} className="flex items-center">
Bulk Edit ({selectedUsers.length} selected)
</Button>
@ -321,61 +326,93 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({ accessToken, toke
</div>
</div>
<TabGroup defaultIndex={0} onIndexChange={(index) => setActiveTab(index === 0 ? "users" : "settings")}>
<TabList className="mb-4">
<Tab>Users</Tab>
<Tab>Default User Settings</Tab>
</TabList>
{isProxyAdmin ? (
<TabGroup defaultIndex={0} onIndexChange={(index) => setActiveTab(index === 0 ? "users" : "settings")}>
<TabList className="mb-4">
<Tab>Users</Tab>
<Tab>Default User Settings</Tab>
</TabList>
<TabPanels>
<TabPanel>
<UserDataTable
data={userListQuery.data?.users || []}
columns={tableColumns}
isLoading={userListQuery.isLoading}
accessToken={accessToken}
userRole={userRole}
onSortChange={handleSortChange}
currentSort={{
sortBy: filters.sort_by,
sortOrder: filters.sort_order,
}}
possibleUIRoles={possibleUIRoles}
handleEdit={(user) => {
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}
/>
</TabPanel>
<TabPanel>
{!userID || !userRole || !accessToken ? (
<div className="flex justify-center items-center h-64">
<Skeleton active paragraph={{ rows: 4 }} />
</div>
) : (
<DefaultUserSettings
<TabPanels>
<TabPanel>
<UserDataTable
data={userListQuery.data?.users || []}
columns={tableColumns}
isLoading={userListQuery.isLoading}
accessToken={accessToken}
possibleUIRoles={possibleUIRoles}
userID={userID}
userRole={userRole}
onSortChange={handleSortChange}
currentSort={{
sortBy: filters.sort_by,
sortOrder: filters.sort_order,
}}
possibleUIRoles={possibleUIRoles}
handleEdit={(user) => {
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}
/>
)}
</TabPanel>
</TabPanels>
</TabGroup>
</TabPanel>
<TabPanel>
{!userID || !userRole || !accessToken ? (
<div className="flex justify-center items-center h-64">
<Skeleton active paragraph={{ rows: 4 }} />
</div>
) : (
<DefaultUserSettings
accessToken={accessToken}
possibleUIRoles={possibleUIRoles}
userID={userID}
userRole={userRole}
/>
)}
</TabPanel>
</TabPanels>
</TabGroup>
) : (
<UserDataTable
data={userListQuery.data?.users || []}
columns={tableColumns}
isLoading={userListQuery.isLoading}
accessToken={accessToken}
userRole={userRole}
onSortChange={handleSortChange}
currentSort={{
sortBy: filters.sort_by,
sortOrder: filters.sort_order,
}}
possibleUIRoles={possibleUIRoles}
handleEdit={(user) => {
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 */}
<EditUserModal