From 06d64afc1020a0bdc6eecd8ac959146309837395 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Tue, 4 Aug 2026 17:49:07 -0700 Subject: [PATCH] feat(proxy): honor the password field on /user/new --- litellm/proxy/_types.py | 1 + .../internal_user_endpoints.py | 1 + .../key_management_endpoints.py | 2 + .../test_internal_user_endpoints.py | 61 +++++++++++++++++ .../test_key_management_endpoints.py | 68 +++++++++++++++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 3 + 6 files changed, 136 insertions(+) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 7d6829aca70..bd54c905713 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1609,6 +1609,7 @@ class NewUserRequest(GenerateRequestBase): max_budget: float | None = None user_email: str | None = None user_alias: str | None = None + password: str | None = None user_role: ( Literal[ LitellmUserRoles.PROXY_ADMIN, diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index e8a66f59241..f927503f245 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -443,6 +443,7 @@ async def new_user( - user_alias: Optional[str] - A descriptive name for you to know who this user id refers to. - teams: Optional[list] - specify a list of team id's a user belongs to. - user_email: Optional[str] - Specify a user email. + - password: Optional[str] - Specify a user password, used for Admin UI username/password login. Stored as a scrypt hash. - send_invite_email: Optional[bool] - Specify if an invite email should be sent. - user_role: Optional[str] - Specify a user role - "proxy_admin", "proxy_admin_viewer", "internal_user", "internal_user_viewer", "team", "customer". Info about each role here: `https://github.com/BerriAI/litellm/litellm/proxy/_types.py#L20` - max_budget: Optional[float] - Specify max budget for a given user. diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index a5a0c9fb88c..954d914dbbb 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -3654,6 +3654,7 @@ async def generate_key_helper_fn( agent_id: str | None = None, user_email: str | None = None, user_role: str | None = None, + password: str | None = None, max_parallel_requests: int | None = None, metadata: dict | None = {}, tpm_limit: int | None = None, @@ -3777,6 +3778,7 @@ async def generate_key_helper_fn( "team_id": team_id, "organization_id": organization_id, "user_role": user_role, + "password": password, "spend": spend, "models": models, "metadata": metadata_json, diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index a37f7ca764d..29dbe383560 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -4032,3 +4032,64 @@ async def test_user_info_v2_returns_the_mcp_entitlement(mocker): assert response.object_permission.mcp_tool_permissions == { "github": ["list_issues"] } + + +@pytest.mark.asyncio +async def test_new_user_password_hashed_before_user_row_write(mocker): + """/user/new with `password` must hand generate_key_helper_fn a scrypt + hash the login flow can verify, never the plaintext. Before the model + carried the field, pydantic silently dropped it and the user was created + with no password at all, so username/password login 401'd.""" + from litellm.proxy.management_endpoints.internal_user_endpoints import new_user + from litellm.proxy.utils import verify_password + + mock_prisma_client = mocker.MagicMock() + + async def mock_count(*args, **kwargs): + return 5 + + mock_prisma_client.db.litellm_usertable.count = mock_count + + async def mock_check(*_args, **_kwargs): + return None + + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints._check_duplicate_user_email", + mock_check, + ) + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints._check_duplicate_user_id", + mock_check, + ) + mock_license_check = mocker.MagicMock() + mock_license_check.is_over_limit.return_value = False + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + mocker.patch("litellm.proxy.proxy_server._license_check", mock_license_check) + + captured_kwargs = {} + + async def stub_helper(**kwargs): + captured_kwargs.update(kwargs) + return {"user_id": "password-user", "key": "sk-new", "expires": None} + + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints.generate_key_helper_fn", + stub_helper, + ) + + data = NewUserRequest( + user_email="password-user@example.com", + user_role=LitellmUserRoles.INTERNAL_USER, + password="super-secret-pw", + auto_create_key=False, + ) + caller = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN) + + response = await new_user(data=data, user_api_key_dict=caller) + + stored = captured_kwargs["password"] + assert stored != "super-secret-pw" + assert stored.startswith("scrypt:") + assert verify_password("super-secret-pw", stored) + assert "super-secret-pw" not in json.dumps(captured_kwargs, default=str) + assert "password" not in response.model_dump() diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index cf9aa477112..d1cf4c6b660 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -15323,3 +15323,71 @@ async def test_rotate_master_key_rotates_sso_identity_assertions( prisma_client=mock_prisma_client, new_master_key="sk-new-master-key", ) + + +@pytest.mark.asyncio +async def test_generate_key_helper_fn_persists_password_in_user_row(monkeypatch): + """Regression: /user/new forwards the hashed `password` as a kwarg, so + generate_key_helper_fn must accept it and write it into the user row. + Before this field existed the kwarg would raise "unexpected keyword + argument" and the password could never reach the database.""" + mock_prisma_client = AsyncMock() + mock_prisma_client.jsonify_object = lambda data: data # type: ignore + + captured_user_data = {} + + async def _insert_data_side_effect(*args, **kwargs): + if kwargs.get("table_name") == "user": + captured_user_data.update(kwargs.get("data", {})) + return MagicMock(models=[], spend=0) + return MagicMock() + + mock_prisma_client.insert_data = AsyncMock(side_effect=_insert_data_side_effect) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + + from litellm.proxy.management_endpoints.key_management_endpoints import ( + generate_key_helper_fn, + ) + + await generate_key_helper_fn( + request_type="user", + table_name="user", + user_id="password-user", + user_email="password-user@example.com", + password="scrypt:stored-hash", + ) + + assert captured_user_data.get("password") == "scrypt:stored-hash" + + +@pytest.mark.asyncio +async def test_generate_key_helper_fn_defaults_password_to_none_in_user_row(monkeypatch): + """A caller that does not pass `password` must create the user row with + password NULL, exactly like before the parameter existed, so no + non-password caller starts writing a value into the column.""" + mock_prisma_client = AsyncMock() + mock_prisma_client.jsonify_object = lambda data: data # type: ignore + + captured_user_data = {} + + async def _insert_data_side_effect(*args, **kwargs): + if kwargs.get("table_name") == "user": + captured_user_data.update(kwargs.get("data", {})) + return MagicMock(models=[], spend=0) + return MagicMock() + + mock_prisma_client.insert_data = AsyncMock(side_effect=_insert_data_side_effect) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + + from litellm.proxy.management_endpoints.key_management_endpoints import ( + generate_key_helper_fn, + ) + + await generate_key_helper_fn( + request_type="user", + table_name="user", + user_id="no-password-user", + user_email="no-password-user@example.com", + ) + + assert captured_user_data.get("password") is None diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index d3f162b150d..a4a4ff27deb 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -15031,6 +15031,7 @@ export interface paths { * - user_alias: Optional[str] - A descriptive name for you to know who this user id refers to. * - teams: Optional[list] - specify a list of team id's a user belongs to. * - user_email: Optional[str] - Specify a user email. + * - password: Optional[str] - Specify a user password, used for Admin UI username/password login. Stored as a scrypt hash. * - send_invite_email: Optional[bool] - Specify if an invite email should be sent. * - user_role: Optional[str] - Specify a user role - "proxy_admin", "proxy_admin_viewer", "internal_user", "internal_user_viewer", "team", "customer". Info about each role here: `https://github.com/BerriAI/litellm/litellm/proxy/_types.py#L20` * - max_budget: Optional[float] - Specify max budget for a given user. @@ -28895,6 +28896,8 @@ export interface components { object_permission?: components["schemas"]["LiteLLM_ObjectPermissionBase"] | null; /** Organizations */ organizations?: string[] | null; + /** Password */ + password?: string | null; /** * Permissions * @default {}