mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-11 16:26:07 +00:00
feat: org admin access to team management — backend auth, UI visibility, tests
- Add _is_user_org_admin_for_team() reusable helper to common_utils.py - Grant org admins access to /team/list, /team/info, /team/member_add, /team/member_delete, /team/member_update, /team/model/add, /team/model/delete, /team/permissions_list, /team/permissions_update - Make validate_membership async with org admin fallback - Add /user/list to self_managed_routes (endpoint handles own auth) - UI: org admins see Members, Member Permissions, Settings tabs in team view - UI: CreateUserButton uses useOrganizations() for org dropdown - UI: org admin delete-member respects disable_team_admin_delete_team_user - Add 16 unit tests for _is_user_org_admin_for_team, validate_membership, _user_is_org_admin route check, and privilege escalation prevention Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
8bf3c0c67f
commit
ce317148b9
@@ -653,6 +653,7 @@ class LiteLLMRoutes(enum.Enum):
|
||||
"/model/delete",
|
||||
"/user/daily/activity",
|
||||
"/user/available_roles", # read-only role metadata; any authenticated user may read
|
||||
"/user/list", # org admins checked in endpoint; non-admins get 403
|
||||
"/model/{model_id}/update",
|
||||
"/prompt/list",
|
||||
"/prompt/info",
|
||||
|
||||
@@ -41,6 +41,46 @@ def _is_user_team_admin(
|
||||
return False
|
||||
|
||||
|
||||
async def _is_user_org_admin_for_team(
|
||||
user_api_key_dict: UserAPIKeyAuth, team_obj: LiteLLM_TeamTable
|
||||
) -> bool:
|
||||
"""
|
||||
Check if user is an org admin for the team's organization.
|
||||
|
||||
Returns True if:
|
||||
- The team belongs to an organization, AND
|
||||
- The user has org_admin role in that organization
|
||||
"""
|
||||
if not team_obj.organization_id or not user_api_key_dict.user_id:
|
||||
return False
|
||||
|
||||
from litellm.proxy.auth.auth_checks import get_user_object
|
||||
from litellm.proxy.proxy_server import (
|
||||
prisma_client,
|
||||
proxy_logging_obj,
|
||||
user_api_key_cache,
|
||||
)
|
||||
|
||||
caller_user = await get_user_object(
|
||||
user_id=user_api_key_dict.user_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
user_id_upsert=False,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
if caller_user is None:
|
||||
return False
|
||||
|
||||
for m in caller_user.organization_memberships or []:
|
||||
if (
|
||||
m.organization_id == team_obj.organization_id
|
||||
and m.user_role == LitellmUserRoles.ORG_ADMIN.value
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _team_member_has_permission(
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
team_obj: LiteLLM_TeamTable,
|
||||
|
||||
@@ -70,6 +70,7 @@ from litellm.proxy.auth.auth_checks import (
|
||||
)
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
_is_user_org_admin_for_team,
|
||||
_is_user_team_admin,
|
||||
_set_object_metadata_field,
|
||||
_team_member_has_permission,
|
||||
@@ -1649,6 +1650,9 @@ async def _validate_team_member_add_permissions(
|
||||
and not _is_user_team_admin(
|
||||
user_api_key_dict=user_api_key_dict, team_obj=complete_team_data
|
||||
)
|
||||
and not await _is_user_org_admin_for_team(
|
||||
user_api_key_dict=user_api_key_dict, team_obj=complete_team_data
|
||||
)
|
||||
and not _is_available_team(
|
||||
team_id=complete_team_data.team_id,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
@@ -2121,13 +2125,16 @@ async def team_member_delete(
|
||||
)
|
||||
existing_team_row = LiteLLM_TeamTable(**_existing_team_row.model_dump())
|
||||
|
||||
## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN
|
||||
## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN OR ORG ADMIN
|
||||
|
||||
if (
|
||||
user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value
|
||||
and not _is_user_team_admin(
|
||||
user_api_key_dict=user_api_key_dict, team_obj=existing_team_row
|
||||
)
|
||||
and not await _is_user_org_admin_for_team(
|
||||
user_api_key_dict=user_api_key_dict, team_obj=existing_team_row
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
@@ -2280,13 +2287,16 @@ async def team_member_update(
|
||||
)
|
||||
existing_team_row = LiteLLM_TeamTable(**_existing_team_row.model_dump())
|
||||
|
||||
## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN
|
||||
## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN OR ORG ADMIN
|
||||
|
||||
if (
|
||||
user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value
|
||||
and not _is_user_team_admin(
|
||||
user_api_key_dict=user_api_key_dict, team_obj=existing_team_row
|
||||
)
|
||||
and not await _is_user_org_admin_for_team(
|
||||
user_api_key_dict=user_api_key_dict, team_obj=existing_team_row
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
@@ -2760,7 +2770,7 @@ async def _persist_deleted_team_records(
|
||||
prisma_client=prisma_client,
|
||||
)
|
||||
|
||||
def validate_membership(
|
||||
async def validate_membership(
|
||||
user_api_key_dict: UserAPIKeyAuth, team_table: LiteLLM_TeamTable
|
||||
):
|
||||
if (
|
||||
@@ -2795,17 +2805,26 @@ def validate_membership(
|
||||
},
|
||||
)
|
||||
|
||||
if user_api_key_dict.user_id not in [
|
||||
# Check direct team membership
|
||||
if user_api_key_dict.user_id in [
|
||||
m.user_id for m in team_table.members_with_roles
|
||||
]:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": "User={} not authorized to access this team={}".format(
|
||||
user_api_key_dict.user_id, team_table.team_id
|
||||
)
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
# Check if user is an org admin for the team's organization
|
||||
if await _is_user_org_admin_for_team(
|
||||
user_api_key_dict=user_api_key_dict, team_obj=team_table
|
||||
):
|
||||
return
|
||||
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": "User={} not authorized to access this team={}".format(
|
||||
user_api_key_dict.user_id, team_table.team_id
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _unfurl_all_proxy_models(
|
||||
@@ -2896,7 +2915,7 @@ async def team_info(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"message": f"Team not found, passed team id: {team_id}."},
|
||||
)
|
||||
validate_membership(
|
||||
await validate_membership(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
team_table=LiteLLM_TeamTable(**team_info.model_dump()),
|
||||
)
|
||||
@@ -3384,19 +3403,11 @@ async def list_team(
|
||||
- user_id: str - Optional. If passed will only return teams that the user_id is a member of.
|
||||
- organization_id: str - Optional. If passed will only return teams that belong to the organization_id. Pass 'default_organization' to get all teams without organization_id.
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if not allowed_route_check_inside_route(
|
||||
user_api_key_dict=user_api_key_dict, requested_user_id=user_id
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": "Only admin users can query all teams/other teams. Your user role={}".format(
|
||||
user_api_key_dict.user_role
|
||||
)
|
||||
},
|
||||
)
|
||||
from litellm.proxy.proxy_server import (
|
||||
prisma_client,
|
||||
proxy_logging_obj,
|
||||
user_api_key_cache,
|
||||
)
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(
|
||||
@@ -3404,6 +3415,46 @@ async def list_team(
|
||||
detail={"error": CommonProxyErrors.db_not_connected_error.value},
|
||||
)
|
||||
|
||||
# Determine access level and org-admin scoping
|
||||
is_proxy_admin = _user_has_admin_view(user_api_key_dict)
|
||||
allowed_org_ids: Optional[List[str]] = None
|
||||
|
||||
if not is_proxy_admin:
|
||||
is_own_query = (
|
||||
user_id is not None
|
||||
and user_api_key_dict.user_id is not None
|
||||
and user_api_key_dict.user_id == user_id
|
||||
)
|
||||
|
||||
# Check if user is an org admin (even for own queries, so they see org teams)
|
||||
if user_api_key_dict.user_id is not None:
|
||||
caller_user = await get_user_object(
|
||||
user_id=user_api_key_dict.user_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
user_id_upsert=False,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
if caller_user is not None:
|
||||
allowed_org_ids = [
|
||||
m.organization_id
|
||||
for m in (caller_user.organization_memberships or [])
|
||||
if m.user_role == LitellmUserRoles.ORG_ADMIN.value
|
||||
]
|
||||
if not allowed_org_ids:
|
||||
allowed_org_ids = None
|
||||
|
||||
# If not an org admin and not querying own teams, reject
|
||||
if allowed_org_ids is None and not is_own_query:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": "Only admin users can query all teams/other teams. Your user role={}".format(
|
||||
user_api_key_dict.user_role
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
response = await prisma_client.db.litellm_teamtable.find_many(
|
||||
include={
|
||||
"litellm_model_table": True,
|
||||
@@ -3411,8 +3462,28 @@ async def list_team(
|
||||
)
|
||||
|
||||
filtered_response = []
|
||||
if user_id:
|
||||
# Get user object to access their teams array
|
||||
if allowed_org_ids is not None:
|
||||
# Org admin: return teams from their organizations
|
||||
allowed_org_set = set(allowed_org_ids)
|
||||
seen_team_ids = set()
|
||||
for team in response:
|
||||
if team.organization_id in allowed_org_set:
|
||||
filtered_response.append(team)
|
||||
seen_team_ids.add(team.team_id)
|
||||
# Also include teams the user is a direct member of (outside their orgs)
|
||||
if user_id:
|
||||
for team in response:
|
||||
if team.team_id not in seen_team_ids and team.members_with_roles:
|
||||
for member in team.members_with_roles:
|
||||
if (
|
||||
"user_id" in member
|
||||
and member["user_id"] is not None
|
||||
and member["user_id"] == user_id
|
||||
):
|
||||
filtered_response.append(team)
|
||||
seen_team_ids.add(team.team_id)
|
||||
elif user_id:
|
||||
# Regular user querying their own teams
|
||||
for team in response:
|
||||
if team.members_with_roles:
|
||||
for member in team.members_with_roles:
|
||||
@@ -3652,12 +3723,15 @@ async def team_model_add(
|
||||
|
||||
team_obj = LiteLLM_TeamTable(**team_row.model_dump())
|
||||
|
||||
# Authorization check - only proxy admin or team admin can add models
|
||||
# Authorization check - only proxy admin, team admin, or org admin can add models
|
||||
if (
|
||||
user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value
|
||||
and not _is_user_team_admin(
|
||||
user_api_key_dict=user_api_key_dict, team_obj=team_obj
|
||||
)
|
||||
and not await _is_user_org_admin_for_team(
|
||||
user_api_key_dict=user_api_key_dict, team_obj=team_obj
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
@@ -3720,12 +3794,15 @@ async def team_model_delete(
|
||||
|
||||
team_obj = LiteLLM_TeamTable(**team_row.model_dump())
|
||||
|
||||
# Authorization check - only proxy admin or team admin can remove models
|
||||
# Authorization check - only proxy admin, team admin, or org admin can remove models
|
||||
if (
|
||||
user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value
|
||||
and not _is_user_team_admin(
|
||||
user_api_key_dict=user_api_key_dict, team_obj=team_obj
|
||||
)
|
||||
and not await _is_user_org_admin_for_team(
|
||||
user_api_key_dict=user_api_key_dict, team_obj=team_obj
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
@@ -3770,7 +3847,7 @@ async def team_member_permissions(
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail={"error": "No db connected"})
|
||||
|
||||
## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN
|
||||
## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN OR ORG ADMIN
|
||||
existing_team_row = await get_team_object(
|
||||
team_id=team_id,
|
||||
prisma_client=prisma_client,
|
||||
@@ -3789,6 +3866,9 @@ async def team_member_permissions(
|
||||
and not _is_user_team_admin(
|
||||
user_api_key_dict=user_api_key_dict, team_obj=complete_team_data
|
||||
)
|
||||
and not await _is_user_org_admin_for_team(
|
||||
user_api_key_dict=user_api_key_dict, team_obj=complete_team_data
|
||||
)
|
||||
and not _is_available_team(
|
||||
team_id=complete_team_data.team_id,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
@@ -3838,7 +3918,7 @@ async def update_team_member_permissions(
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail={"error": "No db connected"})
|
||||
|
||||
## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN
|
||||
## CHECK IF USER IS PROXY ADMIN OR TEAM ADMIN OR ORG ADMIN
|
||||
existing_team_row = await get_team_object(
|
||||
team_id=data.team_id,
|
||||
prisma_client=prisma_client,
|
||||
@@ -3857,6 +3937,9 @@ async def update_team_member_permissions(
|
||||
and not _is_user_team_admin(
|
||||
user_api_key_dict=user_api_key_dict, team_obj=complete_team_data
|
||||
)
|
||||
and not await _is_user_org_admin_for_team(
|
||||
user_api_key_dict=user_api_key_dict, team_obj=complete_team_data
|
||||
)
|
||||
and not _is_available_team(
|
||||
team_id=complete_team_data.team_id,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
|
||||
@@ -5279,7 +5279,7 @@ async def get_available_models_for_user(
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
validate_membership(user_api_key_dict=user_api_key_dict, team_table=team_object)
|
||||
await validate_membership(user_api_key_dict=user_api_key_dict, team_table=team_object)
|
||||
team_models = team_object.models
|
||||
|
||||
team_models = get_team_models(
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
"""
|
||||
Tests for org admin access to team management endpoints.
|
||||
|
||||
Covers:
|
||||
- _is_user_org_admin_for_team helper
|
||||
- validate_membership allowing org admins
|
||||
- _user_is_org_admin route-level check (no privilege escalation)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.abspath("../../../"))
|
||||
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_OrganizationMembershipTable,
|
||||
LiteLLM_TeamTable,
|
||||
LiteLLM_UserTable,
|
||||
LitellmUserRoles,
|
||||
Member,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
|
||||
_NOW = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_team(team_id="team-1", organization_id="org-1") -> LiteLLM_TeamTable:
|
||||
return LiteLLM_TeamTable(
|
||||
team_id=team_id,
|
||||
team_alias="Test Team",
|
||||
organization_id=organization_id,
|
||||
members_with_roles=[
|
||||
Member(user_id="direct-member", role="user"),
|
||||
Member(user_id="team-admin", role="admin"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _make_user_key(
|
||||
user_id="org-admin-user", role=LitellmUserRoles.INTERNAL_USER.value
|
||||
) -> UserAPIKeyAuth:
|
||||
return UserAPIKeyAuth(user_id=user_id, user_role=role)
|
||||
|
||||
|
||||
def _make_membership(user_id, org_id, role="org_admin"):
|
||||
return LiteLLM_OrganizationMembershipTable(
|
||||
user_id=user_id,
|
||||
organization_id=org_id,
|
||||
user_role=role,
|
||||
created_at=_NOW,
|
||||
updated_at=_NOW,
|
||||
)
|
||||
|
||||
|
||||
def _make_caller_user(
|
||||
user_id="org-admin-user", org_id="org-1", org_role="org_admin"
|
||||
) -> LiteLLM_UserTable:
|
||||
return LiteLLM_UserTable(
|
||||
user_id=user_id,
|
||||
organization_memberships=[_make_membership(user_id, org_id, org_role)],
|
||||
)
|
||||
|
||||
|
||||
def _patch_org_admin_deps(get_user_return):
|
||||
"""Context manager that patches the lazy imports inside _is_user_org_admin_for_team."""
|
||||
return (
|
||||
patch("litellm.proxy.auth.auth_checks.get_user_object", new_callable=AsyncMock, return_value=get_user_return),
|
||||
patch("litellm.proxy.proxy_server.prisma_client", MagicMock(), create=True),
|
||||
patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock(), create=True),
|
||||
patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock(), create=True),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_user_org_admin_for_team
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsUserOrgAdminForTeam:
|
||||
"""Tests for the reusable _is_user_org_admin_for_team helper."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_org_admin_for_teams_org_returns_true(self):
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
_is_user_org_admin_for_team,
|
||||
)
|
||||
|
||||
team = _make_team(organization_id="org-1")
|
||||
key = _make_user_key(user_id="org-admin-user")
|
||||
caller = _make_caller_user(user_id="org-admin-user", org_id="org-1")
|
||||
|
||||
p1, p2, p3, p4 = _patch_org_admin_deps(caller)
|
||||
with p1, p2, p3, p4:
|
||||
result = await _is_user_org_admin_for_team(user_api_key_dict=key, team_obj=team)
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_org_admin_different_org_returns_false(self):
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
_is_user_org_admin_for_team,
|
||||
)
|
||||
|
||||
team = _make_team(organization_id="org-1")
|
||||
key = _make_user_key(user_id="other-admin")
|
||||
caller = _make_caller_user(user_id="other-admin", org_id="org-2")
|
||||
|
||||
p1, p2, p3, p4 = _patch_org_admin_deps(caller)
|
||||
with p1, p2, p3, p4:
|
||||
result = await _is_user_org_admin_for_team(user_api_key_dict=key, team_obj=team)
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_team_without_org_returns_false(self):
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
_is_user_org_admin_for_team,
|
||||
)
|
||||
|
||||
team = _make_team(organization_id=None)
|
||||
key = _make_user_key()
|
||||
result = await _is_user_org_admin_for_team(user_api_key_dict=key, team_obj=team)
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_org_member_not_admin_returns_false(self):
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
_is_user_org_admin_for_team,
|
||||
)
|
||||
|
||||
team = _make_team(organization_id="org-1")
|
||||
key = _make_user_key(user_id="regular")
|
||||
caller = _make_caller_user(user_id="regular", org_id="org-1", org_role="user")
|
||||
|
||||
p1, p2, p3, p4 = _patch_org_admin_deps(caller)
|
||||
with p1, p2, p3, p4:
|
||||
result = await _is_user_org_admin_for_team(user_api_key_dict=key, team_obj=team)
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_user_id_returns_false(self):
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
_is_user_org_admin_for_team,
|
||||
)
|
||||
|
||||
team = _make_team(organization_id="org-1")
|
||||
key = _make_user_key(user_id=None)
|
||||
result = await _is_user_org_admin_for_team(user_api_key_dict=key, team_obj=team)
|
||||
assert result is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# validate_membership
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestValidateMembership:
|
||||
"""Tests for validate_membership with org admin support."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_admin_allowed(self):
|
||||
from litellm.proxy.management_endpoints.team_endpoints import validate_membership
|
||||
|
||||
team = _make_team()
|
||||
key = _make_user_key(user_id="admin", role=LitellmUserRoles.PROXY_ADMIN.value)
|
||||
await validate_membership(user_api_key_dict=key, team_table=team)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_team_member_allowed(self):
|
||||
from litellm.proxy.management_endpoints.team_endpoints import validate_membership
|
||||
|
||||
team = _make_team()
|
||||
key = _make_user_key(user_id="direct-member")
|
||||
await validate_membership(user_api_key_dict=key, team_table=team)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_org_admin_for_team_org_allowed(self):
|
||||
from litellm.proxy.management_endpoints.team_endpoints import validate_membership
|
||||
|
||||
team = _make_team(organization_id="org-1")
|
||||
key = _make_user_key(user_id="org-admin-user")
|
||||
caller = _make_caller_user(user_id="org-admin-user", org_id="org-1")
|
||||
|
||||
p1, p2, p3, p4 = _patch_org_admin_deps(caller)
|
||||
with p1, p2, p3, p4:
|
||||
await validate_membership(user_api_key_dict=key, team_table=team)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_member_non_org_admin_rejected(self):
|
||||
from fastapi import HTTPException
|
||||
from litellm.proxy.management_endpoints.team_endpoints import validate_membership
|
||||
|
||||
team = _make_team(organization_id="org-1")
|
||||
key = _make_user_key(user_id="random-user")
|
||||
caller = _make_caller_user(user_id="random-user", org_id="org-2", org_role="user")
|
||||
|
||||
p1, p2, p3, p4 = _patch_org_admin_deps(caller)
|
||||
with p1, p2, p3, p4:
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await validate_membership(user_api_key_dict=key, team_table=team)
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_team_key_matches_team_allowed(self):
|
||||
from litellm.proxy.management_endpoints.team_endpoints import validate_membership
|
||||
|
||||
team = _make_team(team_id="team-1")
|
||||
key = UserAPIKeyAuth(team_id="team-1", user_role=LitellmUserRoles.INTERNAL_USER.value)
|
||||
await validate_membership(user_api_key_dict=key, team_table=team)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _user_is_org_admin (route-level) — no privilege escalation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUserIsOrgAdminRouteCheck:
|
||||
"""
|
||||
Verify that _user_is_org_admin does NOT grant blanket access
|
||||
when no organization_id is in the request body.
|
||||
"""
|
||||
|
||||
def test_no_candidate_org_ids_returns_false(self):
|
||||
from litellm.proxy.auth.auth_checks_organization import _user_is_org_admin
|
||||
|
||||
user = LiteLLM_UserTable(
|
||||
user_id="org-admin-user",
|
||||
organization_memberships=[_make_membership("org-admin-user", "org-1")],
|
||||
)
|
||||
result = _user_is_org_admin(request_data={}, user_object=user)
|
||||
assert result is False, "Must NOT grant blanket access when no org in request"
|
||||
|
||||
def test_matching_org_id_returns_true(self):
|
||||
from litellm.proxy.auth.auth_checks_organization import _user_is_org_admin
|
||||
|
||||
user = LiteLLM_UserTable(
|
||||
user_id="org-admin-user",
|
||||
organization_memberships=[_make_membership("org-admin-user", "org-1")],
|
||||
)
|
||||
result = _user_is_org_admin(request_data={"organization_id": "org-1"}, user_object=user)
|
||||
assert result is True
|
||||
|
||||
def test_non_matching_org_id_returns_false(self):
|
||||
from litellm.proxy.auth.auth_checks_organization import _user_is_org_admin
|
||||
|
||||
user = LiteLLM_UserTable(
|
||||
user_id="org-admin-user",
|
||||
organization_memberships=[_make_membership("org-admin-user", "org-1")],
|
||||
)
|
||||
result = _user_is_org_admin(request_data={"organization_id": "org-99"}, user_object=user)
|
||||
assert result is False
|
||||
|
||||
def test_organizations_list_field(self):
|
||||
from litellm.proxy.auth.auth_checks_organization import _user_is_org_admin
|
||||
|
||||
user = LiteLLM_UserTable(
|
||||
user_id="org-admin-user",
|
||||
organization_memberships=[_make_membership("org-admin-user", "org-1")],
|
||||
)
|
||||
result = _user_is_org_admin(
|
||||
request_data={"organizations": ["org-1"]}, user_object=user
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_none_user_object_returns_false(self):
|
||||
from litellm.proxy.auth.auth_checks_organization import _user_is_org_admin
|
||||
|
||||
result = _user_is_org_admin(request_data={}, user_object=None)
|
||||
assert result is False
|
||||
|
||||
def test_user_list_in_self_managed_routes(self):
|
||||
"""Verify /user/list is in self_managed_routes so org admins can reach it."""
|
||||
from litellm.proxy._types import LiteLLMRoutes
|
||||
|
||||
assert "/user/list" in LiteLLMRoutes.self_managed_routes.value
|
||||
@@ -278,6 +278,12 @@ const TeamsView: React.FC<TeamProps> = ({
|
||||
accessToken={accessToken}
|
||||
is_team_admin={is_team_admin(teams?.find((team) => team.team_id === selectedTeamId))}
|
||||
is_proxy_admin={userRole == "Admin"}
|
||||
is_org_admin={(() => {
|
||||
const team = teams?.find((t) => t.team_id === selectedTeamId);
|
||||
if (!team?.organization_id || !organizations || !userID) return false;
|
||||
const org = organizations.find((o) => o.organization_id === team.organization_id);
|
||||
return org?.members?.some((m: any) => m.user_id === userID && m.user_role === "org_admin") ?? false;
|
||||
})()}
|
||||
userModels={userModels}
|
||||
editTeam={editTeam}
|
||||
premiumUser={premiumUser}
|
||||
|
||||
@@ -24,6 +24,10 @@ vi.mock("./bulk_create_users_button", () => ({
|
||||
default: () => <div data-testid="bulk-create-users">Bulk Create Users</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
|
||||
useOrganizations: vi.fn().mockReturnValue({ data: [], isLoading: false }),
|
||||
}));
|
||||
|
||||
const mockUserCreateCall = vi.mocked(networking.userCreateCall);
|
||||
const mockInvitationCreateCall = vi.mocked(networking.invitationCreateCall);
|
||||
const mockGetProxyUISettings = vi.mocked(networking.getProxyUISettings);
|
||||
@@ -264,7 +268,13 @@ describe("CreateUserButton", { timeout: 20000 }, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should send organizations list in POST body when organizationIds prop is provided", async () => {
|
||||
it("should send organizations list in POST body when organizations are selected", async () => {
|
||||
const { useOrganizations } = await import("@/app/(dashboard)/hooks/organizations/useOrganizations");
|
||||
vi.mocked(useOrganizations).mockReturnValue({
|
||||
data: [{ organization_id: "org-1", organization_alias: "My Org" }],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
const user = userEvent.setup();
|
||||
mockUserCreateCall.mockResolvedValue({ data: { user_id: "org-user" } });
|
||||
mockInvitationCreateCall.mockResolvedValue({
|
||||
@@ -273,10 +283,8 @@ describe("CreateUserButton", { timeout: 20000 }, () => {
|
||||
has_user_setup_sso: false,
|
||||
} as any);
|
||||
|
||||
const orgIds = [{ organization_id: "org-1", organization_alias: "My Org" }];
|
||||
|
||||
renderWithProviders(
|
||||
<CreateUserButton {...defaultProps} possibleUIRoles={{ proxy_user: { ui_label: "User", description: "" } }} organizationIds={orgIds} />,
|
||||
<CreateUserButton {...defaultProps} possibleUIRoles={{ proxy_user: { ui_label: "User", description: "" } }} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -288,6 +296,12 @@ describe("CreateUserButton", { timeout: 20000 }, () => {
|
||||
await user.type(within(dialog).getByLabelText(/user email/i), "org@example.com");
|
||||
await user.click(within(dialog).getByRole("combobox", { name: /global proxy role/i }));
|
||||
await user.click(screen.getByText("User"));
|
||||
|
||||
// Select org from the dropdown
|
||||
const orgSelect = within(dialog).getByRole("combobox", { name: /organization/i });
|
||||
await user.click(orgSelect);
|
||||
await user.click(screen.getByText("My Org (org-1)"));
|
||||
|
||||
await user.click(within(dialog).getByRole("button", { name: /invite user/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -295,13 +309,15 @@ describe("CreateUserButton", { timeout: 20000 }, () => {
|
||||
organizations: ["org-1"],
|
||||
}));
|
||||
});
|
||||
// organization_ids should not be in the payload sent to the backend
|
||||
expect(mockUserCreateCall).toHaveBeenCalledWith("token", null, expect.not.objectContaining({
|
||||
organization_ids: expect.anything(),
|
||||
}));
|
||||
});
|
||||
|
||||
it("should not call organizationMemberAddCall after user creation", async () => {
|
||||
const { useOrganizations } = await import("@/app/(dashboard)/hooks/organizations/useOrganizations");
|
||||
vi.mocked(useOrganizations).mockReturnValue({
|
||||
data: [{ organization_id: "org-1", organization_alias: "My Org" }],
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
const user = userEvent.setup();
|
||||
mockUserCreateCall.mockResolvedValue({ data: { user_id: "no-member-add-user" } });
|
||||
mockInvitationCreateCall.mockResolvedValue({
|
||||
@@ -310,10 +326,8 @@ describe("CreateUserButton", { timeout: 20000 }, () => {
|
||||
has_user_setup_sso: false,
|
||||
} as any);
|
||||
|
||||
const orgIds = [{ organization_id: "org-1", organization_alias: "My Org" }];
|
||||
|
||||
renderWithProviders(
|
||||
<CreateUserButton {...defaultProps} possibleUIRoles={{ proxy_user: { ui_label: "User", description: "" } }} organizationIds={orgIds} />,
|
||||
<CreateUserButton {...defaultProps} possibleUIRoles={{ proxy_user: { ui_label: "User", description: "" } }} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { InfoCircleOutlined, UserAddOutlined } from "@ant-design/icons";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionBody,
|
||||
AccordionHeader,
|
||||
Button as Button2,
|
||||
SelectItem,
|
||||
TextInput,
|
||||
} from "@tremor/react";
|
||||
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
|
||||
import { Accordion, AccordionBody, AccordionHeader, Button as Button2, SelectItem, TextInput } from "@tremor/react";
|
||||
import { Alert, Button, Form, Input, Modal, Select, Select as Select2, Space, Tooltip, Typography } from "antd";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import BulkCreateUsers from "./bulk_create_users_button";
|
||||
import TeamDropdown from "./common_components/team_dropdown";
|
||||
import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key";
|
||||
@@ -44,7 +38,6 @@ interface CreateuserProps {
|
||||
possibleUIRoles: null | Record<string, Record<string, string>>;
|
||||
onUserCreated?: (userId: string) => void;
|
||||
isEmbedded?: boolean;
|
||||
organizationIds?: Array<{organization_id: string, organization_alias: string}> | null;
|
||||
}
|
||||
|
||||
// Define an interface for the UI settings
|
||||
@@ -56,7 +49,13 @@ interface UISettings {
|
||||
}
|
||||
|
||||
export const CreateUserButton: React.FC<CreateuserProps> = ({
|
||||
userID, accessToken, teams, possibleUIRoles, onUserCreated, isEmbedded = false, organizationIds }) => {
|
||||
userID,
|
||||
accessToken,
|
||||
teams,
|
||||
possibleUIRoles,
|
||||
onUserCreated,
|
||||
isEmbedded = false,
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [uiSettings, setUISettings] = useState<UISettings | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
@@ -66,6 +65,15 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
|
||||
const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] = useState(false);
|
||||
const [invitationLinkData, setInvitationLinkData] = useState<InvitationLink | null>(null);
|
||||
const [baseUrl, setBaseUrl] = useState<string | null>(null);
|
||||
const { data: organizations = [] } = useOrganizations();
|
||||
|
||||
// Derive teams from the user's organizations, falling back to the teams prop
|
||||
const availableTeams = useMemo(() => {
|
||||
const orgTeams = organizations.flatMap((org) => org.teams || []);
|
||||
if (orgTeams.length > 0) return orgTeams;
|
||||
return teams || [];
|
||||
}, [organizations, teams]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
@@ -99,7 +107,13 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
const handleCreate = async (formValues: { user_id: string; models?: string[]; user_role: string; organization_ids?: string[]; organizations?: string[] }) => {
|
||||
const handleCreate = async (formValues: {
|
||||
user_id: string;
|
||||
models?: string[];
|
||||
user_role: string;
|
||||
organization_ids?: string[];
|
||||
organizations?: string[];
|
||||
}) => {
|
||||
try {
|
||||
NotificationsManager.info("Making API Call");
|
||||
if (!isEmbedded) {
|
||||
@@ -166,8 +180,8 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
|
||||
message="Email invitations"
|
||||
description={
|
||||
<>
|
||||
New users receive an email invite only when an email integration (SMTP, Resend, or SendGrid) is configured.
|
||||
{" "}
|
||||
New users receive an email invite only when an email integration (SMTP, Resend, or SendGrid) is
|
||||
configured.{" "}
|
||||
<Link href="https://docs.litellm.ai/docs/proxy/email" target="_blank">
|
||||
Learn how to set up email notifications
|
||||
</Link>
|
||||
@@ -197,7 +211,7 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
|
||||
</Form.Item>
|
||||
<Form.Item label="Team" name="team_id">
|
||||
<Select placeholder="Select Team" style={{ width: "100%" }}>
|
||||
<TeamDropdown teams={teams} />
|
||||
<TeamDropdown teams={availableTeams} />
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
@@ -233,8 +247,8 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
|
||||
message="Email invitations"
|
||||
description={
|
||||
<>
|
||||
New users receive an email invite only when an email integration (SMTP, Resend, or SendGrid) is configured.
|
||||
{" "}
|
||||
New users receive an email invite only when an email integration (SMTP, Resend, or SendGrid) is
|
||||
configured.{" "}
|
||||
<Link href="https://docs.litellm.ai/docs/proxy/email" target="_blank">
|
||||
Learn how to set up email notifications
|
||||
</Link>
|
||||
@@ -264,11 +278,10 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
|
||||
{possibleUIRoles &&
|
||||
Object.entries(possibleUIRoles).map(([role, { ui_label, description }]) => (
|
||||
<SelectItem key={role} value={role} title={ui_label}>
|
||||
<Text>
|
||||
{ui_label}
|
||||
</Text>
|
||||
<Text>{ui_label}</Text>
|
||||
<Text type="secondary">
|
||||
{" - "}{description}
|
||||
{" - "}
|
||||
{description}
|
||||
</Text>
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -281,31 +294,22 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
|
||||
name="team_id"
|
||||
help="If selected, user will be added as a 'user' role to the team."
|
||||
>
|
||||
<TeamDropdown teams={teams} />
|
||||
<TeamDropdown teams={availableTeams} />
|
||||
</Form.Item>
|
||||
|
||||
{organizationIds && (
|
||||
<Form.Item
|
||||
label="Organization"
|
||||
name="organization_ids"
|
||||
initialValue={organizationIds.map((o) => o.organization_id)}
|
||||
rules={[{ required: true, message: "Please select at least one organization" }]}
|
||||
help="The user will be added to the selected organization(s)."
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="Select Organization"
|
||||
style={{ width: "100%" }}
|
||||
disabled={organizationIds.length === 1}
|
||||
>
|
||||
{organizationIds.map((org) => (
|
||||
<Option key={org.organization_id} value={org.organization_id}>
|
||||
{org.organization_alias} ({org.organization_id})
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item
|
||||
label="Organization"
|
||||
name="organization_ids"
|
||||
help="The user will be added to the selected organization(s)."
|
||||
>
|
||||
<Select mode="multiple" placeholder="Select Organization" style={{ width: "100%" }}>
|
||||
{organizations.map((org) => (
|
||||
<Option key={org.organization_id} value={org.organization_id}>
|
||||
{org.organization_alias} ({org.organization_id})
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Metadata" name="metadata">
|
||||
<Input.TextArea rows={4} placeholder="Enter metadata as JSON" />
|
||||
@@ -345,7 +349,9 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
<div style={{ textAlign: "right", marginTop: "10px" }}>
|
||||
<Button type="primary" icon={<UserAddOutlined />} htmlType="submit">Invite User</Button>
|
||||
<Button type="primary" icon={<UserAddOutlined />} htmlType="submit">
|
||||
Invite User
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
@@ -359,4 +365,4 @@ export const CreateUserButton: React.FC<CreateuserProps> = ({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -34,6 +34,7 @@ vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
|
||||
useOrganization: vi.fn(),
|
||||
useOrganizations: vi.fn().mockReturnValue({ data: [], isLoading: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/users/useCurrentUser", () => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
|
||||
import UserSearchModal from "@/components/common_components/user_search_modal";
|
||||
import {
|
||||
getGuardrailsList,
|
||||
@@ -122,6 +123,7 @@ export interface TeamInfoProps {
|
||||
accessToken: string | null;
|
||||
is_team_admin: boolean;
|
||||
is_proxy_admin: boolean;
|
||||
is_org_admin?: boolean;
|
||||
userModels: string[];
|
||||
editTeam: boolean;
|
||||
premiumUser?: boolean;
|
||||
@@ -156,6 +158,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
||||
accessToken,
|
||||
is_team_admin,
|
||||
is_proxy_admin,
|
||||
is_org_admin = false,
|
||||
userModels,
|
||||
editTeam,
|
||||
premiumUser = false,
|
||||
@@ -180,9 +183,18 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isTeamSaving, setIsTeamSaving] = useState(false);
|
||||
const [organization, setOrganization] = useState<Organization | null>(null);
|
||||
const { userRole } = useAuthorized();
|
||||
const { userRole, userId } = useAuthorized();
|
||||
const { data: userOrganizations = [] } = useOrganizations();
|
||||
|
||||
const canEditTeam = is_team_admin || is_proxy_admin;
|
||||
// Check if user is org admin for this team's organization
|
||||
const isOrgAdminForTeam = useMemo(() => {
|
||||
const teamOrgId = teamData?.team_info?.organization_id;
|
||||
if (!teamOrgId || !userId) return false;
|
||||
const org = userOrganizations.find((o) => o.organization_id === teamOrgId);
|
||||
return org?.members?.some((m: any) => m.user_id === userId && m.user_role === "org_admin") ?? false;
|
||||
}, [teamData, userOrganizations, userId]);
|
||||
|
||||
const canEditTeam = is_team_admin || is_proxy_admin || is_org_admin || isOrgAdminForTeam;
|
||||
const visibleTabs = useMemo(() => getTeamInfoVisibleTabs(canEditTeam), [canEditTeam]);
|
||||
const defaultTabKey = useMemo(
|
||||
() => getTeamInfoDefaultTab(editTeam, canEditTeam),
|
||||
|
||||
@@ -148,7 +148,7 @@ export default function TeamMemberTab({
|
||||
roleTooltip="This role applies only to this team and is independent from the user's proxy-level role."
|
||||
extraColumns={extraColumns}
|
||||
showDeleteForMember={() =>
|
||||
isProxyAdmin || (isUserTeamAdmin && !disableTeamAdminDeleteTeamUser)
|
||||
isProxyAdmin || (canEditTeam && !isUserTeamAdmin) || (isUserTeamAdmin && !disableTeamAdminDeleteTeamUser)
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -265,7 +265,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({ accessToken, toke
|
||||
orgAdminOrgIds ? orgAdminOrgIds.map((o) => o.organization_id) : null,
|
||||
);
|
||||
},
|
||||
enabled: Boolean(accessToken && token && userRole && userID && orgAdminOrgIds !== undefined),
|
||||
enabled: Boolean(accessToken && token && userRole && userID),
|
||||
placeholderData: (previousData) => previousData,
|
||||
});
|
||||
const userListResponse = userListQuery.data;
|
||||
@@ -304,7 +304,7 @@ const ViewUserDashboard: React.FC<ViewUserDashboardProps> = ({ accessToken, toke
|
||||
</>
|
||||
) : userID && accessToken ? (
|
||||
<>
|
||||
<CreateUserButton userID={userID} accessToken={accessToken} teams={teams} possibleUIRoles={possibleUIRoles} organizationIds={orgAdminOrgIds ?? null} />
|
||||
<CreateUserButton userID={userID} accessToken={accessToken} teams={teams} possibleUIRoles={possibleUIRoles} />
|
||||
|
||||
{isProxyAdmin && (
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user