mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-21 00:21:49 +00:00
feat(proxy): let team admins manage projects via team_admin_editable_team_fields
Adds a projects entry to the team_admin_editable_team_fields setting. When set, team admins (legacy admins list or members_with_roles role admin) can call /project/new and /project/update for the teams they administer. The two routes join self_managed_routes so the endpoint check runs instead of the route gate's blanket 401. /project/delete stays proxy admin only Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
cda022ca68
commit
f3bbeed82f
10 changed files with 233 additions and 31 deletions
|
|
@ -11,7 +11,7 @@ Endpoints for /project operations
|
|||
#### PROJECT MANAGEMENT ####
|
||||
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
|
@ -22,7 +22,11 @@ from litellm._uuid import uuid
|
|||
from litellm.proxy._types import *
|
||||
from litellm.proxy.auth.auth_checks import delete_cached_project_object
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.management_endpoints.common_utils import _set_object_metadata_field
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
_is_user_team_admin, # pyright: ignore[reportPrivateUsage] # shared owner of team-admin membership
|
||||
_set_object_metadata_field,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.team_admin_field_permissions import team_admin_may_manage_projects
|
||||
from litellm.proxy.management_helpers.utils import (
|
||||
management_endpoint_wrapper,
|
||||
)
|
||||
|
|
@ -82,37 +86,38 @@ async def _check_user_permission_for_project(
|
|||
user_api_key_dict: UserAPIKeyAuth,
|
||||
team_id: str | None,
|
||||
prisma_client: PrismaClient,
|
||||
general_settings: Mapping[str, object],
|
||||
require_admin: bool = False,
|
||||
team_object: LiteLLM_TeamTable | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if user has permission to manage a project.
|
||||
|
||||
Returns True if user is proxy admin or team admin (when team_id provided).
|
||||
Returns True if user is proxy admin, or a team admin of ``team_id`` when the
|
||||
``team_admin_editable_team_fields`` setting grants team admins the ``projects`` permission.
|
||||
If require_admin=True, only proxy admins are allowed.
|
||||
|
||||
If team_object is provided, it will be used instead of fetching from DB
|
||||
(avoids duplicate DB queries when team was already fetched for validation).
|
||||
"""
|
||||
is_proxy_admin = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN
|
||||
is_proxy_admin: Final = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN
|
||||
|
||||
if require_admin:
|
||||
if require_admin or is_proxy_admin:
|
||||
return is_proxy_admin
|
||||
|
||||
if is_proxy_admin:
|
||||
return True
|
||||
|
||||
if not team_id or not user_api_key_dict.user_id:
|
||||
if not team_id or not user_api_key_dict.user_id or not team_admin_may_manage_projects(general_settings):
|
||||
return False
|
||||
|
||||
team = team_object
|
||||
if team is None:
|
||||
team = await _team_table(prisma_client).find_unique(where={"team_id": team_id})
|
||||
team_row: Final = (
|
||||
team_object
|
||||
if team_object is not None
|
||||
else await _team_table(prisma_client).find_unique(where={"team_id": team_id})
|
||||
)
|
||||
if team_row is None:
|
||||
return False
|
||||
|
||||
if team and team.admins:
|
||||
return user_api_key_dict.user_id in team.admins
|
||||
|
||||
return False
|
||||
team: Final = LiteLLM_TeamTable.model_validate(team_row.model_dump())
|
||||
return _is_user_team_admin(user_api_key_dict, team) or user_api_key_dict.user_id in (team.admins or [])
|
||||
|
||||
|
||||
async def _validate_team_exists(
|
||||
|
|
@ -531,6 +536,7 @@ async def new_project(
|
|||
user_api_key_dict=user_api_key_dict,
|
||||
team_id=data.team_id,
|
||||
prisma_client=prisma_client,
|
||||
general_settings=general_settings,
|
||||
team_object=LiteLLM_TeamTable.model_validate(team_object.model_dump()),
|
||||
)
|
||||
|
||||
|
|
@ -735,6 +741,7 @@ async def update_project(
|
|||
user_api_key_dict=user_api_key_dict,
|
||||
team_id=existing_project.team_id,
|
||||
prisma_client=prisma_client,
|
||||
general_settings=general_settings,
|
||||
)
|
||||
|
||||
if not has_permission:
|
||||
|
|
@ -751,6 +758,7 @@ async def update_project(
|
|||
user_api_key_dict=user_api_key_dict,
|
||||
team_id=data.team_id,
|
||||
prisma_client=prisma_client,
|
||||
general_settings=general_settings,
|
||||
team_object=(
|
||||
LiteLLM_TeamTable.model_validate(target_team_obj.model_dump()) if target_team_obj else None
|
||||
),
|
||||
|
|
@ -877,7 +885,7 @@ async def delete_project(
|
|||
}'
|
||||
```
|
||||
"""
|
||||
from litellm.proxy.proxy_server import premium_user, prisma_client, user_api_key_cache
|
||||
from litellm.proxy.proxy_server import general_settings, premium_user, prisma_client, user_api_key_cache
|
||||
|
||||
try:
|
||||
if not premium_user:
|
||||
|
|
@ -899,6 +907,7 @@ async def delete_project(
|
|||
user_api_key_dict=user_api_key_dict,
|
||||
team_id=None,
|
||||
prisma_client=prisma_client,
|
||||
general_settings=general_settings,
|
||||
require_admin=True,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -897,6 +897,9 @@ class LiteLLMRoutes(enum.Enum):
|
|||
# Project read routes - endpoint scopes results to caller's teams (non-admin)
|
||||
"/project/list",
|
||||
"/project/info",
|
||||
# Project write routes - endpoint checks team admin + team_admin_editable_team_fields "projects"
|
||||
"/project/new",
|
||||
"/project/update",
|
||||
# Endpoint enforces proxy-admin vs team-admin model access itself.
|
||||
"/health/test_connection",
|
||||
# Invitation routes - org/team admins checked in endpoint via _user_has_admin_privileges
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
"""Proxy-wide allow-list of team-settings fields a team admin may change on /team/update."""
|
||||
"""Proxy-wide allow-list of what a team admin may do on the teams they administer: team-settings fields on
|
||||
/team/update, plus the ``projects`` permission for /project/new and /project/update."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -21,6 +22,10 @@ TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING: Final = "team_admin_editable_team_field
|
|||
|
||||
# TODO(LIT-5722): add the remaining team settings one per PR, each with its value-diff tests and dashboard field
|
||||
SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS: Final[frozenset[str]] = frozenset({"tpm_limit", "rpm_limit", "max_budget"})
|
||||
TEAM_ADMIN_PROJECTS_PERMISSION: Final = "projects"
|
||||
SUPPORTED_TEAM_ADMIN_PERMISSIONS: Final[frozenset[str]] = SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS | {
|
||||
TEAM_ADMIN_PROJECTS_PERMISSION
|
||||
}
|
||||
|
||||
_FIELD_LIST: Final = TypeAdapter(list[str])
|
||||
_JSON_OBJECT: Final = TypeAdapter(dict[str, object])
|
||||
|
|
@ -67,17 +72,23 @@ def resolve_team_admin_editable_fields(
|
|||
"%s must be a list of field names; ignoring %r", TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING, raw
|
||||
)
|
||||
return frozenset()
|
||||
unsupported: Final = configured - supported
|
||||
unsupported: Final = configured - supported - SUPPORTED_TEAM_ADMIN_PERMISSIONS
|
||||
if unsupported:
|
||||
verbose_proxy_logger.warning(
|
||||
"%s ignores unsupported field(s) %s; supported: %s",
|
||||
TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING,
|
||||
sorted(unsupported),
|
||||
sorted(supported),
|
||||
sorted(supported | SUPPORTED_TEAM_ADMIN_PERMISSIONS),
|
||||
)
|
||||
return configured & supported
|
||||
|
||||
|
||||
def team_admin_may_manage_projects(general_settings: Mapping[str, object]) -> bool:
|
||||
return TEAM_ADMIN_PROJECTS_PERMISSION in resolve_team_admin_editable_fields(
|
||||
general_settings, frozenset({TEAM_ADMIN_PROJECTS_PERMISSION})
|
||||
)
|
||||
|
||||
|
||||
def _as_object(value: object) -> Mapping[str, object]:
|
||||
try:
|
||||
return _JSON_OBJECT.validate_json(value) if isinstance(value, str) else _JSON_OBJECT.validate_python(value)
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ from litellm.proxy.config_resolvers.sso import (
|
|||
resolve_sso_config,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.team_admin_field_permissions import (
|
||||
SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS,
|
||||
SUPPORTED_TEAM_ADMIN_PERMISSIONS,
|
||||
TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING,
|
||||
)
|
||||
from litellm.proxy.spend_tracking.ptu_feature_flag import is_ptu_cost_attribution_enabled
|
||||
|
|
@ -216,7 +216,7 @@ class UIThemeSettingsResponse(SettingsResponse):
|
|||
"""Response model for UI theme settings"""
|
||||
|
||||
|
||||
_TEAM_ADMIN_FIELD_ENUM: Final = tuple(sorted(SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS))
|
||||
_TEAM_ADMIN_FIELD_ENUM: Final = tuple(sorted(SUPPORTED_TEAM_ADMIN_PERMISSIONS))
|
||||
|
||||
|
||||
class UISettings(BaseModel):
|
||||
|
|
@ -315,7 +315,8 @@ class UISettings(BaseModel):
|
|||
default=(),
|
||||
description=(
|
||||
"Team settings fields a team admin may change on the teams they administer. "
|
||||
"Empty means team admins cannot edit team settings at all. "
|
||||
"Include 'projects' to let team admins create and update projects for those teams. "
|
||||
"Empty means team admins cannot edit team settings or manage projects at all. "
|
||||
"Proxy admins and org admins are not affected."
|
||||
),
|
||||
json_schema_extra={ # mutable-ok: pydantic only merges json_schema_extra when it is a plain dict
|
||||
|
|
@ -1626,7 +1627,7 @@ async def update_ui_settings(
|
|||
raise HTTPException(status_code=422, detail=e.errors())
|
||||
|
||||
unsupported_team_fields: Final = sorted(
|
||||
frozenset(settings.team_admin_editable_team_fields) - SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS
|
||||
frozenset(settings.team_admin_editable_team_fields) - SUPPORTED_TEAM_ADMIN_PERMISSIONS
|
||||
)
|
||||
if unsupported_team_fields:
|
||||
raise HTTPException(
|
||||
|
|
@ -1634,7 +1635,7 @@ async def update_ui_settings(
|
|||
detail={ # mutable-ok: HTTPException detail must be a plain dict for FastAPI JSON serialization
|
||||
"error": (
|
||||
f"{TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING} does not support {unsupported_team_fields}. "
|
||||
f"Supported fields: {sorted(SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS)}."
|
||||
f"Supported fields: {sorted(SUPPORTED_TEAM_ADMIN_PERMISSIONS)}."
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4038,3 +4038,37 @@ def test_team_key_without_service_account_marker_still_rejected():
|
|||
valid_token=valid_token,
|
||||
request_data={},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", ["/project/new", "/project/update"])
|
||||
def test_project_write_routes_reach_endpoint_for_internal_user(route):
|
||||
"""The route gate lets a non-admin through so /project/new and /project/update can apply the
|
||||
team_admin_editable_team_fields projects permission themselves, instead of a blanket 401."""
|
||||
valid_token = UserAPIKeyAuth(user_id="team_admin", user_role=LitellmUserRoles.INTERNAL_USER.value)
|
||||
request = MagicMock(spec=Request)
|
||||
request.query_params = {}
|
||||
|
||||
RouteChecks.non_proxy_admin_allowed_routes_check(
|
||||
user_obj=None,
|
||||
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
route=route,
|
||||
request=request,
|
||||
valid_token=valid_token,
|
||||
request_data={},
|
||||
)
|
||||
|
||||
|
||||
def test_project_delete_route_stays_proxy_admin_only():
|
||||
valid_token = UserAPIKeyAuth(user_id="team_admin", user_role=LitellmUserRoles.INTERNAL_USER.value)
|
||||
request = MagicMock(spec=Request)
|
||||
request.query_params = {}
|
||||
|
||||
with pytest.raises(Exception, match="Only proxy admin can be used to generate, delete, update"):
|
||||
RouteChecks.non_proxy_admin_allowed_routes_check(
|
||||
user_obj=None,
|
||||
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
route="/project/delete",
|
||||
request=request,
|
||||
valid_token=valid_token,
|
||||
request_data={},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,12 +7,18 @@ Unit tests for the VERIA-55 fixes:
|
|||
member of.
|
||||
"""
|
||||
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.models.team import LiteLLM_TeamTable
|
||||
from litellm.proxy._types import LitellmUserRoles, Member, UserAPIKeyAuth
|
||||
|
||||
_PROJECTS_ENABLED: Final = MappingProxyType({"team_admin_editable_team_fields": ["projects"]})
|
||||
_PROJECTS_DISABLED: Final = MappingProxyType({"team_admin_editable_team_fields": ["max_budget"]})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -20,11 +26,9 @@ from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_prisma_with_team(team_id: str, admins: list):
|
||||
def _make_prisma_with_team(team_id: str, admins: list, members_with_roles: tuple[Member, ...] = ()):
|
||||
prisma = MagicMock()
|
||||
team_row = MagicMock()
|
||||
team_row.team_id = team_id
|
||||
team_row.admins = admins
|
||||
team_row = LiteLLM_TeamTable(team_id=team_id, admins=admins, members_with_roles=list(members_with_roles))
|
||||
prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team_row)
|
||||
return prisma
|
||||
|
||||
|
|
@ -49,6 +53,7 @@ async def test_project_perm_check_uses_current_team_not_caller_supplied():
|
|||
user_api_key_dict=caller,
|
||||
team_id="team-A",
|
||||
prisma_client=prisma,
|
||||
general_settings=_PROJECTS_ENABLED,
|
||||
)
|
||||
assert has_perm is False
|
||||
prisma.db.litellm_teamtable.find_unique.assert_awaited_once()
|
||||
|
|
@ -70,10 +75,105 @@ async def test_project_perm_check_allows_team_admin_of_existing_team():
|
|||
user_api_key_dict=alice,
|
||||
team_id="team-A",
|
||||
prisma_client=prisma,
|
||||
general_settings=_PROJECTS_ENABLED,
|
||||
)
|
||||
assert has_perm is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_perm_check_allows_members_with_roles_admin():
|
||||
"""Team admins added through /team/member_add live in members_with_roles, not the legacy admins list."""
|
||||
from litellm_enterprise.proxy.management_endpoints.project_endpoints import (
|
||||
_check_user_permission_for_project,
|
||||
)
|
||||
|
||||
prisma = _make_prisma_with_team(
|
||||
team_id="team-A",
|
||||
admins=[],
|
||||
members_with_roles=(Member(user_id="carol", role="admin"), Member(user_id="dave", role="user")),
|
||||
)
|
||||
carol = UserAPIKeyAuth(user_id="carol", user_role=LitellmUserRoles.INTERNAL_USER.value)
|
||||
dave = UserAPIKeyAuth(user_id="dave", user_role=LitellmUserRoles.INTERNAL_USER.value)
|
||||
|
||||
assert (
|
||||
await _check_user_permission_for_project(
|
||||
user_api_key_dict=carol, team_id="team-A", prisma_client=prisma, general_settings=_PROJECTS_ENABLED
|
||||
)
|
||||
is True
|
||||
)
|
||||
assert (
|
||||
await _check_user_permission_for_project(
|
||||
user_api_key_dict=dave, team_id="team-A", prisma_client=prisma, general_settings=_PROJECTS_ENABLED
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("general_settings", [MappingProxyType({}), _PROJECTS_DISABLED])
|
||||
async def test_project_perm_check_denies_team_admin_unless_projects_permission_configured(general_settings):
|
||||
from litellm_enterprise.proxy.management_endpoints.project_endpoints import (
|
||||
_check_user_permission_for_project,
|
||||
)
|
||||
|
||||
prisma = _make_prisma_with_team(
|
||||
team_id="team-A", admins=["alice"], members_with_roles=(Member(user_id="carol", role="admin"),)
|
||||
)
|
||||
|
||||
for user_id in ("alice", "carol"):
|
||||
caller = UserAPIKeyAuth(user_id=user_id, user_role=LitellmUserRoles.INTERNAL_USER.value)
|
||||
has_perm = await _check_user_permission_for_project(
|
||||
user_api_key_dict=caller,
|
||||
team_id="team-A",
|
||||
prisma_client=prisma,
|
||||
general_settings=general_settings,
|
||||
)
|
||||
assert has_perm is False
|
||||
prisma.db.litellm_teamtable.find_unique.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_perm_check_require_admin_denies_team_admin_even_when_configured():
|
||||
"""/project/delete passes require_admin=True, so the projects permission must not open it up."""
|
||||
from litellm_enterprise.proxy.management_endpoints.project_endpoints import (
|
||||
_check_user_permission_for_project,
|
||||
)
|
||||
|
||||
prisma = _make_prisma_with_team(team_id="team-A", admins=["alice"])
|
||||
alice = UserAPIKeyAuth(user_id="alice", user_role=LitellmUserRoles.INTERNAL_USER.value)
|
||||
|
||||
has_perm = await _check_user_permission_for_project(
|
||||
user_api_key_dict=alice,
|
||||
team_id=None,
|
||||
prisma_client=prisma,
|
||||
general_settings=_PROJECTS_ENABLED,
|
||||
require_admin=True,
|
||||
)
|
||||
assert has_perm is False
|
||||
prisma.db.litellm_teamtable.find_unique.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_perm_check_uses_injected_team_object_for_reassignment_target():
|
||||
from litellm_enterprise.proxy.management_endpoints.project_endpoints import (
|
||||
_check_user_permission_for_project,
|
||||
)
|
||||
|
||||
prisma = _make_prisma_with_team(team_id="team-A", admins=["alice"])
|
||||
alice = UserAPIKeyAuth(user_id="alice", user_role=LitellmUserRoles.INTERNAL_USER.value)
|
||||
target_team = LiteLLM_TeamTable(team_id="team-B", members_with_roles=[Member(user_id="erin", role="admin")])
|
||||
|
||||
has_perm = await _check_user_permission_for_project(
|
||||
user_api_key_dict=alice,
|
||||
team_id="team-B",
|
||||
prisma_client=prisma,
|
||||
general_settings=_PROJECTS_ENABLED,
|
||||
team_object=target_team,
|
||||
)
|
||||
assert has_perm is False
|
||||
prisma.db.litellm_teamtable.find_unique.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_perm_check_proxy_admin_always_allowed():
|
||||
from litellm_enterprise.proxy.management_endpoints.project_endpoints import (
|
||||
|
|
@ -90,6 +190,7 @@ async def test_project_perm_check_proxy_admin_always_allowed():
|
|||
user_api_key_dict=admin,
|
||||
team_id="team-A",
|
||||
prisma_client=prisma,
|
||||
general_settings=MappingProxyType({}),
|
||||
)
|
||||
assert has_perm is True
|
||||
# Admin shortcut should not even hit the DB.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from litellm.proxy.management_endpoints.team_admin_field_permissions import (
|
|||
changed_team_fields,
|
||||
resolve_team_admin_editable_fields,
|
||||
team_admin_edit_verdict,
|
||||
team_admin_may_manage_projects,
|
||||
team_admin_request_or_raise,
|
||||
)
|
||||
|
||||
|
|
@ -31,6 +32,25 @@ class TestResolveTeamAdminEditableFields:
|
|||
def test_malformed_setting_fails_closed(self, raw):
|
||||
assert resolve_team_admin_editable_fields({"team_admin_editable_team_fields": raw}, _SUPPORTED) == frozenset()
|
||||
|
||||
def test_projects_permission_is_not_a_team_field(self):
|
||||
configured = {"team_admin_editable_team_fields": ["projects", "tpm_limit"]}
|
||||
assert resolve_team_admin_editable_fields(configured, _SUPPORTED) == frozenset({"tpm_limit"})
|
||||
|
||||
|
||||
class TestTeamAdminMayManageProjects:
|
||||
def test_missing_setting_denies(self):
|
||||
assert team_admin_may_manage_projects({}) is False
|
||||
|
||||
def test_team_fields_alone_do_not_grant_projects(self):
|
||||
assert team_admin_may_manage_projects({"team_admin_editable_team_fields": ["tpm_limit", "max_budget"]}) is False
|
||||
|
||||
def test_projects_entry_grants(self):
|
||||
assert team_admin_may_manage_projects({"team_admin_editable_team_fields": ["max_budget", "projects"]}) is True
|
||||
|
||||
@pytest.mark.parametrize("raw", ["projects", 7, [1, 2]])
|
||||
def test_malformed_setting_denies(self, raw):
|
||||
assert team_admin_may_manage_projects({"team_admin_editable_team_fields": raw}) is False
|
||||
|
||||
|
||||
class TestChangedTeamFields:
|
||||
def test_team_id_alone_changes_nothing(self):
|
||||
|
|
|
|||
|
|
@ -3291,7 +3291,7 @@ class TestTeamAdminEditableTeamFieldsSetting:
|
|||
def test_patch_rejects_field_names_the_proxy_does_not_support(self, monkeypatch):
|
||||
mock_prisma = self._as_proxy_admin(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS",
|
||||
"litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.SUPPORTED_TEAM_ADMIN_PERMISSIONS",
|
||||
frozenset({"tpm_limit"}),
|
||||
)
|
||||
|
||||
|
|
@ -3336,6 +3336,26 @@ class TestTeamAdminEditableTeamFieldsSetting:
|
|||
assert stored["team_admin_editable_team_fields"] == enabled
|
||||
assert general_settings["team_admin_editable_team_fields"] == enabled
|
||||
|
||||
def test_patch_accepts_the_projects_permission_and_project_endpoints_see_it(self, monkeypatch):
|
||||
from litellm.proxy.management_endpoints.team_admin_field_permissions import (
|
||||
team_admin_may_manage_projects,
|
||||
)
|
||||
|
||||
mock_prisma = self._as_proxy_admin(monkeypatch)
|
||||
general_settings: dict = {}
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings)
|
||||
assert team_admin_may_manage_projects(general_settings) is False
|
||||
|
||||
try:
|
||||
response = client.patch("/update/ui_settings", json={"team_admin_editable_team_fields": ["projects"]})
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
stored = json.loads(mock_prisma.db.litellm_uisettings.upsert.call_args.kwargs["data"]["create"]["ui_settings"])
|
||||
assert stored["team_admin_editable_team_fields"] == ["projects"]
|
||||
assert team_admin_may_manage_projects(general_settings) is True
|
||||
|
||||
def test_patch_with_an_empty_list_turns_team_admin_editing_off_again(self, monkeypatch):
|
||||
mock_prisma = self._as_proxy_admin(monkeypatch)
|
||||
general_settings: dict = {"team_admin_editable_team_fields": ["tpm_limit"]}
|
||||
|
|
@ -3372,6 +3392,7 @@ class TestTeamAdminEditableTeamFieldsSetting:
|
|||
assert field_schema["type"] == "array"
|
||||
assert field_schema["items"]["type"] == "string"
|
||||
assert "tpm_limit" in field_schema["items"]["enum"]
|
||||
assert "projects" in field_schema["items"]["enum"]
|
||||
|
||||
|
||||
class TestSyncUiSettingsToGeneralSettings:
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ describe("teamAdminFieldLabel", () => {
|
|||
["tpm_limit", "Tokens per minute Limit (TPM)"],
|
||||
["rpm_limit", "Requests per minute Limit (RPM)"],
|
||||
["max_budget", "Max Budget (USD)"],
|
||||
["projects", "Create and update projects"],
|
||||
])("names %s the way the team settings form does", (field, label) => {
|
||||
expect(teamAdminFieldLabel(field)).toBe(label);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ const TEAM_ADMIN_FIELD_LABELS: ReadonlyMap<string, string> = new Map([
|
|||
["tpm_limit", "Tokens per minute Limit (TPM)"],
|
||||
["rpm_limit", "Requests per minute Limit (RPM)"],
|
||||
["max_budget", "Max Budget (USD)"],
|
||||
["projects", "Create and update projects"],
|
||||
]);
|
||||
|
||||
export const teamAdminFieldLabel = (field: string): string => TEAM_ADMIN_FIELD_LABELS.get(field) ?? field;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue