Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

singleuser auth as server extension #3888

Merged
merged 9 commits into from Feb 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
1 change: 1 addition & 0 deletions .prettierignore
@@ -1,2 +1,3 @@
share/jupyterhub/templates/
share/jupyterhub/static/js/admin-react.js
jupyterhub/singleuser/templates/
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'
15 changes: 15 additions & 0 deletions jupyterhub/__init__.py
@@ -1,3 +1,18 @@
from ._version import __version__, version_info


def _jupyter_server_extension_points():
minrk marked this conversation as resolved.
Show resolved Hide resolved
"""
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"]
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"):
_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
consideRatio marked this conversation as resolved.
Show resolved Hide resolved


__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()