Merge pull request #3888 from minrk/server-extension

singleuser auth as server extension
This commit is contained in:
Erik Sundell
2023-02-15 20:46:33 +01:00
committed by GitHub
19 changed files with 1226 additions and 152 deletions

View File

@@ -92,6 +92,8 @@ jobs:
selenium: selenium
- python: "3.11"
main_dependencies: main_dependencies
- python: "3.10"
serverextension: serverextension
steps:
# NOTE: In GitHub workflows, environment variables are set by writing
@@ -115,8 +117,8 @@ jobs:
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
fi
if [ "${{ matrix.jupyter_server }}" != "" ]; then
echo "JUPYTERHUB_SINGLEUSER_APP=jupyterhub.tests.mockserverapp.MockServerApp" >> $GITHUB_ENV
if [ "${{ matrix.serverextension }}" != "" ]; then
echo "JUPYTERHUB_SINGLEUSER_EXTENSION=1" >> $GITHUB_ENV
fi
- uses: actions/checkout@v3
# NOTE: actions/setup-node@v3 make use of a cache within the GitHub base
@@ -165,6 +167,9 @@ jobs:
if [ "${{ matrix.db }}" == "postgres" ]; then
pip install psycopg2-binary
fi
if [ "${{ matrix.serverextension }}" != "" ]; then
pip install 'jupyter-server>=2'
fi
pip freeze

View File

@@ -1,2 +1,3 @@
share/jupyterhub/templates/
share/jupyterhub/static/js/admin-react.js
jupyterhub/singleuser/templates/

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,18 @@
from ._version import __version__, version_info
def _jupyter_server_extension_points():
"""
Makes the jupyter_server singleuser extension discoverable.
Returns a list of dictionaries with metadata describing
where to find the `_load_jupyter_server_extension` function.
ref: https://jupyter-server.readthedocs.io/en/latest/developers/extensions.html
"""
from .singleuser.extension import JupyterHubSingleUser
return [{"module": "jupyterhub", "app": JupyterHubSingleUser}]
__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')
api_token = Unicode(
os.getenv('JUPYTERHUB_API_TOKEN', ''),
help="""API key for accessing Hub API.
Default: $JUPYTERHUB_API_TOKEN
@@ -253,6 +252,10 @@ class HubAuth(SingletonConfigurable):
""",
).tag(config=True)
@default("api_token")
def _default_api_token(self):
return os.getenv('JUPYTERHUB_API_TOKEN', '')
hub_prefix = Unicode(
'/hub/',
help="""The URL prefix for the Hub itself.

View File

@@ -2,17 +2,44 @@
Contains default notebook-app subclass and mixins
"""
from .app import SingleUserNotebookApp, main
import os
from .mixins import HubAuthenticatedHandler, make_singleuser_app
if os.environ.get("JUPYTERHUB_SINGLEUSER_EXTENSION", "") not in ("", "0"):
_as_extension = True
# 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={ext} with JUPYTERHUB_SINGLEUSER_APP={app}."
" Please pick one or the other."
)
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__ = [
"SingleUserNotebookApp",
"main",
"HubAuthenticatedHandler",
"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__':
main()

View File

@@ -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

View File

@@ -14,8 +14,18 @@ from traitlets import import_item
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:
App = import_item(JUPYTERHUB_SINGLEUSER_APP)

View File

@@ -0,0 +1,647 @@
"""
Integrate JupyterHub auth with Jupyter Server as a Server Extension
Instead of earlier versions, implemented via subclassing jupyter-notebook's NotebookApp.
This code runs only in each user's Jupyter Server process.
Jupyter Server 2 provides two new APIs:
- IdentityProvider, which authenticates the user making the request
- Authorizer, which determines whether an authenticated user is authorized to take a particular action
This Extension implements both for resolving permissions with JupyterHub scopes.
By default, in JupyterHub we only _authenticate_ users with sufficient `access:servers` permissions,
therefore the JupyterHub Authorizer allows any authenticated user to take any action,
but custom deployments may refine these permission to enable e.g. read-only access.
- Jupyter Server extension documentation: https://jupyter-server.readthedocs.io/en/latest/developers/extensions.html
- Jupyter Server authentication API documentation: https://jupyter-server.readthedocs.io/en/latest/operators/security.html
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_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.log import log_request
from jupyterhub.services.auth import HubOAuth, HubOAuthCallbackHandler
from jupyterhub.utils import (
exponential_backoff,
isoformat,
make_ssl_context,
url_path_join,
)
from ._disable_user_config import _disable_user_config
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 token stored in single-user cookie (set by hub_auth)
hub_auth.clear_cookie(self)
# redirect to hub to begin logging out of JupyterHub itself
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.
# this is technically the Authorizer's job (below),
# but the IdentityProvider is the only protection on handlers
# decorated only with tornado's `@web.authenticated`,
# that haven't adopted the Jupyter Server 2 authorization decorators.
# so we check access scopes here, to be safe.
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.
Currently only checks the `access:servers` scope(s),
which ought to be redundant with checks already in `JupyterHubIdentityProvider` for safety.
"""
@property
def hub_auth(self):
return self.identity_provider.hub_auth
def is_authorized(self, handler, user, action, resource):
"""
Return whether the authenticated user has permission to perform `action` on `resource`.
Currently: action and resource are ignored,
and only the `access:servers` scope is checked.
This method can be overridden (in combination with custom scopes) to implement granular permissions,
such as read-only access or access to subsets of the server.
"""
# This check for access scopes is redundant
# with the IdentityProvider above,
# but better to be redundant than allow unauthorized actions.
# If we remove a redundant check,
# it should be the one in the identity provider,
# not this one.
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
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_auth.api_url)
except Exception:
self.log.exception(
"Failed to connect to my Hub at %s (attempt %i/%i). Is it running?",
self.hub_auth.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.serverapp.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
app.web_app.settings["log_function"] = log_request
# add jupyterhub version header
headers = app.web_app.settings.setdefault("headers", {})
headers["X-JupyterHub-Version"] = __version__
# check jupyterhub version
app.io_loop.run_sync(self.check_hub_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")
@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
_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
from datetime import timezone
from importlib import import_module
from pathlib import Path
from textwrap import dedent
from urllib.parse import urlparse
@@ -44,6 +45,7 @@ from .._version import __version__, _check_version
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):
@@ -168,70 +170,6 @@ 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):
"""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."""
@@ -624,7 +562,34 @@ class SingleUserNotebookAppMixin(Configurable):
f"Extending {cls.__module__}.{cls.__name__} from {module_name} {mod_version}"
)
# load test extension, if we're testing
def init_server_extension_config(self):
"""
Overloads a method in classic notebook server's NotebookApp class
(notebook < 7) to conditionally enable a jupyterhub test extension.
ref: https://github.com/jupyter/notebook/blob/v6.5.2/notebook/notebookapp.py#L1982
"""
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):
"""
Overloads a method in jupyter_server's ServerApp class (lab or notebook
>=7) to conditionally enable a jupyterhub test extension.
ref: https://github.com/jupyter-server/jupyter_server/blob/v2.2.1/jupyter_server/serverapp.py#L2238
"""
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):
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
@@ -785,6 +750,10 @@ class SingleUserNotebookAppMixin(Configurable):
jinja_envs.append(settings[env_name])
# 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):
if name == 'page.html':
return page_template
@@ -934,12 +903,21 @@ def make_singleuser_app(App):
empty_parent_app = App()
log = empty_parent_app.log
# detect base classes
LoginHandler = empty_parent_app.login_handler_class
LogoutHandler = empty_parent_app.logout_handler_class
# detect base handler classes
if not getattr(empty_parent_app, "login_handler_class", None) and hasattr(
empty_parent_app, "identity_provider_class"
):
# Jupyter Server 2 moves the login handler classes to the identity provider
has_handlers = empty_parent_app.identity_provider_class(parent=empty_parent_app)
else:
# prior to Jupyter Server 2, the app itself had handler class config
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)
# create Handler classes from mixins + bases
class JupyterHubLoginHandler(JupyterHubLoginHandlerMixin, LoginHandler):
pass

View File

@@ -0,0 +1,61 @@
{#
this template customizes all pages (classic notebook, not jupyterlab) served by the single-user server
It makes the following modifications:
- add jupyterhub control panel link to the header
- update logo url to jupyterhub
- remove `?redirects` url param that may be added by jupyterhub
#}
{% 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)
except HTTPError:
pass
print(f"Stopping leftover server {spawner._log_name}")
await user.stop(name)
if user.name not in {'admin', 'user'}:
app.users.delete(uid)
@@ -369,6 +370,15 @@ def slow_spawn(app):
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
def never_spawn(app):
"""Fixture enabling NeverSpawner"""

View File

@@ -0,0 +1,55 @@
"""Jupyter server extension for testing JupyterHub
Adds a `<base_url>/jupyterhub-test-info` endpoint handler 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
- MockHub
- MockSingleUserServer
- StubSingleUserSpawner
- InstrumentedSpawner
- public_host
- public_url
@@ -29,7 +29,6 @@ Other components
import asyncio
import os
import sys
import threading
from concurrent.futures import ThreadPoolExecutor
from tempfile import NamedTemporaryFile
from unittest import mock
@@ -42,7 +41,6 @@ from traitlets import Bool, Dict, default
from .. import metrics, orm, roles
from ..app import JupyterHub
from ..auth import PAMAuthenticator
from ..singleuser import SingleUserNotebookApp
from ..spawner import SimpleLocalProcessSpawner
from ..utils import random_port, utcnow
from .utils import async_requests, public_url, ssl_setup
@@ -87,6 +85,11 @@ class MockSpawner(SimpleLocalProcessSpawner):
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:
@@ -377,27 +380,12 @@ class MockHub(JupyterHub):
return r.cookies
# single-user-server mocking:
class MockSingleUserServer(SingleUserNotebookApp):
"""Mock-out problematic parts of single-user server when run in a thread
Currently:
- disable signal handler
class InstrumentedSpawner(MockSpawner):
"""
Spawner that starts a full singleuser server
def init_signal(self):
pass
@default("log_level")
def _default_log_level(self):
return 10
class StubSingleUserSpawner(MockSpawner):
"""Spawner that starts a MockSingleUserServer in a thread."""
instrumented with the JupyterHub test extension.
"""
@default("default_url")
def _default_url(self):
@@ -411,41 +399,10 @@ class StubSingleUserSpawner(MockSpawner):
"""
return "/tree"
_thread = None
@default('cmd')
def _cmd_default(self):
return [sys.executable, '-m', 'jupyterhub.singleuser']
async def start(self):
ip = self.ip = '127.0.0.1'
port = self.port = random_port()
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
def start(self):
self.environment["JUPYTERHUB_SINGLEUSER_TEST_EXTENSION"] = "1"
return super().start()

View File

@@ -13,7 +13,7 @@ import jupyterhub
from .. import orm
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
@@ -43,11 +43,8 @@ async def test_singleuser_auth(
access_scopes,
server_name,
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
cookies = await app.login_user('nandy')
user = app.users['nandy']
@@ -132,12 +129,10 @@ async def test_singleuser_auth(
r = await s.get(r.url, allow_redirects=False)
assert r.status_code == 403
assert 'burgess' in r.text
await user.stop(server_name)
async def test_disable_user_config(app):
# use StubSingleUserSpawner to launch a single-user app in a thread
app.spawner_class = StubSingleUserSpawner
app.tornado_settings['spawner_class'] = StubSingleUserSpawner
async def test_disable_user_config(request, app, tmpdir, full_spawn):
# login, start the server
cookies = await app.login_user('nandy')
user = app.users['nandy']
@@ -148,6 +143,16 @@ async def test_disable_user_config(app):
# start with new config:
user.spawner.debug = 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 app.proxy.add_user(user)
@@ -161,6 +166,37 @@ async def test_disable_user_config(app):
)
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']
settings = info['settings']
assert 'TestSingleUser' not in server_config
# check config paths
norm_home = os.path.realpath(os.path.abspath(home))
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}"
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():
out = check_output(
@@ -199,7 +235,10 @@ def test_singleuser_app_class(JUPYTERHUB_SINGLEUSER_APP):
have_notebook = True
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."):
expect_error = not have_server
else:
@@ -232,11 +271,7 @@ def test_singleuser_app_class(JUPYTERHUB_SINGLEUSER_APP):
assert '--NotebookApp.' not in out
async def test_nbclassic_control_panel(app, user):
# use StubSingleUserSpawner to launch a single-user app in a thread
app.spawner_class = StubSingleUserSpawner
app.tornado_settings['spawner_class'] = StubSingleUserSpawner
async def test_nbclassic_control_panel(app, user, full_spawn):
# login, start the server
await user.spawn()
cookies = await app.login_user(user.name)

View File

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