feat(proxy): let proxy admins choose which team fields team admins may edit
Some checks failed
LiteLLM Rust / rust-lint (push) Has been cancelled
LiteLLM Rust / rust-test (push) Has been cancelled
Terraform Modules / fmt, validate, test (aws) (push) Has been cancelled
Terraform Modules / fmt, validate, test (gcp) (push) Has been cancelled
Terraform Provider / gofmt, vet, build, test (push) Has been cancelled
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Has been cancelled

Team admins could never reach POST /team/update: the route gate answered
401 before the handler's team-admin branch ran. This moves /team/update
into the self-managed routes, resolves proxy admin, org admin or team
admin inside the handler, and filters team admins through a new
proxy-wide UI setting, team_admin_editable_team_fields. The setting is
an allow-list of team fields. Empty means team admins cannot edit team
settings and get a 403 pointing at the proxy admin, and changing a field
outside the list fails 403 naming that field. Only values that differ
from what is stored count, since the dashboard resends the whole form.

The registry of fields the setting accepts ships empty on purpose. Each
field lands in its own follow-up PR with its value diff and dashboard
wiring. The Admin UI gains a "Team admin editable fields" section under
Settings > UI and a toast on the team page while editing is disabled.

Refs LIT-5722

Claude-Session: https://claude.ai/code/session_01A6SkwJdfZUmkzfUkrEkqX8
This commit is contained in:
ryan-crabbe-berri 2026-09-05 18:08:09 -07:00
parent e8140eb269
commit 7743c3b4e2
17 changed files with 1249 additions and 100 deletions

View file

@ -826,6 +826,9 @@ class LiteLLMRoutes(enum.Enum):
)
self_managed_routes = [
# update_team resolves proxy/org/team admin itself and filters team admins
# through the team_admin_editable_team_fields setting
"/team/update",
"/team/member_add",
"/team/member_delete",
"/team/member_update",

View file

@ -0,0 +1,179 @@
"""Proxy-wide allow-list of team-settings fields a team admin may change on /team/update."""
from collections.abc import Mapping
from dataclasses import dataclass
from types import MappingProxyType
from typing import Final, Literal, TypeAlias, assert_never
from fastapi import HTTPException
from pydantic import TypeAdapter, ValidationError
from litellm._logging import verbose_proxy_logger
from litellm.models.team import LiteLLM_TeamTable
from litellm.proxy._types import (
LiteLLM_ManagementEndpoint_MetadataFields,
LiteLLM_ManagementEndpoint_MetadataFields_Premium,
UpdateTeamRequest,
)
TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING: Final = "team_admin_editable_team_fields"
# TODO(LIT-5722): stays empty until each field's value-diff and dashboard wiring lands, one field per PR
SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS: Final[frozenset[str]] = frozenset()
_FIELD_LIST: Final = TypeAdapter(list[str])
_JSON_OBJECT: Final = TypeAdapter(dict[str, object])
_EMPTY: Final[Mapping[str, object]] = MappingProxyType({})
_METADATA_FOLDED_FIELDS: Final[frozenset[str]] = frozenset(
(*LiteLLM_ManagementEndpoint_MetadataFields, *LiteLLM_ManagementEndpoint_MetadataFields_Premium)
)
_SYSTEM_MANAGED_METADATA_KEYS: Final[frozenset[str]] = frozenset({"team_member_budget_id"})
_NOT_COLUMNS: Final[frozenset[str]] = frozenset({"team_id", "metadata"})
_SETTINGS_LOCATION: Final = "Settings > UI > Team admin editable fields"
@dataclass(frozen=True, slots=True)
class TeamAdminEditAllowed:
kind: Literal["allowed"] = "allowed"
@dataclass(frozen=True, slots=True)
class TeamAdminEditingDisabled:
kind: Literal["disabled"] = "disabled"
@dataclass(frozen=True, slots=True)
class TeamAdminFieldNotPermitted:
field: str
kind: Literal["field_not_permitted"] = "field_not_permitted"
TeamAdminEditVerdict: TypeAlias = TeamAdminEditAllowed | TeamAdminEditingDisabled | TeamAdminFieldNotPermitted
def resolve_team_admin_editable_fields(
general_settings: Mapping[str, object],
supported: frozenset[str],
) -> frozenset[str]:
raw: Final = general_settings.get(TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING)
if raw is None:
return frozenset()
try:
configured: Final = frozenset(_FIELD_LIST.validate_python(raw))
except ValidationError:
verbose_proxy_logger.warning(
"%s must be a list of field names; ignoring %r", TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING, raw
)
return frozenset()
unsupported: Final = configured - supported
if unsupported:
verbose_proxy_logger.warning(
"%s ignores unsupported field(s) %s; supported: %s",
TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING,
sorted(unsupported),
sorted(supported),
)
return configured & supported
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)
except ValidationError:
return _EMPTY
def _stored_metadata(existing: Mapping[str, object]) -> Mapping[str, object]:
return _as_object(existing.get("metadata"))
def _submitted_metadata(
data: UpdateTeamRequest, submitted: Mapping[str, object], existing: Mapping[str, object]
) -> Mapping[str, object]:
"""Metadata as it would be stored: the caller's dict (or the stored one) with top-level folded fields laid over."""
base: Final = (
_as_object(submitted.get("metadata")) if "metadata" in data.model_fields_set else _stored_metadata(existing)
)
folded: Final = data.model_fields_set & _METADATA_FOLDED_FIELDS
return MappingProxyType({key: submitted[key] if key in folded else base[key] for key in base.keys() | folded})
def _metadata_changes(
data: UpdateTeamRequest, submitted: Mapping[str, object], existing: Mapping[str, object]
) -> frozenset[str]:
merged: Final = _submitted_metadata(data, submitted, existing)
stored: Final = _stored_metadata(existing)
return frozenset(
key if key in _METADATA_FOLDED_FIELDS else "metadata"
for key in (merged.keys() | stored.keys()) - _SYSTEM_MANAGED_METADATA_KEYS
if merged.get(key) != stored.get(key)
)
def _stored_model_aliases(existing_row: LiteLLM_TeamTable) -> Mapping[str, object]:
table: Final = existing_row.litellm_model_table
return _as_object(_JSON_OBJECT.validate_json(table.model_dump_json()).get("model_aliases")) if table else _EMPTY
def _column_changed(
field: str, submitted: Mapping[str, object], existing: Mapping[str, object], existing_row: LiteLLM_TeamTable
) -> bool:
if field == "model_aliases":
return _as_object(submitted.get(field)) != _stored_model_aliases(existing_row)
if field in LiteLLM_TeamTable.model_fields:
return submitted.get(field) != existing.get(field)
return True
def changed_team_fields(data: UpdateTeamRequest, existing_row: LiteLLM_TeamTable) -> frozenset[str]:
"""Logical field names whose stored value the request would change.
Request and stored row are compared as JSON values so both sides share one representation. Fields the
server folds into metadata are attributed to their own name whether they arrive top-level or inside
``metadata``; anything else in ``metadata`` is attributed to ``metadata``. Fields with no stored
counterpart on the team row count as changed whenever they are sent.
"""
submitted: Final = _JSON_OBJECT.validate_json(data.model_dump_json(exclude_unset=True))
existing: Final = _JSON_OBJECT.validate_json(existing_row.model_dump_json())
column_fields: Final = frozenset(data.model_fields_set) - _NOT_COLUMNS - _METADATA_FOLDED_FIELDS
column_changes: Final = frozenset(
field for field in column_fields if _column_changed(field, submitted, existing, existing_row)
)
return column_changes | _metadata_changes(data, submitted, existing)
def team_admin_edit_verdict(
data: UpdateTeamRequest,
existing: LiteLLM_TeamTable,
permitted: frozenset[str],
) -> TeamAdminEditVerdict:
if not permitted:
return TeamAdminEditingDisabled()
blocked: Final = sorted(changed_team_fields(data, existing) - permitted)
if blocked:
return TeamAdminFieldNotPermitted(field=blocked[0])
return TeamAdminEditAllowed()
def raise_for_team_admin_edit_verdict(verdict: TeamAdminEditVerdict) -> None:
match verdict:
case TeamAdminEditAllowed():
return
case TeamAdminEditingDisabled():
raise HTTPException(
status_code=403,
detail=(
"Team admins on this proxy cannot edit team settings. "
f"Ask a proxy admin to enable fields under {_SETTINGS_LOCATION}."
),
)
case TeamAdminFieldNotPermitted(field=field):
raise HTTPException(
status_code=403,
detail=(
f"Team admins on this proxy do not have permission to update '{field}'. "
f"Ask a proxy admin to add it under {_SETTINGS_LOCATION}."
),
)
case _:
assert_never(verdict)

View file

@ -18,7 +18,18 @@ from collections.abc import Mapping, Sequence
from collections.abc import Set as AbstractSet
from datetime import datetime, timezone
from types import MappingProxyType
from typing import TYPE_CHECKING, Annotated, Final, NamedTuple, NoReturn, Protocol, TypeVar, cast
from typing import (
TYPE_CHECKING,
Annotated,
Final,
Literal,
NamedTuple,
NoReturn,
Protocol,
TypeAlias,
TypeVar,
cast,
)
import fastapi
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
@ -114,6 +125,12 @@ from litellm.proxy.management_endpoints.organization_endpoints import (
from litellm.proxy.management_endpoints.tag_management_endpoints import (
get_daily_activity,
)
from litellm.proxy.management_endpoints.team_admin_field_permissions import (
SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS,
raise_for_team_admin_edit_verdict,
resolve_team_admin_editable_fields,
team_admin_edit_verdict,
)
from litellm.proxy.management_helpers.access_group_team_sync import (
TEAM_ADVISORY_LOCK_SQL,
AccessGroupSyncTx,
@ -428,35 +445,45 @@ async def _refresh_cached_team(
)
async def _verify_team_access(
team_obj: LiteLLM_TeamTable,
user_api_key_dict: UserAPIKeyAuth,
) -> None:
"""
Verify the caller is authorized to manage the given team.
TeamAccessRole: TypeAlias = Literal["proxy_admin", "org_admin", "team_admin"]
Access is granted if:
- Caller is a proxy admin, OR
- Caller is an org admin for the team's organization, OR
- Caller is a team admin of this team
Raises HTTPException(403) otherwise.
"""
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN:
return
if _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_obj):
return
if await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=team_obj):
return
def _raise_team_access_denied() -> NoReturn:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have access to this team",
)
async def _resolve_team_access(
team_obj: LiteLLM_TeamTable,
user_api_key_dict: UserAPIKeyAuth,
) -> TeamAccessRole | None:
"""Strongest role the caller holds over ``team_obj``, or None when they hold none.
Org admin outranks team admin so a caller holding both keeps unrestricted edits.
"""
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN:
return "proxy_admin"
if await _is_user_org_admin_for_team(user_api_key_dict=user_api_key_dict, team_obj=team_obj):
return "org_admin"
if _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_obj):
return "team_admin"
return None
async def _verify_team_access(
team_obj: LiteLLM_TeamTable,
user_api_key_dict: UserAPIKeyAuth,
) -> None:
"""Raise 403 unless the caller is a proxy admin, an org admin for the team's org, or a team admin."""
if await _resolve_team_access(team_obj=team_obj, user_api_key_dict=user_api_key_dict) is None:
_raise_team_access_denied()
class TeamMemberBudgetHandler:
"""Helper class to handle team member budget, RPM, and TPM limit operations"""
@ -1983,6 +2010,7 @@ async def update_team(
try:
from litellm.proxy.management_helpers.audit_logs import is_audit_logging_enabled
from litellm.proxy.proxy_server import (
general_settings,
litellm_proxy_admin_name,
llm_router,
prisma_client,
@ -2029,16 +2057,29 @@ async def update_team(
)
if existing_team_row is None:
# Non-proxy-admins get the same 403 as an access denial so /team/update
# cannot be used to probe which team ids exist
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
_raise_team_access_denied()
raise HTTPException(
status_code=404,
detail={"error": f"Team not found, passed team_id={data.team_id}"},
)
# Verify caller has access to manage this team
await _verify_team_access(
team_obj=LiteLLM_TeamTable.model_validate(existing_team_row.model_dump()),
user_api_key_dict=user_api_key_dict,
)
existing_team: Final = LiteLLM_TeamTable.model_validate(existing_team_row.model_dump())
access_role: Final = await _resolve_team_access(team_obj=existing_team, user_api_key_dict=user_api_key_dict)
if access_role is None:
_raise_team_access_denied()
if access_role == "team_admin":
raise_for_team_admin_edit_verdict(
team_admin_edit_verdict(
data=data,
existing=existing_team,
permitted=resolve_team_admin_editable_fields(
general_settings, SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS
),
)
)
_existing_team_metadata: Final[object] = getattr(existing_team_row, "metadata", None)
enforce_output_token_estimates_are_admin_only(

View file

@ -29,6 +29,10 @@ from litellm.proxy.config_resolvers.sso import (
SSO_SECRET_FIELDS,
resolve_sso_config,
)
from litellm.proxy.management_endpoints.team_admin_field_permissions import (
SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS,
TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING,
)
from litellm.proxy.spend_tracking.ptu_feature_flag import is_ptu_cost_attribution_enabled
from litellm.proxy.utils import invalidate_config_param
from litellm.repositories.config_repository import ConfigRepository
@ -212,6 +216,9 @@ class UIThemeSettingsResponse(SettingsResponse):
"""Response model for UI theme settings"""
_TEAM_ADMIN_FIELD_ENUM: Final = tuple(sorted(SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS))
class UISettings(BaseModel):
"""Configuration for UI-specific flags"""
@ -304,6 +311,18 @@ class UISettings(BaseModel):
description="If true, shows the Chat page in the UI sidebar, letting users chat with an LLM and connect their own MCP server credentials via OAuth.",
)
team_admin_editable_team_fields: Sequence[str] = Field(
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. "
"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
"items": {"type": "string", "enum": [*_TEAM_ADMIN_FIELD_ENUM]}, # mutable-ok: nested in the dict above
},
)
class UISettingsResponse(SettingsResponse):
"""Response model for UI settings"""
@ -326,6 +345,7 @@ ALLOWED_UI_SETTINGS_FIELDS: Final = {
"disable_custom_api_keys",
"disable_key_generate_for_org_admin",
"enable_chat_ui",
TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING,
}
ENABLE_PTU_COST_ATTRIBUTION_UI_SETTING: Final = "enable_ptu_cost_attribution"
@ -360,6 +380,7 @@ _RUNTIME_GENERAL_SETTINGS_FLAGS: Final = [
"disable_vector_stores_for_internal_users",
"allow_vector_stores_for_team_admins",
"disable_key_generate_for_org_admin",
TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING,
]
# Extension point: packages outside OSS (e.g. litellm_enterprise) can
@ -1571,6 +1592,20 @@ async def update_ui_settings(
except ValidationError as e:
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
)
if unsupported_team_fields:
raise HTTPException(
status_code=400,
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)}."
)
},
)
# Only include fields the caller actually sent (not Pydantic defaults).
settings_dict: Final[Mapping[str, JsonValue]] = settings.model_dump(exclude_unset=True)

View file

@ -9,31 +9,31 @@ pytestmark = pytest.mark.asyncio(loop_scope="session")
# POST /team/update — actor x team-shape matrix (shapes built by _seed_target).
# Each request carries the team's own organization_id so a non-proxy-admin can
# reach the org-scoped branch of the route-permission gate (401 on denial),
# which fronts the handler's _verify_team_access. Only PROXY_ADMIN and an
# ORG_ADMIN of the team's org pass: an internal_user team admin is filtered by
# the route gate before _verify_team_access's team-admin branch is reached.
# The route is self-managed (LIT-5722), so every authenticated caller reaches
# update_team and denials are the handler's 403, never the route gate's 401.
# Only PROXY_ADMIN and an ORG_ADMIN of the team's org pass: a team admin is
# admitted by _resolve_team_access but then refused because no team field is
# enabled for team admins (team_admin_editable_team_fields ships empty).
MARKER_ALIAS = "behavior-pin-update-marker-alias"
_MATRIX = [
("alpha/proxy_admin", Actor.PROXY_ADMIN, "alpha", 200),
("alpha/org_admin", Actor.ORG_ADMIN, "alpha", 200),
("alpha/team_admin", Actor.TEAM_ADMIN, "alpha", 401),
("alpha/internal_user", Actor.INTERNAL_USER, "alpha", 401),
("alpha/owner", Actor.OWNER, "alpha", 401),
("alpha/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "alpha", 401),
("alpha/cross_org_user", Actor.CROSS_ORG_USER, "alpha", 401),
("alpha/service_account", Actor.SERVICE_ACCOUNT, "alpha", 401),
("alpha/org_b_admin", Actor.ORG_B_ADMIN, "alpha", 401),
("alpha/team_admin", Actor.TEAM_ADMIN, "alpha", 403),
("alpha/internal_user", Actor.INTERNAL_USER, "alpha", 403),
("alpha/owner", Actor.OWNER, "alpha", 403),
("alpha/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "alpha", 403),
("alpha/cross_org_user", Actor.CROSS_ORG_USER, "alpha", 403),
("alpha/service_account", Actor.SERVICE_ACCOUNT, "alpha", 403),
("alpha/org_b_admin", Actor.ORG_B_ADMIN, "alpha", 403),
("beta/proxy_admin", Actor.PROXY_ADMIN, "beta", 200),
("beta/org_admin", Actor.ORG_ADMIN, "beta", 401),
("beta/team_admin", Actor.TEAM_ADMIN, "beta", 401),
("beta/internal_user", Actor.INTERNAL_USER, "beta", 401),
("beta/owner", Actor.OWNER, "beta", 401),
("beta/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "beta", 401),
("beta/cross_org_user", Actor.CROSS_ORG_USER, "beta", 401),
("beta/service_account", Actor.SERVICE_ACCOUNT, "beta", 401),
("beta/org_admin", Actor.ORG_ADMIN, "beta", 403),
("beta/team_admin", Actor.TEAM_ADMIN, "beta", 403),
("beta/internal_user", Actor.INTERNAL_USER, "beta", 403),
("beta/owner", Actor.OWNER, "beta", 403),
("beta/unrelated_same_org", Actor.UNRELATED_SAME_ORG, "beta", 403),
("beta/cross_org_user", Actor.CROSS_ORG_USER, "beta", 403),
("beta/service_account", Actor.SERVICE_ACCOUNT, "beta", 403),
("beta/org_b_admin", Actor.ORG_B_ADMIN, "beta", 200),
]
@ -110,8 +110,9 @@ async def test_team_update_org_admin_resolved_from_team_without_org_context(
):
"""With no organization_id in the body the route gate resolves the target
team's org from team_id, so an org admin of the team's own org is allowed
(200), same as PROXY_ADMIN. A team admin of that same team stays denied
(401): the resolution grants org admins access, not team admins."""
(200), same as PROXY_ADMIN. A team admin of that same team reaches the
handler but is refused (403) until a proxy admin enables fields for team
admins, and the response says so."""
await _seed_target(prisma, world, "alpha", scratch.prefix)
allowed_org_admin = await proxy_client.post(
@ -133,21 +134,25 @@ async def test_team_update_org_admin_resolved_from_team_without_org_context(
headers={"Authorization": f"Bearer {world.keys[Actor.TEAM_ADMIN].cleartext}"},
json={"team_id": scratch.prefix, "team_alias": MARKER_ALIAS},
)
assert denied_team_admin.status_code == 401, denied_team_admin.text
assert denied_team_admin.status_code == 403, denied_team_admin.text
assert "cannot edit team settings" in denied_team_admin.text, denied_team_admin.text
assert "Team admin editable fields" in denied_team_admin.text, denied_team_admin.text
# Relocation gate — moving a team to a different org. The scratch team starts
# in ORG_A; each scenario relocates it to ORG_B. PROXY_ADMIN bypasses;
# ORG_B_ADMIN clears the route gate (dest-org admin) but fails
# _verify_team_access on the source team (403); the rest fail the route gate
# (401). The relocation-*allowed* branch (caller is org admin of both orgs) is
# covered by test_team_update_org_relocation_allowed_for_dual_org_admin below.
# ORG_B_ADMIN reaches the handler but holds no role on the source team (403);
# ORG_ADMIN holds the source team but not the destination org (403 from the
# relocation gate); the team admin is refused by the empty field allow-list and
# the internal user holds no role at all (403). The relocation-*allowed* branch
# (caller is org admin of both orgs) is covered by
# test_team_update_org_relocation_allowed_for_dual_org_admin below.
_RELOCATION = [
("proxy_admin", Actor.PROXY_ADMIN, 200),
("org_b_admin", Actor.ORG_B_ADMIN, 403),
("org_admin", Actor.ORG_ADMIN, 401),
("team_admin", Actor.TEAM_ADMIN, 401),
("internal_user", Actor.INTERNAL_USER, 401),
("org_admin", Actor.ORG_ADMIN, 403),
("team_admin", Actor.TEAM_ADMIN, 403),
("internal_user", Actor.INTERNAL_USER, 403),
]

View file

@ -2814,45 +2814,53 @@ def test_team_update_gate_allows_org_admin_with_resolved_org():
)
def test_team_update_gate_rejects_without_org_context():
"""Without organization_id (i.e. resolution found no org, or a non-org-admin),
the gate still rejects /team/update the fix adds no blanket allow. Guards
against re-widening the route (e.g. dropping it into self_managed_routes)."""
def test_team_update_gate_admits_internal_user_without_org_context():
"""/team/update is self-managed (LIT-5722): the coarse gate admits any authenticated
caller and update_team resolves proxy, org or team admin itself, then filters team admins
through the team_admin_editable_team_fields setting. Before that the gate 401'd every
team admin, which left the handler's team-admin branch unreachable."""
from litellm.proxy._types import LiteLLMRoutes
assert "/team/update" in LiteLLMRoutes.self_managed_routes.value
user_obj = LiteLLM_UserTable(
user_id="team-admin-user",
user_role=LitellmUserRoles.INTERNAL_USER.value,
organization_memberships=None,
)
valid_token = UserAPIKeyAuth(user_id="team-admin-user", user_role=LitellmUserRoles.INTERNAL_USER.value)
request = MagicMock(spec=Request)
request.method = "POST"
request.query_params = {}
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=LitellmUserRoles.INTERNAL_USER.value,
route="/team/update",
request=request,
valid_token=valid_token,
request_data={"team_id": "team-1", "max_budget": 42},
)
def test_team_update_gate_defers_cross_org_admin_to_the_handler(): # test-quality-ok: the gate's only success signal is not raising; the handler's 403 it defers to is pinned in test_team_endpoints
"""An org admin of a DIFFERENT org clears the coarse gate like any internal user;
update_team's _resolve_team_access finds no role on the team and 403s (pinned in
test_team_endpoints), so there is still no cross-org escalation."""
user_obj = _make_org_admin_user("org-1")
valid_token = UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER.value)
request = MagicMock(spec=Request)
request.method = "POST"
request.query_params = {}
with pytest.raises(Exception, match="Only proxy admin can be used to generate"):
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=LitellmUserRoles.INTERNAL_USER.value,
route="/team/update",
request=request,
valid_token=valid_token,
request_data={"team_id": "team-1", "max_budget": 42},
)
def test_team_update_gate_rejects_cross_org_admin_with_resolved_org():
"""Even after the target team's org is resolved, an org admin of a DIFFERENT
org is rejected at the gate (no cross-org escalation)."""
user_obj = _make_org_admin_user("org-1")
valid_token = UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER.value)
request = MagicMock(spec=Request)
request.method = "POST"
request.query_params = {}
with pytest.raises(Exception, match="Only proxy admin can be used to generate"):
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=LitellmUserRoles.INTERNAL_USER.value,
route="/team/update",
request=request,
valid_token=valid_token,
request_data={"team_id": "team-1", "organization_id": "org-2"},
)
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=LitellmUserRoles.INTERNAL_USER.value,
route="/team/update",
request=request,
valid_token=valid_token,
request_data={"team_id": "team-1", "organization_id": "org-2"},
)
# ── PATCH /team/{team_id}: same org-context + role reach as POST /team/update ──
@ -2915,10 +2923,11 @@ async def test_add_team_org_context_noop_for_static_team_route():
assert out == body
def test_patch_team_route_has_same_reach_as_team_update():
"""/team/{team_id} is reachable by org admins (in org_admin_allowed_routes) but
NOT by regular internal users or the role-agnostic self_managed_routes the
latter would open /team/new (the collision footgun) to any authenticated user."""
def test_patch_team_route_stays_out_of_self_managed_routes():
"""Unlike POST /team/update, PATCH /team/{team_id} cannot be self-managed: its
template also matches /team/new (the collision footgun), so it stays reachable by
org admins (org_admin_allowed_routes) and proxy admins only, never by regular
internal users or through the role-agnostic self_managed_routes."""
from litellm.proxy._types import LiteLLMRoutes
assert RouteChecks.check_route_access(

View file

@ -0,0 +1,125 @@
import pytest
from fastapi import HTTPException
from litellm.proxy._types import LiteLLM_ModelTable, LiteLLM_TeamTable, UpdateTeamRequest
from litellm.proxy.management_endpoints.team_admin_field_permissions import (
TeamAdminEditAllowed,
TeamAdminEditingDisabled,
TeamAdminFieldNotPermitted,
changed_team_fields,
raise_for_team_admin_edit_verdict,
resolve_team_admin_editable_fields,
team_admin_edit_verdict,
)
_SUPPORTED = frozenset({"tpm_limit", "rpm_limit", "team_alias"})
def _team(**overrides):
return LiteLLM_TeamTable(team_id="team-1", **overrides)
class TestResolveTeamAdminEditableFields:
def test_missing_setting_means_nothing_editable(self):
assert resolve_team_admin_editable_fields({}, _SUPPORTED) == frozenset()
def test_keeps_only_supported_names(self):
configured = {"team_admin_editable_team_fields": ["tpm_limit", "blocked", "organization_id"]}
assert resolve_team_admin_editable_fields(configured, _SUPPORTED) == frozenset({"tpm_limit"})
@pytest.mark.parametrize("raw", ["tpm_limit", 7, {"tpm_limit": True}, [1, 2]])
def test_malformed_setting_fails_closed(self, raw):
assert resolve_team_admin_editable_fields({"team_admin_editable_team_fields": raw}, _SUPPORTED) == frozenset()
class TestChangedTeamFields:
def test_team_id_alone_changes_nothing(self):
assert changed_team_fields(UpdateTeamRequest(team_id="team-1"), _team()) == frozenset()
def test_column_echoing_stored_value_is_not_a_change(self):
data = UpdateTeamRequest(team_id="team-1", tpm_limit=5, team_alias="alpha", max_budget=None)
assert changed_team_fields(data, _team(tpm_limit=5, team_alias="alpha")) == frozenset()
def test_column_with_different_value_is_a_change(self):
data = UpdateTeamRequest(team_id="team-1", tpm_limit=6, team_alias="alpha")
assert changed_team_fields(data, _team(tpm_limit=5, team_alias="alpha")) == frozenset({"tpm_limit"})
def test_explicit_null_clearing_a_stored_column_is_a_change(self):
data = UpdateTeamRequest(team_id="team-1", max_budget=None)
assert changed_team_fields(data, _team(max_budget=30.0)) == frozenset({"max_budget"})
def test_folded_field_sent_top_level_is_named_not_metadata(self):
data = UpdateTeamRequest(team_id="team-1", guardrails=["b"])
assert changed_team_fields(data, _team(metadata={"guardrails": ["a"]})) == frozenset({"guardrails"})
def test_folded_field_sent_inside_metadata_is_named_not_metadata(self):
data = UpdateTeamRequest(team_id="team-1", metadata={"guardrails": ["b"]})
assert changed_team_fields(data, _team(metadata={"guardrails": ["a"]})) == frozenset({"guardrails"})
def test_custom_metadata_key_change_is_attributed_to_metadata(self):
data = UpdateTeamRequest(team_id="team-1", metadata={"guardrails": ["a"], "cost_center": "b"})
existing = _team(metadata={"guardrails": ["a"], "cost_center": "a"})
assert changed_team_fields(data, existing) == frozenset({"metadata"})
def test_metadata_echo_with_top_level_override_only_names_the_override(self):
data = UpdateTeamRequest(team_id="team-1", guardrails=["b"], metadata={"guardrails": ["a"], "cost_center": "a"})
existing = _team(metadata={"guardrails": ["a"], "cost_center": "a"})
assert changed_team_fields(data, existing) == frozenset({"guardrails"})
def test_dropping_a_stored_key_from_submitted_metadata_is_a_change(self):
data = UpdateTeamRequest(team_id="team-1", metadata={"cost_center": "a"})
existing = _team(metadata={"cost_center": "a", "tags": ["x"], "logging": [{"callback": "langfuse"}]})
assert changed_team_fields(data, existing) == frozenset({"tags", "logging"})
def test_server_managed_metadata_key_is_ignored(self):
data = UpdateTeamRequest(team_id="team-1", metadata={"cost_center": "a"})
existing = _team(metadata={"cost_center": "a", "team_member_budget_id": "budget-1"})
assert changed_team_fields(data, existing) == frozenset()
def test_model_aliases_compare_against_the_model_table(self):
table = LiteLLM_ModelTable(model_aliases='{"fast": "gpt-4o-mini"}', created_by="a", updated_by="a")
same = UpdateTeamRequest(team_id="team-1", model_aliases={"fast": "gpt-4o-mini"})
different = UpdateTeamRequest(team_id="team-1", model_aliases={"fast": "gpt-4o"})
assert changed_team_fields(same, _team(litellm_model_table=table)) == frozenset()
assert changed_team_fields(different, _team(litellm_model_table=table)) == frozenset({"model_aliases"})
def test_empty_model_aliases_against_no_model_table_is_not_a_change(self):
assert changed_team_fields(UpdateTeamRequest(team_id="team-1", model_aliases={}), _team()) == frozenset()
def test_field_without_a_stored_counterpart_counts_as_changed_when_sent(self):
data = UpdateTeamRequest(team_id="team-1", team_member_budget=10.0)
assert changed_team_fields(data, _team()) == frozenset({"team_member_budget"})
class TestTeamAdminEditVerdict:
def test_no_permitted_fields_disables_editing_even_for_a_no_op(self):
verdict = team_admin_edit_verdict(UpdateTeamRequest(team_id="team-1"), _team(), frozenset())
assert verdict == TeamAdminEditingDisabled()
def test_changes_within_permitted_fields_are_allowed(self):
data = UpdateTeamRequest(team_id="team-1", tpm_limit=6, team_alias="alpha")
verdict = team_admin_edit_verdict(data, _team(team_alias="alpha"), frozenset({"tpm_limit"}))
assert verdict == TeamAdminEditAllowed()
def test_first_blocked_field_in_sorted_order_is_reported(self):
data = UpdateTeamRequest(team_id="team-1", tpm_limit=6, rpm_limit=6, blocked=True)
verdict = team_admin_edit_verdict(data, _team(), frozenset({"tpm_limit"}))
assert verdict == TeamAdminFieldNotPermitted(field="blocked")
class TestRaiseForTeamAdminEditVerdict:
def test_allowed_does_not_raise(self):
assert raise_for_team_admin_edit_verdict(TeamAdminEditAllowed()) is None
def test_disabled_is_a_403_pointing_at_the_proxy_admin(self):
with pytest.raises(HTTPException) as exc:
raise_for_team_admin_edit_verdict(TeamAdminEditingDisabled())
assert exc.value.status_code == 403
assert "cannot edit team settings" in exc.value.detail
assert "Settings > UI > Team admin editable fields" in exc.value.detail
def test_field_not_permitted_is_a_403_naming_the_field(self):
with pytest.raises(HTTPException) as exc:
raise_for_team_admin_edit_verdict(TeamAdminFieldNotPermitted(field="blocked"))
assert exc.value.status_code == 403
assert "'blocked'" in exc.value.detail

View file

@ -1,6 +1,6 @@
import asyncio
import json
from contextlib import asynccontextmanager
from contextlib import asynccontextmanager, contextmanager
from datetime import datetime, timezone
from types import SimpleNamespace
from typing import Final, Optional, cast
@ -76,6 +76,31 @@ from litellm.types.proxy.management_endpoints.team_endpoints import (
client = TestClient(app)
@contextmanager
def _team_admin_may_edit(*fields: str):
"""Let team admins change ``fields`` on /team/update for the duration of the block.
The registry ships empty (LIT-5722 adds fields one PR at a time), so tests that exercise the
gates layered underneath the allow-list widen it here instead of asserting the early 403."""
with (
patch( # test-quality-ok: the registry is a module constant update_team reads directly; no seam to inject
"litellm.proxy.management_endpoints.team_endpoints.SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS",
frozenset(fields),
),
patch("litellm.proxy.proxy_server.general_settings", {"team_admin_editable_team_fields": list(fields)}), # test-quality-ok: update_team reads general_settings as a proxy_server module global
):
yield
def _not_org_admin():
"""update_team asks whether the caller administers the team's org before it settles for team admin;
a MagicMock prisma cannot answer that lookup, so pin it to False."""
return patch( # test-quality-ok: the org-admin lookup needs a real prisma client this file's MagicMock cannot provide
"litellm.proxy.management_endpoints.team_endpoints._is_user_org_admin_for_team",
AsyncMock(return_value=False),
)
def _wire_team_create_tx(prisma_client):
"""`/team/new` inserts the team and mirrors it onto the access groups in one transaction,
so a mocked client has to hand its team table back out of `db.tx()`.
@ -6099,6 +6124,7 @@ async def test_update_team_standalone_budget_raise_blocked_for_team_admin():
dummy_request = MagicMock(spec=Request)
with (
_team_admin_may_edit("max_budget"),
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"),
@ -6255,6 +6281,7 @@ async def test_update_team_standalone_budget_removal_blocked_for_team_admin():
dummy_request = MagicMock(spec=Request)
with (
_team_admin_may_edit("max_budget"),
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"),
@ -6324,6 +6351,7 @@ async def test_update_team_standalone_uncapped_team_admin_sets_finite_allowed(
dummy_request = MagicMock(spec=Request)
with (
_team_admin_may_edit("max_budget"),
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"),
@ -6418,6 +6446,7 @@ async def test_update_team_standalone_unchanged_budget_allowed(
dummy_request = MagicMock(spec=Request)
with (
_team_admin_may_edit("max_budget", "tpm_limit"),
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"),
@ -6516,6 +6545,7 @@ async def test_update_team_standalone_lower_budget_allowed(
dummy_request = MagicMock(spec=Request)
with (
_team_admin_may_edit("max_budget"),
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"),
@ -6618,6 +6648,8 @@ async def test_update_team_org_scoped_budget_exceeds_org_limit():
mock_org.litellm_budget_table = mock_budget_table
with (
_team_admin_may_edit("max_budget"),
_not_org_admin(),
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"),
@ -6698,6 +6730,7 @@ async def test_update_team_standalone_models_not_gated_by_user_limit(
dummy_request = MagicMock(spec=Request)
with (
_team_admin_may_edit("models"),
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"),
@ -6797,6 +6830,8 @@ async def test_update_team_org_scoped_budget_bypasses_user_limit(
mock_org.litellm_budget_table = mock_budget_table
with (
_team_admin_may_edit("max_budget"),
_not_org_admin(),
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"),
@ -6908,6 +6943,8 @@ async def test_update_team_org_scoped_models_bypasses_user_limit(
mock_org.litellm_budget_table = None
with (
_team_admin_may_edit("models"),
_not_org_admin(),
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"),
@ -7010,6 +7047,8 @@ async def test_update_team_org_scoped_models_not_in_org_models():
mock_org.litellm_budget_table = None
with (
_team_admin_may_edit("models"),
_not_org_admin(),
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"),
@ -7099,6 +7138,8 @@ async def test_update_team_org_scoped_models_with_all_proxy_models(
mock_org.litellm_budget_table = None
with (
_team_admin_may_edit("models"),
_not_org_admin(),
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"),
@ -7208,6 +7249,7 @@ async def test_update_team_tpm_limit_not_gated_by_user_limit(
dummy_request = MagicMock(spec=Request)
with (
_team_admin_may_edit("tpm_limit"),
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"),
@ -7290,6 +7332,7 @@ async def test_update_team_rpm_limit_not_gated_by_user_limit(
dummy_request = MagicMock(spec=Request)
with (
_team_admin_may_edit("rpm_limit"),
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"),
@ -7645,6 +7688,8 @@ async def test_update_team_org_scoped_tpm_exceeds_org_limit():
mock_org.litellm_budget_table = mock_budget_table
with (
_team_admin_may_edit("tpm_limit"),
_not_org_admin(),
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"),
@ -7731,6 +7776,8 @@ async def test_update_team_org_scoped_rpm_exceeds_org_limit():
mock_org.litellm_budget_table = mock_budget_table
with (
_team_admin_may_edit("rpm_limit"),
_not_org_admin(),
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"),
@ -7822,6 +7869,8 @@ async def test_update_team_org_scoped_tpm_rpm_bypasses_user_limit(
mock_org.litellm_budget_table = mock_budget_table
with (
_team_admin_may_edit("tpm_limit", "rpm_limit"),
_not_org_admin(),
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"),
@ -7950,6 +7999,7 @@ async def test_update_team_guardrails_with_org_id(
}
with (
_team_admin_may_edit("guardrails", "organization_id"),
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"),
@ -10803,8 +10853,8 @@ async def test_update_team_blocks_non_admin_passthrough_routes(mock_db_client):
mock_db_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=existing)
with patch(
"litellm.proxy.management_endpoints.team_endpoints._verify_team_access",
AsyncMock(return_value=None),
"litellm.proxy.management_endpoints.team_endpoints._resolve_team_access",
AsyncMock(return_value="org_admin"),
):
with pytest.raises(ProxyException) as exc:
await update_team(
@ -12872,6 +12922,7 @@ async def test_update_team_output_token_estimate_lowered_rejected_for_team_admin
with contextlib.ExitStack() as stack:
_wire_update_team(stack, {_TEAM_ESTIMATE: 4000})
stack.enter_context(_team_admin_may_edit("default_estimated_output_tokens"))
with pytest.raises(ProxyException) as exc:
await update_team(
data=UpdateTeamRequest(team_id="test_team_id", default_estimated_output_tokens=1),
@ -12903,6 +12954,7 @@ async def test_update_team_output_token_estimate_unchanged_allows_team_admin_edi
with contextlib.ExitStack() as stack:
prisma = _wire_update_team(stack, {_TEAM_ESTIMATE: 4000})
stack.enter_context(_team_admin_may_edit("team_alias"))
await update_team(
data=UpdateTeamRequest(
team_id="test_team_id",
@ -12962,6 +13014,7 @@ async def test_update_team_batch_enqueued_token_limit_raised_rejected_for_team_a
with contextlib.ExitStack() as stack:
_wire_update_team(stack, {_TEAM_BATCH_LIMIT: 100000})
stack.enter_context(_team_admin_may_edit("metadata"))
with pytest.raises(ProxyException) as exc:
await update_team(
data=UpdateTeamRequest(team_id="test_team_id", metadata={_TEAM_BATCH_LIMIT: 10**12}),
@ -14111,3 +14164,178 @@ async def test_get_team_spend_by_user_rejects_bad_input(mock_db_client, team_ids
assert exc_info.value.status_code == 400
assert expected_error in str(exc_info.value.detail)
mock_db_client.db.query_raw.assert_not_called()
# ---------------------------------------------------------------------------
# LIT-5722: team admins reach update_team through self_managed_routes and are
# filtered by the team_admin_editable_team_fields setting.
# ---------------------------------------------------------------------------
_TEAM_ADMIN_CALLER = UserAPIKeyAuth(
user_role=LitellmUserRoles.INTERNAL_USER, api_key="sk-team-admin", user_id="team-admin"
)
_PROXY_ADMIN_CALLER = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-admin", user_id="admin")
def _update_request_stub():
from unittest.mock import Mock
from fastapi import Request
return Mock(spec=Request)
@pytest.mark.asyncio
async def test_update_team_team_admin_is_refused_before_any_write_when_no_fields_are_enabled():
import contextlib
with contextlib.ExitStack() as stack:
prisma = _wire_update_team(stack, {})
stack.enter_context(_team_admin_may_edit())
with pytest.raises(ProxyException) as exc:
await update_team(
data=UpdateTeamRequest(team_id="test_team_id", team_alias="renamed"),
http_request=_update_request_stub(),
user_api_key_dict=_TEAM_ADMIN_CALLER,
)
assert str(exc.value.code) == "403"
assert "cannot edit team settings" in str(exc.value.message)
assert not prisma.db.litellm_teamtable.update.called
@pytest.mark.asyncio
async def test_update_team_configured_but_unsupported_field_does_not_open_editing():
"""Only fields in SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS count, whatever general_settings says."""
import contextlib
with contextlib.ExitStack() as stack:
prisma = _wire_update_team(stack, {})
stack.enter_context(
patch("litellm.proxy.proxy_server.general_settings", {"team_admin_editable_team_fields": ["team_alias"]}) # test-quality-ok: update_team reads general_settings as a proxy_server module global
)
with pytest.raises(ProxyException) as exc:
await update_team(
data=UpdateTeamRequest(team_id="test_team_id", team_alias="renamed"),
http_request=_update_request_stub(),
user_api_key_dict=_TEAM_ADMIN_CALLER,
)
assert str(exc.value.code) == "403"
assert "cannot edit team settings" in str(exc.value.message)
assert not prisma.db.litellm_teamtable.update.called
@pytest.mark.asyncio
async def test_update_team_team_admin_changing_an_unpermitted_field_is_refused_by_name():
import contextlib
with contextlib.ExitStack() as stack:
prisma = _wire_update_team(stack, {})
stack.enter_context(_team_admin_may_edit("team_alias"))
with pytest.raises(ProxyException) as exc:
await update_team(
data=UpdateTeamRequest(team_id="test_team_id", team_alias="renamed", tpm_limit=10),
http_request=_update_request_stub(),
user_api_key_dict=_TEAM_ADMIN_CALLER,
)
assert str(exc.value.code) == "403"
assert "'tpm_limit'" in str(exc.value.message)
assert not prisma.db.litellm_teamtable.update.called
@pytest.mark.asyncio
async def test_update_team_team_admin_echoing_unpermitted_fields_unchanged_is_allowed(
disable_audit_logging_for_mocked_team,
):
"""The dashboard resends the whole form, so only a value that differs from what is stored counts."""
import contextlib
with contextlib.ExitStack() as stack:
prisma = _wire_update_team(stack, {})
stack.enter_context(_team_admin_may_edit("team_alias"))
result = await update_team(
data=UpdateTeamRequest(team_id="test_team_id", team_alias="renamed", tpm_limit=None, models=[]),
http_request=_update_request_stub(),
user_api_key_dict=_TEAM_ADMIN_CALLER,
)
assert result["data"].team_id == "test_team_id"
assert prisma.db.litellm_teamtable.update.called
@pytest.mark.asyncio
async def test_update_team_org_admin_is_not_filtered_by_the_team_admin_field_list(
disable_audit_logging_for_mocked_team,
):
"""A caller who is both org admin and roster admin keeps unrestricted edits."""
import contextlib
with contextlib.ExitStack() as stack:
prisma = _wire_update_team(stack, {})
stack.enter_context(_team_admin_may_edit())
stack.enter_context(
patch( # test-quality-ok: the org-admin lookup needs a real prisma client this file's MagicMock cannot provide
"litellm.proxy.management_endpoints.team_endpoints._is_user_org_admin_for_team",
AsyncMock(return_value=True),
)
)
result = await update_team(
data=UpdateTeamRequest(team_id="test_team_id", team_alias="renamed"),
http_request=_update_request_stub(),
user_api_key_dict=_TEAM_ADMIN_CALLER,
)
assert result["data"].team_id == "test_team_id"
assert prisma.db.litellm_teamtable.update.called
@pytest.mark.asyncio
async def test_update_team_unknown_team_is_403_for_non_proxy_admins_and_404_for_proxy_admins():
"""Now that any authenticated caller reaches the handler, 'team not found' must not leak team ids."""
import contextlib
with contextlib.ExitStack() as stack:
prisma = _wire_update_team(stack, {})
prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=None)
with pytest.raises(ProxyException) as denied:
await update_team(
data=UpdateTeamRequest(team_id="no-such-team", team_alias="renamed"),
http_request=_update_request_stub(),
user_api_key_dict=_TEAM_ADMIN_CALLER,
)
with pytest.raises(ProxyException) as missing:
await update_team(
data=UpdateTeamRequest(team_id="no-such-team", team_alias="renamed"),
http_request=_update_request_stub(),
user_api_key_dict=_PROXY_ADMIN_CALLER,
)
assert str(denied.value.code) == "403"
assert "do not have access to this team" in str(denied.value.message)
assert "no-such-team" not in str(denied.value.message)
assert str(missing.value.code) == "404"
@pytest.mark.asyncio
async def test_resolve_team_access_ranks_proxy_admin_then_org_admin_then_team_admin():
from litellm.proxy.management_endpoints.team_endpoints import _resolve_team_access
team = LiteLLM_TeamTable(
team_id="team-1",
organization_id="org-1",
members_with_roles=[Member(user_id="team-admin", role="admin")],
)
roster_admin = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="team-admin")
outsider = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="someone-else")
org_lookup = AsyncMock(return_value=False)
with patch("litellm.proxy.management_endpoints.team_endpoints._is_user_org_admin_for_team", org_lookup): # test-quality-ok: the org-admin lookup needs a real prisma client this file's MagicMock cannot provide
assert await _resolve_team_access(team_obj=team, user_api_key_dict=_PROXY_ADMIN_CALLER) == "proxy_admin"
assert org_lookup.await_count == 0
assert await _resolve_team_access(team_obj=team, user_api_key_dict=roster_admin) == "team_admin"
assert await _resolve_team_access(team_obj=team, user_api_key_dict=outsider) is None
org_lookup.return_value = True
assert await _resolve_team_access(team_obj=team, user_api_key_dict=roster_admin) == "org_admin"

View file

@ -3266,3 +3266,111 @@ class TestPtuCostAttributionUISetting:
assert response.status_code == 400
assert "enable_ptu_cost_attribution" in str(response.json()["detail"])
assert not mock_prisma.db.litellm_uisettings.upsert.called
class TestTeamAdminEditableTeamFieldsSetting:
"""team_admin_editable_team_fields: the proxy-wide allow-list update_team applies to team admins."""
def _as_proxy_admin(self, monkeypatch):
from unittest.mock import AsyncMock, MagicMock
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
user_id="test-user-123",
user_role=LitellmUserRoles.PROXY_ADMIN,
)
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
mock_prisma = MagicMock()
mock_prisma.db.litellm_uisettings.upsert = AsyncMock()
mock_prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=None)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
return mock_prisma
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",
frozenset({"tpm_limit"}),
)
try:
response = client.patch(
"/update/ui_settings",
json={"team_admin_editable_team_fields": ["tpm_limit", "blocked", "organization_id"]},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 400
detail = response.json()["detail"]["error"]
assert "['blocked', 'organization_id']" in detail
assert "['tpm_limit']" in detail
assert not mock_prisma.db.litellm_uisettings.upsert.called
def test_patch_rejects_a_non_list_value(self, monkeypatch):
self._as_proxy_admin(monkeypatch)
try:
response = client.patch("/update/ui_settings", json={"team_admin_editable_team_fields": "tpm_limit"})
finally:
app.dependency_overrides.clear()
assert response.status_code == 422
def test_patch_persists_and_syncs_the_list_to_general_settings(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",
frozenset({"tpm_limit", "rpm_limit"}),
)
general_settings: dict = {"team_admin_editable_team_fields": ["rpm_limit"]}
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings)
try:
response = client.patch("/update/ui_settings", json={"team_admin_editable_team_fields": ["tpm_limit"]})
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"] == ["tpm_limit"]
assert general_settings["team_admin_editable_team_fields"] == ["tpm_limit"]
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"]}
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings)
try:
response = client.patch("/update/ui_settings", json={"team_admin_editable_team_fields": []})
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"] == []
assert general_settings["team_admin_editable_team_fields"] == []
def test_get_reports_the_stored_list_and_advertises_supported_fields(self, mock_auth, monkeypatch):
from unittest.mock import AsyncMock, MagicMock
mock_prisma = MagicMock()
mock_db_record = MagicMock()
mock_db_record.ui_settings = {"team_admin_editable_team_fields": ["tpm_limit"]}
mock_prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=mock_db_record)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
general_settings: dict = {}
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings)
response = client.get("/get/ui_settings")
assert response.status_code == 200
data = response.json()
assert data["values"]["team_admin_editable_team_fields"] == ["tpm_limit"]
assert general_settings["team_admin_editable_team_fields"] == ["tpm_limit"]
field_schema = data["field_schema"]["properties"]["team_admin_editable_team_fields"]
assert field_schema["type"] == "array"
assert field_schema["items"]["type"] == "string"
assert isinstance(field_schema["items"]["enum"], list)

View file

@ -0,0 +1,91 @@
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithProviders, screen } from "@/../tests/test-utils";
import TeamAdminEditableFieldsSettings from "./TeamAdminEditableFieldsSettings";
describe("TeamAdminEditableFieldsSettings", () => {
it("explains that nothing can be enabled when the proxy supports no fields", () => {
renderWithProviders(
<TeamAdminEditableFieldsSettings
editableFields={[]}
supportedFields={[]}
isUpdating={false}
onUpdate={vi.fn()}
/>,
);
expect(screen.getByText("Team admins cannot edit team settings")).toBeInTheDocument();
expect(screen.getByText(/does not support enabling any team settings fields/)).toBeInTheDocument();
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
});
it("renders one checkbox per supported field, checked for the enabled ones", () => {
renderWithProviders(
<TeamAdminEditableFieldsSettings
editableFields={["tpm_limit"]}
supportedFields={["max_budget", "tpm_limit"]}
description="Fields a team admin may change"
isUpdating={false}
onUpdate={vi.fn()}
/>,
);
expect(screen.getByText("1 field enabled")).toBeInTheDocument();
expect(screen.getByText("Fields a team admin may change")).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: "max_budget" })).not.toBeChecked();
expect(screen.getByRole("checkbox", { name: "tpm_limit" })).toBeChecked();
});
it("saves the list with the field added when an unchecked field is ticked", async () => {
const onUpdate = vi.fn();
const user = userEvent.setup();
renderWithProviders(
<TeamAdminEditableFieldsSettings
editableFields={["tpm_limit"]}
supportedFields={["max_budget", "tpm_limit"]}
isUpdating={false}
onUpdate={onUpdate}
/>,
);
await user.click(screen.getByRole("checkbox", { name: "max_budget" }));
expect(onUpdate).toHaveBeenCalledWith({ team_admin_editable_team_fields: ["tpm_limit", "max_budget"] });
});
it("saves the list with the field removed when a checked field is unticked", async () => {
const onUpdate = vi.fn();
const user = userEvent.setup();
renderWithProviders(
<TeamAdminEditableFieldsSettings
editableFields={["max_budget", "tpm_limit"]}
supportedFields={["max_budget", "tpm_limit"]}
isUpdating={false}
onUpdate={onUpdate}
/>,
);
await user.click(screen.getByRole("checkbox", { name: "tpm_limit" }));
expect(onUpdate).toHaveBeenCalledWith({ team_admin_editable_team_fields: ["max_budget"] });
});
it("blocks toggling while a save is in flight", async () => {
const onUpdate = vi.fn();
const user = userEvent.setup();
renderWithProviders(
<TeamAdminEditableFieldsSettings
editableFields={[]}
supportedFields={["tpm_limit"]}
isUpdating={true}
onUpdate={onUpdate}
/>,
);
await user.click(screen.getByRole("checkbox", { name: "tpm_limit" }));
expect(onUpdate).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,64 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
interface TeamAdminEditableFieldsSettingsProps {
editableFields: readonly string[];
supportedFields: readonly string[];
description?: string;
isUpdating: boolean;
onUpdate: (settings: { team_admin_editable_team_fields: string[] }) => void;
}
export default function TeamAdminEditableFieldsSettings({
editableFields,
supportedFields,
description,
isUpdating,
onUpdate,
}: TeamAdminEditableFieldsSettingsProps) {
const toggleField = (field: string, checked: boolean) => {
const next = checked ? [...editableFields, field] : editableFields.filter((item) => item !== field);
onUpdate({ team_admin_editable_team_fields: next });
};
return (
<div className="space-y-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-foreground">Team admin editable fields</p>
<Badge variant={editableFields.length > 0 ? "secondary" : "outline"}>
{editableFields.length > 0
? `${editableFields.length} field${editableFields.length !== 1 ? "s" : ""} enabled`
: "Team admins cannot edit team settings"}
</Badge>
</div>
{description && <p className="text-sm text-muted-foreground">{description}</p>}
</div>
{supportedFields.length === 0 ? (
<p className="text-xs italic text-muted-foreground">
This proxy version does not support enabling any team settings fields for team admins yet.
</p>
) : (
<div className="ml-4 space-y-2">
{supportedFields.map((field) => {
const checkboxId = `team-admin-editable-${field}`;
return (
<label key={field} htmlFor={checkboxId} className="flex cursor-pointer items-center gap-2">
<Checkbox
id={checkboxId}
checked={editableFields.includes(field)}
disabled={isUpdating}
onCheckedChange={(checked) => toggleField(field, checked === true)}
/>
<span className="text-sm text-foreground">{field}</span>
</label>
);
})}
</div>
)}
</div>
);
}

View file

@ -155,4 +155,48 @@ describe("UISettings", () => {
);
expect(toast.success).toHaveBeenCalledWith("UI settings updated successfully");
});
it("saves the team admin editable field list when a supported field is ticked", () => {
const mutateMock = vi.fn((_settings, options) => {
options?.onSuccess?.();
});
mockUseUpdateUISettings.mockReturnValue({
mutate: mutateMock,
isPending: false,
error: null,
});
mockUseUISettings.mockReturnValue(
buildSettingsResponse({
data: {
field_schema: {
properties: {
team_admin_editable_team_fields: {
description: "Team settings fields a team admin may change",
type: "array",
items: { type: "string", enum: ["tpm_limit"] },
},
},
},
values: { team_admin_editable_team_fields: [] },
},
}),
);
render(<UISettings />);
expect(screen.getByText("Team settings fields a team admin may change")).toBeInTheDocument();
act(() => {
fireEvent.click(screen.getByRole("checkbox", { name: "tpm_limit" }));
});
expect(mutateMock).toHaveBeenCalledWith(
{ team_admin_editable_team_fields: ["tpm_limit"] },
expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}),
);
expect(toast.success).toHaveBeenCalledWith("Team admin editable fields updated successfully");
});
});

View file

@ -9,7 +9,12 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import {
parseSupportedTeamAdminEditableFields,
parseTeamAdminEditableFields,
} from "@/components/team/teamAdminEditAccess";
import PageVisibilitySettings from "./PageVisibilitySettings";
import TeamAdminEditableFieldsSettings from "./TeamAdminEditableFieldsSettings";
interface SettingRowProps {
ariaLabel: string;
@ -65,6 +70,7 @@ export default function UISettings() {
const allowVectorStoresTeamAdminsProperty = schema?.properties?.allow_vector_stores_for_team_admins;
const scopeUserSearchProperty = schema?.properties?.scope_user_search_to_org;
const disableCustomApiKeysProperty = schema?.properties?.disable_custom_api_keys;
const teamAdminEditableFieldsProperty = schema?.properties?.team_admin_editable_team_fields;
const values = data?.values ?? {};
const isDisabledForInternalUsers = Boolean(values.disable_model_add_for_internal_users);
const isDisabledTeamAdminDeleteTeamUser = Boolean(values.disable_team_admin_delete_team_user);
@ -110,6 +116,17 @@ export default function UISettings() {
});
};
const handleUpdateTeamAdminEditableFields = (settings: { team_admin_editable_team_fields: string[] }) => {
updateSettings(settings, {
onSuccess: () => {
toast.success("Team admin editable fields updated successfully");
},
onError: (error) => {
toast.fromError(error);
},
});
};
const handleToggleForwardClientHeaders = (checked: boolean) => {
updateSettings(
{ forward_client_headers_to_llm_api: checked },
@ -439,6 +456,15 @@ export default function UISettings() {
isUpdating={isUpdating}
onUpdate={handleUpdatePageVisibility}
/>
<Separator />
<TeamAdminEditableFieldsSettings
editableFields={parseTeamAdminEditableFields(values)}
supportedFields={parseSupportedTeamAdminEditableFields(schema)}
description={teamAdminEditableFieldsProperty?.description}
isUpdating={isUpdating}
onUpdate={handleUpdateTeamAdminEditableFields}
/>
</div>
)}
</CardContent>

View file

@ -68,6 +68,10 @@ vi.mock("@/app/(dashboard)/hooks/teams/useTeamMetadataSchema", () => ({
useTeamMetadataSchema: vi.fn(() => ({ data: [], isLoading: false })),
}));
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
useUISettings: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({
useAllProxyModels: vi.fn(),
}));
@ -227,6 +231,7 @@ import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets";
import { useAccessGroups } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
const mockUseAllProxyModels = vi.mocked(useAllProxyModels);
const mockUseKeys = vi.mocked(useKeys);
@ -236,6 +241,7 @@ const mockUseCurrentUser = vi.mocked(useCurrentUser);
const mockUseMCPServers = vi.mocked(useMCPServers);
const mockUseMCPToolsets = vi.mocked(useMCPToolsets);
const mockUseAccessGroups = vi.mocked(useAccessGroups);
const mockUseUISettings = vi.mocked(useUISettings);
const createMockTeamData = (overrides = {}) => ({
team_id: "123",
@ -304,6 +310,10 @@ const seedDefaultMocks = () => {
isLoading: false,
isError: false,
} as any);
mockUseUISettings.mockReturnValue({
data: { values: { team_admin_editable_team_fields: [] } },
isLoading: false,
} as any);
mockUseKeys.mockReturnValue({
data: { keys: [], total_count: 0, current_page: 1, total_pages: 1 },
isPending: false,
@ -1769,6 +1779,61 @@ describe("TeamInfoView", () => {
});
});
});
describe("team admin edit access", () => {
const teamAdminProps = { ...defaultProps, is_proxy_admin: false, is_team_admin: true };
beforeEach(() => {
authState.userRole = "Internal User";
});
it("tells a team admin to ask a proxy admin when no team field is enabled for them", async () => {
const user = userEvent.setup({ delay: null });
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData());
renderWithProviders(<TeamInfoView {...teamAdminProps} />);
await user.click(await screen.findByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
expect(toast.error).toHaveBeenCalledWith("Team admins cannot edit team settings on this proxy", {
description: "Ask a proxy admin to enable fields under Settings > UI > Team admin editable fields.",
});
expect(screen.queryByLabelText("Team Name")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument();
});
it("opens the form for a team admin once a proxy admin has enabled a field", async () => {
mockUseUISettings.mockReturnValue({
data: { values: { team_admin_editable_team_fields: ["tpm_limit"] } },
isLoading: false,
} as any);
const user = userEvent.setup({ delay: null });
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData());
renderWithProviders(<TeamInfoView {...teamAdminProps} />);
await user.click(await screen.findByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
expect(await screen.findByLabelText("Team Name")).toBeInTheDocument();
expect(toast.error).not.toHaveBeenCalled();
});
it("never gates a proxy admin on the team admin field list", async () => {
authState.userRole = "Admin";
const user = userEvent.setup({ delay: null });
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData());
renderWithProviders(<TeamInfoView {...defaultProps} />);
await user.click(await screen.findByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
expect(await screen.findByLabelText("Team Name")).toBeInTheDocument();
expect(toast.error).not.toHaveBeenCalled();
});
});
});
describe("TeamInfoView - which team member fields reach the update payload depends on the open sections", () => {

View file

@ -1,6 +1,7 @@
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useCan from "@/app/(dashboard)/hooks/useCan";
import { organizationKeys, useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import { useQueryClient } from "@tanstack/react-query";
import UserSearchModal from "@/components/common_components/user_search_modal";
import {
@ -48,6 +49,11 @@ import React, { useEffect, useMemo, useState } from "react";
import { useFieldArray } from "react-hook-form";
import { z } from "zod/v4";
import GuardrailsSelect from "./GuardrailsSelect";
import {
resolveTeamEditAccess,
TEAM_ADMIN_EDITING_DISABLED_DESCRIPTION,
TEAM_ADMIN_EDITING_DISABLED_TITLE,
} from "./teamAdminEditAccess";
import { copyToClipboard as utilCopyToClipboard } from "../../utils/dataUtils";
import AccessGroupSelector from "../common_components/AccessGroupSelector";
import BudgetDurationDropdown, { NEVER_RESETS_BUDGET_DURATION } from "../common_components/budget_duration_dropdown";
@ -561,6 +567,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
const canEditTeamEstimates = isProxyAdminRole(userRole);
const teamEstimateTooltip = estimateTooltips(canEditTeamEstimates, "team");
const { data: userOrganizations = [] } = useOrganizations();
const { data: uiSettingsData } = useUISettings();
const { data: teamMetadataSchemaFields = [], isLoading: isTeamMetadataSchemaLoading } = useTeamMetadataSchema();
const queryClient = useQueryClient();
@ -604,6 +611,12 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
);
const canEditTeam = is_team_admin || is_proxy_admin || is_org_admin || isOrgAdminForTeam || isTeamAdminFromTeamData;
const editsAsTeamAdmin =
canEditTeam && !is_proxy_admin && !isProxyAdminRole(userRole) && !is_org_admin && !isOrgAdminForTeam;
const teamEditAccess = useMemo(
() => resolveTeamEditAccess(editsAsTeamAdmin, uiSettingsData?.values),
[editsAsTeamAdmin, uiSettingsData],
);
const visibleTabs = useMemo(() => getTeamInfoVisibleTabs(canEditTeam), [canEditTeam]);
const defaultTabKey = useMemo(() => getTeamInfoDefaultTab(editTeam, canEditTeam), [editTeam, canEditTeam]);
const { onTabChange, hasVisited } = useVisitedTabs(defaultTabKey);
@ -622,6 +635,15 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
setIsEditing(true);
};
const openSettingsEditor = (modelAliases: Record<string, string>) => {
if (teamEditAccess.kind === "team_admin_disabled") {
toast.error(TEAM_ADMIN_EDITING_DISABLED_TITLE, { description: TEAM_ADMIN_EDITING_DISABLED_DESCRIPTION });
return;
}
setTeamModelAliases(modelAliases);
startEditing();
};
const applyKillSwitchToGuardrails = (checked: boolean) => {
const current = form.getValues("guardrails") ?? [];
const nonGlobals = current.filter((name) => !globalGuardrailNames.has(name));
@ -1307,10 +1329,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
{canEditTeam && !isEditing && (
<Button
variant="outline"
onClick={() => {
setTeamModelAliases(info.litellm_model_table?.model_aliases ?? {});
startEditing();
}}
onClick={() => openSettingsEditor(info.litellm_model_table?.model_aliases ?? {})}
>
<Pencil />
Edit Settings

View file

@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import {
parseSupportedTeamAdminEditableFields,
parseTeamAdminEditableFields,
resolveTeamEditAccess,
} from "./teamAdminEditAccess";
describe("parseTeamAdminEditableFields", () => {
it("returns the configured list", () => {
expect(parseTeamAdminEditableFields({ team_admin_editable_team_fields: ["tpm_limit", "rpm_limit"] })).toEqual([
"tpm_limit",
"rpm_limit",
]);
});
it.each([
["no values yet", undefined],
["setting missing", {}],
["setting is null", { team_admin_editable_team_fields: null }],
["setting is a string", { team_admin_editable_team_fields: "tpm_limit" }],
["list holds a non-string", { team_admin_editable_team_fields: ["tpm_limit", 7] }],
])("fails closed to an empty list when %s", (_label, values) => {
expect(parseTeamAdminEditableFields(values)).toEqual([]);
});
});
describe("parseSupportedTeamAdminEditableFields", () => {
it("reads the enum the proxy advertises on the setting's items schema", () => {
const schema = {
properties: {
team_admin_editable_team_fields: {
type: "array",
items: { type: "string", enum: ["max_budget", "tpm_limit"] },
},
},
};
expect(parseSupportedTeamAdminEditableFields(schema)).toEqual(["max_budget", "tpm_limit"]);
});
it.each([
["schema not loaded", undefined],
["property absent", { properties: {} }],
["items has no enum", { properties: { team_admin_editable_team_fields: { items: { type: "string" } } } }],
["enum is not a string list", { properties: { team_admin_editable_team_fields: { items: { enum: [1] } } } }],
])("returns no supported fields when %s", (_label, schema) => {
expect(parseSupportedTeamAdminEditableFields(schema)).toEqual([]);
});
});
describe("resolveTeamEditAccess", () => {
it("does not restrict callers who edit as proxy or org admin", () => {
expect(resolveTeamEditAccess(false, { team_admin_editable_team_fields: [] })).toEqual({ kind: "unrestricted" });
});
it("disables editing for a team admin when no field is enabled", () => {
expect(resolveTeamEditAccess(true, { team_admin_editable_team_fields: [] })).toEqual({
kind: "team_admin_disabled",
});
expect(resolveTeamEditAccess(true, undefined)).toEqual({ kind: "team_admin_disabled" });
});
it("hands a team admin the enabled fields", () => {
expect(resolveTeamEditAccess(true, { team_admin_editable_team_fields: ["tpm_limit"] })).toEqual({
kind: "team_admin",
editableFields: new Set(["tpm_limit"]),
});
});
});

View file

@ -0,0 +1,38 @@
import { z } from "zod/v4";
export const TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING = "team_admin_editable_team_fields";
export const TEAM_ADMIN_EDITING_DISABLED_TITLE = "Team admins cannot edit team settings on this proxy";
export const TEAM_ADMIN_EDITING_DISABLED_DESCRIPTION =
"Ask a proxy admin to enable fields under Settings > UI > Team admin editable fields.";
export type TeamEditAccess =
| { readonly kind: "unrestricted" }
| { readonly kind: "team_admin"; readonly editableFields: ReadonlySet<string> }
| { readonly kind: "team_admin_disabled" };
const fieldListSchema = z.array(z.string()).catch([]);
export const parseTeamAdminEditableFields = (uiSettingsValues: unknown): readonly string[] => {
const values = z.record(z.string(), z.unknown()).catch({}).parse(uiSettingsValues);
return fieldListSchema.parse(values[TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING]);
};
export const parseSupportedTeamAdminEditableFields = (uiSettingsFieldSchema: unknown): readonly string[] => {
const property = z
.object({ properties: z.object({ [TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING]: z.object({ items: z.unknown() }) }) })
.safeParse(uiSettingsFieldSchema);
if (!property.success) return [];
const items = z
.object({ enum: z.unknown() })
.safeParse(property.data.properties[TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING].items);
return items.success ? fieldListSchema.parse(items.data.enum) : [];
};
export const resolveTeamEditAccess = (editsAsTeamAdmin: boolean, uiSettingsValues: unknown): TeamEditAccess => {
if (!editsAsTeamAdmin) return { kind: "unrestricted" };
const editableFields = parseTeamAdminEditableFields(uiSettingsValues);
return editableFields.length === 0
? { kind: "team_admin_disabled" }
: { kind: "team_admin", editableFields: new Set(editableFields) };
};