From 75b45e39c9a1c7532eca0016037eb3febdcbe33a Mon Sep 17 00:00:00 2001 From: "moyai-devin-berriai[bot]" <336287033+moyai-devin-berriai[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:32:05 -0700 Subject: [PATCH] fix(proxy): enforce internal-user model creation prohibition (#44438) Co-authored-by: moyai-devin-berriai[bot] <336287033+moyai-devin-berriai[bot]@users.noreply.github.com> --- .../proxy/config_resolvers/settings_rules.py | 1 + .../model_management_endpoints.py | 17 +++- litellm/proxy/proxy_server.py | 5 +- .../proxy_setting_endpoints.py | 27 ++++-- .../test_model_management_endpoints.py | 71 ++++++++++++++- .../proxy/proxy_server/test_proxy_config.py | 11 ++- .../test_proxy_setting_endpoints.py | 91 ++++++++++++++++++- .../panels/AutoRoutersTabPanel.test.tsx | 4 +- .../src/utils/modelPermissions.test.ts | 27 +++++- .../src/utils/modelPermissions.ts | 1 + 10 files changed, 239 insertions(+), 16 deletions(-) diff --git a/litellm/proxy/config_resolvers/settings_rules.py b/litellm/proxy/config_resolvers/settings_rules.py index 74e7b0af48b..87c79765452 100644 --- a/litellm/proxy/config_resolvers/settings_rules.py +++ b/litellm/proxy/config_resolvers/settings_rules.py @@ -41,6 +41,7 @@ class Resolved: _UI_SETTINGS_FIELDS: Final[tuple[str, ...]] = ( + "disable_model_add_for_internal_users", "allow_public_health_readiness_details", "forward_client_headers_to_llm_api", "forward_llm_provider_auth_headers", diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py index 253013a2ebf..7cc9dd06620 100644 --- a/litellm/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_management_endpoints.py @@ -2515,6 +2515,19 @@ async def add_new_model( ) ## Auth check + from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ( + model_creation_disabled_for_internal_users, + sync_ui_settings_to_general_settings, + ) + + internal_user_creation: Final = user_api_key_dict.user_role == LitellmUserRoles.INTERNAL_USER + if internal_user_creation: + await sync_ui_settings_to_general_settings(prisma_client, require_fresh=True) + if internal_user_creation and model_creation_disabled_for_internal_users(general_settings): + raise HTTPException( + status_code=403, + detail="Model creation is disabled for internal users by disable_model_add_for_internal_users.", + ) write_authorization: Final = await ModelManagementAuthChecks.can_user_make_model_call( model_params=model_params, user_api_key_dict=user_api_key_dict, @@ -2591,7 +2604,9 @@ async def add_new_model( ), ) reload_outcome = await proxy_config.add_deployment( - prisma_client=prisma_client, proxy_logging_obj=proxy_logging_obj + prisma_client=prisma_client, + proxy_logging_obj=proxy_logging_obj, + ui_settings_already_synced=internal_user_creation, ) # don't let failed slack alert block the /model/new response _alerting: Final = general_settings.get("alerting", []) or [] diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index ac116255082..6d1a04c2b5e 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -8161,6 +8161,8 @@ class ProxyConfig: self, prisma_client: PrismaClient, proxy_logging_obj: ProxyLogging, + *, + ui_settings_already_synced: bool = False, ) -> ReconcileOutcome: """ - Check db for new models @@ -8183,7 +8185,8 @@ class ProxyConfig: Also re-reads the UI settings that back runtime flags. That runs before the lock, so a setting written through one pod reaches the others without waiting on a model reconcile. """ - await sync_ui_settings_to_general_settings(prisma_client) + if not ui_settings_already_synced: + await sync_ui_settings_to_general_settings(prisma_client) async with MODEL_RECONCILE_LOCK: return await self._add_deployment_locked(prisma_client=prisma_client, proxy_logging_obj=proxy_logging_obj) diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index a112b22b4e4..dc519bc4720 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -240,7 +240,11 @@ class UISettings(BaseModel): disable_model_add_for_internal_users: bool = Field( default=False, - description="If true, internal users cannot add models from the UI", + description=( + "If true, internal users cannot create models or auto routers through the UI or API, " + "including team admins and members with auto-router management permission. " + "Proxy admins are exempt. Editing and deleting existing models are unchanged." + ), ) disable_team_admin_delete_team_user: bool = Field( @@ -403,6 +407,7 @@ def _derived_ui_setting_value(key: str) -> object: # Flags that must be synced from the persisted UISettings into # general_settings at runtime (on both read and write). _RUNTIME_GENERAL_SETTINGS_FLAGS: Final = [ + "disable_model_add_for_internal_users", "allow_public_health_readiness_details", "forward_client_headers_to_llm_api", "forward_llm_provider_auth_headers", @@ -1708,6 +1713,11 @@ async def get_ui_settings_cached() -> dict[str, JsonValue]: _UI_SETTINGS_OBJECT: Final = TypeAdapter(dict[str, JsonValue]) +def model_creation_disabled_for_internal_users(settings: Mapping[str, object]) -> bool: + setting: Final = "disable_model_add_for_internal_users" + return UISettings.model_validate({setting: settings.get(setting, False)}).disable_model_add_for_internal_users + + def apply_runtime_general_settings_flags(ui_settings: Mapping[str, JsonValue]) -> Mapping[str, JsonValue]: """Copy the UI settings that gate runtime behavior into ``general_settings``. Returns what was applied.""" from litellm.proxy.config_resolvers import SettingsStore @@ -1721,17 +1731,20 @@ def apply_runtime_general_settings_flags(ui_settings: Mapping[str, JsonValue]) - return MappingProxyType(flags) -async def sync_ui_settings_to_general_settings(prisma_client: object) -> Mapping[str, JsonValue]: +async def sync_ui_settings_to_general_settings( + prisma_client: object, *, require_fresh: bool = False +) -> Mapping[str, JsonValue]: """Re-read the persisted UI settings and apply the runtime flags to ``general_settings``. Runs on startup and on every periodic config reload: the PATCH handler only updates the pod that served it, so every other pod needs its own read to pick up a change without a restart. - Never raises. A read that fails leaves this pod on the flags it already had. + Background failures retain existing flags. Authorization refreshes require the writer + and fail closed if the current settings cannot be read. """ try: - db_record: Final = await _ui_settings_db(UISettingsRepository(prisma_client)).find_unique( - where={"id": "ui_settings"} - ) + db_record: Final = await _ui_settings_db( + UISettingsRepository(prisma_client, use_writer=require_fresh) + ).find_unique(where={"id": "ui_settings"}) stored: Final = (db_record.ui_settings if db_record else None) or "{}" parsed: Final = ( _UI_SETTINGS_OBJECT.validate_json(stored) @@ -1740,6 +1753,8 @@ async def sync_ui_settings_to_general_settings(prisma_client: object) -> Mapping ) except Exception as e: verbose_proxy_logger.warning("Could not refresh UI settings from the database: %s", e) + if require_fresh: + raise HTTPException(status_code=503, detail="Unable to verify model creation policy. Please retry.") from e return MappingProxyType({}) return apply_runtime_general_settings_flags(parsed) diff --git a/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py b/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py index 452fdec648f..fae09369281 100644 --- a/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py @@ -44,6 +44,68 @@ async def _passthrough_row(update_data): return update_data +@pytest.mark.asyncio +@pytest.mark.parametrize("model", ["openai/test-model", "auto_router/complexity_router"]) +@pytest.mark.parametrize("team_role", ["admin", "user"]) +@pytest.mark.parametrize("team_key", [False, True]) +async def test_model_creation_disabled_before_any_write( + monkeypatch: pytest.MonkeyPatch, model: str, team_role: str, team_key: bool +) -> None: + from litellm.proxy import proxy_server + from litellm.proxy.management_endpoints.model_management_endpoints import add_new_model + + prisma: Final = MagicMock() + prisma.writer_db.litellm_uisettings.find_unique = AsyncMock(return_value=SimpleNamespace( + ui_settings={"disable_model_add_for_internal_users": True}, + )) + prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=LiteLLM_TeamTable( + team_id="test_team", members_with_roles=[Member(user_id="test_user", role=team_role)], + team_member_permissions=["/auto_router/manage"], + )) + monkeypatch.setattr(proxy_server, "prisma_client", prisma) + monkeypatch.setattr(proxy_server, "general_settings", {"disable_model_add_for_internal_users": False}) + monkeypatch.setattr(proxy_server, "premium_user", True) + with pytest.raises(ProxyException, match="disable_model_add_for_internal_users") as error: + await add_new_model( + model_params=Deployment( + model_name="creation-policy-test", + litellm_params={"model": model, "disable_model_add_for_internal_users": False}, + model_info={"team_id": "test_team"}, + ), + user_api_key_dict=UserAPIKeyAuth( + user_id="test_user", user_role=LitellmUserRoles.INTERNAL_USER, + team_id="test_team" if team_key else None, + ), + ) + assert error.value.code == "403" + prisma.db.litellm_proxymodeltable.create.assert_not_called() + prisma.db.litellm_teamtable.update.assert_not_called() + prisma.db.litellm_teamtable.find_unique.assert_not_called() + prisma.db.litellm_uisettings.find_unique.assert_not_called() + prisma.writer_db.litellm_uisettings.find_unique.assert_awaited_once_with(where={"id": "ui_settings"}) + + +@pytest.mark.asyncio +async def test_model_creation_fails_closed_when_authoritative_policy_is_unavailable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from litellm.proxy import proxy_server + from litellm.proxy.management_endpoints.model_management_endpoints import add_new_model + + prisma: Final = MagicMock() + prisma.writer_db.litellm_uisettings.find_unique = AsyncMock(side_effect=RuntimeError("writer unavailable")) + monkeypatch.setattr(proxy_server, "prisma_client", prisma) + monkeypatch.setattr(proxy_server, "general_settings", {"disable_model_add_for_internal_users": False}) + with pytest.raises(ProxyException, match="Unable to verify model creation policy") as error: + await add_new_model( + Deployment(model_name="cannot-create", litellm_params={"model": "openai/test-model"}), + UserAPIKeyAuth(user_id="test_user", user_role=LitellmUserRoles.INTERNAL_USER), + ) + assert error.value.code == "503" + prisma.db.litellm_proxymodeltable.create.assert_not_called() + prisma.db.litellm_teamtable.find_unique.assert_not_called() + + async def _write_empty_row(**kwargs): return await kwargs["write_row"]({}) @@ -376,6 +438,7 @@ class TestModelManagementAuthChecks: ) mock_prisma = MagicMock() + mock_prisma.writer_db.litellm_uisettings.find_unique = AsyncMock(return_value=None) with ( patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), # test-quality-ok: endpoint reads proxy server globals with no injection seam patch("litellm.proxy.proxy_server.store_model_in_db", True), # test-quality-ok: endpoint reads proxy server globals with no injection seam @@ -506,6 +569,7 @@ class TestModelManagementAuthChecks: ) mock_prisma = MagicMock() + mock_prisma.writer_db.litellm_uisettings.find_unique = AsyncMock(return_value=None) with ( patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), # test-quality-ok: endpoint reads proxy server globals with no injection seam patch("litellm.proxy.proxy_server.store_model_in_db", True), # test-quality-ok: endpoint reads proxy server globals with no injection seam @@ -7396,7 +7460,7 @@ class TestTeamMemberAutoRouterWrites: litellm_proxymodeltable=table, tx=MagicMock(return_value=context), ) - return MagicMock(db=db, transaction=transaction) + return MagicMock(db=db, writer_db=MagicMock(litellm_uisettings=MagicMock(find_unique=AsyncMock(return_value=None))), transaction=transaction) @staticmethod def _catalog() -> Router: @@ -7880,6 +7944,7 @@ class TestModelManagementActorEdges: actor: Final = UserAPIKeyAuth(user_id="internal-user", user_role=LitellmUserRoles.INTERNAL_USER) prisma: Final = MagicMock() + prisma.writer_db.litellm_uisettings.find_unique = AsyncMock(return_value=None) deployment: Final = Deployment( model_name="internal-model", litellm_params=LiteLLM_Params(model="openai/test-model"), @@ -8097,6 +8162,7 @@ class TestModelManagementActorEdges: def test_post_model_new_binds_to_actor_guard(self): actor: Final = UserAPIKeyAuth(user_id="internal-user", user_role=LitellmUserRoles.INTERNAL_USER) prisma: Final = MagicMock() + prisma.writer_db.litellm_uisettings.find_unique = AsyncMock(return_value=None) with ( patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: [TQ008] route reads proxy-server state through its only test seam patch("litellm.proxy.proxy_server.store_model_in_db", True), # test-quality-ok: [TQ008] route reads proxy-server state through its only test seam @@ -8299,6 +8365,7 @@ class TestAddNewModelBlockedAuthGate: non_admin = UserAPIKeyAuth(user_id="team_admin", user_role=LitellmUserRoles.INTERNAL_USER) mock_prisma = MagicMock() + mock_prisma.writer_db.litellm_uisettings.find_unique = AsyncMock(return_value=None) with ( patch( # test-quality-ok: the proxy wiring under test is what this patches @@ -8339,6 +8406,7 @@ class TestAddNewModelBlockedAuthGate: non_admin = UserAPIKeyAuth(user_id="team_admin", user_role=LitellmUserRoles.INTERNAL_USER) mock_prisma = MagicMock() + mock_prisma.writer_db.litellm_uisettings.find_unique = AsyncMock(return_value=None) created_row = MagicMock() created_row.model_id = "blocked-gate-create-2" created_row.model_dump_json.return_value = "{}" @@ -8614,6 +8682,7 @@ class TestNonAdminCannotPersistWifFieldsOnModel: non_admin = UserAPIKeyAuth(user_id="team_admin", user_role=LitellmUserRoles.INTERNAL_USER) mock_prisma = MagicMock() + mock_prisma.writer_db.litellm_uisettings.find_unique = AsyncMock(return_value=None) with ( patch( # test-quality-ok: the proxy wiring under test is what this patches diff --git a/tests/unit/proxy/proxy_server/test_proxy_config.py b/tests/unit/proxy/proxy_server/test_proxy_config.py index 61aed5c9c81..c8979a07b40 100644 --- a/tests/unit/proxy/proxy_server/test_proxy_config.py +++ b/tests/unit/proxy/proxy_server/test_proxy_config.py @@ -4665,7 +4665,8 @@ async def test_ProxyConfig__update_config_from_db_keeps_keys_the_config_file_omi @pytest.mark.asyncio -async def test_ProxyConfig_add_deployment_continues_after_null_pass_through_endpoints(monkeypatch): +@pytest.mark.parametrize("ui_settings_already_synced", [False, True]) +async def test_ProxyConfig_add_deployment_continues_after_null_pass_through_endpoints(monkeypatch, ui_settings_already_synced): from litellm.proxy import proxy_server pc = ProxyConfig() @@ -4677,14 +4678,18 @@ async def test_ProxyConfig_add_deployment_continues_after_null_pass_through_endp "get_config_param", AsyncMock(return_value=SimpleNamespace(param_value={"pass_through_endpoints": None})), ) - monkeypatch.setattr(proxy_server, "sync_ui_settings_to_general_settings", AsyncMock()) + settings_refresh = AsyncMock() + monkeypatch.setattr(proxy_server, "sync_ui_settings_to_general_settings", settings_refresh) monkeypatch.setattr(pc, "_should_load_db_object", lambda *, object_type: False) monkeypatch.setattr(pc, "get_credentials", AsyncMock()) monkeypatch.setattr(pc, "_init_non_llm_objects_in_db", non_llm_initialization) - await pc.add_deployment(prisma_client=MagicMock(), proxy_logging_obj=MagicMock()) + await pc.add_deployment( + prisma_client=MagicMock(), proxy_logging_obj=MagicMock(), ui_settings_already_synced=ui_settings_already_synced + ) non_llm_initialization.assert_awaited_once() + assert settings_refresh.await_count == (0 if ui_settings_already_synced else 1) # --------------------------------------------------------------------------- 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 0d7a713a380..bea305417f3 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 @@ -16,6 +16,96 @@ from litellm.types.proxy.management_endpoints.ui_sso import ( client = TestClient(app) +@pytest.mark.asyncio +@pytest.mark.parametrize("config_value,stored_value,expected", [ + (None, None, False), (None, False, False), (None, True, True), + (False, True, False), (True, False, True), ("false", True, False), ("true", False, True), +]) +@pytest.mark.parametrize("encoded", [False, True]) +@pytest.mark.parametrize("require_fresh", [False, True]) +async def test_model_creation_policy_uses_refreshed_settings_with_config_precedence( + config_value: bool | str | None, stored_value: bool | None, expected: bool, encoded: bool, + require_fresh: bool, +) -> None: + from types import SimpleNamespace + from unittest.mock import AsyncMock, MagicMock + from litellm.proxy import proxy_server + from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ( + model_creation_disabled_for_internal_users, sync_ui_settings_to_general_settings, + ) + + setting: Final = "disable_model_add_for_internal_users" + stored: Final = {} if stored_value is None else {setting: stored_value} + prisma: Final = MagicMock() + connection: Final = prisma.writer_db if require_fresh else prisma.db + unused_connection: Final = prisma.db if require_fresh else prisma.writer_db + connection.litellm_uisettings.find_unique = AsyncMock(return_value=SimpleNamespace( + ui_settings=json.dumps(stored) if encoded else stored, + )) + proxy_server.proxy_config.settings.load_yaml({} if config_value is None else {setting: config_value}) + await sync_ui_settings_to_general_settings(prisma, require_fresh=require_fresh) + + assert model_creation_disabled_for_internal_users(proxy_server.general_settings) is expected + assert model_creation_disabled_for_internal_users(proxy_server.general_settings) is expected + connection.litellm_uisettings.find_unique.assert_awaited_once_with(where={"id": "ui_settings"}) + unused_connection.litellm_uisettings.find_unique.assert_not_called() + + +@pytest.mark.asyncio +async def test_model_creation_policy_refresh_retains_last_value_on_failure_and_clears_deleted_flag() -> None: + from types import SimpleNamespace + from unittest.mock import AsyncMock, MagicMock + from litellm.proxy import proxy_server + from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ( + model_creation_disabled_for_internal_users, sync_ui_settings_to_general_settings, + ) + + prisma: Final = MagicMock() + prisma.db.litellm_uisettings.find_unique = AsyncMock(side_effect=[ + SimpleNamespace(ui_settings={"disable_model_add_for_internal_users": True}), + RuntimeError("database unavailable"), + SimpleNamespace(ui_settings={"disable_model_add_for_internal_users": False}), + SimpleNamespace(ui_settings={"disable_model_add_for_internal_users": True}), + None, + ]) + for expected in (True, True, False, True, False): + await sync_ui_settings_to_general_settings(prisma) + assert model_creation_disabled_for_internal_users(proxy_server.general_settings) is expected + + +@pytest.mark.asyncio +@pytest.mark.parametrize("disabled", [True, False]) +async def test_model_creation_policy_patch_applies_after_successful_persistence( + monkeypatch: pytest.MonkeyPatch, disabled: bool, +) -> None: + from types import SimpleNamespace + from unittest.mock import AsyncMock, MagicMock + from litellm.proxy import proxy_server + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ( + apply_runtime_general_settings_flags, model_creation_disabled_for_internal_users, update_ui_settings, + ) + + setting: Final = "disable_model_add_for_internal_users" + apply_runtime_general_settings_flags({setting: not disabled}) + prisma: Final = MagicMock() + prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=SimpleNamespace(ui_settings={setting: not disabled})) + prisma.db.litellm_uisettings.upsert = AsyncMock(side_effect=[RuntimeError("write failed"), None]) + monkeypatch.setattr(proxy_server, "prisma_client", prisma) + monkeypatch.setattr(proxy_server, "store_model_in_db", True) + actor: Final = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN) + + with pytest.raises(RuntimeError, match="write failed"): + await update_ui_settings({setting: disabled}, actor) + assert model_creation_disabled_for_internal_users(proxy_server.general_settings) is not disabled + + await update_ui_settings({setting: disabled}, actor) + assert model_creation_disabled_for_internal_users(proxy_server.general_settings) is disabled + assert json.loads(prisma.db.litellm_uisettings.upsert.await_args.kwargs["data"]["update"]["ui_settings"]) == { + setting: disabled, + } + + @pytest.fixture def mock_proxy_config(monkeypatch): """Mock the proxy_config to avoid actual file operations during tests""" @@ -4075,4 +4165,3 @@ class TestSyncUiSettingsToGeneralSettings: assert general_settings["forward_client_headers_to_llm_api"] is False assert general_settings.source("forward_client_headers_to_llm_api") == "config" - diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel.test.tsx index 1b4251c7ac0..7dbdae60e20 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel.test.tsx @@ -25,7 +25,7 @@ const SESSION = { accessToken: "at", userRole: "Admin", userId: "u1", isViewOnly const lastProps = () => panelProps.mock.calls.at(-1)?.[0] as { createScope: string }; describe("AutoRoutersTabPanel", () => { - it("honors member auto-router opt-in when general model creation is disabled", () => { + it("honors the global creation prohibition even when member auto-router management is enabled", () => { mockUseAuthorized.mockReturnValue({ ...SESSION, userRole: "Internal User" }); mockUseTeams.mockReturnValueOnce({ data: [ @@ -39,7 +39,7 @@ describe("AutoRoutersTabPanel", () => { mockUseUISettings.mockReturnValueOnce({ data: { values: { disable_model_add_for_internal_users: true } } }); render(); - expect(lastProps().createScope).toBe("team-required"); + expect(lastProps().createScope).toBe("forbidden"); }); it("grants an unscoped create to a real proxy admin", () => { diff --git a/ui/litellm-dashboard/src/utils/modelPermissions.test.ts b/ui/litellm-dashboard/src/utils/modelPermissions.test.ts index 900bbbfa01d..c6d4510a6f0 100644 --- a/ui/litellm-dashboard/src/utils/modelPermissions.test.ts +++ b/ui/litellm-dashboard/src/utils/modelPermissions.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from "vitest"; import { Team } from "@/components/networking"; -import { canCreateModels, canEditAutoRouter, canModifyModel, modelCreationScope } from "./modelPermissions"; +import { + autoRouterCreationScope, + canCreateModels, + canEditAutoRouter, + canModifyModel, + modelCreationScope, +} from "./modelPermissions"; const teamWhere = (userId: string, role: string, teamId = "team-1"): Team[] => [{ team_id: teamId, members_with_roles: [{ user_id: userId, user_email: "t@test.com", role }] }] as unknown as Team[]; @@ -14,6 +20,25 @@ const VIEW_ONLY_ADMIN = { userRole: "Admin", userID: "u-viewer", isViewOnly: tru const noLimits = { disabledForInternalUsers: false }; +describe("autoRouterCreationScope", () => { + it("lets the global creation prohibition override the member grant without revoking edit access", () => { + const teams = teamWhere("u-member", "user").map((team) => ({ + ...team, + team_member_permissions: ["/auto_router/manage"], + })); + expect(autoRouterCreationScope(MEMBER, { teams, disabledForInternalUsers: true })).toBe("forbidden"); + expect(autoRouterCreationScope(MEMBER, { teams, disabledForInternalUsers: false })).toBe("team-required"); + expect(autoRouterCreationScope(PROXY_ADMIN, { teams, disabledForInternalUsers: true })).toBe("unscoped-ok"); + const origin = { + teamId: "team-1", + isDbModel: true, + createdBy: "u-member", + model: "auto_router/complexity_router", + }; + expect(canEditAutoRouter(MEMBER, teams, origin)).toBe(true); + }); +}); + describe("modelCreationScope", () => { it("lets a proxy admin create without naming a team", () => { expect(modelCreationScope(PROXY_ADMIN, { teams: null, ...noLimits })).toBe("unscoped-ok"); diff --git a/ui/litellm-dashboard/src/utils/modelPermissions.ts b/ui/litellm-dashboard/src/utils/modelPermissions.ts index 7e0a07e8e46..351eb3acb17 100644 --- a/ui/litellm-dashboard/src/utils/modelPermissions.ts +++ b/ui/litellm-dashboard/src/utils/modelPermissions.ts @@ -116,6 +116,7 @@ export const canCreateAutoRouterForTeam = (actor: ModelActor, team: Team): boole export const autoRouterCreationScope = (actor: ModelActor, limits: ModelCreationLimits): ModelWriteScope => { const scope = modelCreationScope(actor, limits); if (scope !== "forbidden") return scope; + if (limits.disabledForInternalUsers) return "forbidden"; return limits.teams?.some((team) => canMemberCreateAutoRouterForTeam(actor, team)) ? "team-required" : "forbidden"; };