jupyterhub-singleuser as a Jupyter Server 2.0 extension

mostly a copy (fork) of singleuser app
using public APIs instead of lots of patching.

opt-in via `JUPYTERHUB_SINGLEUSER_EXTENSION=1`

related changes:

- stop running a test single-user server in a thread. It's complicated and fragile.
  Instead, run it normally, and get the info we need from a custom handler registered via an extension
  via the `full_spawn` fixture
This commit is contained in:
Min RK
2022-12-21 12:28:37 +01:00
parent 63f164ca53
commit 58dccdb59b
17 changed files with 1068 additions and 141 deletions

View File

@@ -92,6 +92,8 @@ jobs:
selenium: selenium selenium: selenium
- python: "3.11" - python: "3.11"
main_dependencies: main_dependencies main_dependencies: main_dependencies
- python: "3.10"
serverextension: serverextension
steps: steps:
# NOTE: In GitHub workflows, environment variables are set by writing # NOTE: In GitHub workflows, environment variables are set by writing
@@ -115,8 +117,8 @@ jobs:
echo "PGPASSWORD=hub[test/:?" >> $GITHUB_ENV echo "PGPASSWORD=hub[test/:?" >> $GITHUB_ENV
echo "JUPYTERHUB_TEST_DB_URL=postgresql://test_user:hub%5Btest%2F%3A%3F@127.0.0.1:5432/jupyterhub" >> $GITHUB_ENV echo "JUPYTERHUB_TEST_DB_URL=postgresql://test_user:hub%5Btest%2F%3A%3F@127.0.0.1:5432/jupyterhub" >> $GITHUB_ENV
fi fi
if [ "${{ matrix.jupyter_server }}" != "" ]; then if [ "${{ matrix.serverextension }}" != "" ]; then
echo "JUPYTERHUB_SINGLEUSER_APP=jupyterhub.tests.mockserverapp.MockServerApp" >> $GITHUB_ENV echo "JUPYTERHUB_SINGLEUSER_EXTENSION=1" >> $GITHUB_ENV
fi fi
- uses: actions/checkout@v3 - uses: actions/checkout@v3
# NOTE: actions/setup-node@v3 make use of a cache within the GitHub base # NOTE: actions/setup-node@v3 make use of a cache within the GitHub base
@@ -165,6 +167,9 @@ jobs:
if [ "${{ matrix.db }}" == "postgres" ]; then if [ "${{ matrix.db }}" == "postgres" ]; then
pip install psycopg2-binary pip install psycopg2-binary
fi fi
if [ "${{ matrix.serverextension }}" != "" ]; then
pip install 'jupyter-server>=2'
fi
pip freeze pip freeze

View File

@@ -0,0 +1,25 @@
# Granting read-only access to user servers
Jupyter Server 2.0 adds the ability to enforce granular permissions via its Authorizer API.
Combining this with JupyterHub's custom scopes and singleuser-server extension, you can use JupyterHub to grant users restricted (e.g. read-only) access to each others' servers,
rather than an all-or-nothing access permission.
This example demonstrates granting read-only access to just one specific user's server to one specific other user,
but you can grant access for groups, all users, etc.
Given users `vex` and `percy`, we want `vex` to have permission to:
1. read and open files, and view the state of the server, but
2. not write or edit files
3. not start/stop the server
4. not execute anything
(Jupyter Server's Authorizer API allows for even more fine-grained control)
To test this, you'll want two browser sessions:
1. login as `percy` (dummy auth means username and no password needed) and start the server
2. in another session (another browser, or logout as percy), login as `vex` (again, any password in the example)
3. as vex, visit http://127.0.0.1:8000/users/percy/
Percy can use their server as normal, but vex will only be able to read files.
Vex won't be able to run any code, connect to kernels, or save edits to files.

View File

@@ -0,0 +1,36 @@
import os
from jupyterhub.singleuser.extension import JupyterHubAuthorizer
class GranularJupyterHubAuthorizer(JupyterHubAuthorizer):
"""Authorizer that looks for permissions in JupyterHub scopes"""
def is_authorized(self, handler, user, action, resource):
# authorize if any of these permissions are present
# filters check for access to this specific user or server
# group filters aren't available!
filters = [
f"!user={os.environ['JUPYTERHUB_USER']}",
f"!server={os.environ['JUPYTERHUB_USER']}/{os.environ['JUPYTERHUB_SERVER_NAME']}",
]
required_scopes = set()
for f in filters:
required_scopes.update(
{
f"custom:jupyter_server:{action}:{resource}{f}",
f"custom:jupyter_server:{action}:*{f}",
}
)
have_scopes = self.hub_auth.check_scopes(required_scopes, user.hub_user)
self.log.debug(
f"{user.username} has permissions {have_scopes} required to {action} on {resource}"
)
return bool(have_scopes)
c = get_config() # noqa
c.ServerApp.authorizer_class = GranularJupyterHubAuthorizer

View File

@@ -0,0 +1,95 @@
c = get_config() # noqa
# define custom scopes so they can be assigned to users
# these could be
c.JupyterHub.custom_scopes = {
"custom:jupyter_server:read:*": {
"description": "read-only access to your server",
},
"custom:jupyter_server:write:*": {
"description": "access to modify files on your server. Does not include execution.",
"subscopes": ["custom:jupyter_server:read:*"],
},
"custom:jupyter_server:execute:*": {
"description": "Execute permissions on servers.",
"subscopes": [
"custom:jupyter_server:write:*",
"custom:jupyter_server:read:*",
],
},
}
c.JupyterHub.load_roles = [
# grant specific users read-only access to all servers
{
"name": "read-only-all",
"scopes": [
"access:servers",
"custom:jupyter_server:read:*",
],
"groups": ["read-only"],
},
{
"name": "read-only-read-only-percy",
"scopes": [
"access:servers!user=percy",
"custom:jupyter_server:read:*!user=percy",
],
"users": ["vex"],
},
{
"name": "admin-ui",
"scopes": [
"admin-ui",
"list:users",
"admin:servers",
],
"users": ["admin"],
},
{
"name": "full-access",
"scopes": [
"access:servers",
"custom:jupyter_server:execute:*",
],
"users": ["minrk"],
},
# all users have full access to their own servers
{
"name": "user",
"scopes": [
"custom:jupyter_server:execute:*!user",
"custom:jupyter_server:read:*!user",
"self",
],
},
]
# servers request access to themselves
c.Spawner.oauth_client_allowed_scopes = [
"access:servers!server",
"custom:jupyter_server:read:*!server",
"custom:jupyter_server:execute:*!server",
]
# enable the jupyter-server extension
c.Spawner.environment = {
"JUPYTERHUB_SINGLEUSER_EXTENSION": "1",
}
from pathlib import Path
here = Path(__file__).parent.resolve()
# load the server config that enables granular permissions
c.Spawner.args = [
f"--config={here}/jupyter_server_config.py",
]
# example boilerplate: dummy auth/spawner
c.JupyterHub.authenticator_class = 'dummy'
c.JupyterHub.spawner_class = 'simple'
c.JupyterHub.ip = '127.0.0.1'

View File

@@ -1,3 +1,10 @@
from ._version import __version__, version_info from ._version import __version__, version_info
def _jupyter_server_extension_points():
from .singleuser.extension import JupyterHubSingleUser
return [{"module": "jupyterhub", "app": JupyterHubSingleUser}]
__all__ = ["__version__", "version_info"] __all__ = ["__version__", "version_info"]

View File

@@ -243,7 +243,6 @@ class HubAuth(SingletonConfigurable):
return 'http://127.0.0.1:8081' + url_path_join(self.hub_prefix, 'api') return 'http://127.0.0.1:8081' + url_path_join(self.hub_prefix, 'api')
api_token = Unicode( api_token = Unicode(
os.getenv('JUPYTERHUB_API_TOKEN', ''),
help="""API key for accessing Hub API. help="""API key for accessing Hub API.
Default: $JUPYTERHUB_API_TOKEN Default: $JUPYTERHUB_API_TOKEN
@@ -253,6 +252,10 @@ class HubAuth(SingletonConfigurable):
""", """,
).tag(config=True) ).tag(config=True)
@default("api_token")
def _default_api_token(self):
return os.getenv('JUPYTERHUB_API_TOKEN', '')
hub_prefix = Unicode( hub_prefix = Unicode(
'/hub/', '/hub/',
help="""The URL prefix for the Hub itself. help="""The URL prefix for the Hub itself.

View File

@@ -2,17 +2,44 @@
Contains default notebook-app subclass and mixins Contains default notebook-app subclass and mixins
""" """
from .app import SingleUserNotebookApp, main import os
from .mixins import HubAuthenticatedHandler, make_singleuser_app from .mixins import HubAuthenticatedHandler, make_singleuser_app
if os.environ.get("JUPYTERHUB_SINGLEUSER_EXTENSION", "") not in ("", "0"):
# check for conflict in singleuser entrypoint environment variables
if os.environ.get("JUPYTERHUB_SINGLEUSER_APP", "") not in {
"",
"jupyter_server",
"jupyter-server",
"extension",
"jupyter_server.serverapp.ServerApp",
}:
ext = os.environ["JUPYTERHUB_SINGLEUSER_EXTENSION"]
app = os.environ["JUPYTERHUB_SINGLEUSER_APP"]
raise ValueError(
f"Cannot use JUPYTERHUB_SINGLEUSER_EXTENSION=1 with JUPYTERHUB_SINGLEUSER_APP={app}."
" Please pick one or the other."
)
_as_extension = True
from .extension import main
else:
_as_extension = False
try:
from .app import SingleUserNotebookApp, main
except ImportError:
# check for Jupyter Server 2.0 ?
from .extension import main
else:
# backward-compatibility
JupyterHubLoginHandler = SingleUserNotebookApp.login_handler_class
JupyterHubLogoutHandler = SingleUserNotebookApp.logout_handler_class
OAuthCallbackHandler = SingleUserNotebookApp.oauth_callback_handler_class
__all__ = [ __all__ = [
"SingleUserNotebookApp", "SingleUserNotebookApp",
"main", "main",
"HubAuthenticatedHandler", "HubAuthenticatedHandler",
"make_singleuser_app", "make_singleuser_app",
] ]
# backward-compatibility
JupyterHubLoginHandler = SingleUserNotebookApp.login_handler_class
JupyterHubLogoutHandler = SingleUserNotebookApp.logout_handler_class
OAuthCallbackHandler = SingleUserNotebookApp.oauth_callback_handler_class

View File

@@ -1,4 +1,4 @@
from .app import main from . import main
if __name__ == '__main__': if __name__ == '__main__':
main() main()

View File

@@ -14,8 +14,18 @@ from traitlets import import_item
from .mixins import make_singleuser_app from .mixins import make_singleuser_app
JUPYTERHUB_SINGLEUSER_APP = os.environ.get("JUPYTERHUB_SINGLEUSER_APP") JUPYTERHUB_SINGLEUSER_APP = os.environ.get("JUPYTERHUB_SINGLEUSER_APP", "")
# allow shortcut references
_app_shortcuts = {
"notebook": "notebook.notebookapp.NotebookApp",
"jupyter-server": "jupyter_server.serverapp.ServerApp",
"extension": "jupyter_server.serverapp.ServerApp",
}
JUPYTERHUB_SINGLEUSER_APP = _app_shortcuts.get(
JUPYTERHUB_SINGLEUSER_APP.replace("_", "-"), JUPYTERHUB_SINGLEUSER_APP
)
if JUPYTERHUB_SINGLEUSER_APP: if JUPYTERHUB_SINGLEUSER_APP:
App = import_item(JUPYTERHUB_SINGLEUSER_APP) App = import_item(JUPYTERHUB_SINGLEUSER_APP)

View File

@@ -0,0 +1,659 @@
"""
Integrate JupyterHub auth with Jupyter Server as an Extension
Requires Jupyter Server 2.0, which in turn requires Python 3.7
"""
from __future__ import annotations
import asyncio
import json
import os
import random
from datetime import timezone
from functools import wraps
from pathlib import Path
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
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
from tornado.httputil import url_concat
from tornado.web import HTTPError
from traitlets import Any, Bool, Instance, Integer, Unicode, default
from jupyterhub._version import __version__, _check_version
from jupyterhub.services.auth import HubOAuth, HubOAuthCallbackHandler
from jupyterhub.utils import (
exponential_backoff,
isoformat,
make_ssl_context,
url_path_join,
)
SINGLEUSER_TEMPLATES_DIR = str(Path(__file__).parent.resolve().joinpath("templates"))
def _bool_env(key):
"""Cast an environment variable to bool
0, empty, or unset is False; All other values are True.
"""
if os.environ.get(key, "") in {"", "0"}:
return False
else:
return True
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 JupyterHubLogoutHandler(LogoutHandler):
def get(self):
hub_auth = self.identity_provider.hub_auth
# clear single-user cookie
hub_auth.clear_cookie(self)
# redirect to hub to clear the rest
self.redirect(hub_auth.hub_host + url_path_join(hub_auth.hub_prefix, "logout"))
class JupyterHubUser(User):
"""Subclass jupyter_server User to store JupyterHub user info"""
# not dataclass fields,
# so these aren't returned in the identity model via the REST API.
# The could be, though!
hub_user: dict
def __init__(self, hub_user):
self.hub_user = hub_user
super().__init__(username=self.hub_user["name"])
class JupyterHubOAuthCallbackHandler(HubOAuthCallbackHandler):
"""Callback handler for completing OAuth with JupyterHub"""
def initialize(self, hub_auth):
self.hub_auth = hub_auth
class JupyterHubIdentityProvider(IdentityProvider):
"""Identity Provider for JupyterHub OAuth
Replacement for JupyterHub's HubAuthenticated mixin
"""
logout_handler_class = JupyterHubLogoutHandler
hub_auth = Instance(HubOAuth)
@property
def token(self):
return self.hub_auth.api_token
token_generated = False
@default("hub_auth")
def _default_hub_auth(self):
# HubAuth gets most of its config from the environment
return HubOAuth(parent=self)
def _patch_get_login_url(self, handler):
original_get_login_url = handler.get_login_url
def get_login_url():
"""Return the Hub's login URL, to begin login redirect"""
login_url = self.hub_auth.login_url
# add state argument to OAuth url
state = self.hub_auth.set_state_cookie(
handler, next_url=handler.request.uri
)
login_url = url_concat(login_url, {'state': state})
# temporary override at setting level,
# to allow any subclass overrides of get_login_url to preserve their effect;
# for example, APIHandler raises 403 to prevent redirects
with mock.patch.dict(
handler.application.settings, {"login_url": login_url}
):
self.log.debug("Redirecting to login url: %s", login_url)
return original_get_login_url()
handler.get_login_url = get_login_url
async def get_user(self, handler):
if hasattr(handler, "_jupyterhub_user"):
return handler._jupyterhub_user
self._patch_get_login_url(handler)
user = await self.hub_auth.get_user(handler, sync=False)
if user is None:
handler._jupyterhub_user = None
return None
# check access scopes - don't allow even authenticated
# users with no access to this service past this stage.
self.log.debug(
f"Checking user {user['name']} with scopes {user['scopes']} against {self.hub_auth.access_scopes}"
)
scopes = self.hub_auth.check_scopes(self.hub_auth.access_scopes, user)
if scopes:
self.log.debug(f"Allowing user {user['name']} with scopes {scopes}")
else:
self.log.warning(f"Not allowing user {user['name']}")
# User is authenticated, but not authorized.
# Override redirect so if/when tornado @web.authenticated
# tries to redirect to login URL, 403 will be raised instead.
# This is not the best, but avoids problems that can be caused
# when get_current_user is allowed to raise,
# and avoids redirect loops for users who are logged it,
# but not allowed to access this resource.
def raise_on_redirect(*args, **kwargs):
raise HTTPError(403, "{kind} {name} is not allowed.".format(**user))
handler.redirect = raise_on_redirect
return None
handler._jupyterhub_user = JupyterHubUser(user)
return handler._jupyterhub_user
def get_handlers(self):
"""Register our OAuth callback handler"""
return [
("/logout", self.logout_handler_class),
(
"/oauth_callback",
JupyterHubOAuthCallbackHandler,
{"hub_auth": self.hub_auth},
),
]
def validate_security(self, app, ssl_options=None):
"""Prevent warnings about security from base class"""
return
def page_config_hook(self, handler, page_config):
"""JupyterLab page config hook
Adds JupyterHub info to page config.
Places the JupyterHub API token in PageConfig.token.
Only has effect on jupyterlab_server >=2.9
"""
user = handler.current_user
# originally implemented in jupyterlab's LabApp
page_config["hubUser"] = user.name if user else ""
page_config["hubPrefix"] = hub_prefix = self.hub_auth.hub_prefix
page_config["hubHost"] = self.hub_auth.hub_host
page_config["shareUrl"] = url_path_join(hub_prefix, "user-redirect")
page_config["hubServerName"] = os.environ.get("JUPYTERHUB_SERVER_NAME", "")
page_config["token"] = self.hub_auth.get_token(handler) or ""
return page_config
class JupyterHubAuthorizer(Authorizer):
"""Authorizer that looks for permissions in JupyterHub scopes"""
# TODO: https://github.com/jupyter-server/jupyter_server/pull/830
hub_auth = Instance(HubOAuth)
@default("hub_auth")
def _default_hub_auth(self):
# HubAuth gets most of its config from the environment
return HubOAuth(parent=self)
def is_authorized(self, handler, user, action, resource):
# This is where we would implement granular scope checks,
# but until then,
# since the IdentityProvider doesn't allow users without access scopes,
# there's no further check to make.
# This scope check is redundant
have_scopes = self.hub_auth.check_scopes(
self.hub_auth.oauth_scopes, user.hub_user
)
self.log.debug(
f"{user.username} has permissions {have_scopes} required to {action} on {resource}"
)
return bool(have_scopes)
def _fatal_errors(f):
"""Decorator to make errors fatal to the server app
Ensures our extension is loaded or the server exits,
rather than starting a server without jupyterhub auth enabled.
"""
@wraps(f)
def wrapped(self, *args, **kwargs):
try:
r = f(self, *args, **kwargs)
except Exception:
self.log.exception("Failed to load JupyterHubSingleUser server extension")
self.exit(1)
return wrapped
# patches
_original_jupyter_paths = None
_jupyter_paths_without_home = None
class JupyterHubSingleUser(ExtensionApp):
"""Jupyter Server extension entrypoint.
Enables JupyterHub authentication
and some JupyterHub-specific configuration from environment variables
Server extensions are loaded before the rest of the server is set up
"""
name = app_namespace = "jupyterhub-singleuser"
version = __version__
load_other_extensions = os.environ.get(
"JUPYTERHUB_SINGLEUSER_LOAD_OTHER_EXTENSIONS", "1"
) not in {"", "0"}
# Most of this is _copied_ from the SingleUserNotebookApp mixin,
# which will be deprecated over time
# (i.e. once we can _require_ jupyter server 2.0)
# this is a _class_ attribute to deal with the lifecycle
# of when it's loaded vs when it's checked
disable_user_config = False
hub_auth = Instance(HubOAuth)
@default("hub_auth")
def _default_hub_auth(self):
# HubAuth gets most of its config from the environment
return HubOAuth(parent=self)
# create dynamic default http client,
# configured with any relevant ssl config
hub_http_client = Any()
@default('hub_http_client')
def _default_client(self):
ssl_context = make_ssl_context(
self.hub_auth.keyfile,
self.hub_auth.certfile,
cafile=self.hub_auth.client_ca,
)
AsyncHTTPClient.configure(None, defaults={"ssl_options": ssl_context})
return AsyncHTTPClient()
async def check_hub_version(self):
"""Test a connection to my Hub
- exit if I can't connect at all
- check version and warn on sufficient mismatch
"""
client = self.hub_http_client
RETRIES = 5
for i in range(1, RETRIES + 1):
try:
resp = await client.fetch(self.hub_api_url)
except Exception:
self.log.exception(
"Failed to connect to my Hub at %s (attempt %i/%i). Is it running?",
self.hub_api_url,
i,
RETRIES,
)
await asyncio.sleep(min(2**i, 16))
else:
break
else:
self.exit(1)
hub_version = resp.headers.get('X-JupyterHub-Version')
_check_version(hub_version, __version__, self.log)
server_name = Unicode()
@default('server_name')
def _server_name_default(self):
return os.environ.get('JUPYTERHUB_SERVER_NAME', '')
hub_activity_url = Unicode(
config=True, help="URL for sending JupyterHub activity updates"
)
@default('hub_activity_url')
def _default_activity_url(self):
return os.environ.get('JUPYTERHUB_ACTIVITY_URL', '')
hub_activity_interval = Integer(
300,
config=True,
help="""
Interval (in seconds) on which to update the Hub
with our latest activity.
""",
)
@default('hub_activity_interval')
def _default_activity_interval(self):
env_value = os.environ.get('JUPYTERHUB_ACTIVITY_INTERVAL')
if env_value:
return int(env_value)
else:
return 300
_last_activity_sent = Any(allow_none=True)
async def notify_activity(self):
"""Notify jupyterhub of activity"""
client = self.hub_http_client
last_activity = self.web_app.last_activity()
if not last_activity:
self.log.debug("No activity to send to the Hub")
return
if last_activity:
# protect against mixed timezone comparisons
if not last_activity.tzinfo:
# assume naive timestamps are utc
self.log.warning("last activity is using naive timestamps")
last_activity = last_activity.replace(tzinfo=timezone.utc)
if self._last_activity_sent and last_activity < self._last_activity_sent:
self.log.debug("No activity since %s", self._last_activity_sent)
return
last_activity_timestamp = isoformat(last_activity)
async def notify():
self.log.debug("Notifying Hub of activity %s", last_activity_timestamp)
req = HTTPRequest(
url=self.hub_activity_url,
method='POST',
headers={
"Authorization": f"token {self.hub_auth.api_token}",
"Content-Type": "application/json",
},
body=json.dumps(
{
'servers': {
self.server_name: {'last_activity': last_activity_timestamp}
},
'last_activity': last_activity_timestamp,
}
),
)
try:
await client.fetch(req)
except Exception:
self.log.exception("Error notifying Hub of activity")
return False
else:
return True
await exponential_backoff(
notify,
fail_message="Failed to notify Hub of activity",
start_wait=1,
max_wait=15,
timeout=60,
)
self._last_activity_sent = last_activity
async def keep_activity_updated(self):
if not self.hub_activity_url or not self.hub_activity_interval:
self.log.warning("Activity events disabled")
return
self.log.info(
"Updating Hub with activity every %s seconds", self.hub_activity_interval
)
while True:
try:
await self.notify_activity()
except Exception as e:
self.log.exception("Error notifying Hub of activity")
# add 20% jitter to the interval to avoid alignment
# of lots of requests from user servers
t = self.hub_activity_interval * (1 + 0.2 * (random.random() - 0.5))
await asyncio.sleep(t)
def _log_app_versions(self):
"""Log application versions at startup
Logs versions of jupyterhub and singleuser-server base versions (jupyterlab, jupyter_server, notebook)
"""
self.log.info(
f"Starting jupyterhub single-user server extension version {__version__}"
)
@_fatal_errors
def load_config_file(self):
"""Load JupyterHub singleuser config from the environment"""
self._log_app_versions()
if not os.environ.get('JUPYTERHUB_SERVICE_URL'):
raise KeyError("Missing required environment $JUPYTERHUB_SERVICE_URL")
cfg = self.config.ServerApp
cfg.identity_provider_class = JupyterHubIdentityProvider
# disable some single-user features
cfg.open_browser = False
cfg.trust_xheaders = True
cfg.quit_button = False
cfg.port_retries = 0
cfg.answer_yes = True
self.config.FileContentsManager.delete_to_trash = False
# load http server config from environment
url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
if url.port:
cfg.port = url.port
elif url.scheme == 'http':
cfg.port = 80
elif url.scheme == 'https':
cfg.port = 443
if url.hostname:
cfg.ip = url.hostname
else:
cfg.ip = "127.0.0.1"
cfg.base_url = os.environ.get('JUPYTERHUB_SERVICE_PREFIX') or '/'
# load default_url at all kinds of priority,
# to make sure it has the desired effect
cfg.default_url = self.default_url = self.get_default_url()
# Jupyter Server default: config files have higher priority than extensions,
# by:
# 1. load config files
# 2. load extension config
# 3. merge file config into extension config
# we invert that by merging our extension config into server config before
# they get merged the other way
# this way config from this extension should always have highest priority
self.serverapp.update_config(self.config)
# add our custom templates
self.config.NotebookApp.extra_template_paths.append(SINGLEUSER_TEMPLATES_DIR)
@default("default_url")
def get_default_url(self):
# 1. explicit via _user_ config (?)
if 'default_url' in self.serverapp.config.ServerApp:
default_url = self.serverapp.config.ServerApp.default_url
self.log.info(f"Using default url from user config: {default_url}")
return default_url
# 2. explicit via JupyterHub admin config (c.Spawner.default_url)
default_url = os.environ.get("JUPYTERHUB_DEFAULT_URL")
if default_url:
self.log.info(
f"Using default url from environment $JUPYTERHUB_DEFAULT_URL: {default_url}"
)
return default_url
# 3. look for known UI extensions
# priority:
# 1. lab
# 2. nbclassic
# 3. retro
extension_points = self.serverapp.extension_manager.extension_points
for name in ["lab", "retro", "nbclassic"]:
if name in extension_points:
default_url = extension_points[name].app.default_url
if default_url and default_url != "/":
self.log.info(
f"Using default url from server extension {name}: {default_url}"
)
return default_url
self.log.warning(
"No default url found in config or known extensions, searching other extensions for default_url"
)
# 3. _any_ UI extension
# 2. discover other extensions
for (
name,
extension_point,
) in extension_points.items():
app = extension_point.app
if app is self or not app:
continue
default_url = app.default_url
if default_url and default_url != "/":
self.log.info(
f"Using default url from server extension {name}: {default_url}"
)
return default_url
self.log.warning(
"Found no extension with a default URL, UI will likely be unavailable"
)
return "/"
def initialize_templates(self):
"""Patch classic-noteboook page templates to add Hub-related buttons"""
app = self.serverapp
jinja_template_vars = app.jinja_template_vars
# override template vars
jinja_template_vars['logo_url'] = self.hub_auth.hub_host + url_path_join(
self.hub_auth.hub_prefix, 'logo'
)
jinja_template_vars[
'hub_control_panel_url'
] = self.hub_auth.hub_host + url_path_join(self.hub_auth.hub_prefix, 'home')
_activity_task = None
@_fatal_errors
def initialize(self, args=None):
# initialize takes place after
# 1. config has been loaded
# 2. Configurables instantiated
# 3. serverapp.web_app set up
super().initialize()
app = self.serverapp
app.web_app.settings[
"page_config_hook"
] = app.identity_provider.page_config_hook
# add jupyterhub version header
headers = app.web_app.settings.setdefault("headers", {})
headers["X-JupyterHub-Version"] = __version__
async def _start_activity():
self._activity_task = asyncio.ensure_future(self.keep_activity_updated())
app.io_loop.run_sync(_start_activity)
async def stop_extension(self):
if self._activity_task:
self._activity_task.cancel()
disable_user_config = Bool()
@default("disable_user_config")
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
Override to customize the ServerApp before it loads any configuration
"""
serverapp = super().make_serverapp(**kwargs)
if _bool_env("JUPYTERHUB_DISABLE_USER_CONFIG"):
# disable user-controllable config
cls._disable_user_config(serverapp)
if _bool_env("JUPYTERHUB_SINGLEUSER_TEST_EXTENSION"):
serverapp.log.warning("Enabling jupyterhub test extension")
serverapp.jpserver_extensions["jupyterhub.tests.extension"] = True
return serverapp
main = JupyterHubSingleUser.launch_instance
if __name__ == "__main__":
main()

View File

@@ -18,6 +18,7 @@ import sys
import warnings import warnings
from datetime import timezone from datetime import timezone
from importlib import import_module from importlib import import_module
from pathlib import Path
from textwrap import dedent from textwrap import dedent
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -168,65 +169,12 @@ flags = {
} }
page_template = """
{% extends "templates/page.html" %}
{% block header_buttons %}
{{super()}}
<span>
<a href='{{hub_control_panel_url}}'
id='jupyterhub-control-panel-link'
class='btn btn-default btn-sm navbar-btn pull-right'
style='margin-right: 4px; margin-left: 2px;'>
Control Panel
</a>
</span>
{% endblock %}
{% block logo %}
<img src='{{logo_url}}' alt='Jupyter Notebook'/>
{% endblock logo %}
{% block script %}
{{ super() }}
<script type='text/javascript'>
function _remove_redirects_param() {
// remove ?redirects= param from URL so that
// successful page loads don't increment the redirect loop counter
if (window.location.search.length <= 1) {
return;
}
var search_parameters = window.location.search.slice(1).split('&');
for (var i = 0; i < search_parameters.length; i++) {
if (search_parameters[i].split('=')[0] === 'redirects') {
// remote token from search parameters
search_parameters.splice(i, 1);
var new_search = '';
if (search_parameters.length) {
new_search = '?' + search_parameters.join('&');
}
var new_url = window.location.origin +
window.location.pathname +
new_search +
window.location.hash;
window.history.replaceState({}, "", new_url);
return;
}
}
}
_remove_redirects_param();
</script>
{% endblock script %}
"""
def _exclude_home(path_list): def _exclude_home(path_list):
"""Filter out any entries in a path list that are in my home directory. """Filter out any entries in a path list that are in my home directory.
Used to disable per-user configuration. Used to disable per-user configuration.
""" """
home = os.path.expanduser('~') home = os.path.expanduser('~/')
for p in path_list: for p in path_list:
if not p.startswith(home): if not p.startswith(home):
yield p yield p
@@ -624,6 +572,21 @@ class SingleUserNotebookAppMixin(Configurable):
f"Extending {cls.__module__}.{cls.__name__} from {module_name} {mod_version}" f"Extending {cls.__module__}.{cls.__name__} from {module_name} {mod_version}"
) )
# load test extension, if we're testing
def init_server_extension_config(self):
# classic notebook server (notebook < 7)
super().init_server_extension_config()
if os.getenv("JUPYTERHUB_SINGLEUSER_TEST_EXTENSION") == "1":
self.log.warning("Enabling jupyterhub test extension, classic edition")
self.nbserver_extensions["jupyterhub.tests.extension"] = True
def find_server_extensions(self):
# jupyter-server (lab or notebook >=7)
super().find_server_extensions()
if os.getenv("JUPYTERHUB_SINGLEUSER_TEST_EXTENSION") == "1":
self.log.warning("Enabling jupyterhub test extension")
self.jpserver_extensions["jupyterhub.tests.extension"] = True
def initialize(self, argv=None): def initialize(self, argv=None):
# disable trash by default # disable trash by default
# this can be re-enabled by config # this can be re-enabled by config
@@ -785,6 +748,10 @@ class SingleUserNotebookAppMixin(Configurable):
jinja_envs.append(settings[env_name]) jinja_envs.append(settings[env_name])
# patch jinja env loading to get modified template, only for base page.html # patch jinja env loading to get modified template, only for base page.html
template_dir = Path(__file__).resolve().parent.joinpath("templates")
with template_dir.joinpath("page.html").open() as f:
page_template = f.read()
def get_page(name): def get_page(name):
if name == 'page.html': if name == 'page.html':
return page_template return page_template
@@ -935,11 +902,18 @@ def make_singleuser_app(App):
log = empty_parent_app.log log = empty_parent_app.log
# detect base classes # detect base classes
LoginHandler = empty_parent_app.login_handler_class if not getattr(empty_parent_app, "login_handler_class", None) and hasattr(
LogoutHandler = empty_parent_app.logout_handler_class empty_parent_app, "identity_provider_class"
):
has_handlers = empty_parent_app.identity_provider_class(parent=empty_parent_app)
else:
has_handlers = empty_parent_app
LoginHandler = has_handlers.login_handler_class
LogoutHandler = has_handlers.logout_handler_class
BaseHandler = _patch_app_base_handlers(empty_parent_app) BaseHandler = _patch_app_base_handlers(empty_parent_app)
# create Handler classes from mixins + bases # create Handler classes from mixins + bases
class JupyterHubLoginHandler(JupyterHubLoginHandlerMixin, LoginHandler): class JupyterHubLoginHandler(JupyterHubLoginHandlerMixin, LoginHandler):
pass pass

View File

@@ -0,0 +1,44 @@
{% extends "templates/page.html" %} {% block header_buttons %} {{super()}}
<span>
<a
href="{{hub_control_panel_url}}"
id="jupyterhub-control-panel-link"
class="btn btn-default btn-sm navbar-btn pull-right"
style="margin-right: 4px; margin-left: 2px"
>
Control Panel
</a>
</span>
{% endblock %} {% block logo %}
<img src="{{logo_url}}" alt="Jupyter Notebook" />
{% endblock logo %} {% block script %} {{ super() }}
<script type="text/javascript">
function _remove_redirects_param() {
// remove ?redirects= param from URL so that
// successful page loads don't increment the redirect loop counter
if (window.location.search.length <= 1) {
return;
}
var search_parameters = window.location.search.slice(1).split("&");
for (var i = 0; i < search_parameters.length; i++) {
if (search_parameters[i].split("=")[0] === "redirects") {
// remote token from search parameters
search_parameters.splice(i, 1);
var new_search = "";
if (search_parameters.length) {
new_search = "?" + search_parameters.join("&");
}
var new_url =
window.location.origin +
window.location.pathname +
new_search +
window.location.hash;
window.history.replaceState({}, "", new_url);
return;
}
}
}
_remove_redirects_param();
</script>
{% endblock script %}

View File

@@ -173,6 +173,7 @@ async def cleanup_after(request, io_loop):
await app.proxy.delete_user(user, name) await app.proxy.delete_user(user, name)
except HTTPError: except HTTPError:
pass pass
print(f"Stopping leftover server {spawner._log_name}")
await user.stop(name) await user.stop(name)
if user.name not in {'admin', 'user'}: if user.name not in {'admin', 'user'}:
app.users.delete(uid) app.users.delete(uid)
@@ -370,6 +371,15 @@ def slow_spawn(app):
yield yield
@fixture
def full_spawn(app):
"""Fixture enabling full instrumented server via InstrumentedSpawner"""
with mock.patch.dict(
app.tornado_settings, {'spawner_class': mocking.InstrumentedSpawner}
):
yield
@fixture @fixture
def never_spawn(app): def never_spawn(app):
"""Fixture enabling NeverSpawner""" """Fixture enabling NeverSpawner"""

View File

@@ -0,0 +1,54 @@
"""Jupyter server extension for testing JupyterHub
Adds endpoints for accessing whatever state we want to check about the server
"""
import json
from jupyter_server.base.handlers import JupyterHandler
from tornado import web
class JupyterHubTestHandler(JupyterHandler):
def initialize(self, app):
self.app = app
@web.authenticated
def get(self):
def _class_str(obj):
"""Given an instance, return the 'module.Class' string"""
if obj is None:
return None
if obj.__class__ is type:
cls = obj
else:
cls = obj.__class__
return f"{cls.__module__}.{cls.__name__}"
info = {
"current_user": self.current_user,
"config": self.app.config,
"disable_user_config": getattr(self.app, "disable_user_config", None),
"settings": self.settings,
"config_file_paths": self.app.config_file_paths,
}
for attr in ("authenticator", "identity_provider"):
info[attr] = _class_str(getattr(self.app, attr, None))
self.set_header("content-type", "application/json")
self.write(json.dumps(info, default=repr))
def _load_jupyter_server_extension(serverapp):
"""
This function is called when the extension is loaded.
"""
serverapp.log.warning(f"Loading jupyterhub test extension for {serverapp}")
handlers = [
(
serverapp.base_url + 'jupyterhub-test-info',
JupyterHubTestHandler,
{"app": serverapp},
)
]
serverapp.web_app.add_handlers('.*$', handlers)

View File

@@ -20,7 +20,7 @@ Other components
- MockPAMAuthenticator - MockPAMAuthenticator
- MockHub - MockHub
- MockSingleUserServer - MockSingleUserServer
- StubSingleUserSpawner - InstrumentedSpawner
- public_host - public_host
- public_url - public_url
@@ -29,7 +29,6 @@ Other components
import asyncio import asyncio
import os import os
import sys import sys
import threading
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
from unittest import mock from unittest import mock
@@ -42,7 +41,6 @@ from traitlets import Bool, Dict, default
from .. import metrics, orm, roles from .. import metrics, orm, roles
from ..app import JupyterHub from ..app import JupyterHub
from ..auth import PAMAuthenticator from ..auth import PAMAuthenticator
from ..singleuser import SingleUserNotebookApp
from ..spawner import SimpleLocalProcessSpawner from ..spawner import SimpleLocalProcessSpawner
from ..utils import random_port, utcnow from ..utils import random_port, utcnow
from .utils import async_requests, public_url, ssl_setup from .utils import async_requests, public_url, ssl_setup
@@ -377,27 +375,12 @@ class MockHub(JupyterHub):
return r.cookies return r.cookies
# single-user-server mocking: class InstrumentedSpawner(MockSpawner):
class MockSingleUserServer(SingleUserNotebookApp):
"""Mock-out problematic parts of single-user server when run in a thread
Currently:
- disable signal handler
""" """
Spawner that starts a full singleuser server
def init_signal(self): instrumented with the JupyterHub test extension.
pass """
@default("log_level")
def _default_log_level(self):
return 10
class StubSingleUserSpawner(MockSpawner):
"""Spawner that starts a MockSingleUserServer in a thread."""
@default("default_url") @default("default_url")
def _default_url(self): def _default_url(self):
@@ -411,41 +394,10 @@ class StubSingleUserSpawner(MockSpawner):
""" """
return "/tree" return "/tree"
_thread = None @default('cmd')
def _cmd_default(self):
return [sys.executable, '-m', 'jupyterhub.singleuser']
async def start(self): def start(self):
ip = self.ip = '127.0.0.1' self.environment["JUPYTERHUB_SINGLEUSER_TEST_EXTENSION"] = "1"
port = self.port = random_port() return super().start()
env = self.get_env()
args = self.get_args()
evt = threading.Event()
print(args, env)
def _run():
with mock.patch.dict(os.environ, env):
app = self._app = MockSingleUserServer()
app.initialize(args)
app.io_loop.add_callback(lambda: evt.set())
assert app.hub_auth.oauth_client_id
assert app.hub_auth.api_token
assert app.hub_auth.oauth_scopes
app.start()
self._thread = threading.Thread(target=_run)
self._thread.start()
ready = evt.wait(timeout=3)
assert ready
return (ip, port)
async def stop(self):
self._app.stop()
self._thread.join(timeout=30)
assert not self._thread.is_alive()
async def poll(self):
if self._thread is None:
return 0
if self._thread.is_alive():
return None
else:
return 0

View File

@@ -13,7 +13,7 @@ import jupyterhub
from .. import orm from .. import orm
from ..utils import url_path_join from ..utils import url_path_join
from .mocking import StubSingleUserSpawner, public_url from .mocking import public_url
from .utils import AsyncSession, async_requests, get_page from .utils import AsyncSession, async_requests, get_page
@@ -43,11 +43,8 @@ async def test_singleuser_auth(
access_scopes, access_scopes,
server_name, server_name,
expect_success, expect_success,
full_spawn,
): ):
# use StubSingleUserSpawner to launch a single-user app in a thread
app.spawner_class = StubSingleUserSpawner
app.tornado_settings['spawner_class'] = StubSingleUserSpawner
# login, start the server # login, start the server
cookies = await app.login_user('nandy') cookies = await app.login_user('nandy')
user = app.users['nandy'] user = app.users['nandy']
@@ -132,12 +129,10 @@ async def test_singleuser_auth(
r = await s.get(r.url, allow_redirects=False) r = await s.get(r.url, allow_redirects=False)
assert r.status_code == 403 assert r.status_code == 403
assert 'burgess' in r.text assert 'burgess' in r.text
await user.stop(server_name)
async def test_disable_user_config(app): async def test_disable_user_config(request, app, tmpdir, full_spawn):
# use StubSingleUserSpawner to launch a single-user app in a thread
app.spawner_class = StubSingleUserSpawner
app.tornado_settings['spawner_class'] = StubSingleUserSpawner
# login, start the server # login, start the server
cookies = await app.login_user('nandy') cookies = await app.login_user('nandy')
user = app.users['nandy'] user = app.users['nandy']
@@ -148,6 +143,16 @@ async def test_disable_user_config(app):
# start with new config: # start with new config:
user.spawner.debug = True user.spawner.debug = True
user.spawner.disable_user_config = True user.spawner.disable_user_config = True
home_dir = tmpdir.join("home")
home_dir.mkdir()
# home_dir is defined on SimpleSpawner
user.spawner.home_dir = home = str(home_dir)
jupyter_config_dir = home_dir.join(".jupyter")
jupyter_config_dir.mkdir()
# verify config paths
with jupyter_config_dir.join("jupyter_server_config.py").open("w") as f:
f.write("c.TestSingleUser.jupyter_config_py = True")
await user.spawn() await user.spawn()
await app.proxy.add_user(user) await app.proxy.add_user(user)
@@ -161,6 +166,27 @@ async def test_disable_user_config(app):
) )
assert r.status_code == 200 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()
import pprint
pprint.pprint(info)
assert info['disable_user_config']
server_config = info['config']
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']:
path = os.path.realpath(os.path.abspath(path))
assert not path.startswith(norm_home + os.path.sep)
# TODO: check legacy notebook config
# nbextensions_path
# static_custom_path
def test_help_output(): def test_help_output():
out = check_output( out = check_output(
@@ -199,7 +225,10 @@ def test_singleuser_app_class(JUPYTERHUB_SINGLEUSER_APP):
have_notebook = True have_notebook = True
if JUPYTERHUB_SINGLEUSER_APP.startswith("notebook."): if JUPYTERHUB_SINGLEUSER_APP.startswith("notebook."):
expect_error = not have_notebook expect_error = (
os.environ.get("JUPYTERHUB_SINGLEUSER_EXTENSION") == "1"
or not have_notebook
)
elif JUPYTERHUB_SINGLEUSER_APP.startswith("jupyter_server."): elif JUPYTERHUB_SINGLEUSER_APP.startswith("jupyter_server."):
expect_error = not have_server expect_error = not have_server
else: else:
@@ -232,11 +261,7 @@ def test_singleuser_app_class(JUPYTERHUB_SINGLEUSER_APP):
assert '--NotebookApp.' not in out assert '--NotebookApp.' not in out
async def test_nbclassic_control_panel(app, user): async def test_nbclassic_control_panel(app, user, full_spawn):
# use StubSingleUserSpawner to launch a single-user app in a thread
app.spawner_class = StubSingleUserSpawner
app.tornado_settings['spawner_class'] = StubSingleUserSpawner
# login, start the server # login, start the server
await user.spawn() await user.spawn()
cookies = await app.login_user(user.name) cookies = await app.login_user(user.name)

View File

@@ -55,6 +55,7 @@ def get_package_data():
'alembic/*', 'alembic/*',
'alembic/versions/*', 'alembic/versions/*',
'event-schemas/*/*.yaml', 'event-schemas/*/*.yaml',
'jupyterhub/singleuser/templates/*.html',
] ]
return package_data return package_data