mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
Merge 11775ff817 into 1a5234acf5
This commit is contained in:
commit
cfc9069491
21 changed files with 1058 additions and 121 deletions
|
|
@ -859,6 +859,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
"/organization/daily/activity",
|
||||
"/user/available_roles", # read-only role metadata; any authenticated user may read
|
||||
"/user/list", # org admins checked in endpoint; non-admins get 403
|
||||
"/user/password/change", # endpoint only ever writes the caller's own row
|
||||
"/model/{model_id}/update",
|
||||
"/prompt/list",
|
||||
"/prompt/info",
|
||||
|
|
@ -1831,7 +1832,8 @@ class NewUserResponse(GenerateKeyResponse):
|
|||
|
||||
|
||||
class UpdateUserRequestNoUserIDorEmail(GenerateRequestBase): # shared with BulkUpdateUserRequest
|
||||
password: str | None = None
|
||||
# repr=False keeps the plaintext out of management-endpoint alerts, which str() the request model
|
||||
password: str | None = Field(default=None, repr=False)
|
||||
spend: float | None = None
|
||||
metadata: dict | None = None
|
||||
user_alias: str | None = None
|
||||
|
|
@ -1861,6 +1863,16 @@ class UpdateUserRequest(UpdateUserRequestNoUserIDorEmail):
|
|||
return values
|
||||
|
||||
|
||||
class ChangePasswordRequest(LiteLLMPydanticObjectBase):
|
||||
current_password: str = Field(repr=False)
|
||||
new_password: str = Field(repr=False)
|
||||
|
||||
|
||||
class ChangePasswordResponse(LiteLLMPydanticObjectBase):
|
||||
user_id: str
|
||||
message: str
|
||||
|
||||
|
||||
class DeleteUserRequest(LiteLLMPydanticObjectBase):
|
||||
user_ids: list[str] # required
|
||||
|
||||
|
|
@ -3789,6 +3801,12 @@ class AllCallbacks(LiteLLMPydanticObjectBase):
|
|||
)
|
||||
|
||||
|
||||
class HTTPExceptionErrorDetail(TypedDict):
|
||||
"""The `{"error": <message>}` shape most proxy endpoints raise as `HTTPException.detail`."""
|
||||
|
||||
error: ReadOnly[str]
|
||||
|
||||
|
||||
class SpendLogsRouterMetadata(TypedDict):
|
||||
"""
|
||||
Router provenance stamped on spend logs for deployments flagged with
|
||||
|
|
|
|||
|
|
@ -796,7 +796,8 @@ class RouteChecks:
|
|||
in the codebase is automatically readable by Admin Viewer
|
||||
without needing to remember to add it to an allowlist.
|
||||
3. Unsafe HTTP method (POST/PUT/PATCH/DELETE):
|
||||
- Allow `/user/update` only when restricted to user_email/password.
|
||||
- Allow `/user/update` only when restricted to user_email.
|
||||
- Allow `/user/password/change` (endpoint only writes the caller's own row).
|
||||
- Block all explicit writes in `_ADMIN_VIEWER_BLOCKED_WRITE_ROUTES`.
|
||||
- Otherwise allow only if the route is in admin_viewer_routes /
|
||||
global_spend_tracking_routes (legacy explicit-allow set).
|
||||
|
|
@ -816,10 +817,10 @@ class RouteChecks:
|
|||
if request_data is not None and isinstance(request_data, dict):
|
||||
_params_updated: Final = request_data.keys()
|
||||
for param in _params_updated:
|
||||
if param not in ["user_email", "password"]:
|
||||
if param != "user_email":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"user not allowed to access this route, role= {_user_role}. Trying to access: {route} and updating invalid param: {param}. only user_email and password can be updated",
|
||||
detail=f"user not allowed to access this route, role= {_user_role}. Trying to access: {route} and updating invalid param: {param}. only user_email can be updated",
|
||||
)
|
||||
elif route in _PROXY_ADMIN_VIEW_ONLY_BLOCKED_ROUTES or (
|
||||
route.startswith("/key/") and route.endswith(_PROXY_ADMIN_VIEW_ONLY_BLOCKED_KEY_SUFFIXES)
|
||||
|
|
@ -838,21 +839,25 @@ class RouteChecks:
|
|||
return
|
||||
|
||||
# ── Unsafe HTTP method: explicit checks ──────────────────────────
|
||||
# Allow `/user/update` for self-service email / password change.
|
||||
# Allow `/user/update` for self-service email change.
|
||||
if route == "/user/update":
|
||||
if request_data is not None and isinstance(request_data, dict):
|
||||
for param in request_data:
|
||||
if param not in ["user_email", "password"]:
|
||||
if param != "user_email":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=(
|
||||
f"user not allowed to access this route, role= {_user_role}. "
|
||||
f"Trying to access: {route} and updating invalid param: {param}. "
|
||||
"only user_email and password can be updated"
|
||||
"only user_email can be updated"
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
# Self-service password change; the endpoint only writes the caller's own row.
|
||||
if route == "/user/password/change":
|
||||
return
|
||||
|
||||
# Hard-block known write routes regardless of HTTP method (defensive
|
||||
# — these are POSTs in practice, but pinning them here protects
|
||||
# against future GET-shaped writes).
|
||||
|
|
|
|||
|
|
@ -1643,7 +1643,7 @@ async def user_update(
|
|||
Parameters:
|
||||
- user_id: Optional[str] - Specify a user id. If not set, a unique id will be generated.
|
||||
- user_email: Optional[str] - Specify a user email.
|
||||
- password: Optional[str] - Specify a user password.
|
||||
- password: Optional[str] - Set the user's password (admin only). Must satisfy the configured password policy. Users change their own password with POST /user/password/change.
|
||||
- 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.
|
||||
- send_invite_email: Optional[bool] - Specify if an invite email should be sent.
|
||||
|
|
@ -1881,6 +1881,14 @@ async def bulk_user_update(
|
|||
status_code=403,
|
||||
detail="Only proxy admins can update all users at once.",
|
||||
)
|
||||
if data.user_updates.password is not None:
|
||||
bulk_password_error: Final[HTTPExceptionErrorDetail] = {
|
||||
"error": (
|
||||
"Setting one password for all users is not supported. "
|
||||
"Use per-user updates via the 'users' list instead."
|
||||
)
|
||||
}
|
||||
raise HTTPException(status_code=400, detail=bulk_password_error)
|
||||
# Optimized path for updating all users directly in database
|
||||
all_users_in_db: Final = await _user_table(prisma_client).find_many(order={"created_at": "desc"})
|
||||
|
||||
|
|
|
|||
121
litellm/proxy/management_endpoints/password_endpoints.py
Normal file
121
litellm/proxy/management_endpoints/password_endpoints.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""
|
||||
Self-service password management.
|
||||
|
||||
/user/password/change
|
||||
|
||||
Deliberately NOT wrapped in `management_endpoint_wrapper`: the wrapper emits
|
||||
request kwargs to OTEL spans, which would log plaintext passwords. The audit
|
||||
signal is emitted by hand below, with field names only, never values.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy._types import (
|
||||
ChangePasswordRequest,
|
||||
ChangePasswordResponse,
|
||||
CommonProxyErrors,
|
||||
HTTPExceptionErrorDetail,
|
||||
LitellmTableNames,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.auth.password_policy import validate_password_not_breached, validate_password_policy
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.management_helpers.audit_logs import create_object_audit_log
|
||||
from litellm.proxy.utils import hash_password, verify_password
|
||||
from litellm.repositories.prisma_protocols import TableActions
|
||||
from litellm.repositories.user_repository import UserRepository
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from prisma import models as prisma_models
|
||||
from prisma import types as prisma_types
|
||||
|
||||
from litellm.proxy.utils import PrismaClient
|
||||
|
||||
router: Final = APIRouter()
|
||||
|
||||
_PASSWORD_CHANGED_AUDIT_VALUES: Final = '{"fields_changed": ["password"]}'
|
||||
|
||||
|
||||
def _error_detail(message: str) -> HTTPExceptionErrorDetail:
|
||||
detail: Final[HTTPExceptionErrorDetail] = {"error": message}
|
||||
return detail
|
||||
|
||||
|
||||
def _user_table(
|
||||
prisma_client: "PrismaClient | None",
|
||||
) -> "TableActions[prisma_models.LiteLLM_UserTable]":
|
||||
user_table: Final[TableActions[prisma_models.LiteLLM_UserTable]] = UserRepository(prisma_client).table
|
||||
return user_table
|
||||
|
||||
|
||||
@router.post(
|
||||
"/user/password/change",
|
||||
tags=("Internal User management",),
|
||||
dependencies=(Depends(user_api_key_auth),),
|
||||
)
|
||||
async def change_password(
|
||||
data: ChangePasswordRequest,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
) -> ChangePasswordResponse:
|
||||
"""
|
||||
Change the calling user's own password.
|
||||
|
||||
Requires the current password. The new password must satisfy the
|
||||
configured password policy (`general_settings.password_policy_*`: minimum
|
||||
length, character classes, and, when enabled, breached-password screening
|
||||
via haveibeenpwned.com).
|
||||
|
||||
Parameters:
|
||||
- current_password: str - The user's current password.
|
||||
- new_password: str - The password to change to.
|
||||
"""
|
||||
from litellm.proxy.proxy_server import general_settings, litellm_proxy_admin_name, prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=_error_detail(CommonProxyErrors.db_not_connected_error.value),
|
||||
)
|
||||
|
||||
user_id: Final = user_api_key_dict.user_id
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=_error_detail("No user is associated with this session, so there is no password to change."),
|
||||
)
|
||||
|
||||
find_user: Final[prisma_types.LiteLLM_UserTableWhereInput] = {"user_id": user_id}
|
||||
user_row: Final = await _user_table(prisma_client).find_first(where=find_user)
|
||||
stored_password: Final = user_row.password if user_row is not None else None
|
||||
if stored_password is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=_error_detail(
|
||||
"This account has no password set, so there is no password to change. "
|
||||
"Passwords are set through an invitation link (POST /invitation/new)."
|
||||
),
|
||||
)
|
||||
|
||||
if not verify_password(data.current_password, stored_password):
|
||||
raise HTTPException(status_code=400, detail=_error_detail("Current password is incorrect."))
|
||||
|
||||
validate_password_policy(data.new_password, general_settings)
|
||||
await validate_password_not_breached(data.new_password, general_settings)
|
||||
|
||||
password_update: Final[prisma_types.LiteLLM_UserTableUpdateInput] = {"password": hash_password(data.new_password)}
|
||||
await _user_table(prisma_client).update(where=find_user, data=password_update)
|
||||
|
||||
verbose_proxy_logger.info("Password changed via /user/password/change for user_id=%s", user_id)
|
||||
await create_object_audit_log(
|
||||
object_id=user_id,
|
||||
action="updated",
|
||||
litellm_changed_by=None,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_proxy_admin_name=litellm_proxy_admin_name,
|
||||
table_name=LitellmTableNames.USER_TABLE_NAME,
|
||||
after_value=_PASSWORD_CHANGED_AUDIT_VALUES,
|
||||
)
|
||||
return ChangePasswordResponse(user_id=user_id, message="Password updated successfully.")
|
||||
|
|
@ -549,6 +549,9 @@ from litellm.proxy.management_endpoints.model_management_endpoints import (
|
|||
from litellm.proxy.management_endpoints.organization_endpoints import (
|
||||
router as organization_router,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.password_endpoints import (
|
||||
router as password_management_router,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.router_settings_endpoints import (
|
||||
router as router_settings_router,
|
||||
)
|
||||
|
|
@ -18709,6 +18712,7 @@ app.include_router(pass_through_router)
|
|||
app.include_router(health_router)
|
||||
app.include_router(key_management_router)
|
||||
app.include_router(internal_user_router)
|
||||
app.include_router(password_management_router)
|
||||
app.include_router(team_router)
|
||||
app.include_router(ui_sso_router)
|
||||
app.include_router(organization_router)
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ POST /team/key/bulk_update
|
|||
POST /team/permissions_bulk_update
|
||||
POST /team/{team_id}/disable_logging
|
||||
POST /user/bulk_update
|
||||
POST /user/password/change
|
||||
|
||||
# Alternate method or path for functionality the provider already manages elsewhere
|
||||
GET /credentials/by_model/{model_id}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import os
|
|||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
|
|
@ -38,7 +37,7 @@ def test_non_admin_config_update_route_rejected():
|
|||
request.query_params = {}
|
||||
|
||||
# Test that calling /config/update route raises HTTPException with 403 status
|
||||
with pytest.raises(Exception, match='Only proxy admin can be used to generate, delete, update') as exc_info:
|
||||
with pytest.raises(Exception, match="Only proxy admin can be used to generate, delete, update") as exc_info:
|
||||
RouteChecks.non_proxy_admin_allowed_routes_check(
|
||||
user_obj=user_obj,
|
||||
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
|
|
@ -49,9 +48,8 @@ def test_non_admin_config_update_route_rejected():
|
|||
)
|
||||
|
||||
# Verify the exception is raised with the correct message
|
||||
assert (
|
||||
"Only proxy admin can be used to generate, delete, update info for new keys/users/teams"
|
||||
in str(exc_info.value)
|
||||
assert "Only proxy admin can be used to generate, delete, update info for new keys/users/teams" in str(
|
||||
exc_info.value
|
||||
)
|
||||
assert "Route=/config/update" in str(exc_info.value)
|
||||
assert "Your role=internal_user" in str(exc_info.value)
|
||||
|
|
@ -130,7 +128,7 @@ def test_user_banner_update_rejected_for_non_admin():
|
|||
request = MagicMock(spec=Request)
|
||||
request.query_params = {}
|
||||
|
||||
with pytest.raises(Exception, match='Only proxy admin can be used to generate, delete, update') as exc_info:
|
||||
with pytest.raises(Exception, match="Only proxy admin can be used to generate, delete, update") as exc_info:
|
||||
RouteChecks.non_proxy_admin_allowed_routes_check(
|
||||
user_obj=user_obj,
|
||||
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
|
|
@ -703,9 +701,7 @@ def test_virtual_key_llm_api_route_includes_passthrough_prefix(route):
|
|||
|
||||
valid_token = UserAPIKeyAuth(user_id="test_user", allowed_routes=["llm_api_routes"])
|
||||
|
||||
result = RouteChecks.is_virtual_key_allowed_to_call_route(
|
||||
route=route, valid_token=valid_token
|
||||
)
|
||||
result = RouteChecks.is_virtual_key_allowed_to_call_route(route=route, valid_token=valid_token)
|
||||
|
||||
assert result is True
|
||||
|
||||
|
|
@ -730,9 +726,7 @@ def test_virtual_key_llm_api_routes_allows_google_routes(route):
|
|||
|
||||
valid_token = UserAPIKeyAuth(user_id="test_user", allowed_routes=["llm_api_routes"])
|
||||
|
||||
result = RouteChecks.is_virtual_key_allowed_to_call_route(
|
||||
route=route, valid_token=valid_token
|
||||
)
|
||||
result = RouteChecks.is_virtual_key_allowed_to_call_route(route=route, valid_token=valid_token)
|
||||
|
||||
assert result is True
|
||||
|
||||
|
|
@ -802,18 +796,14 @@ def test_google_routes_with_dynamic_model_names_accessible_to_internal_users():
|
|||
)
|
||||
# If no exception is raised, the test passes
|
||||
except Exception as e:
|
||||
pytest.fail(
|
||||
f"Internal user should be able to access Google generateContent route. Got error: {str(e)}"
|
||||
)
|
||||
pytest.fail(f"Internal user should be able to access Google generateContent route. Got error: {e!s}")
|
||||
|
||||
|
||||
def test_virtual_key_allowed_routes_with_multiple_litellm_routes_member_names():
|
||||
"""Test that virtual key works with multiple LiteLLMRoutes member names in allowed_routes"""
|
||||
|
||||
# Create a UserAPIKeyAuth with multiple LiteLLMRoutes member names
|
||||
valid_token = UserAPIKeyAuth(
|
||||
user_id="test_user", allowed_routes=["openai_routes", "info_routes"]
|
||||
)
|
||||
valid_token = UserAPIKeyAuth(user_id="test_user", allowed_routes=["openai_routes", "info_routes"])
|
||||
|
||||
# Test that routes from both groups are allowed
|
||||
result1 = RouteChecks.is_virtual_key_allowed_to_call_route(
|
||||
|
|
@ -867,13 +857,9 @@ def test_virtual_key_allowed_routes_with_no_member_names_only_explicit():
|
|||
)
|
||||
|
||||
# Test that explicit routes are allowed
|
||||
result1 = RouteChecks.is_virtual_key_allowed_to_call_route(
|
||||
route="/chat/completions", valid_token=valid_token
|
||||
)
|
||||
result1 = RouteChecks.is_virtual_key_allowed_to_call_route(route="/chat/completions", valid_token=valid_token)
|
||||
|
||||
result2 = RouteChecks.is_virtual_key_allowed_to_call_route(
|
||||
route="/custom/route", valid_token=valid_token
|
||||
)
|
||||
result2 = RouteChecks.is_virtual_key_allowed_to_call_route(route="/custom/route", valid_token=valid_token)
|
||||
|
||||
assert result1 is True
|
||||
assert result2 is True
|
||||
|
|
@ -1241,9 +1227,7 @@ def test_virtual_key_without_llm_api_routes_cannot_access_pass_through():
|
|||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "Virtual key is not allowed to call this route" in str(
|
||||
exc_info.value.detail
|
||||
)
|
||||
assert "Virtual key is not allowed to call this route" in str(exc_info.value.detail)
|
||||
|
||||
|
||||
def test_check_passthrough_route_access_key_metadata_exact_match():
|
||||
|
|
@ -1702,9 +1686,7 @@ def test_videos_route_accessible_to_internal_users():
|
|||
)
|
||||
# If no exception is raised, the test passes
|
||||
except Exception as e:
|
||||
pytest.fail(
|
||||
f"Internal user should be able to access /v1/videos route. Got error: {str(e)}"
|
||||
)
|
||||
pytest.fail(f"Internal user should be able to access /v1/videos route. Got error: {e!s}")
|
||||
|
||||
|
||||
def test_videos_route_with_virtual_key_llm_api_routes():
|
||||
|
|
@ -1726,12 +1708,8 @@ def test_videos_route_with_virtual_key_llm_api_routes():
|
|||
]
|
||||
|
||||
for route in test_routes:
|
||||
result = RouteChecks.is_virtual_key_allowed_to_call_route(
|
||||
route=route, valid_token=valid_token
|
||||
)
|
||||
assert (
|
||||
result is True
|
||||
), f"Virtual key with llm_api_routes should be able to access {route}"
|
||||
result = RouteChecks.is_virtual_key_allowed_to_call_route(route=route, valid_token=valid_token)
|
||||
assert result is True, f"Virtual key with llm_api_routes should be able to access {route}"
|
||||
|
||||
|
||||
def test_non_proxy_admin_wildcard_allowed_routes():
|
||||
|
|
@ -1802,9 +1780,7 @@ def test_proxy_admin_viewer_can_access_global_spend_tags():
|
|||
)
|
||||
# If no exception is raised, the test passes
|
||||
except Exception as e:
|
||||
pytest.fail(
|
||||
f"proxy_admin_viewer should be able to access /global/spend/tags route. Got error: {str(e)}"
|
||||
)
|
||||
pytest.fail(f"proxy_admin_viewer should be able to access /global/spend/tags route. Got error: {e!s}")
|
||||
|
||||
|
||||
# Routes returning proxy-wide spend across every team / customer / api_key.
|
||||
|
|
@ -1832,7 +1808,7 @@ def test_internal_user_blocked_from_global_spend_routes(route):
|
|||
request = MagicMock(spec=Request)
|
||||
request.query_params = {}
|
||||
|
||||
with pytest.raises(Exception, match='Only proxy admin can be used to generate, delete, update') as exc_info:
|
||||
with pytest.raises(Exception, match="Only proxy admin can be used to generate, delete, update") as exc_info:
|
||||
RouteChecks.non_proxy_admin_allowed_routes_check(
|
||||
user_obj=user_obj,
|
||||
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
|
|
@ -1861,7 +1837,7 @@ def test_internal_user_view_only_blocked_from_global_spend_routes(route):
|
|||
request = MagicMock(spec=Request)
|
||||
request.query_params = {}
|
||||
|
||||
with pytest.raises(Exception, match='Only proxy admin can be used to generate, delete, update') as exc_info:
|
||||
with pytest.raises(Exception, match="Only proxy admin can be used to generate, delete, update") as exc_info:
|
||||
RouteChecks.non_proxy_admin_allowed_routes_check(
|
||||
user_obj=user_obj,
|
||||
_user_role=LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
|
||||
|
|
@ -1963,9 +1939,7 @@ def test_proxy_admin_viewer_can_access_audit_logs(route):
|
|||
request_data={},
|
||||
)
|
||||
except Exception as e:
|
||||
pytest.fail(
|
||||
f"proxy_admin_viewer should be able to access {route} route. Got error: {str(e)}"
|
||||
)
|
||||
pytest.fail(f"proxy_admin_viewer should be able to access {route} route. Got error: {e!s}")
|
||||
|
||||
|
||||
# ── Admin Viewer parity: Logs page endpoints ──────────────────────────────────
|
||||
|
|
@ -2028,9 +2002,7 @@ def test_proxy_admin_viewer_can_access_logs_page_endpoints(route):
|
|||
request_data={},
|
||||
)
|
||||
except Exception as e:
|
||||
pytest.fail(
|
||||
f"proxy_admin_viewer should be able to access {route}. Got error: {str(e)}"
|
||||
)
|
||||
pytest.fail(f"proxy_admin_viewer should be able to access {route}. Got error: {e!s}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -2140,7 +2112,7 @@ def test_internal_user_blocked_from_admin_viewer_logs_routes(route):
|
|||
if route not in INTERNAL_USER_BLOCKED_SUBSET:
|
||||
return
|
||||
|
||||
with pytest.raises(Exception, match='Only proxy admin can be used to generate, delete, update') as exc_info:
|
||||
with pytest.raises(Exception, match="Only proxy admin can be used to generate, delete, update") as exc_info:
|
||||
RouteChecks.non_proxy_admin_allowed_routes_check(
|
||||
user_obj=user_obj,
|
||||
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
|
|
@ -2216,9 +2188,7 @@ def test_proxy_admin_viewer_can_access_settings_read_endpoints(route):
|
|||
request_data={},
|
||||
)
|
||||
except Exception as e:
|
||||
pytest.fail(
|
||||
f"proxy_admin_viewer should be able to access {route}. Got error: {str(e)}"
|
||||
)
|
||||
pytest.fail(f"proxy_admin_viewer should be able to access {route}. Got error: {e!s}")
|
||||
|
||||
|
||||
# ── Admin Viewer parity: default-allow GET semantics ─────────────────────────
|
||||
|
|
@ -2417,9 +2387,7 @@ class TestModelsRouteExemptFromDisableLLMEndpoints:
|
|||
)
|
||||
local_file = os.path.abspath(local_file)
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"local_enterprise_route_checks", local_file
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location("local_enterprise_route_checks", local_file)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod.EnterpriseRouteChecks
|
||||
|
|
@ -2430,9 +2398,7 @@ class TestModelsRouteExemptFromDisableLLMEndpoints:
|
|||
EnterpriseRouteChecks = self._get_enterprise_route_checks()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True
|
||||
),
|
||||
patch.object(EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True),
|
||||
patch.object(
|
||||
EnterpriseRouteChecks,
|
||||
"is_management_routes_disabled",
|
||||
|
|
@ -2448,9 +2414,7 @@ class TestModelsRouteExemptFromDisableLLMEndpoints:
|
|||
EnterpriseRouteChecks = self._get_enterprise_route_checks()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True
|
||||
),
|
||||
patch.object(EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True),
|
||||
patch.object(
|
||||
EnterpriseRouteChecks,
|
||||
"is_management_routes_disabled",
|
||||
|
|
@ -2466,9 +2430,7 @@ class TestModelsRouteExemptFromDisableLLMEndpoints:
|
|||
EnterpriseRouteChecks = self._get_enterprise_route_checks()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True
|
||||
),
|
||||
patch.object(EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True),
|
||||
patch.object(
|
||||
EnterpriseRouteChecks,
|
||||
"is_management_routes_disabled",
|
||||
|
|
@ -2479,9 +2441,7 @@ class TestModelsRouteExemptFromDisableLLMEndpoints:
|
|||
EnterpriseRouteChecks.should_call_route("/v1/chat/completions")
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "LLM API routes are disabled for this instance." in str(
|
||||
exc_info.value.detail
|
||||
)
|
||||
assert "LLM API routes are disabled for this instance." in str(exc_info.value.detail)
|
||||
|
||||
@patch("litellm.proxy.proxy_server.premium_user", True)
|
||||
def test_should_embeddings_still_blocked_when_llm_api_disabled(self):
|
||||
|
|
@ -2489,9 +2449,7 @@ class TestModelsRouteExemptFromDisableLLMEndpoints:
|
|||
EnterpriseRouteChecks = self._get_enterprise_route_checks()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True
|
||||
),
|
||||
patch.object(EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True),
|
||||
patch.object(
|
||||
EnterpriseRouteChecks,
|
||||
"is_management_routes_disabled",
|
||||
|
|
@ -2509,9 +2467,7 @@ class TestModelsRouteExemptFromDisableLLMEndpoints:
|
|||
EnterpriseRouteChecks = self._get_enterprise_route_checks()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=False
|
||||
),
|
||||
patch.object(EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=False),
|
||||
patch.object(
|
||||
EnterpriseRouteChecks,
|
||||
"is_management_routes_disabled",
|
||||
|
|
@ -2530,9 +2486,7 @@ def test_route_in_additional_public_routes_wildcard_match():
|
|||
from litellm.proxy.auth.auth_utils import route_in_additonal_public_routes
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.general_settings", {"public_routes": ["/api/*"]}
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.general_settings", {"public_routes": ["/api/*"]}),
|
||||
patch("litellm.proxy.proxy_server.premium_user", True),
|
||||
):
|
||||
# Wildcard should match subpaths
|
||||
|
|
@ -2624,7 +2578,7 @@ def test_non_admin_non_team_admin_cannot_access_config_update_but_can_attempt_re
|
|||
)
|
||||
|
||||
# /config/update is still blocked
|
||||
with pytest.raises(Exception, match='Only proxy admin can be used to generate, delete, update') as exc_info:
|
||||
with pytest.raises(Exception, match="Only proxy admin can be used to generate, delete, update") as exc_info:
|
||||
RouteChecks.non_proxy_admin_allowed_routes_check(
|
||||
user_obj=user_obj,
|
||||
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
|
|
@ -2712,8 +2666,6 @@ def test_available_roles_accessible_to_non_admin_users(user_role):
|
|||
# ── _user_is_org_admin tests ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
|
||||
def _make_org_admin_user(org_id: str) -> LiteLLM_UserTable:
|
||||
membership = LiteLLM_OrganizationMembershipTable(
|
||||
user_id="org-admin-user",
|
||||
|
|
@ -2836,9 +2788,7 @@ async def test_add_team_org_context_noop_when_org_id_already_present():
|
|||
raise AssertionError("must not resolve when organization_id is present")
|
||||
|
||||
body = {"team_id": "team-1", "organization_id": "org-explicit"}
|
||||
out = await add_team_org_context_to_request_body(
|
||||
route="/team/update", request_body=body, fetch_team_org_id=fetch
|
||||
)
|
||||
out = await add_team_org_context_to_request_body(route="/team/update", request_body=body, fetch_team_org_id=fetch)
|
||||
assert out == body
|
||||
|
||||
|
||||
|
|
@ -2850,9 +2800,7 @@ async def test_add_team_org_context_noop_for_other_routes():
|
|||
raise AssertionError("must not resolve for a non-opted-in route")
|
||||
|
||||
body = {"team_id": "team-1"}
|
||||
out = await add_team_org_context_to_request_body(
|
||||
route="/team/delete", request_body=body, fetch_team_org_id=fetch
|
||||
)
|
||||
out = await add_team_org_context_to_request_body(route="/team/delete", request_body=body, fetch_team_org_id=fetch)
|
||||
assert out == body
|
||||
|
||||
|
||||
|
|
@ -2865,9 +2813,7 @@ async def test_add_team_org_context_noop_when_team_has_no_org():
|
|||
return None
|
||||
|
||||
body = {"team_id": "team-1"}
|
||||
out = await add_team_org_context_to_request_body(
|
||||
route="/team/update", request_body=body, fetch_team_org_id=fetch
|
||||
)
|
||||
out = await add_team_org_context_to_request_body(route="/team/update", request_body=body, fetch_team_org_id=fetch)
|
||||
assert out == body
|
||||
|
||||
|
||||
|
|
@ -3151,9 +3097,7 @@ async def test_initialize_pass_through_registers_wildcard_for_auth_subpath():
|
|||
# Removing the endpoint should clean up openai_routes
|
||||
# remove_endpoint_routes takes endpoint_id (UUID portion of
|
||||
# the route key "{id}:exact:{path}:{methods}")
|
||||
registered = (
|
||||
InitPassThroughEndpointHelpers.get_all_registered_pass_through_routes()
|
||||
)
|
||||
registered = InitPassThroughEndpointHelpers.get_all_registered_pass_through_routes()
|
||||
endpoint_ids = {k.split(":")[0] for k in registered}
|
||||
for eid in endpoint_ids:
|
||||
InitPassThroughEndpointHelpers.remove_endpoint_routes(eid)
|
||||
|
|
@ -3163,9 +3107,7 @@ async def test_initialize_pass_through_registers_wildcard_for_auth_subpath():
|
|||
LiteLLMRoutes.openai_routes.value[:] = original_routes
|
||||
# Clean up any routes registered during this test to avoid
|
||||
# polluting the module-level _registered_pass_through_routes
|
||||
registered = (
|
||||
InitPassThroughEndpointHelpers.get_all_registered_pass_through_routes()
|
||||
)
|
||||
registered = InitPassThroughEndpointHelpers.get_all_registered_pass_through_routes()
|
||||
for k in registered:
|
||||
InitPassThroughEndpointHelpers.remove_endpoint_routes(k.split(":")[0])
|
||||
|
||||
|
|
@ -3196,8 +3138,7 @@ def test_provider_name_substring_not_classified_as_llm_route(route):
|
|||
from litellm.proxy.auth.route_checks import RouteChecks
|
||||
|
||||
assert RouteChecks.is_llm_api_route(route=route) is False, (
|
||||
f"{route!r} should NOT be classified as an LLM API route — "
|
||||
"provider-name substring match bypass"
|
||||
f"{route!r} should NOT be classified as an LLM API route — provider-name substring match bypass"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -3219,9 +3160,7 @@ def test_legitimate_passthrough_routes_still_classified_as_llm_route(route):
|
|||
"""Legitimate passthrough routes must still pass is_llm_api_route."""
|
||||
from litellm.proxy.auth.route_checks import RouteChecks
|
||||
|
||||
assert (
|
||||
RouteChecks.is_llm_api_route(route=route) is True
|
||||
), f"{route!r} should be classified as an LLM API route"
|
||||
assert RouteChecks.is_llm_api_route(route=route) is True, f"{route!r} should be classified as an LLM API route"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -3279,7 +3218,7 @@ def test_internal_user_blocked_from_search_tool_writes(route):
|
|||
request = MagicMock(spec=Request)
|
||||
request.query_params = {}
|
||||
|
||||
with pytest.raises(Exception, match='Only proxy admin can be used to generate, delete, update') as exc_info:
|
||||
with pytest.raises(Exception, match="Only proxy admin can be used to generate, delete, update") as exc_info:
|
||||
RouteChecks.non_proxy_admin_allowed_routes_check(
|
||||
user_obj=user_obj,
|
||||
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
|
|
@ -3655,12 +3594,7 @@ def test_agent_inference_routes_stay_llm_api(route):
|
|||
def test_agent_routes_union_still_covers_both_halves(route):
|
||||
"""Keys configured with allowed_routes=["agent_routes"] must keep both halves."""
|
||||
|
||||
assert (
|
||||
RouteChecks.check_route_access(
|
||||
route=route, allowed_routes=LiteLLMRoutes.agent_routes.value
|
||||
)
|
||||
is True
|
||||
)
|
||||
assert RouteChecks.check_route_access(route=route, allowed_routes=LiteLLMRoutes.agent_routes.value) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", AGENT_MANAGEMENT_ROUTES)
|
||||
|
|
@ -3714,6 +3648,75 @@ def test_agent_registry_route_gate_open_to_non_admin_roles(user_role, method, ro
|
|||
valid_token=valid_token,
|
||||
request_data={},
|
||||
)
|
||||
|
||||
|
||||
def test_proxy_admin_viewer_user_update_password_param_rejected():
|
||||
"""The self-service /user/update password carve-out is closed: non-admins
|
||||
change their own password through /user/password/change, which verifies
|
||||
the current password. Admin password sets don't pass through this check."""
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
RouteChecks._check_proxy_admin_viewer_access(
|
||||
route="/user/update",
|
||||
_user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
||||
request_data={"password": "hunter2hunter2"},
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "password" in str(exc_info.value.detail)
|
||||
|
||||
|
||||
def test_proxy_admin_viewer_user_update_user_email_still_allowed():
|
||||
request = MagicMock(spec=Request)
|
||||
request.method = "POST"
|
||||
|
||||
allowed = RouteChecks._check_proxy_admin_viewer_access(
|
||||
route="/user/update",
|
||||
_user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
||||
request_data={"user_email": "viewer@example.com"},
|
||||
request=request,
|
||||
)
|
||||
|
||||
assert allowed is None
|
||||
|
||||
|
||||
def test_proxy_admin_viewer_can_change_own_password():
|
||||
request = MagicMock(spec=Request)
|
||||
request.method = "POST"
|
||||
|
||||
allowed = RouteChecks._check_proxy_admin_viewer_access(
|
||||
route="/user/password/change",
|
||||
_user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
||||
request_data={"current_password": "a", "new_password": "b"},
|
||||
request=request,
|
||||
)
|
||||
|
||||
assert allowed is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"user_role",
|
||||
[
|
||||
LitellmUserRoles.INTERNAL_USER.value,
|
||||
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
|
||||
],
|
||||
)
|
||||
def test_non_admin_roles_can_change_own_password(user_role):
|
||||
valid_token = UserAPIKeyAuth(user_id="test_user", user_role=user_role)
|
||||
request = MagicMock(spec=Request)
|
||||
request.method = "POST"
|
||||
request.query_params = {}
|
||||
|
||||
allowed = RouteChecks.non_proxy_admin_allowed_routes_check(
|
||||
user_obj=LiteLLM_UserTable(user_id="test_user", user_role=user_role),
|
||||
_user_role=user_role,
|
||||
route="/user/password/change",
|
||||
request=request,
|
||||
valid_token=valid_token,
|
||||
request_data={"current_password": "a", "new_password": "b"},
|
||||
)
|
||||
|
||||
assert allowed is None
|
||||
|
||||
|
||||
TEAM_CALLBACK_ROUTES = (
|
||||
"/team/06bda574-5ca9-43d3-beb8-3b23c2f17112/callback",
|
||||
"/team/06bda574-5ca9-43d3-beb8-3b23c2f17112/callback/langfuse",
|
||||
|
|
|
|||
|
|
@ -4297,6 +4297,35 @@ async def test_user_update_rejects_breached_password(_admin_prisma):
|
|||
_admin_prisma.db.litellm_usertable.find_first.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_update_all_users_rejects_a_password(_admin_prisma):
|
||||
"""The all_users fast path writes user_updates straight to update_many,
|
||||
bypassing _update_single_user_helper. A password riding along would be
|
||||
stored as unvalidated plaintext on every row, so it must be rejected
|
||||
before any DB access."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy._types import UpdateUserRequestNoUserIDorEmail
|
||||
from litellm.proxy.management_endpoints.internal_user_endpoints import bulk_user_update
|
||||
from litellm.types.proxy.management_endpoints.internal_user_endpoints import (
|
||||
BulkUpdateUserRequest,
|
||||
)
|
||||
|
||||
data = BulkUpdateUserRequest(
|
||||
all_users=True,
|
||||
user_updates=UpdateUserRequestNoUserIDorEmail(password="Str0ng!Passw0rd"),
|
||||
)
|
||||
admin_caller = UserAPIKeyAuth(user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await bulk_user_update(data=data, user_api_key_dict=admin_caller)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "not supported" in str(exc_info.value.detail)
|
||||
_admin_prisma.db.litellm_usertable.find_many.assert_not_called()
|
||||
_admin_prisma.db.litellm_usertable.update_many.assert_not_called()
|
||||
|
||||
|
||||
def _hibp_client_with_handler(handler) -> AsyncHTTPHandler:
|
||||
"""A real AsyncHTTPHandler over httpx.MockTransport (the DI seam used
|
||||
throughout test_password_policy.py), so no network is touched."""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,327 @@
|
|||
"""
|
||||
Tests for POST /user/password/change (litellm/proxy/management_endpoints/password_endpoints.py).
|
||||
|
||||
HIBP traffic is intercepted with respx; no test here touches the network.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy._types import LitellmTableNames, ProxyErrorTypes, ProxyException, UserAPIKeyAuth
|
||||
from litellm.proxy.management_endpoints.password_endpoints import change_password
|
||||
from litellm.proxy.utils import hash_password, verify_password
|
||||
|
||||
CURRENT_PASSWORD = "OldP@ssw0rd-2026"
|
||||
NEW_PASSWORD = "NewP@ssw0rd-2026"
|
||||
|
||||
_POLICY_NO_BREACH_CHECK = {"password_policy_check_breached_passwords": False}
|
||||
|
||||
|
||||
def _make_user_row(password: str | None) -> MagicMock:
|
||||
user = MagicMock()
|
||||
user.user_id = "user-123"
|
||||
user.password = password
|
||||
return user
|
||||
|
||||
|
||||
def _make_prisma(user: MagicMock | None) -> MagicMock:
|
||||
prisma = MagicMock()
|
||||
prisma.db.litellm_usertable.find_first = AsyncMock(return_value=user)
|
||||
prisma.db.litellm_usertable.update = AsyncMock(return_value=user)
|
||||
return prisma
|
||||
|
||||
|
||||
def _caller(user_id: str | None = "user-123") -> UserAPIKeyAuth:
|
||||
return UserAPIKeyAuth(user_id=user_id)
|
||||
|
||||
|
||||
def _hibp_url_for(password: str) -> str:
|
||||
sha1 = hashlib.sha1(password.encode(), usedforsecurity=False).hexdigest().upper()
|
||||
return f"https://api.pwnedpasswords.com/range/{sha1[:5]}"
|
||||
|
||||
|
||||
def _hibp_suffix_for(password: str) -> str:
|
||||
return hashlib.sha1(password.encode(), usedforsecurity=False).hexdigest().upper()[5:]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_change_password_success_writes_new_scrypt_hash():
|
||||
from litellm.proxy._types import ChangePasswordRequest
|
||||
|
||||
prisma = _make_prisma(_make_user_row(hash_password(CURRENT_PASSWORD)))
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
|
||||
"litellm.proxy.proxy_server.prisma_client", prisma
|
||||
),
|
||||
patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
|
||||
"litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK
|
||||
),
|
||||
):
|
||||
response = await change_password(
|
||||
data=ChangePasswordRequest(current_password=CURRENT_PASSWORD, new_password=NEW_PASSWORD),
|
||||
user_api_key_dict=_caller(),
|
||||
)
|
||||
|
||||
assert response.user_id == "user-123"
|
||||
update_kwargs = prisma.db.litellm_usertable.update.call_args.kwargs
|
||||
assert update_kwargs["where"] == {"user_id": "user-123"}
|
||||
stored = update_kwargs["data"]["password"]
|
||||
assert stored != NEW_PASSWORD
|
||||
assert verify_password(NEW_PASSWORD, stored)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_change_password_rejects_wrong_current_password():
|
||||
from litellm.proxy._types import ChangePasswordRequest
|
||||
|
||||
prisma = _make_prisma(_make_user_row(hash_password(CURRENT_PASSWORD)))
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
|
||||
"litellm.proxy.proxy_server.prisma_client", prisma
|
||||
),
|
||||
patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
|
||||
"litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await change_password(
|
||||
data=ChangePasswordRequest(current_password="not-the-password", new_password=NEW_PASSWORD),
|
||||
user_api_key_dict=_caller(),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "Current password is incorrect" in exc_info.value.detail["error"]
|
||||
prisma.db.litellm_usertable.update.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_change_password_rejects_session_without_user():
|
||||
from litellm.proxy._types import ChangePasswordRequest
|
||||
|
||||
prisma = _make_prisma(user=None)
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
|
||||
"litellm.proxy.proxy_server.prisma_client", prisma
|
||||
),
|
||||
patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
|
||||
"litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await change_password(
|
||||
data=ChangePasswordRequest(current_password=CURRENT_PASSWORD, new_password=NEW_PASSWORD),
|
||||
user_api_key_dict=_caller(user_id=None),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
prisma.db.litellm_usertable.find_first.assert_not_called()
|
||||
prisma.db.litellm_usertable.update.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_change_password_rejects_account_without_password():
|
||||
"""SSO users and the env-credential admin have no DB password row to change."""
|
||||
from litellm.proxy._types import ChangePasswordRequest
|
||||
|
||||
prisma = _make_prisma(_make_user_row(password=None))
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
|
||||
"litellm.proxy.proxy_server.prisma_client", prisma
|
||||
),
|
||||
patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
|
||||
"litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await change_password(
|
||||
data=ChangePasswordRequest(current_password=CURRENT_PASSWORD, new_password=NEW_PASSWORD),
|
||||
user_api_key_dict=_caller(),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "no password set" in exc_info.value.detail["error"]
|
||||
prisma.db.litellm_usertable.update.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_change_password_enforces_min_length():
|
||||
from litellm.proxy._types import ChangePasswordRequest
|
||||
|
||||
prisma = _make_prisma(_make_user_row(hash_password(CURRENT_PASSWORD)))
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
|
||||
"litellm.proxy.proxy_server.prisma_client", prisma
|
||||
),
|
||||
patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
|
||||
"litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK
|
||||
),
|
||||
):
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await change_password(
|
||||
data=ChangePasswordRequest(current_password=CURRENT_PASSWORD, new_password="Short1!"),
|
||||
user_api_key_dict=_caller(),
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "400"
|
||||
assert exc_info.value.type == ProxyErrorTypes.validation_error
|
||||
assert exc_info.value.param == "password"
|
||||
assert "at least 12 characters" in exc_info.value.message
|
||||
prisma.db.litellm_usertable.update.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_change_password_rejects_breached_password():
|
||||
"""With the default policy, the new password is screened against HIBP."""
|
||||
from litellm.proxy._types import ChangePasswordRequest
|
||||
|
||||
breached_password = "Password123!"
|
||||
respx.get(_hibp_url_for(breached_password)).mock(
|
||||
return_value=httpx.Response(200, text=f"{_hibp_suffix_for(breached_password)}:1")
|
||||
)
|
||||
prisma = _make_prisma(_make_user_row(hash_password(CURRENT_PASSWORD)))
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
|
||||
"litellm.proxy.proxy_server.prisma_client", prisma
|
||||
),
|
||||
patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
|
||||
"litellm.proxy.proxy_server.general_settings", {}
|
||||
),
|
||||
):
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await change_password(
|
||||
data=ChangePasswordRequest(current_password=CURRENT_PASSWORD, new_password=breached_password),
|
||||
user_api_key_dict=_caller(),
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "400"
|
||||
assert exc_info.value.type == ProxyErrorTypes.validation_error
|
||||
assert exc_info.value.param == "password"
|
||||
assert "data breaches" in exc_info.value.message
|
||||
prisma.db.litellm_usertable.update.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@respx.mock
|
||||
async def test_change_password_verifies_current_password_before_hibp_lookup():
|
||||
"""A caller who fails current-password verification must not trigger any
|
||||
HIBP traffic. The HIBP check fails open on errors, so an unmocked lookup
|
||||
could not prove ordering; instead the route is registered and asserted
|
||||
uncalled."""
|
||||
from litellm.proxy._types import ChangePasswordRequest
|
||||
|
||||
hibp_route = respx.get(_hibp_url_for(NEW_PASSWORD)).mock(return_value=httpx.Response(200, text=""))
|
||||
prisma = _make_prisma(_make_user_row(hash_password(CURRENT_PASSWORD)))
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
|
||||
"litellm.proxy.proxy_server.prisma_client", prisma
|
||||
),
|
||||
patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
|
||||
"litellm.proxy.proxy_server.general_settings", {}
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await change_password(
|
||||
data=ChangePasswordRequest(current_password="not-the-password", new_password=NEW_PASSWORD),
|
||||
user_api_key_dict=_caller(),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "Current password is incorrect" in exc_info.value.detail["error"]
|
||||
assert not hibp_route.called
|
||||
prisma.db.litellm_usertable.update.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_change_password_success_emits_redacted_audit_log():
|
||||
"""A successful change must land in the audit trail as field names only;
|
||||
the plaintext passwords must never reach the audit call."""
|
||||
from litellm.proxy._types import ChangePasswordRequest
|
||||
|
||||
prisma = _make_prisma(_make_user_row(hash_password(CURRENT_PASSWORD)))
|
||||
audit_mock = AsyncMock()
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
|
||||
"litellm.proxy.proxy_server.prisma_client", prisma
|
||||
),
|
||||
patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
|
||||
"litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK
|
||||
),
|
||||
patch( # test-quality-ok: audit sink is a module-level import; no injection seam
|
||||
"litellm.proxy.management_endpoints.password_endpoints.create_object_audit_log", audit_mock
|
||||
),
|
||||
):
|
||||
await change_password(
|
||||
data=ChangePasswordRequest(current_password=CURRENT_PASSWORD, new_password=NEW_PASSWORD),
|
||||
user_api_key_dict=_caller(),
|
||||
)
|
||||
|
||||
audit_mock.assert_awaited_once()
|
||||
audit_kwargs = audit_mock.await_args.kwargs
|
||||
assert audit_kwargs["object_id"] == "user-123"
|
||||
assert audit_kwargs["action"] == "updated"
|
||||
assert audit_kwargs["table_name"] == LitellmTableNames.USER_TABLE_NAME
|
||||
assert audit_kwargs["after_value"] == '{"fields_changed": ["password"]}'
|
||||
assert CURRENT_PASSWORD not in str(audit_kwargs)
|
||||
assert NEW_PASSWORD not in str(audit_kwargs)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_change_password_failure_emits_no_audit_log():
|
||||
from litellm.proxy._types import ChangePasswordRequest
|
||||
|
||||
prisma = _make_prisma(_make_user_row(hash_password(CURRENT_PASSWORD)))
|
||||
audit_mock = AsyncMock()
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
|
||||
"litellm.proxy.proxy_server.prisma_client", prisma
|
||||
),
|
||||
patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
|
||||
"litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK
|
||||
),
|
||||
patch( # test-quality-ok: audit sink is a module-level import; no injection seam
|
||||
"litellm.proxy.management_endpoints.password_endpoints.create_object_audit_log", audit_mock
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException):
|
||||
await change_password(
|
||||
data=ChangePasswordRequest(current_password="not-the-password", new_password=NEW_PASSWORD),
|
||||
user_api_key_dict=_caller(),
|
||||
)
|
||||
|
||||
audit_mock.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_change_password_requires_db():
|
||||
from litellm.proxy._types import ChangePasswordRequest
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
|
||||
"litellm.proxy.proxy_server.prisma_client", None
|
||||
),
|
||||
patch( # test-quality-ok: change_password reads proxy_server module globals; no injection seam
|
||||
"litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await change_password(
|
||||
data=ChangePasswordRequest(current_password=CURRENT_PASSWORD, new_password=NEW_PASSWORD),
|
||||
user_api_key_dict=_caller(),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
|
|
@ -5,6 +5,7 @@ from pydantic import ValidationError
|
|||
|
||||
from litellm.proxy._types import (
|
||||
ROLES_WITHIN_ORG,
|
||||
ChangePasswordRequest,
|
||||
GenerateKeyRequest,
|
||||
KeyRequest,
|
||||
LiteLLM_AuditLogs,
|
||||
|
|
@ -293,3 +294,28 @@ def test_new_user_request_loudly_rejects_a_password():
|
|||
def test_new_user_request_without_password_still_works():
|
||||
request = NewUserRequest(user_email="alice@example.com")
|
||||
assert request.password is None
|
||||
|
||||
|
||||
def test_update_user_request_accepts_a_password():
|
||||
"""Admins set user passwords through /user/update; the value must survive
|
||||
model validation so the endpoint can policy-check and hash it."""
|
||||
request = UpdateUserRequest(user_id="user-123", password="hunter2hunter2")
|
||||
assert request.password == "hunter2hunter2"
|
||||
|
||||
|
||||
def test_update_user_request_password_hidden_from_repr():
|
||||
"""management_endpoint_wrapper string-formats endpoint kwargs into Slack
|
||||
alerts, so the model's repr/str must never contain the plaintext password."""
|
||||
request = UpdateUserRequest(user_id="user-123", password="hunter2hunter2")
|
||||
assert "hunter2hunter2" not in repr(request)
|
||||
assert "hunter2hunter2" not in str(request)
|
||||
|
||||
|
||||
def test_change_password_request_passwords_hidden_from_repr():
|
||||
"""Any accidental str()/repr() of the request model (debug logs, exception
|
||||
handlers, a future management_endpoint_wrapper) must never contain either
|
||||
plaintext password."""
|
||||
request = ChangePasswordRequest(current_password="hunter2hunter2", new_password="NewP@ssw0rd-2026")
|
||||
for rendered in (repr(request), str(request)):
|
||||
assert "hunter2hunter2" not in rendered
|
||||
assert "NewP@ssw0rd-2026" not in rendered
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ChangePasswordForm from "./ChangePasswordForm";
|
||||
|
||||
const mockChangePasswordCall = vi.fn();
|
||||
const mockToastSuccess = vi.fn();
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
changePasswordCall: (...args: unknown[]) => mockChangePasswordCall(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: () => ({ accessToken: "sk-session-token" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/toast", () => ({
|
||||
toast: {
|
||||
success: (...args: unknown[]) => mockToastSuccess(...args),
|
||||
fromError: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const fillForm = (values: { current: string; next: string; confirm: string }) => {
|
||||
fireEvent.change(screen.getByLabelText("Current Password"), { target: { value: values.current } });
|
||||
fireEvent.change(screen.getByLabelText("New Password"), { target: { value: values.next } });
|
||||
fireEvent.change(screen.getByLabelText("Confirm New Password"), { target: { value: values.confirm } });
|
||||
};
|
||||
|
||||
const submit = () => fireEvent.click(screen.getByRole("button", { name: "Change Password" }));
|
||||
|
||||
describe("ChangePasswordForm", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("sends the current and new password to the change endpoint and resets on success", async () => {
|
||||
mockChangePasswordCall.mockResolvedValue({ user_id: "user-123", message: "Password updated successfully." });
|
||||
render(<ChangePasswordForm />);
|
||||
|
||||
fillForm({ current: "OldP@ssw0rd-2026", next: "NewP@ssw0rd-2026", confirm: "NewP@ssw0rd-2026" });
|
||||
submit();
|
||||
|
||||
expect(await screen.findByLabelText("Current Password")).toHaveValue("");
|
||||
expect(mockChangePasswordCall).toHaveBeenCalledWith("sk-session-token", "OldP@ssw0rd-2026", "NewP@ssw0rd-2026");
|
||||
expect(mockToastSuccess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks submission when the confirmation does not match", async () => {
|
||||
render(<ChangePasswordForm />);
|
||||
|
||||
fillForm({ current: "OldP@ssw0rd-2026", next: "NewP@ssw0rd-2026", confirm: "Different-2026" });
|
||||
submit();
|
||||
|
||||
expect(await screen.findByText("New passwords do not match")).toBeInTheDocument();
|
||||
expect(mockChangePasswordCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the proxy's rejection message unwrapped", async () => {
|
||||
mockChangePasswordCall.mockRejectedValue(new Error("{'error': 'Current password is incorrect.'}"));
|
||||
render(<ChangePasswordForm />);
|
||||
|
||||
fillForm({ current: "wrong-password", next: "NewP@ssw0rd-2026", confirm: "NewP@ssw0rd-2026" });
|
||||
submit();
|
||||
|
||||
expect(await screen.findByText("Current password is incorrect.")).toBeInTheDocument();
|
||||
expect(mockToastSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { CircleAlert } from "lucide-react";
|
||||
import { z } from "zod/v4";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { Alert, AlertTitle } from "@/components/shared/Alert";
|
||||
import { PasswordInput } from "@/components/shared/PasswordInput";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { FieldGroup } from "@/components/ui/field";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { changePasswordCall } from "@/components/networking";
|
||||
import { extractProxyErrorMessage } from "@/lib/http/client";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
const changePasswordSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(1, "Current password is required"),
|
||||
newPassword: z.string().min(1, "New password is required"),
|
||||
confirmNewPassword: z.string().min(1, "Confirm your new password"),
|
||||
})
|
||||
.refine((values) => values.newPassword === values.confirmNewPassword, {
|
||||
message: "New passwords do not match",
|
||||
path: ["confirmNewPassword"],
|
||||
});
|
||||
|
||||
type ChangePasswordValues = z.infer<typeof changePasswordSchema>;
|
||||
|
||||
export function ChangePasswordForm() {
|
||||
const { accessToken } = useAuthorized();
|
||||
const form = useZodForm(changePasswordSchema, {
|
||||
defaultValues: { currentPassword: "", newPassword: "", confirmNewPassword: "" },
|
||||
});
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (values: ChangePasswordValues) => {
|
||||
if (!accessToken) return;
|
||||
setSubmitError(null);
|
||||
setIsPending(true);
|
||||
try {
|
||||
await changePasswordCall(accessToken, values.currentPassword, values.newPassword);
|
||||
toast.success("Password updated");
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
setSubmitError(extractProxyErrorMessage(error));
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-10 w-full max-w-md">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<h3 className="text-2xl font-semibold text-foreground">Change Password</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter your current password and choose a new one. The new password must meet this proxy's password
|
||||
policy.
|
||||
</p>
|
||||
|
||||
<form className="mb-2 mt-8" onSubmit={form.handleSubmit(handleSubmit)}>
|
||||
<FieldGroup>
|
||||
<FormField control={form.control} name="currentPassword" label="Current Password">
|
||||
{({ ref, ...field }) => <PasswordInput {...field} ref={ref} autoComplete="current-password" />}
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} name="newPassword" label="New Password">
|
||||
{({ ref, ...field }) => <PasswordInput {...field} ref={ref} autoComplete="new-password" />}
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} name="confirmNewPassword" label="Confirm New Password">
|
||||
{({ ref, ...field }) => <PasswordInput {...field} ref={ref} autoComplete="new-password" />}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
|
||||
{submitError && (
|
||||
<Alert variant="error" className="mt-6">
|
||||
<CircleAlert />
|
||||
<AlertTitle>{submitError}</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="mt-8">
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending && <UiLoadingSpinner className="size-4" role="img" aria-label="loading" />}
|
||||
Change Password
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChangePasswordForm;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import ChangePasswordForm from "./ChangePasswordForm";
|
||||
|
||||
export default function ChangePasswordPage() {
|
||||
return <ChangePasswordForm />;
|
||||
}
|
||||
|
|
@ -50,6 +50,7 @@ const useAuthorized = () => {
|
|||
isViewOnly: isViewOnlySessionRole(decoded?.user_role),
|
||||
premiumUser: decoded?.premium_user ?? null,
|
||||
disabledPersonalKeyCreation: decoded?.disabled_non_admin_personal_key_creation ?? null,
|
||||
loginMethod: decoded?.login_method ?? null,
|
||||
showSSOBanner: decoded?.login_method === "username_password",
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,13 +3,25 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import { renderWithProviders, screen, waitFor } from "../../../../tests/test-utils";
|
||||
import UserDropdown from "./UserDropdown";
|
||||
|
||||
let mockUseAuthorizedImpl = () => ({
|
||||
let mockUseAuthorizedImpl: () => {
|
||||
userId: string | null;
|
||||
userEmail: string | null;
|
||||
userRoleLabel: string;
|
||||
premiumUser: boolean;
|
||||
loginMethod?: string | null;
|
||||
} = () => ({
|
||||
userId: "test-user-id",
|
||||
userEmail: "test@example.com",
|
||||
userRoleLabel: "Admin",
|
||||
premiumUser: false,
|
||||
});
|
||||
|
||||
const mockRouterPush = vi.fn();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: mockRouterPush }),
|
||||
}));
|
||||
|
||||
let mockUseDisableShowPromptsImpl = () => false;
|
||||
|
||||
let mockGetLocalStorageItemImpl = (key: string): string | null => {
|
||||
|
|
@ -143,6 +155,44 @@ describe("UserDropdown", () => {
|
|||
expect(mockOnLogout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should navigate to the change-password page for username/password sessions", async () => {
|
||||
mockUseAuthorizedImpl = () => ({
|
||||
userId: "test-user-id",
|
||||
userEmail: "test@example.com",
|
||||
userRoleLabel: "Admin",
|
||||
premiumUser: false,
|
||||
loginMethod: "username_password",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
||||
await user.click(getAccountTrigger());
|
||||
|
||||
await user.click(await screen.findByText("Change Password"));
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith(expect.stringContaining("change-password"));
|
||||
});
|
||||
|
||||
it("should hide the change-password entry for SSO sessions", async () => {
|
||||
mockUseAuthorizedImpl = () => ({
|
||||
userId: "test-user-id",
|
||||
userEmail: "test@example.com",
|
||||
userRoleLabel: "Admin",
|
||||
premiumUser: false,
|
||||
loginMethod: "sso",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
||||
await user.click(getAccountTrigger());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("test@example.com").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(screen.queryByText("Change Password")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should toggle hide new feature indicators switch", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ import {
|
|||
setLocalStorageItem,
|
||||
} from "@/utils/localStorageUtils";
|
||||
import { navAccountDisplayName } from "@/components/Navbar/navDisplayName";
|
||||
import { ChevronDown, ChevronsUpDown, Crown, LogOut, Mail, ShieldCheck, User } from "lucide-react";
|
||||
import { uiHref } from "@/utils/uiHref";
|
||||
import { ChevronDown, ChevronsUpDown, Crown, KeyRound, LogOut, Mail, ShieldCheck, User } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
|
@ -63,7 +65,9 @@ interface UserDropdownProps {
|
|||
}
|
||||
|
||||
const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar", collapsed = false }) => {
|
||||
const { userId, userEmail, userRoleLabel: userRole, premiumUser } = useAuthorized();
|
||||
const { userId, userEmail, userRoleLabel: userRole, premiumUser, loginMethod } = useAuthorized();
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const disableShowPrompts = useDisableShowPrompts();
|
||||
const disableBlogPosts = useDisableBlogPosts();
|
||||
const disableBouncingIcon = useDisableBouncingIcon();
|
||||
|
|
@ -197,7 +201,7 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
|
|||
const displayName = navAccountDisplayName(userEmail, userId);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
{variant === "sidebar" ? (
|
||||
<PopoverTrigger
|
||||
render={
|
||||
|
|
@ -258,6 +262,19 @@ const UserDropdown: React.FC<UserDropdownProps> = ({ onLogout, variant = "navbar
|
|||
>
|
||||
{renderUserInfoSection()}
|
||||
<Separator />
|
||||
{loginMethod === "username_password" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
router.push(uiHref("change-password"));
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
|
||||
>
|
||||
<KeyRound className="size-4" />
|
||||
Change Password
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ interface AuthMock {
|
|||
userRoleLabel: string;
|
||||
premiumUser: boolean;
|
||||
accessToken: string;
|
||||
loginMethod?: string | null;
|
||||
}
|
||||
|
||||
let mockUseAuthorizedImpl: () => AuthMock = () => ({
|
||||
|
|
@ -19,6 +20,12 @@ let mockUseAuthorizedImpl: () => AuthMock = () => ({
|
|||
accessToken: "test-token",
|
||||
});
|
||||
|
||||
const mockRouterPush = vi.fn();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: mockRouterPush }),
|
||||
}));
|
||||
|
||||
let mockUseDisableShowPromptsImpl = () => false;
|
||||
let mockUseDisableBouncingIconImpl = () => false;
|
||||
let mockHealthDataImpl = (): { litellm_version?: string } | undefined => ({ litellm_version: "1.99.0" });
|
||||
|
|
@ -201,6 +208,42 @@ describe("SidebarAccountMenu", () => {
|
|||
expect(mockOnLogout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should navigate to the change-password page for username/password sessions", async () => {
|
||||
mockUseAuthorizedImpl = () => ({
|
||||
userId: "test-user-id",
|
||||
userEmail: "test@example.com",
|
||||
userRoleLabel: "Admin",
|
||||
premiumUser: false,
|
||||
accessToken: "test-token",
|
||||
loginMethod: "username_password",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<SidebarAccountMenu onLogout={mockOnLogout} />);
|
||||
|
||||
await openMenu(user);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /change password/i }));
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith(expect.stringContaining("change-password"));
|
||||
});
|
||||
|
||||
it("should hide the change-password entry for SSO sessions", async () => {
|
||||
mockUseAuthorizedImpl = () => ({
|
||||
userId: "test-user-id",
|
||||
userEmail: "test@example.com",
|
||||
userRoleLabel: "Admin",
|
||||
premiumUser: false,
|
||||
accessToken: "test-token",
|
||||
loginMethod: "sso",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<SidebarAccountMenu onLogout={mockOnLogout} />);
|
||||
|
||||
await openMenu(user);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /change password/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should toggle hide new feature indicators on", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<SidebarAccountMenu onLogout={mockOnLogout} />);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
|||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { ChevronsUpDown, Crown, IdCard, LogOut, Mail, ShieldCheck } from "lucide-react";
|
||||
import { uiHref } from "@/utils/uiHref";
|
||||
import { ChevronsUpDown, Crown, IdCard, KeyRound, LogOut, Mail, ShieldCheck } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React from "react";
|
||||
|
||||
const RELEASE_NOTES_URL = "https://docs.litellm.ai/release_notes";
|
||||
|
|
@ -81,7 +83,9 @@ interface SidebarAccountMenuProps {
|
|||
}
|
||||
|
||||
const SidebarAccountMenu: React.FC<SidebarAccountMenuProps> = ({ onLogout, collapsed = false }) => {
|
||||
const { userId, userEmail, userRoleLabel: userRole, premiumUser, accessToken } = useAuthorized();
|
||||
const { userId, userEmail, userRoleLabel: userRole, premiumUser, accessToken, loginMethod } = useAuthorized();
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const { data: healthData } = useHealthReadinessDetails(accessToken);
|
||||
const version = healthData?.litellm_version;
|
||||
const disableShowPrompts = useDisableShowPrompts();
|
||||
|
|
@ -136,7 +140,7 @@ const SidebarAccountMenu: React.FC<SidebarAccountMenuProps> = ({ onLogout, colla
|
|||
const triggerLabel = `Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center rounded-lg border border-transparent transition-colors hover:bg-sidebar-accent",
|
||||
|
|
@ -235,6 +239,20 @@ const SidebarAccountMenu: React.FC<SidebarAccountMenuProps> = ({ onLogout, colla
|
|||
|
||||
<Separator />
|
||||
|
||||
{loginMethod === "username_password" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
router.push(uiHref("change-password"));
|
||||
}}
|
||||
className="h-[42px] w-full justify-start gap-2.5 rounded-none px-3 text-sm font-medium text-foreground"
|
||||
>
|
||||
<KeyRound className="size-[19px] text-muted-foreground" />
|
||||
Change Password
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onLogout}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const navState = vi.hoisted(() => ({ pathname: "/ui/api-keys" }));
|
|||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => navState.pathname,
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
|
||||
const { mockUseAuthorized, mockUseOrganizations } = vi.hoisted(() => {
|
||||
|
|
|
|||
|
|
@ -1677,6 +1677,20 @@ export const claimOnboardingToken = async (
|
|||
}
|
||||
};
|
||||
|
||||
export const changePasswordCall = async (
|
||||
accessToken: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<{ user_id: string; message: string }> => {
|
||||
return await apiClient.post(`/user/password/change`, {
|
||||
accessToken,
|
||||
body: {
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const regenerateKeyCall = async (accessToken: string, keyToRegenerate: string, formData: any) => {
|
||||
try {
|
||||
const url = proxyBaseUrl
|
||||
|
|
|
|||
78
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
78
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -16780,6 +16780,35 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/user/password/change": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Change Password
|
||||
* @description Change the calling user's own password.
|
||||
*
|
||||
* Requires the current password. The new password must satisfy the
|
||||
* configured password policy (`general_settings.password_policy_*`: minimum
|
||||
* length, character classes, and, when enabled, breached-password screening
|
||||
* via haveibeenpwned.com).
|
||||
*
|
||||
* Parameters:
|
||||
* - current_password: str - The user's current password.
|
||||
* - new_password: str - The password to change to.
|
||||
*/
|
||||
post: operations["change_password_user_password_change_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/user/spend/report": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -16827,7 +16856,7 @@ export interface paths {
|
|||
* Parameters:
|
||||
* - user_id: Optional[str] - Specify a user id. If not set, a unique id will be generated.
|
||||
* - user_email: Optional[str] - Specify a user email.
|
||||
* - password: Optional[str] - Specify a user password.
|
||||
* - password: Optional[str] - Set the user's password (admin only). Must satisfy the configured password policy. Users change their own password with POST /user/password/change.
|
||||
* - 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.
|
||||
* - send_invite_email: Optional[bool] - Specify if an invite email should be sent.
|
||||
|
|
@ -24819,6 +24848,20 @@ export interface components {
|
|||
*/
|
||||
status: "cancelled";
|
||||
};
|
||||
/** ChangePasswordRequest */
|
||||
ChangePasswordRequest: {
|
||||
/** Current Password */
|
||||
current_password: string;
|
||||
/** New Password */
|
||||
new_password: string;
|
||||
};
|
||||
/** ChangePasswordResponse */
|
||||
ChangePasswordResponse: {
|
||||
/** Message */
|
||||
message: string;
|
||||
/** User Id */
|
||||
user_id: string;
|
||||
};
|
||||
/** ChatCompletionAnnotation */
|
||||
ChatCompletionAnnotation: {
|
||||
/**
|
||||
|
|
@ -60797,6 +60840,39 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
change_password_user_password_change_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ChangePasswordRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ChangePasswordResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_user_spend_report_user_spend_report_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue