From 942a14d26a3ab06d47eea9565ef6d7a53ddbe1a2 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Thu, 9 Jul 2026 23:23:34 -0700 Subject: [PATCH] feat(team): opt-in self-service team creation via general_settings.allow_user_team_creation Add an opt-in `allow_user_team_creation` flag (default False) that lets internal users create their own standalone teams instead of asking a proxy admin. When enabled, an INTERNAL_USER may call POST /team/new for a team with no organization_id; the existing handler already makes the creator the team admin. The route gate is scoped tightly: only INTERNAL_USER, only /team/new, only when the request carries no organization_id, and only when the flag is on. Org-scoped requests still fall through to the existing org-admin path so a user cannot mint a team inside an organization they do not belong to. A self-served team is clamped to the creating user's effective limits so it can never be wider than its creator. The clamp uses the tightest of the calling key's and the underlying user's tpm/rpm caps, and the user's own max_budget; the per-session UI key budget is deliberately excluded. Values seeded by default_team_params that exceed the caller's ceiling are clamped down rather than rejected, so the feature stays usable under an admin default that is higher than a given user's cap. The flag is surfaced in the Admin UI Settings so an admin can toggle it, and the Teams page shows Create Team to internal users when it is on. The create form no longer forces a non-admin into a required-but-empty organization dropdown. --- litellm/proxy/_types.py | 4 + litellm/proxy/auth/route_checks.py | 13 + .../management_endpoints/team_endpoints.py | 45 +++ .../proxy_setting_endpoints.py | 7 + .../proxy/auth/test_route_checks.py | 106 ++++++ .../test_team_endpoints.py | 303 ++++++++++++++++++ .../test_proxy_setting_endpoints.py | 16 + .../src/components/OldTeams.test.tsx | 178 +++++++--- .../src/components/OldTeams.tsx | 20 +- .../UISettings/UISettings.test.tsx | 72 +++++ .../AdminSettings/UISettings/UISettings.tsx | 34 ++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 6 + 12 files changed, 762 insertions(+), 42 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index b6bef568637..ee97b3c5373 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2251,6 +2251,10 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase): description="sends alerts if requests hang for 5min+", ) ui_access_mode: Optional[Literal["admin_only", "all"]] = Field("all", description="Control access to the Proxy UI") + allow_user_team_creation: bool = Field( + default=False, + description="When True, internal users can call POST /team/new to create standalone teams (no organization_id). The creating user is automatically added as the team's admin, and the team inherits the caller's model/tpm/rpm restrictions for any fields left unset.", + ) allowed_routes: Optional[List] = Field(None, description="Proxy API Endpoints you want users to be able to access") reject_clientside_metadata_tags: Optional[bool] = Field( None, diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py index dd0a34a7898..8d31d07ad9e 100644 --- a/litellm/proxy/auth/route_checks.py +++ b/litellm/proxy/auth/route_checks.py @@ -288,6 +288,13 @@ class RouteChecks: request_data=request_data, request=request, ) + elif ( + _user_role == LitellmUserRoles.INTERNAL_USER.value + and route == "/team/new" + and request_data.get("organization_id") is None + and RouteChecks._user_team_creation_enabled() + ): + pass elif _user_role == LitellmUserRoles.INTERNAL_USER.value and RouteChecks.check_route_access( route=route, allowed_routes=LiteLLMRoutes.internal_user_routes.value ): @@ -326,6 +333,12 @@ class RouteChecks: else: RouteChecks._raise_admin_only_route_exception(user_obj=user_obj, route=route) + @staticmethod + def _user_team_creation_enabled() -> bool: + from litellm.proxy.proxy_server import general_settings + + return general_settings.get("allow_user_team_creation", False) is True + @staticmethod def custom_admin_only_route_check(route: str): from litellm.proxy.proxy_server import general_settings, premium_user diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 8ec7ec707a2..f8637d968f2 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -74,6 +74,7 @@ from litellm.proxy.auth.auth_checks import ( get_team_object, get_user_object, ) +from litellm.proxy.auth.route_checks import RouteChecks from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_utils.callback_utils import encrypt_callback_vars from litellm.proxy.management_endpoints.common_utils import ( @@ -784,6 +785,42 @@ async def _check_org_team_limits( ) +def _tightest_cap(*caps: Optional[float]) -> Optional[float]: + set_caps = tuple(cap for cap in caps if cap is not None) + return min(set_caps) if set_caps else None + + +def _inherit_caller_limits_for_self_served_team( + data: NewTeamRequest, + user_api_key_dict: UserAPIKeyAuth, +) -> NewTeamRequest: + """Clamp a self-served team to the creating user's effective limits. + + A self-served team must never be wider than its creator. The creator's + authority spans two layers: the calling key (`tpm_limit`/`rpm_limit`/ + `models`) and the underlying user (`user_tpm_limit`/`user_rpm_limit`/ + `user_max_budget`). The primary caller is a UI SSO session whose key + carries no tpm/rpm and only a tiny per-session `max_budget` + (`max_ui_session_budget`), so reading key limits alone would leave the team + uncapped. We take the tightest of both layers per dimension and clamp the + team down to it, filling unset fields and shrinking any value (including one + seeded by `default_team_params`) that exceeds the creator's ceiling. The + per-session key budget is deliberately excluded from the budget cap; only + the user's own `max_budget` bounds the team. + """ + effective_tpm = _tightest_cap(user_api_key_dict.tpm_limit, user_api_key_dict.user_tpm_limit) + effective_rpm = _tightest_cap(user_api_key_dict.rpm_limit, user_api_key_dict.user_rpm_limit) + effective_budget = user_api_key_dict.user_max_budget + return data.model_copy( + update={ + "models": data.models if data.models else list(user_api_key_dict.models), + "tpm_limit": _tightest_cap(data.tpm_limit, effective_tpm), + "rpm_limit": _tightest_cap(data.rpm_limit, effective_rpm), + "max_budget": _tightest_cap(data.max_budget, effective_budget), + } + ) + + async def _check_user_team_limits( data: Union[NewTeamRequest, UpdateTeamRequest], user_api_key_dict: UserAPIKeyAuth, @@ -1115,6 +1152,14 @@ async def new_team( # Only validate user budget/models/tpm/rpm for standalone teams (not org-scoped) # For org-scoped teams, validation is done by _check_org_team_limits() if data.organization_id is None: + if ( + user_api_key_dict.user_role == LitellmUserRoles.INTERNAL_USER + and RouteChecks._user_team_creation_enabled() + ): + data = _inherit_caller_limits_for_self_served_team( + data=data, + user_api_key_dict=user_api_key_dict, + ) await _check_user_team_limits( data=data, user_api_key_dict=user_api_key_dict, diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index a8926d26047..ace07b5b9d5 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -182,6 +182,11 @@ 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.", ) + allow_user_team_creation: bool = Field( + default=False, + description="If true, internal users can create their own standalone teams via POST /team/new and the Teams page. The creating user is automatically added as the team's admin, and the team inherits the caller's model/tpm/rpm restrictions for any fields left unset.", + ) + class UISettingsResponse(SettingsResponse): """Response model for UI settings""" @@ -206,6 +211,7 @@ ALLOWED_UI_SETTINGS_FIELDS = { "disable_custom_api_keys", "disable_key_generate_for_org_admin", "enable_chat_ui", + "allow_user_team_creation", } # Flags that must be synced from the persisted UISettings into @@ -219,6 +225,7 @@ _RUNTIME_GENERAL_SETTINGS_FLAGS = [ "disable_vector_stores_for_internal_users", "allow_vector_stores_for_team_admins", "disable_key_generate_for_org_admin", + "allow_user_team_creation", ] # Extension point: packages outside OSS (e.g. litellm_enterprise) can diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index 204e6a671e3..d88d49ef0a9 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -2926,3 +2926,109 @@ def test_internal_user_blocked_from_search_tool_writes(route): assert "Only proxy admin" in str(exc_info.value) assert f"Route={route}" in str(exc_info.value) assert "Your role=internal_user" in str(exc_info.value) + + +def _self_serve_team_new_check( + route: str, + general_settings: dict, + role: str = LitellmUserRoles.INTERNAL_USER.value, + request_data: dict | None = None, +): + """Run non_proxy_admin_allowed_routes_check for the LIT-3254 self-serve + team creation gate with the given general_settings patched in.""" + user_obj = LiteLLM_UserTable( + user_id="self-serve-user", + user_email="user@example.com", + user_role=role, + ) + valid_token = UserAPIKeyAuth( + user_id="self-serve-user", + user_role=role, + ) + request = MagicMock(spec=Request) + request.query_params = {} + request.method = "POST" + with ( + patch("litellm.proxy.proxy_server.general_settings", general_settings), + patch("litellm.proxy.proxy_server.premium_user", True), + ): + return RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=role, + route=route, + request=request, + valid_token=valid_token, + request_data=request_data if request_data is not None else {}, + ) + + +@pytest.mark.parametrize( + "general_settings", + [{}, {"allow_user_team_creation": False}, {"allow_user_team_creation": "yes"}], +) +def test_internal_user_team_new_blocked_unless_flag_enabled(general_settings): + """LIT-3254: /team/new stays admin-only when allow_user_team_creation is + absent, False, or any non-boolean truthy value.""" + with pytest.raises(Exception) as exc_info: + _self_serve_team_new_check("/team/new", general_settings) + assert "Only proxy admin" in str(exc_info.value) + + +@pytest.mark.parametrize( + "request_data", + [{}, {"organization_id": None}, {"team_alias": "my-team"}], +) +def test_internal_user_team_new_allowed_when_flag_enabled(request_data): + """LIT-3254: with the flag on, an internal user may create a standalone + team (no organization_id in the request body).""" + result = _self_serve_team_new_check( + "/team/new", + {"allow_user_team_creation": True}, + request_data=request_data, + ) + assert result is None + + +def test_internal_user_team_new_with_organization_id_blocked_despite_flag(): + """LIT-3254 cross-org bypass guard: the self-serve gate must not admit + org-scoped requests. new_team only checks that the organization row + exists, not that the caller belongs to it, so an org-scoped request from + a non-org-admin must keep failing at the route layer.""" + with pytest.raises(Exception) as exc_info: + _self_serve_team_new_check( + "/team/new", + {"allow_user_team_creation": True}, + request_data={"organization_id": "org-i-dont-belong-to"}, + ) + assert "Only proxy admin" in str(exc_info.value) + + +@pytest.mark.parametrize( + "route", + ["/team/delete", "/team/update", "/team/block", "/user/new", "/organization/new"], +) +def test_flag_scoped_to_team_new_only(route): + """LIT-3254: the flag opens exactly one route. Every other management + route must keep raising for an internal user even with the flag on.""" + with pytest.raises(Exception) as exc_info: + _self_serve_team_new_check(route, {"allow_user_team_creation": True}) + assert "Only proxy admin" in str(exc_info.value) + + +@pytest.mark.parametrize( + "role", + [ + LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value, + LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, + LitellmUserRoles.TEAM.value, + ], +) +def test_flag_scoped_to_internal_user_role_only(role): + """LIT-3254: view-only and team-scoped roles must stay blocked from + /team/new regardless of allow_user_team_creation.""" + with pytest.raises(Exception): + _self_serve_team_new_check( + "/team/new", + {"allow_user_team_creation": True}, + role=role, + ) diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index 180fb1d3d8f..c9d7a7ea6b1 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -9535,3 +9535,306 @@ async def test_new_team_rejects_reserved_ui_session_team_id(): assert exc_info.value.code == "400" assert "reserved" in str(exc_info.value.message) mock_prisma.get_data.assert_not_called() + + +class TestSelfServeTeamLimitInheritance: + """LIT-3254: _inherit_caller_limits_for_self_served_team fills unset team + fields from the caller so a self-served team can never be wider than its + creator.""" + + def _caller(self, **kwargs): + from litellm.proxy._types import UserAPIKeyAuth + + return UserAPIKeyAuth(api_key="sk-test", user_id="u1", **kwargs) + + def test_inherits_models_when_unset_or_empty(self): + from litellm.proxy._types import NewTeamRequest + from litellm.proxy.management_endpoints.team_endpoints import ( + _inherit_caller_limits_for_self_served_team, + ) + + caller = self._caller(models=["gpt-5", "gpt-5-mini"]) + result = _inherit_caller_limits_for_self_served_team( + data=NewTeamRequest(team_alias="t", models=[]), + user_api_key_dict=caller, + ) + assert result.models == ["gpt-5", "gpt-5-mini"] + + def test_inherits_key_tpm_and_rpm_when_unset(self): + from litellm.proxy._types import NewTeamRequest + from litellm.proxy.management_endpoints.team_endpoints import ( + _inherit_caller_limits_for_self_served_team, + ) + + caller = self._caller(tpm_limit=1000, rpm_limit=10) + result = _inherit_caller_limits_for_self_served_team( + data=NewTeamRequest(team_alias="t"), + user_api_key_dict=caller, + ) + assert result.tpm_limit == 1000 + assert result.rpm_limit == 10 + + def test_inherits_user_level_limits_when_key_has_none(self): + """Finding 7: a UI SSO session key carries no tpm/rpm and only a tiny + per-session budget, so the team must inherit the USER-level caps or it + is created uncapped despite a rate/budget-limited creator.""" + from litellm.proxy._types import NewTeamRequest + from litellm.proxy.management_endpoints.team_endpoints import ( + _inherit_caller_limits_for_self_served_team, + ) + + caller = self._caller( + tpm_limit=None, + rpm_limit=None, + max_budget=0.25, # ephemeral UI-session budget, must NOT propagate + user_tpm_limit=100, + user_rpm_limit=5, + user_max_budget=10.0, + ) + result = _inherit_caller_limits_for_self_served_team( + data=NewTeamRequest(team_alias="t"), + user_api_key_dict=caller, + ) + assert result.tpm_limit == 100 + assert result.rpm_limit == 5 + assert result.max_budget == 10.0 + + def test_uses_tightest_of_key_and_user_limits(self): + from litellm.proxy._types import NewTeamRequest + from litellm.proxy.management_endpoints.team_endpoints import ( + _inherit_caller_limits_for_self_served_team, + ) + + caller = self._caller(tpm_limit=100, user_tpm_limit=1000, rpm_limit=50, user_rpm_limit=5) + result = _inherit_caller_limits_for_self_served_team( + data=NewTeamRequest(team_alias="t"), + user_api_key_dict=caller, + ) + assert result.tpm_limit == 100 + assert result.rpm_limit == 5 + + def test_clamps_over_cap_values_down_to_caller(self): + """A value seeded by default_team_params (or an explicit over-request) + that exceeds the creator's ceiling is clamped, not rejected — so the + feature stays usable when an admin default is higher than a user cap.""" + from litellm.proxy._types import NewTeamRequest + from litellm.proxy.management_endpoints.team_endpoints import ( + _inherit_caller_limits_for_self_served_team, + ) + + caller = self._caller(user_tpm_limit=100, user_rpm_limit=5, user_max_budget=10.0) + result = _inherit_caller_limits_for_self_served_team( + data=NewTeamRequest(team_alias="t", tpm_limit=100000, rpm_limit=9999, max_budget=1000.0), + user_api_key_dict=caller, + ) + assert result.tpm_limit == 100 + assert result.rpm_limit == 5 + assert result.max_budget == 10.0 + + def test_under_cap_values_preserved(self): + from litellm.proxy._types import NewTeamRequest + from litellm.proxy.management_endpoints.team_endpoints import ( + _inherit_caller_limits_for_self_served_team, + ) + + caller = self._caller(models=["a", "b"], user_tpm_limit=5000, user_rpm_limit=100, user_max_budget=100.0) + result = _inherit_caller_limits_for_self_served_team( + data=NewTeamRequest(team_alias="t", models=["a"], tpm_limit=1000, rpm_limit=50, max_budget=25.0), + user_api_key_dict=caller, + ) + assert result.models == ["a"] + assert result.tpm_limit == 1000 + assert result.rpm_limit == 50 + assert result.max_budget == 25.0 + + def test_unrestricted_caller_leaves_team_unrestricted(self): + from litellm.proxy._types import NewTeamRequest + from litellm.proxy.management_endpoints.team_endpoints import ( + _inherit_caller_limits_for_self_served_team, + ) + + caller = self._caller( + models=[], + tpm_limit=None, + rpm_limit=None, + user_tpm_limit=None, + user_rpm_limit=None, + user_max_budget=None, + ) + result = _inherit_caller_limits_for_self_served_team( + data=NewTeamRequest(team_alias="t"), + user_api_key_dict=caller, + ) + assert result.models == [] + assert result.tpm_limit is None + assert result.rpm_limit is None + assert result.max_budget is None + + def test_session_key_budget_not_propagated(self): + """The tiny per-session key budget must never become the team budget; + only the user's own max_budget bounds the team.""" + from litellm.proxy._types import NewTeamRequest + from litellm.proxy.management_endpoints.team_endpoints import ( + _inherit_caller_limits_for_self_served_team, + ) + + caller = self._caller(max_budget=0.25, user_max_budget=None) + result = _inherit_caller_limits_for_self_served_team( + data=NewTeamRequest(team_alias="t"), + user_api_key_dict=caller, + ) + assert result.max_budget is None + + +@pytest.mark.parametrize( + "flag_enabled,caller_role,expect_inherited", + [ + (True, LitellmUserRoles.INTERNAL_USER, True), + (False, LitellmUserRoles.INTERNAL_USER, False), + (True, LitellmUserRoles.ORG_ADMIN, False), + ], +) +@pytest.mark.asyncio +async def test_new_team_self_serve_inheritance_call_site(flag_enabled, caller_role, expect_inherited): + """LIT-3254: new_team applies caller-limit inheritance exactly when + allow_user_team_creation is on AND the caller is an INTERNAL_USER. + Org admins and flag-off callers keep today's behavior (an omitted models + list stays empty).""" + from fastapi import Request + + from litellm.proxy._types import NewTeamRequest, UserAPIKeyAuth + from litellm.proxy.management_endpoints.team_endpoints import new_team + + caller = UserAPIKeyAuth( + user_role=caller_role, + user_id="self-serve-user-1", + models=["gpt-5"], + tpm_limit=1000, + rpm_limit=10, + ) + team_request = NewTeamRequest(team_alias="self-serve-team") + dummy_request = MagicMock(spec=Request) + + with ( + 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._license_check") as mock_license, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch("litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock()), + patch( + "litellm.proxy.proxy_server.general_settings", + {"allow_user_team_creation": flag_enabled}, + ), + ): + mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0) + mock_license.is_team_count_over_limit.return_value = False + mock_prisma.jsonify_team_object = lambda db_data: db_data + mock_prisma.get_data = AsyncMock(return_value=None) + mock_prisma.update_data = AsyncMock() + mock_cache.async_get_cache = AsyncMock(return_value=None) + + mock_created_team = MagicMock() + mock_created_team.team_id = "team-self-serve-1" + mock_created_team.members_with_roles = [] + mock_created_team.metadata = None + mock_created_team.default_team_member_models = None + mock_created_team.model_dump.return_value = {"team_id": "team-self-serve-1"} + mock_prisma.db.litellm_teamtable.create = AsyncMock(return_value=mock_created_team) + mock_prisma.db.litellm_teamtable.update = AsyncMock(return_value=mock_created_team) + mock_prisma.db.litellm_modeltable = MagicMock() + mock_prisma.db.litellm_modeltable.create = AsyncMock(return_value=MagicMock(id="model123")) + + mock_user = MagicMock() + mock_user.user_id = "self-serve-user-1" + mock_user.model_dump.return_value = { + "user_id": "self-serve-user-1", + "teams": ["team-self-serve-1"], + } + mock_prisma.db.litellm_usertable = MagicMock() + mock_prisma.db.litellm_usertable.upsert = AsyncMock(return_value=mock_user) + mock_prisma.db.litellm_usertable.update = AsyncMock(return_value=mock_user) + + mock_membership = MagicMock() + mock_membership.model_dump.return_value = { + "team_id": "team-self-serve-1", + "user_id": "self-serve-user-1", + "budget_id": None, + } + mock_prisma.db.litellm_teammembership = MagicMock() + mock_prisma.db.litellm_teammembership.create = AsyncMock(return_value=mock_membership) + + await new_team( + data=team_request, + http_request=dummy_request, + user_api_key_dict=caller, + ) + + created_row = mock_prisma.db.litellm_teamtable.create.call_args.kwargs["data"] + if expect_inherited: + assert created_row["models"] == ["gpt-5"] + assert created_row["tpm_limit"] == 1000 + assert created_row["rpm_limit"] == 10 + else: + assert created_row["models"] == [] + assert created_row.get("tpm_limit") is None + assert created_row.get("rpm_limit") is None + + +@pytest.mark.asyncio +async def test_new_team_self_serve_creator_becomes_team_admin(): + """LIT-3254: a self-served team must include its creator as a team admin + in members_with_roles.""" + from fastapi import Request + + from litellm.proxy._types import NewTeamRequest, UserAPIKeyAuth + from litellm.proxy.management_endpoints.team_endpoints import new_team + + caller = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + user_id="self-serve-user-2", + models=[], + ) + team_request = NewTeamRequest(team_alias="self-serve-admin-team") + dummy_request = MagicMock(spec=Request) + + with ( + 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._license_check") as mock_license, + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + patch("litellm.proxy.proxy_server.create_audit_log_for_update", new=AsyncMock()), + patch( + "litellm.proxy.proxy_server.general_settings", + {"allow_user_team_creation": True}, + ), + patch( + "litellm.proxy.management_endpoints.team_endpoints._add_team_members_to_team", + new=AsyncMock(), + ) as mock_add_members, + ): + mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0) + mock_license.is_team_count_over_limit.return_value = False + mock_prisma.jsonify_team_object = lambda db_data: db_data + mock_prisma.get_data = AsyncMock(return_value=None) + mock_prisma.update_data = AsyncMock() + mock_cache.async_get_cache = AsyncMock(return_value=None) + + mock_created_team = MagicMock() + mock_created_team.team_id = "team-self-serve-2" + mock_created_team.members_with_roles = [] + mock_created_team.metadata = None + mock_created_team.default_team_member_models = None + mock_created_team.model_dump.return_value = {"team_id": "team-self-serve-2"} + mock_prisma.db.litellm_teamtable.create = AsyncMock(return_value=mock_created_team) + mock_prisma.db.litellm_teamtable.update = AsyncMock(return_value=mock_created_team) + mock_prisma.db.litellm_modeltable = MagicMock() + mock_prisma.db.litellm_modeltable.create = AsyncMock(return_value=MagicMock(id="model123")) + + await new_team( + data=team_request, + http_request=dummy_request, + user_api_key_dict=caller, + ) + + add_request = mock_add_members.call_args.kwargs["data"] + assert [(m.user_id, m.role) for m in add_request.member] == [("self-serve-user-2", "admin")] diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index 69845ec59c2..2986b8f29ec 100644 --- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -2393,3 +2393,19 @@ def test_update_mcp_semantic_filter_settings_requires_proxy_admin(monkeypatch): assert "proxy admin" in resp.json()["detail"].lower() finally: app.dependency_overrides.pop(user_api_key_auth, None) + + +def test_allow_user_team_creation_is_wired_into_ui_settings_sync(): + """LIT-3254: the allow_user_team_creation UI toggle only reaches the + /team/new route gate through the general_settings runtime sync. If the + flag drops out of either list, the DB-persisted toggle silently stops + working while the UI still shows it as enabled.""" + from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ( + ALLOWED_UI_SETTINGS_FIELDS, + _RUNTIME_GENERAL_SETTINGS_FLAGS, + UISettings, + ) + + assert "allow_user_team_creation" in ALLOWED_UI_SETTINGS_FIELDS + assert "allow_user_team_creation" in _RUNTIME_GENERAL_SETTINGS_FLAGS + assert UISettings.model_fields["allow_user_team_creation"].default is False diff --git a/ui/litellm-dashboard/src/components/OldTeams.test.tsx b/ui/litellm-dashboard/src/components/OldTeams.test.tsx index 0b6e5786aaf..b0ddd417290 100644 --- a/ui/litellm-dashboard/src/components/OldTeams.test.tsx +++ b/ui/litellm-dashboard/src/components/OldTeams.test.tsx @@ -3,8 +3,8 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" import React from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { fetchAvailableModelsForTeamOrKey } from "./key_team_helpers/fetch_available_models_team_key"; -import { fetchMCPAccessGroups, getGuardrailsList, teamCreateCall } from "./networking"; -import OldTeams from "./OldTeams"; +import { fetchMCPAccessGroups, getGuardrailsList, teamCreateCall, type Organization } from "./networking"; +import OldTeams, { canCreateOrManageTeams } from "./OldTeams"; import { teamListCall } from "@/app/(dashboard)/hooks/teams/useTeams"; const mockTeamInfoView = vi.fn(); @@ -95,6 +95,12 @@ vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ useOrganizations: () => mockUseOrganizations(), })); +const mockUseUISettings = vi.fn(); + +vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ + useUISettings: () => mockUseUISettings() ?? { data: undefined }, +})); + vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroups", () => ({ useAccessGroups: vi.fn().mockReturnValue({ data: [ @@ -131,6 +137,17 @@ const renderWithQueryClient = (component: React.ReactElement) => { return render({component}); }; +const emptyTeamListResult = { teams: [], total: 0, page: 1, page_size: 100, total_pages: 0 }; +const selfServeCreatedTeam = { + team_id: "new-team-1", + team_alias: "My Self Serve Team", + models: ["gpt-4"], + organization_id: null, + keys: [], + members_with_roles: [], + spend: 0, +}; + describe("OldTeams - handleCreate organization handling", () => { beforeEach(() => { vi.clearAllMocks(); @@ -529,48 +546,39 @@ describe("OldTeams - helper functions", () => { }); describe("canCreateOrManageTeams", () => { - it("should return true for Admin role", () => { - const userRole = "Admin"; - const result = userRole === "Admin"; - expect(result).toBe(true); + const orgWithOrgAdmin = { + organization_id: "org-1", + organization_alias: "Org 1", + models: [], + members: [{ user_id: "user-123", user_role: "org_admin" }], + } as unknown as Organization; + + const orgWithPlainMember = { + organization_id: "org-1", + organization_alias: "Org 1", + models: [], + members: [{ user_id: "user-123", user_role: "member" }], + } as unknown as Organization; + + it("returns true for Admin role regardless of the self-serve flag", () => { + expect(canCreateOrManageTeams("Admin", "user-123", null, false)).toBe(true); }); - it("should return true for org_admin in any organization", () => { - const userID = "user-123"; - const organizations = [ - { - organization_id: "org-1", - organization_alias: "Org 1", - models: [], - members: [{ user_id: "user-123", user_role: "org_admin" }], - }, - ]; - - const result = organizations.some((org) => - org.members?.some((member) => member.user_id === userID && member.user_role === "org_admin"), - ); - - expect(result).toBe(true); + it("returns true for org_admin in any organization regardless of the self-serve flag", () => { + expect(canCreateOrManageTeams("Internal User", "user-123", [orgWithOrgAdmin], false)).toBe(true); }); - it("should return false when user has no admin permissions", () => { - const userID = "user-123"; - const userRole: string = "User"; - const organizations = [ - { - organization_id: "org-1", - organization_alias: "Org 1", - models: [], - members: [{ user_id: "user-123", user_role: "member" }], - }, - ]; + it("returns true for Internal User when allow_user_team_creation is on", () => { + expect(canCreateOrManageTeams("Internal User", "user-123", null, true)).toBe(true); + }); - const isAdmin = userRole === "Admin"; - const isOrgAdmin = organizations.some((org) => - org.members?.some((member) => member.user_id === userID && member.user_role === "org_admin"), - ); + it("returns false for Internal User when allow_user_team_creation is off", () => { + expect(canCreateOrManageTeams("Internal User", "user-123", [orgWithPlainMember], false)).toBe(false); + }); - expect(isAdmin || isOrgAdmin).toBe(false); + it("does not extend the self-serve flag to viewer roles", () => { + expect(canCreateOrManageTeams("Internal Viewer", "user-123", null, true)).toBe(false); + expect(canCreateOrManageTeams("Admin Viewer", "user-123", null, true)).toBe(false); }); }); }); @@ -1146,3 +1154,97 @@ describe("OldTeams - LIT-2530 organization stays optional for proxy admin with a }); }); }); + +describe("OldTeams - self-serve team creation (LIT-3254)", () => { + beforeEach(() => { + mockTeamInfoView.mockClear(); + vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue([]); + vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]); + vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] }); + vi.mocked(teamListCall).mockResolvedValue(emptyTeamListResult); + mockUseOrganizations.mockReturnValue({ data: [] }); + }); + + it("shows the Create Team button to an Internal User when allow_user_team_creation is on", async () => { + mockUseUISettings.mockReturnValue({ data: { values: { allow_user_team_creation: true } } }); + + renderWithQueryClient(); + + const buttons = await screen.findAllByTestId("create-team-button"); + expect(buttons.length).toBeGreaterThan(0); + }); + + it("hides the Create Team button from an Internal User when allow_user_team_creation is off", async () => { + mockUseUISettings.mockReturnValue({ data: { values: { allow_user_team_creation: false } } }); + + renderWithQueryClient(); + + await waitFor(() => expect(screen.getAllByText("Teams").length).toBeGreaterThan(0)); + expect(screen.queryByTestId("create-team-button")).not.toBeInTheDocument(); + }); + + it("omits the Organization selector for a self-serve Internal User with no admin orgs", async () => { + mockUseUISettings.mockReturnValue({ data: { values: { allow_user_team_creation: true } } }); + + renderWithQueryClient(); + + const buttons = await screen.findAllByTestId("create-team-button"); + fireEvent.click(buttons[0]); + + await waitFor(() => expect(screen.getByTestId("team-name-input")).toBeInTheDocument()); + expect(document.getElementById("organization_id")).toBeNull(); + }); + + it("submits a standalone team creation for a self-serve Internal User", async () => { + mockUseUISettings.mockReturnValue({ data: { values: { allow_user_team_creation: true } } }); + vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4"]); + vi.mocked(teamCreateCall).mockResolvedValue(selfServeCreatedTeam); + + renderWithQueryClient(); + + const buttons = await screen.findAllByTestId("create-team-button"); + act(() => { + fireEvent.click(buttons[0]); + }); + + await waitFor(() => expect(screen.getByLabelText(/team name/i)).toBeInTheDocument()); + fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "My Self Serve Team" } }); + fireEvent.change(screen.getByTestId("create-team-models-select"), { target: { value: "gpt-4" } }); + + const submitButtons = screen.getAllByRole("button", { name: /create team/i }); + fireEvent.click(submitButtons[submitButtons.length - 1]); + + await waitFor(() => { + expect(teamCreateCall).toHaveBeenCalledWith( + "test-token", + expect.objectContaining({ + team_alias: "My Self Serve Team", + models: ["gpt-4"], + organization_id: null, + }), + ); + }); + }); + + it("keeps the required Organization selector for an org admin when the flag is on", async () => { + mockUseUISettings.mockReturnValue({ data: { values: { allow_user_team_creation: true } } }); + mockUseOrganizations.mockReturnValue({ + data: [ + { + organization_id: "org-1", + organization_alias: "Org 1", + models: [], + members: [{ user_id: "user-123", user_role: "org_admin" }], + }, + ], + }); + + renderWithQueryClient(); + + const buttons = await screen.findAllByTestId("create-team-button"); + fireEvent.click(buttons[0]); + + await waitFor(() => expect(screen.getByTestId("team-name-input")).toBeInTheDocument()); + expect(document.getElementById("organization_id")).not.toBeNull(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/OldTeams.tsx b/ui/litellm-dashboard/src/components/OldTeams.tsx index e83d1acf4e5..48c81c2366d 100644 --- a/ui/litellm-dashboard/src/components/OldTeams.tsx +++ b/ui/litellm-dashboard/src/components/OldTeams.tsx @@ -1,4 +1,5 @@ import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; +import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import AvailableTeamsPanel from "@/components/team/available_teams"; import TeamInfoView from "@/components/team/TeamInfo"; import TeamSSOSettings from "@/components/TeamSSOSettings"; @@ -108,16 +109,21 @@ const getOrganizationModels = (organization: Organization | null, userModels: st return unfurlWildcardModelsInList(tempModelsToPick, userModels); }; -const canCreateOrManageTeams = ( +export const canCreateOrManageTeams = ( userRole: string | null, userID: string | null, organizations: Organization[] | null, + allowUserTeamCreation: boolean, ): boolean => { // Admin role always has permission if (userRole === "Admin") { return true; } + if (allowUserTeamCreation && userRole === "Internal User") { + return true; + } + // Check if user is an org_admin in any organization if (organizations && userID) { return organizations.some((org) => @@ -164,6 +170,8 @@ const getOrganizationAlias = ( const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser = false }) => { const { data: organizationsData } = useOrganizations(); const organizations = organizationsData ?? null; + const { data: uiSettings } = useUISettings(); + const allowUserTeamCreation = Boolean(uiSettings?.values?.allow_user_team_creation); const [teams, setTeams] = useState(null); const [isLoading, setIsLoading] = useState(true); const [fetchError, setFetchError] = useState(null); @@ -879,7 +887,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser Create your first team to organize members and manage access to models. - {canCreateOrManageTeams(userRole, userID, organizations) && ( + {canCreateOrManageTeams(userRole, userID, organizations, allowUserTeamCreation) && (