From 91bfbe6efedf09f099417f1c8ba5c1e452e77e0f Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Thu, 16 Apr 2026 01:45:57 +0000 Subject: [PATCH 1/7] fix(proxy): enforce organization boundaries in admin operations Validate org admin role against all requested organizations instead of returning on first match. Scope team list queries to the caller's permitted organizations when filtering by user_id. --- .../proxy/auth/auth_checks_organization.py | 16 +- .../management_endpoints/team_endpoints.py | 93 +-- .../test_team_endpoints.py | 701 ++++++++++-------- 3 files changed, 428 insertions(+), 382 deletions(-) diff --git a/litellm/proxy/auth/auth_checks_organization.py b/litellm/proxy/auth/auth_checks_organization.py index 50efe137209..d89afcffa9a 100644 --- a/litellm/proxy/auth/auth_checks_organization.py +++ b/litellm/proxy/auth/auth_checks_organization.py @@ -144,7 +144,7 @@ def _user_is_org_admin( user_object: Optional[LiteLLM_UserTable] = None, ) -> bool: """ - Helper function to check if user is an org admin for any of the passed organizations. + Helper function to check if user is an org admin for all of the passed organizations. Checks both: - `organization_id` (singular string) — legacy callers @@ -168,9 +168,13 @@ def _user_is_org_admin( if not candidate_org_ids: return False - for _membership in user_object.organization_memberships: - if _membership.organization_id in candidate_org_ids: - if _membership.user_role == LitellmUserRoles.ORG_ADMIN.value: - return True + # Build set of orgs where user is admin + admin_org_ids = { + _membership.organization_id + for _membership in user_object.organization_memberships + if _membership.user_role == LitellmUserRoles.ORG_ADMIN.value + and _membership.organization_id is not None + } - return False + # User must be admin of ALL requested orgs, not just any one + return all(org_id in admin_org_ids for org_id in candidate_org_ids) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 138469312e1..edd92cb83de 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -120,6 +120,7 @@ def _sanitize_for_log(value: Any) -> str: text = repr(value) return text.replace("\r", "").replace("\n", "") + async def _verify_team_access( team_obj: LiteLLM_TeamTable, user_api_key_dict: UserAPIKeyAuth, @@ -314,10 +315,8 @@ class TeamMemberBudgetHandler: return # Batch-fetch existing memberships for this team (avoids N+1 queries) - existing_memberships = ( - await prisma_client.db.litellm_teammembership.find_many( - where={"team_id": team_id} - ) + existing_memberships = await prisma_client.db.litellm_teammembership.find_many( + where={"team_id": team_id} ) existing_user_ids = {m.user_id for m in existing_memberships} @@ -1659,12 +1658,12 @@ async def update_team( # noqa: PLR0915 updated_kv["router_settings"] = safe_dumps(updated_kv["router_settings"]) updated_kv = prisma_client.jsonify_team_object(db_data=updated_kv) - team_row: Optional[ - LiteLLM_TeamTable - ] = await prisma_client.db.litellm_teamtable.update( - where={"team_id": data.team_id}, - data=updated_kv, - include={"litellm_model_table": True}, # type: ignore + team_row: Optional[LiteLLM_TeamTable] = ( + await prisma_client.db.litellm_teamtable.update( + where={"team_id": data.team_id}, + data=updated_kv, + include={"litellm_model_table": True}, # type: ignore + ) ) if team_row is None or team_row.team_id is None: @@ -2411,13 +2410,13 @@ async def team_member_delete( ) # Fetch keys before deletion to persist them - keys_to_delete: List[ - LiteLLM_VerificationToken - ] = await prisma_client.db.litellm_verificationtoken.find_many( - where={ - "user_id": {"in": list(user_ids_to_delete)}, - "team_id": data.team_id, - } + keys_to_delete: List[LiteLLM_VerificationToken] = ( + await prisma_client.db.litellm_verificationtoken.find_many( + where={ + "user_id": {"in": list(user_ids_to_delete)}, + "team_id": data.team_id, + } + ) ) if keys_to_delete: @@ -2801,10 +2800,10 @@ async def delete_team( team_rows: List[LiteLLM_TeamTable] = [] for team_id in data.team_ids: try: - team_row_base: Optional[ - BaseModel - ] = await prisma_client.db.litellm_teamtable.find_unique( - where={"team_id": team_id} + team_row_base: Optional[BaseModel] = ( + await prisma_client.db.litellm_teamtable.find_unique( + where={"team_id": team_id} + ) ) if team_row_base is None: raise Exception @@ -2870,10 +2869,10 @@ async def delete_team( _persist_deleted_verification_tokens, ) - keys_to_delete: List[ - LiteLLM_VerificationToken - ] = await prisma_client.db.litellm_verificationtoken.find_many( - where={"team_id": {"in": data.team_ids}} + keys_to_delete: List[LiteLLM_VerificationToken] = ( + await prisma_client.db.litellm_verificationtoken.find_many( + where={"team_id": {"in": data.team_ids}} + ) ) if keys_to_delete: @@ -3110,11 +3109,11 @@ async def team_info( ) try: - team_info: Optional[ - BaseModel - ] = await prisma_client.db.litellm_teamtable.find_unique( - where={"team_id": team_id}, - include={"object_permission": True}, + team_info: Optional[BaseModel] = ( + await prisma_client.db.litellm_teamtable.find_unique( + where={"team_id": team_id}, + include={"object_permission": True}, + ) ) if team_info is None: raise Exception @@ -3405,6 +3404,7 @@ async def _get_org_admin_org_ids( m.organization_id for m in (caller_user.organization_memberships or []) if m.user_role == LitellmUserRoles.ORG_ADMIN.value + and m.organization_id is not None ] return org_ids if org_ids else None @@ -3439,13 +3439,8 @@ async def _build_team_list_where_conditions( if organization_id: where_conditions["organization_id"] = organization_id - elif org_admin_org_ids is not None and not user_id: - # Org admin without explicit org or user filter: scope to their orgs. - # NOTE: when user_id is provided, no org filter is applied — the - # query returns all teams the target user belongs to across all - # organisations. This matches the legacy /team/list behaviour in - # _authorize_and_filter_teams which fetches direct-membership teams - # without an org constraint. + elif org_admin_org_ids is not None: + # Org admin: always scope to their orgs, even when filtering by user_id. where_conditions["organization_id"] = {"in": org_admin_org_ids} if user_id: @@ -3815,7 +3810,7 @@ async def _authorize_and_filter_teams( 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. + - Org admins: teams from their orgs (scoped to user_id if provided). - Own query (user_id matches caller): teams the user is a member of. - Others: 401. """ @@ -3843,6 +3838,7 @@ async def _authorize_and_filter_teams( m.organization_id for m in (caller_user.organization_memberships or []) if m.user_role == LitellmUserRoles.ORG_ADMIN.value + and m.organization_id is not None ] if not allowed_org_ids: allowed_org_ids = None @@ -3865,20 +3861,13 @@ async def _authorize_and_filter_teams( ) 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 + # Filter org teams to only those where the target user is a member + return [ + team + for team in org_teams + if team.members_with_roles + and any(m.get("user_id") == user_id for m in team.members_with_roles) + ] 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( diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index bee6642dec7..9b4bd790493 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -1010,12 +1010,15 @@ async def test_validate_team_member_add_permissions_non_admin(): team.organization_id = None # Mock the helper functions to return False - with patch( - "litellm.proxy.management_endpoints.team_endpoints._is_user_team_admin", - return_value=False, - ), patch( - "litellm.proxy.management_endpoints.team_endpoints._is_available_team", - return_value=False, + with ( + patch( + "litellm.proxy.management_endpoints.team_endpoints._is_user_team_admin", + return_value=False, + ), + patch( + "litellm.proxy.management_endpoints.team_endpoints._is_available_team", + return_value=False, + ), ): # Should raise HTTPException for non-admin with pytest.raises(HTTPException) as exc_info: @@ -1257,19 +1260,17 @@ async def test_update_team_team_member_budget_not_passed_to_db(): user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test_user_id" ) - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, patch( - "litellm.proxy.proxy_server.llm_router" - ) as mock_llm_router, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ) as mock_cache, patch( - "litellm.proxy.proxy_server.proxy_logging_obj" - ) as mock_logging, patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" - ), patch( - "litellm.proxy.auth.auth_checks._cache_team_object" - ) as mock_cache_team, patch( - "litellm.proxy.management_endpoints.team_endpoints.TeamMemberBudgetHandler.upsert_team_member_budget_table" - ) as mock_upsert_budget: + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, + patch("litellm.proxy.proxy_server.llm_router") as mock_llm_router, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server.proxy_logging_obj") as mock_logging, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch("litellm.proxy.auth.auth_checks._cache_team_object") as mock_cache_team, + patch( + "litellm.proxy.management_endpoints.team_endpoints.TeamMemberBudgetHandler.upsert_team_member_budget_table" + ) as mock_upsert_budget, + ): # Setup mock prisma client mock_existing_team = MagicMock() mock_existing_team.model_dump.return_value = { @@ -1690,19 +1691,17 @@ async def test_update_team_with_team_member_budget_duration(): user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test_user_id" ) - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, patch( - "litellm.proxy.proxy_server.llm_router" - ) as mock_llm_router, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ) as mock_cache, patch( - "litellm.proxy.proxy_server.proxy_logging_obj" - ) as mock_logging, patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" - ), patch( - "litellm.proxy.auth.auth_checks._cache_team_object" - ) as mock_cache_team, patch( - "litellm.proxy.management_endpoints.team_endpoints.TeamMemberBudgetHandler.upsert_team_member_budget_table" - ) as mock_upsert_budget: + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, + patch("litellm.proxy.proxy_server.llm_router") as mock_llm_router, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server.proxy_logging_obj") as mock_logging, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch("litellm.proxy.auth.auth_checks._cache_team_object") as mock_cache_team, + patch( + "litellm.proxy.management_endpoints.team_endpoints.TeamMemberBudgetHandler.upsert_team_member_budget_table" + ) as mock_upsert_budget, + ): mock_existing_team = MagicMock() mock_existing_team.model_dump.return_value = { "team_id": "test_team_id", @@ -1777,7 +1776,9 @@ async def test_backfill_team_member_budget_entries_creates_missing_memberships() from unittest.mock import AsyncMock, MagicMock from litellm.proxy._types import Member - from litellm.proxy.management_endpoints.team_endpoints import TeamMemberBudgetHandler + from litellm.proxy.management_endpoints.team_endpoints import ( + TeamMemberBudgetHandler, + ) team_id = "team-abc" budget_id = "budget-xyz" @@ -1847,7 +1848,9 @@ async def test_backfill_team_member_budget_entries_no_op_when_all_exist(): from unittest.mock import AsyncMock, MagicMock from litellm.proxy._types import Member - from litellm.proxy.management_endpoints.team_endpoints import TeamMemberBudgetHandler + from litellm.proxy.management_endpoints.team_endpoints import ( + TeamMemberBudgetHandler, + ) team_id = "team-abc" budget_id = "budget-xyz" @@ -1886,7 +1889,9 @@ async def test_backfill_team_member_budget_entries_empty_members(): """ from unittest.mock import AsyncMock, MagicMock - from litellm.proxy.management_endpoints.team_endpoints import TeamMemberBudgetHandler + from litellm.proxy.management_endpoints.team_endpoints import ( + TeamMemberBudgetHandler, + ) mock_prisma = MagicMock() mock_prisma.db.litellm_teammembership.find_many = AsyncMock(return_value=[]) @@ -2092,11 +2097,14 @@ async def test_bulk_team_member_add_all_users_flag(): updated_team_memberships=[], ) - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( - "litellm.proxy.management_endpoints.team_endpoints.team_member_add", - new_callable=AsyncMock, - return_value=mock_team_response, - ) as mock_team_member_add: + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch( + "litellm.proxy.management_endpoints.team_endpoints.team_member_add", + new_callable=AsyncMock, + return_value=mock_team_response, + ) as mock_team_member_add, + ): # Mock the database find_many call mock_prisma.db.litellm_usertable.find_many = AsyncMock( return_value=mock_db_users @@ -2213,12 +2221,15 @@ async def test_list_team_v2_security_check_non_admin_user(): user_id="non_admin_user_123", ) - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ), patch("litellm.proxy.proxy_server.proxy_logging_obj"), patch( - "litellm.proxy.management_endpoints.team_endpoints.get_user_object", - new_callable=AsyncMock, - return_value=None, + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, + patch("litellm.proxy.proxy_server.user_api_key_cache"), + patch("litellm.proxy.proxy_server.proxy_logging_obj"), + patch( + "litellm.proxy.management_endpoints.team_endpoints.get_user_object", + new_callable=AsyncMock, + return_value=None, + ), ): mock_prisma_client.return_value = MagicMock() # Mock non-None prisma client @@ -2260,12 +2271,15 @@ async def test_list_team_v2_security_check_non_admin_user_other_user(): user_id="non_admin_user_123", ) - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ), patch("litellm.proxy.proxy_server.proxy_logging_obj"), patch( - "litellm.proxy.management_endpoints.team_endpoints.get_user_object", - new_callable=AsyncMock, - return_value=None, + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, + patch("litellm.proxy.proxy_server.user_api_key_cache"), + patch("litellm.proxy.proxy_server.proxy_logging_obj"), + patch( + "litellm.proxy.management_endpoints.team_endpoints.get_user_object", + new_callable=AsyncMock, + return_value=None, + ), ): mock_prisma_client.return_value = MagicMock() # Mock non-None prisma client @@ -2305,9 +2319,11 @@ async def test_list_team_v2_security_check_non_admin_user_own_teams(): user_id="non_admin_user_123", ) - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ), patch("litellm.proxy.proxy_server.proxy_logging_obj"): + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, + patch("litellm.proxy.proxy_server.user_api_key_cache"), + patch("litellm.proxy.proxy_server.proxy_logging_obj"), + ): # Mock prisma client and database operations mock_db = Mock() mock_prisma_client.db = mock_db @@ -2509,12 +2525,15 @@ async def test_list_team_v2_org_admin_sees_org_teams(): ], ) - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ), patch("litellm.proxy.proxy_server.proxy_logging_obj"), patch( - "litellm.proxy.management_endpoints.team_endpoints.get_user_object", - new_callable=AsyncMock, - return_value=mock_user, + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache"), + patch("litellm.proxy.proxy_server.proxy_logging_obj"), + patch( + "litellm.proxy.management_endpoints.team_endpoints.get_user_object", + new_callable=AsyncMock, + return_value=mock_user, + ), ): mock_db = Mock() mock_prisma.db = mock_db @@ -2592,12 +2611,15 @@ async def test_list_team_v2_org_admin_cannot_view_other_orgs(): ], ) - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ), patch("litellm.proxy.proxy_server.proxy_logging_obj"), patch( - "litellm.proxy.management_endpoints.team_endpoints.get_user_object", - new_callable=AsyncMock, - return_value=mock_user, + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache"), + patch("litellm.proxy.proxy_server.proxy_logging_obj"), + patch( + "litellm.proxy.management_endpoints.team_endpoints.get_user_object", + new_callable=AsyncMock, + return_value=mock_user, + ), ): mock_prisma.db = Mock() @@ -2680,11 +2702,14 @@ async def test_list_team_v2_org_admin_with_user_id_returns_user_teams(): return mock_org_admin return mock_target_user - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ), patch("litellm.proxy.proxy_server.proxy_logging_obj"), patch( - "litellm.proxy.management_endpoints.team_endpoints.get_user_object", - side_effect=mock_get_user_object, + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache"), + patch("litellm.proxy.proxy_server.proxy_logging_obj"), + patch( + "litellm.proxy.management_endpoints.team_endpoints.get_user_object", + side_effect=mock_get_user_object, + ), ): mock_db = Mock() mock_prisma.db = mock_db @@ -2714,10 +2739,10 @@ async def test_list_team_v2_org_admin_with_user_id_returns_user_teams(): assert result["total"] == 1 - # Verify the where clause filters by user's teams, not org scope + # Verify the where clause filters by user's teams AND org scope where = mock_db.litellm_teamtable.find_many.call_args.kwargs["where"] assert where["team_id"] == {"in": ["team_X", "team_Y"]} - assert "organization_id" not in where + assert where["organization_id"] == {"in": ["org_A"]} @pytest.mark.asyncio @@ -2913,15 +2938,15 @@ async def test_new_team_max_budget_exceeds_user_max_budget(): dummy_request = MagicMock(spec=Request) - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( - "litellm.proxy.proxy_server._license_check" - ) as mock_license, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ) as mock_cache, patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" - ), patch( - "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() - ) as mock_audit: + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server._license_check") as mock_license, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() + ) as mock_audit, + ): # Setup basic mocks mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0) mock_license.is_team_count_over_limit.return_value = False @@ -2982,15 +3007,15 @@ async def test_new_team_max_budget_within_user_limit(): dummy_request = MagicMock(spec=Request) - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ) as mock_cache, patch( - "litellm.proxy.proxy_server._license_check" - ) as mock_license, patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" - ), patch( - "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() - ) as mock_audit: + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server._license_check") as mock_license, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() + ) as mock_audit, + ): # Setup mocks mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0) mock_license.is_team_count_over_limit.return_value = False @@ -3111,17 +3136,18 @@ async def test_new_team_org_scoped_budget_bypasses_user_limit(): dummy_request = MagicMock(spec=Request) - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ) as mock_cache, patch( - "litellm.proxy.proxy_server._license_check" - ) as mock_license, patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" - ), patch( - "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() - ) as mock_audit, patch( - "litellm.proxy.management_endpoints.team_endpoints.get_org_object" - ) as mock_get_org: + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server._license_check") as mock_license, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() + ) as mock_audit, + patch( + "litellm.proxy.management_endpoints.team_endpoints.get_org_object" + ) as mock_get_org, + ): # Setup mocks mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0) mock_license.is_team_count_over_limit.return_value = False @@ -3253,17 +3279,18 @@ async def test_new_team_org_scoped_models_bypasses_user_limit(): dummy_request = MagicMock(spec=Request) - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ) as mock_cache, patch( - "litellm.proxy.proxy_server._license_check" - ) as mock_license, patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" - ), patch( - "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() - ) as mock_audit, patch( - "litellm.proxy.management_endpoints.team_endpoints.get_org_object" - ) as mock_get_org: + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server._license_check") as mock_license, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() + ) as mock_audit, + patch( + "litellm.proxy.management_endpoints.team_endpoints.get_org_object" + ) as mock_get_org, + ): # Setup mocks mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0) mock_license.is_team_count_over_limit.return_value = False @@ -3393,13 +3420,14 @@ async def test_new_team_standalone_validates_against_user_models(monkeypatch): dummy_request = MagicMock(spec=Request) - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( - "litellm.proxy.proxy_server._license_check" - ) as mock_license, patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" - ), patch( - "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() - ) as mock_audit: + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server._license_check") as mock_license, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() + ) as mock_audit, + ): # Setup basic mocks mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0) mock_license.is_team_count_over_limit.return_value = False @@ -3460,15 +3488,15 @@ async def test_new_team_standalone_validates_against_user_budget(): dummy_request = MagicMock(spec=Request) - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( - "litellm.proxy.proxy_server._license_check" - ) as mock_license, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ) as mock_cache, patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" - ), patch( - "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() - ) as mock_audit: + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server._license_check") as mock_license, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() + ) as mock_audit, + ): # Setup basic mocks mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0) mock_license.is_team_count_over_limit.return_value = False @@ -3534,17 +3562,18 @@ async def test_new_team_org_scoped_budget_exceeds_org_limit(): dummy_request = MagicMock(spec=Request) - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ) as mock_cache, patch( - "litellm.proxy.proxy_server._license_check" - ) as mock_license, patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" - ), patch( - "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() - ) as mock_audit, patch( - "litellm.proxy.management_endpoints.team_endpoints.get_org_object" - ) as mock_get_org: + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server._license_check") as mock_license, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() + ) as mock_audit, + patch( + "litellm.proxy.management_endpoints.team_endpoints.get_org_object" + ) as mock_get_org, + ): # Setup mocks mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0) mock_license.is_team_count_over_limit.return_value = False @@ -3613,17 +3642,18 @@ async def test_new_team_org_scoped_models_not_in_org_models(): dummy_request = MagicMock(spec=Request) - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ) as mock_cache, patch( - "litellm.proxy.proxy_server._license_check" - ) as mock_license, patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" - ), patch( - "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() - ) as mock_audit, patch( - "litellm.proxy.management_endpoints.team_endpoints.get_org_object" - ) as mock_get_org: + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server._license_check") as mock_license, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() + ) as mock_audit, + patch( + "litellm.proxy.management_endpoints.team_endpoints.get_org_object" + ) as mock_get_org, + ): # Setup mocks mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0) mock_license.is_team_count_over_limit.return_value = False @@ -3688,13 +3718,14 @@ async def test_update_team_standalone_budget_exceeds_user_limit(): dummy_request = MagicMock(spec=Request) - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ) as mock_cache, patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" - ), patch( - "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() - ) as mock_audit: + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() + ) as mock_audit, + ): # Mock existing standalone team (no organization_id) mock_existing_team = MagicMock() mock_existing_team.team_id = "standalone-team-123" @@ -3778,16 +3809,18 @@ async def test_update_team_org_scoped_budget_exceeds_org_limit(): mock_org.models = ["gpt-4"] mock_org.litellm_budget_table = mock_budget_table - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ) as mock_cache, patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" - ), patch( - "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() - ) as mock_audit, patch( - "litellm.proxy.management_endpoints.team_endpoints.get_org_object", - new=AsyncMock(return_value=mock_org), - ) as mock_get_org: + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() + ) as mock_audit, + patch( + "litellm.proxy.management_endpoints.team_endpoints.get_org_object", + new=AsyncMock(return_value=mock_org), + ) as mock_get_org, + ): # Mock existing org-scoped team mock_existing_team = MagicMock() mock_existing_team.team_id = "org-team-456" @@ -3852,13 +3885,14 @@ async def test_update_team_standalone_models_exceeds_user_limit(): dummy_request = MagicMock(spec=Request) - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ) as mock_cache, patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" - ), patch( - "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() - ) as mock_audit: + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() + ) as mock_audit, + ): # Mock existing standalone team (no organization_id) mock_existing_team = MagicMock() mock_existing_team.team_id = "standalone-team-models-123" @@ -3936,16 +3970,18 @@ async def test_update_team_org_scoped_budget_bypasses_user_limit(): mock_org.models = ["gpt-4", "gpt-3.5-turbo"] mock_org.litellm_budget_table = mock_budget_table - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ) as mock_cache, patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" - ), patch( - "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() - ) as mock_audit, patch( - "litellm.proxy.management_endpoints.team_endpoints.get_org_object", - new=AsyncMock(return_value=mock_org), - ) as mock_get_org: + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() + ) as mock_audit, + patch( + "litellm.proxy.management_endpoints.team_endpoints.get_org_object", + new=AsyncMock(return_value=mock_org), + ) as mock_get_org, + ): # Mock existing org-scoped team mock_existing_team = MagicMock() mock_existing_team.team_id = "org-team-update-budget-123" @@ -4044,16 +4080,18 @@ async def test_update_team_org_scoped_models_bypasses_user_limit(): mock_org.models = ["gpt-4", "gpt-3.5-turbo", "claude-3-opus"] mock_org.litellm_budget_table = None - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ) as mock_cache, patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" - ), patch( - "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() - ) as mock_audit, patch( - "litellm.proxy.management_endpoints.team_endpoints.get_org_object", - new=AsyncMock(return_value=mock_org), - ) as mock_get_org: + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() + ) as mock_audit, + patch( + "litellm.proxy.management_endpoints.team_endpoints.get_org_object", + new=AsyncMock(return_value=mock_org), + ) as mock_get_org, + ): # Mock existing org-scoped team mock_existing_team = MagicMock() mock_existing_team.team_id = "org-team-update-models-123" @@ -4145,16 +4183,18 @@ async def test_update_team_org_scoped_models_not_in_org_models(): mock_org.models = ["gpt-4", "gpt-3.5-turbo"] # claude-3-opus is NOT allowed mock_org.litellm_budget_table = None - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ) as mock_cache, patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" - ), patch( - "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() - ) as mock_audit, patch( - "litellm.proxy.management_endpoints.team_endpoints.get_org_object", - new=AsyncMock(return_value=mock_org), - ) as mock_get_org: + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() + ) as mock_audit, + patch( + "litellm.proxy.management_endpoints.team_endpoints.get_org_object", + new=AsyncMock(return_value=mock_org), + ) as mock_get_org, + ): # Mock existing org-scoped team mock_existing_team = MagicMock() mock_existing_team.team_id = "org-team-update-models-fail-123" @@ -4231,16 +4271,18 @@ async def test_update_team_org_scoped_models_with_all_proxy_models(): mock_org.models = [SpecialModelNames.all_proxy_models.value] # Allows all models mock_org.litellm_budget_table = None - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ) as mock_cache, patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" - ), patch( - "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() - ) as mock_audit, patch( - "litellm.proxy.management_endpoints.team_endpoints.get_org_object", - new=AsyncMock(return_value=mock_org), - ) as mock_get_org: + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() + ) as mock_audit, + patch( + "litellm.proxy.management_endpoints.team_endpoints.get_org_object", + new=AsyncMock(return_value=mock_org), + ) as mock_get_org, + ): # Mock existing org-scoped team mock_existing_team = MagicMock() mock_existing_team.team_id = "org-team-all-proxy-models-123" @@ -4333,10 +4375,10 @@ async def test_update_team_tpm_limit_exceeds_user_limit(): dummy_request = MagicMock(spec=Request) - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ) as mock_cache, patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), ): # Mock existing standalone team mock_existing_team = MagicMock() @@ -4397,10 +4439,10 @@ async def test_update_team_rpm_limit_exceeds_user_limit(): dummy_request = MagicMock(spec=Request) - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ) as mock_cache, patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), ): # Mock existing standalone team mock_existing_team = MagicMock() @@ -4479,15 +4521,15 @@ async def test_new_team_org_scoped_tpm_exceeds_org_limit(): mock_org.models = ["gpt-4"] mock_org.litellm_budget_table = mock_budget_table - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ) as mock_cache, patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" - ), patch( - "litellm.proxy.proxy_server._license_check" - ) as mock_license, patch( - "litellm.proxy.management_endpoints.team_endpoints.get_org_object", - new=AsyncMock(return_value=mock_org), + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch("litellm.proxy.proxy_server._license_check") as mock_license, + patch( + "litellm.proxy.management_endpoints.team_endpoints.get_org_object", + new=AsyncMock(return_value=mock_org), + ), ): mock_license.is_team_count_over_limit.return_value = False mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0) @@ -4555,15 +4597,15 @@ async def test_new_team_org_scoped_rpm_exceeds_org_limit(): mock_org.models = ["gpt-4"] mock_org.litellm_budget_table = mock_budget_table - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ) as mock_cache, patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" - ), patch( - "litellm.proxy.proxy_server._license_check" - ) as mock_license, patch( - "litellm.proxy.management_endpoints.team_endpoints.get_org_object", - new=AsyncMock(return_value=mock_org), + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch("litellm.proxy.proxy_server._license_check") as mock_license, + patch( + "litellm.proxy.management_endpoints.team_endpoints.get_org_object", + new=AsyncMock(return_value=mock_org), + ), ): mock_license.is_team_count_over_limit.return_value = False mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0) @@ -4634,20 +4676,22 @@ async def test_new_team_org_scoped_tpm_rpm_bypasses_user_limit(): mock_org.models = ["gpt-4"] mock_org.litellm_budget_table = mock_budget_table - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ) as mock_cache, patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" - ), patch( - "litellm.proxy.proxy_server._license_check" - ) as mock_license, patch( - "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() - ), patch( - "litellm.proxy.management_endpoints.team_endpoints.get_org_object", - new=AsyncMock(return_value=mock_org), - ), patch( - "litellm.proxy.management_endpoints.team_endpoints._add_team_members_to_team", - new=AsyncMock(), + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch("litellm.proxy.proxy_server._license_check") as mock_license, + patch( + "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() + ), + patch( + "litellm.proxy.management_endpoints.team_endpoints.get_org_object", + new=AsyncMock(return_value=mock_org), + ), + patch( + "litellm.proxy.management_endpoints.team_endpoints._add_team_members_to_team", + new=AsyncMock(), + ), ): mock_license.is_team_count_over_limit.return_value = False mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0) @@ -4736,13 +4780,14 @@ async def test_update_team_org_scoped_tpm_exceeds_org_limit(): mock_org.models = ["gpt-4"] mock_org.litellm_budget_table = mock_budget_table - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ) as mock_cache, patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" - ), patch( - "litellm.proxy.management_endpoints.team_endpoints.get_org_object", - new=AsyncMock(return_value=mock_org), + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.management_endpoints.team_endpoints.get_org_object", + new=AsyncMock(return_value=mock_org), + ), ): # Mock existing org-scoped team mock_existing_team = MagicMock() @@ -4822,13 +4867,14 @@ async def test_update_team_org_scoped_rpm_exceeds_org_limit(): mock_org.models = ["gpt-4"] mock_org.litellm_budget_table = mock_budget_table - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ) as mock_cache, patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" - ), patch( - "litellm.proxy.management_endpoints.team_endpoints.get_org_object", - new=AsyncMock(return_value=mock_org), + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.management_endpoints.team_endpoints.get_org_object", + new=AsyncMock(return_value=mock_org), + ), ): # Mock existing org-scoped team mock_existing_team = MagicMock() @@ -4911,15 +4957,15 @@ async def test_update_team_org_scoped_tpm_rpm_bypasses_user_limit(): mock_org.models = ["gpt-4"] mock_org.litellm_budget_table = mock_budget_table - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ) as mock_cache, patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" - ), patch( - "litellm.proxy.proxy_server.proxy_logging_obj" - ) as mock_logging, patch( - "litellm.proxy.management_endpoints.team_endpoints.get_org_object", - new=AsyncMock(return_value=mock_org), + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch("litellm.proxy.proxy_server.proxy_logging_obj") as mock_logging, + patch( + "litellm.proxy.management_endpoints.team_endpoints.get_org_object", + new=AsyncMock(return_value=mock_org), + ), ): # Mock existing org-scoped team mock_existing_team = MagicMock() @@ -5036,17 +5082,18 @@ async def test_update_team_guardrails_with_org_id(): "teams": [], } - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ) as mock_cache, patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" - ), patch( - "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() - ), patch( - "litellm.proxy.proxy_server.premium_user", - True, # Required for guardrails feature - ), patch( - "litellm.proxy.proxy_server.llm_router", MagicMock() + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() + ), + patch( + "litellm.proxy.proxy_server.premium_user", + True, # Required for guardrails feature + ), + patch("litellm.proxy.proxy_server.llm_router", MagicMock()), ): # Mock existing team - must have compatible models with organization mock_existing_team = MagicMock() @@ -5601,15 +5648,15 @@ async def test_new_team_soft_budget_validation( dummy_request = MagicMock(spec=Request) - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ) as mock_cache, patch( - "litellm.proxy.proxy_server._license_check" - ) as mock_license, patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" - ), patch( - "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() - ) as mock_audit: + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server._license_check") as mock_license, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() + ) as mock_audit, + ): # Setup mocks mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0) mock_license.is_team_count_over_limit.return_value = False @@ -5799,13 +5846,14 @@ async def test_update_team_soft_budget_validation( dummy_request = MagicMock(spec=Request) - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, patch( - "litellm.proxy.proxy_server.user_api_key_cache" - ) as mock_cache, patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" - ), patch( - "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() - ) as mock_audit: + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch("litellm.proxy.proxy_server.user_api_key_cache") as mock_cache, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock() + ) as mock_audit, + ): # Mock existing team with existing budgets mock_existing_team = MagicMock() mock_existing_team.team_id = "test-team-123" @@ -6794,14 +6842,18 @@ async def test_list_team_v1_batches_key_queries(): key3 = MagicMock() key3.team_id = "team-2" - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, patch( - "litellm.proxy.management_endpoints.team_endpoints._authorize_and_filter_teams", - new_callable=AsyncMock, - return_value=[team1, team2], - ), patch( - "litellm.proxy.management_endpoints.team_endpoints.get_all_team_memberships", - new_callable=AsyncMock, - return_value=[], + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, + patch( + "litellm.proxy.management_endpoints.team_endpoints._authorize_and_filter_teams", + new_callable=AsyncMock, + return_value=[team1, team2], + ), + patch( + "litellm.proxy.management_endpoints.team_endpoints.get_all_team_memberships", + new_callable=AsyncMock, + return_value=[], + ), ): async def filtered_find_many(**kwargs): @@ -7070,16 +7122,17 @@ async def test_update_team_rejects_unauthorized_caller(): from litellm.proxy._types import UpdateTeamRequest - with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, patch( - "litellm.proxy.proxy_server.llm_router" - ), patch("litellm.proxy.proxy_server.user_api_key_cache"), patch( - "litellm.proxy.proxy_server.proxy_logging_obj" - ), patch( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin" - ), patch( - "litellm.proxy.management_endpoints.team_endpoints._is_user_org_admin_for_team", - new_callable=AsyncMock, - return_value=False, + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, + patch("litellm.proxy.proxy_server.llm_router"), + patch("litellm.proxy.proxy_server.user_api_key_cache"), + patch("litellm.proxy.proxy_server.proxy_logging_obj"), + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch( + "litellm.proxy.management_endpoints.team_endpoints._is_user_org_admin_for_team", + new_callable=AsyncMock, + return_value=False, + ), ): mock_existing_team = MagicMock() mock_existing_team.model_dump.return_value = { From 815a2bed1af796a7639a9e72bbb88c9e288da5e5 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Thu, 16 Apr 2026 02:07:20 +0000 Subject: [PATCH 2/7] test: add regression tests for cross-org admin escalation Verify that an org admin of org-A cannot operate on org-B, and that an admin of both orgs can operate on both. --- .../proxy/auth/test_route_checks.py | 142 +++++++++++++----- 1 file changed, 106 insertions(+), 36 deletions(-) diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index f1344a302d7..bca6b9e78d9 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -1058,8 +1058,15 @@ class TestModelsRouteExemptFromDisableLLMEndpoints: local_file = os.path.join( os.path.dirname(__file__), - "..", "..", "..", "..", "enterprise", - "litellm_enterprise", "proxy", "auth", "route_checks.py", + "..", + "..", + "..", + "..", + "enterprise", + "litellm_enterprise", + "proxy", + "auth", + "route_checks.py", ) local_file = os.path.abspath(local_file) @@ -1075,10 +1082,15 @@ class TestModelsRouteExemptFromDisableLLMEndpoints: """Test that /models is allowed even when LLM API routes are disabled""" EnterpriseRouteChecks = self._get_enterprise_route_checks() - with patch.object( - EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True - ), patch.object( - EnterpriseRouteChecks, "is_management_routes_disabled", return_value=False + with ( + patch.object( + EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True + ), + patch.object( + EnterpriseRouteChecks, + "is_management_routes_disabled", + return_value=False, + ), ): # /models should NOT raise - it's exempt EnterpriseRouteChecks.should_call_route("/models") @@ -1088,10 +1100,15 @@ class TestModelsRouteExemptFromDisableLLMEndpoints: """Test that /v1/models is allowed even when LLM API routes are disabled""" EnterpriseRouteChecks = self._get_enterprise_route_checks() - with patch.object( - EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True - ), patch.object( - EnterpriseRouteChecks, "is_management_routes_disabled", return_value=False + with ( + patch.object( + EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True + ), + patch.object( + EnterpriseRouteChecks, + "is_management_routes_disabled", + return_value=False, + ), ): # /v1/models should NOT raise - it's exempt EnterpriseRouteChecks.should_call_route("/v1/models") @@ -1101,10 +1118,15 @@ class TestModelsRouteExemptFromDisableLLMEndpoints: """Test that non-exempt LLM routes like /v1/chat/completions are still blocked""" EnterpriseRouteChecks = self._get_enterprise_route_checks() - with patch.object( - EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True - ), patch.object( - EnterpriseRouteChecks, "is_management_routes_disabled", return_value=False + with ( + patch.object( + EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True + ), + patch.object( + EnterpriseRouteChecks, + "is_management_routes_disabled", + return_value=False, + ), ): with pytest.raises(HTTPException) as exc_info: EnterpriseRouteChecks.should_call_route("/v1/chat/completions") @@ -1119,10 +1141,15 @@ class TestModelsRouteExemptFromDisableLLMEndpoints: """Test that /v1/embeddings is still blocked when LLM API routes are disabled""" EnterpriseRouteChecks = self._get_enterprise_route_checks() - with patch.object( - EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True - ), patch.object( - EnterpriseRouteChecks, "is_management_routes_disabled", return_value=False + with ( + patch.object( + EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True + ), + patch.object( + EnterpriseRouteChecks, + "is_management_routes_disabled", + return_value=False, + ), ): with pytest.raises(HTTPException) as exc_info: EnterpriseRouteChecks.should_call_route("/v1/embeddings") @@ -1134,10 +1161,15 @@ class TestModelsRouteExemptFromDisableLLMEndpoints: """Test that /models works normally when LLM API routes are not disabled""" EnterpriseRouteChecks = self._get_enterprise_route_checks() - with patch.object( - EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=False - ), patch.object( - EnterpriseRouteChecks, "is_management_routes_disabled", return_value=False + with ( + patch.object( + EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=False + ), + patch.object( + EnterpriseRouteChecks, + "is_management_routes_disabled", + return_value=False, + ), ): # Should not raise EnterpriseRouteChecks.should_call_route("/models") @@ -1359,6 +1391,38 @@ def test_non_org_admin_with_organizations_list(): assert _user_is_org_admin({"organizations": ["org-1"]}, user_obj) is False +def test_org_admin_cannot_escalate_to_other_org(): + """Regression: admin of org-A requesting [org-A, org-B] must be rejected.""" + user_obj = _make_org_admin_user("org-A") + assert _user_is_org_admin({"organizations": ["org-A", "org-B"]}, user_obj) is False + + +def test_org_admin_of_multiple_orgs_can_operate_on_both(): + """Admin of both org-A and org-B can operate on both.""" + memberships = [ + LiteLLM_OrganizationMembershipTable( + user_id="multi-admin", + organization_id="org-A", + user_role=LitellmUserRoles.ORG_ADMIN.value, + created_at=datetime(2024, 1, 1), + updated_at=datetime(2024, 1, 1), + ), + LiteLLM_OrganizationMembershipTable( + user_id="multi-admin", + organization_id="org-B", + user_role=LitellmUserRoles.ORG_ADMIN.value, + created_at=datetime(2024, 1, 1), + updated_at=datetime(2024, 1, 1), + ), + ] + user_obj = LiteLLM_UserTable( + user_id="multi-admin", + user_role=LitellmUserRoles.INTERNAL_USER.value, + organization_memberships=memberships, + ) + assert _user_is_org_admin({"organizations": ["org-A", "org-B"]}, user_obj) is True + + @pytest.mark.asyncio async def test_initialize_pass_through_registers_wildcard_for_auth_subpath(): """ @@ -1389,15 +1453,19 @@ async def test_initialize_pass_through_registers_wildcard_for_auth_subpath(): original_routes = LiteLLMRoutes.openai_routes.value[:] try: - with patch( - "litellm.proxy.proxy_server.app", - MagicMock(), - ), patch( - "litellm.proxy.proxy_server.premium_user", - True, - ), patch( - "litellm.proxy.proxy_server.config_passthrough_endpoints", - None, + with ( + patch( + "litellm.proxy.proxy_server.app", + MagicMock(), + ), + patch( + "litellm.proxy.proxy_server.premium_user", + True, + ), + patch( + "litellm.proxy.proxy_server.config_passthrough_endpoints", + None, + ), ): await initialize_pass_through_endpoints([endpoint_config]) @@ -1417,7 +1485,9 @@ async def test_initialize_pass_through_registers_wildcard_for_auth_subpath(): # Removing the endpoint should clean up openai_routes # remove_endpoint_routes takes endpoint_id (UUID portion of # the route key "{id}:exact:{path}:{methods}") - registered = InitPassThroughEndpointHelpers.get_all_registered_pass_through_routes() + registered = ( + InitPassThroughEndpointHelpers.get_all_registered_pass_through_routes() + ) endpoint_ids = {k.split(":")[0] for k in registered} for eid in endpoint_ids: InitPassThroughEndpointHelpers.remove_endpoint_routes(eid) @@ -1427,8 +1497,8 @@ async def test_initialize_pass_through_registers_wildcard_for_auth_subpath(): LiteLLMRoutes.openai_routes.value[:] = original_routes # Clean up any routes registered during this test to avoid # polluting the module-level _registered_pass_through_routes - registered = InitPassThroughEndpointHelpers.get_all_registered_pass_through_routes() + registered = ( + InitPassThroughEndpointHelpers.get_all_registered_pass_through_routes() + ) for k in registered: - InitPassThroughEndpointHelpers.remove_endpoint_routes( - k.split(":")[0] - ) + InitPassThroughEndpointHelpers.remove_endpoint_routes(k.split(":")[0]) From 467166fdd7c26b89ea73cf418e419881e25fa1fe Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Fri, 17 Apr 2026 00:11:00 +0000 Subject: [PATCH 3/7] fix(proxy): enforce per-target org authorization on /user/delete Veria admin-queue finding E3NpkuAd, Audit-B #1. The route-level gate accepts this call when the caller is PROXY_ADMIN or ORG_ADMIN of any org named in request_data["organization_id"]/["organizations"]. The handler processes data.user_ids without cross-checking whether those users belong to the caller's administered orgs, so an org-admin of org-A could delete users in org-B via: {"user_ids": ["victim_in_org_B"], "organization_id": "org-A"} Add per-target authorization: org-admins may only delete users whose entire org membership is within their admin scope; targets with any org outside scope (or no org at all) require PROXY_ADMIN. Regression test confirms an ORG_ADMIN call fails with 403 and no cascade delete_many runs. --- .../internal_user_endpoints.py | 98 +++++++++++++++---- .../test_internal_user_endpoints.py | 71 ++++++++++++++ 2 files changed, 149 insertions(+), 20 deletions(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 1772da3d15e..be0f02439bc 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -2057,6 +2057,38 @@ async def delete_user( if data.user_ids is None: raise HTTPException(status_code=400, detail={"error": "No user id passed in"}) + # Per-target authorization: the route-level gate accepts this call when + # the caller is PROXY_ADMIN or an ORG_ADMIN of *any* org named in + # request_data["organization_id"]/["organizations"]. That gate does NOT + # cross-check data.user_ids against the caller's scope, so without this + # loop an org-admin of org-A could delete users in org-B by supplying + # {"user_ids": [victim_in_org_B], "organization_id": "org-A"}. + caller_is_proxy_admin = ( + user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value + ) + caller_admin_org_ids: set = set() + if not caller_is_proxy_admin: + caller_memberships = ( + await prisma_client.db.litellm_organizationmembership.find_many( + where={ + "user_id": user_api_key_dict.user_id, + "user_role": LitellmUserRoles.ORG_ADMIN.value, + } + ) + if user_api_key_dict.user_id + else [] + ) + caller_admin_org_ids = { + m.organization_id for m in caller_memberships if m.organization_id + } + if not caller_admin_org_ids: + raise HTTPException( + status_code=403, + detail={ + "error": "Only PROXY_ADMIN or ORG_ADMIN users may delete users." + }, + ) + # check that all teams passed exist for user_id in data.user_ids: user_row = await prisma_client.db.litellm_usertable.find_unique( @@ -2068,30 +2100,56 @@ async def delete_user( status_code=404, detail={"error": f"User not found, passed user_id={user_id}"}, ) - else: - # Enterprise Feature - Audit Logging. Enable with litellm.store_audit_logs = True - # we do this after the first for loop, since first for loop is for validation. we only want this inserted after validation passes - if litellm.store_audit_logs is True: - # make an audit log for each team deleted - _user_row = user_row.json(exclude_none=True) - asyncio.create_task( - create_audit_log_for_update( - request_data=LiteLLM_AuditLogs( - id=str(uuid.uuid4()), - updated_at=datetime.now(timezone.utc), - changed_by=litellm_changed_by - or user_api_key_dict.user_id - or litellm_proxy_admin_name, - changed_by_api_key=user_api_key_dict.api_key, - table_name=LitellmTableNames.USER_TABLE_NAME, - object_id=user_id, - action="deleted", - updated_values="{}", - before_value=_user_row, + if not caller_is_proxy_admin: + target_memberships = ( + await prisma_client.db.litellm_organizationmembership.find_many( + where={"user_id": user_id} + ) + ) + target_org_ids = { + m.organization_id for m in target_memberships if m.organization_id + } + # Org-admin may only delete users whose entire org membership is + # within their admin scope. A target with ANY org outside the + # caller's scope (or no org at all) requires PROXY_ADMIN. + if not target_org_ids or not target_org_ids.issubset( + caller_admin_org_ids + ): + raise HTTPException( + status_code=403, + detail={ + "error": ( + f"User {user_id} is not within your admin scope. " + "Only PROXY_ADMIN may delete users outside your " + "administered organizations." ) + }, + ) + + # Enterprise Feature - Audit Logging. Enable with litellm.store_audit_logs = True + # we do this after the first for loop, since first for loop is for validation. we only want this inserted after validation passes + if litellm.store_audit_logs is True: + # make an audit log for each team deleted + _user_row = user_row.json(exclude_none=True) + + asyncio.create_task( + create_audit_log_for_update( + request_data=LiteLLM_AuditLogs( + id=str(uuid.uuid4()), + updated_at=datetime.now(timezone.utc), + changed_by=litellm_changed_by + or user_api_key_dict.user_id + or litellm_proxy_admin_name, + changed_by_api_key=user_api_key_dict.api_key, + table_name=LitellmTableNames.USER_TABLE_NAME, + object_id=user_id, + action="deleted", + updated_values="{}", + before_value=_user_row, ) ) + ) ## CLEANUP MEMBERS_WITH_ROLES fetch_all_teams = await prisma_client.db.litellm_teamtable.find_many( diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index 0f90d236aed..104071c3e61 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -1878,6 +1878,77 @@ async def test_delete_user_cleans_up_created_by_invitation_links(mocker): assert condition[field] == {"in": ["admin-creator"]} +@pytest.mark.asyncio +async def test_delete_user_rejects_org_admin_deleting_outside_scope(mocker): + """Regression: an org admin of org-A must not be able to delete a user + whose org memberships include org-B. + + Route-level gate accepts the request when the caller supplies an + `organization_id` they administer; without per-user org authorization + the handler would cascade-delete the victim's keys, memberships, and + user row regardless of where the victim actually belongs. + """ + from fastapi import HTTPException + + from litellm.proxy._types import DeleteUserRequest, UserAPIKeyAuth + from litellm.proxy.management_endpoints.internal_user_endpoints import delete_user + + mock_prisma_client = mocker.MagicMock() + + # Target user exists and is a member of org-B only. + mock_target_user = mocker.MagicMock() + mock_target_user.user_id = "victim" + mock_target_user.user_email = "victim@example.com" + mock_target_user.teams = [] + mock_target_user.json.return_value = "{}" + + async def mock_find_unique(*args, **kwargs): + return mock_target_user + + mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock( + side_effect=mock_find_unique + ) + + # Caller (org_admin_user) administers org-A. + caller_membership = mocker.MagicMock() + caller_membership.organization_id = "org-A" + + # Target user is a member of org-B (outside caller's scope). + target_membership = mocker.MagicMock() + target_membership.organization_id = "org-B" + + async def mock_find_memberships(*args, **kwargs): + where = kwargs.get("where") or (args[0] if args else {}) + user_id = where.get("user_id") + if user_id == "org_admin_user": + return [caller_membership] + if user_id == "victim": + return [target_membership] + return [] + + mock_prisma_client.db.litellm_organizationmembership.find_many = mocker.AsyncMock( + side_effect=mock_find_memberships + ) + + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + + data = DeleteUserRequest(user_ids=["victim"]) + user_api_key_dict = UserAPIKeyAuth( + user_id="org_admin_user", user_role=LitellmUserRoles.ORG_ADMIN + ) + + with pytest.raises(HTTPException) as exc: + await delete_user(data=data, user_api_key_dict=user_api_key_dict) + assert exc.value.status_code == 403 + + # Critical: no delete_many calls should have executed. + assert not hasattr( + mock_prisma_client.db.litellm_verificationtoken.delete_many, "mock_calls" + ) or len( + mock_prisma_client.db.litellm_verificationtoken.delete_many.mock_calls + ) == 0 + + # ===================================================================== # /v2/user/info endpoint tests # ===================================================================== From 8c0668f105c00772d785b7981973709edc718c05 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Fri, 17 Apr 2026 00:17:57 +0000 Subject: [PATCH 4/7] perf: batch target membership lookup in delete_user to avoid N+1 Greptile P1 on the /user/delete fix. Per CLAUDE.md 'No N+1 queries', move the find_many inside the per-user loop to a single batched fetch with {'user_id': {'in': data.user_ids}} before the loop, then distribute to a per-user set in memory. --- .../internal_user_endpoints.py | 25 +++++++++++++------ .../test_internal_user_endpoints.py | 15 ++++++++--- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index be0f02439bc..09cd7fc5f6d 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -2089,6 +2089,22 @@ async def delete_user( }, ) + # Batch-fetch target memberships once before the per-user loop. Avoids + # an N+1 DB call when delete_user is called with a large user_ids list. + target_org_ids_by_user: Dict[str, set] = {} + if not caller_is_proxy_admin: + all_target_memberships = ( + await prisma_client.db.litellm_organizationmembership.find_many( + where={"user_id": {"in": data.user_ids}} + ) + ) + for m in all_target_memberships: + if not m.organization_id: + continue + target_org_ids_by_user.setdefault(m.user_id, set()).add( + m.organization_id + ) + # check that all teams passed exist for user_id in data.user_ids: user_row = await prisma_client.db.litellm_usertable.find_unique( @@ -2102,14 +2118,7 @@ async def delete_user( ) if not caller_is_proxy_admin: - target_memberships = ( - await prisma_client.db.litellm_organizationmembership.find_many( - where={"user_id": user_id} - ) - ) - target_org_ids = { - m.organization_id for m in target_memberships if m.organization_id - } + target_org_ids = target_org_ids_by_user.get(user_id, set()) # Org-admin may only delete users whose entire org membership is # within their admin scope. A target with ANY org outside the # caller's scope (or no org at all) requires PROXY_ADMIN. diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index 104071c3e61..a074a4a211f 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -1919,11 +1919,18 @@ async def test_delete_user_rejects_org_admin_deleting_outside_scope(mocker): async def mock_find_memberships(*args, **kwargs): where = kwargs.get("where") or (args[0] if args else {}) - user_id = where.get("user_id") - if user_id == "org_admin_user": + user_id_filter = where.get("user_id") + # Batched lookup: {"user_id": {"in": [...]}} returns target memberships. + # Caller role lookup: {"user_id": "", "user_role": ...}. + if isinstance(user_id_filter, dict) and "in" in user_id_filter: + if "victim" in user_id_filter["in"]: + # Attach user_id on the mock so the caller can build its + # per-user dict from the batch result. + target_membership.user_id = "victim" + return [target_membership] + return [] + if user_id_filter == "org_admin_user": return [caller_membership] - if user_id == "victim": - return [target_membership] return [] mock_prisma_client.db.litellm_organizationmembership.find_many = mocker.AsyncMock( From 662d05531d61d8d9e58fcdd1d25080da5d6119fa Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Fri, 17 Apr 2026 00:36:28 +0000 Subject: [PATCH 5/7] fix(proxy): close three more org-boundary escape paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continuation of Veria E3NpkuAd / Audit-B hardening. All three are the same anti-pattern PR #25904 already addressed for _user_is_org_admin and /user/delete: route-level gate trusts a caller-supplied scope field, handler operates on a different scope. 1. /user/update no longer silently creates a user when the target email doesn't exist. Pre-fix, an org admin could supply a fresh email + caller-chosen budget/models/metadata; the INSERT path created the user with no org attachment, bypassing /user/new's org/team authorization. Now require PROXY_ADMIN for the create branch; return 404 otherwise. Also fixes /user/bulk_update because it dispatches through the same _update_single_user_helper. 2. /team/bulk_member_add with all_users=true restricted to PROXY_ADMIN. The flag pulls every user in the database into the target team, ignoring org scope — any team admin could use it to capture every user across every org into their team. 3. /team/update now verifies destination-org admin rights. When the request carries an organization_id that differs from the team's current org, an org admin of the caller's current org could previously relocate the team into any other org (draining their resources, or capturing a team they once administered). Require PROXY_ADMIN or org-admin of the DESTINATION org for the relocation. Regression tests for #1 and #3; #2 covered by the existing bulk_add suite after the gate addition. --- .../internal_user_endpoints.py | 17 +++++++ .../management_endpoints/team_endpoints.py | 50 +++++++++++++++++++ .../test_internal_user_endpoints.py | 37 ++++++++++++++ .../test_team_endpoints.py | 10 ++++ 4 files changed, 114 insertions(+) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 09cd7fc5f6d..e343429ff12 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -1180,6 +1180,23 @@ async def _update_single_user_helper( "error": "User does not have permission to update this user. Only PROXY_ADMIN can update other users." }, ) + else: + # Silent-create guard: if the target user doesn't exist, the update + # path falls through to an upsert that creates a new user with + # caller-supplied fields (models, metadata, budgets, …). Only + # PROXY_ADMIN is allowed to create users this way; otherwise an org + # admin could spawn arbitrary users attached to nothing by supplying + # a fresh email, bypassing the /user/new org/team-scoping checks. + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value: + raise HTTPException( + status_code=404, + detail={ + "error": ( + "User not found. Only PROXY_ADMIN can create users " + "via /user/update; use /user/new instead." + ) + }, + ) existing_metadata = ( cast(Dict, getattr(existing_user_row, "metadata", {}) or {}) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index edd92cb83de..0bb6c3d90f3 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -1544,6 +1544,42 @@ async def update_team( # noqa: PLR0915 if ( data.organization_id is not None and len(data.organization_id) > 0 ): # allow unsetting the organization_id + # If the caller is relocating the team to a different org, they + # must also be PROXY_ADMIN or an org-admin of the DESTINATION org. + # _verify_team_access above only checked the team's CURRENT org, + # so without this gate an org-admin could hand their team to any + # other org (or capture a team from another org they once + # administered into a new destination). + current_org_id = getattr(existing_team_row, "organization_id", None) + if ( + data.organization_id != current_org_id + and user_api_key_dict.user_role + != LitellmUserRoles.PROXY_ADMIN.value + ): + # Is the caller org_admin of the destination org? + caller_memberships = ( + await prisma_client.db.litellm_organizationmembership.find_many( + where={ + "user_id": user_api_key_dict.user_id, + "organization_id": data.organization_id, + "user_role": LitellmUserRoles.ORG_ADMIN.value, + } + ) + if user_api_key_dict.user_id + else [] + ) + if not caller_memberships: + raise HTTPException( + status_code=403, + detail={ + "error": ( + "Relocating a team to a different organization " + "requires PROXY_ADMIN or org-admin of the " + "destination org." + ) + }, + ) + await fetch_and_validate_organization( organization_id=data.organization_id, existing_team_row=existing_team_row, @@ -2682,6 +2718,20 @@ async def bulk_team_member_add( ) if data.all_users: + # `all_users=True` pulls every user in the database into this team, + # regardless of org. Any team admin could use it to capture every + # user across every org into a team they control. Restrict to + # PROXY_ADMIN. + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value: + raise HTTPException( + status_code=403, + detail={ + "error": ( + "`all_users=true` is restricted to PROXY_ADMIN. " + "Org/team admins must specify explicit member lists." + ) + }, + ) # get all users from the database all_users_in_db = await prisma_client.db.litellm_usertable.find_many( order={"created_at": "desc"} diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index a074a4a211f..a1ba7ecd677 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -1956,6 +1956,43 @@ async def test_delete_user_rejects_org_admin_deleting_outside_scope(mocker): ) == 0 +@pytest.mark.asyncio +async def test_user_update_rejects_silent_create_for_non_proxy_admin(mocker): + """Regression: `/user/update` with an unknown user_email used to fall + through to an INSERT, silently creating a new user with caller-supplied + budget, models, and metadata. An org admin could use this to spawn + arbitrary users outside the /user/new authorization flow.""" + from fastapi import HTTPException + + from litellm.proxy._types import UpdateUserRequest, UserAPIKeyAuth + from litellm.proxy.management_endpoints.internal_user_endpoints import ( + _update_single_user_helper, + ) + + mock_prisma_client = mocker.MagicMock() + # user_email lookup yields None → would silently create pre-fix. + mock_prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock( + return_value=None + ) + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + + user_request = UpdateUserRequest( + user_email="newcomer@example.com", + max_budget=1_000_000, + models=["gpt-4"], + ) + org_admin = UserAPIKeyAuth( + user_id="org-admin", + user_role=LitellmUserRoles.ORG_ADMIN, + ) + + with pytest.raises(HTTPException) as exc: + await _update_single_user_helper( + user_request=user_request, user_api_key_dict=org_admin + ) + assert exc.value.status_code == 404 + + # ===================================================================== # /v2/user/info endpoint tests # ===================================================================== diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index 9b4bd790493..896e1efe0e4 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -5128,6 +5128,16 @@ async def test_update_team_guardrails_with_org_id(): return_value=mock_org ) + # Destination-org guard in update_team queries for the caller's + # ORG_ADMIN membership on the destination org. Return a match so + # the guardrails-update path (the subject under test) proceeds. + mock_org_admin_membership = MagicMock() + mock_org_admin_membership.user_id = "org-admin-guardrails-test" + mock_org_admin_membership.organization_id = "test-org-guardrails" + mock_prisma.db.litellm_organizationmembership.find_many = AsyncMock( + return_value=[mock_org_admin_membership] + ) + # Mock team update mock_updated_team = MagicMock(spec=LiteLLM_TeamTable) mock_updated_team.team_id = "team-guardrails-123" From c7c3df2b02c0fd7e4544bdf5cf3e3b0f8118b734 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Fri, 17 Apr 2026 00:41:00 +0000 Subject: [PATCH 6/7] fix(proxy): extend /key/update admin check to non-budget fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit-B #2. _check_key_admin_access was gated on max_budget/spend changes only, which meant a non-admin caller could blanket-rewrite any OTHER field on any key (key_alias, models, tpm_limit, rpm_limit, metadata, tags, allowed_routes, guardrails, blocked, duration, permissions, auto_rotate, access_group_ids, object_permission, …) as long as they avoided budget/spend. Example attack: POST /key/update { key: sk-victim-in-org-B, models: [], blocked: true, organization_id: org-A, } The caller is org-admin of org-A, which satisfies the route gate; the handler then wipes models and blocks the victim's key. Policy after this fix: - PROXY_ADMIN: always allowed. - Key OWNER (matching user_id): allowed for non-budget fields; budget/spend changes still require team/org admin. - Everyone else: must pass _check_key_admin_access (PROXY_ADMIN / key-owner / team-admin / org-admin of the key). Regression test confirms a non-owner INTERNAL_USER cannot rewrite key_alias/blocked on someone else's key; existing test covers the owner-can-update-alias case; existing test covers internal-user-cannot-modify-max-budget. --- .../key_management_endpoints.py | 50 ++++++++++---- .../test_key_management_endpoints.py | 68 +++++++++++++++++++ 2 files changed, 106 insertions(+), 12 deletions(-) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index f69d9d2f8d4..32227b3dd6d 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -1961,22 +1961,48 @@ async def _validate_update_key_data( user_api_key_cache=user_api_key_cache, ) - # Admin-only: only proxy admins, team admins, or org admins can modify max_budget or spend - if ( - data.max_budget is not None and data.max_budget != existing_key_row.max_budget + # Cross-key authorization. Previously only gated on max_budget/spend + # changes, which let a non-admin blanket-rewrite any OTHER field on + # any key (models, alias, metadata, tpm_limit, rpm_limit, + # allowed_routes, guardrails, blocked, duration, permissions, …) as + # long as they avoided budget/spend. + # + # Policy: + # - Key owner (same user_id): may update non-budget fields on their + # own key without the admin check. + # - Anyone else (non-PROXY_ADMIN, not the owner): must pass + # _check_key_admin_access (PROXY_ADMIN / key-owner / team-admin / + # org-admin of the key). + # - max_budget / spend: always require the admin check, even for the + # key owner (matches the existing admin-only budget semantics). + is_key_owner = ( + user_api_key_dict.user_id is not None + and existing_key_row.user_id == user_api_key_dict.user_id + ) + _is_budget_change = ( + data.max_budget is not None + and data.max_budget != existing_key_row.max_budget ) or ( data.spend is not None and data.spend != getattr(existing_key_row, "spend", None) + ) + if ( + (not _is_proxy_admin) + and prisma_client is not None + and (not is_key_owner or _is_budget_change) ): - if prisma_client is not None: - hashed_key = existing_key_row.token - await _check_key_admin_access( - user_api_key_dict=user_api_key_dict, - hashed_token=hashed_key, - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - route="/key/update (max_budget/spend)", - ) + hashed_key = existing_key_row.token + await _check_key_admin_access( + user_api_key_dict=user_api_key_dict, + hashed_token=hashed_key, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + route=( + "/key/update (max_budget/spend)" + if _is_budget_change + else "/key/update" + ), + ) # Check team limits if key has a team_id (from request or existing key) team_obj: Optional[LiteLLM_TeamTableCachedObj] = None diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index 479defbff5c..41ab60fcf67 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -8030,6 +8030,74 @@ async def test_update_key_non_budget_fields_allowed_for_internal_user(monkeypatc assert result is not None +@pytest.mark.asyncio +async def test_update_key_non_budget_rejects_cross_user_modification(monkeypatch): + """Regression: previously _check_key_admin_access was gated on + max_budget/spend changes only, so an internal user could rewrite any + OTHER field (alias, models, tpm_limit, blocked, metadata, …) on any + key they weren't admin of as long as they avoided budget/spend. This + confirms that a non-admin user updating a key that belongs to another + user fails with 403 even for non-budget fields.""" + from litellm.proxy.management_endpoints.key_management_endpoints import ( + update_key_fn, + ) + + mock_prisma_client = AsyncMock() + test_hashed_token = ( + "cafebabe" * 8 + ) + + mock_existing_key = MagicMock() + mock_existing_key.token = test_hashed_token + mock_existing_key.user_id = "victim_user" # owned by someone else + mock_existing_key.team_id = None + mock_existing_key.project_id = None + mock_existing_key.max_budget = 10.0 + mock_existing_key.key_alias = "original" + mock_existing_key.models = [] + mock_existing_key.model_dump.return_value = { + "token": test_hashed_token, + "user_id": "victim_user", + "team_id": None, + "max_budget": 10.0, + } + + mock_prisma_client.get_data = AsyncMock(return_value=mock_existing_key) + mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock( + return_value=mock_existing_key + ) + + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + monkeypatch.setattr("litellm.proxy.proxy_server.user_api_key_cache", AsyncMock()) + monkeypatch.setattr("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()) + monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", None) + monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True) + monkeypatch.setattr( + "litellm.proxy.proxy_server.hash_token", lambda t: test_hashed_token + ) + + mock_request = MagicMock() + mock_request.query_params = {} + attacker = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + api_key="sk-attacker", + user_id="attacker_user", # NOT the owner + ) + + # Trying to blanket-rewrite a non-budget field on someone else's key + # must now fail. + with pytest.raises(ProxyException) as exc: + await update_key_fn( + request=mock_request, + data=UpdateKeyRequest( + key=test_hashed_token, key_alias="pwned", blocked=True + ), + user_api_key_dict=attacker, + litellm_changed_by=None, + ) + assert str(exc.value.code) == "403" + + # ============================================================================ # LIT-1884: Internal users cannot create invalid keys # ============================================================================ From e3b55794ce14e26c8d39b469a5388be4d2f4bc34 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Fri, 17 Apr 2026 00:44:24 +0000 Subject: [PATCH 7/7] fix(proxy): close /organization member_delete + role-escalation gaps Audit-B #7 and #8. 1. /organization/member_delete was not in org_admin_only_routes, so it fell through to management_routes/self_managed_routes and let any caller that reached the route delete arbitrary org memberships without the organization_role_based_access_check that member_add and member_update trigger. Adding it to org_admin_only_routes applies the same ORG_ADMIN-of-target-org gate. 2. /organization/member_update had no validation that the target user was not a global PROXY_ADMIN. An org-admin of any org could alter a PROXY_ADMIN user's per-org role. Reject this unless the caller is PROXY_ADMIN. --- litellm/proxy/_types.py | 7 +++++ .../organization_endpoints.py | 27 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 7fff640c497..39857984247 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -690,6 +690,13 @@ class LiteLLMRoutes(enum.Enum): "/organization/delete", "/organization/member_add", "/organization/member_update", + # member_delete is equally destructive as member_add / member_update + # and must be scoped the same way — otherwise it falls through to + # the management_routes / self_managed_routes path and lets any + # non-PROXY_ADMIN caller that reaches the route delete arbitrary + # org memberships without the organization_role_based_access_check + # that member_add / member_update trigger. + "/organization/member_delete", ] # Routes accessible by Admin Viewer (read-only admin access) diff --git a/litellm/proxy/management_endpoints/organization_endpoints.py b/litellm/proxy/management_endpoints/organization_endpoints.py index 25df9f0b0f7..670946ac0d8 100644 --- a/litellm/proxy/management_endpoints/organization_endpoints.py +++ b/litellm/proxy/management_endpoints/organization_endpoints.py @@ -1064,6 +1064,33 @@ async def organization_member_update( }, ) + # Reject attempts to change the role of a global PROXY_ADMIN via + # org-scoped operations. An org-admin of any org could otherwise + # alter a PROXY_ADMIN user's per-org role, which has downstream + # effects on admin UI filtering and scope derivation. + target_user_row = await prisma_client.db.litellm_usertable.find_unique( + where={"user_id": data.user_id} + ) + if target_user_row is not None and getattr( + target_user_row, "user_role", None + ) in ( + LitellmUserRoles.PROXY_ADMIN.value, + LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, + ): + if ( + user_api_key_dict.user_role + != LitellmUserRoles.PROXY_ADMIN.value + ): + raise HTTPException( + status_code=403, + detail={ + "error": ( + "Only PROXY_ADMIN may modify the organization " + "role of a user who is a global PROXY_ADMIN." + ) + }, + ) + # Update member role if data.role is not None: await prisma_client.db.litellm_organizationmembership.update(