diff --git a/examples/authorization/README.md b/examples/authorization/README.md new file mode 100644 index 0000000000..d63fade0b9 --- /dev/null +++ b/examples/authorization/README.md @@ -0,0 +1,86 @@ +# Authorization in a simple Jupyter Notebook Server + +This folder contains the following examples: +1. a "read-only" Jupyter Notebook Server +2. a read/write Server without the ability to execute code on kernels. +3. a "temporary notebook server", i.e. read and execute notebooks but cannot save/write notebooks. + +## How does it work? + +To add a custom authorization system to the Jupyter Server, you simply override (i.e. patch) the `user_is_authorized()` method in the `JupyterHandler`. + +In the examples here, we do this by patching this method in our Jupyter configuration files. It looks something like this: + +```python +from jupyter_server.base import JupyterHandler + + +# Define my own method here for handling authorization. +# The argument signature must have `self`, `user`, `action`, and `resource`. + +def my_authorization_method(self, user, action, resource): + """My override for handling authorization in Jupyter services.""" + + # Add logic here to check if user is allowed. + # For example, here is an example of a read-only server + if action in ['write', 'execute']: + return False + + return True + +# Patch the user_is_authorized method with my own method. +JupyterHandler.user_is_authorized = my_authorization_method +``` + +In the `jupyter_nbclassic_readonly_config.py` + + +## Try it out! + +### Read-only example + +1. Clone and install nbclassic using `pip`. + + git clone https://github.com/Zsailer/nbclassic + cd nbclassic + pip install . + +2. Navigate to the jupyter_authorized_server `examples/` folder. + +3. Launch nbclassic and load `jupyter_nbclassic_readonly_config.py`: + + jupyter nbclassic --config=jupyter_nbclassic_readonly_config.py + +4. Try creating a notebook, running a notebook in a cell, etc. You should see a `401: Unauthorized` error. + +### Read+Write example + +1. Clone and install nbclassic using `pip`. + + git clone https://github.com/Zsailer/nbclassic + cd nbclassic + pip install . + +2. Navigate to the jupyter_authorized_server `examples/` folder. + +3. Launch nbclassic and load `jupyter_nbclassic_rw_config.py`: + + jupyter nbclassic --config=jupyter_nbclassic_rw_config.py + +4. Try running a cell in a notebook. You should see a `401: Unauthorized` error. + +### Temporary notebook server example + +1. Clone and install nbclassic using `pip`. + + git clone https://github.com/Zsailer/nbclassic + cd nbclassic + pip install . + +2. Navigate to the jupyter_authorized_server `examples/` folder. + +3. Launch nbclassic and load `jupyter_temporary_config.py`: + + jupyter nbclassic --config=jupyter_temporary_config.py + +4. Edit a notebook, run a cell, etc. Everything works fine. Then try to save your changes... you should see a `401: Unauthorized` error. \ No newline at end of file diff --git a/examples/authorization/jupyter_nbclassic_readonly_config.py b/examples/authorization/jupyter_nbclassic_readonly_config.py new file mode 100644 index 0000000000..dacdaec695 --- /dev/null +++ b/examples/authorization/jupyter_nbclassic_readonly_config.py @@ -0,0 +1,9 @@ +from jupyter_server.base.handlers import JupyterHandler + +def user_is_authorized(self, user, action, resource): + """Only allows `read` operations.""" + if action in ['write', 'execute']: + return False + return True + +JupyterHandler.user_is_authorized = user_is_authorized \ No newline at end of file diff --git a/examples/authorization/jupyter_nbclassic_rw_config.py b/examples/authorization/jupyter_nbclassic_rw_config.py new file mode 100644 index 0000000000..0de12670fb --- /dev/null +++ b/examples/authorization/jupyter_nbclassic_rw_config.py @@ -0,0 +1,9 @@ +from jupyter_server.base.handlers import JupyterHandler + +def user_is_authorized(self, user, action, resource=None): + """Only allows `read` operations.""" + if action == 'execute': + return False + return True + +JupyterHandler.user_is_authorized = user_is_authorized \ No newline at end of file diff --git a/examples/authorization/jupyter_temporary_config.py b/examples/authorization/jupyter_temporary_config.py new file mode 100644 index 0000000000..0ff5735f77 --- /dev/null +++ b/examples/authorization/jupyter_temporary_config.py @@ -0,0 +1,9 @@ +from jupyter_server.base.handlers import JupyterHandler + +def user_is_authorized(self, user, action, resource=None): + """Only allows `read` operations.""" + if action == 'write' and resource == 'contents': + return False + return True + +JupyterHandler.user_is_authorized = user_is_authorized \ No newline at end of file diff --git a/jupyter_server/base/handlers.py b/jupyter_server/base/handlers.py index c230753246..4bab6ac669 100755 --- a/jupyter_server/base/handlers.py +++ b/jupyter_server/base/handlers.py @@ -185,7 +185,16 @@ def login_available(self): return bool(self.login_handler.get_login_available(self.settings)) -class JupyterHandler(AuthenticatedHandler): +class AuthorizedHandlerMixin: + """A mixin class for Tornado request handlers that checks whether + the current user is authorized to execute the current action. + """ + def user_is_authorized(self, user, action, resource): + """Check is `user` is authorized to do `action` on given `resource`.""" + return True + + +class JupyterHandler(AuthenticatedHandler, AuthorizedHandlerMixin): """Jupyter-specific extensions to authenticated handling Mostly property shortcuts to Jupyter-specific settings. diff --git a/jupyter_server/base/zmqhandlers.py b/jupyter_server/base/zmqhandlers.py index c62d0b9077..5fe648e9f0 100644 --- a/jupyter_server/base/zmqhandlers.py +++ b/jupyter_server/base/zmqhandlers.py @@ -261,10 +261,15 @@ def pre_get(self): the websocket finishes completing. """ # authenticate the request before opening the websocket - if self.get_current_user() is None: + user = self.get_current_user() + if user is None: self.log.warning("Couldn't authenticate WebSocket connection") raise web.HTTPError(403) + # authorize the user. + if not self.user_is_authorized(user, 'execute', 'channels'): + raise web.HTTPError(401) + if self.get_argument('session_id', False): self.session.session = cast_unicode(self.get_argument('session_id')) else: diff --git a/jupyter_server/files/handlers.py b/jupyter_server/files/handlers.py index e73c445c65..5d20df3940 100644 --- a/jupyter_server/files/handlers.py +++ b/jupyter_server/files/handlers.py @@ -8,7 +8,8 @@ from base64 import decodebytes from tornado import web from jupyter_server.base.handlers import JupyterHandler -from jupyter_server.utils import ensure_async +from jupyter_server.utils import ensure_async, authorized + class FilesHandler(JupyterHandler): """serve files via ContentsManager @@ -27,10 +28,12 @@ def content_security_policy(self): "; sandbox allow-scripts" @web.authenticated + @authorized('read') def head(self, path): self.get(path, include_body=False) @web.authenticated + @authorized('read') async def get(self, path, include_body=True): cm = self.contents_manager diff --git a/jupyter_server/kernelspecs/handlers.py b/jupyter_server/kernelspecs/handlers.py index f53cdcd840..a5f5d7cf05 100644 --- a/jupyter_server/kernelspecs/handlers.py +++ b/jupyter_server/kernelspecs/handlers.py @@ -1,6 +1,7 @@ from tornado import web from ..base.handlers import JupyterHandler from ..services.kernelspecs.handlers import kernel_name_regex +from jupyter_server.utils import authorized class KernelSpecResourceHandler(web.StaticFileHandler, JupyterHandler): @@ -10,6 +11,7 @@ def initialize(self): web.StaticFileHandler.initialize(self, path='') @web.authenticated + @authorized("read") def get(self, kernel_name, path, include_body=True): ksm = self.kernel_spec_manager try: @@ -21,6 +23,7 @@ def get(self, kernel_name, path, include_body=True): return web.StaticFileHandler.get(self, path, include_body=include_body) @web.authenticated + @authorized("read") def head(self, kernel_name, path): return self.get(kernel_name, path, include_body=False) diff --git a/jupyter_server/services/config/handlers.py b/jupyter_server/services/config/handlers.py index 76c1bd3e56..6a42b151db 100644 --- a/jupyter_server/services/config/handlers.py +++ b/jupyter_server/services/config/handlers.py @@ -11,20 +11,26 @@ from ipython_genutils.py3compat import PY3 from ...base.handlers import APIHandler +from jupyter_server.utils import authorized + + class ConfigHandler(APIHandler): @web.authenticated + @authorized('read') def get(self, section_name): self.set_header("Content-Type", 'application/json') self.finish(json.dumps(self.config_manager.get(section_name))) @web.authenticated + @authorized('write') def put(self, section_name): data = self.get_json_body() # Will raise 400 if content is not valid JSON self.config_manager.set(section_name, data) self.set_status(204) @web.authenticated + @authorized('write') def patch(self, section_name): new_data = self.get_json_body() section = self.config_manager.update(section_name, new_data) diff --git a/jupyter_server/services/contents/handlers.py b/jupyter_server/services/contents/handlers.py index b7a8b1af1b..6e149971e0 100644 --- a/jupyter_server/services/contents/handlers.py +++ b/jupyter_server/services/contents/handlers.py @@ -17,6 +17,7 @@ JupyterHandler, APIHandler, path_regex, ) +from jupyter_server.utils import authorized def validate_model(model, expect_content): """ @@ -88,6 +89,7 @@ def _finish_model(self, model, location=True): self.finish(json.dumps(model, default=date_default)) @web.authenticated + @authorized('read', resource='contents') async def get(self, path=''): """Return a model for a file or directory. @@ -114,6 +116,7 @@ async def get(self, path=''): self._finish_model(model, location=False) @web.authenticated + @authorized('write', resource='contents') async def patch(self, path=''): """PATCH renames a file or directory without re-uploading content.""" cm = self.contents_manager @@ -162,6 +165,7 @@ async def _save(self, model, path): self._finish_model(model) @web.authenticated + @authorized('write', resource='contents') async def post(self, path=''): """Create a new file in the specified path. @@ -198,6 +202,7 @@ async def post(self, path=''): await self._new_untitled(path) @web.authenticated + @authorized('write', resource='contents') async def put(self, path=''): """Saves the file in the location specified by name and path. @@ -222,6 +227,7 @@ async def put(self, path=''): await self._new_untitled(path) @web.authenticated + @authorized('write', resource='contents') async def delete(self, path=''): """delete a file in the given path""" cm = self.contents_manager @@ -234,6 +240,7 @@ async def delete(self, path=''): class CheckpointsHandler(APIHandler): @web.authenticated + @authorized('read', resource='checkpoints') async def get(self, path=''): """get lists checkpoints for a file""" cm = self.contents_manager @@ -242,6 +249,7 @@ async def get(self, path=''): self.finish(data) @web.authenticated + @authorized('write', resource='checkpoints') async def post(self, path=''): """post creates a new checkpoint""" cm = self.contents_manager @@ -257,6 +265,7 @@ async def post(self, path=''): class ModifyCheckpointsHandler(APIHandler): @web.authenticated + @authorized('write', resource='checkpoints') async def post(self, path, checkpoint_id): """post restores a file from a checkpoint""" cm = self.contents_manager @@ -265,6 +274,7 @@ async def post(self, path, checkpoint_id): self.finish() @web.authenticated + @authorized('write', resource='checkpoints') async def delete(self, path, checkpoint_id): """delete clears a checkpoint for a given file""" cm = self.contents_manager @@ -292,11 +302,13 @@ class TrustNotebooksHandler(JupyterHandler): """ Handles trust/signing of notebooks """ @web.authenticated + @authorized('write', resource='trust_notebook') async def post(self,path=''): cm = self.contents_manager await ensure_async(cm.trust_notebook(path)) self.set_status(201) self.finish() + #----------------------------------------------------------------------------- # URL to handler mappings #----------------------------------------------------------------------------- diff --git a/jupyter_server/services/kernels/handlers.py b/jupyter_server/services/kernels/handlers.py index 77eca92dc9..fcb1ea9cb5 100644 --- a/jupyter_server/services/kernels/handlers.py +++ b/jupyter_server/services/kernels/handlers.py @@ -22,17 +22,19 @@ from ...base.handlers import APIHandler from ...base.zmqhandlers import AuthenticatedZMQStreamHandler, deserialize_binary_message - +from jupyter_server.utils import authorized class MainKernelHandler(APIHandler): @web.authenticated + @authorized('read', resource='kernels') async def get(self): km = self.kernel_manager kernels = await ensure_async(km.list_kernels()) self.finish(json.dumps(kernels, default=date_default)) @web.authenticated + @authorized('write', resource='kernels') async def post(self): km = self.kernel_manager model = self.get_json_body() @@ -55,12 +57,14 @@ async def post(self): class KernelHandler(APIHandler): @web.authenticated + @authorized('read', resource='kernels') async def get(self, kernel_id): km = self.kernel_manager model = await ensure_async(km.kernel_model(kernel_id)) self.finish(json.dumps(model, default=date_default)) @web.authenticated + @authorized('write', resource='kernels') async def delete(self, kernel_id): km = self.kernel_manager await ensure_async(km.shutdown_kernel(kernel_id)) @@ -71,6 +75,7 @@ async def delete(self, kernel_id): class KernelActionHandler(APIHandler): @web.authenticated + @authorized('write', resource='kernels') async def post(self, kernel_id, action): km = self.kernel_manager if action == 'interrupt': diff --git a/jupyter_server/services/kernelspecs/handlers.py b/jupyter_server/services/kernelspecs/handlers.py index 310caec99f..19afda888c 100644 --- a/jupyter_server/services/kernelspecs/handlers.py +++ b/jupyter_server/services/kernelspecs/handlers.py @@ -16,7 +16,7 @@ from ...base.handlers import APIHandler from ...utils import ensure_async, url_path_join, url_unescape - +from jupyter_server.utils import authorized def kernelspec_model(handler, name, spec_dict, resource_dir): """Load a KernelSpec by name and return the REST API model""" @@ -56,6 +56,7 @@ def is_kernelspec_model(spec_dict): class MainKernelSpecHandler(APIHandler): @web.authenticated + @authorized('read', resource='kernelspecs') async def get(self): ksm = self.kernel_spec_manager km = self.kernel_manager @@ -80,6 +81,7 @@ async def get(self): class KernelSpecHandler(APIHandler): @web.authenticated + @authorized('read', resource='kernelspecs') async def get(self, kernel_name): ksm = self.kernel_spec_manager kernel_name = url_unescape(kernel_name) diff --git a/jupyter_server/services/sessions/handlers.py b/jupyter_server/services/sessions/handlers.py index 6eb8bb4d31..dda6ea9892 100644 --- a/jupyter_server/services/sessions/handlers.py +++ b/jupyter_server/services/sessions/handlers.py @@ -15,10 +15,13 @@ from jupyter_server.utils import url_path_join, ensure_async from jupyter_client.kernelspec import NoSuchKernel +from jupyter_server.utils import authorized + class SessionRootHandler(APIHandler): @web.authenticated + @authorized('read', resource='sessions') async def get(self): # Return a list of running sessions sm = self.session_manager @@ -26,6 +29,7 @@ async def get(self): self.finish(json.dumps(sessions, default=date_default)) @web.authenticated + @authorized('write', resource='sessions') async def post(self): # Creates a new session #(unless a session already exists for the named session) @@ -86,6 +90,7 @@ async def post(self): class SessionHandler(APIHandler): @web.authenticated + @authorized('read', resource='sessions') async def get(self, session_id): # Returns the JSON model for a single session sm = self.session_manager @@ -93,6 +98,7 @@ async def get(self, session_id): self.finish(json.dumps(model, default=date_default)) @web.authenticated + @authorized('write', resource='sessions') async def patch(self, session_id): """Patch updates sessions: @@ -143,6 +149,7 @@ async def patch(self, session_id): self.finish(json.dumps(model, default=date_default)) @web.authenticated + @authorized('write', resource='sessions') async def delete(self, session_id): # Deletes the session with given session_id sm = self.session_manager diff --git a/jupyter_server/utils.py b/jupyter_server/utils.py index 04b1175e9b..41a49c6202 100644 --- a/jupyter_server/utils.py +++ b/jupyter_server/utils.py @@ -8,11 +8,15 @@ import inspect import os import sys +import functools + from distutils.version import LooseVersion from urllib.parse import quote, unquote, urlparse, urljoin from urllib.request import pathname2url +from tornado.web import HTTPError + from ipython_genutils import py3compat @@ -184,6 +188,7 @@ async def ensure_async(obj): return obj +<<<<<<< HEAD def run_sync(maybe_async): """If async, runs maybe_async and blocks until it has executed, possibly creating an event loop. @@ -226,3 +231,49 @@ def wrapped(): raise e return result return wrapped() +======= + +def authorized(action, resource=None, message=None): + """A decorator for tornado.web.RequestHandler methods + that verifies whether the current user is authorized + to make the following request. + + Helpful for adding an 'authorization' layer to + a REST API. + + Parameters + ---------- + action : str + the type of permission or action to check. + + resource: str or None + the name of the resource the action is being authorized + to access. + + message : str or none + a message for the unauthorized action. + """ + # Get message + if message is None: + "User is not authorized to make this request." + + error = HTTPError( + status_code=401, + log_message=message + ) + + def wrapper(method): + + def inner(self, *args, **kwargs): + user = self.current_user + # If the user is allowed to do this action, + # call the method. + if self.user_is_authorized(user, action, resource): + return method(self, *args, **kwargs) + # else raise an exception. + else: + raise error + return inner + + return wrapper +>>>>>>> add authorization layer to request handlers