diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index f505406fe0e..8851c26b019 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -1822,6 +1822,7 @@ async def new_team( "tpm_limit", "rpm_limit", "team_member_permissions", + "access_group_ids", ): if getattr(data, field, None) is None and ( field != "budget_duration" or field not in data.model_fields_set diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 6d31fa3f226..6b56c513c5f 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -51,6 +51,7 @@ from litellm.repositories.config_repository import ConfigRepository from litellm.repositories.organization_repository import OrganizationRepository from litellm.repositories.prisma_protocols import TableActions from litellm.repositories.table_repositories import ( + AccessGroupRepository, SSOConfigRepository, UISettingsRepository, ) @@ -1169,6 +1170,39 @@ async def update_internal_user_settings( ) +async def _validate_default_access_groups_exist(access_group_ids: list[str]) -> None: + """Reject default access groups that cannot be attached. + + Teams are created from these settings long after they are saved, and /team/new does + not check that an access group exists, so an unknown id would silently attach nothing + to every future team instead of failing here, where the admin can still fix it. + """ + if not access_group_ids: + return + + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + raise HTTPException( + status_code=500, + detail={"error": "Database not connected. Please connect a database."}, + ) + + existing_groups: Final = await AccessGroupRepository(prisma_client).table.find_many( + where={"access_group_id": {"in": list(dict.fromkeys(access_group_ids))}} + ) + existing_ids: Final = {group.access_group_id for group in existing_groups} + missing_ids: Final = tuple(dict.fromkeys(ag_id for ag_id in access_group_ids if ag_id not in existing_ids)) + if missing_ids: + raise HTTPException( + status_code=400, + detail={ + "error": f"Access group(s) not found: {', '.join(missing_ids)}. " + "Create the access group first or remove it from the default team settings." + }, + ) + + @router.patch( "/update/default_team_settings", tags=["SSO Settings"], @@ -1185,6 +1219,8 @@ async def update_default_team_settings( """ if settings.organization_id is not None: await _validate_default_organization_exists(settings.organization_id) + if settings.access_group_ids is not None: + await _validate_default_access_groups_exist(settings.access_group_ids) return await _update_litellm_setting( settings=settings, diff --git a/litellm/types/proxy/management_endpoints/ui_sso.py b/litellm/types/proxy/management_endpoints/ui_sso.py index affb6d2e9ce..e7ccb2d4aad 100644 --- a/litellm/types/proxy/management_endpoints/ui_sso.py +++ b/litellm/types/proxy/management_endpoints/ui_sso.py @@ -234,3 +234,7 @@ class DefaultTeamSSOParams(LiteLLMPydanticObjectBase): default=None, description="Default organization for new teams created without an explicit organization", ) + access_group_ids: list[str] | None = Field( + default=None, + description="Default access groups attached to new teams created without explicit access_group_ids", + ) diff --git a/tests/unit/proxy/management_endpoints/test_team_default_params.py b/tests/unit/proxy/management_endpoints/test_team_default_params.py index cf4961dc82b..8a9030db7f2 100644 --- a/tests/unit/proxy/management_endpoints/test_team_default_params.py +++ b/tests/unit/proxy/management_endpoints/test_team_default_params.py @@ -214,6 +214,40 @@ class TestNewTeamDefaultParamsApplied: assert data.rpm_limit == 500 assert data.team_member_permissions == ["/key/generate", "/key/update"] + @pytest.mark.asyncio + async def test_default_access_group_ids_attach_to_a_team_created_without_any(self, monkeypatch): + """A central access group configured in default_team_params lands on every + /team/new call that omits access_group_ids, not only on SSO-created teams.""" + from litellm.proxy.management_endpoints.team_endpoints import new_team + + monkeypatch.setattr(litellm, "default_team_params", {"access_group_ids": ["central-models"]}) + + data = NewTeamRequest(team_alias="my-team") + + try: + await new_team(data=data, user_api_key_dict=self._make_admin_auth(), http_request=MagicMock()) + except Exception: + pass + + assert data.access_group_ids == ["central-models"] + + @pytest.mark.asyncio + @pytest.mark.parametrize("explicit_access_group_ids", [["research"], []]) + async def test_explicit_access_group_ids_win_over_the_default(self, monkeypatch, explicit_access_group_ids): + """An explicit list, including an empty one, is the caller's choice and is kept.""" + from litellm.proxy.management_endpoints.team_endpoints import new_team + + monkeypatch.setattr(litellm, "default_team_params", {"access_group_ids": ["central-models"]}) + + data = NewTeamRequest(team_alias="my-team", access_group_ids=explicit_access_group_ids) + + try: + await new_team(data=data, user_api_key_dict=self._make_admin_auth(), http_request=MagicMock()) + except Exception: + pass + + assert data.access_group_ids == explicit_access_group_ids + @pytest.mark.asyncio async def test_explicit_values_not_overridden(self, monkeypatch): """When request provides explicit values, defaults do not override them.""" diff --git a/tests/unit/proxy/management_endpoints/test_ui_sso.py b/tests/unit/proxy/management_endpoints/test_ui_sso.py index ba78fefd653..59179d50b79 100644 --- a/tests/unit/proxy/management_endpoints/test_ui_sso.py +++ b/tests/unit/proxy/management_endpoints/test_ui_sso.py @@ -727,10 +727,15 @@ def test_get_group_ids_from_graph_api_response(): [ # Test case 1: Using DefaultTeamSSOParams DefaultTeamSSOParams( - max_budget=10, budget_duration="1d", models=["special-gpt-5"] + max_budget=10, budget_duration="1d", models=["special-gpt-5"], access_group_ids=["central-models"] ), # Test case 2: Using Dict - {"max_budget": 10, "budget_duration": "1d", "models": ["special-gpt-5"]}, + { + "max_budget": 10, + "budget_duration": "1d", + "models": ["special-gpt-5"], + "access_group_ids": ["central-models"], + }, ], ) async def test_default_team_params(team_params): @@ -775,6 +780,7 @@ async def test_default_team_params(team_params): assert create_call_args["max_budget"] == 10 assert create_call_args["budget_duration"] == "1d" assert create_call_args["models"] == ["special-gpt-5"] + assert create_call_args["access_group_ids"] == ["central-models"] @pytest.mark.asyncio diff --git a/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index e66050877df..59c88fb3979 100644 --- a/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -3458,6 +3458,86 @@ def test_update_default_team_settings_without_organization_skips_lookup( assert mock_proxy_config["save_call_count"]() == 1 +@pytest.fixture +def mock_access_group_lookup(monkeypatch): + """Fake the access group table so the default team settings endpoint can check ids.""" + from unittest.mock import AsyncMock, MagicMock + + import litellm + import litellm.proxy.proxy_server as proxy_server_module + + existing_access_group_ids: set = set() + + async def _find_many(where): + return [ + MagicMock(access_group_id=access_group_id) + for access_group_id in where["access_group_id"]["in"] + if access_group_id in existing_access_group_ids + ] + + find_many = AsyncMock(side_effect=_find_many) + fake_prisma = MagicMock() + fake_prisma.db.litellm_accessgrouptable.find_many = find_many + + monkeypatch.setattr(proxy_server_module, "prisma_client", fake_prisma) + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) + monkeypatch.setattr(litellm, "default_team_params", {}) + + return {"existing_access_group_ids": existing_access_group_ids, "find_many": find_many} + + +def test_update_default_team_settings_rejects_unknown_access_group(mock_proxy_config, mock_auth, mock_access_group_lookup): + """/team/new never checks that an access group exists, so an unknown default id + would silently attach nothing to every future team. Catch it where the admin typed it.""" + mock_access_group_lookup["existing_access_group_ids"].add("real-group") + + resp = client.patch( + "/update/default_team_settings", + json={"access_group_ids": ["real-group", "ghost-group"]}, + ) + + assert resp.status_code == 400, resp.text + assert "ghost-group" in resp.json()["detail"]["error"] + assert "real-group" not in resp.json()["detail"]["error"] + assert mock_proxy_config["save_call_count"]() == 0 + + import litellm + + assert litellm.default_team_params == {} + + +def test_update_default_team_settings_saves_existing_access_groups(mock_proxy_config, mock_auth, mock_access_group_lookup): + """Real access group ids save and reach the in-memory settings /team/new reads.""" + mock_access_group_lookup["existing_access_group_ids"].update({"real-group", "other-group"}) + + resp = client.patch( + "/update/default_team_settings", + json={"access_group_ids": ["real-group", "other-group"]}, + ) + + assert resp.status_code == 200, resp.text + assert resp.json()["settings"]["access_group_ids"] == ["real-group", "other-group"] + assert mock_proxy_config["save_call_count"]() == 1 + + import litellm + + assert litellm.default_team_params["access_group_ids"] == ["real-group", "other-group"] + + +def test_update_default_team_settings_with_empty_access_groups_skips_lookup( + mock_proxy_config, mock_auth, mock_access_group_lookup +): + """Clearing the default access groups must not pay for a DB round trip.""" + resp = client.patch( + "/update/default_team_settings", + json={"access_group_ids": []}, + ) + + assert resp.status_code == 200, resp.text + mock_access_group_lookup["find_many"].assert_not_awaited() + assert mock_proxy_config["save_call_count"]() == 1 + + def test_update_mcp_semantic_filter_settings_requires_proxy_admin(monkeypatch): """Non-admin callers must not mutate global MCP semantic filter settings.""" from litellm.proxy._types import UserAPIKeyAuth diff --git a/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx b/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx index 4a042c32cda..5a4ce705b82 100644 --- a/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx +++ b/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx @@ -89,6 +89,32 @@ vi.mock("./common_components/OrganizationDropdown", () => ({ ), })); +vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroups", () => ({ + useAccessGroups: () => ({ + data: [ + { access_group_id: "ag-1", access_group_name: "Central Models" }, + { access_group_id: "ag-2", access_group_name: "Research" }, + ], + isLoading: false, + isError: false, + }), +})); + +vi.mock("./common_components/AccessGroupSelector", () => ({ + default: ({ value = [], onChange }: { value?: string[]; onChange?: (v: string[]) => void }) => ( + + ), +})); + vi.mock("./ModelSelect/ModelSelect", () => { const ModelSelect = ({ value, onChange }: { value: string[]; onChange: (value: string[]) => void }) => (