Example #1
0
def add_action_routes_to_blueprint(
    blueprint: flask.Blueprint,
    client_id: str,
    client_secret: str,
    client_name: Optional[str],
    provider_description: ActionProviderDescription,
    action_run_callback: ActionRunType,
    action_status_callback: ActionStatusType,
    action_cancel_callback: ActionCancelType,
    action_release_callback: ActionReleaseType,
    action_log_callback: Optional[ActionLogType] = None,
    additional_scopes: Optional[List[str]] = None,
    action_enumeration_callback: ActionEnumerationType = None,
) -> None:
    """Add routes to a Flask Blueprint to implement the required operations of the Action
    Provider Interface: Introspect, Run, Status, Cancel and Release. The route handlers
    added to the blueprint perform basic functionality such as input validation and
    authorization checks where appropriate, and use the provided callbacks to implement
    the action provider specific functionality. See description of each callback below
    for a description of functionality performed prior to invoking the callback.

    **Parameters**

    ``blueprint`` (*Flask.Blueprint*) 
    A flask blueprint to which routes for the URL paths '/', '/run', '/status',
    '/cancel', and '/release' will be added. Optionally, (see below) '/log' will be added
    as well. The blueprint should define a ``url_prefix`` to define a root to the paths
    where these new paths will be added. In addition to the new URL paths, the blueprint
    will also have a custom JSONEncoder associated with it to aid in the serialization of
    data-types associated with these operations.

    ``client_id`` (*string*)
    A Globus Auth registered ``client_id`` which will be used when validating input
    request tokens.

    ``client_secret`` (*string*)
    A Globus Auth generated ``client_secret`` which will be used when validating input
    request tokens.

    ``client_name`` (*string*) Most commonly, this will be a None value. In the rare,
    legacy case where a name has been associated with a client_id, it can be provided
    here. If you are not aware of a name associated with your client_id, it most likely
    doesn't have one and the value should be None. This will be passed to the
    (:class:`TokenChecker<globus_action_provider_tools.authentication>`) as the
    `expected_audience`.

    ``provider_description`` (:class:`ActionProviderDescription\
    <globus_action_provider_tools.data_types>`)
    A structure describing the provider to be returned by the provider introspection
    operation (`GET /`). Some fields are also used in processing requests: the
    `input_schema` field is used to validate the `body` of incoming action requests on
    the `/run` operation. The `globus_auth_scope` value is used to validate the incoming
    tokens on all requests. The `visible_to` and `runnable_by` lists are used to
    authorization operations on the introspect (GET '/') and run (POST '/run') operations
    respectively. The `log_supported` field should be `True` only if the
    `action_log_callback` parameter is provided a value.

    ``action_run_callback`` (* Callable[[ActionRequest, AuthState], Union[ActionStatus,
    Tuple[ActionStatus, int]]] *)
    A function which will be called when an action /run invocation is called. Prior to
    invoking the callback, the handler will validate the input conforms to the Action
    Provider defined request format *and* that the input `body` matches the
    `input_schema` defined in the `provider_description`. It will also authorize the
    caller against the `runnable_by` property of the `provider_description`. In the case
    of any validation or authorization errors, the corresponding werkzeug defined
    exception will be raised. When validation and authorization succeed, the callback
    will be invoked providing the `ActionRequest` structure corresponding to the request
    and the authorization state (`AuthState`) of the caller. The callback should return
    an `ActionStatus` value to be returned on the invocation. Optionally, a status
    integer can be added to the return (making the return a (ActionStatus, int) tuple)
    which defines the HTTP status code to be returned. This is useful in the case where
    an existing request with the same id and body are seen which should return a 200 HTTP
    status rather than the normal 201 HTTP status (which is the default when the status
    code is not returned).

    """
    if additional_scopes:
        all_accepted_scopes = additional_scopes + [
            provider_description.globus_auth_scope
        ]
    else:
        all_accepted_scopes = [provider_description.globus_auth_scope]

    checker = TokenChecker(
        client_id=client_id,
        client_secret=client_secret,
        expected_scopes=all_accepted_scopes,
        expected_audience=client_name,
    )

    blueprint.json_encoder = ActionProviderJsonEncoder
    input_body_validator = get_input_body_validator(provider_description)
    blueprint.register_error_handler(Exception, blueprint_error_handler)

    @blueprint.route("/", methods=["GET"], strict_slashes=False)
    def action_introspect() -> ViewReturn:
        auth_state = check_token(request, checker)
        if not auth_state.check_authorization(
            provider_description.visible_to,
            allow_public=True,
            allow_all_authenticated_users=True,
        ):
            raise ActionNotFound
        return jsonify(provider_description), 200

    @blueprint.route("/actions", methods=["POST"])
    @blueprint.route("/run", methods=["POST"])
    def action_run() -> ViewReturn:
        auth_state = check_token(request, checker)
        if not auth_state.check_authorization(
            provider_description.runnable_by, allow_all_authenticated_users=True
        ):
            log.info(f"Unauthorized call to action run, errors: {auth_state.errors}")
            raise UnauthorizedRequest
        if blueprint.url_prefix:
            request.path = request.path.lstrip(blueprint.url_prefix)
            if request.url_rule is not None:
                request.url_rule.rule = request.url_rule.rule.lstrip(
                    blueprint.url_prefix
                )

        action_request = validate_input(
            request.get_json(force=True), input_body_validator
        )

        # It's possible the user will attempt to make a malformed ActionStatus -
        # pydantic won't like that. So log and handle the error with a 500
        try:
            status = action_run_callback(action_request, auth_state)
        except ValidationError as ve:
            log.error(
                f"ActionProvider attempted to create a non-conformant ActionStatus"
                f" in {action_run_callback.__name__}: {ve.errors()}"
            )
            raise ActionProviderError

        return action_status_return_to_view_return(status, 202)

    @blueprint.route("/<string:action_id>/status", methods=["GET"])
    @blueprint.route("/actions/<string:action_id>", methods=["GET"])
    def action_status(action_id: str) -> ViewReturn:
        auth_state = check_token(request, checker)
        try:
            status = action_status_callback(action_id, auth_state)
        except ValidationError as ve:
            log.error(
                f"ActionProvider attempted to create a non-conformant ActionStatus"
                f" in {action_status_callback.__name__}: {ve.errors()}"
            )
            raise ActionProviderError
        return action_status_return_to_view_return(status, 200)

    @blueprint.route("/<string:action_id>/cancel", methods=["POST"])
    @blueprint.route("/actions/<string:action_id>/cancel", methods=["POST"])
    def action_cancel(action_id: str) -> ViewReturn:
        auth_state = check_token(request, checker)
        try:
            status = action_cancel_callback(action_id, auth_state)
        except ValidationError as ve:
            log.error(
                f"ActionProvider attempted to create a non-conformant ActionStatus"
                f" in {action_cancel_callback.__name__}: {ve.errors()}"
            )
            raise ActionProviderError
        return action_status_return_to_view_return(status, 200)

    @blueprint.route("/<string:action_id>/release", methods=["POST"])
    @blueprint.route("/actions/<string:action_id>", methods=["DELETE"])
    def action_release(action_id: str) -> ViewReturn:
        auth_state = check_token(request, checker)
        try:
            status = action_release_callback(action_id, auth_state)
        except ValidationError as ve:
            log.error(
                f"ActionProvider attempted to create a non-conformant ActionStatus"
                f" in {action_release_callback.__name__}: {ve.errors()}"
            )
            raise ActionProviderError
        return action_status_return_to_view_return(status, 200)

    if action_log_callback is not None:

        @blueprint.route("/actions/<string:action_id>/log", methods=["GET"])
        @blueprint.route("/<string:action_id>/log", methods=["GET"])
        def action_log(action_id: str) -> ViewReturn:
            auth_state = check_token(request, checker)
            return jsonify({"log": "message"}), 200

    if action_enumeration_callback is not None:

        @blueprint.route("/actions", methods=["GET"])
        def action_enumeration():
            auth_state = check_token(request, checker)

            valid_statuses = set(e.name.casefold() for e in ActionStatusValue)
            statuses = parse_query_args(
                request,
                arg_name="status",
                default_value="active",
                valid_vals=valid_statuses,
            )
            statuses = query_args_to_enum(statuses, ActionStatusValue)
            roles = parse_query_args(
                request,
                arg_name="roles",
                default_value="creator_id",
                valid_vals={"creator_id", "monitor_by", "manage_by"},
            )
            query_params = {"statuses": statuses, "roles": roles}
            return jsonify(action_enumeration_callback(auth_state, query_params)), 200
Example #2
0
    def __init__(
        self,
        provider_description: ActionProviderDescription,
        *args,
        globus_auth_client_name: t.Optional[str] = None,
        additional_scopes: t.Iterable[str] = (),
        **kwarg,
    ):
        """Create a new ActionProviderBlueprint. All arguments not listed here are the
        same as a Flask Blueprint.

        :param provider_description: A Provider Description which will be
        returned from introspection calls to this Blueprint.

        :param globus_auth_client_name: The name of the Globus Auth Client (also
        known as the resource server name). This will be used to validate the
        intended audience for tokens passed to the operations on this
        Blueprint. By default, the client id will be used for checkign audience,
        and unless the client has explicitly been given a resource server name
        in Globus Auth, this will be proper behavior.

        :param additional_scopes: Additional scope strings the Action Provider
        should allow scopes in addition to the one specified by the
        ``globus_auth_scope`` value of the input provider description. Only
        needed if more than one scope has been allocated for the Action
        Provider's Globus Auth client_id.
        """

        super().__init__(*args, **kwarg)

        self.action_loader_plugin: t.Optional[ActionLoaderType] = None
        self.action_saver_plugin: t.Optional[ActionSaverType] = None

        self.provider_description = provider_description
        self.input_body_validator = get_input_body_validator(provider_description)
        self.globus_auth_client_name = globus_auth_client_name
        self.additional_scopes = additional_scopes

        self.json_encoder = ActionProviderJsonEncoder
        self.before_request(self._check_token)
        self.register_error_handler(Exception, blueprint_error_handler)
        self.record_once(self._create_token_checker)

        self.add_url_rule(
            "/",
            "action_introspect",
            self._action_introspect,
            methods=["GET"],
            strict_slashes=False,
        )

        # If using an action-loader, it's possible that the status and cancel
        # endpoints do not need to be implemented. Therefore, we initialize the
        # API route for those operations with "auto" functions that
        # Old-style AP API endpoints
        self.add_url_rule(
            "/<string:action_id>/status",
            None,
            self._auto_action_status,
            methods=["GET"],
        )
        self.add_url_rule(
            "/<string:action_id>/cancel",
            None,
            self._auto_action_cancel,
            methods=["POST"],
        )
        # Add new-style AP API endpoints
        self.add_url_rule(
            "/actions/<string:action_id>",
            "action_status",
            self._auto_action_status,
            methods=["GET"],
        )
        self.add_url_rule(
            "/actions/<string:action_id>/cancel",
            "action_cancel",
            self._auto_action_cancel,
            methods=["POST"],
        )
Example #3
0
def test_validating_action_request():
    ap_description.input_schema = {}
    validator = get_input_body_validator(ap_description)
    validate_input({"request_id": 100, "body": {}}, validator)
Example #4
0
def test_validating_malformed_action_request():
    ap_description.input_schema = json.dumps(action_provider_json_input_schema)
    validator = get_input_body_validator(ap_description)
    with pytest.raises(BadActionRequest):
        validate_input({}, validator)
Example #5
0
def test_exception_on_invalid_input_schema():
    ap_description.input_schema = None

    with pytest.raises(ActionProviderError):
        validator = get_input_body_validator(ap_description)
Example #6
0
def test_can_get_validator_for_pydantic_input_schema():
    ap_description.input_schema = ActionProviderPydanticInputSchema
    validator = get_input_body_validator(ap_description)
    assert validator is not None
    assert validator.func is pydantic_input_validation
Example #7
0
def test_can_get_validator_for_dict_input_schema():
    ap_description.input_schema = action_provider_json_input_schema
    validator = get_input_body_validator(ap_description)
    assert validator is not None
    assert validator.func is json_schema_input_validation