mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
feat(proxy): honor the password field on /user/new
This commit is contained in:
parent
794338af67
commit
06d64afc10
6 changed files with 136 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
3
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
3
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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 {}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue