Skip to content

Commit

Permalink
add authorization layer to request handlers
Browse files Browse the repository at this point in the history
  • Loading branch information
Zsailer authored and davidbrochart committed Apr 8, 2021
1 parent 723786e commit 5596fd3
Show file tree
Hide file tree
Showing 14 changed files with 221 additions and 5 deletions.
86 changes: 86 additions & 0 deletions examples/authorization/README.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions examples/authorization/jupyter_nbclassic_readonly_config.py
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions examples/authorization/jupyter_nbclassic_rw_config.py
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions examples/authorization/jupyter_temporary_config.py
Original file line number Diff line number Diff line change
@@ -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
11 changes: 10 additions & 1 deletion jupyter_server/base/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion jupyter_server/base/zmqhandlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion jupyter_server/files/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
3 changes: 3 additions & 0 deletions jupyter_server/kernelspecs/handlers.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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:
Expand All @@ -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)

Expand Down
6 changes: 6 additions & 0 deletions jupyter_server/services/config/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions jupyter_server/services/contents/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
JupyterHandler, APIHandler, path_regex,
)

from jupyter_server.utils import authorized

def validate_model(model, expect_content):
"""
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
#-----------------------------------------------------------------------------
Expand Down
7 changes: 6 additions & 1 deletion jupyter_server/services/kernels/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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))
Expand All @@ -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':
Expand Down
4 changes: 3 additions & 1 deletion jupyter_server/services/kernelspecs/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
Loading

0 comments on commit 5596fd3

Please sign in to comment.