Skip to content

Commit

Permalink
setup: migrate to jupyter server 2
Browse files Browse the repository at this point in the history
  • Loading branch information
oliver-sanders committed Aug 10, 2023
1 parent 7a6e1b9 commit 20c0149
Show file tree
Hide file tree
Showing 11 changed files with 171 additions and 202 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ jobs:
strategy:
matrix:
os: ['ubuntu-latest']
python: ['3.7', '3.8', '3.9']
python: ['3.8', '3.9']
include:
- os: 'macos-latest'
python: '3.7'
python: '3.8'
steps:
- name: Checkout
uses: actions/checkout@v3
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ jobs:
strategy:
matrix:
os: ['ubuntu-latest']
python: ['3.7', '3.8', '3.9']
python: ['3.8', '3.9']
include:
- os: 'macos-latest'
python: '3.7'
python: '3.8'
env:
PYTEST_ADDOPTS: --cov --color=yes

Expand Down
10 changes: 10 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ creating a new release entry be sure to copy & paste the span tag with the
updated. Only the first match gets replaced, so it's fine to leave the old
ones in. -->

-------------------------------------------------------------------------------
## __cylc-uiserver-1.4.0 (<span actions:bind='release-date'>Awaiting Release</span>)__

### Enhancements

[#450](https://github.com/cylc/cylc-uiserver/pull/450) -
Upgraded to Jupyter Server 2.7+ and Jupyter Hub 4.0+. Note cylc-uiserver
1.3 remains supported and compatible with cylc-flow 8.2 for those not ready
to make the jump just yet.

-------------------------------------------------------------------------------
## __cylc-uiserver-1.3.0 (<span actions:bind='release-date'>Released 2023-07-21</span>)__

Expand Down
3 changes: 2 additions & 1 deletion cylc/uiserver/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ class CylcUIServer(ExtensionApp):
config_file_paths.insert(0, str(Path(uis_pkg).parent)) # packaged config
config_file_paths.reverse()
# TODO: Add a link to the access group table mappings in cylc documentation
# https://github.com/cylc/cylc-uiserver/issues/466
AUTH_DESCRIPTION = '''
Authorization can be granted at operation (mutation) level, i.e.
specifically grant user access to execute Cylc commands, e.g.
Expand All @@ -172,7 +173,7 @@ class CylcUIServer(ExtensionApp):
applied to all workflows.
For more information, including the access group mappings, see
:ref:`Authorization`.
:ref:`cylc.uiserver.multi-user`.
'''

site_authorization = Dict(
Expand Down
69 changes: 68 additions & 1 deletion cylc/uiserver/authorise.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,85 @@

from contextlib import suppress
from functools import lru_cache
import graphene
from getpass import getuser
import grp
from typing import List, Dict, Optional, Union, Any, Sequence, Set, Tuple
from inspect import iscoroutinefunction
import os
import re

import graphene
from jupyter_server.auth import Authorizer
from tornado import web
from traitlets.config.loader import LazyConfigValue

from cylc.uiserver.schema import UISMutations


class CylcAuthorizer(Authorizer):
"""Defines a safe default authorization policy for Jupyter Server.
`Jupyter Server`_ provides an authorisation layer which gives full
permissions to any user who has been granted permission to the Jupyter Hub
``access:servers`` scope
(see :ref:`JupyterHub scopes reference <jupyterhub-scopes>`). This allows
the execution of arbitrary code under another user account.
To prevent this you must define an authorisation policy using
:py:attr:`c.ServerApp.authorizer_class
<jupyter_server.serverapp.ServerApp.authorizer_class>`.
This class defines a policy which blocks all API calls to another user's
server, apart from calls to Cylc interfaces explicitly defined in the
:ref:`Cylc authorisation configuration <cylc.uiserver.user_authorization>`.
This class is configured as the default authoriser for all Jupyter Server
instances spawned via the ``cylc hubapp`` command. This is the default if
you started `Jupyter Hub`_ using the ``cylc hub`` command. To see where
this default is set, see this file for the appropriate release of
cylc-uiserver:
https://github.com/cylc/cylc-uiserver/blob/master/cylc/uiserver/jupyter_config.py
If you are launching Jupyter Hub via another command (e.g. ``jupyterhub``)
or are overriding :py:attr:`jupyterhub.app.JupyterHub.spawner_class`, then
you will need to configure a safe authorisation policy e.g:
.. code-block:: python
from cylc.uiserver.authorise import CylcAuthorizer
c.ServerApp.authorizer_class = CylcAuthorizer
.. note::
It is possible to provide read-only access to Jupyter Server extensions
such as Jupyter Lab, however, this isn't advisable as Jupyter Lab does
not apply file-system permissions to what another user is allowed to
see.
If you wish to grant users access to other user's Jupyter Lab servers,
override this configuration with due care over what you choose to
expose.
"""

def is_authorized(self, handler, user, action, resource) -> bool:
"""Allow a user to access their own server.
Note that Cylc uses its own authorization system (which is locked-down
by default) and is not affected by this policy.
"""
# the username of the user running this server
# (used for authorzation purposes)
me = getuser()

if user.username == me:
# give the user full permissions to their own server
return True

# block access to everyone else
return False


def constant(func):
"""Decorator preventing reassignment"""

Expand Down
135 changes: 53 additions & 82 deletions cylc/uiserver/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,17 @@
import json
import getpass
import os
import socket
from typing import TYPE_CHECKING, Callable, Union, Dict
from typing import TYPE_CHECKING, Callable, Dict

from graphene_tornado.tornado_graphql_handler import TornadoGraphQLHandler
from graphql import get_default_backend
from graphql_ws.constants import GRAPHQL_WS
from jupyter_server.base.handlers import JupyterHandler
from jupyter_server.auth.identity import (
User as JPSUser,
IdentityProvider as JPSIdentityProvider,
PasswordIdentityProvider,
)
from tornado import web, websocket
from tornado.ioloop import IOLoop

Expand Down Expand Up @@ -61,60 +65,42 @@ def _inner(
**kwargs,
):
nonlocal fun
user: Union[
None, # unauthenticated
dict, # hub auth
str, # token auth or anonymous

] = handler.get_current_user()
if user is None or user == 'anonymous':
# user is not authenticated - calls should not get this far
# but the extra safety doesn't hurt
# NOTE: Auth tests will hit this line unless mocked authentication
# is provided.
raise web.HTTPError(403, reason='Forbidden')
if not (
isinstance(user, str) # token authenticated
or (
isinstance(user, dict)
and _authorise(handler, user['name'])
)
):
user: JPSUser = handler.current_user

if not user or not user.username:
# the user is only truthy if they have authenticated successfully
raise web.HTTPError(403, reason='authorization insufficient')

if not handler.identity_provider.auth_enabled:
# if authentication is turned off we don't want to work with this
raise web.HTTPError(403, reason='authorization insufficient')

if is_token_authenticated(handler):
# token or password authenticated, the bearer of the token or
# password has full control
pass

elif not _authorise(handler, user.username):
# other authentication (e.g. JupyterHub auth), check the user has
# read permissions for Cylc
raise web.HTTPError(403, reason='authorization insufficient')

return fun(handler, *args, **kwargs)
return _inner


def is_token_authenticated(
handler: JupyterHandler,
user: Union[bytes, dict, str],
) -> bool:
"""Returns True if the UIS is running "standalone".
def is_token_authenticated(handler: 'CylcAppHandler') -> bool:
"""Returns True if this request is bearer token authenticated.
At present we cannot use handler.is_token_authenticated because it
returns False when the token is cached in a cookie.
E.G. The default single-user token-based authenticated.
https://github.com/jupyter-server/jupyter_server/pull/562
In these cases the bearer of the token is awarded full privileges.
"""
if isinstance(user, bytes): # noqa: SIM114
# Cookie authentication:
# * The URL token is added to a secure cookie, it can then be
# removed from the URL for subsequent requests, the cookie is
# used in its place.
# * If the token was used token_authenticated is True.
# * If the cookie was used it is False (despite the cookie auth
# being derived from token auth).
# * Due to a bug in jupyter_server the user is returned as bytes
# when cookie auth is used so at present we can use this to
# tell.
# https://github.com/jupyter-server/jupyter_server/pull/562
# TODO: this hack is obviously not suitable for production!
return True
elif handler.token_authenticated:
# standalone UIS, the bearer of the token is the owner
# (no multi-user functionality so no further auth required)
return True
return False
identity_provider: JPSIdentityProvider = (
handler.serverapp.identity_provider
)
return identity_provider.__class__ == PasswordIdentityProvider
# NOTE: not using isinstance to narrow this down to just the one class


def _authorise(
Expand All @@ -136,28 +122,24 @@ def _authorise(
return False


def parse_current_user(current_user):
"""Standardises and returns the current user."""
if isinstance(current_user, dict):
# the server is running with authentication services provided
# by a hub
current_user = dict(current_user) # make a copy for safety
return current_user
def get_username(handler: 'CylcAppHandler'):
"""Return the username for the authenticated user.
If the handler is token authenticated, then we return the username of the
account that this server instance is running under.
"""
if is_token_authenticated(handler):
# the bearer of the token has full privileges
return ME
else:
# the server is running using a token
# authentication is provided by jupyter server
return {
'kind': 'user',
'name': ME,
'server': socket.gethostname()
}
return handler.current_user.username


class CylcAppHandler(JupyterHandler):
"""Base handler for Cylc endpoints.
This handler adds the Cylc authorisation layer which is triggered by
calling CylcAppHandler.get_current_user which is called by
accessing CylcAppHandler.current_user which is called by
web.authenticated.
When running as a Hub application the make_singleuser_app method patches
Expand All @@ -171,11 +153,7 @@ class CylcAppHandler(JupyterHandler):

def initialize(self, auth):
self.auth = auth

# Without this, there is no xsrf token from the GET which causes a 403 and
# a missing _xsrf argument error on the first POST.
def prepare(self):
_ = self.xsrf_token
super().initialize()

@property
def hub_users(self):
Expand Down Expand Up @@ -266,11 +244,11 @@ def set_default_headers(self) -> None:
self.set_header("Content-Type", 'application/json')

@web.authenticated
@authorised
# @authorised TODO: I can't think why we would want to authorise this
def get(self):
user_info = self.get_current_user()

user_info = parse_current_user(user_info)
user_info = {
'name': get_username(self)
}

# add an entry for the workflow owner
# NOTE: when running behind a hub this may be different from the
Expand All @@ -283,6 +261,7 @@ def get(self):
self.auth.get_permitted_operations(user_info['name']))
]
# Pass the gui mode to the ui
# (used for functionality not security)
if not os.environ.get("JUPYTERHUB_SINGLEUSER_APP"):
user_info['mode'] = 'single user'
else:
Expand Down Expand Up @@ -337,15 +316,9 @@ def context(self):
'graphql_params': self.graphql_params,
'request': self.request,
'resolvers': self.resolvers,
'current_user': parse_current_user(
self.get_current_user()
).get('name'),
'current_user': get_username(self),
}

@web.authenticated
def prepare(self):
super().prepare()

@web.authenticated # type: ignore[arg-type]
async def execute(self, *args, **kwargs) -> 'ExecutionResult':
# Use own backend, and TornadoGraphQLHandler already does validation.
Expand Down Expand Up @@ -411,9 +384,7 @@ def context(self):
return {
'request': self.request,
'resolvers': self.resolvers,
'current_user': parse_current_user(
self.get_current_user()
).get('name'),
'current_user': get_username(self),
'ops_queue': {},
'sub_statuses': self.sub_statuses
}
9 changes: 8 additions & 1 deletion cylc/uiserver/jupyter_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@

# Configuration file for jupyterhub.

import os
from pathlib import Path
import pkg_resources

from cylc.uiserver import (
__file__ as uis_pkg,
getenv)
from cylc.uiserver.app import USER_CONF_ROOT
from cylc.uiserver.authorise import CylcAuthorizer


# the command the hub should spawn (i.e. the cylc uiserver itself)
Expand Down Expand Up @@ -94,3 +94,10 @@
}
},
}


# Define the authorization-policy for Jupyter Server.
# This prevents users being granted full access to extensions such as Jupyter
# Lab as a result of being granted the ``access:servers`` permission in Jupyter
# Hub.
c.ServerApp.authorizer_class = CylcAuthorizer
Loading

0 comments on commit 20c0149

Please sign in to comment.