Source code for cylc.uiserver.authorise

# Copyright (C) NIWA & British Crown (Met Office) & Contributors.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

from contextlib import suppress
from functools import lru_cache
from getpass import getuser
import grp
from inspect import iscoroutinefunction
import os
from typing import List, Optional, Union, Set, Tuple

import graphene
from jupyter_server.auth import Authorizer
from tornado import web

from cylc.uiserver.schema import UISMutations
from cylc.uiserver.utils import is_bearer_token_authenticated

from graphene.utils.str_converters import to_snake_case


[docs] class CylcAuthorizer(Authorizer): """Defines a safe default authorization policy for Jupyter Server. `Jupyter Server`_ provides an authorisation layer which gives full permissions to any user who has been granted permission to the Jupyter Hub ``access:servers`` scope (see :ref:`JupyterHub scopes reference <jupyterhub-scopes>`). This allows the execution of arbitrary code under another user account. To prevent this you must define an authorisation policy using :py:attr:`c.ServerApp.authorizer_class <jupyter_server.serverapp.ServerApp.authorizer_class>`. This class defines a policy which blocks all API calls to another user's server, apart from calls to Cylc interfaces explicitly defined in the :ref:`Cylc authorisation configuration <cylc.uiserver.user_authorization>`. This class is configured as the default authoriser for all Jupyter Server instances spawned via the ``cylc hubapp`` command. This is the default if you started `Jupyter Hub`_ using the ``cylc hub`` command. To see where this default is set, see this file for the appropriate release of cylc-uiserver: https://github.com/cylc/cylc-uiserver/blob/master/cylc/uiserver/jupyter_config.py If you are launching Jupyter Hub via another command (e.g. ``jupyterhub``) or are overriding :py:attr:`jupyterhub.app.JupyterHub.spawner_class`, then you will need to configure a safe authorisation policy e.g: .. code-block:: python from cylc.uiserver.authorise import CylcAuthorizer c.ServerApp.authorizer_class = CylcAuthorizer .. note:: It is possible to provide read-only access to Jupyter Server extensions such as Jupyter Lab, however, this isn't advisable as Jupyter Lab does not apply file-system permissions to what another user is allowed to see. If you wish to grant users access to other user's Jupyter Lab servers, override this configuration with due care over what you choose to expose. """ # This is here just to fix sphinx autodoc warning from traitlets' __init__ # see https://github.com/cylc/cylc-uiserver/pull/560 def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def is_authorized(self, handler, user, action, resource) -> bool: """Allow a user to access their own server. Note that Cylc uses its own authorization system (which is locked-down by default) and is not affected by this policy. """ if is_bearer_token_authenticated(handler): # this session is authenticated by a token or password NOT by # Jupyter Hub -> the bearer of the token has full permissions return True # the username of the user running this server # (used for authorzation purposes) me = getuser() if user.username == me: # give the user full permissions to their own server return True # block access to everyone else return False
class Authorization: """Authorization configuration object. One instance of this class lives for the life of the UI Server. If authorization settings change the UI Server will need to be re-started to pick them up. Authorization has access groups: `READ`, `CONTROL`, `ALL` - along with their negations, `!READ`, `!CONTROL` and `!ALL` which indicate removal of the permission groups. Args: owner: The server owner's user name. owner_auth_conf: The server owner's authorization configuration. site_auth_conf: The site's authorization configuration. log: The application logger. """ # config literals DEFAULT = "default" LIMIT = "limit" GRP_IDENTIFIER = "group:" # Operations ########################################################################## # !WARNING! # # # # Beware of changing these permission groups. Users may be relying on # # these settings. Changes should be widely publicised to users. # # # # If adding/removing operations, ensure documentation is updated. # # # ########################################################################## READ_OPERATION = "read" # Access group identifiers (used in config) READ = "READ" CONTROL = "CONTROL" ALL = "ALL" NOT_READ = "!READ" NOT_CONTROL = "!CONTROL" NOT_ALL = "!ALL" # Access Groups READ_OPS = {READ_OPERATION} ASYNC_OPS = {"query", "mutation"} READ_AUTH_OPS = {"query", "subscription"} def __init__( self, owner_user_name: str, owner_auth_conf: dict, site_auth_conf: dict, log, ): self.owner_user_name: str = owner_user_name self.owner_user_groups: List[str] = self._get_groups( self.owner_user_name ) self.log = log self.owner_auth_conf: dict = owner_auth_conf self.site_auth_config: dict = site_auth_conf self.owner_dict = self.build_owner_site_auth_conf() # lru_cache this method - see flake8-bugbear B019 self.get_permitted_operations = lru_cache(maxsize=128)( self._get_permitted_operations ) @property def ALL_OPS(self) -> List[str]: """ALL OPS constant, returns list of all mutations.""" return get_list_of_mutations() @property def CONTROL_OPS(self) -> List[str]: """CONTROL OPS constant, returns list of all control mutations.""" return get_list_of_mutations(control=True) def expand_and_process_access_groups(self, permission_set: set) -> set: """Process a permission set. Takes a permission set, e.g. limits, defaults. Expands the access groups and removes negated operations. Args: permission_set: set of permissions Returns: processed permission set. """ # Expand permission groups # E.G. ALL -> ["read", "trigger", "broadcast", ...] for action_group, expansion in { Authorization.READ: Authorization.READ_OPS, Authorization.CONTROL: self.CONTROL_OPS, Authorization.ALL: self.ALL_OPS, }.items(): if action_group in permission_set: permission_set.remove(action_group) permission_set.update(expansion) # Expand negated permission groups # E.G. !CONTROL -> ["!trigger", "!stop", "!pause", ...] for action_group, expansion in { Authorization.NOT_READ: [f"!{x}" for x in Authorization.READ_OPS], Authorization.NOT_CONTROL: [ f"!{x}" for x in self.CONTROL_OPS ], Authorization.NOT_ALL: [ f"!{x}" for x in self.ALL_OPS ], }.items(): if action_group in permission_set: permission_set.remove(action_group) permission_set.update(expansion) # Remove negated permissions remove = set() for perm in permission_set: if perm.startswith("!"): remove.add(perm.lstrip("!")) remove.add(perm) permission_set.difference_update(remove) permission_set.discard("") return permission_set def get_owner_site_limits_for_access_user( self, access_user_name: str, access_user_groups: List[str] ) -> Set[str]: """Returns limits owner can give to given access_user Args: access_user_name: The username of the authenticated user. access_user_groups: All groups the authenticated user belongs to. Returns: Set of limits that the uiserver owner is allowed to give away for given access user. """ limits: Set[str] = set() if not self.owner_dict: return limits items_to_check = ["*", access_user_name] items_to_check.extend(access_user_groups) for item in items_to_check: permission: Union[str, List] = "" default = "" with suppress(KeyError): default = self.owner_dict[item].get(Authorization.DEFAULT, "") with suppress(KeyError): permission = self.owner_dict[item].get( Authorization.LIMIT, default ) if permission == []: raise_auth_config_exception("site") if isinstance(permission, str): limits.add(permission) else: limits.update(permission) limits.discard("") return limits def get_access_user_permissions_from_owner_conf( self, access_user_name: str, access_user_groups: List[str] ) -> set: """ Returns set of operations specific to access user from owner user conf. Args: access_user_name: The username of the authenticated user. access_user_groups: All groups the authenticated user belongs to. """ items_to_check = ["*", access_user_name] items_to_check.extend(access_user_groups) allowed_operations = set() for item in items_to_check: permission = self.owner_auth_conf.get(item, "") # Specifiying empty list equates to removing of all permissions. if permission == []: raise_auth_config_exception("user") if isinstance(permission, str): allowed_operations.add(permission) else: allowed_operations.update(permission) allowed_operations.discard("") return allowed_operations # lru_cached - see __init__() def _get_permitted_operations(self, access_user: str): """Return permitted operations for given access_user. This method is cached for efficiency. Checks: - site config to ensure owner is permitted to give away permissions - user config for authorised operations related to access_user and their groups - if user not in user config, then returns defaults from site config. Args: access_user: username to check for permitted operations Returns: Set of operations permitted by given access user for this UI Server """ # users have full access to their own server (ALL) if access_user == self.owner_user_name: return set(self.ALL_OPS) # all groups the authenticated user belongs to access_user_groups = self._get_groups(access_user) # the maximum permissions the site permits the user to grant limits_owner_can_give = self.get_owner_site_limits_for_access_user( access_user, access_user_groups ) # the permissions the user wishes to grant user_conf_permitted_ops = ( self.get_access_user_permissions_from_owner_conf( access_user, access_user_groups ) ) if len(user_conf_permitted_ops) == 0: # the user has not specified the permissions they wish to grant # -> fallback to the site defaults user_conf_permitted_ops = ( self.return_site_auth_defaults_for_access_user( access_user, access_user_groups ) ) # expand permission groups and remove negated permissions user_conf_permitted_ops = self.expand_and_process_access_groups( user_conf_permitted_ops ) limits_owner_can_give = self.expand_and_process_access_groups( limits_owner_can_give ) # subtract permissions that the site does not permit to be granted allowed_operations = limits_owner_can_give.intersection( user_conf_permitted_ops ) self.log.info( f"User {access_user} authorized permissions: " f"{sorted(allowed_operations)}" ) return allowed_operations def is_permitted(self, access_user: str, operation: str) -> bool: """Checks if user is permitted to action operation. Args: access_user: User attempting to action given operation. operation: operation name Returns: True if access_user permitted to action operation, otherwise, False. """ if access_user == self.owner_user_name: return True # convert from GraphQL camel case to Python snake case operation = to_snake_case(operation) if operation in self.get_permitted_operations(access_user): self.log.info(f"{access_user}: authorized to {operation}") return True self.log.info(f"{access_user}: not authorized to {operation}") return False def build_owner_site_auth_conf(self): """Build UI Server owner permissions dictionary. Creates a reduced site auth dictionary for the ui-server owner. """ owner_dict = {} items_to_check = ["*", self.owner_user_name] items_to_check.extend(self.owner_user_groups) # dict containing user info applying to the current ui_server owner for uis_owner_conf, access_user_dict in self.site_auth_config.items(): if uis_owner_conf in items_to_check: # acc_user = access_user for acc_user_conf, acc_user_perms in access_user_dict.items(): existing_user_conf = owner_dict.get(acc_user_conf) if existing_user_conf: # process limits and defaults and update dictionary existing_default = existing_user_conf.get( Authorization.DEFAULT, '' ) existing_limit = existing_user_conf.get( Authorization.LIMIT, existing_default ) new_default = acc_user_perms.get( Authorization.DEFAULT, '' ) new_limit = acc_user_perms.get( Authorization.LIMIT, new_default ) set_defs = set() for conf in [existing_default, new_default]: if isinstance(conf, list): set_defs.update(conf) else: set_defs.add(conf) set_lims = set() for conf in [existing_limit, new_limit]: if isinstance(conf, list): set_lims.update(conf) else: set_lims.add(conf) # update and continue owner_dict[acc_user_conf][Authorization.LIMIT] = list( set_lims ) owner_dict[acc_user_conf][Authorization.DEFAULT] = ( list(set_defs) ) continue owner_dict.update(access_user_dict) # Now we have a reduced site auth dictionary for the current owner return owner_dict def return_site_auth_defaults_for_access_user( self, access_user_name: str, access_user_groups: List[str] ) -> Set: """Return site authorization defaults for given access user. Args: access_user_name: The username of the authenticated user. access_user_groups: All groups the authenticated user belongs to. Returns: The set of default operations permitted. """ defaults: Set[str] = set() if not self.owner_dict: return defaults items_to_check = ["*", access_user_name] items_to_check.extend(access_user_groups) for item in items_to_check: permission: Union[str, List] = "" with suppress(KeyError): permission = self.owner_dict[item].get( Authorization.DEFAULT, "" ) if permission == []: raise_auth_config_exception("site") if isinstance(permission, str): defaults.add(permission) else: defaults.update(permission) defaults.discard("") return defaults def _get_groups(self, user: str) -> List[str]: """Allows get groups to use self.logger if something goes wrong. Added to provide a single interface for get_groups to this class, to avoid having to pass the logger to get_groups (and methods it calls). """ good_groups, bad_groups = get_groups(user) if bad_groups: self.log.warning( f'{user} has the following invalid groups in their profile ' f'{bad_groups} - these groups will be ignored.' ) return good_groups # GraphQL middleware class AuthorizationMiddleware: """Authorization Middleware for authorization checking GraphQL. Mutations are checked against permissions from config files. Raises: web.HTTPError: Unauthorized requests. """ auth = None def resolve(self, next_, root, info, **args): current_user = info.context["current_user"] # We won't be re-checking auth for return variables if len(info.path) > 1: return next_(root, info, **args) op_name = self.get_op_name(info.field_name, info.operation.operation) # It shouldn't get here but worth checking for zero trust if not op_name: self.auth_failed( current_user, op_name, http_code=400, msg="Operation not in schema.", ) try: authorised = self.auth.is_permitted(current_user, op_name) except Exception: # Fail secure authorised = False if not authorised: self.auth_failed(current_user, op_name, http_code=403) if ( info.operation.operation in Authorization.ASYNC_OPS or iscoroutinefunction(next_) ): return self.async_resolve(next_, root, info, **args) return next_(root, info, **args) def auth_failed( self, current_user: str, op_name: str, http_code: int, message: Optional[str] = None, ): """Raise an authorization error. Args: current_user: username accessing operation op_name: operation name http_code: http error code to raise message: Message to log Defaults to None. Raises: web.HTTPError """ log_message = ( f"Authorization failed for {current_user}" f":requested to {op_name}." ) if message: log_message = log_message + " " + message raise web.HTTPError(http_code, reason=message) def get_op_name(self, field_name: str, operation: str) -> Optional[str]: """Returns the operation name required for authorization. Converts queries and subscriptions to read operations. Args: field_name: Field name e.g. play operation: operation type Returns: The operation name. """ if operation in Authorization.READ_AUTH_OPS: return Authorization.READ_OPERATION # convert from GraphQL camel case to Python snake case field_name = to_snake_case(field_name) # Check it is a mutation in our schema if self.auth and field_name in self.auth.ALL_OPS: return field_name return None async def async_resolve(self, next_, root, info, **args): """Return awaited coroutine""" return await next_(root, info, **args)
[docs] def get_groups(username: str) -> Tuple[List[str], List[str]]: """Return a list of system groups for given user. Uses ``os.getgrouplist`` and ``os.NGROUPS_MAX`` to get system groups for a given user. ``grp.getgrgid`` then parses these to return a list of group names. Args: username: username used to check system groups. Returns: System groups for username given """ groupmax = os.NGROUPS_MAX # type: ignore group_ids = os.getgrouplist(username, groupmax) group_ids.remove(groupmax) # turn list of group_ids into group names with group identifier prepended return parse_group_ids(group_ids)
def parse_group_ids(group_ids: List) -> Tuple[List[str], List[str]]: """Returns list of groups in the correct format for authorisation. Args: group_ids: List of users groups, in number format Returns: List of users groups, in id format with group identifier prepended. """ group_list = [] bad_group_list = [] for x in group_ids: try: group_list.append( f"{Authorization.GRP_IDENTIFIER}{grp.getgrgid(x).gr_name}" ) except OverflowError: continue except KeyError: bad_group_list.append(x) return group_list, bad_group_list def get_list_of_mutations(control: bool = False) -> List[str]: """Gets list of mutations""" list_of_mutations = [ attr for attr in dir(UISMutations) if isinstance(getattr(UISMutations, attr), graphene.Field) ] if control: # Broadcast is an ALL mutation list_of_mutations.remove("broadcast") else: # 'read' is used soley for authorization and is not a UISMutation list_of_mutations.append(Authorization.READ_OPERATION) return list_of_mutations def raise_auth_config_exception(config_type: str): """Error raise for empty list in auth config. Args: config_type: Either site or user. """ raise Exception( f'Error in {config_type} config: ' f'`c.CylcUIServer.{config_type}_authorization`. ' f'"[]" is not supported. Use "{Authorization.NOT_ALL}" to remove all' ' permissions.' )