Skip to content

Commit

Permalink
add /api/me to get identity model
Browse files Browse the repository at this point in the history
includes fields:

- username: str
- name: Optional[str]
- display_name: Optional[str]
- initials: Optional[str]
- avatar_url: Optional[str]
- color: Optional[str]
- permissions in the form {"resource": ["action", ],}

where permissions are only populated _by request_,
because the server cannot know what all resource/action combinations are available.

Defines new jupyter_server.auth.IdentityProvider API for implementing authorization

- IdP.get_user(Handler) returns opaque truthy user for authenticated requests or None
- IdP.identity_model adapts opaque User to standard identity model dict
  • Loading branch information
minrk committed Feb 11, 2022
1 parent 49dfe2e commit 098c511
Show file tree
Hide file tree
Showing 11 changed files with 678 additions and 22 deletions.
3 changes: 2 additions & 1 deletion docs/source/conf.py
Expand Up @@ -133,7 +133,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 @@ -373,6 +373,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
118 changes: 116 additions & 2 deletions docs/source/operators/security.rst
Expand Up @@ -77,17 +77,130 @@ 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(Handler)`.
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 truthy object representing the authenticated user.
It should return None if the request is not authenticated.

The default implementation accepts token or password authentication

This 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.

Technically, any truthy Python object is allowed, but when in doubt a dict with at least a `username` field is a good choice:

.. sourcecode:: python

{
"username": "some-user",
}

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.
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 +346,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
@@ -1,3 +1,4 @@
from .authorizer import * # noqa
from .decorator import authorized # noqa
from .identity import * # noqa
from .security import passwd # noqa
5 changes: 3 additions & 2 deletions jupyter_server/auth/authorizer.py
Expand Up @@ -39,9 +39,10 @@ def is_authorized(self, handler: JupyterHandler, user: str, action: str, resourc
Parameters
----------
user : usually a dict or string
A truthy model representing the authenticated user.
A truthy object representing the authenticated user,
as returned by :meth:`.IdentityProvider.get_user`.
A username string by default,
but usually a dict when integrating with an auth provider.
but usually a dict when integrating with an identity provider.
action : str
the category of action for the current request: read, write, or execute.
Expand Down
155 changes: 155 additions & 0 deletions jupyter_server/auth/identity.py
@@ -0,0 +1,155 @@
"""Identity Provider interface
This defines the _authentication_ layer of Jupyter Server,
to be used in combination with Authorizer for _authorization_.
.. versionadded:: 2.0
"""
import sys
from typing import Any
from typing import Dict
from typing import List
from typing import Optional

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

if sys.version_info >= (3, 8):
from typing import TypedDict
else:
try:
from typing_extensions import TypedDict
except ImportError:
TypedDict = Dict


class IdentityModel(TypedDict):
# see the JupyterLab IUser model for definitions

username: str # the only truly required field

# these fields are derived from username if not specified
name: str
display_name: str

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

# Jupyter Server permissions
# as a dict of permitted {"resource": ["actions"]}
permissions: Dict[str, List[str]]


class IdentityProvider(LoggingConfigurable):
"""
Interface for providing identity
Two principle methods:
- :meth:`~.IdentityProvider.get_user` returns a user object.
For successful authentication,
this may return anything truthy.
- :meth:`~.IdentityProvider.identity_model` returns a standard identity model dictionary,
for use in the /me API.
This should accept whatever is returned from get_user()
and return a dictionary matching the structure of
:class:`~.IdentityModel`.
.. versionadded:: 2.0
"""

def get_user(self, handler: RequestHandler) -> Any:
"""Get the authenticated user for a request
User may be anything truthy, but must be understood by identity_model method.
Return None if the request is not authenticated.
When in doubt, use a standard identity model.
"""

if handler.login_handler is None:
return {
"username": "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)
return user

def identity_model(self, user: Any) -> IdentityModel:
"""Construct standardized identity model for the identity API
Casts objects returned by `.get_user` (generally str username or dict with 'username' or 'name')
To a complete IdentityModel dict.
`username` is required.
Any other missing fields will be filled out with defaults.
"""
identity = {}
if isinstance(user, str):
return {
"username": user,
}
elif isinstance(user, dict):
# username may be in 'username' field or 'name' (e.g. JupyterHub)
# but only accept 'name' for username if 'username' not present
for username_key in ("username", "name"):
if username_key in user:
identity["username"] = user[username_key]
break

for key, value in user.items():
# annotations is where fields are stored
if key in IdentityModel.__annotations__:
identity[key] = user[key]

# handle other types, e.g. custom objects. Subclasses must define this method
# in order to handler these.
if "username" not in identity:
clsname = self.__class__.__name__
self.log.warning(
f"Unable to find username in current_user. {clsname}.identity_model() must accept user objects as returned by {clsname}.get_user()."
)
self.log.debug("Unable to find username in current_user: %s", user)
identity["username"] = "unknown"
# fill defaults
return self.fill_identity(identity)

def _get_identity_model(self, user: Any) -> IdentityModel:
"""
Private method to always return a filled identity model
This is how the user model should be accessed, in general.
"""
return self.fill_identity(self.identity_model(user))

def fill_identity(self, identity: IdentityModel) -> IdentityModel:
"""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 identity.get("username"):
raise ValueError(f"identity.username must not be empty: {identity}")

# derive name fields from username -> name -> display name
if not identity.get("name"):
identity["name"] = identity["username"]
if not identity.get("display_name"):
identity["display_name"] = identity["name"]

# fields that should be defined, but use null if no information is provided
for key in ("avatar_url", "color", "initials"):
identity.setdefault(key, None)

identity.setdefault("permissions", {})
return identity
8 changes: 5 additions & 3 deletions jupyter_server/base/handlers.py
Expand Up @@ -134,9 +134,7 @@ def clear_login_cookie(self):
self.force_clear_cookie(self.cookie_name)

def get_current_user(self):
if self.login_handler is None:
return "anonymous"
return self.login_handler.get_user(self)
return self.identity_provider.get_user(self)

def skip_check_origin(self):
"""Ask my login_handler if I should skip the origin_check
Expand Down Expand Up @@ -195,6 +193,10 @@ def login_available(self):
def authorizer(self):
return self.settings["authorizer"]

@property
def identity_provider(self):
return self.settings["identity_provider"]


class JupyterHandler(AuthenticatedHandler):
"""Jupyter-specific extensions to authenticated handling
Expand Down

0 comments on commit 098c511

Please sign in to comment.