feat(proxy): honor the password field on /user/new

This commit is contained in:
Yuneng Jiang 2026-08-04 17:49:07 -07:00
parent 794338af67
commit 06d64afc10
No known key found for this signature in database
6 changed files with 136 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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