From 9acd2954bb1229398e24c26bcd045ca2c68f6094 Mon Sep 17 00:00:00 2001 From: jasquat <2487833+jasquat@users.noreply.github.com> Date: Thu, 14 Mar 2024 19:55:37 +0000 Subject: [PATCH] Unauthed endpoint support (#1210) * some basic updates for unauthed endpoints and the start of a test w/ burnettk * added logic to create public access token if appropriate w/ burnettk * updated message_form_show to return the rjs form w/ burnettk * pyl w/ burnettk * WIP: adding public routes to frontend w/ burnettk * added public message form page to start a process instance w/ burnettk * added api endpoint to submit message task data w/ burnettk * allow switching rjsf themes in customform w/ burnettk * we can submit a public message form w/ burnettk * add message start submit to public exclusion list w/ burnettk * run message submit in synchronous mode w/ burnettk * a little refactoring to get ready for submitting unauthed tasks w/ burnettk * created public controller w/ burnettk * added api endpoint to submit additional public forms w/ burnettk * added ability to submit a second form from the public web ui w/ burnettk * some clean up and show markdown confirmation messages w/ burnettk * added support for instructions and added a logout page for public users w/ burnettk * support instructions for end user on the start message event as well w/ burnettk * minor tweaks to public logout page w/ burnettk * pyl w/ burnettk * log unsupported form in custom form w/ burnettk --------- Co-authored-by: jasquat --- .../src/spiffworkflow_backend/api.yml | 137 ++++++++++- .../spiffworkflow_backend/config/default.py | 1 + .../config/permissions/local_development.yml | 7 + .../models/human_task.py | 2 +- .../models/message_instance.py | 7 + .../models/process_instance.py | 12 +- .../src/spiffworkflow_backend/models/user.py | 45 ++++ .../routes/authentication_controller.py | 44 +++- .../routes/messages_controller.py | 40 +--- .../routes/process_api_blueprint.py | 200 ++++++++++++++++ .../routes/public_controller.py | 172 +++++++++++++ .../routes/tasks_controller.py | 226 +----------------- .../services/authorization_service.py | 101 ++++---- .../services/jinja_service.py | 4 +- .../services/message_service.py | 147 +++++++++--- .../services/task_service.py | 13 + .../services/user_service.py | 9 + .../entry-form-schema.json | 11 + .../entry-form-uischema.json | 1 + .../message-start-event-with-form.bpmn | 68 ++++++ .../process_model.json | 9 + .../admin-task-exampledata.json | 4 + .../admin-task-schema.json | 15 ++ .../admin-task-uischema.json | 10 + .../entry-form-schema.json | 11 + .../entry-form-uischema.json | 1 + ...ssage-start-event-with-multiple-forms.bpmn | 122 ++++++++++ .../process_model.json | 9 + .../second-form-exampledata.json | 5 + .../second-form-schema.json | 11 + .../second-form-uischema.json | 5 + .../helpers/base_test.py | 4 +- .../integration/test_authentication.py | 49 ++++ .../integration/test_authorization.py | 157 ------------ .../integration/test_public_controller.py | 163 +++++++++++++ spiffworkflow-frontend/src/App.tsx | 40 +++- .../src/ContainerForExtensions.tsx | 57 ++--- .../src/components/CustomForm.tsx | 53 ++-- spiffworkflow-frontend/src/helpers.tsx | 2 + spiffworkflow-frontend/src/index.css | 4 +- spiffworkflow-frontend/src/interfaces.ts | 12 + .../src/routes/BaseRoutes.tsx | 2 +- .../src/routes/PublicRoutes.tsx | 18 ++ .../routes/public/MessageStartEventForm.tsx | 127 ++++++++++ .../src/routes/public/SignOut.tsx | 26 ++ .../src/services/HttpService.ts | 2 + .../src/services/UserService.ts | 42 ++-- 47 files changed, 1611 insertions(+), 596 deletions(-) create mode 100644 spiffworkflow-backend/src/spiffworkflow_backend/routes/public_controller.py create mode 100644 spiffworkflow-backend/tests/data/message-start-event-with-form/entry-form-schema.json create mode 100644 spiffworkflow-backend/tests/data/message-start-event-with-form/entry-form-uischema.json create mode 100644 spiffworkflow-backend/tests/data/message-start-event-with-form/message-start-event-with-form.bpmn create mode 100644 spiffworkflow-backend/tests/data/message-start-event-with-form/process_model.json create mode 100644 spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/admin-task-exampledata.json create mode 100644 spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/admin-task-schema.json create mode 100644 spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/admin-task-uischema.json create mode 100644 spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/entry-form-schema.json create mode 100644 spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/entry-form-uischema.json create mode 100644 spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/message-start-event-with-multiple-forms.bpmn create mode 100644 spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/process_model.json create mode 100644 spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/second-form-exampledata.json create mode 100644 spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/second-form-schema.json create mode 100644 spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/second-form-uischema.json delete mode 100644 spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_authorization.py create mode 100644 spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_public_controller.py create mode 100644 spiffworkflow-frontend/src/routes/PublicRoutes.tsx create mode 100644 spiffworkflow-frontend/src/routes/public/MessageStartEventForm.tsx create mode 100644 spiffworkflow-frontend/src/routes/public/SignOut.tsx diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml index 84df4260..8ea813be 100755 --- a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml @@ -91,6 +91,11 @@ paths: required: true schema: type: string + - name: backend_only + in: query + required: false + schema: + type: boolean get: operationId: spiffworkflow_backend.routes.authentication_controller.logout summary: Logout authenticated user @@ -909,11 +914,6 @@ paths: get: operationId: spiffworkflow_backend.routes.extensions_controller.extension_list summary: Returns the list of available extensions - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/AwesomeUnspecifiedPayload" tags: - Extensions responses: @@ -2530,12 +2530,12 @@ paths: schema: $ref: "#/components/schemas/Workflow" - /messages/{message_name}: + /messages/{modified_message_name}: parameters: - - name: message_name + - name: modified_message_name in: path required: true - description: The unique name of the message. + description: The message_name, modified to replace slashes (/) with colons schema: type: string - name: execution_mode @@ -2556,14 +2556,125 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/Workflow" + $ref: "#/components/schemas/AwesomeUnspecifiedPayload" responses: "200": description: One task content: application/json: schema: - $ref: "#/components/schemas/Workflow" + properties: + task_data: + $ref: "#/components/schemas/AwesomeUnspecifiedPayload" + process_instance: + $ref: "#/components/schemas/AwesomeUnspecifiedPayload" + + /public/messages/form/{modified_message_name}: + parameters: + - name: modified_message_name + in: path + required: true + description: The message_name, modified to replace slashes (/) with colons + schema: + type: string + get: + tags: + - Messages + operationId: spiffworkflow_backend.routes.public_controller.message_form_show + summary: Gets the form associated with the given message name. + responses: + "200": + description: The json schema form. + content: + application/json: + schema: + $ref: "#/components/schemas/AwesomeUnspecifiedPayload" + + /public/messages/submit/{modified_message_name}: + parameters: + - name: modified_message_name + in: path + required: true + description: The message_name, modified to replace slashes (/) with colons + schema: + type: string + - name: execution_mode + in: query + required: false + description: Either run in "synchronous" or "asynchronous" mode. + schema: + type: string + enum: + - synchronous + - asynchronous + post: + tags: + - Messages + operationId: spiffworkflow_backend.routes.public_controller.message_form_submit + summary: Instantiate and run a given process model with a message start event matching given name + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/AwesomeUnspecifiedPayload" + responses: + "200": + description: One task + content: + application/json: + schema: + properties: + task_data: + $ref: "#/components/schemas/AwesomeUnspecifiedPayload" + process_instance: + $ref: "#/components/schemas/AwesomeUnspecifiedPayload" + + /public/tasks/{process_instance_id}/{task_guid}: + parameters: + - name: task_guid + in: path + required: true + description: The unique id of an existing process group. + schema: + type: string + - name: process_instance_id + in: path + required: true + description: The unique id of an existing process instance. + schema: + type: integer + - name: execution_mode + in: query + required: false + description: Either run in "synchronous" or "asynchronous" mode. + schema: + type: string + enum: + - synchronous + - asynchronous + put: + tags: + - Tasks + operationId: spiffworkflow_backend.routes.public_controller.form_submit + summary: Update the form data for a tasks + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/AwesomeUnspecifiedPayload" + responses: + "200": + description: One task + content: + application/json: + schema: + $ref: "#/components/schemas/Task" + "202": + description: "ok: true" + content: + application/json: + schema: + $ref: "#/components/schemas/OkTrue" /logs/{modified_process_model_identifier}/{process_instance_id}: parameters: @@ -3775,9 +3886,9 @@ components: # it will fail validation and not pass the request to the controller. that is generally not desirable # until we take a closer look at the schemas in here. AwesomeUnspecifiedPayload: - properties: - anythingyouwant: - type: string + # we know that task_submit submits no body at all, and None is not an object, as this so helpfully tells us + # type: "object" + additionalProperties: {} ReportMetadata: properties: columns: diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py b/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py index 7a005f1e..6cb121b1 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py @@ -143,6 +143,7 @@ config_from_env("SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_ABSOLUTE_PATH") config_from_env("SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME") # FIXME: do not default this but we will need to coordinate release of it since it is a breaking change config_from_env("SPIFFWORKFLOW_BACKEND_DEFAULT_USER_GROUP", default="everybody") +config_from_env("SPIFFWORKFLOW_BACKEND_DEFAULT_PUBLIC_USER_GROUP", default="spiff_public") ### sentry config_from_env("SPIFFWORKFLOW_BACKEND_SENTRY_DSN", default="") diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/local_development.yml b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/local_development.yml index 6398883d..df7cbb95 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/local_development.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/local_development.yml @@ -18,6 +18,8 @@ groups: users: [dan@sartography.com] group3: users: [jon@sartography.com] + spiff_public: + users: [] permissions: admin: @@ -43,3 +45,8 @@ permissions: groups: [group3] actions: [read] uri: PG:misc + + public_access: + groups: [spiff_public] + actions: [read, create] + uri: /public/* diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/human_task.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/human_task.py index fee793c8..13d0ca34 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/human_task.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/human_task.py @@ -68,7 +68,7 @@ class HumanTaskModel(SpiffworkflowBaseDBModel): break new_task = Task( - task.task_id, + task.task_guid, task.task_name, task.task_title, task.task_type, diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/message_instance.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/message_instance.py index 3081ecd3..c9c7d57c 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/message_instance.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/message_instance.py @@ -65,6 +65,13 @@ class MessageInstanceModel(SpiffworkflowBaseDBModel): def validate_status(self, key: str, value: Any) -> Any: return self.validate_enum_field(key, value, MessageStatuses) + @classmethod + def split_modified_message_name(cls, modified_message_name: str) -> tuple[str, str]: + message_name_array = modified_message_name.split(":") + message_name = message_name_array.pop() + process_group_identifier = "/".join(message_name_array) + return (message_name, process_group_identifier) + def correlates(self, other: Any, expression_engine: PythonScriptEngine) -> bool: """Returns true if the this Message correlates with the given message. diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py index 57e08d58..04cc8129 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py @@ -199,15 +199,19 @@ class ProcessInstanceModel(SpiffworkflowBaseDBModel): def immediately_runnable_statuses(cls) -> list[str]: return ["not_started", "running"] - def get_data(self) -> dict: - """Returns the data of the last completed task in this process instance.""" - last_completed_task = ( + def get_last_completed_task(self) -> TaskModel | None: + last_completed_task: TaskModel | None = ( TaskModel.query.filter_by(process_instance_id=self.id, state="COMPLETED") .order_by(desc(TaskModel.end_in_seconds)) # type: ignore .first() ) + return last_completed_task + + def get_data(self) -> dict: + """Returns the data of the last completed task in this process instance.""" + last_completed_task = self.get_last_completed_task() if last_completed_task: # pragma: no cover - return last_completed_task.json_data() # type: ignore + return last_completed_task.json_data() else: return {} diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py index 9f2f7884..b711867e 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py @@ -1,6 +1,8 @@ from __future__ import annotations import math +import random +import string import time from dataclasses import dataclass from typing import Any @@ -91,3 +93,46 @@ class UserModel(SpiffworkflowBaseDBModel): user_as_json_string = current_app.json.dumps(self) user_dict: dict[str, Any] = current_app.json.loads(user_as_json_string) return user_dict + + @classmethod + def generate_random_username(cls, prefix: str = "public") -> str: + adjectives = [ + "fluffy", + "cuddly", + "tiny", + "joyful", + "sweet", + "gentle", + "cheerful", + "adorable", + "whiskered", + "silky", + ] + animals = [ + "panda", + "kitten", + "puppy", + "bunny", + "chick", + "duckling", + "chipmunk", + "hedgehog", + "lamb", + "fawn", + "otter", + "calf", + "penguin", + "koala", + "giraffe", + "monkey", + "fox", + "raccoon", + "squirrel", + "owl", + ] + fuzz = "".join(random.SystemRandom().choice(string.ascii_lowercase + string.digits) for _ in range(7)) + # this is not for cryptographic purposes + adjective = random.choice(adjectives) # noqa: S311 + animal = random.choice(animals) # noqa: S311 + username = f"{prefix}{adjective}{animal}{fuzz}" + return username diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/authentication_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/authentication_controller.py index 4ed292fa..96b75943 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/authentication_controller.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/authentication_controller.py @@ -18,12 +18,14 @@ from spiffworkflow_backend.exceptions.error import TokenExpiredError from spiffworkflow_backend.helpers.api_version import V1_API_PATH_PREFIX from spiffworkflow_backend.models.group import SPIFF_GUEST_GROUP from spiffworkflow_backend.models.group import SPIFF_NO_AUTH_GROUP +from spiffworkflow_backend.models.group import GroupModel from spiffworkflow_backend.models.service_account import ServiceAccountModel from spiffworkflow_backend.models.task import TaskModel # noqa: F401 from spiffworkflow_backend.models.user import SPIFF_GUEST_USER from spiffworkflow_backend.models.user import SPIFF_NO_AUTH_USER from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.services.authentication_service import AuthenticationService +from spiffworkflow_backend.services.authorization_service import PUBLIC_AUTHENTICATION_EXCLUSION_LIST from spiffworkflow_backend.services.authorization_service import AuthorizationService from spiffworkflow_backend.services.user_service import UserService @@ -76,6 +78,13 @@ def verify_token(token: str | None = None, force_run: bool | None = False) -> di user_model = _get_user_model_from_token(decoded_token) elif token_info["api_key"] is not None: user_model = _get_user_model_from_api_key(token_info["api_key"]) + else: + # if there is no token in the request, hit the database to see if this path allows unauthed access + # we could choose to put all of the APIs that can be accessed unauthed behind a certain path. + # if we did that, we would not have to hit the db on *every* tokenless request + api_function_full_path, _ = AuthorizationService.get_fully_qualified_api_function_from_request() + if api_function_full_path and api_function_full_path in PUBLIC_AUTHENTICATION_EXCLUSION_LIST: + _check_if_request_is_public() if user_model: g.user = user_model @@ -213,13 +222,17 @@ def login_api_return(code: str, state: str, session_state: str) -> str: return access_token -def logout(id_token: str, authentication_identifier: str, redirect_url: str | None) -> Response: +def logout(id_token: str, authentication_identifier: str, redirect_url: str | None, backend_only: bool = False) -> Response: if redirect_url is None: redirect_url = "" AuthenticationService.set_user_has_logged_out() - return AuthenticationService().logout( - redirect_url=redirect_url, id_token=id_token, authentication_identifier=authentication_identifier - ) + + if backend_only: + return redirect(redirect_url) + else: + return AuthenticationService().logout( + redirect_url=redirect_url, id_token=id_token, authentication_identifier=authentication_identifier + ) def logout_return() -> Response: @@ -460,3 +473,26 @@ def _get_authentication_identifier_from_request() -> str: authentication_identifier: str = request.headers["SpiffWorkflow-Authentication-Identifier"] return authentication_identifier return "default" + + +def _check_if_request_is_public() -> None: + permission_string = AuthorizationService.get_permission_from_http_method(request.method) + if permission_string: + public_group = GroupModel.query.filter_by( + identifier=current_app.config.get("SPIFFWORKFLOW_BACKEND_DEFAULT_PUBLIC_USER_GROUP") + ).first() + if public_group is not None: + has_permission = AuthorizationService.has_permission( + principals=[public_group.principal], + permission=permission_string, + target_uri=request.path, + ) + if has_permission: + g.user = UserService.create_public_user() + g.token = g.user.encode_auth_token( + {"public": True}, + ) + tld = current_app.config["THREAD_LOCAL_DATA"] + tld.new_access_token = g.token + tld.new_id_token = g.token + tld.new_authentication_identifier = _get_authentication_identifier_from_request() diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/messages_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/messages_controller.py index e867e26e..595a0c48 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/messages_controller.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/messages_controller.py @@ -1,15 +1,11 @@ -"""APIs for dealing with process groups, process models, and process instances.""" import json from typing import Any import flask.wrappers -from flask import g from flask import jsonify from flask import make_response from flask.wrappers import Response -from spiffworkflow_backend import db -from spiffworkflow_backend.exceptions.api_error import ApiError from spiffworkflow_backend.models.message_instance import MessageInstanceModel from spiffworkflow_backend.models.process_instance import ProcessInstanceModel from spiffworkflow_backend.models.process_instance import ProcessInstanceModelSchema @@ -63,43 +59,11 @@ def message_instance_list( # -H 'content-type: application/json' \ # --data-raw '{"payload":{"sure": "yes", "food": "spicy"}}' def message_send( - message_name: str, + modified_message_name: str, body: dict[str, Any], execution_mode: str | None = None, ) -> flask.wrappers.Response: - process_instance = None - - # Create the send message - message_instance = MessageInstanceModel( - message_type="send", - name=message_name, - payload=body, - user_id=g.user.id, - ) - db.session.add(message_instance) - db.session.commit() - try: - receiver_message = MessageService.correlate_send_message(message_instance, execution_mode=execution_mode) - except Exception as e: - db.session.delete(message_instance) - db.session.commit() - raise e - if not receiver_message: - db.session.delete(message_instance) - db.session.commit() - raise ( - ApiError( - error_code="message_not_accepted", - message=( - "No running process instances correlate with the given message" - f" name of '{message_name}'. And this message name is not" - " currently associated with any process Start Event. Nothing" - " to do." - ), - status_code=400, - ) - ) - + receiver_message = MessageService.run_process_model_from_message(modified_message_name, body, execution_mode) process_instance = ProcessInstanceModel.query.filter_by(id=receiver_message.process_instance_id).first() response_json = { "task_data": process_instance.get_data(), diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_api_blueprint.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_api_blueprint.py index e7bef567..68564b82 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_api_blueprint.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_api_blueprint.py @@ -1,18 +1,28 @@ +import json +import uuid from typing import Any from uuid import UUID import flask.wrappers +import sentry_sdk from flask import Blueprint from flask import current_app from flask import g from flask import jsonify from flask import make_response from flask.wrappers import Response +from SpiffWorkflow.task import Task as SpiffTask # type: ignore +from SpiffWorkflow.util.task import TaskState # type: ignore from sqlalchemy import and_ from sqlalchemy import or_ +from spiffworkflow_backend.background_processing.celery_tasks.process_instance_task_producer import ( + queue_enabled_for_process_model, +) +from spiffworkflow_backend.data_migrations.process_instance_migrator import ProcessInstanceMigrator from spiffworkflow_backend.exceptions.api_error import ApiError from spiffworkflow_backend.exceptions.process_entity_not_found_error import ProcessEntityNotFoundError +from spiffworkflow_backend.models.db import db from spiffworkflow_backend.models.human_task import HumanTaskModel from spiffworkflow_backend.models.human_task_user import HumanTaskUserModel from spiffworkflow_backend.models.principal import PrincipalModel @@ -21,12 +31,19 @@ from spiffworkflow_backend.models.process_instance_file_data import ProcessInsta from spiffworkflow_backend.models.process_model import ProcessModelInfo from spiffworkflow_backend.models.reference_cache import ReferenceCacheModel from spiffworkflow_backend.models.reference_cache import ReferenceSchema +from spiffworkflow_backend.models.task import TaskModel # noqa: F401 from spiffworkflow_backend.services.authentication_service import AuthenticationService # noqa: F401 from spiffworkflow_backend.services.authorization_service import AuthorizationService +from spiffworkflow_backend.services.git_service import GitCommandError from spiffworkflow_backend.services.git_service import GitService +from spiffworkflow_backend.services.jinja_service import JinjaService from spiffworkflow_backend.services.process_caller_service import ProcessCallerService from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor +from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceQueueService +from spiffworkflow_backend.services.process_instance_service import ProcessInstanceService from spiffworkflow_backend.services.process_model_service import ProcessModelService +from spiffworkflow_backend.services.task_service import TaskModelError +from spiffworkflow_backend.services.task_service import TaskService process_api_blueprint = Blueprint("process_api", __name__) @@ -357,3 +374,186 @@ def _get_process_model_for_instantiation( status_code=400, ) return process_model + + +def _prepare_form_data( + form_file: str, process_model: ProcessModelInfo, task_model: TaskModel | None = None, revision: str | None = None +) -> dict: + try: + form_contents = GitService.get_file_contents_for_revision_if_git_revision( + process_model=process_model, + revision=revision, + file_name=form_file, + ) + except GitCommandError as exception: + raise ( + ApiError( + error_code="git_error_loading_form", + message=( + f"Could not load form schema from: {form_file}. Was git history rewritten such that revision" + f" '{revision}' no longer exists? Error was: {str(exception)}" + ), + status_code=400, + ) + ) from exception + + if task_model and task_model.data is not None: + try: + form_contents = JinjaService.render_jinja_template(form_contents, task=task_model) + except TaskModelError as wfe: + wfe.add_note(f"Error in Json Form File '{form_file}'") + api_error = ApiError.from_workflow_exception("instructions_error", str(wfe), exp=wfe) + api_error.file_name = form_file + raise api_error from wfe + + try: + # form_contents is a str + hot_dict: dict = json.loads(form_contents) + return hot_dict + except Exception as exception: + raise ( + ApiError( + error_code="error_loading_form", + message=f"Could not load form schema from: {form_file}. Error was: {str(exception)}", + status_code=400, + ) + ) from exception + + +def _task_submit_shared( + process_instance_id: int, + task_guid: str, + body: dict[str, Any], + execution_mode: str | None = None, +) -> dict: + principal = _find_principal_or_raise() + process_instance = _find_process_instance_by_id_or_raise(process_instance_id) + if not process_instance.can_submit_task(): + raise ApiError( + error_code="process_instance_not_runnable", + message=( + f"Process Instance ({process_instance.id}) has status " + f"{process_instance.status} which does not allow tasks to be submitted." + ), + status_code=400, + ) + + # we're dequeing twice in this function. + # tried to wrap the whole block in one dequeue, but that has the confusing side-effect that every exception + # in the block causes the process instance to go into an error state. for example, when + # AuthorizationService.assert_user_can_complete_task raises. this would have been solvable, but this seems simpler, + # and the cost is not huge given that this function is not the most common code path in the world. + with ProcessInstanceQueueService.dequeued(process_instance): + ProcessInstanceMigrator.run(process_instance) + + processor = ProcessInstanceProcessor( + process_instance, workflow_completed_handler=ProcessInstanceService.schedule_next_process_model_cycle + ) + spiff_task = _get_spiff_task_from_processor(task_guid, processor) + AuthorizationService.assert_user_can_complete_task(process_instance.id, str(spiff_task.id), principal.user) + + if spiff_task.state != TaskState.READY: + raise ( + ApiError( + error_code="invalid_state", + message="You may not update a task unless it is in the READY state.", + status_code=400, + ) + ) + + human_task = _find_human_task_or_raise( + process_instance_id=process_instance_id, + task_guid=task_guid, + only_tasks_that_can_be_completed=True, + ) + + with sentry_sdk.start_span(op="task", description="complete_form_task"): + with ProcessInstanceQueueService.dequeued(process_instance): + ProcessInstanceService.complete_form_task( + processor=processor, + spiff_task=spiff_task, + data=body, + user=g.user, + human_task=human_task, + execution_mode=execution_mode, + ) + + # currently task_model has the potential to be None. This should be removable once + # we backfill the human_task table for task_guid and make that column not nullable + task_model: TaskModel | None = human_task.task_model + if task_model is None: + task_model = TaskModel.query.filter_by(guid=human_task.task_id).first() + + # delete draft data when we submit a task to ensure cycling back to the task contains the + # most up-to-date data + task_draft_data = TaskService.task_draft_data_from_task_model(task_model) + if task_draft_data is not None: + db.session.delete(task_draft_data) + db.session.commit() + + next_human_task_assigned_to_me = TaskService.next_human_task_for_user(process_instance_id, principal.user_id) + if next_human_task_assigned_to_me: + return {"next_task_assigned_to_me": HumanTaskModel.to_task(next_human_task_assigned_to_me)} + + # a guest user completed a task, it has a guest_confirmation message to display to them, + # and there is nothing else for them to do + spiff_task_extensions = spiff_task.task_spec.extensions + if "guestConfirmation" in spiff_task_extensions and spiff_task_extensions["guestConfirmation"]: + guest_confirmation = JinjaService.render_jinja_template(spiff_task_extensions["guestConfirmation"], task_model) + return {"guest_confirmation": guest_confirmation} + + if processor.next_task(): + task = ProcessInstanceService.spiff_task_to_api_task(processor, processor.next_task()) + task.process_model_uses_queued_execution = queue_enabled_for_process_model(process_instance) + return {"next_task": task} + + # next_task always returns something, even if the instance is complete, so we never get here + return { + "ok": True, + "process_model_identifier": process_instance.process_model_identifier, + "process_instance_id": process_instance_id, + } + + +def _find_human_task_or_raise( + process_instance_id: int, + task_guid: str, + only_tasks_that_can_be_completed: bool = False, +) -> HumanTaskModel: + if only_tasks_that_can_be_completed: + human_task_query = HumanTaskModel.query.filter_by( + process_instance_id=process_instance_id, + task_id=task_guid, + completed=False, + ) + else: + human_task_query = HumanTaskModel.query.filter_by(process_instance_id=process_instance_id, task_id=task_guid) + + human_task: HumanTaskModel = human_task_query.first() + if human_task is None: + raise ( + ApiError( + error_code="no_human_task", + message=f"Cannot find a task to complete for task id '{task_guid}' and process instance {process_instance_id}.", + status_code=500, + ) + ) + return human_task + + +def _get_spiff_task_from_processor( + task_guid: str, + processor: ProcessInstanceProcessor, +) -> SpiffTask: + task_uuid = uuid.UUID(task_guid) + spiff_task = processor.bpmn_process_instance.get_task_from_id(task_uuid) + + if spiff_task is None: + raise ( + ApiError( + error_code="empty_task", + message="Processor failed to obtain task.", + status_code=500, + ) + ) + return spiff_task diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/public_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/public_controller.py new file mode 100644 index 00000000..dc501787 --- /dev/null +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/public_controller.py @@ -0,0 +1,172 @@ +from typing import Any + +import flask.wrappers +from flask import g +from flask import jsonify +from flask import make_response +from SpiffWorkflow.bpmn.specs.mixins import StartEventMixin # type: ignore +from SpiffWorkflow.util.task import TaskState # type: ignore + +from spiffworkflow_backend.exceptions.api_error import ApiError +from spiffworkflow_backend.models.process_instance import ProcessInstanceModel +from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus +from spiffworkflow_backend.models.process_model import ProcessModelInfo +from spiffworkflow_backend.models.task import TaskModel +from spiffworkflow_backend.models.task_definition import TaskDefinitionModel # noqa: F401 +from spiffworkflow_backend.routes.process_api_blueprint import _prepare_form_data +from spiffworkflow_backend.routes.process_api_blueprint import _task_submit_shared +from spiffworkflow_backend.services.jinja_service import JinjaService +from spiffworkflow_backend.services.message_service import MessageService +from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor +from spiffworkflow_backend.services.process_model_service import ProcessModelService +from spiffworkflow_backend.services.task_service import TaskService + + +def message_form_show( + modified_message_name: str, +) -> flask.wrappers.Response: + message_triggerable_process_model = MessageService.find_message_triggerable_process_model(modified_message_name) + + process_instance = ProcessInstanceModel( + status=ProcessInstanceStatus.not_started.value, + process_initiator_id=None, + process_model_identifier=message_triggerable_process_model.process_model_identifier, + persistence_level="none", + ) + processor = ProcessInstanceProcessor(process_instance) + start_tasks = processor.bpmn_process_instance.get_tasks(spec_class=StartEventMixin) + matching_start_tasks = [ + t for t in start_tasks if t.task_spec.event_definition.name == message_triggerable_process_model.message_name + ] + if len(matching_start_tasks) == 0: + raise ( + ApiError( + error_code="message_start_event_not_found", + message=( + f"Could not find a message start event for message '{message_triggerable_process_model.message_name}' in" + f" process model '{message_triggerable_process_model.process_model_identifier}'." + ), + status_code=400, + ) + ) + + process_model = ProcessModelService.get_process_model(message_triggerable_process_model.process_model_identifier) + extensions = matching_start_tasks[0].task_spec.extensions + response_body = _get_form_and_prepare_data(extensions=extensions, process_model=process_model) + + return make_response(jsonify(response_body), 200) + + +def message_form_submit( + modified_message_name: str, + body: dict[str, Any], + execution_mode: str | None = None, +) -> flask.wrappers.Response: + receiver_message = MessageService.run_process_model_from_message(modified_message_name, body, execution_mode) + process_instance = ProcessInstanceModel.query.filter_by(id=receiver_message.process_instance_id).first() + next_human_task_assigned_to_me = TaskService.next_human_task_for_user(process_instance.id, g.user.id) + + next_form_contents = None + task_guid = None + confirmation_message_markdown = None + if next_human_task_assigned_to_me: + task_guid = next_human_task_assigned_to_me.task_guid + process_model = ProcessModelService.get_process_model(process_instance.process_model_identifier) + next_form_contents = _get_form_and_prepare_data( + process_model=process_model, task_guid=next_human_task_assigned_to_me.task_guid, process_instance=process_instance + ) + else: + processor = ProcessInstanceProcessor(process_instance) + start_tasks = processor.bpmn_process_instance.get_tasks(spec_class=StartEventMixin, state=TaskState.COMPLETED) + matching_start_tasks = [t for t in start_tasks if t.task_spec.event_definition.name == receiver_message.name] + if len(matching_start_tasks) > 0: + spiff_task = matching_start_tasks[0] + task_model = TaskModel.query.filter_by(guid=str(spiff_task.id)).first() + spiff_task_extensions = spiff_task.task_spec.extensions + if "guestConfirmation" in spiff_task_extensions and spiff_task_extensions["guestConfirmation"]: + confirmation_message_markdown = JinjaService.render_jinja_template( + spiff_task.task_spec.extensions["guestConfirmation"], task_model + ) + + response_json = { + "form": next_form_contents, + "task_guid": task_guid, + "process_instance_id": process_instance.id, + "confirmation_message_markdown": confirmation_message_markdown, + } + return make_response(jsonify(response_json), 200) + + +def form_submit( + process_instance_id: int, + task_guid: str, + body: dict[str, Any], + execution_mode: str | None = None, +) -> flask.wrappers.Response: + response_item = _task_submit_shared(process_instance_id, task_guid, body, execution_mode=execution_mode) + + next_form_contents = None + next_task_guid = None + if "next_task_assigned_to_me" in response_item: + next_task_assigned_to_me = response_item["next_task_assigned_to_me"] + process_instance = ProcessInstanceModel.query.filter_by(id=process_instance_id).first() + next_task_guid = next_task_assigned_to_me.id + process_model = ProcessModelService.get_process_model(process_instance.process_model_identifier) + next_form_contents = _get_form_and_prepare_data( + process_model=process_model, task_guid=next_task_assigned_to_me.task_guid, process_instance=process_instance + ) + + response_json = { + "form": next_form_contents, + "task_guid": next_task_guid, + "process_instance_id": process_instance_id, + "confirmation_message_markdown": response_item.get("guest_confirmation"), + } + return make_response(jsonify(response_json), 200) + + +def _get_form_and_prepare_data( + process_model: ProcessModelInfo, + extensions: dict | None = None, + task_guid: str | None = None, + process_instance: ProcessInstanceModel | None = None, +) -> dict: + task_model = None + extension_list = extensions + if task_guid and process_instance: + task_model = TaskModel.query.filter_by(guid=task_guid, process_instance_id=process_instance.id).first() + task_model.data = task_model.json_data() + extension_list = TaskService.get_extensions_from_task_model(task_model) + + revision = None + if process_instance: + revision = process_instance.bpmn_version_control_identifier + + form_contents: dict = {} + if extension_list: + if "properties" in extension_list: + properties = extension_list["properties"] + if "formJsonSchemaFilename" in properties: + form_schema_file_name = properties["formJsonSchemaFilename"] + form_contents["form_schema"] = _prepare_form_data( + form_file=form_schema_file_name, + task_model=task_model, + process_model=process_model, + revision=revision, + ) + if "formUiSchemaFilename" in properties: + form_ui_schema_file_name = properties["formUiSchemaFilename"] + form_contents["form_ui_schema"] = _prepare_form_data( + form_file=form_ui_schema_file_name, + task_model=task_model, + process_model=process_model, + revision=revision, + ) + if "instructionsForEndUser" in extension_list and extension_list["instructionsForEndUser"]: + task_data = {} + if task_model is not None and task_model.data: + task_data = task_model.data + form_contents["instructions_for_end_user"] = JinjaService.render_jinja_template( + extension_list["instructionsForEndUser"], task_data=task_data + ) + return form_contents diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py index 644fe544..515985ca 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py @@ -1,6 +1,5 @@ import json import os -import uuid from collections import OrderedDict from collections.abc import Generator from typing import Any @@ -20,15 +19,11 @@ from SpiffWorkflow.bpmn.workflow import BpmnWorkflow # type: ignore from SpiffWorkflow.task import Task as SpiffTask # type: ignore from SpiffWorkflow.util.task import TaskState # type: ignore from sqlalchemy import and_ -from sqlalchemy import asc from sqlalchemy import desc from sqlalchemy import func from sqlalchemy.orm import aliased from sqlalchemy.orm.util import AliasedClass -from spiffworkflow_backend.background_processing.celery_tasks.process_instance_task_producer import ( - queue_enabled_for_process_model, -) from spiffworkflow_backend.data_migrations.process_instance_migrator import ProcessInstanceMigrator from spiffworkflow_backend.exceptions.api_error import ApiError from spiffworkflow_backend.exceptions.error import HumanTaskAlreadyCompletedError @@ -46,7 +41,6 @@ from spiffworkflow_backend.models.process_instance import ProcessInstanceModelSc from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus from spiffworkflow_backend.models.process_instance import ProcessInstanceTaskDataCannotBeUpdatedError from spiffworkflow_backend.models.process_instance_event import ProcessInstanceEventType -from spiffworkflow_backend.models.process_model import ProcessModelInfo from spiffworkflow_backend.models.task import Task from spiffworkflow_backend.models.task import TaskModel from spiffworkflow_backend.models.task_definition import TaskDefinitionModel @@ -58,11 +52,11 @@ from spiffworkflow_backend.routes.process_api_blueprint import _find_principal_o from spiffworkflow_backend.routes.process_api_blueprint import _find_process_instance_by_id_or_raise from spiffworkflow_backend.routes.process_api_blueprint import _find_process_instance_for_me_or_raise from spiffworkflow_backend.routes.process_api_blueprint import _get_process_model +from spiffworkflow_backend.routes.process_api_blueprint import _prepare_form_data +from spiffworkflow_backend.routes.process_api_blueprint import _task_submit_shared from spiffworkflow_backend.services.authorization_service import AuthorizationService from spiffworkflow_backend.services.error_handling_service import ErrorHandlingService from spiffworkflow_backend.services.file_system_service import FileSystemService -from spiffworkflow_backend.services.git_service import GitCommandError -from spiffworkflow_backend.services.git_service import GitService from spiffworkflow_backend.services.jinja_service import JinjaService from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceIsAlreadyLockedError @@ -71,7 +65,6 @@ from spiffworkflow_backend.services.process_instance_service import ProcessInsta from spiffworkflow_backend.services.process_instance_tmp_service import ProcessInstanceTmpService from spiffworkflow_backend.services.process_model_service import ProcessModelService from spiffworkflow_backend.services.spec_file_service import SpecFileService -from spiffworkflow_backend.services.task_service import TaskModelError from spiffworkflow_backend.services.task_service import TaskService @@ -557,7 +550,12 @@ def task_submit( execution_mode: str | None = None, ) -> flask.wrappers.Response: with sentry_sdk.start_span(op="controller_action", description="tasks_controller.task_submit"): - return _task_submit_shared(process_instance_id, task_guid, body, execution_mode=execution_mode) + response_item = _task_submit_shared(process_instance_id, task_guid, body, execution_mode=execution_mode) + if "next_task_assigned_to_me" in response_item: + response_item = response_item["next_task_assigned_to_me"] + elif "next_task" in response_item: + response_item = response_item["next_task"] + return make_response(jsonify(response_item), 200) def process_instance_progress( @@ -567,7 +565,7 @@ def process_instance_progress( process_instance = _find_process_instance_for_me_or_raise(process_instance_id, include_actions=True) principal = _find_principal_or_raise() - next_human_task_assigned_to_me = _next_human_task_for_user(process_instance_id, principal.user_id) + next_human_task_assigned_to_me = TaskService.next_human_task_for_user(process_instance_id, principal.user_id) if next_human_task_assigned_to_me: response["task"] = HumanTaskModel.to_task(next_human_task_assigned_to_me) # this may not catch all times we should redirect to instance show page @@ -869,110 +867,6 @@ def task_save_draft( ) -def _task_submit_shared( - process_instance_id: int, - task_guid: str, - body: dict[str, Any], - execution_mode: str | None = None, -) -> flask.wrappers.Response: - principal = _find_principal_or_raise() - process_instance = _find_process_instance_by_id_or_raise(process_instance_id) - if not process_instance.can_submit_task(): - raise ApiError( - error_code="process_instance_not_runnable", - message=( - f"Process Instance ({process_instance.id}) has status " - f"{process_instance.status} which does not allow tasks to be submitted." - ), - status_code=400, - ) - - # we're dequeing twice in this function. - # tried to wrap the whole block in one dequeue, but that has the confusing side-effect that every exception - # in the block causes the process instance to go into an error state. for example, when - # AuthorizationService.assert_user_can_complete_task raises. this would have been solvable, but this seems simpler, - # and the cost is not huge given that this function is not the most common code path in the world. - with ProcessInstanceQueueService.dequeued(process_instance): - ProcessInstanceMigrator.run(process_instance) - - processor = ProcessInstanceProcessor( - process_instance, workflow_completed_handler=ProcessInstanceService.schedule_next_process_model_cycle - ) - spiff_task = _get_spiff_task_from_process_instance(task_guid, process_instance, processor=processor) - AuthorizationService.assert_user_can_complete_task(process_instance.id, str(spiff_task.id), principal.user) - - if spiff_task.state != TaskState.READY: - raise ( - ApiError( - error_code="invalid_state", - message="You may not update a task unless it is in the READY state.", - status_code=400, - ) - ) - - human_task = _find_human_task_or_raise( - process_instance_id=process_instance_id, - task_guid=task_guid, - only_tasks_that_can_be_completed=True, - ) - - with sentry_sdk.start_span(op="task", description="complete_form_task"): - with ProcessInstanceQueueService.dequeued(process_instance): - ProcessInstanceService.complete_form_task( - processor=processor, - spiff_task=spiff_task, - data=body, - user=g.user, - human_task=human_task, - execution_mode=execution_mode, - ) - - # currently task_model has the potential to be None. This should be removable once - # we backfill the human_task table for task_guid and make that column not nullable - task_model: TaskModel | None = human_task.task_model - if task_model is None: - task_model = TaskModel.query.filter_by(guid=human_task.task_id).first() - - # delete draft data when we submit a task to ensure cycling back to the task contains the - # most up-to-date data - task_draft_data = TaskService.task_draft_data_from_task_model(task_model) - if task_draft_data is not None: - db.session.delete(task_draft_data) - db.session.commit() - - next_human_task_assigned_to_me = _next_human_task_for_user(process_instance_id, principal.user_id) - if next_human_task_assigned_to_me: - return make_response(jsonify(HumanTaskModel.to_task(next_human_task_assigned_to_me)), 200) - - # a guest user completed a task, it has a guest_confirmation message to display to them, - # and there is nothing else for them to do - spiff_task_extensions = spiff_task.task_spec.extensions - if ( - "allowGuest" in spiff_task_extensions - and spiff_task_extensions["allowGuest"] == "true" - and "guestConfirmation" in spiff_task.task_spec.extensions - ): - return make_response(jsonify({"guest_confirmation": spiff_task.task_spec.extensions["guestConfirmation"]}), 200) - - if processor.next_task(): - task = ProcessInstanceService.spiff_task_to_api_task(processor, processor.next_task()) - task.process_model_uses_queued_execution = queue_enabled_for_process_model(process_instance) - return make_response(jsonify(task), 200) - - # next_task always returns something, even if the instance is complete, so we never get here - return Response( - json.dumps( - { - "ok": True, - "process_model_identifier": process_instance.process_model_identifier, - "process_instance_id": process_instance_id, - } - ), - status=202, - mimetype="application/json", - ) - - def _get_tasks( processes_started_by_user: bool = True, has_lane_assignment_id: bool = True, @@ -1069,71 +963,6 @@ def _get_tasks( return make_response(jsonify(response_json), 200) -def _prepare_form_data( - form_file: str, task_model: TaskModel, process_model: ProcessModelInfo, revision: str | None = None -) -> dict: - if task_model.data is None: - return {} - - try: - file_contents = GitService.get_file_contents_for_revision_if_git_revision( - process_model=process_model, - revision=revision, - file_name=form_file, - ) - except GitCommandError as exception: - raise ( - ApiError( - error_code="git_error_loading_form", - message=( - f"Could not load form schema from: {form_file}. Was git history rewritten such that revision" - f" '{revision}' no longer exists? Error was: {str(exception)}" - ), - status_code=400, - ) - ) from exception - - try: - form_contents = JinjaService.render_jinja_template(file_contents, task=task_model) - except TaskModelError as wfe: - wfe.add_note(f"Error in Json Form File '{form_file}'") - api_error = ApiError.from_workflow_exception("instructions_error", str(wfe), exp=wfe) - api_error.file_name = form_file - raise api_error from wfe - - try: - # form_contents is a str - hot_dict: dict = json.loads(form_contents) - return hot_dict - except Exception as exception: - raise ( - ApiError( - error_code="error_loading_form", - message=f"Could not load form schema from: {form_file}. Error was: {str(exception)}", - status_code=400, - ) - ) from exception - - -def _get_spiff_task_from_process_instance( - task_guid: str, - process_instance: ProcessInstanceModel, - processor: ProcessInstanceProcessor, -) -> SpiffTask: - task_uuid = uuid.UUID(task_guid) - spiff_task = processor.bpmn_process_instance.get_task_from_id(task_uuid) - - if spiff_task is None: - raise ( - ApiError( - error_code="empty_task", - message="Processor failed to obtain task.", - status_code=500, - ) - ) - return spiff_task - - # originally from: https://bitcoden.com/answers/python-nested-dictionary-update-value-where-any-nested-key-matches def _update_form_schema_with_task_data_as_needed(in_dict: dict, task_data: dict) -> None: for k, value in in_dict.items(): @@ -1218,32 +1047,6 @@ def _get_potential_owner_usernames(assigned_user: AliasedClass) -> Any: return potential_owner_usernames_from_group_concat_or_similar -def _find_human_task_or_raise( - process_instance_id: int, - task_guid: str, - only_tasks_that_can_be_completed: bool = False, -) -> HumanTaskModel: - if only_tasks_that_can_be_completed: - human_task_query = HumanTaskModel.query.filter_by( - process_instance_id=process_instance_id, - task_id=task_guid, - completed=False, - ) - else: - human_task_query = HumanTaskModel.query.filter_by(process_instance_id=process_instance_id, task_id=task_guid) - - human_task: HumanTaskModel = human_task_query.first() - if human_task is None: - raise ( - ApiError( - error_code="no_human_task", - message=f"Cannot find a task to complete for task id '{task_guid}' and process instance {process_instance_id}.", - status_code=500, - ) - ) - return human_task - - def _munge_form_ui_schema_based_on_hidden_fields_in_task_data(form_ui_schema: dict | None, task_data: dict) -> None: if form_ui_schema is None: return @@ -1269,14 +1072,3 @@ def _get_task_model_from_guid_or_raise(task_guid: str, process_instance_id: int) status_code=400, ) return task_model - - -def _next_human_task_for_user(process_instance_id: int, user_id: int) -> HumanTaskModel | None: - next_human_task: HumanTaskModel | None = ( - HumanTaskModel.query.filter_by(process_instance_id=process_instance_id, completed=False) - .order_by(asc(HumanTaskModel.id)) # type: ignore - .join(HumanTaskUserModel) - .filter_by(user_id=user_id) - .first() - ) - return next_human_task diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py index ad92e467..a049ba18 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py @@ -1,6 +1,7 @@ import inspect import re from dataclasses import dataclass +from typing import Any import yaml from flask import current_app @@ -77,27 +78,33 @@ PATH_SEGMENTS_FOR_PERMISSION_ALL = [ {"path": "/task-data", "relevant_permissions": ["read", "update"]}, ] -AUTHENTICATION_EXCLUSION_LIST = { - "authentication_begin": "spiffworkflow_backend.routes.service_tasks_controller", - "authentication_callback": "spiffworkflow_backend.routes.service_tasks_controller", - "authentication_options": "spiffworkflow_backend.routes.authentication_controller", - "github_webhook_receive": "spiffworkflow_backend.routes.webhooks_controller", - "login": "spiffworkflow_backend.routes.authentication_controller", - "login_api_return": "spiffworkflow_backend.routes.authentication_controller", - "login_return": "spiffworkflow_backend.routes.authentication_controller", - "login_with_access_token": "spiffworkflow_backend.routes.authentication_controller", - "logout": "spiffworkflow_backend.routes.authentication_controller", - "logout_return": "spiffworkflow_backend.routes.authentication_controller", - "status": "spiffworkflow_backend.routes.health_controller", - "task_allows_guest": "spiffworkflow_backend.routes.tasks_controller", - "test_raise_error": "spiffworkflow_backend.routes.debug_controller", - "url_info": "spiffworkflow_backend.routes.debug_controller", - "webhook": "spiffworkflow_backend.routes.webhooks_controller", +AUTHENTICATION_EXCLUSION_LIST = [ + "spiffworkflow_backend.routes.authentication_controller.authentication_options", + "spiffworkflow_backend.routes.authentication_controller.login", + "spiffworkflow_backend.routes.authentication_controller.login_api_return", + "spiffworkflow_backend.routes.authentication_controller.login_return", + "spiffworkflow_backend.routes.authentication_controller.login_with_access_token", + "spiffworkflow_backend.routes.authentication_controller.logout", + "spiffworkflow_backend.routes.authentication_controller.logout_return", + "spiffworkflow_backend.routes.debug_controller.test_raise_error", + "spiffworkflow_backend.routes.debug_controller.url_info", + "spiffworkflow_backend.routes.health_controller.status", + "spiffworkflow_backend.routes.service_tasks_controller.authentication_begin", + "spiffworkflow_backend.routes.service_tasks_controller.authentication_callback", + "spiffworkflow_backend.routes.tasks_controller.task_allows_guest", + "spiffworkflow_backend.routes.webhooks_controller.github_webhook_receive", + "spiffworkflow_backend.routes.webhooks_controller.webhook", # swagger api calls - "console_ui_home": "connexion.apis.flask_api", - "console_ui_static_files": "connexion.apis.flask_api", - "get_json_spec": "connexion.apis.flask_api", -} + "connexion.apis.flask_api.console_ui_home", + "connexion.apis.flask_api.console_ui_static_files", + "connexion.apis.flask_api.get_json_spec", +] + +# these are api calls that are allowed to generate a public jwt when called +PUBLIC_AUTHENTICATION_EXCLUSION_LIST = [ + "spiffworkflow_backend.routes.public_controller.message_form_show", + "spiffworkflow_backend.routes.public_controller.message_form_submit", +] class AuthorizationService: @@ -250,6 +257,17 @@ class AuthorizationService: db.session.commit() return permission_assignment + @classmethod + def get_fully_qualified_api_function_from_request(cls) -> tuple[str | None, Any]: + api_view_function = current_app.view_functions[request.endpoint] + module = inspect.getmodule(api_view_function) + api_function_name = api_view_function.__name__ if api_view_function else None + controller_name = module.__name__ if module is not None else None + function_full_path = None + if api_function_name: + function_full_path = f"{controller_name}.{api_function_name}" + return (function_full_path, module) + @classmethod def should_disable_auth_for_request(cls) -> bool: if request.method == "OPTIONS": @@ -262,17 +280,10 @@ class AuthorizationService: if not request.endpoint: return True - api_view_function = current_app.view_functions[request.endpoint] - module = inspect.getmodule(api_view_function) - api_function_name = api_view_function.__name__ if api_view_function else None - controller_name = module.__name__ if module is not None else None + api_function_full_path, module = cls.get_fully_qualified_api_function_from_request() if ( - api_function_name - and ( - api_function_name in AUTHENTICATION_EXCLUSION_LIST - and controller_name - and controller_name in AUTHENTICATION_EXCLUSION_LIST[api_function_name] - ) + api_function_full_path + and (api_function_full_path in AUTHENTICATION_EXCLUSION_LIST) or (module == openid_blueprint or module == scaffold) # don't check permissions for static assets ): return True @@ -292,6 +303,22 @@ class AuthorizationService: return None + @classmethod + def check_permission_for_request(cls) -> None: + permission_string = cls.get_permission_from_http_method(request.method) + if permission_string: + has_permission = cls.user_has_permission( + user=g.user, + permission=permission_string, + target_uri=request.path, + ) + if has_permission: + return None + + raise NotAuthorizedError( + f"User {g.user.username} is not authorized to perform requested action: {permission_string} - {request.path}", + ) + @classmethod def check_for_permission(cls, decoded_token: dict | None) -> None: if cls.should_disable_auth_for_request(): @@ -308,19 +335,7 @@ class AuthorizationService: if cls.request_allows_guest_access(decoded_token): return None - permission_string = cls.get_permission_from_http_method(request.method) - if permission_string: - has_permission = AuthorizationService.user_has_permission( - user=g.user, - permission=permission_string, - target_uri=request.path, - ) - if has_permission: - return None - - raise NotAuthorizedError( - f"User {g.user.username} is not authorized to perform requested action: {permission_string} - {request.path}", - ) + cls.check_permission_for_request() @classmethod def request_is_excluded_from_permission_check(cls) -> bool: diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/jinja_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/jinja_service.py index eee981be..2026ad9b 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/jinja_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/jinja_service.py @@ -38,12 +38,12 @@ class JinjaHelpers: class JinjaService: @classmethod - def render_instructions_for_end_user(cls, task: TaskModel | SpiffTask, extensions: dict | None = None) -> str: + def render_instructions_for_end_user(cls, task: TaskModel | SpiffTask | None = None, extensions: dict | None = None) -> str: """Assure any instructions for end user are processed for jinja syntax.""" if extensions is None: if isinstance(task, TaskModel): extensions = TaskService.get_extensions_from_task_model(task) - elif hasattr(task.task_spec, "extensions"): + elif task and hasattr(task.task_spec, "extensions"): extensions = task.task_spec.extensions if extensions and "instructionsForEndUser" in extensions: if extensions["instructionsForEndUser"]: diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/message_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/message_service.py index 7fa2bbd0..8a389164 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/message_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/message_service.py @@ -1,5 +1,7 @@ import os +from typing import Any +from flask import g from SpiffWorkflow.bpmn import BpmnEvent # type: ignore from SpiffWorkflow.bpmn.specs.event_definitions.message import CorrelationProperty # type: ignore from SpiffWorkflow.bpmn.specs.mixins import StartEventMixin # type: ignore @@ -9,6 +11,7 @@ from spiffworkflow_backend.background_processing.celery_tasks.process_instance_t queue_process_instance_if_appropriate, ) from spiffworkflow_backend.background_processing.celery_tasks.process_instance_task_producer import should_queue_process_instance +from spiffworkflow_backend.exceptions.api_error import ApiError from spiffworkflow_backend.helpers.spiff_enum import ProcessInstanceExecutionMode from spiffworkflow_backend.models.db import db from spiffworkflow_backend.models.message_instance import MessageInstanceModel @@ -145,40 +148,6 @@ class MessageService: return process_instance_receive - @classmethod - def _cancel_non_matching_start_events( - cls, processor_receive: ProcessInstanceProcessor, message_triggerable_process_model: MessageTriggerableProcessModel - ) -> None: - """Cancel any start event that does not match the start event that triggered this. - - After that SpiffWorkflow and the WorkflowExecutionService can figure it out. - """ - start_tasks = processor_receive.bpmn_process_instance.get_tasks(spec_class=StartEventMixin) - for start_task in start_tasks: - if not isinstance(start_task.task_spec.event_definition, MessageEventDefinition): - start_task.cancel() - elif start_task.task_spec.event_definition.name != message_triggerable_process_model.message_name: - start_task.cancel() - - @staticmethod - def get_process_instance_for_message_instance( - message_instance_receive: MessageInstanceModel, - ) -> ProcessInstanceModel: - process_instance_receive: ProcessInstanceModel = ProcessInstanceModel.query.filter_by( - id=message_instance_receive.process_instance_id - ).first() - if process_instance_receive is None: - raise MessageServiceError( - ( - ( - "Process instance cannot be found for queued message:" - f" {message_instance_receive.id}. Tried with id" - f" {message_instance_receive.process_instance_id}" - ), - ) - ) - return process_instance_receive - @staticmethod def process_message_receive( process_instance_receive: ProcessInstanceModel, @@ -221,3 +190,113 @@ class MessageService: message_instance_receive.status = MessageStatuses.completed.value db.session.add(message_instance_receive) db.session.commit() + + @classmethod + def find_message_triggerable_process_model(cls, modified_message_name: str) -> MessageTriggerableProcessModel: + message_name, process_group_identifier = MessageInstanceModel.split_modified_message_name(modified_message_name) + potential_matches = MessageTriggerableProcessModel.query.filter_by(message_name=message_name).all() + actual_matches = [] + for potential_match in potential_matches: + pgi, _ = potential_match.process_model_identifier.rsplit("/", 1) + if pgi.startswith(process_group_identifier): + actual_matches.append(potential_match) + + if len(actual_matches) == 0: + raise ( + ApiError( + error_code="message_triggerable_process_model_not_found", + message=( + f"Could not find a message triggerable process model for {modified_message_name} in the scope of group" + f" {process_group_identifier}" + ), + status_code=400, + ) + ) + + if len(actual_matches) > 1: + message_names = [f"{m.process_model_identifier} - {m.message_name}" for m in actual_matches] + raise ( + ApiError( + error_code="multiple_message_triggerable_process_models_found", + message=f"Found {len(actual_matches)}. Expected 1. Found entries: {message_names}", + status_code=400, + ) + ) + mtp: MessageTriggerableProcessModel = actual_matches[0] + return mtp + + @classmethod + def run_process_model_from_message( + cls, + modified_message_name: str, + body: dict[str, Any], + execution_mode: str | None = None, + ) -> MessageInstanceModel: + message_name, _process_group_identifier = MessageInstanceModel.split_modified_message_name(modified_message_name) + + # Create the send message + # TODO: support the full message id - including process group - in message instance + message_instance = MessageInstanceModel( + message_type="send", + name=message_name, + payload=body, + user_id=g.user.id, + ) + db.session.add(message_instance) + db.session.commit() + try: + receiver_message = cls.correlate_send_message(message_instance, execution_mode=execution_mode) + except Exception as e: + db.session.delete(message_instance) + db.session.commit() + raise e + if not receiver_message: + db.session.delete(message_instance) + db.session.commit() + raise ( + ApiError( + error_code="message_not_accepted", + message=( + "No running process instances correlate with the given message" + f" name of '{modified_message_name}'. And this message name is not" + " currently associated with any process Start Event. Nothing" + " to do." + ), + status_code=400, + ) + ) + return receiver_message + + @classmethod + def _cancel_non_matching_start_events( + cls, processor_receive: ProcessInstanceProcessor, message_triggerable_process_model: MessageTriggerableProcessModel + ) -> None: + """Cancel any start event that does not match the start event that triggered this. + + After that SpiffWorkflow and the WorkflowExecutionService can figure it out. + """ + start_tasks = processor_receive.bpmn_process_instance.get_tasks(spec_class=StartEventMixin) + for start_task in start_tasks: + if not isinstance(start_task.task_spec.event_definition, MessageEventDefinition): + start_task.cancel() + elif start_task.task_spec.event_definition.name != message_triggerable_process_model.message_name: + start_task.cancel() + + @staticmethod + def get_process_instance_for_message_instance( + message_instance_receive: MessageInstanceModel, + ) -> ProcessInstanceModel: + process_instance_receive: ProcessInstanceModel = ProcessInstanceModel.query.filter_by( + id=message_instance_receive.process_instance_id + ).first() + if process_instance_receive is None: + raise MessageServiceError( + ( + ( + "Process instance cannot be found for queued message:" + f" {message_instance_receive.id}. Tried with id" + f" {message_instance_receive.process_instance_id}" + ), + ) + ) + return process_instance_receive diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/task_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/task_service.py index 15ecb230..88542c67 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/task_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/task_service.py @@ -10,6 +10,7 @@ from SpiffWorkflow.bpmn.workflow import BpmnWorkflow # type: ignore from SpiffWorkflow.exceptions import WorkflowException # type: ignore from SpiffWorkflow.task import Task as SpiffTask # type: ignore from SpiffWorkflow.util.task import TaskState # type: ignore +from sqlalchemy import asc from spiffworkflow_backend.models.bpmn_process import BpmnProcessModel from spiffworkflow_backend.models.bpmn_process import BpmnProcessNotFoundError @@ -17,6 +18,7 @@ from spiffworkflow_backend.models.bpmn_process_definition import BpmnProcessDefi from spiffworkflow_backend.models.db import SpiffworkflowBaseDBModel from spiffworkflow_backend.models.db import db from spiffworkflow_backend.models.human_task import HumanTaskModel +from spiffworkflow_backend.models.human_task_user import HumanTaskUserModel from spiffworkflow_backend.models.json_data import JsonDataDict from spiffworkflow_backend.models.json_data import JsonDataModel from spiffworkflow_backend.models.process_instance import ProcessInstanceModel @@ -707,6 +709,17 @@ class TaskService: def get_name_for_display(cls, entity: TaskDefinitionModel | BpmnProcessDefinitionModel) -> str: return entity.bpmn_name or entity.bpmn_identifier + @classmethod + def next_human_task_for_user(cls, process_instance_id: int, user_id: int) -> HumanTaskModel | None: + next_human_task: HumanTaskModel | None = ( + HumanTaskModel.query.filter_by(process_instance_id=process_instance_id, completed=False) + .order_by(asc(HumanTaskModel.id)) # type: ignore + .join(HumanTaskUserModel) + .filter_by(user_id=user_id) + .first() + ) + return next_human_task + @classmethod def _task_subprocess(cls, spiff_task: SpiffTask) -> tuple[str | None, BpmnWorkflow | None]: top_level_workflow = spiff_task.workflow.top_workflow diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/user_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/user_service.py index 76ef5d0f..85279cef 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/user_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/user_service.py @@ -285,3 +285,12 @@ class UserService: user = cls.create_user(username, "spiff_system_service", "spiff_system_service_id") return user + + @classmethod + def create_public_user(cls) -> UserModel: + username = UserModel.generate_random_username() + user = UserService.create_user(username, "spiff_public_service", username) + cls.add_user_to_group_or_add_to_waiting( + user.username, current_app.config["SPIFFWORKFLOW_BACKEND_DEFAULT_PUBLIC_USER_GROUP"] + ) + return user diff --git a/spiffworkflow-backend/tests/data/message-start-event-with-form/entry-form-schema.json b/spiffworkflow-backend/tests/data/message-start-event-with-form/entry-form-schema.json new file mode 100644 index 00000000..9bf6b006 --- /dev/null +++ b/spiffworkflow-backend/tests/data/message-start-event-with-form/entry-form-schema.json @@ -0,0 +1,11 @@ +{ + "title": "Form for message start event", + "type": "object", + "required": ["firstName"], + "properties": { + "firstName": { + "type": "string", + "title": "First name" + } + } +} diff --git a/spiffworkflow-backend/tests/data/message-start-event-with-form/entry-form-uischema.json b/spiffworkflow-backend/tests/data/message-start-event-with-form/entry-form-uischema.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/spiffworkflow-backend/tests/data/message-start-event-with-form/entry-form-uischema.json @@ -0,0 +1 @@ +{} diff --git a/spiffworkflow-backend/tests/data/message-start-event-with-form/message-start-event-with-form.bpmn b/spiffworkflow-backend/tests/data/message-start-event-with-form/message-start-event-with-form.bpmn new file mode 100644 index 00000000..7fa5969d --- /dev/null +++ b/spiffworkflow-backend/tests/data/message-start-event-with-form/message-start-event-with-form.bpmn @@ -0,0 +1,68 @@ + + + + + + + + + The process instance completed successfully. + + Flow_12pkbxb + + + + + + ## Enter your frist name + # Thanks + +We hear you. Your name is **{{incoming_request['firstName']}}**. + + + + + + Flow_17db3yp + + + + Flow_17db3yp + Flow_12pkbxb + a = 1 + + + + + incoming_request + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spiffworkflow-backend/tests/data/message-start-event-with-form/process_model.json b/spiffworkflow-backend/tests/data/message-start-event-with-form/process_model.json new file mode 100644 index 00000000..3c29ee93 --- /dev/null +++ b/spiffworkflow-backend/tests/data/message-start-event-with-form/process_model.json @@ -0,0 +1,9 @@ +{ + "description": "", + "display_name": "message-start-event-with-form", + "exception_notification_addresses": [], + "fault_or_suspend_on_exception": "fault", + "metadata_extraction_paths": null, + "primary_file_name": "message-start-event-with-form.bpmn", + "primary_process_id": "Process_message_start_event_with_form_bkmtffv" +} diff --git a/spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/admin-task-exampledata.json b/spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/admin-task-exampledata.json new file mode 100644 index 00000000..ce6e1671 --- /dev/null +++ b/spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/admin-task-exampledata.json @@ -0,0 +1,4 @@ +{ + "firstName": "Chuck", + "done": false +} diff --git a/spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/admin-task-schema.json b/spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/admin-task-schema.json new file mode 100644 index 00000000..07c53cba --- /dev/null +++ b/spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/admin-task-schema.json @@ -0,0 +1,15 @@ +{ + "title": "Approval time", + "description": "Are we approving this?", + "type": "object", + "required": [ + "firstName" + ], + "properties": { + "done": { + "type": "boolean", + "title": "Approved", + "default": false + } + } +} diff --git a/spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/admin-task-uischema.json b/spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/admin-task-uischema.json new file mode 100644 index 00000000..9d334096 --- /dev/null +++ b/spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/admin-task-uischema.json @@ -0,0 +1,10 @@ +{ + "firstName": { + "ui:autofocus": true, + "ui:emptyValue": "", + "ui:placeholder": "ui:emptyValue causes this field to always be valid despite being required", + "ui:autocomplete": "family-name", + "ui:enableMarkdownInDescription": true, + "ui:description": "Make text **bold** or *italic*. Take a look at other options [here](https://probablyup.com/markdown-to-jsx/)." + } +} diff --git a/spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/entry-form-schema.json b/spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/entry-form-schema.json new file mode 100644 index 00000000..9bf6b006 --- /dev/null +++ b/spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/entry-form-schema.json @@ -0,0 +1,11 @@ +{ + "title": "Form for message start event", + "type": "object", + "required": ["firstName"], + "properties": { + "firstName": { + "type": "string", + "title": "First name" + } + } +} diff --git a/spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/entry-form-uischema.json b/spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/entry-form-uischema.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/entry-form-uischema.json @@ -0,0 +1 @@ +{} diff --git a/spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/message-start-event-with-multiple-forms.bpmn b/spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/message-start-event-with-multiple-forms.bpmn new file mode 100644 index 00000000..9c6eb4b5 --- /dev/null +++ b/spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/message-start-event-with-multiple-forms.bpmn @@ -0,0 +1,122 @@ + + + + + + + + + message_start_event_one + script_task_one + user_task_one + + + Event_0c0wt46 + admin_task + + + + + + + ## Enter your first name + + + + + + Flow_17db3yp + + + + Flow_17db3yp + Flow_12pkbxb + a = 1 + + + + ## Enter your last name {{incoming_request['firstName']}} + + + + + false + # Thanks + +We hear you. Your name is **{{incoming_request['firstName']}} {{lastName}}**. + + Flow_12pkbxb + Flow_14h4dnh + + + Flow_1aprws7 + + + + + + + + + + + Flow_14h4dnh + Flow_1aprws7 + + + + + incoming_request + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/process_model.json b/spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/process_model.json new file mode 100644 index 00000000..a9568869 --- /dev/null +++ b/spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/process_model.json @@ -0,0 +1,9 @@ +{ + "description": "", + "display_name": "message-start-event-with-multiple-forms", + "exception_notification_addresses": [], + "fault_or_suspend_on_exception": "fault", + "metadata_extraction_paths": null, + "primary_file_name": "message-start-event-with-multiple-forms.bpmn", + "primary_process_id": "Process_message_start_event_with_multiple_forms_bkmtffv" +} diff --git a/spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/second-form-exampledata.json b/spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/second-form-exampledata.json new file mode 100644 index 00000000..b5f33cdd --- /dev/null +++ b/spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/second-form-exampledata.json @@ -0,0 +1,5 @@ +{ + "incoming_request": { + "firstName": "joe" + } +} diff --git a/spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/second-form-schema.json b/spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/second-form-schema.json new file mode 100644 index 00000000..19a7f558 --- /dev/null +++ b/spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/second-form-schema.json @@ -0,0 +1,11 @@ +{ + "title": "Information request, part deux", + "description": "Hey, {{incoming_request['firstName']}}. Thanks for telling us who you are. Just one more field.", + "type": "object", + "properties": { + "lastName": { + "type": "string", + "title": "Last name" + } + } +} diff --git a/spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/second-form-uischema.json b/spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/second-form-uischema.json new file mode 100644 index 00000000..add08bd0 --- /dev/null +++ b/spiffworkflow-backend/tests/data/message-start-event-with-multiple-forms/second-form-uischema.json @@ -0,0 +1,5 @@ +{ + "lastName": { + "ui:autoFocus": true + } +} diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/helpers/base_test.py b/spiffworkflow-backend/tests/spiffworkflow_backend/helpers/base_test.py index 0c54a4c6..516883f4 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/helpers/base_test.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/helpers/base_test.py @@ -57,9 +57,7 @@ class BaseTest: ) @staticmethod - def logged_in_headers( - user: UserModel, _redirect_url: str = "http://some/frontend/url", extra_token_payload: dict | None = None - ) -> dict[str, str]: + def logged_in_headers(user: UserModel, extra_token_payload: dict | None = None) -> dict[str, str]: return {"Authorization": "Bearer " + user.encode_auth_token(extra_token_payload)} def create_group_and_model_with_bpmn( diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_authentication.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_authentication.py index 13ad6575..3df5a76b 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_authentication.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_authentication.py @@ -14,6 +14,7 @@ from spiffworkflow_backend.services.service_account_service import ServiceAccoun from spiffworkflow_backend.services.user_service import UserService from tests.spiffworkflow_backend.helpers.base_test import BaseTest +from tests.spiffworkflow_backend.helpers.test_data import load_test_spec class TestAuthentication(BaseTest): @@ -152,3 +153,51 @@ class TestAuthentication(BaseTest): assert response.status_code == 500 assert response.json is not None assert response.json["message"].startswith("InvalidRedirectUrlError:") + + def test_can_access_public_endpoints_and_get_token( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + ) -> None: + group_info: list[GroupPermissionsDict] = [ + { + "users": [], + "name": app.config["SPIFFWORKFLOW_BACKEND_DEFAULT_PUBLIC_USER_GROUP"], + "permissions": [{"actions": ["create", "read"], "uri": "/public/*"}], + } + ] + AuthorizationService.refresh_permissions(group_info, group_permissions_only=True) + process_model = load_test_spec( + process_model_id="test_group/message-start-event-with-form", + process_model_source_directory="message-start-event-with-form", + ) + process_group_identifier, _ = process_model.modified_process_model_identifier().rsplit(":", 1) + url = f"/v1.0/public/messages/form/{process_group_identifier}:bounty_start" + + response = client.get(url) + assert response.status_code == 200 + headers_dict = dict(response.headers) + assert "Set-Cookie" in headers_dict + cookie = headers_dict["Set-Cookie"] + cookie_split = cookie.split(";") + access_token = [cookie for cookie in cookie_split if cookie.startswith("access_token=")][0] + assert access_token is not None + re_result = re.match(r"^access_token=[\w_\.-]+$", access_token) + assert re_result is not None + + response = client.get( + url, + headers={"Authorization": "Bearer " + access_token.split("=")[1]}, + ) + assert response.status_code == 200 + + # make sure we do not create and set a new cookie with this request + headers_dict = dict(response.headers) + assert "Set-Cookie" not in headers_dict + + response = client.get( + "/v1.0/process-groups", + headers={"Authorization": "Bearer " + access_token.split("=")[1]}, + ) + assert response.status_code == 403 diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_authorization.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_authorization.py deleted file mode 100644 index 0619b291..00000000 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_authorization.py +++ /dev/null @@ -1,157 +0,0 @@ -from tests.spiffworkflow_backend.helpers.base_test import BaseTest - - -class TestAuthorization(BaseTest): - pass - # def test_get_bearer_token(self, app: Flask) -> None: - # """Test_get_bearer_token.""" - # for user_id in ("user_1", "user_2", "admin_1", "admin_2"): - # public_access_token = self.get_public_access_token(user_id, user_id) - # bearer_token = AuthenticationService.get_bearer_token(public_access_token) - # assert isinstance(public_access_token, str) - # assert isinstance(bearer_token, dict) - # assert "access_token" in bearer_token - # assert isinstance(bearer_token["access_token"], str) - # assert "refresh_token" in bearer_token - # assert isinstance(bearer_token["refresh_token"], str) - # assert "token_type" in bearer_token - # assert bearer_token["token_type"] == "Bearer" - # assert "scope" in bearer_token - # assert isinstance(bearer_token["scope"], str) - # - # def test_get_user_info_from_public_access_token(self, app: Flask) -> None: - # """Test_get_user_info_from_public_access_token.""" - # for user_id in ("user_1", "user_2", "admin_1", "admin_2"): - # public_access_token = self.get_public_access_token(user_id, user_id) - # user_info = AuthenticationService.get_user_info_from_id_token( - # public_access_token - # ) - # assert "sub" in user_info - # assert isinstance(user_info["sub"], str) - # assert len(user_info["sub"]) == 36 - # assert "preferred_username" in user_info - # assert user_info["preferred_username"] == user_id - # assert "email" in user_info - # assert user_info["email"] == f"{user_id}@example.com" - # - # def test_introspect_token(self, app: Flask) -> None: - # """Test_introspect_token.""" - # ( - # keycloak_server_url, - # keycloak_client_id, - # keycloak_realm_name, - # keycloak_client_secret_key, - # ) = self.get_keycloak_constants(app) - # for user_id in ("user_1", "user_2", "admin_1", "admin_2"): - # basic_token = self.get_public_access_token(user_id, user_id) - # introspection = AuthenticationService.introspect_token(basic_token) - # assert isinstance(introspection, dict) - # assert introspection["typ"] == "Bearer" - # assert introspection["preferred_username"] == user_id - # assert introspection["client_id"] == "spiffworkflow-frontend" - # - # assert "resource_access" in introspection - # resource_access = introspection["resource_access"] - # assert isinstance(resource_access, dict) - # - # assert keycloak_client_id in resource_access - # client = resource_access[keycloak_client_id] - # assert "roles" in client - # roles = client["roles"] - # - # assert isinstance(roles, list) - # if user_id == "admin_1": - # assert len(roles) == 2 - # for role in roles: - # assert role in ("User", "Admin") - # elif user_id == "admin_2": - # assert len(roles) == 1 - # assert roles[0] == "User" - # elif user_id == "user_1" or user_id == "user_2": - # assert len(roles) == 2 - # for role in roles: - # assert role in ("User", "Anonymous") - # - # def test_get_permission_by_token(self, app: Flask) -> None: - # """Test_get_permission_by_token.""" - # output: dict = {} - # for user_id in ("user_1", "user_2", "admin_1", "admin_2"): - # output[user_id] = {} - # basic_token = self.get_public_access_token(user_id, user_id) - # permissions = AuthenticationService.get_permission_by_basic_token( - # basic_token - # ) - # if isinstance(permissions, list): - # for permission in permissions: - # resource_name = permission["rsname"] - # output[user_id][resource_name] = {} - # # assert resource_name in resource_names - # # if resource_name == 'Process Groups' or resource_name == 'Process Models': - # if "scopes" in permission: - # scopes = permission["scopes"] - # output[user_id][resource_name]["scopes"] = scopes - # - # # if user_id == 'admin_1': - # # # assert len(permissions) == 3 - # # for permission in permissions: - # # resource_name = permission['rsname'] - # # # assert resource_name in resource_names - # # if resource_name == 'Process Groups' or resource_name == 'Process Models': - # # # assert len(permission['scopes']) == 4 - # # for item in permission['scopes']: - # # # assert item in ('instantiate', 'read', 'update', 'delete') - # # ... - # # else: - # # # assert resource_name == 'Default Resource' - # # # assert 'scopes' not in permission - # # ... - # # - # # if user_id == 'admin_2': - # # # assert len(permissions) == 3 - # # for permission in permissions: - # # resource_name = permission['rsname'] - # # # assert resource_name in resource_names - # # if resource_name == 'Process Groups' or resource_name == 'Process Models': - # # # assert len(permission['scopes']) == 1 - # # # assert permission['scopes'][0] == 'read' - # # ... - # # else: - # # # assert resource_name == 'Default Resource' - # # # assert 'scopes' not in permission - # # ... - # # else: - # # print(f"No Permissions: {permissions}") - # print("test_get_permission_by_token") - # - # def test_get_auth_status_for_resource_and_scope_by_token(self, app: Flask) -> None: - # """Test_get_auth_status_for_resource_and_scope_by_token.""" - # resources = "Admin", "Process Groups", "Process Models" - # # scope = 'read' - # output: dict = {} - # for user_id in ("user_1", "user_2", "admin_1", "admin_2"): - # output[user_id] = {} - # basic_token = self.get_public_access_token(user_id, user_id) - # for resource in resources: - # output[user_id][resource] = {} - # for scope in "instantiate", "read", "update", "delete": - # auth_status = AuthenticationService.get_auth_status_for_resource_and_scope_by_token( - # basic_token, resource, scope - # ) - # output[user_id][resource][scope] = auth_status - # print("test_get_auth_status_for_resource_and_scope_by_token") - # - # def test_get_permissions_by_token_for_resource_and_scope(self, app: Flask) -> None: - # """Test_get_permissions_by_token_for_resource_and_scope.""" - # resource_names = "Default Resource", "Process Groups", "Process Models" - # output: dict = {} - # for user_id in ("user_1", "user_2", "admin_1", "admin_2"): - # output[user_id] = {} - # basic_token = self.get_public_access_token(user_id, user_id) - # for resource in resource_names: - # output[user_id][resource] = {} - # for scope in "instantiate", "read", "update", "delete": - # permissions = AuthenticationService.get_permissions_by_token_for_resource_and_scope( - # basic_token, resource, scope - # ) - # output[user_id][resource][scope] = permissions - # print("test_get_permissions_by_token_for_resource_and_scope") diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_public_controller.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_public_controller.py new file mode 100644 index 00000000..da05aba1 --- /dev/null +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_public_controller.py @@ -0,0 +1,163 @@ +import json + +from flask import Flask +from flask.testing import FlaskClient +from spiffworkflow_backend.models.process_instance import ProcessInstanceModel +from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus +from spiffworkflow_backend.services.authorization_service import AuthorizationService +from spiffworkflow_backend.services.authorization_service import GroupPermissionsDict + +from tests.spiffworkflow_backend.helpers.base_test import BaseTest +from tests.spiffworkflow_backend.helpers.test_data import load_test_spec + + +class TestPublicController(BaseTest): + def test_can_get_a_form_from_message_start_event( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + ) -> None: + group_info: list[GroupPermissionsDict] = [ + { + "users": [], + "name": app.config["SPIFFWORKFLOW_BACKEND_DEFAULT_PUBLIC_USER_GROUP"], + "permissions": [{"actions": ["create", "read"], "uri": "/public/*"}], + } + ] + AuthorizationService.refresh_permissions(group_info, group_permissions_only=True) + process_model = load_test_spec( + process_model_id="test_group/message-start-event-with-form", + process_model_source_directory="message-start-event-with-form", + ) + process_group_identifier, _ = process_model.modified_process_model_identifier().rsplit(":", 1) + url = f"/v1.0/public/messages/form/{process_group_identifier}:bounty_start" + + response = client.get(url) + assert response.status_code == 200 + assert response.json is not None + assert "form_schema" in response.json + assert "form_ui_schema" in response.json + assert response.json["form_schema"]["title"] == "Form for message start event" + + def test_can_submit_to_public_message_submit( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + ) -> None: + group_info: list[GroupPermissionsDict] = [ + { + "users": [], + "name": app.config["SPIFFWORKFLOW_BACKEND_DEFAULT_PUBLIC_USER_GROUP"], + "permissions": [{"actions": ["create", "read"], "uri": "/public/*"}], + } + ] + AuthorizationService.refresh_permissions(group_info, group_permissions_only=True) + process_model = load_test_spec( + process_model_id="test_group/message-start-event-with-form", + process_model_source_directory="message-start-event-with-form", + ) + process_group_identifier, _ = process_model.modified_process_model_identifier().rsplit(":", 1) + url = f"/v1.0/public/messages/submit/{process_group_identifier}:bounty_start?execution_mode=synchronous" + + response = client.post( + url, + data=json.dumps( + {"firstName": "MyName"}, + ), + content_type="application/json", + ) + assert response.status_code == 200 + assert response.json is not None + assert "form" in response.json + assert "confirmation_message_markdown" in response.json + assert "task_guid" in response.json + assert response.json["form"] is None + assert response.json["confirmation_message_markdown"] == "# Thanks\n\nWe hear you. Your name is **MyName**." + assert response.json["task_guid"] is None + + def test_can_submit_to_public_message_submit_and_get_and_submit_subsequent_form( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + ) -> None: + user = self.find_or_create_user("testuser1") + admin_user = self.find_or_create_user("admin") + headers = self.logged_in_headers(user, extra_token_payload={"public": True}) + group_info: list[GroupPermissionsDict] = [ + { + "users": [user.username], + "name": app.config["SPIFFWORKFLOW_BACKEND_DEFAULT_PUBLIC_USER_GROUP"], + "permissions": [{"actions": ["create", "read", "update"], "uri": "/public/*"}], + }, + { + "users": [admin_user.username], + "name": "admin", + "permissions": [{"actions": ["all"], "uri": "/*"}], + }, + ] + AuthorizationService.refresh_permissions(group_info) + process_model = load_test_spec( + process_model_id="test_group/message-start-event-with-multiple-forms", + process_model_source_directory="message-start-event-with-multiple-forms", + ) + process_group_identifier, _ = process_model.modified_process_model_identifier().rsplit(":", 1) + initial_url = ( + f"/v1.0/public/messages/submit/{process_group_identifier}:bounty_start_multiple_forms?execution_mode=synchronous" + ) + response = client.post( + initial_url, + data=json.dumps( + {"firstName": "MyName"}, + ), + content_type="application/json", + headers=headers, + ) + assert response.status_code == 200 + assert response.json is not None + assert "form" in response.json + assert "confirmation_message_markdown" in response.json + assert "task_guid" in response.json + assert "process_instance_id" in response.json + assert response.json["form"] == { + "form_schema": { + "description": "Hey, MyName. Thanks for telling us who you are. Just one more field.", + "properties": {"lastName": {"title": "Last name", "type": "string"}}, + "title": "Information request, part deux", + "type": "object", + }, + "form_ui_schema": {"lastName": {"ui:autoFocus": True}}, + "instructions_for_end_user": "## Enter your last name MyName", + } + assert response.json["confirmation_message_markdown"] is None + + task_guid = response.json["task_guid"] + assert task_guid is not None + process_instance_id = response.json["process_instance_id"] + assert process_instance_id is not None + + second_form_url = f"/v1.0/public/tasks/{process_instance_id}/{task_guid}?execution_mode=synchronous" + response = client.put( + second_form_url, + data=json.dumps( + {"lastName": "MyLastName"}, + ), + content_type="application/json", + headers=headers, + ) + + assert response.status_code == 200 + assert response.json is not None + assert "form" in response.json + assert "confirmation_message_markdown" in response.json + assert "task_guid" in response.json + assert "process_instance_id" in response.json + assert response.json["form"] is None + assert response.json["confirmation_message_markdown"] == "# Thanks\n\nWe hear you. Your name is **MyName MyLastName**." + assert response.json["task_guid"] is None + assert response.json["process_instance_id"] == process_instance_id + + process_instance = ProcessInstanceModel.query.filter_by(id=process_instance_id).first() + assert process_instance.status == ProcessInstanceStatus.user_input_required.value diff --git a/spiffworkflow-frontend/src/App.tsx b/spiffworkflow-frontend/src/App.tsx index 31adaf5c..8a9f46e9 100644 --- a/spiffworkflow-frontend/src/App.tsx +++ b/spiffworkflow-frontend/src/App.tsx @@ -1,19 +1,41 @@ import { defineAbility } from '@casl/ability'; import React from 'react'; +import { createBrowserRouter, Outlet, RouterProvider } from 'react-router-dom'; import { AbilityContext } from './contexts/Can'; import APIErrorProvider from './contexts/APIErrorContext'; import ContainerForExtensions from './ContainerForExtensions'; +import PublicRoutes from './routes/PublicRoutes'; export default function App() { const ability = defineAbility(() => {}); - return ( -
- - - - - -
- ); + const routeComponents = () => { + return [ + { path: 'public/*', element: }, + { + path: '*', + element: , + }, + ]; + }; + + const layout = () => { + return ( +
+ + + ; + + +
+ ); + }; + const router = createBrowserRouter([ + { + path: '*', + Component: layout, + children: routeComponents(), + }, + ]); + return ; } diff --git a/spiffworkflow-frontend/src/ContainerForExtensions.tsx b/spiffworkflow-frontend/src/ContainerForExtensions.tsx index 6fc5ed6d..6c6c2e24 100644 --- a/spiffworkflow-frontend/src/ContainerForExtensions.tsx +++ b/spiffworkflow-frontend/src/ContainerForExtensions.tsx @@ -1,5 +1,5 @@ import { Content } from '@carbon/react'; -import { createBrowserRouter, RouterProvider, Outlet } from 'react-router-dom'; +import { Routes, Route } from 'react-router-dom'; import React, { useEffect, useState } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; @@ -100,15 +100,17 @@ export default function ContainerForExtensions() { ]); const routeComponents = () => { - return [ - { - path: '*', - element: , - }, - { path: 'editor/*', element: }, - { path: 'extensions/:page_identifier', element: }, - { path: 'login', element: }, - ]; + return ( + + } + /> + } /> + } /> + } /> + + ); }; const backendIsDownPage = () => { @@ -120,31 +122,20 @@ export default function ContainerForExtensions() { return []; } if (backendIsUp) { - return ; + return routeComponents(); } return backendIsDownPage(); }; - const layout = () => { - return ( - <> - - - - - {innerComponents()} - - - - ); - }; - - const router = createBrowserRouter([ - { - path: '*', - Component: layout, - children: routeComponents(), - }, - ]); - return ; + return ( + <> + + + + + {innerComponents()} + + + + ); } diff --git a/spiffworkflow-frontend/src/components/CustomForm.tsx b/spiffworkflow-frontend/src/components/CustomForm.tsx index 60f34de0..b6da747d 100644 --- a/spiffworkflow-frontend/src/components/CustomForm.tsx +++ b/spiffworkflow-frontend/src/components/CustomForm.tsx @@ -2,7 +2,8 @@ import validator from '@rjsf/validator-ajv8'; import { ReactNode } from 'react'; import { RegistryFieldsType } from '@rjsf/utils'; import { Button } from '@carbon/react'; -import { Form } from '../rjsf/carbon_theme'; +import { Form as MuiForm } from '@rjsf/mui'; +import { Form as CarbonForm } from '../rjsf/carbon_theme'; import { DATE_RANGE_DELIMITER } from '../config'; import DateRangePickerWidget from '../rjsf/custom_widgets/DateRangePicker/DateRangePickerWidget'; import TypeaheadWidget from '../rjsf/custom_widgets/TypeaheadWidget/TypeaheadWidget'; @@ -29,6 +30,7 @@ type OwnProps = { noValidate?: boolean; restrictedWidth?: boolean; submitButtonText?: string; + reactJsonSchemaForm?: string; }; export default function CustomForm({ @@ -43,6 +45,7 @@ export default function CustomForm({ noValidate = false, restrictedWidth = false, submitButtonText, + reactJsonSchemaForm = 'carbon', }: OwnProps) { // set in uiSchema using the "ui:widget" key for a property const rjsfWidgets = { @@ -444,24 +447,32 @@ export default function CustomForm({ ); } - return ( -
- {childrenToUse} -
- ); + const formProps = { + id, + disabled, + formData, + onChange, + onSubmit, + schema, + uiSchema, + widgets: rjsfWidgets, + validator, + customValidate, + noValidate, + fields: rjsfFields, + templates: rjsfTemplates, + omitExtraData: true, + }; + if (reactJsonSchemaForm === 'carbon') { + // eslint-disable-next-line react/jsx-props-no-spreading + return {childrenToUse}; + } + + if (reactJsonSchemaForm === 'mui') { + // eslint-disable-next-line react/jsx-props-no-spreading + return {childrenToUse}; + } + + console.error(`Unsupported form type: ${reactJsonSchemaForm}`); + return null; } diff --git a/spiffworkflow-frontend/src/helpers.tsx b/spiffworkflow-frontend/src/helpers.tsx index be507684..477b41e3 100644 --- a/spiffworkflow-frontend/src/helpers.tsx +++ b/spiffworkflow-frontend/src/helpers.tsx @@ -55,6 +55,8 @@ export const getKeyByValue = ( }); }; +// NOTE: rjsf sets blanks values to undefined and JSON.stringify removes keys with undefined values +// so we convert undefined values to null recursively so that we can unset values in form fields export const recursivelyChangeNullAndUndefined = (obj: any, newValue: any) => { if (obj === null || obj === undefined) { return newValue; diff --git a/spiffworkflow-frontend/src/index.css b/spiffworkflow-frontend/src/index.css index eac89731..e8639304 100644 --- a/spiffworkflow-frontend/src/index.css +++ b/spiffworkflow-frontend/src/index.css @@ -46,12 +46,14 @@ a.cds--header__menu-item { font-style: italic; } -h1 { +h1, +h1.MuiTypography-h1 { font-weight: 400; font-size: 28px; line-height: 36px; color: #161616; margin-bottom: 1rem; + letter-spacing: 0.00938em; } h2 { diff --git a/spiffworkflow-frontend/src/interfaces.ts b/spiffworkflow-frontend/src/interfaces.ts index 0b239b4b..e94da2af 100644 --- a/spiffworkflow-frontend/src/interfaces.ts +++ b/spiffworkflow-frontend/src/interfaces.ts @@ -503,3 +503,15 @@ export interface ElementForArray { key: string; component: ReactElement | null; } + +export interface PublicTaskForm { + form_schema: any; + form_ui_schema: any; + instructions_for_end_user?: string; +} +export interface PublicTaskSubmitResponse { + form: PublicTaskForm; + task_guid: string; + process_instance_id: number; + confirmation_message_markdown: string; +} diff --git a/spiffworkflow-frontend/src/routes/BaseRoutes.tsx b/spiffworkflow-frontend/src/routes/BaseRoutes.tsx index 335c33f6..80a4f15d 100644 --- a/spiffworkflow-frontend/src/routes/BaseRoutes.tsx +++ b/spiffworkflow-frontend/src/routes/BaseRoutes.tsx @@ -32,7 +32,7 @@ export default function BaseRoutes({ extensionUxElements }: OwnProps) { ); }; - if (extensionUxElements) { + if (extensionUxElements !== null) { const extensionRoutes = ExtensionUxElementMap({ displayLocation: 'routes', elementCallback, diff --git a/spiffworkflow-frontend/src/routes/PublicRoutes.tsx b/spiffworkflow-frontend/src/routes/PublicRoutes.tsx new file mode 100644 index 00000000..c32b50a8 --- /dev/null +++ b/spiffworkflow-frontend/src/routes/PublicRoutes.tsx @@ -0,0 +1,18 @@ +import { Content } from '@carbon/react'; +import { Route, Routes } from 'react-router-dom'; +import MessageStartEventForm from './public/MessageStartEventForm'; +import SignOut from './public/SignOut'; + +export default function PublicRoutes() { + return ( + + + } + /> + } /> + + + ); +} diff --git a/spiffworkflow-frontend/src/routes/public/MessageStartEventForm.tsx b/spiffworkflow-frontend/src/routes/public/MessageStartEventForm.tsx new file mode 100644 index 00000000..fd5f4693 --- /dev/null +++ b/spiffworkflow-frontend/src/routes/public/MessageStartEventForm.tsx @@ -0,0 +1,127 @@ +import { Column, Grid, Loading } from '@carbon/react'; +import { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import HttpService from '../../services/HttpService'; +import CustomForm from '../../components/CustomForm'; +import Page404 from '../Page404'; +import { recursivelyChangeNullAndUndefined } from '../../helpers'; +import useAPIError from '../../hooks/UseApiError'; +import { PublicTaskForm, PublicTaskSubmitResponse } from '../../interfaces'; +import MarkdownRenderer from '../../components/MarkdownRenderer'; +import InstructionsForEndUser from '../../components/InstructionsForEndUser'; + +export default function MessageStartEventForm() { + const params = useParams(); + const [formContents, setFormContents] = useState(null); + const [taskData, setTaskData] = useState(null); + const [formButtonsDisabled, setFormButtonsDisabled] = useState(false); + const { addError, removeError } = useAPIError(); + const [confirmationMessage, setConfirmationMessage] = useState( + null + ); + const [taskSubmitResponse, setTaskSubmitResponse] = + useState(null); + + useEffect(() => { + HttpService.makeCallToBackend({ + path: `/public/messages/form/${params.modified_message_name}`, + successCallback: (result: PublicTaskForm) => setFormContents(result), + failureCallback: (error: any) => { + console.error(error); + }, + }); + }, [params.modified_message_name]); + + const processSubmitResult = (result: PublicTaskSubmitResponse) => { + removeError(); + setFormButtonsDisabled(false); + setTaskSubmitResponse(result); + if (result.form) { + setFormContents(result.form); + } else if (result.confirmation_message_markdown) { + setConfirmationMessage(result.confirmation_message_markdown); + } else { + setConfirmationMessage('Thank you!'); + } + }; + + const handleFormSubmit = (formObject: any, _event: any) => { + if (formButtonsDisabled) { + return; + } + + const dataToSubmit = formObject?.formData; + + setFormButtonsDisabled(true); + removeError(); + + // removing form contents will force the loading icon to appear. + // we could also set a state for it at some point but this seemed + // like a way to reduce states. + setFormContents(null); + + recursivelyChangeNullAndUndefined(dataToSubmit, null); + let path = `/public/messages/submit/${params.modified_message_name}`; + let httpMethod = 'POST'; + if ( + taskSubmitResponse?.task_guid && + taskSubmitResponse?.process_instance_id + ) { + path = `/public/tasks/${taskSubmitResponse.process_instance_id}/${taskSubmitResponse.task_guid}`; + httpMethod = 'PUT'; + } + + HttpService.makeCallToBackend({ + path: `${path}?execution_mode=synchronous`, + successCallback: processSubmitResult, + failureCallback: (error: any) => { + addError(error); + }, + httpMethod, + postBody: dataToSubmit, + }); + }; + + if (confirmationMessage) { + return ( + + ); + } + if (formContents) { + if (formContents.form_schema) { + return ( +
+ + + + { + setTaskData(obj.formData); + }} + onSubmit={handleFormSubmit} + schema={formContents.form_schema} + uiSchema={formContents.form_ui_schema} + restrictedWidth + reactJsonSchemaForm="mui" + /> + + +
+ ); + } + return ; + } + const style = { margin: '50px 0 50px 50px' }; + return ( + + ); +} diff --git a/spiffworkflow-frontend/src/routes/public/SignOut.tsx b/spiffworkflow-frontend/src/routes/public/SignOut.tsx new file mode 100644 index 00000000..20135899 --- /dev/null +++ b/spiffworkflow-frontend/src/routes/public/SignOut.tsx @@ -0,0 +1,26 @@ +import Button from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; + +import UserService from '../../services/UserService'; + +export default function SignOut() { + const logoutUser = () => { + UserService.doLogout(); + }; + + return ( +
+ Access Denied + + You are currently logged in as{' '} + {UserService.getPreferredUsername()}. You do not have + access to this page. Would you like to sign out and sign in as a + different user? + +
+ +
+ ); +} diff --git a/spiffworkflow-frontend/src/services/HttpService.ts b/spiffworkflow-frontend/src/services/HttpService.ts index e553ac63..82d09485 100644 --- a/spiffworkflow-frontend/src/services/HttpService.ts +++ b/spiffworkflow-frontend/src/services/HttpService.ts @@ -96,6 +96,8 @@ backendCallProps) => { } else if (is403) { if (onUnauthorized) { onUnauthorized(result); + } else if (UserService.isPublicUser()) { + window.location.href = '/public/sign_out'; } else { // Hopefully we can make this service a hook and use the error message context directly // eslint-disable-next-line no-alert diff --git a/spiffworkflow-frontend/src/services/UserService.ts b/spiffworkflow-frontend/src/services/UserService.ts index 31cd2965..41332748 100644 --- a/spiffworkflow-frontend/src/services/UserService.ts +++ b/spiffworkflow-frontend/src/services/UserService.ts @@ -43,6 +43,30 @@ const checkPathForTaskShowParams = ( return null; }; +// required for logging out +const getIdToken = () => { + return getCookie('id_token'); +}; +const getAccessToken = () => { + return getCookie('access_token'); +}; +const getAuthenticationIdentifier = () => { + return getCookie('authentication_identifier'); +}; + +const isLoggedIn = () => { + return !!getAccessToken(); +}; + +const isPublicUser = () => { + const idToken = getIdToken(); + if (idToken) { + const idObject = jwt(idToken); + return (idObject as any).public; + } + return false; +}; + const doLogin = ( authenticationOption?: AuthenticationOption, redirectUrl?: string | null @@ -64,21 +88,6 @@ const doLogin = ( window.location.href = url; }; -// required for logging out -const getIdToken = () => { - return getCookie('id_token'); -}; -const getAccessToken = () => { - return getCookie('access_token'); -}; -const getAuthenticationIdentifier = () => { - return getCookie('authentication_identifier'); -}; - -const isLoggedIn = () => { - return !!getAccessToken(); -}; - const doLogout = () => { const idToken = getIdToken(); @@ -88,6 +97,8 @@ const doLogout = () => { // edge case. if the user is already logged out, just take them somewhere that will force them to sign in. if (idToken === null) { logoutRedirectUrl = SIGN_IN_PATH; + } else if (isPublicUser()) { + logoutRedirectUrl += '&backend_only=true'; } window.location.href = logoutRedirectUrl; @@ -169,6 +180,7 @@ const UserService = { getPreferredUsername, getUserEmail, isLoggedIn, + isPublicUser, loginIfNeeded, onlyGuestTaskCompletion, };