From 702dc63cab204cb53a35dd40f8bf99c2ec03374d Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 3 Feb 2025 16:40:48 +0100 Subject: [PATCH 1/7] support using JupyterHub service access scopes in JupyterHubAuth - disabled by default for backward-compatibility - opt-in by setting jupyterhub_service_name - prefix service usernames so they don't collide with users --- .../dask_gateway_server/auth.py | 71 ++++++++++++++++++- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/dask-gateway-server/dask_gateway_server/auth.py b/dask-gateway-server/dask_gateway_server/auth.py index a9842d91..a6e446c9 100644 --- a/dask-gateway-server/dask_gateway_server/auth.py +++ b/dask-gateway-server/dask_gateway_server/auth.py @@ -5,7 +5,7 @@ import aiohttp from aiohttp import web -from traitlets import Instance, Integer, Unicode, default +from traitlets import Bool, Instance, Integer, Unicode, default from traitlets.config import LoggingConfigurable from .models import User @@ -315,6 +315,49 @@ def _default_jupyterhub_api_url(self): raise ValueError("JUPYTERHUB_API_URL must be set") return out + jupyterhub_service_name = Unicode( + # should this be "dask-gateway"? + # that would enable service scope enforcement by default + "", + help=""" + The name of dask-gateway as a jupyterhub service. + + By default this is determined from the ``JUPYTERHUB_SERVICE_NAME`` + environment variable. + """, + config=True, + ) + + @default("jupyterhub_service_name") + def _default_jupyterhub_service_name(self): + return os.environ.get("JUPYTERHUB_SERVICE_NAME", "") + + use_service_access_scopes = Bool( + help=""" + Require tokens to have `access:services!service={jupyterhub_service_name}` permissions + in order to access the gateway. + + Allows JupyterHub RBAC to controll access to dask-gateway. + + Disabled by default for backward-compatibility, but strongly encouraged. + Enabled by default if `jupyterhub_service_name` is set. + """, + config=True, + ) + + @default("use_service_access_scopes") + def _default_use_service_access_scopes(self): + if self.jupyterhub_service_name: + return True + else: + self.log.warning( + "jupyterhub_service_name not set, " + "any jupyterhub token may be used to create clusters. " + "Set JupyterHubAuth.jupyterhub_service_name " + "to use jupyterhub scopes to control access to dask-gateway." + ) + return False + tls_key = Unicode( "", help=""" @@ -386,9 +429,33 @@ async def authenticate(self, request): if resp.status < 400: data = await resp.json() + # avoid collisions between user names and service names + # 'kind' may be 'user' or 'service' + username = data["name"] + if data["kind"] != "user" or username.startswith(("user:", "service:")): + # avoid collision without changing the name for users + username = f"{data['kind']}:{username}" + + scopes = data.get("scopes", []) + if self.use_service_access_scopes: + # check scopes for access permissions + access_scopes = { + "access:services", + f"access:services!service={self.jupyterhub_service_name}", + } + have_scopes = set(scopes) + if not access_scopes.intersection(have_scopes): + self.log.debug( + "Token for %r does not have access to service %r; has scopes: %s", + username, + self.jupyterhub_service_name, + scopes, + ) + raise unauthorized("jupyterhub") + # "groups" attribute doesn't exists in case of a service return User( - data["name"], + username, groups=data.get("groups", []), admin=data.get("admin", False), ) From eead5887578d13ade599669288d166e2d5d2daf6 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 13 Feb 2026 09:24:22 -0800 Subject: [PATCH 2/7] document jupyterhub service auth --- docs/source/authentication.rst | 59 ++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/docs/source/authentication.rst b/docs/source/authentication.rst index 344a6869..52ea2c84 100644 --- a/docs/source/authentication.rst +++ b/docs/source/authentication.rst @@ -97,11 +97,26 @@ Then add the following lines to your ``dask_gateway_config.py`` file: c.JupyterHubAuthenticator.api_token = "" c.JupyterHubAuthenticator.api_url = "" + # enables jupyterhub scope-based access + # also set via $JUPYTERHUB_SERVICE_NAME + c.JupyterHubAuthenticator.jupyterhub_service_name = "dask-gateway" + Where: - ```` is the token generated above - ```` is JupyterHub's API url. This is usually of the form ``https://:/hub/api``. +- ``dask-gateway`` is the name of the service registered with JupyterHub (see below) + +.. warning:: + + If you do not set ``jupyterhub_service_name``, then any JupyterHub token, + regardless of user or token permissions, + can be used to access Dask-Gateway. + This is insecure, but the default for backward compatibility with earlier Dask-Gateway behavior. + + When set, only users and tokens with the ``access:services!service=dask-gateway`` scope + will have access to Dask-Gateway. You'll also need to register the API token with JupyterHub. This can be done by adding the following to the corresponding ``jupyterhub_config.py`` file: @@ -112,10 +127,50 @@ adding the following to the corresponding ``jupyterhub_config.py`` file: {"name": "dask-gateway", "api_token": ""} ] -again, replacing ```` with the output from above. +Finally, you'll want to grant some or all jupyterhub users access to the ``dask-gateway`` service. +You can do this by granting all users access by overriding the default ``user`` role: + +.. code-block:: python + + c.JupyterHub.load_roles = [ + { + # defining the 'user' role + # sets the base permissions for all jupyterhub users + "name": "user", + "scopes": [ + "self", + "access:services!service=dask-gateway", + ], + }, + ] + +or select users, via username and/or group membership: + +.. code-block:: python + + c.JupyterHub.load_roles = [ + { + "name": "dask-users", + "scopes": [ + "access:services!service=dask-gateway", + ], + "groups": ["dask-users"], + "users": ["patience"], + }, + ] + +Finally, if you want the token used in singleuser server environments +(e.g. for the dask labextension), add this access scope to ``c.Spawner.server_token_scopes``: + +.. code-block:: python + + c.Spawner.server_token_scopes = [ + "access:services!service-dask-gateway", + ] With this configuration, JupyterHub will be used to authenticate requests -between users and the ``dask-gateway-server``. +between users and the ``dask-gateway-server``, +and JupyterHub admins can control which JupyterHub users have access to Dask-Gateway. For more information see the :ref:`jupyterhub-auth-config` docs. From 413c474c6bd9035f74474ef11c8618cd64f4e2d4 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 13 Feb 2026 09:55:24 -0800 Subject: [PATCH 3/7] test jupyterhub service auth tokens --- tests/test_auth.py | 87 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 77 insertions(+), 10 deletions(-) diff --git a/tests/test_auth.py b/tests/test_auth.py index ba01befc..1d8197f0 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,3 +1,4 @@ +import logging import os import subprocess import uuid @@ -22,6 +23,8 @@ import jupyterhub.tests.mocking as hub_mocking except ImportError: hub_mocking = None +else: + from tornado.log import access_log, app_log, gen_log KEYTAB_PATH = "/home/dask/dask.keytab" @@ -91,9 +94,6 @@ async def __aenter__(self): await self.hub.start() # alembic turns off all logs, reenable them for the tests - import logging - - from tornado.log import access_log, app_log, gen_log logs = [app_log, access_log, gen_log, logging.getLogger("DaskGateway")] for log in logs: @@ -111,18 +111,20 @@ async def __aexit__(self, *args): type(self.hub).clear_instance() -def configure_dask_gateway(jhub_api_token, jhub_bind_url): +def configure_dask_gateway(jhub_api_token, jhub_bind_url, service_name=""): config = Config() config.DaskGateway.authenticator_class = ( "dask_gateway_server.auth.JupyterHubAuthenticator" ) config.JupyterHubAuthenticator.jupyterhub_api_token = jhub_api_token config.JupyterHubAuthenticator.jupyterhub_api_url = jhub_bind_url + "api" + if service_name: + config.JupyterHubAuthenticator.jupyterhub_service_name = service_name return config @pytest.mark.skipif(not hub_mocking, reason="JupyterHub not installed") -async def test_jupyterhub_auth_user(monkeypatch): +async def test_jupyterhub_auth_legacy(monkeypatch): from jupyterhub.tests.utils import add_user jhub_api_token = uuid.uuid4().hex @@ -138,7 +140,7 @@ class MockHub(hub_mocking.MockHub): def init_logging(self): pass - hub = MockHub(config=hub_config) + hub = MockHub(log=app_log, config=hub_config) # Configure gateway config = configure_dask_gateway(jhub_api_token, jhub_bind_url) @@ -163,37 +165,102 @@ def init_logging(self): await gateway.list_clusters() +@pytest.mark.skipif(not hub_mocking, reason="JupyterHub not installed") +async def test_jupyterhub_auth_user(monkeypatch): + from jupyterhub.tests.utils import add_user + + jhub_api_token = uuid.uuid4().hex + jhub_bind_url = "http://127.0.0.1:%i/@/space%%20word/" % random_port() + + hub_config = Config() + hub_config.JupyterHub.services = [ + {"name": "dask-gateway", "api_token": jhub_api_token} + ] + hub_config.JupyterHub.bind_url = jhub_bind_url + hub_config.JupyterHub.load_roles = [ + { + "name": "dask-users", + "users": ["alice"], + } + ] + + class MockHub(hub_mocking.MockHub): + def init_logging(self): + pass + + hub = MockHub(log=app_log, config=hub_config) + + # Configure gateway + config = configure_dask_gateway( + jhub_api_token, jhub_bind_url, service_name="dask-gateway" + ) + + async with temp_gateway(config=config) as g: + async with temp_hub(hub): + # Create a new jupyterhub user alice, and get the api token + u = add_user(hub.db, name="alice") + api_token = u.new_api_token() + hub.db.commit() + + u2 = add_user(hub.db, name="bob") + wrong_api_token = u2.new_api_token() + hub.db.commit() + + # Configure auth with incorrect api token + auth = JupyterHubAuth(api_token=wrong_api_token) + + async with g.gateway_client(auth=auth) as gateway: + # Auth fails with bad token + with pytest.raises(Exception): + await gateway.list_clusters() + + # Auth works with correct token + auth.api_token = api_token + await gateway.list_clusters() + + @pytest.mark.skipif(not hub_mocking, reason="JupyterHub not installed") async def test_jupyterhub_auth_service(monkeypatch): jhub_api_token = uuid.uuid4().hex jhub_service_token = uuid.uuid4().hex + other_service_token = uuid.uuid4().hex jhub_bind_url = "http://127.0.0.1:%i/@/space%%20word/" % random_port() hub_config = Config() hub_config.JupyterHub.services = [ {"name": "dask-gateway", "api_token": jhub_api_token}, {"name": "any-service", "api_token": jhub_service_token}, + {"name": "other-service", "api_token": other_service_token}, ] hub_config.JupyterHub.bind_url = jhub_bind_url + hub_config.JupyterHub.load_roles = [ + { + "name": "dask-users", + "scopes": ["access:services!service=dask-gateway"], + "services": ["any-service"], + } + ] class MockHub(hub_mocking.MockHub): def init_logging(self): pass - hub = MockHub(config=hub_config) + hub = MockHub(log=app_log, config=hub_config) # Configure gateway - config = configure_dask_gateway(jhub_api_token, jhub_bind_url) + config = configure_dask_gateway( + jhub_api_token, jhub_bind_url, service_name="dask-gateway" + ) async with temp_gateway(config=config) as g: async with temp_hub(hub): # Configure auth with incorrect api token - auth = JupyterHubAuth(api_token=uuid.uuid4().hex) + auth = JupyterHubAuth(api_token=other_service_token) async with g.gateway_client(auth=auth) as gateway: # Auth fails with bad token with pytest.raises(Exception): await gateway.list_clusters() # Auth works with service token - auth.api_token = jhub_api_token + auth.api_token = jhub_service_token await gateway.list_clusters() From 47efc80c29ac64627a11a7c5b9a393bc132d63ec Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 13 Feb 2026 10:05:39 -0800 Subject: [PATCH 4/7] expose jupyterhub_service_name in helm chart --- .../helm/dask-gateway/templates/gateway/configmap.yaml | 3 +++ resources/helm/dask-gateway/values.schema.yaml | 8 ++++++++ resources/helm/dask-gateway/values.yaml | 5 +++++ 3 files changed, 16 insertions(+) diff --git a/resources/helm/dask-gateway/templates/gateway/configmap.yaml b/resources/helm/dask-gateway/templates/gateway/configmap.yaml index 230713e9..0ced116a 100644 --- a/resources/helm/dask-gateway/templates/gateway/configmap.yaml +++ b/resources/helm/dask-gateway/templates/gateway/configmap.yaml @@ -104,6 +104,9 @@ data: "your config file" ) c.DaskGateway.JupyterHubAuthenticator.jupyterhub_api_url = api_url + service_name = get_property("gateway.auth.jupyterhub.jupyterhubServiceName") + if service_name is not None: + c.DaskGateway.JupyterHubAuthenticator.jupyterhub_service_name = service_name elif auth_type == "custom": auth_cls = get_property("gateway.auth.custom.class") c.DaskGateway.authenticator_class = auth_cls diff --git a/resources/helm/dask-gateway/values.schema.yaml b/resources/helm/dask-gateway/values.schema.yaml index d02fa7b4..8da499ae 100644 --- a/resources/helm/dask-gateway/values.schema.yaml +++ b/resources/helm/dask-gateway/values.schema.yaml @@ -261,6 +261,14 @@ properties: description: | JupyterHub's api url. Inferred from JupyterHub's service name if running in the same namespace. + jupyterhubServiceName: + type: [string, "null"] + description: | + The JupyterHub service name + (usually "dask-gateway"). + This should always be set. + If not set (default), + any JupyterHub token will be able to access the gateway. custom: type: object additionalProperties: false diff --git a/resources/helm/dask-gateway/values.yaml b/resources/helm/dask-gateway/values.yaml index ab42fda0..8b9e7cc4 100644 --- a/resources/helm/dask-gateway/values.yaml +++ b/resources/helm/dask-gateway/values.yaml @@ -79,6 +79,11 @@ gateway: # in the same namespace. apiUrl: + # JupyterHub service name. + # if not set, JupyterHub permissions are ignored + # and all tokens can access the gateway + jupyterhubServiceName: + custom: # The full authenticator class name. class: From 9e5097f487897f7572ac0a4609d8d2c9d5045ca2 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 13 Feb 2026 10:10:39 -0800 Subject: [PATCH 5/7] need to set scopes to test them --- tests/test_auth.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_auth.py b/tests/test_auth.py index 1d8197f0..0a418681 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -180,6 +180,7 @@ async def test_jupyterhub_auth_user(monkeypatch): hub_config.JupyterHub.load_roles = [ { "name": "dask-users", + "scopes": ["access:services!service=dask-gateway"], "users": ["alice"], } ] From 9109d5bfdf7d60f8ac170df5719db68aa5e7a980 Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 16 Feb 2026 16:03:59 -0800 Subject: [PATCH 6/7] simplify/clarify collision avoidance in jupyterhub auth --- dask-gateway-server/dask_gateway_server/auth.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dask-gateway-server/dask_gateway_server/auth.py b/dask-gateway-server/dask_gateway_server/auth.py index a6e446c9..95a4c883 100644 --- a/dask-gateway-server/dask_gateway_server/auth.py +++ b/dask-gateway-server/dask_gateway_server/auth.py @@ -432,8 +432,10 @@ async def authenticate(self, request): # avoid collisions between user names and service names # 'kind' may be 'user' or 'service' username = data["name"] - if data["kind"] != "user" or username.startswith(("user:", "service:")): + if data["kind"] != "user" or ":" in username: # avoid collision without changing the name for users + # but disambiguate if usernames might look like + # `service:name` (unlikely but not prohibited) username = f"{data['kind']}:{username}" scopes = data.get("scopes", []) From 2d4a10cc52b922714f7bc902edcb0c1cfddff253 Mon Sep 17 00:00:00 2001 From: Erik Sundell Date: Tue, 17 Feb 2026 09:08:41 +0100 Subject: [PATCH 7/7] Fix traitlets configuration detail --- resources/helm/dask-gateway/templates/gateway/configmap.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/helm/dask-gateway/templates/gateway/configmap.yaml b/resources/helm/dask-gateway/templates/gateway/configmap.yaml index 0ced116a..d9af41c9 100644 --- a/resources/helm/dask-gateway/templates/gateway/configmap.yaml +++ b/resources/helm/dask-gateway/templates/gateway/configmap.yaml @@ -103,10 +103,10 @@ data: "please specify `gateway.auth.jupyterhub.apiUrl` in " "your config file" ) - c.DaskGateway.JupyterHubAuthenticator.jupyterhub_api_url = api_url + c.JupyterHubAuthenticator.jupyterhub_api_url = api_url service_name = get_property("gateway.auth.jupyterhub.jupyterhubServiceName") if service_name is not None: - c.DaskGateway.JupyterHubAuthenticator.jupyterhub_service_name = service_name + c.JupyterHubAuthenticator.jupyterhub_service_name = service_name elif auth_type == "custom": auth_cls = get_property("gateway.auth.custom.class") c.DaskGateway.authenticator_class = auth_cls