Repository: jupyterhub/jupyterhub

Issue:
Unable to disable user config with Jupyter Server
The hub administrator is supposed to be able to prevent per-user notebook configuration scripts from running by setting

```
c.Spawner.disable_user_config = True
```

In the `jupyterhub_config.py` config.  This sets the environment variable `JUPYTERHUB_DISABLE_USER_CONFIG=1` for the spawned notebook server.  However this seems to be being ignored?

<details>
<summary>Using this Dockerfile</summary>

```
FROM jupyterhub/jupyterhub:2

RUN python3 -m pip install --no-cache jupyterlab

RUN \
    adduser -q --gecos "" --disabled-password user1     && \
    echo user1:user1 | chpasswd

ADD jupyterhub_config.py .

RUN mkdir -p /home/user1/.jupyter
ADD jupyter_notebook_config.py /home/user1/.jupyter/.
RUN chown -R user1:user1 /home/user1/.jupyter

CMD ["jupyterhub"]
```

</details>

<details><summary>
with this `jupyterhub_config.py` and example notebook config for `user1`:
</summary>

```
c.Spawner.disable_user_config = True
```

```
import os

print("HELLO FROM THE NOTEBOOK CONFIG")
print(os.getenv("JUPYTERHUB_DISABLE_USER_CONFIG"))

c.ServerApp.shutdown_no_activity_timeout = 600
c.MappingKernelManager.cull_idle_timeout = 600
c.TerminalManager.cull_inactive_timeout = 600
```

</details>

I see the "HELLO" message and the value 1 printed when the notebook starts up, and the timeout message indicating that my config setting is in effect:

```
[I 2022-02-22 22:35:23.167 SingleUserLabApp serverapp:2161] Will shut down after 600 seconds with no kernels or terminals.
```

Am I misunderstanding exactly what config files are excluded?  I see there's a test for this but I wonder is it actually verifying that the config is being ignored?


Discussion:
It looks like `disable_user_config` modifies the config search path by overriding some methods in a mixin:
https://github.com/jupyterhub/jupyterhub/blob/874c91a086345246044ab70d6b5a8c371db89cd2/jupyterhub/singleuser/mixins.py#L434-L456
which is eventually mixed with the actual singleuser app:
https://github.com/jupyterhub/jupyterhub/blob/874c91a086345246044ab70d6b5a8c371db89cd2/jupyterhub/singleuser/mixins.py#L870
https://github.com/jupyterhub/jupyterhub/blob/874c91a086345246044ab70d6b5a8c371db89cd2/jupyterhub/singleuser/app.py#L41

I spent a few minutes digging into https://github.com/jupyter-server/jupyter_server and https://github.com/jupyter/jupyter_core to see if they might be bypassing the above overridden methods by loading config in a different function but got very lost in the code!
It's related to the interaction between JupyterHub and JupyterServer. The config is correctly ignored in `NotebookApp` (default in 1.5.0) but not `ServerApp` (default in 2.0.0):

| JupyterHub version | `JUPYTERHUB_SINGLEUSER_APP` | `c.Spawner.disable_user_config = True` |
|---|---|---|
| 1.5.0 | notebook.notebookapp.NotebookApp | :heavy_check_mark: |
| 1.5.0 | jupyter_server.serverapp.ServerApp | ❌ |
| 2.0.0 | notebook.notebookapp.NotebookApp | :heavy_check_mark: |
| 2.0.0 | jupyter_server.serverapp.ServerApp | ❌ |
| 2.1.1 | notebook.notebookapp.NotebookApp | :heavy_check_mark: |
| 2.1.1 | jupyter_server.serverapp.ServerApp | ❌ |

@manics you are amazing! Thanks for all your investigative work!! @rcthomas beautifully summarized issue as well!! :tada: :heart:
Thanks @manics, did you look further into `jupyter_server` and get a sense of what happened or would it make sense for us to open an issue over there?
I haven't looked any further.

@blink1073 any chance you could give us some help?
It looks like [SingleUserLabApp](https://github.com/jupyterlab/jupyterlab/blob/886b2ab9999dd281d47e9970f60e7d3398e7020f/jupyterlab/labhubapp.py#L27) needs to add handling of [`disable_user_config`](https://github.com/jupyterhub/jupyterhub/blob/3ed345f4960f083b2d35c72b330254aa3cf31fe9/jupyterhub/singleuser/mixins.py#L370).  cc @fcollonval @jtpio 
@blink1073 shouldn't it be `jupyter_sever.serverapp.ServerApp` as `SingleUserLabApp` is inheriting from it (as well as other alternative app)?
I don't think so, this is Hub-level only.  cc @Zsailer 
@blink1073 thanks for the clues.....

It looks like Jupyter-server calls `jupyter_config_path()`
https://github.com/jupyter-server/jupyter_server/blob/62607ce28576e50358217534161f9e7c4aab2611/jupyter_server/serverapp.py#L2142-L2157
which is defined in
https://github.com/jupyter/jupyter_core/blob/5ae115928f57ae55928d217f9221c68ef188153c/jupyter_core/paths.py#L213-L261
This could be relevant:
```py
    if os.environ.get('JUPYTER_NO_CONFIG'):
        # jupyter_config_dir makes a blank config when JUPYTER_NO_CONFIG is set.
        return [jupyter_config_dir()]
```
So maybe setting `JUPYTER_NO_CONFIG=1` in the spawner environment will disable the user config?
> So maybe setting JUPYTER_NO_CONFIG=1 in the spawner environment will disable the user config?

But that will block configuration from the environment too, isn't it? That is probably too broad.
I think it's `nbclassic` that's loading this config (maybe notebook_shim? Unclear to me.), but technically any ExtensionApp could introduce the same problem.

The general issue is that each ExtensionApp may load its own config, have its own paths to search, and the patches applied by JupyterHub to the base ServerApp do not apply to extensions. When we put together JupyterHub, all Applications were a single entrypoint for loading config, but ExtensionApps make this quite a lot more complicated. They are much more independent, so it's not feasible to patch them all.

https://github.com/jupyter-server/jupyter_server/pull/715 solves two problems:

1. locating server extensions ignored config_file_paths, reimplementing the same logic in a way that cannot be overridden (the original notebookapp does this, too, for server extensions)
2. ExtensionApps ignore the parent's config file paths

I think just number 2 solves this particular case, because the nbclassic ExtensionApp would inherit config_file_path.

Maybe we also need to special-case patches for nbclassic for other properties.

Perhaps the 'no_user_config' implementation really belongs in ServerApp (or even jupyter_core)? It really is jupyterhub-specific, but server extensions make it challenging to exert the control we need.

I really want to move `jupyterhub-singleuser` to just _be_ a jupyter server extension, rather than a wrapper, but right now that's hard (especially while keeping support for NotebookApp).

Resolving patch:
diff --git a/jupyterhub/singleuser/_disable_user_config.py b/jupyterhub/singleuser/_disable_user_config.py
--- a/jupyterhub/singleuser/_disable_user_config.py
+++ b/jupyterhub/singleuser/_disable_user_config.py
@@ -20,19 +20,35 @@
 """
 
 import os
+from pathlib import Path
 
 from jupyter_core import paths
 
 
+def _is_relative_to(path, prefix):
+    """
+    Backport Path.is_relative_to for Python < 3.9
+
+    added in Python 3.9
+    """
+    if hasattr(path, "is_relative_to"):
+        # Python >= 3.9
+        return path.is_relative_to(prefix)
+    else:
+        return path == prefix or prefix in path.parents
+
+
 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
+    # resolve paths before comparison
+    # so we do the right thing when $HOME is a symlink
+    home = Path.home().resolve()
+    for path in path_list:
+        if not _is_relative_to(Path(path).resolve(), home):
+            yield path
 
 
 # record patches


Test patch:
diff --git a/jupyterhub/tests/test_singleuser.py b/jupyterhub/tests/test_singleuser.py
--- a/jupyterhub/tests/test_singleuser.py
+++ b/jupyterhub/tests/test_singleuser.py
@@ -3,6 +3,7 @@
 import os
 import sys
 from contextlib import nullcontext
+from pathlib import Path
 from pprint import pprint
 from subprocess import CalledProcessError, check_output
 from unittest import mock
@@ -145,7 +146,7 @@ async def test_singleuser_auth(
 @pytest.mark.skipif(
     IS_JUPYVERSE, reason="jupyverse doesn't look up directories for configuration files"
 )
-async def test_disable_user_config(request, app, tmpdir, full_spawn):
+async def test_disable_user_config(request, app, tmp_path, full_spawn):
     # login, start the server
     cookies = await app.login_user('nandy')
     user = app.users['nandy']
@@ -156,14 +157,21 @@ async def test_disable_user_config(request, app, tmpdir, full_spawn):
     # start with new config:
     user.spawner.debug = True
     user.spawner.disable_user_config = True
-    home_dir = tmpdir.join("home")
-    home_dir.mkdir()
+    user.spawner.default_url = "/jupyterhub-test-info"
+
+    # make sure it's resolved to start
+    tmp_path = tmp_path.resolve()
+    real_home_dir = tmp_path / "realhome"
+    real_home_dir.mkdir()
+    # make symlink to test resolution
+    home_dir = tmp_path / "home"
+    home_dir.symlink_to(real_home_dir)
     # home_dir is defined on SimpleSpawner
-    user.spawner.home_dir = home = str(home_dir)
-    jupyter_config_dir = home_dir.join(".jupyter")
+    user.spawner.home_dir = str(home_dir)
+    jupyter_config_dir = home_dir / ".jupyter"
     jupyter_config_dir.mkdir()
     # verify config paths
-    with jupyter_config_dir.join("jupyter_server_config.py").open("w") as f:
+    with (jupyter_config_dir / "jupyter_server_config.py").open("w") as f:
         f.write("c.TestSingleUser.jupyter_config_py = True")
 
     await user.spawn()
@@ -174,29 +182,22 @@ async def test_disable_user_config(request, app, tmpdir, full_spawn):
     # with cookies, login successful
     r = await async_requests.get(url, cookies=cookies)
     r.raise_for_status()
-    assert r.url.rstrip('/').endswith(
-        url_path_join('/user/nandy', user.spawner.default_url or "/tree")
-    )
+    assert r.url.endswith('/user/nandy/jupyterhub-test-info')
     assert r.status_code == 200
-
-    r = await async_requests.get(
-        url_path_join(public_url(app, user), 'jupyterhub-test-info'), cookies=cookies
-    )
-    r.raise_for_status()
     info = r.json()
     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))
 
+    # check against tmp_path, the parent of both our home directories
+    # (symlink and real)
     def assert_not_in_home(path, name):
-        path = os.path.realpath(os.path.abspath(path))
-        assert not path.startswith(
-            norm_home + os.path.sep
-        ), f"{name}: {path} is in home {norm_home}"
+        path = Path(path).resolve()
+        assert not (str(path) + os.path.sep).startswith(
+            str(tmp_path) + os.path.sep
+        ), f"{name}: {path} is in home {tmp_path}"
 
     for path in info['config_file_paths']:
         assert_not_in_home(path, 'config_file_paths')