diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index ea4f7d557ef..6c383eebe80 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -3801,6 +3801,12 @@ class AllCallbacks(LiteLLMPydanticObjectBase): ) +class HTTPExceptionErrorDetail(TypedDict): + """The `{"error": }` shape most proxy endpoints raise as `HTTPException.detail`.""" + + error: ReadOnly[str] + + class SpendLogsRouterMetadata(TypedDict): """ Router provenance stamped on spend logs for deployments flagged with diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 0840b4fde01..b564e3b9f64 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -1864,15 +1864,13 @@ async def bulk_user_update( detail="Only proxy admins can update all users at once.", ) if data.user_updates.password is not None: - raise HTTPException( - status_code=400, - detail={ - "error": ( - "Setting one password for all users is not supported. " - "Use per-user updates via the 'users' list instead." - ) - }, - ) + 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"}) diff --git a/litellm/proxy/management_endpoints/password_endpoints.py b/litellm/proxy/management_endpoints/password_endpoints.py index 78e53d7a777..c1d409cc08d 100644 --- a/litellm/proxy/management_endpoints/password_endpoints.py +++ b/litellm/proxy/management_endpoints/password_endpoints.py @@ -17,6 +17,7 @@ from litellm.proxy._types import ( ChangePasswordRequest, ChangePasswordResponse, CommonProxyErrors, + HTTPExceptionErrorDetail, LitellmTableNames, UserAPIKeyAuth, ) @@ -29,6 +30,7 @@ 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 @@ -37,6 +39,11 @@ 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]": @@ -46,7 +53,7 @@ def _user_table( @router.post( "/user/password/change", - tags=["Internal User management"], + tags=("Internal User management",), dependencies=(Depends(user_api_key_auth),), ) async def change_password( @@ -70,39 +77,36 @@ async def change_password( if prisma_client is None: raise HTTPException( status_code=500, - detail={"error": CommonProxyErrors.db_not_connected_error.value}, + 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": "No user is associated with this session, so there is no password to change."}, + detail=_error_detail("No user is associated with this session, so there is no password to change."), ) - user_row: Final = await _user_table(prisma_client).find_first(where={"user_id": user_id}) + 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": ( - "This account has no password set, so there is no password to change. " - "Passwords are set through an invitation link (POST /invitation/new)." - ) - }, + 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": "Current password is incorrect."}) + 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) - await _user_table(prisma_client).update( - where={"user_id": user_id}, - data={"password": hash_password(data.new_password)}, - ) + 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( diff --git a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt index 6bc8947e89f..73bf2eac2b7 100644 --- a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt +++ b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt @@ -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} diff --git a/tests/test_litellm/proxy/management_endpoints/test_password_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_password_endpoints.py index c7e0a385ae9..4c6de608003 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_password_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_password_endpoints.py @@ -56,8 +56,12 @@ async def test_change_password_success_writes_new_scrypt_hash(): prisma = _make_prisma(_make_user_row(hash_password(CURRENT_PASSWORD))) with ( - patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: change_password reads proxy_server module globals; no injection seam - patch("litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK), # test-quality-ok: change_password reads proxy_server module globals; no injection seam + 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), @@ -79,8 +83,12 @@ async def test_change_password_rejects_wrong_current_password(): prisma = _make_prisma(_make_user_row(hash_password(CURRENT_PASSWORD))) with ( - patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: change_password reads proxy_server module globals; no injection seam - patch("litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK), # test-quality-ok: change_password reads proxy_server module globals; no injection seam + 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( @@ -100,8 +108,12 @@ async def test_change_password_rejects_session_without_user(): prisma = _make_prisma(user=None) with ( - patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: change_password reads proxy_server module globals; no injection seam - patch("litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK), # test-quality-ok: change_password reads proxy_server module globals; no injection seam + 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( @@ -122,8 +134,12 @@ async def test_change_password_rejects_account_without_password(): prisma = _make_prisma(_make_user_row(password=None)) with ( - patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: change_password reads proxy_server module globals; no injection seam - patch("litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK), # test-quality-ok: change_password reads proxy_server module globals; no injection seam + 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( @@ -143,8 +159,12 @@ async def test_change_password_enforces_min_length(): prisma = _make_prisma(_make_user_row(hash_password(CURRENT_PASSWORD))) with ( - patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: change_password reads proxy_server module globals; no injection seam - patch("litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK), # test-quality-ok: change_password reads proxy_server module globals; no injection seam + 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( @@ -172,8 +192,12 @@ async def test_change_password_rejects_breached_password(): prisma = _make_prisma(_make_user_row(hash_password(CURRENT_PASSWORD))) with ( - patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: change_password reads proxy_server module globals; no injection seam - patch("litellm.proxy.proxy_server.general_settings", {}), # test-quality-ok: change_password reads proxy_server module globals; no injection seam + 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( @@ -201,8 +225,12 @@ async def test_change_password_verifies_current_password_before_hibp_lookup(): prisma = _make_prisma(_make_user_row(hash_password(CURRENT_PASSWORD))) with ( - patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: change_password reads proxy_server module globals; no injection seam - patch("litellm.proxy.proxy_server.general_settings", {}), # test-quality-ok: change_password reads proxy_server module globals; no injection seam + 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( @@ -226,9 +254,15 @@ async def test_change_password_success_emits_redacted_audit_log(): audit_mock = AsyncMock() with ( - patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: change_password reads proxy_server module globals; no injection seam - patch("litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK), # test-quality-ok: change_password reads proxy_server module globals; no injection seam - patch("litellm.proxy.management_endpoints.password_endpoints.create_object_audit_log", audit_mock), + 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), @@ -253,9 +287,15 @@ async def test_change_password_failure_emits_no_audit_log(): audit_mock = AsyncMock() with ( - patch("litellm.proxy.proxy_server.prisma_client", prisma), # test-quality-ok: change_password reads proxy_server module globals; no injection seam - patch("litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK), # test-quality-ok: change_password reads proxy_server module globals; no injection seam - patch("litellm.proxy.management_endpoints.password_endpoints.create_object_audit_log", audit_mock), + 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( @@ -271,8 +311,12 @@ async def test_change_password_requires_db(): from litellm.proxy._types import ChangePasswordRequest with ( - patch("litellm.proxy.proxy_server.prisma_client", None), # test-quality-ok: change_password reads proxy_server module globals; no injection seam - patch("litellm.proxy.proxy_server.general_settings", _POLICY_NO_BREACH_CHECK), # test-quality-ok: change_password reads proxy_server module globals; no injection seam + 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( diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx index 6eb0218c41d..5fa6e9728cf 100644 --- a/ui/litellm-dashboard/src/components/leftnav.test.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx @@ -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(() => {