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) && (