mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
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:
parent
f81a3f5243
commit
75b45e39c9
10 changed files with 239 additions and 16 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue