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>
This commit is contained in:
moyai-devin-berriai[bot] 2026-10-05 11:32:05 -07:00 • committed by GitHub
parent f81a3f5243
commit 75b45e39c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 239 additions and 16 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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(<AutoRoutersTabPanel />);
expect(lastProps().createScope).toBe("team-required");
expect(lastProps().createScope).toBe("forbidden");
});
it("grants an unscoped create to a real proxy admin", () => {

View file

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

View file

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