Skip to content

Commit

Permalink
jupyterhub-singleuser as a Jupyter Server 2.0 extension
Browse files Browse the repository at this point in the history
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
  • Loading branch information
minrk committed Feb 2, 2023
1 parent 63f164c commit f3afac7
Show file tree
Hide file tree
Showing 16 changed files with 1,068 additions and 142 deletions.
9 changes: 7 additions & 2 deletions .github/workflows/test.yml
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions examples/read-only/README.md
@@ -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.
36 changes: 36 additions & 0 deletions examples/read-only/jupyter_server_config.py
@@ -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
95 changes: 95 additions & 0 deletions examples/read-only/jupyterhub_config.py
@@ -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'
7 changes: 7 additions & 0 deletions jupyterhub/__init__.py
@@ -1,3 +1,10 @@
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"]
5 changes: 4 additions & 1 deletion jupyterhub/services/auth.py
Expand Up @@ -243,7 +243,6 @@ def _api_url(self):
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
Expand All @@ -253,6 +252,10 @@ def _api_url(self):
""",
).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.
Expand Down
39 changes: 33 additions & 6 deletions jupyterhub/singleuser/__init__.py
Expand Up @@ -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"):
# 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__ = [
"SingleUserNotebookApp",
"main",
"HubAuthenticatedHandler",
"make_singleuser_app",
]

# backward-compatibility
JupyterHubLoginHandler = SingleUserNotebookApp.login_handler_class
JupyterHubLogoutHandler = SingleUserNotebookApp.logout_handler_class
OAuthCallbackHandler = SingleUserNotebookApp.oauth_callback_handler_class
2 changes: 1 addition & 1 deletion jupyterhub/singleuser/__main__.py
@@ -1,4 +1,4 @@
from .app import main
from . import main

if __name__ == '__main__':
main()
14 changes: 12 additions & 2 deletions jupyterhub/singleuser/app.py
Expand Up @@ -14,8 +14,18 @@

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)
Expand Down

0 comments on commit f3afac7

Please sign in to comment.