Exemple #1
0
def test_normalize_string_return_false_with_missing_name():
    """
    Does a missing container name raise a value error?
    """
    container_name = ''
    utils = LTIUtils()
    with pytest.raises(ValueError):
        utils.normalize_string(container_name)
def test_normalize_string_with_long_name():
    """
    Does a container name with more than 20 characters get normalized?
    """
    container_name = 'this_is_a_really_long_container_name'
    utils = LTIUtils()
    normalized_container_name = utils.normalize_string(container_name)

    assert len(normalized_container_name) <= 20
def test_normalize_string_with_first_letter_as_alphanumeric():
    """
    Does a container name with start with an alphanumeric character?
    """
    container_name = '___this_is_a_container_name'
    utils = LTIUtils()
    normalized_container_name = utils.normalize_string(container_name)
    regex = re.compile('_.-')
    first_character = normalized_container_name[0]
    assert first_character != regex.search(normalized_container_name)
def test_normalize_string_with_special_characters():
    """
    Does a container name with with special characters get normalized?
    """
    container_name = '#$%_this_is_a_container_name'
    utils = LTIUtils()
    normalized_container_name = utils.normalize_string(container_name)
    regex = re.compile('[@!#$%^&*()<>?/\\|}{~:]')

    assert regex.search(normalized_container_name) is None
Exemple #5
0
async def setup_course_hook(
    authenticator: Authenticator, handler: RequestHandler, authentication: Dict[str, str]
) -> Dict[str, str]:
    """
    Calls the microservice to setup up a new course in case it does not exist.
    The data needed is received from auth_state within authentication object. This
    function assumes that the required k/v's in the auth_state dictionary are available,
    since the Authenticator(s) validates the data beforehand.

    This function requires `Authenticator.enable_auth_state = True` and is intended
    to be used as a post_auth_hook.

    Args:
        authenticator: the JupyterHub Authenticator object
        handler: the JupyterHub handler object
        authentication: the authentication object returned by the
          authenticator class

    Returns:
        authentication (Required): updated authentication object
    """
    lti_utils = LTIUtils()
    jupyterhub_api = JupyterHubAPI()

    # normalize the name and course_id strings in authentication dictionary
    course_id = lti_utils.normalize_string(authentication['auth_state']['course_id'])
    nb_service = NbGraderServiceHelper(course_id)
    username = lti_utils.normalize_string(authentication['name'])
    lms_user_id = authentication['auth_state']['lms_user_id']
    user_role = authentication['auth_state']['user_role']
    # register the user (it doesn't matter if it is a student or instructor) with her/his lms_user_id in nbgrader
    nb_service.add_user_to_nbgrader_gradebook(username, lms_user_id)
    # TODO: verify the logic to simplify groups creation and membership
    if user_is_a_student(user_role):
        # assign the user to 'nbgrader-<course_id>' group in jupyterhub and gradebook
        await jupyterhub_api.add_student_to_jupyterhub_group(course_id, username)
    elif user_is_an_instructor(user_role):
        # assign the user in 'formgrade-<course_id>' group
        await jupyterhub_api.add_instructor_to_jupyterhub_group(course_id, username)
    # launch the new (?) grader-notebook as a service
    setup_response = await register_new_service(org_name=ORG_NAME, course_id=course_id)

    return authentication
Exemple #6
0
    async def authenticate(  # noqa: C901
        self, handler: LTI13LoginHandler, data: Dict[str, str] = None
    ) -> Dict[str, str]:
        """
        Overrides authenticate from base class to handle LTI 1.3 authentication requests.

        Args:
          handler: handler object
          data: authentication dictionary

        Returns:
          Authentication dictionary
        """
        lti_utils = LTIUtils()
        validator = LTI13LaunchValidator()

        # get jwks endpoint and token to use as args to decode jwt. we could pass in
        # self.endpoint directly as arg to jwt_verify_and_decode() but logging the
        self.log.debug('JWKS platform endpoint is %s' % self.endpoint)
        id_token = handler.get_argument('id_token')
        self.log.debug('ID token issued by platform is %s' % id_token)

        # extract claims from jwt (id_token) sent by the platform. as tool use the jwks (public key)
        # to verify the jwt's signature.
        jwt_decoded = await validator.jwt_verify_and_decode(id_token, self.endpoint, False, audience=self.client_id)
        self.log.debug('Decoded JWT is %s' % jwt_decoded)

        if validator.validate_launch_request(jwt_decoded):
            course_id = jwt_decoded['https://purl.imsglobal.org/spec/lti/claim/context']['label']
            course_id = lti_utils.normalize_string(course_id)
            self.log.debug('Normalized course label is %s' % course_id)
            username = ''
            if 'email' in jwt_decoded and jwt_decoded['email']:
                username = lti_utils.email_to_username(jwt_decoded['email'])
            elif 'name' in jwt_decoded and jwt_decoded['name']:
                username = jwt_decoded['name']
            elif 'given_name' in jwt_decoded and jwt_decoded['given_name']:
                username = jwt_decoded['given_name']
            elif 'family_name' in jwt_decoded and jwt_decoded['family_name']:
                username = jwt_decoded['family_name']
            elif (
                'https://purl.imsglobal.org/spec/lti/claim/lis' in jwt_decoded
                and 'person_sourcedid' in jwt_decoded['https://purl.imsglobal.org/spec/lti/claim/lis']
                and jwt_decoded['https://purl.imsglobal.org/spec/lti/claim/lis']['person_sourcedid']
            ):
                username = jwt_decoded['https://purl.imsglobal.org/spec/lti/claim/lis']['person_sourcedid'].lower()
            elif (
                'lms_user_id' in jwt_decoded['https://purl.imsglobal.org/spec/lti/claim/custom']
                and jwt_decoded['https://purl.imsglobal.org/spec/lti/claim/custom']['lms_user_id']
            ):
                username = str(jwt_decoded['https://purl.imsglobal.org/spec/lti/claim/custom']['lms_user_id'])
            self.log.debug('username is %s' % username)
            # ensure the username is normalized
            self.log.debug('username is %s' % username)
            if username == '':
                raise HTTPError('Unable to set the username')

            # set role to learner role (by default) if instructor or learner/student roles aren't
            # sent with the request
            user_role = 'Learner'
            for role in jwt_decoded['https://purl.imsglobal.org/spec/lti/claim/roles']:
                if role.find('Instructor') >= 1:
                    user_role = 'Instructor'
                elif role.find('Learner') >= 1 or role.find('Student') >= 1:
                    user_role = 'Learner'
            self.log.debug('user_role is %s' % user_role)

            launch_return_url = ''
            if (
                'https://purl.imsglobal.org/spec/lti/claim/launch_presentation' in jwt_decoded
                and 'return_url' in jwt_decoded['https://purl.imsglobal.org/spec/lti/claim/launch_presentation']
            ):
                launch_return_url = jwt_decoded['https://purl.imsglobal.org/spec/lti/claim/launch_presentation'][
                    'return_url'
                ]
            # if there is a resource link request then process additional steps
            if not validator.is_deep_link_launch(jwt_decoded):
                await process_resource_link(self.log, course_id, jwt_decoded)

            lms_user_id = jwt_decoded['sub'] if 'sub' in jwt_decoded else username

            # ensure the user name is normalized
            username_normalized = lti_utils.normalize_string(username)
            self.log.debug('Assigned username is: %s' % username_normalized)

            return {
                'name': username_normalized,
                'auth_state': {
                    'course_id': course_id,
                    'user_role': user_role,
                    'lms_user_id': lms_user_id,
                    'launch_return_url': launch_return_url,
                },  # noqa: E231
            }
Exemple #7
0
    async def authenticate(self, handler: BaseHandler, data: Dict[str, str] = None) -> Dict[str, str]:  # noqa: C901
        """
        LTI 1.1 authenticator which overrides authenticate function from base LTIAuthenticator.
        After validating the LTI 1.1 signuature, this function decodes the dictionary object
        from the request payload to set normalized strings for the course_id, username,
        user role, and lms user id. Once those values are set they are added to the auth_state
        and returned as a dictionary for further processing by hooks defined in jupyterhub_config.

        One or more consumer keys/values must be set in the jupyterhub config with the
        LTIAuthenticator.consumers dict.

        Args:
            handler: JupyterHub's Authenticator handler object. For LTI 1.1 requests, the handler is
              an instance of LTIAuthenticateHandler.
            data: optional data object

        Returns:
            Authentication's auth_state dictionary

        Raises:
            HTTPError if the required values are not in the request
        """
        validator = LTI11LaunchValidator(self.consumers)
        lti_utils = LTIUtils()

        self.log.debug('Original arguments received in request: %s' % handler.request.arguments)

        # extract the request arguments to a dict
        args = lti_utils.convert_request_to_dict(handler.request.arguments)
        self.log.debug('Decoded args from request: %s' % args)

        # get the origin protocol
        protocol = lti_utils.get_client_protocol(handler)
        self.log.debug('Origin protocol is: %s' % protocol)

        # build the full launch url value required for oauth1 signatures
        launch_url = f'{protocol}://{handler.request.host}{handler.request.uri}'
        self.log.debug('Launch url is: %s' % launch_url)

        if validator.validate_launch_request(launch_url, handler.request.headers, args):
            # get the lms vendor to implement optional logic for said vendor
            lms_vendor = ''
            if 'tool_consumer_info_product_family_code' in args and args['tool_consumer_info_product_family_code']:
                lms_vendor = args['tool_consumer_info_product_family_code']

            # We use the course_id to setup the grader service notebook. Since this service
            # runs as a container we need to normalize the string so we can use it
            # as a container name.
            if 'context_label' in args and args['context_label']:
                course_id = args['context_label']
                self.log.debug('Course context_label normalized to: %s' % course_id)
            elif 'context_title' in args and args['context_title']:
                course_id = args['context_title']
                self.log.debug('Course context_title normalized to: %s' % course_id)
            else:
                raise HTTPError(400, 'course_label or course_title not included in the LTI request')

            # Get the user's role, assign to Learner role by default. Roles are sent as institution
            # roles, where the roles' value is <handle>,<full URN>.
            # https://www.imsglobal.org/specs/ltiv1p0/implementation-guide#toc-16
            user_role = 'Learner'
            if 'roles' in args and args['roles']:
                user_role = args['roles'].split(',')[0]
                self.log.debug('User LTI role is: %s' % user_role)
            else:
                raise HTTPError(400, 'User role not included in the LTI request')

            # Assign the user_id. Check the tool consumer (lms) vendor. If canvas use their
            # custom user id extension by default, else use standar lti values.
            username = ''
            if lms_vendor == 'canvas':
                login_id = ''
                user_id = ''
                self.log.debug('TC is a Canvas LMS instance')
                if (
                    'custom_canvas_user_login_id' in args
                    and args['custom_canvas_user_login_id']
                    and 'custom_canvas_user_id' in args
                    and args['custom_canvas_user_id']
                ):
                    custom_canvas_user_login_id = args['custom_canvas_user_login_id']
                    login_id = lti_utils.email_to_username(custom_canvas_user_login_id)
                    self.log.debug('using custom_canvas_user_login_id for username')
                if 'custom_canvas_user_id' in args and args['custom_canvas_user_id']:
                    custom_canvas_user_id = args['custom_canvas_user_id']
                    user_id = lti_utils.email_to_username(custom_canvas_user_id)
                    self.log.debug('using custom_canvas_user_id for username')
                username = f'{login_id}-{user_id}'
            if (
                not username
                and 'lis_person_contact_email_primary' in args
                and args['lis_person_contact_email_primary']
            ):
                email = args['lis_person_contact_email_primary']
                username = lti_utils.email_to_username(email)
                self.log.debug('using lis_person_contact_email_primary for username')
            elif not username and 'lis_person_name_given' in args and args['lis_person_name_given']:
                username = args['lis_person_name_given']
                self.log.debug('using lis_person_name_given for username')
            elif not username and 'lis_person_sourcedid' in args and args['lis_person_sourcedid']:
                username = args['lis_person_sourcedid']
                self.log.debug('using lis_person_sourcedid for username')
            elif not username and 'lis_person_name_family' in args and args['lis_person_name_family']:
                username = args['lis_person_name_family']
                self.log.debug('using lis_person_name_family for username')
            elif not username and 'lis_person_name_full' in args and args['lis_person_name_full']:
                username = args['lis_person_name_full']
                self.log.debug('using lis_person_name_full for username')
            elif not username and 'user_id' in args and args['user_id']:
                username = args['user_id']
            elif not username:
                raise HTTPError(400, 'Unable to get username from request arguments')

            # use the user_id to identify the unique user id, if its not sent with the request
            # then default to the username
            lms_user_id = args['user_id'] if 'user_id' in args else username

            # GRADES-SENDER: fetch the information needed to register assignments within the control file
            # retrieve assignment_name from standard property vs custom lms properties
            assignment_name = ''
            # the next fields must come in args
            if 'custom_canvas_assignment_title' in args and args['custom_canvas_assignment_title']:
                assignment_name = lti_utils.normalize_string(args['custom_canvas_assignment_title'])
            # this requires adding a the assignment_title as a custom parameter in the tool consumer (lms)
            elif 'custom_assignment_title' in args and args['custom_assignment_title']:
                assignment_name = lti_utils.normalize_string(args['custom_assignment_title'])
            elif 'resource_link_title' in args and args['resource_link_title']:
                assignment_name = lti_utils.normalize_string(args['resource_link_title'])
            elif 'resource_link_id' in args and args['resource_link_id']:
                assignment_name = lti_utils.normalize_string(args['resource_link_id'])

            # Get lis_outcome_service_url and lis_result_sourcedid values that will help us to submit grades later
            lis_outcome_service_url = ''
            lis_result_sourcedid = ''

            # the next fields must come in args
            if 'lis_outcome_service_url' in args and args['lis_outcome_service_url']:
                lis_outcome_service_url = args['lis_outcome_service_url']
            if 'lis_result_sourcedid' in args and args['lis_result_sourcedid']:
                lis_result_sourcedid = args['lis_result_sourcedid']
            # only if both values exist we can register them to submit grades later
            if lis_outcome_service_url and lis_result_sourcedid:
                control_file = LTIGradesSenderControlFile(f'/home/grader-{course_id}/{course_id}')
                control_file.register_data(assignment_name, lis_outcome_service_url, lms_user_id, lis_result_sourcedid)
            # Assignment creation
            if assignment_name:
                nbgrader_service = NbGraderServiceHelper(course_id, True)
                self.log.debug(
                    'Creating a new assignment from the Authentication flow with title %s' % assignment_name
                )
                nbgrader_service.register_assignment(assignment_name)
            # ensure the user name is normalized
            username_normalized = lti_utils.normalize_string(username)
            self.log.debug('Assigned username is: %s' % username_normalized)

            return {
                'name': username_normalized,
                'auth_state': {
                    'course_id': course_id,
                    'lms_user_id': lms_user_id,
                    'user_role': user_role,
                },  # noqa: E231
            }
Exemple #8
0
async def setup_course_hook_lti11(
    handler: RequestHandler,
    authentication: Dict[str, str],
) -> Dict[str, str]:
    """
    Calls the microservice to setup up a new course in case it does not exist when receiving
    LTI 1.1 launch requests. The data needed is received from auth_state within authentication object.
    This function assumes that the required k/v's in the auth_state dictionary are available,
    since the Authenticator(s) validates the data beforehand.

    This function requires `Authenticator.enable_auth_state = True` and is intended
    to be used as a post_auth_hook.

    Args:
        handler: the JupyterHub handler object
        authentication: the authentication object returned by the
            authenticator class

    Returns:
        authentication (Required): updated authentication object
    """
    lti_utils = LTIUtils()
    jupyterhub_api = JupyterHubAPI()

    # normalize the name and course_id strings in authentication dictionary
    username = authentication["name"]
    lms_user_id = authentication["auth_state"]["user_id"]
    user_role = authentication["auth_state"]["roles"].split(",")[0]
    course_id = lti_utils.normalize_string(
        authentication["auth_state"]["context_label"])
    nb_service = NbGraderServiceHelper(course_id, True)

    # register the user (it doesn't matter if it is a student or instructor) with her/his lms_user_id in nbgrader
    nb_service.add_user_to_nbgrader_gradebook(username, lms_user_id)
    # TODO: verify the logic to simplify groups creation and membership
    if user_is_a_student(user_role):
        try:
            # assign the user to 'nbgrader-<course_id>' group in jupyterhub and gradebook
            await jupyterhub_api.add_student_to_jupyterhub_group(
                course_id, username)
        except AddJupyterHubUserException as e:
            logger.error(
                "An error when adding student username: %s to course_id: %s with exception %s",
                (username, course_id, e),
            )
    elif user_is_an_instructor(user_role):
        try:
            # assign the user in 'formgrade-<course_id>' group
            await jupyterhub_api.add_instructor_to_jupyterhub_group(
                course_id, username)
        except AddJupyterHubUserException as e:
            logger.error(
                "An error when adding instructor username: %s to course_id: %s with exception %s",
                (username, course_id, e),
            )

    # launch the new grader-notebook as a service
    try:
        _ = await register_new_service(org_name=ORG_NAME, course_id=course_id)
    except Exception as e:
        logger.error(
            "Unable to launch the shared grader notebook with exception %s", e)

    return authentication
Exemple #9
0
    async def authenticate(  # noqa: C901
            self,
            handler: LTI13LoginHandler,
            data: Dict[str, str] = None) -> Dict[str, str]:
        """
        Overrides authenticate from base class to handle LTI 1.3 authentication requests.

        Args:
          handler: handler object
          data: authentication dictionary

        Returns:
          Authentication dictionary
        """
        lti_utils = LTIUtils()
        validator = LTI13LaunchValidator()

        # get jwks endpoint and token to use as args to decode jwt. we could pass in
        # self.endpoint directly as arg to jwt_verify_and_decode() but logging the
        self.log.debug("JWKS platform endpoint is %s" % self.endpoint)
        id_token = handler.get_argument("id_token")
        self.log.debug("ID token issued by platform is %s" % id_token)

        # extract claims from jwt (id_token) sent by the platform. as tool use the jwks (public key)
        # to verify the jwt's signature.
        jwt_decoded = await validator.jwt_verify_and_decode(
            id_token, self.endpoint, False, audience=self.client_id)
        self.log.debug("Decoded JWT is %s" % jwt_decoded)

        if validator.validate_launch_request(jwt_decoded):
            course_id = jwt_decoded[
                "https://purl.imsglobal.org/spec/lti/claim/context"]["label"]
            course_id = lti_utils.normalize_string(course_id)
            self.log.debug("Normalized course label is %s" % course_id)
            username = ""
            if "email" in jwt_decoded and jwt_decoded["email"]:
                username = lti_utils.email_to_username(jwt_decoded["email"])
            elif "name" in jwt_decoded and jwt_decoded["name"]:
                username = jwt_decoded["name"]
            elif "given_name" in jwt_decoded and jwt_decoded["given_name"]:
                username = jwt_decoded["given_name"]
            elif "family_name" in jwt_decoded and jwt_decoded["family_name"]:
                username = jwt_decoded["family_name"]
            elif ("https://purl.imsglobal.org/spec/lti/claim/lis"
                  in jwt_decoded and "person_sourcedid" in
                  jwt_decoded["https://purl.imsglobal.org/spec/lti/claim/lis"]
                  and
                  jwt_decoded["https://purl.imsglobal.org/spec/lti/claim/lis"]
                  ["person_sourcedid"]):
                username = jwt_decoded[
                    "https://purl.imsglobal.org/spec/lti/claim/lis"][
                        "person_sourcedid"].lower()
            elif ("lms_user_id" in jwt_decoded[
                    "https://purl.imsglobal.org/spec/lti/claim/custom"]
                  and jwt_decoded[
                      "https://purl.imsglobal.org/spec/lti/claim/custom"]
                  ["lms_user_id"]):
                username = str(jwt_decoded[
                    "https://purl.imsglobal.org/spec/lti/claim/custom"]
                               ["lms_user_id"])

            # set role to learner role (by default) if instructor or learner/student roles aren't
            # sent with the request
            user_role = "Learner"
            for role in jwt_decoded[
                    "https://purl.imsglobal.org/spec/lti/claim/roles"]:
                if role.find("Instructor") >= 1:
                    user_role = "Instructor"
                elif role.find("Learner") >= 1 or role.find("Student") >= 1:
                    user_role = "Learner"
            self.log.debug("user_role is %s" % user_role)

            launch_return_url = ""
            if ("https://purl.imsglobal.org/spec/lti/claim/launch_presentation"
                    in jwt_decoded and "return_url" in jwt_decoded[
                        "https://purl.imsglobal.org/spec/lti/claim/launch_presentation"]
                ):
                launch_return_url = jwt_decoded[
                    "https://purl.imsglobal.org/spec/lti/claim/launch_presentation"][
                        "return_url"]
            # if there is a resource link request then process additional steps
            if not validator.is_deep_link_launch(jwt_decoded):
                await process_resource_link_lti_13(self.log, course_id,
                                                   jwt_decoded)

            lms_user_id = jwt_decoded[
                "sub"] if "sub" in jwt_decoded else username

            # ensure the username is normalized
            self.log.debug("username is %s" % username)
            if not username:
                raise HTTPError(400, "Unable to set the username")

            # ensure the user name is normalized
            username_normalized = lti_utils.normalize_string(username)
            self.log.debug("Assigned username is: %s" % username_normalized)

            return {
                "name": username_normalized,
                "auth_state": {
                    "course_id": course_id,
                    "user_role": user_role,
                    "lms_user_id": lms_user_id,
                    "launch_return_url": launch_return_url,
                },  # noqa: E231
            }
async def setup_course_hook(authenticator: Authenticator,
                            handler: RequestHandler,
                            authentication: Dict[str, str]) -> Dict[str, str]:
    """
    Calls the microservice to setup up a new course in case it does not exist.
    The data needed is received from auth_state within authentication object. This
    function assumes that the required k/v's in the auth_state dictionary are available,
    since the Authenticator(s) validates the data beforehand.

    This function requires `Authenticator.enable_auth_state = True` and is intended
    to be used as a post_auth_hook.

    Args:
        authenticator: the JupyterHub Authenticator object
        handler: the JupyterHub handler object
        authentication: the authentication object returned by the
          authenticator class

    Returns:
        authentication (Required): updated authentication object
    """
    lti_utils = LTIUtils()
    jupyterhub_api = JupyterHubAPI()

    announcement_port = os.environ.get('ANNOUNCEMENT_SERVICE_PORT') or '8889'
    org = os.environ.get('ORGANIZATION_NAME')
    if not org:
        raise EnvironmentError('ORGANIZATION_NAME env-var is not set')
    # normalize the name and course_id strings in authentication dictionary
    course_id = lti_utils.normalize_string(
        authentication['auth_state']['course_id'])
    username = lti_utils.normalize_string(authentication['name'])
    lms_user_id = authentication['auth_state']['lms_user_id']
    user_role = authentication['auth_state']['user_role']
    # TODO: verify the logic to simplify groups creation and membership
    if user_role == 'Student' or user_role == 'Learner':
        # assign the user to 'nbgrader-<course_id>' group in jupyterhub and gradebook
        await jupyterhub_api.add_student_to_jupyterhub_group(
            course_id, username)
        await jupyterhub_api.add_user_to_nbgrader_gradebook(
            course_id, username, lms_user_id)
    elif user_role == 'Instructor':
        # assign the user in 'formgrade-<course_id>' group
        await jupyterhub_api.add_instructor_to_jupyterhub_group(
            course_id, username)
    client = AsyncHTTPClient()
    data = {
        'org': org,
        'course_id': course_id,
        'domain': handler.request.host,
    }
    service_name = os.environ.get(
        'DOCKER_SETUP_COURSE_SERVICE_NAME') or 'setup-course'
    port = os.environ.get('DOCKER_SETUP_COURSE_PORT') or '8000'
    url = f'http://{service_name}:{port}'
    headers = {'Content-Type': 'application/json'}
    response = await client.fetch(url,
                                  headers=headers,
                                  body=json.dumps(data),
                                  method='POST')
    if not response.body:
        raise JSONDecodeError('The setup course response body is empty', '', 0)
    resp_json = json.loads(response.body)
    logger.debug(f'Setup-Course service response: {resp_json}')

    # In case of new courses launched then execute a rolling update with jhub to reload our configuration file
    if 'is_new_setup' in resp_json and resp_json['is_new_setup'] is True:
        # notify the user the browser needs to be reload (when traefik redirects to a new jhub)
        url = f'http://localhost:{int(announcement_port)}/services/announcement'
        jupyterhub_api_token = os.environ.get('JUPYTERHUB_API_TOKEN')
        headers['Authorization'] = f'token {jupyterhub_api_token}'
        body_data = {
            'announcement':
            'A new service was detected, please reload this page...'
        }
        await client.fetch(url,
                           headers=headers,
                           body=json.dumps(body_data),
                           method='POST')

        logger.debug(
            'The current jupyterhub instance will be updated by setup-course service...'
        )
        url = f'http://{service_name}:{port}/rolling-update'
        # our setup-course not requires auth
        del headers['Authorization']
        # WE'RE NOT USING <<<AWAIT>>> because the rolling update should occur later
        client.fetch(url, headers=headers, body='', method='POST')

    return authentication