async def get_role_resource_permissions( request: Request, role: Role, resource: Resource, nocache: bool = False) -> typing.List[int]: """ Get the permission granted on a resource for a specified role. Bypassing the cache can have a significant performance and should only be used in exceptional circumstances. :param request: The request associated with this call :param role: The role :param resource: The resource :param nocache: Bypass the cache if True :return: A list of permission ids """ role_id = role if type(role) is int else Mappings.role_id_for(role) resource_id = resource if type(resource) is int else \ Mappings.resource_id_for(resource) key = bytes(f"{MKP_ROLE_RESOURCE_PERMISSION}:{role_id}:{resource_id}", encoding="utf8") with client_exception_handler(): role_resource_permissions = None if nocache else await request.app[ "redis"].get(key) if not role_resource_permissions: # The API call returns a List[RoleResourcePermission] with client_exception_handler(): role_resource_permissions = await request.app[ "access_control_api"].roleresourcepermission_list( role_id=role_id, resource_id=resource_id) # Internally we use only the permission ids, i.e. List[int] role_resource_permissions = [ entry.permission_id for entry in role_resource_permissions ] with client_exception_handler(): await request.app["redis"].set( key, json.dumps(role_resource_permissions).encode("utf8"), expire=CACHE_TIME) else: role_resource_permissions = json.loads(role_resource_permissions, encoding="utf8") return role_resource_permissions
async def role_has_permission(request: Request, role: Role, permission: Permission, resource: Resource, nocache: bool = False) -> bool: """ Check if a role has a specified resource permission. Bypassing the cache can have a significant performance impact and should only be used in exceptional circumstances. :param request: The request associated with this call :param role: The role to check :param permission: The required permission :param resource: The resource :param nocache: Bypass the cache if True :return: True if the role has the permission on the resource, else False """ result = False permission_id = permission if type(permission) is int else \ Mappings.permission_id_for(permission) permission_ids = await get_role_resource_permissions(request, role, resource, nocache=nocache) if permission_ids: result = permission_id in permission_ids return result
async def test_empty_resource_permissions_lists(self, mocked_function): """ Check the expected behaviour when an empty resource permission list is used with the any or all operation. We mock a response for the get_user_roles_for_site() function in order to test the case where the specified required resource permission list is empty. """ # require_permissions(all, []) always succeeds, regardless of which # roles the user has. @require_permissions(all, [], nocache=True) def empty_all(_request): return True # require_permissions(any, []) always fails, regardless of which roles # the user has. @require_permissions(any, [], nocache=True) def empty_any(_request): return "You must be a tech admin" mocked_function.side_effect = make_coroutine_returning( {0}) # An arbitrary id # Always gets allowed, regardless of the user's roles self.assertTrue(await empty_all(self.mocked_request)) # Never gets allowed unless the user has the TECH_ADMIN role with self.assertRaises(JSONForbidden): await empty_any(self.mocked_request) mocked_function.side_effect = make_coroutine_returning( {Mappings.role_id_for(TECH_ADMIN_ROLE_LABEL)}) self.assertEqual(await empty_any(self.mocked_request), "You must be a tech admin")
def get_user_and_site(request): """ A request contains the JWT token payload from which we get * the user id from the "sub" (subscriber) field * the client id from the "aud" (audience) field :param request: An HttpRequest :return: A (user_id, site_id) tuple. :raises: HTTPForbidden if the token's client id does not map to a site id. """ # Extract the user's UUID from the request user_id = uuid.UUID(request["token"]["sub"]) # Extract the client id from the request client_id = request["token"]["aud"] try: site_id = Mappings.site_id_for_token_client_id(client_id) except KeyError: raise JSONForbidden( message=f"No site linked to the client '{client_id}'") return user_id, site_id
async def get_user_roles_for_site(request: Request, user: UserId, site: SiteId, nocache: bool = False) -> typing.Set[int]: """ Get the roles that a user has for a specified site. Bypassing the cache can have a significant performance and should only be used in exceptional circumstances. :param request: The request associated with this call :param user: The user :param site: The site :param nocache: Bypass the cache if True :return: A set of role ids """ site_id = site if type( site) is int else Mappings.site_id_for_token_client_id(site) user_roles = await get_all_user_roles(request, user, nocache) # Look up the list of role ids associated with the site key. Return an # empty set of it does not exist. return set(user_roles.get(f"s:{site_id}", []))
async def get_user_roles_for_domain(request: Request, user: UserId, domain: Domain, nocache: bool = False) -> typing.Set[int]: """ Get the roles that a user has for a specified domain. Bypassing the cache can have a significant performance and should only be used in exceptional circumstances. :param request: The request associated with this call :param user: The user :param domain: The site :param nocache: Bypass the cache if True :return: A set of role ids """ domain_id = domain if type(domain) is int else Mappings.domain_id_for( domain) user_roles = await get_all_user_roles(request, user, nocache) # Look up the list of role ids associated with the domain key. Return an # empty set of it does not exist. return set(user_roles.get(f"d:{domain_id}", []))
async def wrapped_f(*args, **kwargs): """ :param args: A list of positional arguments :param kwargs: A dictionary of keyword arguments :return: Whatever the wrapped function """ # Extract the request from the function arguments request = get_value_from_args_or_kwargs(request_field, args, kwargs) user_id, token_site_id = get_user_and_site(request) # The decorator need to get the values for the following variables from the # decorated function. role_id = None target_user_id = None target_invitation_id = None domain_id = None site_id = None if body_field is not None: body = get_value_from_args_or_kwargs(body_field, args, kwargs) # When body_field is specified, the xxx_field arguments must be the names of the # fields in the body dictionary or None, in which case the defaults will be used. role_id = body[role_id_field or "role_id"] target_user_id = body.get(target_user_id_field or "user_id") target_invitation_id = body.get(target_invitation_id or "invitation_id") # We expect either a domain_id or a site_id to be provided domain_id = body.get(domain_id_field or "domain_id") site_id = body.get(site_id_field or "site_id") if domain_id is None and site_id is None or \ domain_id is not None and site_id is not None: raise RuntimeError( "Either a domain_id or site_id needs to exist in the body" ) else: # If the body_field is not specified, the rest of the arguments are considered to be # positional arguments. role_id = get_value_from_args_or_kwargs( role_id_field, args, kwargs) if domain_id_field is None and site_id_field is None or \ domain_id_field is not None and site_id_field is not None: raise RuntimeError( "Either a domain_id_field or site_id_field needs to defined" ) if domain_id_field: domain_id = get_value_from_args_or_kwargs( domain_id_field, args, kwargs) else: site_id = get_value_from_args_or_kwargs( site_id_field, args, kwargs) # Extract the user_id or invitation_id for which a role must be a assigned/revoked if target_user_id_field is not None: target_user_id = get_value_from_args_or_kwargs( target_user_id_field, args, kwargs) else: target_invitation_id = get_value_from_args_or_kwargs( target_invitation_id_field, args, kwargs) if domain_id: user_roles = await utils.get_user_roles_for_domain( request, user_id, domain_id, nocache) else: # Use site_id user_roles = await utils.get_user_roles_for_site( request, user_id, site_id, nocache) # If the user roles contains the one we want to assign or Tech Admin, we allow # the call to proceed. if user_roles.intersection( [role_id, Mappings.role_id_for(TECH_ADMIN_ROLE_LABEL)]): if asyncio.iscoroutinefunction(f): return await f(*args, **kwargs) else: return f(*args, **kwargs) if target_user_id_field is not None: log_message = f"User {user_id} cannot assign role {Mappings.role_label_for(role_id)}" \ f" to target user {target_user_id} on " else: log_message = f"User {user_id} cannot assign role {Mappings.role_label_for(role_id)}" \ f" to target invitation {target_invitation_id} on " if domain_id: log_message += f"domain {Mappings.domain_name_for(domain_id)} " else: log_message += f"site {Mappings.site_name_for(site_id)} " log_message += f"because it does not have the role or {TECH_ADMIN_ROLE_LABEL} itself." logger.debug(log_message) raise JSONForbidden(message=log_message)
async def roles_have_permissions(request: Request, roles: typing.Set[Role], operator: Operator, resource_permissions: ResourcePermissions, nocache: bool = False) -> bool: """ Check whether the specified roles has any or all (as per the operator) of the specified resource permissions. It is typically used as part of the user_has_permissions() function as the following simplified pseudo-code shows: user_has_permissions(user, operator, resource_permissions): roles = get_user_roles() return roles_have_permissions(roles, operator, resource_permissions) If any of the roles has the required permission, the requirement is satisfied. Bypassing the cache can have a significant performance impact and should only be used in exceptional circumstances. :param request: The request associated with this call :param roles: A set of role ids :param operator: any or all :param resource_permissions: The resource permissions required :param nocache: Bypass the cache if True :return: True if the user is has the required permissions on the resources """ if operator not in [all, any]: raise RuntimeError("The operator must be all or any") # Normalise to ids roles = { role if type(role) is int else Mappings.role_id_for(role) for role in roles } if Mappings.role_id_for(TECH_ADMIN_ROLE_LABEL) in roles: return True # This code snippet was originally used before the utility functions were changed # into co-routines. It is elegant and efficient and left here for posterity and as # an example of the logic that is performed in the code below, which unfortunately # ended up being more verbose. - cobusc # # return operator( # # Any role can provide the permission. # any(role_has_permission(role, permission, resource, nocache) for role in roles) # for resource, permission in resource_permissions) for resource, permission in resource_permissions: some_role_has_the_permission = False for role in roles: if await role_has_permission(request, role, permission, resource, nocache=nocache): some_role_has_the_permission = True break # No need to look further # Short-circuit conditions if operator is any and some_role_has_the_permission: return True if operator is all and not some_role_has_the_permission: return False # If none of the short-circuit conditions have been met, it means that # all of the checks where True if the operator was all, or none of the checks # were True if the operator is any. return True if operator is all else False
async def auth_middleware(request, handler): authorization_header = request.headers.get("authorization", None) if request.method == "OPTIONS": # HTTP OPTION requests do not need a token pass elif request.path in WHITELIST_URLS: # URLs that do not require authorisation pass elif any( request.path.startswith(prefix) for prefix in WHITELIST_URL_PREFIXES): # URLs that do not require authorisation pass elif authorization_header: if not authorization_header.lower().startswith(TOKEN_PREFIX): return json_response({"message": "Malformed authorization header"}, status=INVALID_TOKEN_STATUS) jwt_token = authorization_header[TOKEN_PREFIX_LENGTH:] try: LOGGER.debug("JWT Token: {}".format(jwt_token.encode("utf-8"))) unverified_headers = jwt.get_unverified_header(jwt_token) algorithm = unverified_headers["alg"] if algorithm == "HS256": payload = jwt.decode( jwt_token, key=settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM], audience=settings.JWT_AUDIENCE, issuer=settings.JWT_ISSUER, options=VERIFICATION_OPTIONS, ) elif algorithm == "RS256": key_id = unverified_headers["kid"] key = Mappings.public_key_for(key_id) payload = jwt.decode( jwt_token, key=key, algorithms=[algorithm], audience=settings.JWT_AUDIENCE, issuer=settings.JWT_ISSUER, options=VERIFICATION_OPTIONS, ) else: return json_response( { "message": "Unsupported token algorithm: {}".format(algorithm) }, status=INVALID_TOKEN_STATUS) LOGGER.debug("Token payload: {}".format(payload)) except jwt.DecodeError as e: return json_response({"message": "Token is invalid: {}".format(e)}, status=INVALID_TOKEN_STATUS) except jwt.ExpiredSignatureError: return json_response({"message": "Token expired"}, status=INVALID_TOKEN_STATUS) except jwt.InvalidAudienceError: return json_response({"message": "Token audience is invalid"}, status=INVALID_TOKEN_STATUS) except jwt.InvalidIssuerError: return json_response({"message": "Token issuer is invalid"}, status=INVALID_TOKEN_STATUS) except Exception as e: return json_response( {"message": "Exception: {} {}".format(type(e), str(e))}, status=INVALID_TOKEN_STATUS) LOGGER.debug("Token payload: {}".format(payload)) request["token"] = payload else: return json_response( {"message": "An authentication token is required"}, status=INVALID_TOKEN_STATUS) return await handler(request)
async def test_roles_have_permissions(self, mocked_roleresourcepermission_list): """ Test that the "roles_have_permissions" function work as intended. :param mocked_roleresourcepermission_list: Function mocked by @patch decorator """ async def dummy_roleresourcepermission_list(role_id, resource_id): responses = { (1, 1): [ RoleResourcePermission( **{ "role_id": role_id, "resource_id": resource_id, "permission_id": TEST_PERMISSION_NAME_TO_ID_MAP["permission1"], "created_at": datetime.now(), "updated_at": datetime.now() }) ] } return responses.get((role_id, resource_id), []) mocked_roleresourcepermission_list.side_effect = \ dummy_roleresourcepermission_list # We test both the cached and non-cached use-cases. It is important # that we test with nocache=True first, since this will not read from # the cache, but populate it for the next iteration where nocache=False. # Test using the 'all' operator for nocache in [True, False]: # Call function using labels self.assertTrue(await utils.roles_have_permissions( self.dummy_request, {"role1", "role2"}, all, [("urn:resource1", "permission1")], nocache)) self.assertFalse(await utils.roles_have_permissions( self.dummy_request, {"role1", "role2"}, all, [("urn:resource1", "permission2")], nocache)) self.assertFalse(await utils.roles_have_permissions( self.dummy_request, {"role1", "role2"}, all, [("urn:resource2", "permission1")], nocache)) # Call function using ids self.assertTrue(await utils.roles_have_permissions( self.dummy_request, { TEST_ROLE_LABEL_TO_ID_MAP["role1"], TEST_ROLE_LABEL_TO_ID_MAP["role2"] }, all, [(TEST_RESOURCE_URN_TO_ID_MAP["urn:resource1"], TEST_PERMISSION_NAME_TO_ID_MAP["permission1"])], nocache)) self.assertFalse(await utils.roles_have_permissions( self.dummy_request, { TEST_ROLE_LABEL_TO_ID_MAP["role1"], TEST_ROLE_LABEL_TO_ID_MAP["role2"] }, all, [(TEST_RESOURCE_URN_TO_ID_MAP["urn:resource1"], TEST_PERMISSION_NAME_TO_ID_MAP["permission2"])], nocache)) self.assertFalse(await utils.roles_have_permissions( self.dummy_request, { TEST_ROLE_LABEL_TO_ID_MAP["role1"], TEST_ROLE_LABEL_TO_ID_MAP["role2"] }, all, [(TEST_RESOURCE_URN_TO_ID_MAP["urn:resource2"], TEST_PERMISSION_NAME_TO_ID_MAP["permission1"])], nocache)) # Test using the 'any' operator for nocache in [True, False]: # Call function using labels self.assertTrue(await utils.roles_have_permissions( self.dummy_request, {"role1", "role2"}, any, [("urn:resource1", "permission1"), ("urn:resource3", "permission3")], nocache)) self.assertFalse(await utils.roles_have_permissions( self.dummy_request, {"role1", "role2"}, any, [("urn:resource3", "permission3"), ("urn:resource4", "permission4")], nocache)) self.assertFalse(await utils.roles_have_permissions( self.dummy_request, {"role1", "role2"}, any, [("urn:resource2", "permission1")], nocache)) # Call function using ids self.assertTrue(await utils.roles_have_permissions( self.dummy_request, { TEST_ROLE_LABEL_TO_ID_MAP["role1"], TEST_ROLE_LABEL_TO_ID_MAP["role2"] }, any, [(TEST_RESOURCE_URN_TO_ID_MAP["urn:resource1"], TEST_PERMISSION_NAME_TO_ID_MAP["permission1"]), (TEST_RESOURCE_URN_TO_ID_MAP["urn:resource3"], TEST_PERMISSION_NAME_TO_ID_MAP["permission3"])], nocache)) self.assertFalse(await utils.roles_have_permissions( self.dummy_request, { TEST_ROLE_LABEL_TO_ID_MAP["role1"], TEST_ROLE_LABEL_TO_ID_MAP["role2"] }, all, [(TEST_RESOURCE_URN_TO_ID_MAP["urn:resource3"], TEST_PERMISSION_NAME_TO_ID_MAP["permission3"]), (TEST_RESOURCE_URN_TO_ID_MAP["urn:resource4"], TEST_PERMISSION_NAME_TO_ID_MAP["permission4"])], nocache)) self.assertFalse(await utils.roles_have_permissions( self.dummy_request, { TEST_ROLE_LABEL_TO_ID_MAP["role1"], TEST_ROLE_LABEL_TO_ID_MAP["role2"] }, all, [(TEST_RESOURCE_URN_TO_ID_MAP["urn:resource1"], TEST_PERMISSION_NAME_TO_ID_MAP["permission1"]), (TEST_RESOURCE_URN_TO_ID_MAP["urn:resource3"], TEST_PERMISSION_NAME_TO_ID_MAP["permission3"])], nocache)) # Check that the TECH_ADMIN role grants permission to everything # for 'all' and 'any' operator. for operator in [all, any]: self.assertTrue(await utils.roles_have_permissions( self.dummy_request, {Mappings.role_id_for(TECH_ADMIN_ROLE_LABEL)}, operator, [(f"urn:resource{i}", f"permission{i}") for i in range(1, 11)], nocache))
async def test_context_header(self, mocked_site_function, mocked_domain_function): """ """ @require_permissions(all, [("urn:resource1", "permission1")], nocache=True) async def somecall(_request): return True mocked_site_function.side_effect = make_coroutine_returning( set()) # No roles mocked_domain_function.side_effect = make_coroutine_returning( set()) # No roles # Invalid value mocked_request = make_mocked_request( "GET", "/", headers={PORTAL_CONTEXT_HEADER: "foo"}) mocked_request["token"] = { "sub": str(self.user), "aud": MANAGEMENT_PORTAL_CLIENT_ID } with self.assertRaises(JSONBadRequest): await somecall(mocked_request) # Invalid value mocked_request = make_mocked_request( "GET", "/", headers={PORTAL_CONTEXT_HEADER: "foo:bar"}) mocked_request["token"] = { "sub": str(self.user), "aud": MANAGEMENT_PORTAL_CLIENT_ID } with self.assertRaises(JSONBadRequest): await somecall(mocked_request) # Invalid value mocked_request = make_mocked_request( "GET", "/", headers={PORTAL_CONTEXT_HEADER: "foo:123"}) mocked_request["token"] = { "sub": str(self.user), "aud": MANAGEMENT_PORTAL_CLIENT_ID } with self.assertRaises(JSONBadRequest): await somecall(mocked_request) # Valid header, but Management Portal is not the caller mocked_request = make_mocked_request( "GET", "/", headers={PORTAL_CONTEXT_HEADER: "d:123"}) mocked_request["token"] = { "sub": str(self.user), "aud": MANAGEMENT_PORTAL_CLIENT_ID + "foo" } with self.assertRaises(JSONForbidden): await somecall(mocked_request) # Valid site but not roles mocked_request = make_mocked_request( "GET", "/", headers={PORTAL_CONTEXT_HEADER: "s:123"}) mocked_request["token"] = { "sub": str(self.user), "aud": MANAGEMENT_PORTAL_CLIENT_ID } with self.assertRaises(JSONForbidden): await somecall(mocked_request) # Valid domain, but no roles mocked_request = make_mocked_request( "GET", "/", headers={PORTAL_CONTEXT_HEADER: "d:123"}) mocked_request["token"] = { "sub": str(self.user), "aud": MANAGEMENT_PORTAL_CLIENT_ID } with self.assertRaises(JSONForbidden): await somecall(mocked_request) # Roles will be returned mocked_site_function.side_effect = make_coroutine_returning( {Mappings.role_id_for(TECH_ADMIN_ROLE_LABEL)}) mocked_domain_function.side_effect = make_coroutine_returning( {Mappings.role_id_for(TECH_ADMIN_ROLE_LABEL)}) # Valid site mocked_request = make_mocked_request( "GET", "/", headers={PORTAL_CONTEXT_HEADER: "s:123"}) mocked_request["token"] = { "sub": str(self.user), "aud": MANAGEMENT_PORTAL_CLIENT_ID } self.assertTrue(await somecall(mocked_request)) # Valid domain, but no roles mocked_request = make_mocked_request( "GET", "/", headers={PORTAL_CONTEXT_HEADER: "d:123"}) mocked_request["token"] = { "sub": str(self.user), "aud": MANAGEMENT_PORTAL_CLIENT_ID } self.assertTrue(await somecall(mocked_request))