diff --git a/cylc/uiserver/app.py b/cylc/uiserver/app.py index 8e9fb00c..a10665c1 100644 --- a/cylc/uiserver/app.py +++ b/cylc/uiserver/app.py @@ -542,15 +542,15 @@ def set_sub_server(self): auth=self.authobj, ) - def set_auth(self): + def set_auth(self) -> Authorization: """Create authorization object. One for the lifetime of the UIServer. """ return Authorization( getpass.getuser(), - self.config.CylcUIServer.user_authorization, - self.config.CylcUIServer.site_authorization, - self.log + self.config.CylcUIServer.user_authorization.to_dict(), + self.config.CylcUIServer.site_authorization.to_dict(), + self.log, ) def initialize_templates(self): diff --git a/cylc/uiserver/authorise.py b/cylc/uiserver/authorise.py index 858ca369..c45a1df5 100644 --- a/cylc/uiserver/authorise.py +++ b/cylc/uiserver/authorise.py @@ -17,19 +17,19 @@ 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 +from typing import List, Optional, Union, Set, Tuple 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 +from graphene.utils.str_converters import to_snake_case + class CylcAuthorizer(Authorizer): """Defines a safe default authorization policy for Jupyter Server. @@ -105,26 +105,24 @@ def is_authorized(self, handler, user, action, resource) -> bool: return False -def constant(func): - """Decorator preventing reassignment""" - - def fset(self, value): - raise TypeError - - def fget(): - return func() +class Authorization: + """Authorization configuration object. - return property(fget, fset) + 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. -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. + 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 @@ -147,7 +145,6 @@ class Authorization: READ_OPERATION = "read" # Access group identifiers (used in config) - READ = "READ" CONTROL = "CONTROL" ALL = "ALL" @@ -156,32 +153,24 @@ class Authorization: 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 + 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 = 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_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 @@ -189,8 +178,17 @@ def __init__(self, owner, owner_auth_conf, site_auth_conf, log) -> None: self._get_permitted_operations ) - @staticmethod - def expand_and_process_access_groups(permission_set: set) -> set: + @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. @@ -200,70 +198,65 @@ def expand_and_process_access_groups(permission_set: set) -> set: permission_set: set of permissions Returns: - permission_set: processed permission set. + processed permission set. + """ + # Expand permission groups + # E.G. ALL -> ["read", "trigger", "broadcast", ...] for action_group, expansion in { - Authorization.CONTROL: Authorization.CONTROL_OPS.fget(), - Authorization.ALL: Authorization.ALL_OPS.fget(), 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 permissions + + # Expand negated permission groups + # E.G. !CONTROL -> ["!trigger", "!stop", "!pause", ...] 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(): + 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 - @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 + return permission_set def get_owner_site_limits_for_access_user( - self, access_user: Dict[str, Union[str, Sequence[Any]]] - ) -> Set: + self, access_user_name: str, access_user_groups: List[str] + ) -> Set[str]: """Returns limits owner can give to given access_user Args: - access_user: Dictionary containing info about access user and their - membership of system groups. + 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["access_username"]] - items_to_check.extend(access_user["access_user_groups"]) + items_to_check = ["*", access_user_name] + items_to_check.extend(access_user_groups) for item in items_to_check: permission: Union[str, List] = "" default = "" @@ -271,7 +264,8 @@ def get_owner_site_limits_for_access_user( default = self.owner_dict[item].get(Authorization.DEFAULT, "") with suppress(KeyError): permission = self.owner_dict[item].get( - Authorization.LIMIT, default) + Authorization.LIMIT, default + ) if permission == []: raise_auth_config_exception("site") if isinstance(permission, str): @@ -282,17 +276,18 @@ def get_owner_site_limits_for_access_user( return limits def get_access_user_permissions_from_owner_conf( - self, access_user: Dict[str, Union[str, Sequence[Any]]] + 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: Dictionary containing info about access user and their - membership of system groups. Defaults to None. + access_user_name: The username of the authenticated user. + access_user_groups: All groups the authenticated user belongs to. + """ - items_to_check = ["*", access_user["access_username"]] - items_to_check.extend(access_user["access_user_groups"]) + 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, "") @@ -310,7 +305,8 @@ def get_access_user_permissions_from_owner_conf( def _get_permitted_operations(self, access_user: str): """Return permitted operations for given access_user. - Cached for efficiency. + 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 @@ -322,39 +318,49 @@ def _get_permitted_operations(self, access_user: str): 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), - } + # 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_dict) + 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_dict) + access_user, access_user_groups + ) ) - # If not explicit permissions for access user in owner conf then revert - # to site defaults + 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_dict + 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)}" @@ -371,27 +377,29 @@ def is_permitted(self, access_user: str, operation: str) -> bool: Returns: True if access_user permitted to action operation, otherwise, False. + """ - if access_user == self.owner_user_info["user"]: + if access_user == self.owner_user_name: return True - # re.sub needed for snake/camel case - if re.sub( - r'(? 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,...]' - } + access_user_name: The username of the authenticated user. + access_user_groups: All groups the authenticated user belongs to. + Returns: - Set of default operations permitted + The 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"]) + 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): @@ -466,7 +478,7 @@ def return_site_auth_defaults_for_access_user( defaults.discard("") return defaults - def _get_groups(self, user: str) -> List: + 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 @@ -489,6 +501,7 @@ class AuthorizationMiddleware: Raises: web.HTTPError: Unauthorized requests. + """ auth = None @@ -502,8 +515,10 @@ def resolve(self, next_, root, info, **args): # 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." + current_user, + op_name, + http_code=400, + msg="Operation not in schema.", ) try: authorised = self.auth.is_permitted(current_user, op_name) @@ -512,15 +527,22 @@ def resolve(self, next_, root, info, **args): 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_)): + 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 + 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 @@ -529,32 +551,39 @@ def auth_failed(self, current_user: str, op_name: str, Raises: web.HTTPError + """ - log_message = (f"Authorization failed for {current_user}" - f":requested to {op_name}.") + 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. + """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: - operation name + The 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'(? Tuple[List[str], List[str]]: - """Return list of system groups for given user. + """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 @@ -573,7 +602,8 @@ def get_groups(username: str) -> Tuple[List[str], List[str]]: username: username used to check system groups. Returns: - list: system groups for username given + System groups for username given + """ groupmax = os.NGROUPS_MAX # type: ignore group_ids = os.getgrouplist(username, groupmax) @@ -589,8 +619,8 @@ def parse_group_ids(group_ids: List) -> Tuple[List[str], List[str]]: group_ids: List of users groups, in number format Returns: - List: List of users groups, in id format with group identifier - prepended. + List of users groups, in id format with group identifier prepended. + """ group_list = [] bad_group_list = [] @@ -609,7 +639,8 @@ def parse_group_ids(group_ids: List) -> Tuple[List[str], List[str]]: def get_list_of_mutations(control: bool = False) -> List[str]: """Gets list of mutations""" list_of_mutations = [ - attr for attr in dir(UISMutations) + attr + for attr in dir(UISMutations) if isinstance(getattr(UISMutations, attr), graphene.Field) ] if control: @@ -626,6 +657,7 @@ def raise_auth_config_exception(config_type: str): Args: config_type: Either site or user. + """ raise Exception( f'Error in {config_type} config: ' diff --git a/cylc/uiserver/tests/test_authorise.py b/cylc/uiserver/tests/test_authorise.py index 7122623d..bb1da22b 100644 --- a/cylc/uiserver/tests/test_authorise.py +++ b/cylc/uiserver/tests/test_authorise.py @@ -13,20 +13,22 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from jupyter_server.extension.application import ExtensionApp +import logging from types import SimpleNamespace from unittest.mock import Mock, patch +from jupyter_server.extension.application import ExtensionApp + import pytest from cylc.uiserver.authorise import ( Authorization, AuthorizationMiddleware, get_list_of_mutations, - parse_group_ids + parse_group_ids, ) -log = ExtensionApp().log +LOG = ExtensionApp().log CONTROL_OPS = [ "clean", @@ -86,9 +88,14 @@ }, }, "server_owner_1": { - "*": {"default": ["READ", "message"], - "limit": ["READ", "CONTROL"]}, - "user1": {"default": ["READ", "play", "pause"], "limit": ["ALL"]}, + "*": { + "default": ["READ", "message"], + "limit": ["READ", "CONTROL"], + }, + "user1": { + "default": ["READ", "play", "pause"], + "limit": ["ALL"], + }, }, "server_owner_2": { "user2": {"limit": "ALL"}, @@ -180,7 +187,7 @@ ["group:group2"], set(), id="owner only in group and *", - ) + ), ], ) @patch("cylc.uiserver.authorise.get_groups") @@ -193,9 +200,7 @@ def test_get_permitted_operations( user_groups, ): mocked_get_groups.side_effect = [(owner_groups, []), (user_groups, [])] - auth_obj = Authorization( - owner_name, FAKE_USER_CONF, FAKE_SITE_CONF, log - ) + auth_obj = Authorization(owner_name, FAKE_USER_CONF, FAKE_SITE_CONF, LOG) actual_operations = auth_obj.get_permitted_operations( access_user=user_name ) @@ -203,14 +208,13 @@ def test_get_permitted_operations( @pytest.mark.parametrize( - "expected_operations, access_user_dict, owner_auth_conf,", + 'expected_operations, access_user_name,' + ' access_user_groups, owner_auth_conf,', [ pytest.param( {"!kill", "READ", "kill", "!stop", "pause", "play"}, - { - "access_username": "access_user_1", - "access_user_groups": ["group:group1", "group:group2"], - }, + "access_user_1", + ["group:group1", "group:group2"], { "*": ["READ", "!kill"], "access_user_1": ["READ", "pause", "kill", "play", "!stop"], @@ -219,10 +223,8 @@ def test_get_permitted_operations( ), pytest.param( {"READ"}, - { - "access_username": "access_user_2", - "access_user_groups": ["group:group1", "group:group2"], - }, + "access_user_2", + ["group:group1", "group:group2"], { "*": ["READ"], "access_user_1": ["READ", "pause", "kill", "play", "!stop"], @@ -231,10 +233,8 @@ def test_get_permitted_operations( ), pytest.param( {"pause", "kill", "!stop", "READ", "CONTROL"}, - { - "access_username": "access_user_1", - "access_user_groups": ["group:group1", "group:group2"], - }, + "access_user_1", + ["group:group1", "group:group2"], { "*": ["READ"], "access_user_1": ["READ", "pause", "kill", "!stop"], @@ -246,15 +246,19 @@ def test_get_permitted_operations( ) @patch("cylc.uiserver.authorise.get_groups") def test_get_access_user_permissions_from_owner_conf( - mocked_get_groups, expected_operations, access_user_dict, owner_auth_conf + mocked_get_groups, + expected_operations, + access_user_name, + access_user_groups, + owner_auth_conf, ): """Test the un-processed permissions of owner conf.""" mocked_get_groups.return_value = (["group:blah"], []) authobj = Authorization( - "some_user", owner_auth_conf, {"fake": "config"}, log + "some_user", owner_auth_conf, {"fake": "config"}, LOG ) permitted_operations = authobj.get_access_user_permissions_from_owner_conf( - access_user_dict + access_user_name, access_user_groups ) assert permitted_operations == expected_operations @@ -284,7 +288,7 @@ def test_expand_and_process_access_groups(permission_set, expected): "some_user", {"fake": "config"}, {"fake": "config"}, - log + LOG, ) actual = authobj.expand_and_process_access_groups(permission_set) assert actual == expected @@ -321,8 +325,7 @@ def test_expand_and_process_access_groups(permission_set, expected): ) def test_get_op_name(mut_field_name, operation, expected_op_name): mock_authobj = Authorization( - "some_user", {"fake": "config"}, - {"fake": "config"}, log + "some_user", {"fake": "config"}, {"fake": "config"}, LOG ) auth_middleware = AuthorizationMiddleware auth_middleware.auth = mock_authobj @@ -336,11 +339,7 @@ def test_get_op_name(mut_field_name, operation, expected_op_name): "owner_name, user_name, get_permitted_operations_is_called, expected", [ pytest.param( - "mel", - "mel", - False, - True, - id="Owner user always permitted" + "mel", "mel", False, True, id="Owner user always permitted" ), pytest.param( "mel", @@ -360,7 +359,7 @@ def test_is_permitted( expected, ): mocked_get_groups.side_effect = [([""], []), ([""], [])] - auth_obj = Authorization(owner_name, FAKE_USER_CONF, FAKE_SITE_CONF, log) + auth_obj = Authorization(owner_name, FAKE_USER_CONF, FAKE_SITE_CONF, LOG) auth_obj.get_permitted_operations = Mock(return_value=["fake_operation"]) actual = auth_obj.is_permitted( access_user=user_name, operation="fake_operation" @@ -383,17 +382,9 @@ def test_get_list_of_mutations(control, expected): assert set(actual) == set(expected) -@pytest.mark.parametrize( - 'input_', - ( - [123], - [123, 456], - [100, 123] - ) -) +@pytest.mark.parametrize('input_', ([123], [123, 456], [100, 123])) def test_parse_group_ids(monkeypatch, input_): - """Returns a list of group ids or groups where ID's haven't worked - """ + """Returns a list of group ids or groups where ID's haven't worked""" mock_grid_db = { 123: 'foo', 456: 'bar', @@ -403,11 +394,85 @@ def test_parse_group_ids(monkeypatch, input_): ) result = parse_group_ids(input_) assert result == ( - [ - f'group:{mock_grid_db[i]}' - for i in input_ if i in mock_grid_db - ], - [ - i for i in input_ if i not in mock_grid_db - ], - ) + [f'group:{mock_grid_db[i]}' for i in input_ if i in mock_grid_db], + [i for i in input_ if i not in mock_grid_db], + ) + + +def test_empty_configs(): + """Test the default permissions when no auth config is provided.""" + # blank site & user configs + auth_obj = Authorization('me', {}, {}, LOG) + # the owner_dict should be empty + assert auth_obj.owner_dict == {} + + # other users should not have any permissions + assert auth_obj._get_permitted_operations('someone-else') == set() + + # the owner should always have full permissions + assert auth_obj._get_permitted_operations('me') == set(ALL_OPS) + + +def test_case_conversion(caplog): + """Test camel to snake case conversion of permission names.""" + my_log = logging.getLogger('test_case_conversion') + caplog.set_level(logging.INFO, logger=my_log.name) + + auth_obj = Authorization( + 'me', + {'other': ['CONTROL']}, + {'*': {'*': {'limit': ['ALL']}}}, + my_log, + ) + + # internal use case (perm provided in Python syntax) + assert auth_obj.is_permitted('other', 'release_hold_point') + assert auth_obj.is_permitted('other', 'set_graph_window_extent') + + # external use case (perm provided in GraphQL syntax) + assert auth_obj.is_permitted('other', 'ReleaseHoldPoint') + assert auth_obj.is_permitted('other', 'SetGraphWindowExtent') + + # invalid permission names + assert not auth_obj.is_permitted('other', 'no_such_permission') + assert not auth_obj.is_permitted('other', 'RELeaseHOLdPoint') + assert not auth_obj.is_permitted('other', 'SEtGRAPhWINDOwEXTENt') + assert not auth_obj.is_permitted('other', 'Release_Hold_Point') + + # calls should be logged + # (successfull call) + caplog.clear() + assert auth_obj.is_permitted('other', 'release_hold_point') + assert caplog.messages[-1] == 'other: authorized to release_hold_point' + + # (rejected call) + caplog.clear() + assert not auth_obj.is_permitted('other', 'broadcast') + assert caplog.messages[-1] == 'other: not authorized to broadcast' + + +def test_empty_list_in_config(): + """Test empty lists cause exceptions to be raised. + + Empty lists have an unclear meaning due to the inheritance of default + permissions so are not permitted. + + Note: + It would be nicer to validate this out at startup than forcing the + error at runtime. + """ + auth_obj = Authorization('me', {'other': []}, {}, LOG) + with pytest.raises(Exception, match='Error in user config'): + auth_obj.is_permitted('other', 'read') + + auth_obj = Authorization('me', {}, {'*': {'other': {'limit': []}}}, LOG) + with pytest.raises(Exception, match='Error in site config'): + auth_obj.is_permitted('other', 'read') + + auth_obj = Authorization('me', {}, {'*': {'other': {'default': []}}}, LOG) + with pytest.raises(Exception, match='Error in site config'): + auth_obj.is_permitted('other', 'read') + + auth_obj = Authorization('me', {}, {'*': {'me': {'default': []}}}, LOG) + with pytest.raises(Exception, match='Error in site config'): + auth_obj.return_site_auth_defaults_for_access_user('me', ['x'])