Skip to content

Commit

Permalink
Identity API at /api/me (#671)
Browse files Browse the repository at this point in the history
  • Loading branch information
minrk authored Apr 29, 2022
1 parent bf40316 commit 6d84507
Show file tree
Hide file tree
Showing 18 changed files with 888 additions and 66 deletions.
4 changes: 3 additions & 1 deletion docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"sphinx.ext.intersphinx",
"sphinx.ext.autosummary",
"sphinx.ext.mathjax",
"sphinx.ext.napoleon",
"IPython.sphinxext.ipython_console_highlighting",
"sphinxcontrib_github_alt",
"sphinxcontrib.openapi",
Expand Down Expand Up @@ -131,7 +132,7 @@

# The reST default role (used for this markup: `text`) to use for all
# documents.
# default_role = None
default_role = "literal"

# If true, '()' will be appended to :func: etc. cross-reference text.
# add_function_parentheses = True
Expand Down Expand Up @@ -360,6 +361,7 @@
"nbconvert": ("https://nbconvert.readthedocs.io/en/latest/", None),
"nbformat": ("https://nbformat.readthedocs.io/en/latest/", None),
"jupyter": ("https://jupyter.readthedocs.io/en/latest/", None),
"tornado": ("https://www.tornadoweb.org/en/stable/", None),
}

spelling_lang = "en_US"
Expand Down
117 changes: 115 additions & 2 deletions docs/source/operators/security.rst
Original file line number Diff line number Diff line change
Expand Up @@ -77,17 +77,129 @@ but this is **NOT RECOMMENDED**, unless authentication or access restrictions ar
c.ServerApp.token = ''
c.ServerApp.password = ''

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

Authentication and Authorization
--------------------------------

.. versionadded:: 2.0

There are two steps to deciding whether to allow a given request to be happen.

The first step is "Authentication" (identifying who is making the request).
This is handled by the :class:`.IdentityProvider`.

Whether a given user is allowed to take a specific action is called "Authorization",
and is handled separately, by an :class:`.Authorizer`.

These two classes may work together,
as the information returned by the IdentityProvider is given to the Authorizer when it makes its decisions.

Authentication always takes precedence because if no user is authenticated,
no authorization checks need to be made,
as all requests requiring _authorization_ must first complete _authentication_.

Identity Providers
******************

The :class:`.IdentityProvider` class is responsible for the "authorization" step,
identifying the user making the request,
and constructing information about them.

It principally implements two methods.

.. autoclass:: jupyter_server.auth.IdentityProvider

.. automethod:: get_user
.. automethod:: identity_model

The first is :meth:`.IdentityProvider.get_user`.
This method is given a RequestHandler, and is responsible for deciding whether there is an authenticated user making the request.
If the request is authenticated, it should return a :class:`.jupyter_server.auth.User` object representing the authenticated user.
It should return None if the request is not authenticated.

The default implementation accepts token or password authentication.

This User object will be available as `self.current_user` in any request handler.
Request methods decorated with tornado's `@web.authenticated` decorator
will only be allowed if this method returns something.

The User object will be a Python :py:class:`dataclasses.dataclass`, `jupyter_server.auth.User`:

.. autoclass:: jupyter_server.auth.User

A custom IdentityProvider _may_ return a custom subclass.


The next method an identity provider has is :meth:`~.IdentityProvider.identity_model`.
`identity_model(user)` is responsible for transforming the user object returned from `.get_user()`
into a standard identity model dictionary,
for use in the `/api/me` endpoint.

If your user object is a simple username string or a dict with a `username` field,
you may not need to implement this method, as the default implementation will suffice.

Any required fields missing from the dict returned by this method will be filled-out with defaults.
Only `username` is strictly required, if that is all the information the identity provider has available.

Missing will be derived according to:

- if `name` is missing, use `username`
- if `display_name` is missing, use `name`

Other required fields will be filled with `None`.


Identity Model
^^^^^^^^^^^^^^

The identity model is the model accessed at `/api/me`,
and describes the currently authenticated user.

It has the following fields:

username
(string)
Unique string identifying the user.
Must be non-empty.
name
(string)
For-humans name of the user.
May be the same as `username` in systems where only usernames are available.
display_name
(string)
Alternate rendering of name for display, such as a nickname.
Often the same as `name`.
initials
(string or null)
Short string of initials.
Initials should not be derived automatically due to localization issues.
May be `null` if unavailable.
avatar_url
(string or null)
URL of an avatar image to be used for the user.
May be `null` if unavailable.
color
(string or null)
A CSS color string to use as a preferred color,
such as for collaboration cursors.
May be `null` if unavailable.

Authorization
*************

Authorization is the second step in allowing an action,
after a user has been _authenticated_ by the IdentityProvider.

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.

.. autoclass:: jupyter_server.auth.Authorizer

.. automethod:: is_authorized

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``:

Expand Down Expand Up @@ -233,6 +345,7 @@ The ``is_authorized()`` method will automatically be called whenever a handler i
``@authorized`` (from ``jupyter_server.auth``), similarly to the
``@authenticated`` decorator for authorization (from ``tornado.web``).


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

Expand Down
1 change: 1 addition & 0 deletions jupyter_server/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .authorizer import * # noqa
from .decorator import authorized # noqa
from .identity import * # noqa
from .security import passwd # noqa
23 changes: 16 additions & 7 deletions jupyter_server/auth/authorizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

from jupyter_server.base.handlers import JupyterHandler

from .identity import User


class Authorizer(LoggingConfigurable):
"""Base class for authorizing access to resources
Expand All @@ -32,23 +34,28 @@ class Authorizer(LoggingConfigurable):
.. versionadded:: 2.0
"""

def is_authorized(self, handler: JupyterHandler, user: str, action: str, resource: str) -> bool:
def is_authorized(
self, handler: JupyterHandler, user: User, 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.
user : jupyter_server.auth.User
An object representing the authenticated user,
as returned by :meth:`.IdentityProvider.get_user`.
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.
Returns
-------
bool
True if user authorized to make request; False, otherwise
"""
raise NotImplementedError()

Expand All @@ -61,7 +68,9 @@ class AllowAllAuthorizer(Authorizer):
.. versionadded:: 2.0
"""

def is_authorized(self, handler: JupyterHandler, user: str, action: str, resource: str) -> bool:
def is_authorized(
self, handler: JupyterHandler, user: User, action: str, resource: str
) -> bool:
"""This method always returns True.
All authenticated users are allowed to do anything in the Jupyter Server.
Expand Down
17 changes: 6 additions & 11 deletions jupyter_server/auth/decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from tornado.log import app_log
from tornado.web import HTTPError

from .utils import HTTP_METHOD_TO_AUTH_ACTION, warn_disabled_authorization
from .utils import HTTP_METHOD_TO_AUTH_ACTION


def authorized(
Expand Down Expand Up @@ -57,18 +57,13 @@ def inner(self, *args, **kwargs):
if not user:
app_log.warning("Attempting to authorize request without authentication!")
raise HTTPError(status_code=403, log_message=message)

# Handle the case where an authorizer wasn't attached to the handler.
if not self.authorizer:
warn_disabled_authorization()
return method(self, *args, **kwargs)

# Only return the method if the action is authorized.
# If the user is allowed to do this action,
# call the method.
if self.authorizer.is_authorized(self, user, action, resource):
return method(self, *args, **kwargs)

# Raise an exception if the method wasn't returned (i.e. not authorized)
raise HTTPError(status_code=403, log_message=message)
# else raise an exception.
else:
raise HTTPError(status_code=403, log_message=message)

return inner

Expand Down
141 changes: 141 additions & 0 deletions jupyter_server/auth/identity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"""Identity Provider interface
This defines the _authentication_ layer of Jupyter Server,
to be used in combination with Authorizer for _authorization_.
.. versionadded:: 2.0
"""
from dataclasses import asdict, dataclass
from typing import Any, Optional

from tornado.web import RequestHandler
from traitlets.config import LoggingConfigurable

# from dataclasses import field


@dataclass
class User:
"""Object representing a User
This or a subclass should be returned from IdentityProvider.get_user
"""

username: str # the only truly required field

# these fields are filled from username if not specified
# name is the 'real' name of the user
name: str = ""
# display_name is a shorter name for us in UI,
# if different from name. e.g. a nickname
display_name: str = ""

# these fields are left as None if undefined
initials: Optional[str] = None
avatar_url: Optional[str] = None
color: Optional[str] = None

# TODO: extension fields?
# ext: Dict[str, Dict[str, Any]] = field(default_factory=dict)

def __post_init__(self):
self.fill_defaults()

def fill_defaults(self):
"""Fill out default fields in the identity model
- Ensures all values are defined
- Fills out derivative values for name fields fields
- Fills out null values for optional fields
"""

# username is the only truly required field
if not self.username:
raise ValueError(f"user.username must not be empty: {self}")

# derive name fields from username -> name -> display name
if not self.name:
self.name = self.username
if not self.display_name:
self.display_name = self.name

def to_dict(self):
pass


def _backward_compat_user(got_user: Any) -> User:
"""Backward-compatibility for LoginHandler.get_user
Prior to 2.0, LoginHandler.get_user could return anything truthy.
Typically, this was either a simple string username,
or a simple dict.
Make some effort to allow common patterns to keep working.
"""
if isinstance(got_user, str):
return User(username=got_user)
elif isinstance(got_user, dict):
kwargs = {}
if "username" not in got_user:
if "name" in got_user:
kwargs["username"] = got_user["name"]
for field in User.__dataclass_fields__:
if field in got_user:
kwargs[field] = got_user[field]
try:
return User(**kwargs)
except TypeError:
raise ValueError(f"Unrecognized user: {got_user}")
else:
raise ValueError(f"Unrecognized user: {got_user}")


class IdentityProvider(LoggingConfigurable):
"""
Interface for providing identity
_may_ be a coroutine.
Two principle methods:
- :meth:`~.IdentityProvider.get_user` returns a :class:`~.User` object
for successful authentication, or None for no-identity-found.
- :meth:`~.IdentityProvider.identity_model` turns a :class:`~.User` into a JSONable dict.
The default is to use :py:meth:`dataclasses.asdict`,
and usually shouldn't need override.
.. versionadded:: 2.0
"""

def get_user(self, handler: RequestHandler) -> User:
"""Get the authenticated user for a request
Must return a :class:`.jupyter_server.auth.User`,
though it may be a subclass.
Return None if the request is not authenticated.
"""

if handler.login_handler is None:
return User("anonymous")

# The default: call LoginHandler.get_user for backward-compatibility
# TODO: move default implementation to this class,
# deprecate `LoginHandler.get_user`
user = handler.login_handler.get_user(handler)
if user and not isinstance(user, User):
return _backward_compat_user(user)
return user

def identity_model(self, user: User) -> dict:
"""Return a User as an Identity model"""
# TODO: validate?
return asdict(user)

def get_handlers(self) -> list:
"""Return list of additional handlers for this identity provider
For example, an OAuth callback handler.
"""
return []
Loading

0 comments on commit 6d84507

Please sign in to comment.