Skip to content

Commit fedb6a7

Browse files
sampan-s-nayaksampanedoakes
authored
[Core] pass auth token in dashboard head python client sdk (#58281)
Supports token based authentication in dashboard head sdk, all clients which build on top of the submission_client will now support token auth out of the box. so this covers all cli commands like job submit, state api, serve related cli commands etc. --------- Signed-off-by: sampan <[email protected]> Signed-off-by: Sampan S Nayak <[email protected]> Signed-off-by: Edward Oakes <[email protected]> Co-authored-by: sampan <[email protected]> Co-authored-by: Edward Oakes <[email protected]>
1 parent da9ad9c commit fedb6a7

File tree

12 files changed

+670
-313
lines changed

12 files changed

+670
-313
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Token setup instructions (used in multiple contexts)
2+
TOKEN_SETUP_INSTRUCTIONS = """Please provide an authentication token using one of these methods:
3+
1. Set the RAY_AUTH_TOKEN environment variable
4+
2. Set the RAY_AUTH_TOKEN_PATH environment variable (pointing to a token file)
5+
3. Create a token file at the default location: ~/.ray/auth_token"""
6+
7+
# When token auth is enabled but no token is found anywhere
8+
TOKEN_AUTH_ENABLED_BUT_NO_TOKEN_FOUND_ERROR_MESSAGE = (
9+
"Token authentication is enabled but no authentication token was found. "
10+
+ TOKEN_SETUP_INSTRUCTIONS
11+
)
12+
13+
# When HTTP request fails with 401 (Unauthorized - missing token)
14+
HTTP_REQUEST_MISSING_TOKEN_ERROR_MESSAGE = (
15+
"The Ray cluster requires authentication, but no token was provided.\n\n"
16+
+ TOKEN_SETUP_INSTRUCTIONS
17+
)
18+
19+
# When HTTP request fails with 403 (Forbidden - invalid token)
20+
HTTP_REQUEST_INVALID_TOKEN_ERROR_MESSAGE = (
21+
"The authentication token you provided is invalid or incorrect.\n\n"
22+
+ TOKEN_SETUP_INSTRUCTIONS
23+
)

python/ray/_private/authentication/authentication_token_setup.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
from pathlib import Path
1010
from typing import Any, Dict, Optional
1111

12+
from ray._private.authentication.authentication_constants import (
13+
TOKEN_AUTH_ENABLED_BUT_NO_TOKEN_FOUND_ERROR_MESSAGE,
14+
)
1215
from ray._private.authentication.authentication_token_generator import (
1316
generate_new_authentication_token,
1417
)
@@ -20,13 +23,6 @@
2023

2124
logger = logging.getLogger(__name__)
2225

23-
TOKEN_AUTH_ENABLED_BUT_NO_TOKEN_FOUND_ERROR_MESSAGE = (
24-
"Token authentication is enabled but no authentication token was found. Please provide a token with one of these options:\n"
25-
+ " 1. RAY_AUTH_TOKEN environment variable\n"
26-
+ " 2. RAY_AUTH_TOKEN_PATH environment variable (path to token file)\n"
27-
+ " 3. Default token file: ~/.ray/auth_token"
28-
)
29-
3026

3127
def generate_and_save_token() -> None:
3228
"""Generate a new random token and save it in the default token path.

python/ray/dashboard/modules/dashboard_sdk.py

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import yaml
1313

1414
import ray
15+
from ray._private.authentication import authentication_constants
1516
from ray._private.runtime_env.packaging import (
1617
create_package,
1718
get_uri_for_directory,
@@ -20,7 +21,9 @@
2021
from ray._private.runtime_env.py_modules import upload_py_modules_if_needed
2122
from ray._private.runtime_env.working_dir import upload_working_dir_if_needed
2223
from ray._private.utils import split_address
24+
from ray._raylet import AuthenticationTokenLoader
2325
from ray.autoscaler._private.cli_logger import cli_logger
26+
from ray.dashboard.authentication_utils import is_token_auth_enabled
2427
from ray.dashboard.modules.job.common import uri_to_http_components
2528
from ray.util.annotations import DeveloperAPI, PublicAPI
2629

@@ -222,7 +225,9 @@ def __init__(
222225
self._default_metadata = cluster_info.metadata or {}
223226
# Headers used for all requests sent to job server, optional and only
224227
# needed for cases like authentication to remote cluster.
225-
self._headers = cluster_info.headers
228+
self._headers = cluster_info.headers or {}
229+
self._headers.update(**self._get_auth_headers())
230+
226231
# Set SSL verify parameter for the requests library and create an ssl_context
227232
# object when needed for the aiohttp library.
228233
self._verify = verify
@@ -242,6 +247,36 @@ def __init__(
242247
else:
243248
self._ssl_context = None
244249

250+
def _get_auth_headers(self) -> Dict[str, str]:
251+
"""Get authentication headers if token auth is enabled.
252+
253+
Returns:
254+
dict: Authentication headers to merge with request headers.
255+
Empty dict if no auth needed or token unavailable.
256+
"""
257+
if not is_token_auth_enabled():
258+
return {}
259+
260+
# Check if user provided their own Authorization header (case-insensitive)
261+
has_user_auth = any(
262+
key.lower() == "authorization" for key in self._headers.keys()
263+
)
264+
if has_user_auth:
265+
# User has provided their own auth header, don't override
266+
return {}
267+
268+
token_loader = AuthenticationTokenLoader.instance()
269+
auth_headers = token_loader.get_token_for_http_header()
270+
271+
if not auth_headers:
272+
# Token auth enabled but no token found
273+
logger.warning(
274+
"Token authentication is enabled but no token was found. "
275+
"Requests to authenticated clusters will fail."
276+
)
277+
278+
return auth_headers
279+
245280
def _check_connection_and_version(
246281
self, min_version: str = "1.9", version_error_message: str = None
247282
):
@@ -293,14 +328,15 @@ def _do_request(
293328
json_data: Optional[dict] = None,
294329
**kwargs,
295330
) -> "requests.Response":
296-
"""Perform the actual HTTP request
331+
"""Perform the actual HTTP request with authentication error handling.
297332
298333
Keyword arguments other than "cookies", "headers" are forwarded to the
299334
`requests.request()`.
300335
"""
301336
url = self._address + endpoint
302337
logger.debug(f"Sending request to {url} with json data: {json_data or {}}.")
303-
return requests.request(
338+
339+
response = requests.request(
304340
method,
305341
url,
306342
cookies=self._cookies,
@@ -311,6 +347,22 @@ def _do_request(
311347
**kwargs,
312348
)
313349

350+
# Check for authentication errors and provide helpful messages
351+
if response.status_code == 401:
352+
# Unauthorized - missing or no token provided
353+
raise RuntimeError(
354+
f"Authentication required: {response.text}\n\n"
355+
+ authentication_constants.HTTP_REQUEST_MISSING_TOKEN_ERROR_MESSAGE
356+
)
357+
elif response.status_code == 403:
358+
# Forbidden - invalid token
359+
raise RuntimeError(
360+
f"Authentication failed: {response.text}\n\n"
361+
+ authentication_constants.HTTP_REQUEST_INVALID_TOKEN_ERROR_MESSAGE
362+
)
363+
364+
return response
365+
314366
def _package_exists(
315367
self,
316368
package_uri: str,
Lines changed: 27 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,117 +1,68 @@
11
"""Tests for dashboard token authentication."""
2-
import os
2+
33
import sys
44

55
import pytest
66
import requests
77

8-
import ray
9-
from ray._raylet import Config
10-
11-
12-
@pytest.fixture
13-
def start_ray_with_env_vars(request):
14-
"""Clean up environment variables after each test."""
15-
env_vars = getattr(request, "param", {}).pop("env_vars", {})
16-
os.environ.update(**env_vars)
17-
Config.initialize("")
18-
19-
yield ray.init()
20-
21-
ray.shutdown()
22-
for k in env_vars.keys():
23-
del os.environ[k]
248

25-
26-
TEST_TOKEN = "test_token_12345678901234567890123456789012"
27-
28-
29-
@pytest.mark.parametrize(
30-
"start_ray_with_env_vars",
31-
[
32-
{
33-
"env_vars": {"RAY_auth_mode": "token", "RAY_AUTH_TOKEN": TEST_TOKEN},
34-
},
35-
],
36-
indirect=True,
37-
)
38-
def test_auth_enabled_valid_token(start_ray_with_env_vars):
9+
def test_dashboard_request_requires_auth_with_valid_token(
10+
setup_cluster_with_token_auth,
11+
):
3912
"""Test that requests succeed with valid token when auth is enabled."""
40-
dashboard_url = start_ray_with_env_vars.address_info["webui_url"]
4113

42-
# Request with valid auth should succeed
43-
headers = {"Authorization": f"Bearer {TEST_TOKEN}"}
14+
cluster_info = setup_cluster_with_token_auth
15+
headers = {"Authorization": f"Bearer {cluster_info['token']}"}
16+
4417
response = requests.get(
45-
f"http://{dashboard_url}/api/component_activities",
18+
f"{cluster_info['dashboard_url']}/api/component_activities",
4619
headers=headers,
4720
)
21+
4822
assert response.status_code == 200
4923

5024

51-
@pytest.mark.parametrize(
52-
"start_ray_with_env_vars",
53-
[
54-
{
55-
"env_vars": {"RAY_auth_mode": "token", "RAY_AUTH_TOKEN": TEST_TOKEN},
56-
},
57-
],
58-
indirect=True,
59-
)
60-
def test_auth_enabled_missing_token(start_ray_with_env_vars):
25+
def test_dashboard_request_requires_auth_missing_token(setup_cluster_with_token_auth):
6126
"""Test that requests fail without token when auth is enabled."""
62-
dashboard_url = start_ray_with_env_vars.address_info["webui_url"]
6327

64-
# GET without auth should fail with 401
28+
cluster_info = setup_cluster_with_token_auth
29+
6530
response = requests.get(
66-
f"http://{dashboard_url}/api/component_activities",
31+
f"{cluster_info['dashboard_url']}/api/component_activities",
6732
json={"test": "data"},
6833
)
34+
6935
assert response.status_code == 401
7036

7137

72-
@pytest.mark.parametrize(
73-
"start_ray_with_env_vars",
74-
[
75-
{
76-
"env_vars": {"RAY_auth_mode": "token", "RAY_AUTH_TOKEN": TEST_TOKEN},
77-
},
78-
],
79-
indirect=True,
80-
)
81-
def test_auth_enabled_invalid_token(start_ray_with_env_vars):
38+
def test_dashboard_request_requires_auth_invalid_token(setup_cluster_with_token_auth):
8239
"""Test that requests fail with invalid token when auth is enabled."""
83-
dashboard_url = start_ray_with_env_vars.address_info["webui_url"]
8440

85-
# Request with wrong token should fail with 403
86-
headers = {"Authorization": "Bearer INCORRECT_TOKEN"}
41+
cluster_info = setup_cluster_with_token_auth
42+
headers = {"Authorization": "Bearer wrong_token_00000000000000000000000000000000"}
43+
8744
response = requests.get(
88-
f"http://{dashboard_url}/api/component_activities",
45+
f"{cluster_info['dashboard_url']}/api/component_activities",
8946
json={"test": "data"},
9047
headers=headers,
9148
)
49+
9250
assert response.status_code == 403
9351

9452

95-
@pytest.mark.parametrize(
96-
"start_ray_with_env_vars",
97-
[
98-
{
99-
"env_vars": {"RAY_auth_mode": "disabled"},
100-
},
101-
],
102-
indirect=True,
103-
)
104-
def test_auth_disabled(start_ray_with_env_vars):
53+
def test_dashboard_auth_disabled(setup_cluster_without_token_auth):
10554
"""Test that auth is not enforced when auth_mode is disabled."""
106-
dashboard_url = start_ray_with_env_vars.address_info["webui_url"]
10755

108-
# GET without auth should succeed when auth is disabled
56+
cluster_info = setup_cluster_without_token_auth
57+
10958
response = requests.get(
110-
f"http://{dashboard_url}/api/component_activities", json={"test": "data"}
59+
f"{cluster_info['dashboard_url']}/api/component_activities",
60+
json={"test": "data"},
11161
)
112-
# Should not return 401 or 403
62+
11363
assert response.status_code == 200
11464

11565

11666
if __name__ == "__main__":
67+
11768
sys.exit(pytest.main(["-vv", __file__]))

python/ray/includes/rpc_token_authentication.pxd

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ cdef extern from "ray/rpc/authentication/authentication_token.h" namespace "ray:
1616
CAuthenticationToken(string value)
1717
c_bool empty()
1818
c_bool Equals(const CAuthenticationToken& other)
19+
string ToAuthorizationHeaderValue()
1920
@staticmethod
2021
CAuthenticationToken FromMetadata(string metadata_value)
2122

python/ray/includes/rpc_token_authentication.pxi

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ class AuthenticationMode:
1111
DISABLED = CAuthenticationMode.DISABLED
1212
TOKEN = CAuthenticationMode.TOKEN
1313

14+
_AUTHORIZATION_HEADER_NAME = "authorization"
1415

1516
def get_authentication_mode():
1617
"""Get the current authentication mode.
@@ -69,3 +70,26 @@ class AuthenticationTokenLoader:
6970
variables or files on the next request.
7071
"""
7172
CAuthenticationTokenLoader.instance().ResetCache()
73+
74+
def get_token_for_http_header(self) -> dict:
75+
"""Get authentication token as a dictionary for HTTP headers.
76+
77+
This method loads the token from C++ AuthenticationTokenLoader and returns it
78+
as a dictionary that can be merged with existing headers. It returns an empty
79+
dictionary if:
80+
- A token does not exist
81+
- The token is empty
82+
83+
Returns:
84+
dict: Empty dict or {"authorization": "Bearer <token>"}
85+
"""
86+
if not self.has_token():
87+
return {}
88+
89+
# Get the token from C++ layer
90+
cdef optional[CAuthenticationToken] token_opt = CAuthenticationTokenLoader.instance().GetToken()
91+
92+
if not token_opt.has_value() or token_opt.value().empty():
93+
return {}
94+
95+
return {_AUTHORIZATION_HEADER_NAME: token_opt.value().ToAuthorizationHeaderValue().decode('utf-8')}

python/ray/tests/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,7 @@ py_test_module_list(
578578
"test_runtime_env_py_executable.py",
579579
"test_state_api_summary.py",
580580
"test_streaming_generator_regression.py",
581+
"test_submission_client_auth.py",
581582
"test_system_metrics.py",
582583
"test_task_events_3.py",
583584
"test_task_metrics_reconstruction.py",

0 commit comments

Comments
 (0)