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:
ryan 2026-09-19 01:02:50 +00:00
parent cda022ca68
commit f3bbeed82f
10 changed files with 233 additions and 31 deletions

View file

@ -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,
)

View file

@ -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

View file

@ -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)

View file

@ -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)}."
)
},
)

View file

@ -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={},
)

View file

@ -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.

View file

@ -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):

View file

@ -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:

View file

@ -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);
});

View file

@ -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;