Skip to content

Commit

Permalink
Add authorization layer to server request handlers (#165)
Browse files Browse the repository at this point in the history
* add authorization layer to request handlers

* update authorized wrapper with resource

* Add tests

* Add documentation

* Add AuthorizationManager class

* Update examples/authorization/README.md

* authorization: address review

- "contents" applies to /view
- "terminals" is plural
- "server" is scope for shutdown
- failed authorization is 403, not 401
- calling it Authorizer instead of AuthorizationManager
- 'user' term is more broadly understood than 'subject'.
  Plus, it always comes from `self.current_user`.
- default authorizer that allows all users is AllowAllAuthorizer

* allow `@authorized` to be used with no arguments

- use auth_resource on handler
- use http method name for action

* Structure authorization resources as a table

* Move Authorizer to existing jupyter_server.auth

since it's a public API packages should import,
let's not nest it deep in services.auth.authorizer

Co-authored-by: David Brochart <david.brochart@gmail.com>
Co-authored-by: Steven Silvester <steven.silvester@gmail.com>
Co-authored-by: Min RK <benjaminrk@gmail.com>
  • Loading branch information
4 people committed Feb 9, 2022
1 parent 0ab0e1e commit 49dfe2e
Show file tree
Hide file tree
Showing 53 changed files with 1,355 additions and 98 deletions.
156 changes: 156 additions & 0 deletions docs/source/operators/security.rst
Expand Up @@ -77,6 +77,162 @@ but this is **NOT RECOMMENDED**, unless authentication or access restrictions ar
c.ServerApp.token = ''
c.ServerApp.password = ''

Authorization
-------------

.. versionadded:: 2.0

Authorization in Jupyter Server serves to provide finer grained control of access to its
API resources. With authentication, requests are accepted if the current user is known by
the server. Thus it can restrain access to specific users, but there is no way to give allowed
users more or less permissions. Jupyter Server provides a thin and extensible authorization layer
which checks if the current user is authorized to make a specific request.

This is done by calling a ``is_authorized(handler, user, action, resource)`` method before each
request handler. Each request is labeled as either a "read", "write", or "execute" ``action``:

- "read" wraps all ``GET`` and ``HEAD`` requests.
In general, read permissions grants access to read but not modify anything about the given resource.
- "write" wraps all ``POST``, ``PUT``, ``PATCH``, and ``DELETE`` requests.
In general, write permissions grants access to modify the given resource.
- "execute" wraps all requests to ZMQ/Websocket channels (terminals and kernels).
Execute is a special permission that usually corresponds to arbitrary execution,
such as via a kernel or terminal.
These permissions should generally be considered sufficient to perform actions equivalent
to ~all other permissions via other means.

The ``resource`` being accessed refers to the resource name in the Jupyter Server's API endpoints.
In most cases, this is matches the field after `/api/`.
For instance, values for ``resource`` in the endpoints provided by the base jupyter server package,
and the corresponding permissions:

.. list-table::
:header-rows: 1

* - resource
- read
- write
- execute
- endpoints

* - *resource name*
- *what can you do with read permissions?*
- *what can you do with write permissions?*
- *what can you do with execute permissions, if anything?*
- ``/api/...`` *what endpoints are governed by this resource?*

* - api
- read server status (last activity, number of kernels, etc.), OpenAPI specification
-
-
- ``/api/status``, ``/api/spec.yaml``
* - csp
-
- report content-security-policy violations
-
- ``/api/security/csp-report``
* - config
- read frontend configuration, such as for notebook extensions
- modify frontend configuration
-
- ``/api/config``
* - contents
- read files
- modify files (create, modify, delete)
-
- ``/api/contents``, ``/view``, ``/files``
* - kernels
- list kernels, get status of kernels
- start, stop, and restart kernels
- Connect to kernel websockets, send/recv kernel messages.
**This generally means arbitrary code execution,
and should usually be considered equivalent to having all other permissions.**
- ``/api/kernels``
* - kernelspecs
- read, list information about available kernels
-
-
- ``/api/kernelspecs``
* - nbconvert
- render notebooks to other formats via nbconvert.
**Note: depending on server-side configuration,
this *could* involve execution.**
-
-
- ``/api/nbconvert``
* - server
-
- Shutdown the server
-
- ``/api/shutdown``
* - sessions
- list current sessions (association of documents to kernels)
- create, modify, and delete existing sessions,
which includes starting, stopping, and deleting kernels.
-
- ``/api/sessions``
* - terminals
- list running terminals and their last activity
- start new terminals, stop running terminals
- Connect to terminal websockets, execute code in a shell.
**This generally means arbitrary code execution,
and should usually be considered equivalent to having all other permissions.**
- ``/api/terminals``


Extensions may define their own resources.
Extension resources should start with ``extension_name:`` to avoid namespace conflicts.

If ``is_authorized(...)`` returns ``True``, the request is made; otherwise, a
``HTTPError(403)`` (403 means "Forbidden") error is raised, and the request is blocked.

By default, authorization is turned off—i.e. ``is_authorized()`` always returns ``True`` and
all authenticated users are allowed to make all types of requests. To turn-on authorization, pass
a class that inherits from ``Authorizer`` to the ``ServerApp.authorizer_class``
parameter, implementing a ``is_authorized()`` method with your desired authorization logic, as
follows:

.. sourcecode:: python

from jupyter_server.auth import Authorizer

class MyAuthorizationManager(Authorizer):
"""Class for authorizing access to resources in the Jupyter Server.

All authorizers used in Jupyter Server should inherit from
AuthorizationManager and, at the very minimum, override and implement
an `is_authorized` method with the following signature.

The `is_authorized` method is called by the `@authorized` decorator in
JupyterHandler. If it returns True, the incoming request to the server
is accepted; if it returns False, the server returns a 403 (Forbidden) error code.
"""

def is_authorized(self, handler: JupyterHandler, user: Any, action: str, resource: str) -> bool:
"""A method to determine if `user` is authorized to perform `action`
(read, write, or execute) on the `resource` type.

Parameters
------------
user : usually a dict or string
A truthy model representing the authenticated user.
A username string by default,
but usually a dict when integrating with an auth provider.

action : str
the category of action for the current request: read, write, or execute.

resource : str
the type of resource (i.e. contents, kernels, files, etc.) the user is requesting.

Returns True if user authorized to make request; otherwise, returns False.
"""
return True # implement your authorization logic here

The ``is_authorized()`` method will automatically be called whenever a handler is decorated with
``@authorized`` (from ``jupyter_server.auth``), similarly to the
``@authenticated`` decorator for authorization (from ``tornado.web``).

Security in notebook documents
==============================

Expand Down
84 changes: 84 additions & 0 deletions examples/authorization/README.md
@@ -0,0 +1,84 @@
# 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 files.

## How does it work?

To add a custom authorization system to the Jupyter Server, you will need to write your own `Authorizer` subclass and pass it to Jupyter's configuration system (i.e. by file or CLI).

The examples below demonstrate some basic implementations of an `Authorizer`.

```python
from jupyter_server.auth import Authorizer


class MyCustomAuthorizer(Authorizer):
"""Custom authorization manager."""

# Define my own method here for handling authorization.
# The argument signature must have `self`, `handler`, `user`, `action`, and `resource`.
def is_authorized(self, handler, 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 != "read":
return False

return True

# Pass this custom class to Jupyter Server
c.ServerApp.authorizer_class = MyCustomAuthorizer
```

In the `jupyter_nbclassic_readonly_config.py`

## Try it out!

### Read-only example

1. Install nbclassic using `pip`.

pip install nbclassic

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 `403: Forbidden` error.

### Read+Write example

1. Install nbclassic using `pip`.

pip install nbclassic

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 `403: Forbidden` error.

### Temporary notebook server example

This configuration allows everything except saving files.

1. Install nbclassic using `pip`.

pip install nbclassic

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 `403: Forbidden` error.
14 changes: 14 additions & 0 deletions examples/authorization/jupyter_nbclassic_readonly_config.py
@@ -0,0 +1,14 @@
from jupyter_server.auth import Authorizer


class ReadOnly(Authorizer):
"""Authorizer that makes Jupyter Server a read-only server."""

def is_authorized(self, handler, user, action, resource):
"""Only allows `read` operations."""
if action != "read":
return False
return True


c.ServerApp.authorizer_class = ReadOnly
14 changes: 14 additions & 0 deletions examples/authorization/jupyter_nbclassic_rw_config.py
@@ -0,0 +1,14 @@
from jupyter_server.auth import Authorizer


class ReadWriteOnly(Authorizer):
"""Authorizer class that makes Jupyter Server a read/write-only server."""

def is_authorized(self, handler, user, action, resource):
"""Only allows `read` and `write` operations."""
if action not in {"read", "write"}:
return False
return True


c.ServerApp.authorizer_class = ReadWriteOnly
14 changes: 14 additions & 0 deletions examples/authorization/jupyter_temporary_config.py
@@ -0,0 +1,14 @@
from jupyter_server.auth import Authorizer


class TemporaryServerPersonality(Authorizer):
"""Authorizer that prevents modifying files via the contents service"""

def is_authorized(self, handler, user, action, resource):
"""Allow everything but write on contents"""
if action == "write" and resource == "contents":
return False
return True


c.ServerApp.authorizer_class = TemporaryServerPersonality
2 changes: 2 additions & 0 deletions jupyter_server/auth/__init__.py
@@ -1 +1,3 @@
from .authorizer import * # noqa
from .decorator import authorized # noqa
from .security import passwd # noqa
69 changes: 69 additions & 0 deletions jupyter_server/auth/authorizer.py
@@ -0,0 +1,69 @@
"""An Authorizer for use in the Jupyter server.
The default authorizer (AllowAllAuthorizer)
allows all authenticated requests
.. versionadded:: 2.0
"""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
from traitlets.config import LoggingConfigurable

from jupyter_server.base.handlers import JupyterHandler


class Authorizer(LoggingConfigurable):
"""Base class for authorizing access to resources
in the Jupyter Server.
All authorizers used in Jupyter Server
should inherit from this base class and, at the very minimum,
implement an `is_authorized` method with the
same signature as in this base class.
The `is_authorized` method is called by the `@authorized` decorator
in JupyterHandler. If it returns True, the incoming request
to the server is accepted; if it returns False, the server
returns a 403 (Forbidden) error code.
The authorization check will only be applied to requests
that have already been authenticated.
.. versionadded:: 2.0
"""

def is_authorized(self, handler: JupyterHandler, user: str, action: str, resource: str) -> bool:
"""A method to determine if `user` is authorized to perform `action`
(read, write, or execute) on the `resource` type.
Parameters
----------
user : usually a dict or string
A truthy model representing the authenticated user.
A username string by default,
but usually a dict when integrating with an auth provider.
action : str
the category of action for the current request: read, write, or execute.
resource : str
the type of resource (i.e. contents, kernels, files, etc.) the user is requesting.
Returns True if user authorized to make request; otherwise, returns False.
"""
raise NotImplementedError()


class AllowAllAuthorizer(Authorizer):
"""A no-op implementation of the Authorizer
This authorizer allows all authenticated requests.
.. versionadded:: 2.0
"""

def is_authorized(self, handler: JupyterHandler, user: str, action: str, resource: str) -> bool:
"""This method always returns True.
All authenticated users are allowed to do anything in the Jupyter Server.
"""
return True

0 comments on commit 49dfe2e

Please sign in to comment.