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 typing import List, Dict, Optional, Union, Any, Sequence, Set, Tuple
from inspect import iscoroutinefunction
import os
import re

import graphene
from jupyter_server.auth import Authorizer
from tornado import web
from traitlets.config.loader import LazyConfigValue

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


[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
def constant(func): """Decorator preventing reassignment""" def fset(self, value): raise TypeError def fget(): return func() return property(fget, fset) class Authorization: """Authorization Information Class One instance for the life of the UI Server. If authorization settings change they will need to re-start the UI Server. Authorization has access groups: `READ`, `CONTROL`, `ALL` - along with their negations, `!READ`, `!CONTROL` and `!ALL` which indicate removal of the permission groups. """ # 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"} @staticmethod @constant def ALL_OPS() -> List[str]: """ALL OPS constant, returns list of all mutations.""" return get_list_of_mutations() @staticmethod @constant def CONTROL_OPS() -> List[str]: """CONTROL OPS constant, returns list of all control mutations.""" return get_list_of_mutations(control=True) def __init__(self, owner, owner_auth_conf, site_auth_conf, log) -> None: self.owner = owner self.log = log self.owner_auth_conf = self.set_auth_conf(owner_auth_conf) self.site_auth_config = self.set_auth_conf(site_auth_conf) self.owner_user_info = { "user": self.owner, "user_groups": self._get_groups(self.owner), } 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 ) @staticmethod def expand_and_process_access_groups(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: permission_set: processed permission set. """ for action_group, expansion in { Authorization.CONTROL: Authorization.CONTROL_OPS.fget(), Authorization.ALL: Authorization.ALL_OPS.fget(), Authorization.READ: Authorization.READ_OPS, }.items(): if action_group in permission_set: permission_set.remove(action_group) permission_set.update(expansion) # Expand negated permissions for action_group, expansion in { Authorization.NOT_CONTROL: [ f"!{x}" for x in Authorization.CONTROL_OPS.fget()], Authorization.NOT_ALL: [ f"!{x}" for x in Authorization.ALL_OPS.fget()], Authorization.NOT_READ: [ f"!{x}" for x in Authorization.READ_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 @staticmethod def set_auth_conf(auth_conf: Union[LazyConfigValue, dict]) -> dict: """Resolve lazy config where empty Args: auth_conf: Authorization configuration from a jupyter_config.py Returns: Valid configuration dictionary """ if isinstance(auth_conf, LazyConfigValue): return auth_conf.to_dict() return auth_conf def get_owner_site_limits_for_access_user( self, access_user: Dict[str, Union[str, Sequence[Any]]] ) -> Set: """Returns limits owner can give to given access_user Args: access_user: Dictionary containing info about access user and their membership of system groups. 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["access_username"]] items_to_check.extend(access_user["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: Dict[str, Union[str, Sequence[Any]]] ) -> set: """ Returns set of operations specific to access user from owner user conf. Args: access_user: Dictionary containing info about access user and their membership of system groups. Defaults to None. """ items_to_check = ["*", access_user["access_username"]] items_to_check.extend(access_user["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. 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 """ # For use in the ui, owner permissions (ALL operations) are set if access_user == self.owner: return set(Authorization.ALL_OPS.fget()) # Otherwise process permissions for (non-uiserver owner) access_user access_user_dict = { "access_username": access_user, "access_user_groups": self._get_groups(access_user), } limits_owner_can_give = self.get_owner_site_limits_for_access_user( access_user=access_user_dict) user_conf_permitted_ops = ( self.get_access_user_permissions_from_owner_conf( access_user=access_user_dict) ) # If not explicit permissions for access user in owner conf then revert # to site defaults if len(user_conf_permitted_ops) == 0: user_conf_permitted_ops = ( self.return_site_auth_defaults_for_access_user( access_user=access_user_dict ) ) 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 ) 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_info["user"]: return True # re.sub needed for snake/camel case if re.sub( r'(?<!^)(?=[A-Z])', '_', operation ).lower() 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_info["user"]] items_to_check.extend(self.owner_user_info["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: Dict[str, Union[str, Sequence[Any]]] ) -> Set: """Return site authorization defaults for given access user. Args: access_user: access_user dictionary, in the form {'access_username': username 'access_user_group: [group1, group2,...]' } Returns: Set of default operations permitted """ defaults: Set[str] = set() if not self.owner_dict: return defaults items_to_check = ["*", access_user["access_username"]] items_to_check.extend(access_user["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: """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 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 operation name required for authorization. Converts queries and subscriptions to read operations. Args: field_name: Field name e.g. play operation: operation type Returns: operation name """ if operation in Authorization.READ_AUTH_OPS: return Authorization.READ_OPERATION else: # Check it is a mutation in our schema if self.auth and re.sub( r'(?<!^)(?=[A-Z])', '_', field_name ).lower() in Authorization.ALL_OPS.fget(): 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 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: list: 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: 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.' )