Skip to content

Commit 6d84507

Browse files
authored
Identity API at /api/me (#671)
1 parent bf40316 commit 6d84507

File tree

18 files changed

+888
-66
lines changed

18 files changed

+888
-66
lines changed

docs/source/conf.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
"sphinx.ext.intersphinx",
7474
"sphinx.ext.autosummary",
7575
"sphinx.ext.mathjax",
76+
"sphinx.ext.napoleon",
7677
"IPython.sphinxext.ipython_console_highlighting",
7778
"sphinxcontrib_github_alt",
7879
"sphinxcontrib.openapi",
@@ -131,7 +132,7 @@
131132

132133
# The reST default role (used for this markup: `text`) to use for all
133134
# documents.
134-
# default_role = None
135+
default_role = "literal"
135136

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

365367
spelling_lang = "en_US"

docs/source/operators/security.rst

Lines changed: 115 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,17 +77,129 @@ but this is **NOT RECOMMENDED**, unless authentication or access restrictions ar
7777
c.ServerApp.token = ''
7878
c.ServerApp.password = ''
7979

80-
Authorization
81-
-------------
80+
81+
Authentication and Authorization
82+
--------------------------------
8283

8384
.. versionadded:: 2.0
8485

86+
There are two steps to deciding whether to allow a given request to be happen.
87+
88+
The first step is "Authentication" (identifying who is making the request).
89+
This is handled by the :class:`.IdentityProvider`.
90+
91+
Whether a given user is allowed to take a specific action is called "Authorization",
92+
and is handled separately, by an :class:`.Authorizer`.
93+
94+
These two classes may work together,
95+
as the information returned by the IdentityProvider is given to the Authorizer when it makes its decisions.
96+
97+
Authentication always takes precedence because if no user is authenticated,
98+
no authorization checks need to be made,
99+
as all requests requiring _authorization_ must first complete _authentication_.
100+
101+
Identity Providers
102+
******************
103+
104+
The :class:`.IdentityProvider` class is responsible for the "authorization" step,
105+
identifying the user making the request,
106+
and constructing information about them.
107+
108+
It principally implements two methods.
109+
110+
.. autoclass:: jupyter_server.auth.IdentityProvider
111+
112+
.. automethod:: get_user
113+
.. automethod:: identity_model
114+
115+
The first is :meth:`.IdentityProvider.get_user`.
116+
This method is given a RequestHandler, and is responsible for deciding whether there is an authenticated user making the request.
117+
If the request is authenticated, it should return a :class:`.jupyter_server.auth.User` object representing the authenticated user.
118+
It should return None if the request is not authenticated.
119+
120+
The default implementation accepts token or password authentication.
121+
122+
This User object will be available as `self.current_user` in any request handler.
123+
Request methods decorated with tornado's `@web.authenticated` decorator
124+
will only be allowed if this method returns something.
125+
126+
The User object will be a Python :py:class:`dataclasses.dataclass`, `jupyter_server.auth.User`:
127+
128+
.. autoclass:: jupyter_server.auth.User
129+
130+
A custom IdentityProvider _may_ return a custom subclass.
131+
132+
133+
The next method an identity provider has is :meth:`~.IdentityProvider.identity_model`.
134+
`identity_model(user)` is responsible for transforming the user object returned from `.get_user()`
135+
into a standard identity model dictionary,
136+
for use in the `/api/me` endpoint.
137+
138+
If your user object is a simple username string or a dict with a `username` field,
139+
you may not need to implement this method, as the default implementation will suffice.
140+
141+
Any required fields missing from the dict returned by this method will be filled-out with defaults.
142+
Only `username` is strictly required, if that is all the information the identity provider has available.
143+
144+
Missing will be derived according to:
145+
146+
- if `name` is missing, use `username`
147+
- if `display_name` is missing, use `name`
148+
149+
Other required fields will be filled with `None`.
150+
151+
152+
Identity Model
153+
^^^^^^^^^^^^^^
154+
155+
The identity model is the model accessed at `/api/me`,
156+
and describes the currently authenticated user.
157+
158+
It has the following fields:
159+
160+
username
161+
(string)
162+
Unique string identifying the user.
163+
Must be non-empty.
164+
name
165+
(string)
166+
For-humans name of the user.
167+
May be the same as `username` in systems where only usernames are available.
168+
display_name
169+
(string)
170+
Alternate rendering of name for display, such as a nickname.
171+
Often the same as `name`.
172+
initials
173+
(string or null)
174+
Short string of initials.
175+
Initials should not be derived automatically due to localization issues.
176+
May be `null` if unavailable.
177+
avatar_url
178+
(string or null)
179+
URL of an avatar image to be used for the user.
180+
May be `null` if unavailable.
181+
color
182+
(string or null)
183+
A CSS color string to use as a preferred color,
184+
such as for collaboration cursors.
185+
May be `null` if unavailable.
186+
187+
Authorization
188+
*************
189+
190+
Authorization is the second step in allowing an action,
191+
after a user has been _authenticated_ by the IdentityProvider.
192+
85193
Authorization in Jupyter Server serves to provide finer grained control of access to its
86194
API resources. With authentication, requests are accepted if the current user is known by
87195
the server. Thus it can restrain access to specific users, but there is no way to give allowed
88196
users more or less permissions. Jupyter Server provides a thin and extensible authorization layer
89197
which checks if the current user is authorized to make a specific request.
90198

199+
.. autoclass:: jupyter_server.auth.Authorizer
200+
201+
.. automethod:: is_authorized
202+
91203
This is done by calling a ``is_authorized(handler, user, action, resource)`` method before each
92204
request handler. Each request is labeled as either a "read", "write", or "execute" ``action``:
93205

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

348+
236349
Security in notebook documents
237350
==============================
238351

jupyter_server/auth/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
from .authorizer import * # noqa
22
from .decorator import authorized # noqa
3+
from .identity import * # noqa
34
from .security import passwd # noqa

jupyter_server/auth/authorizer.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
from jupyter_server.base.handlers import JupyterHandler
1313

14+
from .identity import User
15+
1416

1517
class Authorizer(LoggingConfigurable):
1618
"""Base class for authorizing access to resources
@@ -32,23 +34,28 @@ class Authorizer(LoggingConfigurable):
3234
.. versionadded:: 2.0
3335
"""
3436

35-
def is_authorized(self, handler: JupyterHandler, user: str, action: str, resource: str) -> bool:
37+
def is_authorized(
38+
self, handler: JupyterHandler, user: User, action: str, resource: str
39+
) -> bool:
3640
"""A method to determine if `user` is authorized to perform `action`
3741
(read, write, or execute) on the `resource` type.
3842
3943
Parameters
4044
----------
41-
user : usually a dict or string
42-
A truthy model representing the authenticated user.
43-
A username string by default,
44-
but usually a dict when integrating with an auth provider.
45+
user : jupyter_server.auth.User
46+
An object representing the authenticated user,
47+
as returned by :meth:`.IdentityProvider.get_user`.
48+
4549
action : str
4650
the category of action for the current request: read, write, or execute.
4751
4852
resource : str
4953
the type of resource (i.e. contents, kernels, files, etc.) the user is requesting.
5054
51-
Returns True if user authorized to make request; otherwise, returns False.
55+
Returns
56+
-------
57+
bool
58+
True if user authorized to make request; False, otherwise
5259
"""
5360
raise NotImplementedError()
5461

@@ -61,7 +68,9 @@ class AllowAllAuthorizer(Authorizer):
6168
.. versionadded:: 2.0
6269
"""
6370

64-
def is_authorized(self, handler: JupyterHandler, user: str, action: str, resource: str) -> bool:
71+
def is_authorized(
72+
self, handler: JupyterHandler, user: User, action: str, resource: str
73+
) -> bool:
6574
"""This method always returns True.
6675
6776
All authenticated users are allowed to do anything in the Jupyter Server.

jupyter_server/auth/decorator.py

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from tornado.log import app_log
99
from tornado.web import HTTPError
1010

11-
from .utils import HTTP_METHOD_TO_AUTH_ACTION, warn_disabled_authorization
11+
from .utils import HTTP_METHOD_TO_AUTH_ACTION
1212

1313

1414
def authorized(
@@ -57,18 +57,13 @@ def inner(self, *args, **kwargs):
5757
if not user:
5858
app_log.warning("Attempting to authorize request without authentication!")
5959
raise HTTPError(status_code=403, log_message=message)
60-
61-
# Handle the case where an authorizer wasn't attached to the handler.
62-
if not self.authorizer:
63-
warn_disabled_authorization()
64-
return method(self, *args, **kwargs)
65-
66-
# Only return the method if the action is authorized.
60+
# If the user is allowed to do this action,
61+
# call the method.
6762
if self.authorizer.is_authorized(self, user, action, resource):
6863
return method(self, *args, **kwargs)
69-
70-
# Raise an exception if the method wasn't returned (i.e. not authorized)
71-
raise HTTPError(status_code=403, log_message=message)
64+
# else raise an exception.
65+
else:
66+
raise HTTPError(status_code=403, log_message=message)
7267

7368
return inner
7469

jupyter_server/auth/identity.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""Identity Provider interface
2+
3+
This defines the _authentication_ layer of Jupyter Server,
4+
to be used in combination with Authorizer for _authorization_.
5+
6+
.. versionadded:: 2.0
7+
"""
8+
from dataclasses import asdict, dataclass
9+
from typing import Any, Optional
10+
11+
from tornado.web import RequestHandler
12+
from traitlets.config import LoggingConfigurable
13+
14+
# from dataclasses import field
15+
16+
17+
@dataclass
18+
class User:
19+
"""Object representing a User
20+
21+
This or a subclass should be returned from IdentityProvider.get_user
22+
"""
23+
24+
username: str # the only truly required field
25+
26+
# these fields are filled from username if not specified
27+
# name is the 'real' name of the user
28+
name: str = ""
29+
# display_name is a shorter name for us in UI,
30+
# if different from name. e.g. a nickname
31+
display_name: str = ""
32+
33+
# these fields are left as None if undefined
34+
initials: Optional[str] = None
35+
avatar_url: Optional[str] = None
36+
color: Optional[str] = None
37+
38+
# TODO: extension fields?
39+
# ext: Dict[str, Dict[str, Any]] = field(default_factory=dict)
40+
41+
def __post_init__(self):
42+
self.fill_defaults()
43+
44+
def fill_defaults(self):
45+
"""Fill out default fields in the identity model
46+
47+
- Ensures all values are defined
48+
- Fills out derivative values for name fields fields
49+
- Fills out null values for optional fields
50+
"""
51+
52+
# username is the only truly required field
53+
if not self.username:
54+
raise ValueError(f"user.username must not be empty: {self}")
55+
56+
# derive name fields from username -> name -> display name
57+
if not self.name:
58+
self.name = self.username
59+
if not self.display_name:
60+
self.display_name = self.name
61+
62+
def to_dict(self):
63+
pass
64+
65+
66+
def _backward_compat_user(got_user: Any) -> User:
67+
"""Backward-compatibility for LoginHandler.get_user
68+
69+
Prior to 2.0, LoginHandler.get_user could return anything truthy.
70+
71+
Typically, this was either a simple string username,
72+
or a simple dict.
73+
74+
Make some effort to allow common patterns to keep working.
75+
"""
76+
if isinstance(got_user, str):
77+
return User(username=got_user)
78+
elif isinstance(got_user, dict):
79+
kwargs = {}
80+
if "username" not in got_user:
81+
if "name" in got_user:
82+
kwargs["username"] = got_user["name"]
83+
for field in User.__dataclass_fields__:
84+
if field in got_user:
85+
kwargs[field] = got_user[field]
86+
try:
87+
return User(**kwargs)
88+
except TypeError:
89+
raise ValueError(f"Unrecognized user: {got_user}")
90+
else:
91+
raise ValueError(f"Unrecognized user: {got_user}")
92+
93+
94+
class IdentityProvider(LoggingConfigurable):
95+
"""
96+
Interface for providing identity
97+
98+
_may_ be a coroutine.
99+
100+
Two principle methods:
101+
102+
- :meth:`~.IdentityProvider.get_user` returns a :class:`~.User` object
103+
for successful authentication, or None for no-identity-found.
104+
- :meth:`~.IdentityProvider.identity_model` turns a :class:`~.User` into a JSONable dict.
105+
The default is to use :py:meth:`dataclasses.asdict`,
106+
and usually shouldn't need override.
107+
108+
.. versionadded:: 2.0
109+
"""
110+
111+
def get_user(self, handler: RequestHandler) -> User:
112+
"""Get the authenticated user for a request
113+
114+
Must return a :class:`.jupyter_server.auth.User`,
115+
though it may be a subclass.
116+
117+
Return None if the request is not authenticated.
118+
"""
119+
120+
if handler.login_handler is None:
121+
return User("anonymous")
122+
123+
# The default: call LoginHandler.get_user for backward-compatibility
124+
# TODO: move default implementation to this class,
125+
# deprecate `LoginHandler.get_user`
126+
user = handler.login_handler.get_user(handler)
127+
if user and not isinstance(user, User):
128+
return _backward_compat_user(user)
129+
return user
130+
131+
def identity_model(self, user: User) -> dict:
132+
"""Return a User as an Identity model"""
133+
# TODO: validate?
134+
return asdict(user)
135+
136+
def get_handlers(self) -> list:
137+
"""Return list of additional handlers for this identity provider
138+
139+
For example, an OAuth callback handler.
140+
"""
141+
return []

0 commit comments

Comments
 (0)