mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-12 21:04:10 +00:00
863f922be8
* fix(team_endpoints): auto-add SSO team members to org for proxy admins * test: proxy_admin vs team_admin security boundary for team→org move * screenshots: before/after for team-org SSO fix * fix(team_endpoints): restore staging security features dropped in SSO commit Co-Authored-By: Ishaan Jaff <ishaan@berri.ai> * style: black formatting for team_endpoints
233 lines
7.8 KiB
Python
233 lines
7.8 KiB
Python
"""
|
|
Tests for moving teams to organizations.
|
|
|
|
Covers the SSO/Entra scenario where:
|
|
- Proxy admins can move teams freely; missing members are auto-added to the org.
|
|
- Non-proxy-admins (team admins) must have all team members pre-added to the org,
|
|
preserving the original security model (no privilege escalation via team move).
|
|
"""
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from litellm.proxy._types import (
|
|
LiteLLM_OrganizationTableWithMembers,
|
|
LiteLLM_OrganizationMembershipTable,
|
|
LiteLLM_TeamTable,
|
|
LitellmUserRoles,
|
|
Member,
|
|
OrgMember,
|
|
SpecialProxyStrings,
|
|
)
|
|
from litellm.proxy.management_endpoints.team_endpoints import (
|
|
_auto_add_team_members_to_organization,
|
|
validate_team_org_change,
|
|
)
|
|
from litellm.router import Router
|
|
|
|
|
|
def _make_org(organization_id="org-1", members=None, models=None):
|
|
from datetime import datetime
|
|
|
|
return LiteLLM_OrganizationTableWithMembers(
|
|
organization_id=organization_id,
|
|
organization_alias="test-org",
|
|
budget_id="budget-test",
|
|
spend=0.0,
|
|
metadata={},
|
|
models=models or [],
|
|
created_by="default_user_id",
|
|
updated_by="default_user_id",
|
|
members=members or [],
|
|
teams=[],
|
|
litellm_budget_table=None,
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
)
|
|
|
|
|
|
def _make_team(team_id="team-1", member_ids=None, organization_id=None):
|
|
members = [
|
|
Member(user_id=uid, role="user") for uid in (member_ids or [])
|
|
]
|
|
members.append(Member(user_id=SpecialProxyStrings.default_user_id.value, role="admin"))
|
|
return LiteLLM_TeamTable(
|
|
team_id=team_id,
|
|
team_alias="test-team",
|
|
organization_id=organization_id,
|
|
admins=[],
|
|
members=[],
|
|
members_with_roles=members,
|
|
metadata={},
|
|
models=[],
|
|
blocked=False,
|
|
spend=0.0,
|
|
)
|
|
|
|
|
|
def _make_org_membership(user_id):
|
|
from datetime import datetime
|
|
|
|
return LiteLLM_OrganizationMembershipTable(
|
|
user_id=user_id,
|
|
organization_id="org-1",
|
|
user_role=LitellmUserRoles.INTERNAL_USER,
|
|
spend=0.0,
|
|
budget_id=None,
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
)
|
|
|
|
|
|
class TestValidateTeamOrgChange:
|
|
def test_proxy_admin_not_blocked_when_members_not_in_org(self):
|
|
"""Proxy admins bypass the membership check — auto-add handles it instead."""
|
|
router = MagicMock(spec=Router)
|
|
team = _make_team(member_ids=["sso-user-001", "sso-user-002"])
|
|
org = _make_org(members=[])
|
|
|
|
result = validate_team_org_change(
|
|
team=team, organization=org, llm_router=router, is_proxy_admin=True
|
|
)
|
|
assert result is True
|
|
|
|
def test_non_admin_blocked_when_members_not_in_org(self):
|
|
"""Team admins (non-proxy-admin) must have all members pre-added to the org."""
|
|
router = MagicMock(spec=Router)
|
|
team = _make_team(member_ids=["sso-user-001"])
|
|
org = _make_org(members=[])
|
|
|
|
with pytest.raises(Exception) as exc_info:
|
|
validate_team_org_change(
|
|
team=team, organization=org, llm_router=router, is_proxy_admin=False
|
|
)
|
|
assert "403" in str(exc_info.value) or "not a member" in str(exc_info.value)
|
|
|
|
def test_non_admin_passes_when_all_members_in_org(self):
|
|
"""Team admin move succeeds when all team members are already org members."""
|
|
router = MagicMock(spec=Router)
|
|
team = _make_team(member_ids=["u1"])
|
|
org = _make_org(members=[_make_org_membership("u1")])
|
|
|
|
result = validate_team_org_change(
|
|
team=team, organization=org, llm_router=router, is_proxy_admin=False
|
|
)
|
|
assert result is True
|
|
|
|
def test_same_org_short_circuits(self):
|
|
"""Moving to the same org is always a no-op, regardless of role."""
|
|
router = MagicMock(spec=Router)
|
|
team = _make_team(member_ids=["u1"], organization_id="org-1")
|
|
org = _make_org(organization_id="org-1")
|
|
|
|
assert validate_team_org_change(
|
|
team=team, organization=org, llm_router=router, is_proxy_admin=False
|
|
) is True
|
|
assert validate_team_org_change(
|
|
team=team, organization=org, llm_router=router, is_proxy_admin=True
|
|
) is True
|
|
|
|
def test_default_user_excluded_from_membership_check(self):
|
|
"""default_user_id is never checked for org membership."""
|
|
router = MagicMock(spec=Router)
|
|
# Team has only default_user_id (added by _make_team)
|
|
team = _make_team(member_ids=[])
|
|
org = _make_org(members=[])
|
|
|
|
# Should not raise even for non-proxy-admin
|
|
result = validate_team_org_change(
|
|
team=team, organization=org, llm_router=router, is_proxy_admin=False
|
|
)
|
|
assert result is True
|
|
|
|
|
|
class TestAutoAddTeamMembersToOrg:
|
|
@pytest.mark.asyncio
|
|
async def test_adds_missing_members(self):
|
|
team = _make_team(member_ids=["sso-user-001", "sso-user-002"])
|
|
org = _make_org(members=[])
|
|
|
|
mock_add = AsyncMock()
|
|
import litellm.proxy.management_endpoints.team_endpoints as te
|
|
original = te.add_member_to_organization
|
|
te.add_member_to_organization = mock_add
|
|
|
|
try:
|
|
await _auto_add_team_members_to_organization(
|
|
team=team,
|
|
organization=org,
|
|
prisma_client=MagicMock(),
|
|
)
|
|
finally:
|
|
te.add_member_to_organization = original
|
|
|
|
assert mock_add.call_count == 2
|
|
called_user_ids = {
|
|
call.kwargs["member"].user_id for call in mock_add.call_args_list
|
|
}
|
|
assert called_user_ids == {"sso-user-001", "sso-user-002"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skips_existing_org_members(self):
|
|
team = _make_team(member_ids=["u1", "u2"])
|
|
org = _make_org(members=[_make_org_membership("u1")])
|
|
|
|
mock_add = AsyncMock()
|
|
import litellm.proxy.management_endpoints.team_endpoints as te
|
|
original = te.add_member_to_organization
|
|
te.add_member_to_organization = mock_add
|
|
|
|
try:
|
|
await _auto_add_team_members_to_organization(
|
|
team=team,
|
|
organization=org,
|
|
prisma_client=MagicMock(),
|
|
)
|
|
finally:
|
|
te.add_member_to_organization = original
|
|
|
|
assert mock_add.call_count == 1
|
|
assert mock_add.call_args.kwargs["member"].user_id == "u2"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skips_default_user(self):
|
|
"""default_user_id should never be added as an org member."""
|
|
team = _make_team(member_ids=[])
|
|
org = _make_org(members=[])
|
|
|
|
mock_add = AsyncMock()
|
|
import litellm.proxy.management_endpoints.team_endpoints as te
|
|
original = te.add_member_to_organization
|
|
te.add_member_to_organization = mock_add
|
|
|
|
try:
|
|
await _auto_add_team_members_to_organization(
|
|
team=team,
|
|
organization=org,
|
|
prisma_client=MagicMock(),
|
|
)
|
|
finally:
|
|
te.add_member_to_organization = original
|
|
|
|
assert mock_add.call_count == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_logs_and_continues_on_error(self):
|
|
"""Errors must not propagate — they are logged at DEBUG and skipped."""
|
|
team = _make_team(member_ids=["u1"])
|
|
org = _make_org(members=[])
|
|
|
|
mock_add = AsyncMock(side_effect=Exception("duplicate key"))
|
|
import litellm.proxy.management_endpoints.team_endpoints as te
|
|
original = te.add_member_to_organization
|
|
te.add_member_to_organization = mock_add
|
|
|
|
try:
|
|
await _auto_add_team_members_to_organization(
|
|
team=team,
|
|
organization=org,
|
|
prisma_client=MagicMock(),
|
|
)
|
|
finally:
|
|
te.add_member_to_organization = original
|