Skip to content

Commit

Permalink
consolidate disable_user_config implementation
Browse files Browse the repository at this point in the history
found some fixes required to run on ServerApp to affect extensions,
which were not affected before
  • Loading branch information
minrk committed Feb 10, 2023
1 parent 40164e6 commit 9a49d06
Show file tree
Hide file tree
Showing 5 changed files with 140 additions and 70 deletions.
113 changes: 113 additions & 0 deletions jupyterhub/singleuser/_disable_user_config.py
@@ -0,0 +1,113 @@
"""
Disable user-controlled config for single-user servers
Applies patches to prevent loading configuration from the user's home directory.
Only used when launching a single-user server with disable_user_config=True.
This is where we still have some monkeypatches,
because we want to prevent loading configuration from user directories,
and `jupyter_core` functions don't allow that.
Due to extensions, we aren't able to apply patches in one place on the ServerApp,
we have to insert the patches at the lowest-level
on function objects themselves,
to ensure we modify calls to e.g. `jupyter_core.jupyter_path`
that may have been imported already!
We should perhaps ask for the necessary hooks to modify this in jupyter_core,
rather than keeing these monkey patches around.
"""

import os

from jupyter_core import paths


def _exclude_home(path_list):
"""Filter out any entries in a path list that are in my home directory.
Used to disable per-user configuration.
"""
home = os.path.expanduser('~/')
for p in path_list:
if not p.startswith(home):
yield p


# record patches
_original_jupyter_paths = None
_jupyter_paths_without_home = None


def _disable_user_config(serverapp):
"""
disable user-controlled sources of configuration
by excluding directories in their home from paths.
This _does not_ disable frontend config,
such as UI settings persistence.
1. Python config file paths
2. Search paths for extensions, etc.
3. import path
"""
original_jupyter_path = paths.jupyter_path()
jupyter_path_without_home = list(_exclude_home(original_jupyter_path))

# config_file_paths is a property without a setter
# can't override on the instance
default_config_file_paths = serverapp.config_file_paths
config_file_paths = list(_exclude_home(default_config_file_paths))
serverapp.__class__.config_file_paths = property(
lambda self: config_file_paths,
)
# verify patch applied
assert serverapp.config_file_paths == config_file_paths

# patch jupyter_path to exclude $HOME
global _original_jupyter_paths, _jupyter_paths_without_home, _original_jupyter_config_dir
_original_jupyter_paths = paths.jupyter_path()
_jupyter_paths_without_home = list(_exclude_home(_original_jupyter_paths))

def get_jupyter_path_without_home(*subdirs):
# reimport because of our `__code__` patch
# affects what is resolved as the parent namespace
from jupyterhub.singleuser._disable_user_config import (
_jupyter_paths_without_home,
)

paths = list(_jupyter_paths_without_home)
if subdirs:
paths = [os.path.join(p, *subdirs) for p in paths]
return paths

# patch `jupyter_path.__code__` to ensure all callers are patched,
# even if they've already imported
# this affects e.g. nbclassic.nbextension_paths
paths.jupyter_path.__code__ = get_jupyter_path_without_home.__code__

# same thing for config_dir,
# which applies to some things like ExtensionApp config paths
# and nbclassic.static_custom_path

# allows explicit override if $JUPYTER_CONFIG_DIR is set
# or config dir is otherwise not in $HOME

if not os.getenv("JUPYTER_CONFIG_DIR") and not list(
_exclude_home([paths.jupyter_config_dir()])
):
# patch specifically Application.config_dir
# this affects ServerApp and ExtensionApp,
# but does not affect JupyterLab's user-settings, etc.
# patching the traitlet directly affects all instances,
# already-created or future
from jupyter_core.application import JupyterApp

def get_env_config_dir(obj, cls=None):
return paths.ENV_CONFIG_PATH[0]

JupyterApp.config_dir.get = get_env_config_dir

# record disabled state on app object
serverapp.disable_user_config = True
58 changes: 4 additions & 54 deletions jupyterhub/singleuser/extension.py
Expand Up @@ -16,7 +16,6 @@
from unittest import mock
from urllib.parse import urlparse

from jupyter_core import paths
from jupyter_server.auth import Authorizer, IdentityProvider, User
from jupyter_server.auth.logout import LogoutHandler
from jupyter_server.extension.application import ExtensionApp
Expand All @@ -34,6 +33,8 @@
url_path_join,
)

from ._disable_user_config import _disable_user_config

SINGLEUSER_TEMPLATES_DIR = str(Path(__file__).parent.resolve().joinpath("templates"))


Expand Down Expand Up @@ -245,11 +246,6 @@ def wrapped(self, *args, **kwargs):
return wrapped


# patches
_original_jupyter_paths = None
_jupyter_paths_without_home = None


class JupyterHubSingleUser(ExtensionApp):
"""Jupyter Server extension entrypoint.
Expand Down Expand Up @@ -357,7 +353,7 @@ def _default_activity_interval(self):
async def notify_activity(self):
"""Notify jupyterhub of activity"""
client = self.hub_http_client
last_activity = self.web_app.last_activity()
last_activity = self.serverapp.web_app.last_activity()
if not last_activity:
self.log.debug("No activity to send to the Hub")
return
Expand Down Expand Up @@ -590,52 +586,6 @@ async def stop_extension(self):
def _defaut_disable_user_config(self):
return _bool_env("JUPYTERHUB_DISABLE_USER_CONFIG")

@staticmethod
def _disable_user_config(serverapp):
"""
disable user-controlled sources of configuration
by excluding directories in their home
from paths.
This _does not_ disable frontend config,
such as UI settings persistence.
1. Python config file paths
2. Search paths for extensions, etc.
3. import path
"""

# config_file_paths is a property without a setter
# can't override on the instance
default_config_file_paths = serverapp.config_file_paths
config_file_paths = list(_exclude_home(default_config_file_paths))
serverapp.__class__.config_file_paths = property(
lambda self: config_file_paths,
)
# verify patch applied
assert serverapp.config_file_paths == config_file_paths

# patch jupyter_path to exclude $HOME
global _original_jupyter_paths, _jupyter_paths_without_home
_original_jupyter_paths = paths.jupyter_path()
_jupyter_paths_without_home = list(_exclude_home(_original_jupyter_paths))

def get_jupyter_path_without_home(*subdirs):
from jupyterhub.singleuser.extension import _original_jupyter_paths

paths = list(_original_jupyter_paths)
if subdirs:
paths = [os.path.join(p, *subdirs) for p in paths]
return paths

# patch `jupyter_path.__code__` to ensure all callers are patched,
# even if they've already imported
# this affects e.g. nbclassic.nbextension_paths
paths.jupyter_path.__code__ = get_jupyter_path_without_home.__code__

# prevent loading default static custom path in nbclassic
serverapp.config.NotebookApp.static_custom_path = []

@classmethod
def make_serverapp(cls, **kwargs):
"""Instantiate the ServerApp
Expand All @@ -645,7 +595,7 @@ def make_serverapp(cls, **kwargs):
serverapp = super().make_serverapp(**kwargs)
if _bool_env("JUPYTERHUB_DISABLE_USER_CONFIG"):
# disable user-controllable config
cls._disable_user_config(serverapp)
_disable_user_config(serverapp)

if _bool_env("JUPYTERHUB_SINGLEUSER_TEST_EXTENSION"):
serverapp.log.warning("Enabling jupyterhub test extension")
Expand Down
14 changes: 3 additions & 11 deletions jupyterhub/singleuser/mixins.py
Expand Up @@ -45,6 +45,7 @@
from ..log import log_request
from ..services.auth import HubOAuth, HubOAuthCallbackHandler, HubOAuthenticated
from ..utils import exponential_backoff, isoformat, make_ssl_context, url_path_join
from ._disable_user_config import _disable_user_config, _exclude_home


def _bool_env(key):
Expand Down Expand Up @@ -169,17 +170,6 @@ def hub_auth(self):
}


def _exclude_home(path_list):
"""Filter out any entries in a path list that are in my home directory.
Used to disable per-user configuration.
"""
home = os.path.expanduser('~/')
for p in path_list:
if not p.startswith(home):
yield p


class SingleUserNotebookAppMixin(Configurable):
"""A Subclass of the regular NotebookApp that is aware of the parent multiuser context."""

Expand Down Expand Up @@ -598,6 +588,8 @@ def find_server_extensions(self):
self.jpserver_extensions["jupyterhub.tests.extension"] = True

def initialize(self, argv=None):
if self.disable_user_config:
_disable_user_config(self)
# disable trash by default
# this can be re-enabled by config
self.config.FileContentsManager.delete_to_trash = False
Expand Down
5 changes: 5 additions & 0 deletions jupyterhub/tests/mocking.py
Expand Up @@ -85,6 +85,11 @@ def _cmd_default(self):
use_this_api_token = None

def start(self):
# preserve any JupyterHub env in mock spawner
for key in os.environ:
if 'JUPYTERHUB' in key and key not in self.env_keep:
self.env_keep.append(key)

if self.use_this_api_token:
self.api_token = self.use_this_api_token
elif self.will_resume:
Expand Down
20 changes: 15 additions & 5 deletions jupyterhub/tests/test_singleuser.py
Expand Up @@ -176,16 +176,26 @@ async def test_disable_user_config(request, app, tmpdir, full_spawn):
pprint.pprint(info)
assert info['disable_user_config']
server_config = info['config']
settings = info['settings']
assert 'TestSingleUser' not in server_config
# check config paths
norm_home = os.path.realpath(os.path.abspath(home))
for path in info['config_file_paths']:

def assert_not_in_home(path, name):
path = os.path.realpath(os.path.abspath(path))
assert not path.startswith(norm_home + os.path.sep)
assert not path.startswith(
norm_home + os.path.sep
), f"{name}: {path} is in home {norm_home}"

# TODO: check legacy notebook config
# nbextensions_path
# static_custom_path
for path in info['config_file_paths']:
assert_not_in_home(path, 'config_file_paths')

# check every path setting for lookup in $HOME
# is this too much?
for key, setting in settings.items():
if 'path' in key and isinstance(setting, list):
for path in setting:
assert_not_in_home(path, key)


def test_help_output():
Expand Down

0 comments on commit 9a49d06

Please sign in to comment.