mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
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:
commit
5acb25fd9e
25 changed files with 1494 additions and 221 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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", () => ({
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue