From 098c511ac634806511341b02157940db62e4fd81 Mon Sep 17 00:00:00 2001 From: Min RK Date: Thu, 20 Jan 2022 11:03:31 +0100 Subject: [PATCH] add /api/me to get identity model 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 --- docs/source/conf.py | 3 +- docs/source/operators/security.rst | 118 ++++++++++++- jupyter_server/auth/__init__.py | 1 + jupyter_server/auth/authorizer.py | 5 +- jupyter_server/auth/identity.py | 155 ++++++++++++++++++ jupyter_server/base/handlers.py | 8 +- jupyter_server/serverapp.py | 53 ++++-- jupyter_server/services/api/api.yaml | 88 +++++++++- jupyter_server/services/api/handlers.py | 42 +++++ jupyter_server/tests/auth/test_identity.py | 105 ++++++++++++ jupyter_server/tests/services/api/test_api.py | 122 ++++++++++++++ 11 files changed, 678 insertions(+), 22 deletions(-) create mode 100644 jupyter_server/auth/identity.py create mode 100644 jupyter_server/tests/auth/test_identity.py diff --git a/docs/source/conf.py b/docs/source/conf.py index 23369d43f1..837ee2c5ac 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -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 @@ -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" diff --git a/docs/source/operators/security.rst b/docs/source/operators/security.rst index 87148cbe41..9856969714 100644 --- a/docs/source/operators/security.rst +++ b/docs/source/operators/security.rst @@ -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``: @@ -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 ============================== diff --git a/jupyter_server/auth/__init__.py b/jupyter_server/auth/__init__.py index 54477ffd1b..77a2599560 100644 --- a/jupyter_server/auth/__init__.py +++ b/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 diff --git a/jupyter_server/auth/authorizer.py b/jupyter_server/auth/authorizer.py index 952cb0278d..daa8f73e23 100644 --- a/jupyter_server/auth/authorizer.py +++ b/jupyter_server/auth/authorizer.py @@ -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. diff --git a/jupyter_server/auth/identity.py b/jupyter_server/auth/identity.py new file mode 100644 index 0000000000..5ea9109581 --- /dev/null +++ b/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 diff --git a/jupyter_server/base/handlers.py b/jupyter_server/base/handlers.py index 7de52f4e04..85a487682f 100644 --- a/jupyter_server/base/handlers.py +++ b/jupyter_server/base/handlers.py @@ -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 @@ -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 diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index 0630037dbe..1790966e14 100644 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -92,6 +92,7 @@ GatewayClient, ) from jupyter_server.auth.authorizer import Authorizer, AllowAllAuthorizer +from jupyter_server.auth.identity import IdentityProvider from jupyter_server.auth.login import LoginHandler from jupyter_server.auth.logout import LogoutHandler @@ -230,7 +231,9 @@ def __init__( default_url, settings_overrides, jinja_env_options, + *, authorizer=None, + identity_provider=None, ): if authorizer is None: warnings.warn( @@ -239,7 +242,16 @@ def __init__( RuntimeWarning, stacklevel=2, ) - authorizer = AllowAllAuthorizer(jupyter_app) + authorizer = AllowAllAuthorizer(parent=jupyter_app) + + if identity_provider is None: + warnings.warn( + "identity_provider unspecified. Using default IdentityProvider." + " Specify an identity_provider to avoid this message.", + RuntimeWarning, + stacklevel=2, + ) + identity_provider = IdentityProvider(parent=jupyter_app) settings = self.init_settings( jupyter_app, @@ -255,6 +267,7 @@ def __init__( settings_overrides, jinja_env_options, authorizer=authorizer, + identity_provider=identity_provider, ) handlers = self.init_handlers(default_services, settings) @@ -274,7 +287,9 @@ def init_settings( default_url, settings_overrides, jinja_env_options=None, + *, authorizer=None, + identity_provider=None, ): _template_path = settings_overrides.get( @@ -360,6 +375,7 @@ def init_settings( kernel_spec_manager=kernel_spec_manager, config_manager=config_manager, authorizer=authorizer, + identity_provider=identity_provider, # handlers extra_services=extra_services, # Jupyter stuff @@ -559,7 +575,7 @@ class JupyterServerStopApp(JupyterApp): help="Port of the server to be killed. Default %s" % DEFAULT_JUPYTER_SERVER_PORT, ) - sock = Unicode(u"", config=True, help="UNIX socket of the server to be killed.") + sock = Unicode("", config=True, help="UNIX socket of the server to be killed.") def parse_command_line(self, argv=None): super(JupyterServerStopApp, self).parse_command_line(argv) @@ -795,7 +811,9 @@ def _default_log_level(self): @default("log_format") def _default_log_format(self): """override default log format to include date & time""" - return u"%(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s]%(end_color)s %(message)s" + return ( + "%(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s]%(end_color)s %(message)s" + ) # file to be opened in the Jupyter server file_to_run = Unicode("", help="Open the named file when the application is launched.").tag( @@ -880,12 +898,12 @@ def _default_ip(self): @validate("ip") def _validate_ip(self, proposal): value = proposal["value"] - if value == u"*": - value = u"" + if value == "*": + value = "" return value custom_display_url = Unicode( - u"", + "", config=True, help=_i18n( """Override URL shown to users. @@ -928,7 +946,7 @@ def port_default(self): def port_retries_default(self): return int(os.getenv(self.port_retries_env, self.port_retries_default_value)) - sock = Unicode(u"", config=True, help="The UNIX socket the Jupyter server will listen on.") + sock = Unicode("", config=True, help="The UNIX socket the Jupyter server will listen on.") sock_mode = Unicode( "0600", @@ -959,19 +977,19 @@ def _validate_sock_mode(self, proposal): return value certfile = Unicode( - u"", + "", config=True, help=_i18n("""The full path to an SSL/TLS certificate file."""), ) keyfile = Unicode( - u"", + "", config=True, help=_i18n("""The full path to a private key file for usage with SSL/TLS."""), ) client_ca = Unicode( - u"", + "", config=True, help=_i18n( """The full path to a certificate authority certificate for SSL/TLS client authentication.""" @@ -1053,7 +1071,7 @@ def _token_default(self): if self.password: # no token if password is enabled self._token_generated = False - return u"" + return "" else: self._token_generated = True return binascii.hexlify(os.urandom(24)).decode("ascii") @@ -1116,7 +1134,7 @@ def _token_changed(self, change): self._token_generated = False password = Unicode( - u"", + "", config=True, help="""Hashed password to use for web authentication. @@ -1261,7 +1279,7 @@ def _default_allow_remote(self): ) browser = Unicode( - u"", + "", config=True, help="""Specify what command to use to invoke a web browser when starting the server. If not specified, the @@ -1513,6 +1531,13 @@ def _observe_contents_manager_class(self, change): help=_i18n("The authorizer class to use."), ) + identity_provider_class = Type( + default_value=IdentityProvider, + klass=IdentityProvider, + config=True, + help=_i18n("The identity provider class to use."), + ) + trust_xheaders = Bool( False, config=True, @@ -1838,6 +1863,7 @@ def init_configurables(self): log=self.log, ) self.authorizer = self.authorizer_class(parent=self, log=self.log) + self.identity_provider = self.identity_provider_class(parent=self, log=self.log) def init_logging(self): # This prevents double log messages because tornado use a root logger that @@ -1925,6 +1951,7 @@ def init_webapp(self): self.tornado_settings, self.jinja_environment_options, authorizer=self.authorizer, + identity_provider=self.identity_provider, ) if self.certfile: self.ssl_options["certfile"] = self.certfile diff --git a/jupyter_server/services/api/api.yaml b/jupyter_server/services/api/api.yaml index 844831e045..ef13098afe 100644 --- a/jupyter_server/services/api/api.yaml +++ b/jupyter_server/services/api/api.yaml @@ -33,6 +33,16 @@ parameters: in: path description: file path type: string + permissions: + name: permissions + type: string + required: false + in: query + description: | + JSON-serialized dictionary of `{"resource": ["action",]}` + (dict of lists of strings) to check. + The same dictionary structure will be returned, + containing only the actions for which the user is authorized. checkpoint_id: name: checkpoint_id required: true @@ -616,7 +626,21 @@ paths: description: Forbidden to access 404: description: Not found - + /api/me: + get: + summary: | + Get the identity of the currently authenticated user. + If present, a `permissions` argument may be specified + to check what actions the user currently is authorized to take. + tags: + - identity + parameters: + - $ref: "#/parameters/permissions" + responses: + 200: + description: The user's identity + schema: + $ref: "#/definitions/Identity" /api/status: get: summary: Get the current status/activity of the server. @@ -663,6 +687,68 @@ definitions: type: number description: | The total number of running kernels. + Identity: + description: The identity of the currently authenticated user + properties: + username: + type: string + description: | + Unique string identifying the user + name: + type: string + description: | + For-humans name of the user. + May be the same as `username` in systems where + only usernames are available. + display_name: + type: string + description: | + Alternate rendering of name for display. + Often the same as `name`. + initials: + type: string + description: | + Short string of initials. + Initials should not be derived automatically due to localization issues. + May be `null` if unavailable. + avatar_url: + type: string + description: | + URL of an avatar to be used for the user. + May be `null` if unavailable. + color: + type: string + description: | + A CSS color string to use as a preferred color, + such as for collaboration cursors. + May be `null` if unavailable. + permissions: + type: object + description: | + A dict of the form: `{"resource": ["action",]}` + containing only the AUTHORIZED subset of resource+actions + from the permissions specified in the request. + If no permission checks were made in the request, + this will be empty. + additionalProperties: + type: array + items: + type: string + example: + username: minrk + name: Min Ragan-Kelley + display_name: Min RK + initials: MRK + avatar_url: null + color: null + permissions: + contents: + - read + - write + kernels: + - read + - write + - execute KernelSpec: description: Kernel spec (contents of kernel.json) properties: diff --git a/jupyter_server/services/api/handlers.py b/jupyter_server/services/api/handlers.py index 8974215eb1..530aa9dc3d 100644 --- a/jupyter_server/services/api/handlers.py +++ b/jupyter_server/services/api/handlers.py @@ -3,9 +3,12 @@ # Distributed under the terms of the Modified BSD License. import json import os +from typing import Dict +from typing import List from tornado import web +from ...auth.identity import IdentityModel from ...base.handlers import APIHandler from ...base.handlers import JupyterHandler from jupyter_server._tz import isoformat @@ -57,7 +60,46 @@ async def get(self): self.finish(json.dumps(model, sort_keys=True)) +class IdentityHandler(APIHandler): + """Get the current user's identity model""" + + @web.authenticated + def get(self): + permissions_json: str = self.get_argument("permissions", "") + bad_permissions_msg = f'permissions should be a JSON dict of {{"resource": ["action",]}}, got {permissions_json!r}' + if permissions_json: + try: + permissions_to_check = json.loads(permissions_json) + except ValueError: + raise web.HTTPError(400, bad_permissions_msg) + if not isinstance(permissions_to_check, dict): + raise web.HTTPError(400, bad_permissions_msg) + else: + permissions_to_check = {} + + permissions: Dict[str, List[str]] = {} + user = self.current_user + + for resource, actions in permissions_to_check.items(): + if ( + not isinstance(resource, str) + or not isinstance(actions, list) + or not all(isinstance(action, str) for action in actions) + ): + raise web.HTTPError(400, bad_permissions_msg) + + allowed = permissions[resource] = [] + for action in actions: + if self.authorizer.is_authorized(self, user=user, resource=resource, action=action): + allowed.append(action) + + identity: IdentityModel = self.identity_provider._get_identity_model(user) + identity["permissions"] = permissions + self.write(json.dumps(identity)) + + default_handlers = [ (r"/api/spec.yaml", APISpecHandler), (r"/api/status", APIStatusHandler), + (r"/api/me", IdentityHandler), ] diff --git a/jupyter_server/tests/auth/test_identity.py b/jupyter_server/tests/auth/test_identity.py new file mode 100644 index 0000000000..0489e83c0c --- /dev/null +++ b/jupyter_server/tests/auth/test_identity.py @@ -0,0 +1,105 @@ +import pytest + +from jupyter_server.auth import IdentityModel +from jupyter_server.auth import IdentityProvider + + +class CustomUser: + def __init__(self, name): + self.name = name + + +@pytest.mark.parametrize( + "user, expected", + [ + ( + "str-name", + {"username": "str-name", "name": "str-name", "display_name": "str-name"}, + ), + ( + {"username": "user.username", "name": "user.name"}, + { + "username": "user.username", + "name": "user.name", + "display_name": "user.name", + }, + ), + ( + {"username": "user.username", "display_name": "display"}, + { + "username": "user.username", + "name": "user.username", + "display_name": "display", + }, + ), + ({"name": "user.name"}, {"username": "user.name", "name": "user.name"}), + (CustomUser("custom_name"), {"username": "unknown", "name": "unknown"}), + ], +) +def test_identity_model(user, expected): + idp = IdentityProvider() + identity = idp._get_identity_model(user) + print(identity) + identity_subset = {key: identity[key] for key in expected} + print(type(identity), type(identity_subset), type(expected)) + assert identity_subset == expected + + +@pytest.mark.parametrize( + "identity, expected", + [ + ({"name": "user"}, ValueError), + ( + {"username": "user.username"}, + { + "username": "user.username", + "name": "user.username", + "initials": None, + "avatar_url": None, + "color": None, + }, + ), + ( + {"username": "user.username", "name": "user.name", "color": "#abcdef"}, + { + "username": "user.username", + "name": "user.name", + "display_name": "user.name", + "color": "#abcdef", + }, + ), + ( + {"username": "user.username", "display_name": "display"}, + { + "username": "user.username", + "name": "user.username", + "display_name": "display", + }, + ), + ], +) +def test_fill_identity(identity, expected): + idp = IdentityProvider() + if isinstance(expected, type) and issubclass(expected, Exception): + with pytest.raises(expected): + filled = idp.fill_identity(identity) + return + filled = idp.fill_identity(identity) + + # fields match + assert set(filled.keys()) == set(IdentityModel.__annotations__.keys()) + + # check expected fields + for key in expected: + assert filled[key] == expected[key] + + # check types + for key in ("username", "name", "display_name"): + assert key in filled + assert isinstance(filled[key], str) + # don't allow empty strings + assert filled[key] + + for key in ("initials", "avatar_url", "color"): + assert key in filled + assert filled[key] is None or isinstance(filled[key], str) diff --git a/jupyter_server/tests/services/api/test_api.py b/jupyter_server/tests/services/api/test_api.py index c1620ff052..958cb1fceb 100644 --- a/jupyter_server/tests/services/api/test_api.py +++ b/jupyter_server/tests/services/api/test_api.py @@ -1,4 +1,12 @@ import json +from unittest import mock + +import pytest +from tornado.httpclient import HTTPError + +from jupyter_server.auth import Authorizer +from jupyter_server.auth import IdentityModel +from jupyter_server.auth import IdentityProvider async def test_get_spec(jp_fetch): @@ -21,3 +29,117 @@ async def test_get_status(jp_fetch): assert status["kernels"] == 0 assert status["last_activity"].endswith("Z") assert status["started"].endswith("Z") + + +class MockIdentityProvider(IdentityProvider): + mock_user: dict + + def get_user(self, handler): + # super returns a UUID + # return our mock user instead, as long as the request is authorized + authorized = super().get_user(handler) + if authorized: + return self.mock_user + + +class MockAuthorizer(Authorizer): + def is_authorized(self, handler, user, action, resource): + permissions = user.get("permissions", {}) + if permissions == "*": + return True + actions = permissions.get(resource, []) + return action in actions + + +@pytest.fixture +def identity_provider(jp_serverapp): + idp = MockIdentityProvider(parent=jp_serverapp) + authorizer = MockAuthorizer(parent=jp_serverapp) + with mock.patch.dict( + jp_serverapp.web_app.settings, + {"identity_provider": idp, "authorizer": authorizer}, + ): + yield idp + + +@pytest.mark.parametrize( + "user, check_permissions, expected", + [ + ( + {"username": "user.username"}, + None, + {"username": "user.username", "name": "user.username", "permissions": {}}, + ), + ( + { + "username": "user.username", + "permissions": { + "contents": ["read"], + "kernels": ["read", "write"], + "sessions": ["write"], + }, + }, + { + "contents": ["read", "write"], + "kernels": ["read", "write", "execute"], + "terminals": ["execute"], + }, + { + "username": "user.username", + "name": "user.username", + "permissions": { + "contents": ["read"], + "kernels": ["read", "write"], + "terminals": [], + }, + }, + ), + ({}, None, {}), + ({}, {"contents": ["write"]}, {"permissions": {"contents": ["write"]}}), + ], +) +async def test_identity(jp_fetch, user, check_permissions, expected, identity_provider): + user.setdefault("username", "user.username") + if not expected: + # default 'expected' + expected = dict(user) + expected.pop("permissions", None) + + # if no permissions are specified, allow anything + user.setdefault("permissions", "*") + identity_provider.mock_user = user + + if check_permissions is not None: + params = {"permissions": json.dumps(check_permissions)} + else: + expected["permissions"] = {} + params = None + + r = await jp_fetch("api/me", params=params) + assert r.code == 200 + identity = json.loads(r.body.decode()) + for key, value in expected.items(): + assert identity[key] == value + + assert set(identity.keys()) == set(IdentityModel.__annotations__.keys()) + + +@pytest.mark.parametrize( + "permissions", + [ + "", + "[]", + '"abc"', + json.dumps({"resource": "action"}), + json.dumps({"resource": [5]}), + json.dumps({"resource": {}}), + ], +) +async def test_identity_bad_permissions(jp_fetch, permissions): + with pytest.raises(HTTPError) as exc: + await jp_fetch("api/me", params={"permissions": json.dumps(permissions)}) + + r = exc.value.response + assert r.code == 400 + reply = json.loads(r.body.decode()) + assert "permissions should be a JSON dict" in reply["message"]