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 <jasquat@users.noreply.github.com>
This commit is contained in:
jasquat 2024-03-14 19:55:37 +00:00 committed by GitHub
parent 66067a89da
commit 9acd2954bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 1611 additions and 596 deletions

View File

@ -91,6 +91,11 @@ paths:
required: true required: true
schema: schema:
type: string type: string
- name: backend_only
in: query
required: false
schema:
type: boolean
get: get:
operationId: spiffworkflow_backend.routes.authentication_controller.logout operationId: spiffworkflow_backend.routes.authentication_controller.logout
summary: Logout authenticated user summary: Logout authenticated user
@ -909,11 +914,6 @@ paths:
get: get:
operationId: spiffworkflow_backend.routes.extensions_controller.extension_list operationId: spiffworkflow_backend.routes.extensions_controller.extension_list
summary: Returns the list of available extensions summary: Returns the list of available extensions
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/AwesomeUnspecifiedPayload"
tags: tags:
- Extensions - Extensions
responses: responses:
@ -2530,12 +2530,12 @@ paths:
schema: schema:
$ref: "#/components/schemas/Workflow" $ref: "#/components/schemas/Workflow"
/messages/{message_name}: /messages/{modified_message_name}:
parameters: parameters:
- name: message_name - name: modified_message_name
in: path in: path
required: true required: true
description: The unique name of the message. description: The message_name, modified to replace slashes (/) with colons
schema: schema:
type: string type: string
- name: execution_mode - name: execution_mode
@ -2556,14 +2556,125 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Workflow" $ref: "#/components/schemas/AwesomeUnspecifiedPayload"
responses: responses:
"200": "200":
description: One task description: One task
content: content:
application/json: application/json:
schema: 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}: /logs/{modified_process_model_identifier}/{process_instance_id}:
parameters: parameters:
@ -3775,9 +3886,9 @@ components:
# it will fail validation and not pass the request to the controller. that is generally not desirable # 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. # until we take a closer look at the schemas in here.
AwesomeUnspecifiedPayload: AwesomeUnspecifiedPayload:
properties: # we know that task_submit submits no body at all, and None is not an object, as this so helpfully tells us
anythingyouwant: # type: "object"
type: string additionalProperties: {}
ReportMetadata: ReportMetadata:
properties: properties:
columns: columns:

View File

@ -143,6 +143,7 @@ config_from_env("SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_ABSOLUTE_PATH")
config_from_env("SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME") 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 # 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_USER_GROUP", default="everybody")
config_from_env("SPIFFWORKFLOW_BACKEND_DEFAULT_PUBLIC_USER_GROUP", default="spiff_public")
### sentry ### sentry
config_from_env("SPIFFWORKFLOW_BACKEND_SENTRY_DSN", default="") config_from_env("SPIFFWORKFLOW_BACKEND_SENTRY_DSN", default="")

View File

@ -18,6 +18,8 @@ groups:
users: [dan@sartography.com] users: [dan@sartography.com]
group3: group3:
users: [jon@sartography.com] users: [jon@sartography.com]
spiff_public:
users: []
permissions: permissions:
admin: admin:
@ -43,3 +45,8 @@ permissions:
groups: [group3] groups: [group3]
actions: [read] actions: [read]
uri: PG:misc uri: PG:misc
public_access:
groups: [spiff_public]
actions: [read, create]
uri: /public/*

View File

@ -68,7 +68,7 @@ class HumanTaskModel(SpiffworkflowBaseDBModel):
break break
new_task = Task( new_task = Task(
task.task_id, task.task_guid,
task.task_name, task.task_name,
task.task_title, task.task_title,
task.task_type, task.task_type,

View File

@ -65,6 +65,13 @@ class MessageInstanceModel(SpiffworkflowBaseDBModel):
def validate_status(self, key: str, value: Any) -> Any: def validate_status(self, key: str, value: Any) -> Any:
return self.validate_enum_field(key, value, MessageStatuses) 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: def correlates(self, other: Any, expression_engine: PythonScriptEngine) -> bool:
"""Returns true if the this Message correlates with the given message. """Returns true if the this Message correlates with the given message.

View File

@ -199,15 +199,19 @@ class ProcessInstanceModel(SpiffworkflowBaseDBModel):
def immediately_runnable_statuses(cls) -> list[str]: def immediately_runnable_statuses(cls) -> list[str]:
return ["not_started", "running"] return ["not_started", "running"]
def get_data(self) -> dict: def get_last_completed_task(self) -> TaskModel | None:
"""Returns the data of the last completed task in this process instance.""" last_completed_task: TaskModel | None = (
last_completed_task = (
TaskModel.query.filter_by(process_instance_id=self.id, state="COMPLETED") TaskModel.query.filter_by(process_instance_id=self.id, state="COMPLETED")
.order_by(desc(TaskModel.end_in_seconds)) # type: ignore .order_by(desc(TaskModel.end_in_seconds)) # type: ignore
.first() .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 if last_completed_task: # pragma: no cover
return last_completed_task.json_data() # type: ignore return last_completed_task.json_data()
else: else:
return {} return {}

View File

@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
import math import math
import random
import string
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
@ -91,3 +93,46 @@ class UserModel(SpiffworkflowBaseDBModel):
user_as_json_string = current_app.json.dumps(self) user_as_json_string = current_app.json.dumps(self)
user_dict: dict[str, Any] = current_app.json.loads(user_as_json_string) user_dict: dict[str, Any] = current_app.json.loads(user_as_json_string)
return user_dict 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

View File

@ -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.helpers.api_version import V1_API_PATH_PREFIX
from spiffworkflow_backend.models.group import SPIFF_GUEST_GROUP 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 SPIFF_NO_AUTH_GROUP
from spiffworkflow_backend.models.group import GroupModel
from spiffworkflow_backend.models.service_account import ServiceAccountModel from spiffworkflow_backend.models.service_account import ServiceAccountModel
from spiffworkflow_backend.models.task import TaskModel # noqa: F401 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_GUEST_USER
from spiffworkflow_backend.models.user import SPIFF_NO_AUTH_USER from spiffworkflow_backend.models.user import SPIFF_NO_AUTH_USER
from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.models.user import UserModel
from spiffworkflow_backend.services.authentication_service import AuthenticationService 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.authorization_service import AuthorizationService
from spiffworkflow_backend.services.user_service import UserService 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) user_model = _get_user_model_from_token(decoded_token)
elif token_info["api_key"] is not None: elif token_info["api_key"] is not None:
user_model = _get_user_model_from_api_key(token_info["api_key"]) 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: if user_model:
g.user = 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 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: if redirect_url is None:
redirect_url = "" redirect_url = ""
AuthenticationService.set_user_has_logged_out() 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: def logout_return() -> Response:
@ -460,3 +473,26 @@ def _get_authentication_identifier_from_request() -> str:
authentication_identifier: str = request.headers["SpiffWorkflow-Authentication-Identifier"] authentication_identifier: str = request.headers["SpiffWorkflow-Authentication-Identifier"]
return authentication_identifier return authentication_identifier
return "default" 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()

View File

@ -1,15 +1,11 @@
"""APIs for dealing with process groups, process models, and process instances."""
import json import json
from typing import Any from typing import Any
import flask.wrappers import flask.wrappers
from flask import g
from flask import jsonify from flask import jsonify
from flask import make_response from flask import make_response
from flask.wrappers import 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.message_instance import MessageInstanceModel
from spiffworkflow_backend.models.process_instance import ProcessInstanceModel from spiffworkflow_backend.models.process_instance import ProcessInstanceModel
from spiffworkflow_backend.models.process_instance import ProcessInstanceModelSchema from spiffworkflow_backend.models.process_instance import ProcessInstanceModelSchema
@ -63,43 +59,11 @@ def message_instance_list(
# -H 'content-type: application/json' \ # -H 'content-type: application/json' \
# --data-raw '{"payload":{"sure": "yes", "food": "spicy"}}' # --data-raw '{"payload":{"sure": "yes", "food": "spicy"}}'
def message_send( def message_send(
message_name: str, modified_message_name: str,
body: dict[str, Any], body: dict[str, Any],
execution_mode: str | None = None, execution_mode: str | None = None,
) -> flask.wrappers.Response: ) -> flask.wrappers.Response:
process_instance = None receiver_message = MessageService.run_process_model_from_message(modified_message_name, body, execution_mode)
# 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,
)
)
process_instance = ProcessInstanceModel.query.filter_by(id=receiver_message.process_instance_id).first() process_instance = ProcessInstanceModel.query.filter_by(id=receiver_message.process_instance_id).first()
response_json = { response_json = {
"task_data": process_instance.get_data(), "task_data": process_instance.get_data(),

View File

@ -1,18 +1,28 @@
import json
import uuid
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
import flask.wrappers import flask.wrappers
import sentry_sdk
from flask import Blueprint from flask import Blueprint
from flask import current_app from flask import current_app
from flask import g from flask import g
from flask import jsonify from flask import jsonify
from flask import make_response from flask import make_response
from flask.wrappers import 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 and_
from sqlalchemy import or_ 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.api_error import ApiError
from spiffworkflow_backend.exceptions.process_entity_not_found_error import ProcessEntityNotFoundError 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 import HumanTaskModel
from spiffworkflow_backend.models.human_task_user import HumanTaskUserModel from spiffworkflow_backend.models.human_task_user import HumanTaskUserModel
from spiffworkflow_backend.models.principal import PrincipalModel 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.process_model import ProcessModelInfo
from spiffworkflow_backend.models.reference_cache import ReferenceCacheModel from spiffworkflow_backend.models.reference_cache import ReferenceCacheModel
from spiffworkflow_backend.models.reference_cache import ReferenceSchema 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.authentication_service import AuthenticationService # noqa: F401
from spiffworkflow_backend.services.authorization_service import AuthorizationService 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.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_caller_service import ProcessCallerService
from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor 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.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__) process_api_blueprint = Blueprint("process_api", __name__)
@ -357,3 +374,186 @@ def _get_process_model_for_instantiation(
status_code=400, status_code=400,
) )
return process_model 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

View File

@ -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

View File

@ -1,6 +1,5 @@
import json import json
import os import os
import uuid
from collections import OrderedDict from collections import OrderedDict
from collections.abc import Generator from collections.abc import Generator
from typing import Any 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.task import Task as SpiffTask # type: ignore
from SpiffWorkflow.util.task import TaskState # type: ignore from SpiffWorkflow.util.task import TaskState # type: ignore
from sqlalchemy import and_ from sqlalchemy import and_
from sqlalchemy import asc
from sqlalchemy import desc from sqlalchemy import desc
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import aliased from sqlalchemy.orm import aliased
from sqlalchemy.orm.util import AliasedClass 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.data_migrations.process_instance_migrator import ProcessInstanceMigrator
from spiffworkflow_backend.exceptions.api_error import ApiError from spiffworkflow_backend.exceptions.api_error import ApiError
from spiffworkflow_backend.exceptions.error import HumanTaskAlreadyCompletedError 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 ProcessInstanceStatus
from spiffworkflow_backend.models.process_instance import ProcessInstanceTaskDataCannotBeUpdatedError from spiffworkflow_backend.models.process_instance import ProcessInstanceTaskDataCannotBeUpdatedError
from spiffworkflow_backend.models.process_instance_event import ProcessInstanceEventType 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 Task
from spiffworkflow_backend.models.task import TaskModel from spiffworkflow_backend.models.task import TaskModel
from spiffworkflow_backend.models.task_definition import TaskDefinitionModel 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_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 _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 _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.authorization_service import AuthorizationService
from spiffworkflow_backend.services.error_handling_service import ErrorHandlingService from spiffworkflow_backend.services.error_handling_service import ErrorHandlingService
from spiffworkflow_backend.services.file_system_service import FileSystemService 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.jinja_service import JinjaService
from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor
from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceIsAlreadyLockedError 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_instance_tmp_service import ProcessInstanceTmpService
from spiffworkflow_backend.services.process_model_service import ProcessModelService from spiffworkflow_backend.services.process_model_service import ProcessModelService
from spiffworkflow_backend.services.spec_file_service import SpecFileService 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 from spiffworkflow_backend.services.task_service import TaskService
@ -557,7 +550,12 @@ def task_submit(
execution_mode: str | None = None, execution_mode: str | None = None,
) -> flask.wrappers.Response: ) -> flask.wrappers.Response:
with sentry_sdk.start_span(op="controller_action", description="tasks_controller.task_submit"): 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( 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) process_instance = _find_process_instance_for_me_or_raise(process_instance_id, include_actions=True)
principal = _find_principal_or_raise() 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: if next_human_task_assigned_to_me:
response["task"] = HumanTaskModel.to_task(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 # 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( def _get_tasks(
processes_started_by_user: bool = True, processes_started_by_user: bool = True,
has_lane_assignment_id: bool = True, has_lane_assignment_id: bool = True,
@ -1069,71 +963,6 @@ def _get_tasks(
return make_response(jsonify(response_json), 200) 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 # 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: def _update_form_schema_with_task_data_as_needed(in_dict: dict, task_data: dict) -> None:
for k, value in in_dict.items(): 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 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: 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: if form_ui_schema is None:
return return
@ -1269,14 +1072,3 @@ def _get_task_model_from_guid_or_raise(task_guid: str, process_instance_id: int)
status_code=400, status_code=400,
) )
return task_model 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

View File

@ -1,6 +1,7 @@
import inspect import inspect
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any
import yaml import yaml
from flask import current_app from flask import current_app
@ -77,27 +78,33 @@ PATH_SEGMENTS_FOR_PERMISSION_ALL = [
{"path": "/task-data", "relevant_permissions": ["read", "update"]}, {"path": "/task-data", "relevant_permissions": ["read", "update"]},
] ]
AUTHENTICATION_EXCLUSION_LIST = { AUTHENTICATION_EXCLUSION_LIST = [
"authentication_begin": "spiffworkflow_backend.routes.service_tasks_controller", "spiffworkflow_backend.routes.authentication_controller.authentication_options",
"authentication_callback": "spiffworkflow_backend.routes.service_tasks_controller", "spiffworkflow_backend.routes.authentication_controller.login",
"authentication_options": "spiffworkflow_backend.routes.authentication_controller", "spiffworkflow_backend.routes.authentication_controller.login_api_return",
"github_webhook_receive": "spiffworkflow_backend.routes.webhooks_controller", "spiffworkflow_backend.routes.authentication_controller.login_return",
"login": "spiffworkflow_backend.routes.authentication_controller", "spiffworkflow_backend.routes.authentication_controller.login_with_access_token",
"login_api_return": "spiffworkflow_backend.routes.authentication_controller", "spiffworkflow_backend.routes.authentication_controller.logout",
"login_return": "spiffworkflow_backend.routes.authentication_controller", "spiffworkflow_backend.routes.authentication_controller.logout_return",
"login_with_access_token": "spiffworkflow_backend.routes.authentication_controller", "spiffworkflow_backend.routes.debug_controller.test_raise_error",
"logout": "spiffworkflow_backend.routes.authentication_controller", "spiffworkflow_backend.routes.debug_controller.url_info",
"logout_return": "spiffworkflow_backend.routes.authentication_controller", "spiffworkflow_backend.routes.health_controller.status",
"status": "spiffworkflow_backend.routes.health_controller", "spiffworkflow_backend.routes.service_tasks_controller.authentication_begin",
"task_allows_guest": "spiffworkflow_backend.routes.tasks_controller", "spiffworkflow_backend.routes.service_tasks_controller.authentication_callback",
"test_raise_error": "spiffworkflow_backend.routes.debug_controller", "spiffworkflow_backend.routes.tasks_controller.task_allows_guest",
"url_info": "spiffworkflow_backend.routes.debug_controller", "spiffworkflow_backend.routes.webhooks_controller.github_webhook_receive",
"webhook": "spiffworkflow_backend.routes.webhooks_controller", "spiffworkflow_backend.routes.webhooks_controller.webhook",
# swagger api calls # swagger api calls
"console_ui_home": "connexion.apis.flask_api", "connexion.apis.flask_api.console_ui_home",
"console_ui_static_files": "connexion.apis.flask_api", "connexion.apis.flask_api.console_ui_static_files",
"get_json_spec": "connexion.apis.flask_api", "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: class AuthorizationService:
@ -250,6 +257,17 @@ class AuthorizationService:
db.session.commit() db.session.commit()
return permission_assignment 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 @classmethod
def should_disable_auth_for_request(cls) -> bool: def should_disable_auth_for_request(cls) -> bool:
if request.method == "OPTIONS": if request.method == "OPTIONS":
@ -262,17 +280,10 @@ class AuthorizationService:
if not request.endpoint: if not request.endpoint:
return True return True
api_view_function = current_app.view_functions[request.endpoint] api_function_full_path, module = cls.get_fully_qualified_api_function_from_request()
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
if ( if (
api_function_name api_function_full_path
and ( and (api_function_full_path in AUTHENTICATION_EXCLUSION_LIST)
api_function_name in AUTHENTICATION_EXCLUSION_LIST
and controller_name
and controller_name in AUTHENTICATION_EXCLUSION_LIST[api_function_name]
)
or (module == openid_blueprint or module == scaffold) # don't check permissions for static assets or (module == openid_blueprint or module == scaffold) # don't check permissions for static assets
): ):
return True return True
@ -292,6 +303,22 @@ class AuthorizationService:
return None 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 @classmethod
def check_for_permission(cls, decoded_token: dict | None) -> None: def check_for_permission(cls, decoded_token: dict | None) -> None:
if cls.should_disable_auth_for_request(): if cls.should_disable_auth_for_request():
@ -308,19 +335,7 @@ class AuthorizationService:
if cls.request_allows_guest_access(decoded_token): if cls.request_allows_guest_access(decoded_token):
return None return None
permission_string = cls.get_permission_from_http_method(request.method) cls.check_permission_for_request()
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}",
)
@classmethod @classmethod
def request_is_excluded_from_permission_check(cls) -> bool: def request_is_excluded_from_permission_check(cls) -> bool:

View File

@ -38,12 +38,12 @@ class JinjaHelpers:
class JinjaService: class JinjaService:
@classmethod @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.""" """Assure any instructions for end user are processed for jinja syntax."""
if extensions is None: if extensions is None:
if isinstance(task, TaskModel): if isinstance(task, TaskModel):
extensions = TaskService.get_extensions_from_task_model(task) 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 extensions = task.task_spec.extensions
if extensions and "instructionsForEndUser" in extensions: if extensions and "instructionsForEndUser" in extensions:
if extensions["instructionsForEndUser"]: if extensions["instructionsForEndUser"]:

View File

@ -1,5 +1,7 @@
import os import os
from typing import Any
from flask import g
from SpiffWorkflow.bpmn import BpmnEvent # type: ignore from SpiffWorkflow.bpmn import BpmnEvent # type: ignore
from SpiffWorkflow.bpmn.specs.event_definitions.message import CorrelationProperty # type: ignore from SpiffWorkflow.bpmn.specs.event_definitions.message import CorrelationProperty # type: ignore
from SpiffWorkflow.bpmn.specs.mixins import StartEventMixin # 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, queue_process_instance_if_appropriate,
) )
from spiffworkflow_backend.background_processing.celery_tasks.process_instance_task_producer import should_queue_process_instance 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.helpers.spiff_enum import ProcessInstanceExecutionMode
from spiffworkflow_backend.models.db import db from spiffworkflow_backend.models.db import db
from spiffworkflow_backend.models.message_instance import MessageInstanceModel from spiffworkflow_backend.models.message_instance import MessageInstanceModel
@ -145,40 +148,6 @@ class MessageService:
return process_instance_receive 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 @staticmethod
def process_message_receive( def process_message_receive(
process_instance_receive: ProcessInstanceModel, process_instance_receive: ProcessInstanceModel,
@ -221,3 +190,113 @@ class MessageService:
message_instance_receive.status = MessageStatuses.completed.value message_instance_receive.status = MessageStatuses.completed.value
db.session.add(message_instance_receive) db.session.add(message_instance_receive)
db.session.commit() 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

View File

@ -10,6 +10,7 @@ from SpiffWorkflow.bpmn.workflow import BpmnWorkflow # type: ignore
from SpiffWorkflow.exceptions import WorkflowException # type: ignore from SpiffWorkflow.exceptions import WorkflowException # type: ignore
from SpiffWorkflow.task import Task as SpiffTask # type: ignore from SpiffWorkflow.task import Task as SpiffTask # type: ignore
from SpiffWorkflow.util.task import TaskState # 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 BpmnProcessModel
from spiffworkflow_backend.models.bpmn_process import BpmnProcessNotFoundError 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 SpiffworkflowBaseDBModel
from spiffworkflow_backend.models.db import db from spiffworkflow_backend.models.db import db
from spiffworkflow_backend.models.human_task import HumanTaskModel 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 JsonDataDict
from spiffworkflow_backend.models.json_data import JsonDataModel from spiffworkflow_backend.models.json_data import JsonDataModel
from spiffworkflow_backend.models.process_instance import ProcessInstanceModel from spiffworkflow_backend.models.process_instance import ProcessInstanceModel
@ -707,6 +709,17 @@ class TaskService:
def get_name_for_display(cls, entity: TaskDefinitionModel | BpmnProcessDefinitionModel) -> str: def get_name_for_display(cls, entity: TaskDefinitionModel | BpmnProcessDefinitionModel) -> str:
return entity.bpmn_name or entity.bpmn_identifier 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 @classmethod
def _task_subprocess(cls, spiff_task: SpiffTask) -> tuple[str | None, BpmnWorkflow | None]: def _task_subprocess(cls, spiff_task: SpiffTask) -> tuple[str | None, BpmnWorkflow | None]:
top_level_workflow = spiff_task.workflow.top_workflow top_level_workflow = spiff_task.workflow.top_workflow

View File

@ -285,3 +285,12 @@ class UserService:
user = cls.create_user(username, "spiff_system_service", "spiff_system_service_id") user = cls.create_user(username, "spiff_system_service", "spiff_system_service_id")
return user 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

View File

@ -0,0 +1,11 @@
{
"title": "Form for message start event",
"type": "object",
"required": ["firstName"],
"properties": {
"firstName": {
"type": "string",
"title": "First name"
}
}
}

View File

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:spiffworkflow="http://spiffworkflow.org/bpmn/schema/1.0/core" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" id="Definitions_96f6665" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.0.0-dev">
<bpmn:collaboration id="Collaboration_1h79dxi">
<bpmn:participant id="Participant_04cn64m" processRef="Process_message_start_event_with_form_bkmtffv" />
</bpmn:collaboration>
<bpmn:process id="Process_message_start_event_with_form_bkmtffv" isExecutable="true">
<bpmn:endEvent id="EndEvent_1">
<bpmn:extensionElements>
<spiffworkflow:instructionsForEndUser>The process instance completed successfully.</spiffworkflow:instructionsForEndUser>
</bpmn:extensionElements>
<bpmn:incoming>Flow_12pkbxb</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_17db3yp" sourceRef="message_start_event_one" targetRef="script_task_one" />
<bpmn:sequenceFlow id="Flow_12pkbxb" sourceRef="script_task_one" targetRef="EndEvent_1" />
<bpmn:startEvent id="message_start_event_one" name="Message Start Event One" messageRef="[object Object]">
<bpmn:extensionElements>
<spiffworkflow:instructionsForEndUser>## Enter your frist name</spiffworkflow:instructionsForEndUser>
<spiffworkflow:guestConfirmation># Thanks
We hear you. Your name is **{{incoming_request['firstName']}}**.</spiffworkflow:guestConfirmation>
<spiffworkflow:properties>
<spiffworkflow:property name="formJsonSchemaFilename" value="entry-form-schema.json" />
<spiffworkflow:property name="formUiSchemaFilename" value="entry-form-uischema.json" />
</spiffworkflow:properties>
</bpmn:extensionElements>
<bpmn:outgoing>Flow_17db3yp</bpmn:outgoing>
<bpmn:messageEventDefinition id="MessageEventDefinition_13ctnqx" messageRef="Message_1rfi4qj" />
</bpmn:startEvent>
<bpmn:scriptTask id="script_task_one" name="Script Task One">
<bpmn:incoming>Flow_17db3yp</bpmn:incoming>
<bpmn:outgoing>Flow_12pkbxb</bpmn:outgoing>
<bpmn:script>a = 1</bpmn:script>
</bpmn:scriptTask>
</bpmn:process>
<bpmn:message id="Message_1rfi4qj" name="bounty_start">
<bpmn:extensionElements>
<spiffworkflow:messageVariable>incoming_request</spiffworkflow:messageVariable>
</bpmn:extensionElements>
</bpmn:message>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Collaboration_1h79dxi">
<bpmndi:BPMNShape id="Participant_04cn64m_di" bpmnElement="Participant_04cn64m" isHorizontal="true">
<dc:Bounds x="40" y="52" width="600" height="250" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_14za570_di" bpmnElement="EndEvent_1">
<dc:Bounds x="432" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_0aasbbk_di" bpmnElement="message_start_event_one">
<dc:Bounds x="182" y="159" width="36" height="36" />
<bpmndi:BPMNLabel>
<dc:Bounds x="166" y="202" width="71" height="27" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0mqmnb0_di" bpmnElement="script_task_one">
<dc:Bounds x="280" y="137" width="100" height="80" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Flow_17db3yp_di" bpmnElement="Flow_17db3yp">
<di:waypoint x="218" y="177" />
<di:waypoint x="280" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_12pkbxb_di" bpmnElement="Flow_12pkbxb">
<di:waypoint x="380" y="177" />
<di:waypoint x="432" y="177" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -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"
}

View File

@ -0,0 +1,4 @@
{
"firstName": "Chuck",
"done": false
}

View File

@ -0,0 +1,15 @@
{
"title": "Approval time",
"description": "Are we approving this?",
"type": "object",
"required": [
"firstName"
],
"properties": {
"done": {
"type": "boolean",
"title": "Approved",
"default": false
}
}
}

View File

@ -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/)."
}
}

View File

@ -0,0 +1,11 @@
{
"title": "Form for message start event",
"type": "object",
"required": ["firstName"],
"properties": {
"firstName": {
"type": "string",
"title": "First name"
}
}
}

View File

@ -0,0 +1,122 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:spiffworkflow="http://spiffworkflow.org/bpmn/schema/1.0/core" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" id="Definitions_96f6665" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.0.0-dev">
<bpmn:collaboration id="Collaboration_1h79dxi">
<bpmn:participant id="Participant_04cn64m" processRef="Process_message_start_event_with_multiple_forms_bkmtffv" />
</bpmn:collaboration>
<bpmn:process id="Process_message_start_event_with_multiple_forms_bkmtffv" isExecutable="true">
<bpmn:laneSet id="LaneSet_0okk2le">
<bpmn:lane id="Lane_0n1tm41">
<bpmn:flowNodeRef>message_start_event_one</bpmn:flowNodeRef>
<bpmn:flowNodeRef>script_task_one</bpmn:flowNodeRef>
<bpmn:flowNodeRef>user_task_one</bpmn:flowNodeRef>
</bpmn:lane>
<bpmn:lane id="admin" name="admin">
<bpmn:flowNodeRef>Event_0c0wt46</bpmn:flowNodeRef>
<bpmn:flowNodeRef>admin_task</bpmn:flowNodeRef>
</bpmn:lane>
</bpmn:laneSet>
<bpmn:sequenceFlow id="Flow_17db3yp" sourceRef="message_start_event_one" targetRef="script_task_one" />
<bpmn:sequenceFlow id="Flow_12pkbxb" sourceRef="script_task_one" targetRef="user_task_one" />
<bpmn:startEvent id="message_start_event_one" name="Message Start Event One" messageRef="[object Object]">
<bpmn:extensionElements>
<spiffworkflow:instructionsForEndUser>## Enter your first name</spiffworkflow:instructionsForEndUser>
<spiffworkflow:properties>
<spiffworkflow:property name="formJsonSchemaFilename" value="entry-form-schema.json" />
<spiffworkflow:property name="formUiSchemaFilename" value="entry-form-uischema.json" />
</spiffworkflow:properties>
</bpmn:extensionElements>
<bpmn:outgoing>Flow_17db3yp</bpmn:outgoing>
<bpmn:messageEventDefinition id="MessageEventDefinition_13ctnqx" messageRef="Message_1rfi4qj" />
</bpmn:startEvent>
<bpmn:scriptTask id="script_task_one" name="Script Task One">
<bpmn:incoming>Flow_17db3yp</bpmn:incoming>
<bpmn:outgoing>Flow_12pkbxb</bpmn:outgoing>
<bpmn:script>a = 1</bpmn:script>
</bpmn:scriptTask>
<bpmn:userTask id="user_task_one" name="User Task One">
<bpmn:extensionElements>
<spiffworkflow:instructionsForEndUser>## Enter your last name {{incoming_request['firstName']}}</spiffworkflow:instructionsForEndUser>
<spiffworkflow:properties>
<spiffworkflow:property name="formJsonSchemaFilename" value="second-form-schema.json" />
<spiffworkflow:property name="formUiSchemaFilename" value="second-form-uischema.json" />
</spiffworkflow:properties>
<spiffworkflow:allowGuest>false</spiffworkflow:allowGuest>
<spiffworkflow:guestConfirmation># Thanks
We hear you. Your name is **{{incoming_request['firstName']}} {{lastName}}**.</spiffworkflow:guestConfirmation>
</bpmn:extensionElements>
<bpmn:incoming>Flow_12pkbxb</bpmn:incoming>
<bpmn:outgoing>Flow_14h4dnh</bpmn:outgoing>
</bpmn:userTask>
<bpmn:endEvent id="Event_0c0wt46">
<bpmn:incoming>Flow_1aprws7</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_1aprws7" sourceRef="admin_task" targetRef="Event_0c0wt46" />
<bpmn:sequenceFlow id="Flow_14h4dnh" sourceRef="user_task_one" targetRef="admin_task" />
<bpmn:userTask id="admin_task" name="Admin task">
<bpmn:extensionElements>
<spiffworkflow:properties>
<spiffworkflow:property name="formJsonSchemaFilename" value="admin-task-schema.json" />
<spiffworkflow:property name="formUiSchemaFilename" value="admin-task-uischema.json" />
</spiffworkflow:properties>
</bpmn:extensionElements>
<bpmn:incoming>Flow_14h4dnh</bpmn:incoming>
<bpmn:outgoing>Flow_1aprws7</bpmn:outgoing>
</bpmn:userTask>
</bpmn:process>
<bpmn:message id="Message_1rfi4qj" name="bounty_start_multiple_forms">
<bpmn:extensionElements>
<spiffworkflow:messageVariable>incoming_request</spiffworkflow:messageVariable>
</bpmn:extensionElements>
</bpmn:message>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Collaboration_1h79dxi">
<bpmndi:BPMNShape id="Participant_04cn64m_di" bpmnElement="Participant_04cn64m" isHorizontal="true">
<dc:Bounds x="40" y="52" width="600" height="370" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Lane_0w9549p_di" bpmnElement="admin" isHorizontal="true">
<dc:Bounds x="70" y="302" width="570" height="120" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Lane_0n1tm41_di" bpmnElement="Lane_0n1tm41" isHorizontal="true">
<dc:Bounds x="70" y="52" width="570" height="250" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_0aasbbk_di" bpmnElement="message_start_event_one">
<dc:Bounds x="132" y="159" width="36" height="36" />
<bpmndi:BPMNLabel>
<dc:Bounds x="116" y="202" width="71" height="27" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0mqmnb0_di" bpmnElement="script_task_one">
<dc:Bounds x="230" y="137" width="100" height="80" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0ziqn0f_di" bpmnElement="user_task_one">
<dc:Bounds x="400" y="137" width="100" height="80" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_0c0wt46_di" bpmnElement="Event_0c0wt46">
<dc:Bounds x="542" y="342" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_1hiuuow_di" bpmnElement="admin_task">
<dc:Bounds x="400" y="320" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Flow_17db3yp_di" bpmnElement="Flow_17db3yp">
<di:waypoint x="168" y="177" />
<di:waypoint x="230" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_12pkbxb_di" bpmnElement="Flow_12pkbxb">
<di:waypoint x="330" y="177" />
<di:waypoint x="400" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1aprws7_di" bpmnElement="Flow_1aprws7">
<di:waypoint x="500" y="360" />
<di:waypoint x="542" y="360" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_14h4dnh_di" bpmnElement="Flow_14h4dnh">
<di:waypoint x="450" y="217" />
<di:waypoint x="450" y="320" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -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"
}

View File

@ -0,0 +1,5 @@
{
"incoming_request": {
"firstName": "joe"
}
}

View File

@ -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"
}
}
}

View File

@ -0,0 +1,5 @@
{
"lastName": {
"ui:autoFocus": true
}
}

View File

@ -57,9 +57,7 @@ class BaseTest:
) )
@staticmethod @staticmethod
def logged_in_headers( def logged_in_headers(user: UserModel, extra_token_payload: dict | None = None) -> dict[str, str]:
user: UserModel, _redirect_url: str = "http://some/frontend/url", extra_token_payload: dict | None = None
) -> dict[str, str]:
return {"Authorization": "Bearer " + user.encode_auth_token(extra_token_payload)} return {"Authorization": "Bearer " + user.encode_auth_token(extra_token_payload)}
def create_group_and_model_with_bpmn( def create_group_and_model_with_bpmn(

View File

@ -14,6 +14,7 @@ from spiffworkflow_backend.services.service_account_service import ServiceAccoun
from spiffworkflow_backend.services.user_service import UserService from spiffworkflow_backend.services.user_service import UserService
from tests.spiffworkflow_backend.helpers.base_test import BaseTest from tests.spiffworkflow_backend.helpers.base_test import BaseTest
from tests.spiffworkflow_backend.helpers.test_data import load_test_spec
class TestAuthentication(BaseTest): class TestAuthentication(BaseTest):
@ -152,3 +153,51 @@ class TestAuthentication(BaseTest):
assert response.status_code == 500 assert response.status_code == 500
assert response.json is not None assert response.json is not None
assert response.json["message"].startswith("InvalidRedirectUrlError:") 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

View File

@ -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")

View File

@ -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

View File

@ -1,19 +1,41 @@
import { defineAbility } from '@casl/ability'; import { defineAbility } from '@casl/ability';
import React from 'react'; import React from 'react';
import { createBrowserRouter, Outlet, RouterProvider } from 'react-router-dom';
import { AbilityContext } from './contexts/Can'; import { AbilityContext } from './contexts/Can';
import APIErrorProvider from './contexts/APIErrorContext'; import APIErrorProvider from './contexts/APIErrorContext';
import ContainerForExtensions from './ContainerForExtensions'; import ContainerForExtensions from './ContainerForExtensions';
import PublicRoutes from './routes/PublicRoutes';
export default function App() { export default function App() {
const ability = defineAbility(() => {}); const ability = defineAbility(() => {});
return ( const routeComponents = () => {
<div className="cds--white"> return [
<APIErrorProvider> { path: 'public/*', element: <PublicRoutes /> },
<AbilityContext.Provider value={ability}> {
<ContainerForExtensions /> path: '*',
</AbilityContext.Provider> element: <ContainerForExtensions />,
</APIErrorProvider> },
</div> ];
); };
const layout = () => {
return (
<div className="cds--white">
<APIErrorProvider>
<AbilityContext.Provider value={ability}>
<Outlet />;
</AbilityContext.Provider>
</APIErrorProvider>
</div>
);
};
const router = createBrowserRouter([
{
path: '*',
Component: layout,
children: routeComponents(),
},
]);
return <RouterProvider router={router} />;
} }

View File

@ -1,5 +1,5 @@
import { Content } from '@carbon/react'; 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 React, { useEffect, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary'; import { ErrorBoundary } from 'react-error-boundary';
@ -100,15 +100,17 @@ export default function ContainerForExtensions() {
]); ]);
const routeComponents = () => { const routeComponents = () => {
return [ return (
{ <Routes>
path: '*', <Route
element: <BaseRoutes extensionUxElements={extensionUxElements} />, path="*"
}, element={<BaseRoutes extensionUxElements={extensionUxElements} />}
{ path: 'editor/*', element: <EditorRoutes /> }, />
{ path: 'extensions/:page_identifier', element: <Extension /> }, <Route path="editor/*" element={<EditorRoutes />} />
{ path: 'login', element: <Login /> }, <Route path="extensions/:page_identifier" element={<Extension />} />
]; <Route path="login" element={<Login />} />
</Routes>
);
}; };
const backendIsDownPage = () => { const backendIsDownPage = () => {
@ -120,31 +122,20 @@ export default function ContainerForExtensions() {
return []; return [];
} }
if (backendIsUp) { if (backendIsUp) {
return <Outlet />; return routeComponents();
} }
return backendIsDownPage(); return backendIsDownPage();
}; };
const layout = () => { return (
return ( <>
<> <NavigationBar extensionUxElements={extensionUxElements} />
<NavigationBar extensionUxElements={extensionUxElements} /> <Content className={contentClassName}>
<Content className={contentClassName}> <ScrollToTop />
<ScrollToTop /> <ErrorBoundary FallbackComponent={ErrorBoundaryFallback}>
<ErrorBoundary FallbackComponent={ErrorBoundaryFallback}> {innerComponents()}
{innerComponents()} </ErrorBoundary>
</ErrorBoundary> </Content>
</Content> </>
</> );
);
};
const router = createBrowserRouter([
{
path: '*',
Component: layout,
children: routeComponents(),
},
]);
return <RouterProvider router={router} />;
} }

View File

@ -2,7 +2,8 @@ import validator from '@rjsf/validator-ajv8';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { RegistryFieldsType } from '@rjsf/utils'; import { RegistryFieldsType } from '@rjsf/utils';
import { Button } from '@carbon/react'; 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 { DATE_RANGE_DELIMITER } from '../config';
import DateRangePickerWidget from '../rjsf/custom_widgets/DateRangePicker/DateRangePickerWidget'; import DateRangePickerWidget from '../rjsf/custom_widgets/DateRangePicker/DateRangePickerWidget';
import TypeaheadWidget from '../rjsf/custom_widgets/TypeaheadWidget/TypeaheadWidget'; import TypeaheadWidget from '../rjsf/custom_widgets/TypeaheadWidget/TypeaheadWidget';
@ -29,6 +30,7 @@ type OwnProps = {
noValidate?: boolean; noValidate?: boolean;
restrictedWidth?: boolean; restrictedWidth?: boolean;
submitButtonText?: string; submitButtonText?: string;
reactJsonSchemaForm?: string;
}; };
export default function CustomForm({ export default function CustomForm({
@ -43,6 +45,7 @@ export default function CustomForm({
noValidate = false, noValidate = false,
restrictedWidth = false, restrictedWidth = false,
submitButtonText, submitButtonText,
reactJsonSchemaForm = 'carbon',
}: OwnProps) { }: OwnProps) {
// set in uiSchema using the "ui:widget" key for a property // set in uiSchema using the "ui:widget" key for a property
const rjsfWidgets = { const rjsfWidgets = {
@ -444,24 +447,32 @@ export default function CustomForm({
); );
} }
return ( const formProps = {
<Form id,
id={id} disabled,
disabled={disabled} formData,
formData={formData} onChange,
onChange={onChange} onSubmit,
onSubmit={onSubmit} schema,
schema={schema} uiSchema,
uiSchema={uiSchema} widgets: rjsfWidgets,
widgets={rjsfWidgets} validator,
validator={validator} customValidate,
customValidate={customValidate} noValidate,
noValidate={noValidate} fields: rjsfFields,
fields={rjsfFields} templates: rjsfTemplates,
templates={rjsfTemplates} omitExtraData: true,
omitExtraData };
> if (reactJsonSchemaForm === 'carbon') {
{childrenToUse} // eslint-disable-next-line react/jsx-props-no-spreading
</Form> return <CarbonForm {...formProps}>{childrenToUse}</CarbonForm>;
); }
if (reactJsonSchemaForm === 'mui') {
// eslint-disable-next-line react/jsx-props-no-spreading
return <MuiForm {...formProps}>{childrenToUse}</MuiForm>;
}
console.error(`Unsupported form type: ${reactJsonSchemaForm}`);
return null;
} }

View File

@ -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) => { export const recursivelyChangeNullAndUndefined = (obj: any, newValue: any) => {
if (obj === null || obj === undefined) { if (obj === null || obj === undefined) {
return newValue; return newValue;

View File

@ -46,12 +46,14 @@ a.cds--header__menu-item {
font-style: italic; font-style: italic;
} }
h1 { h1,
h1.MuiTypography-h1 {
font-weight: 400; font-weight: 400;
font-size: 28px; font-size: 28px;
line-height: 36px; line-height: 36px;
color: #161616; color: #161616;
margin-bottom: 1rem; margin-bottom: 1rem;
letter-spacing: 0.00938em;
} }
h2 { h2 {

View File

@ -503,3 +503,15 @@ export interface ElementForArray {
key: string; key: string;
component: ReactElement | null; 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;
}

View File

@ -32,7 +32,7 @@ export default function BaseRoutes({ extensionUxElements }: OwnProps) {
); );
}; };
if (extensionUxElements) { if (extensionUxElements !== null) {
const extensionRoutes = ExtensionUxElementMap({ const extensionRoutes = ExtensionUxElementMap({
displayLocation: 'routes', displayLocation: 'routes',
elementCallback, elementCallback,

View File

@ -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 (
<Content className="main-site-body-centered">
<Routes>
<Route
path="/:modified_message_name"
element={<MessageStartEventForm />}
/>
<Route path="/sign_out" element={<SignOut />} />
</Routes>
</Content>
);
}

View File

@ -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<PublicTaskForm | null>(null);
const [taskData, setTaskData] = useState<any>(null);
const [formButtonsDisabled, setFormButtonsDisabled] = useState(false);
const { addError, removeError } = useAPIError();
const [confirmationMessage, setConfirmationMessage] = useState<string | null>(
null
);
const [taskSubmitResponse, setTaskSubmitResponse] =
useState<PublicTaskSubmitResponse | null>(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 (
<MarkdownRenderer linkTarget="_blank" source={confirmationMessage} />
);
}
if (formContents) {
if (formContents.form_schema) {
return (
<div className="fixed-width-container">
<InstructionsForEndUser
defaultMessage={formContents.instructions_for_end_user}
/>
<Grid fullWidth condensed className="megacondensed">
<Column sm={4} md={5} lg={8}>
<CustomForm
id="form-to-submit"
disabled={formButtonsDisabled}
formData={taskData}
onChange={(obj: any) => {
setTaskData(obj.formData);
}}
onSubmit={handleFormSubmit}
schema={formContents.form_schema}
uiSchema={formContents.form_ui_schema}
restrictedWidth
reactJsonSchemaForm="mui"
/>
</Column>
</Grid>
</div>
);
}
return <Page404 />;
}
const style = { margin: '50px 0 50px 50px' };
return (
<Loading
description="Active loading indicator"
withOverlay={false}
style={style}
/>
);
}

View File

@ -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 (
<div className="fixed-width-container">
<Typography variant="h1">Access Denied</Typography>
<Typography variant="body1">
You are currently logged in as{' '}
<strong>{UserService.getPreferredUsername()}</strong>. You do not have
access to this page. Would you like to sign out and sign in as a
different user?
</Typography>
<br />
<Button variant="contained" onClick={logoutUser}>
Sign out
</Button>
</div>
);
}

View File

@ -96,6 +96,8 @@ backendCallProps) => {
} else if (is403) { } else if (is403) {
if (onUnauthorized) { if (onUnauthorized) {
onUnauthorized(result); onUnauthorized(result);
} else if (UserService.isPublicUser()) {
window.location.href = '/public/sign_out';
} else { } else {
// Hopefully we can make this service a hook and use the error message context directly // Hopefully we can make this service a hook and use the error message context directly
// eslint-disable-next-line no-alert // eslint-disable-next-line no-alert

View File

@ -43,6 +43,30 @@ const checkPathForTaskShowParams = (
return null; 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 = ( const doLogin = (
authenticationOption?: AuthenticationOption, authenticationOption?: AuthenticationOption,
redirectUrl?: string | null redirectUrl?: string | null
@ -64,21 +88,6 @@ const doLogin = (
window.location.href = url; 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 doLogout = () => {
const idToken = getIdToken(); 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. // edge case. if the user is already logged out, just take them somewhere that will force them to sign in.
if (idToken === null) { if (idToken === null) {
logoutRedirectUrl = SIGN_IN_PATH; logoutRedirectUrl = SIGN_IN_PATH;
} else if (isPublicUser()) {
logoutRedirectUrl += '&backend_only=true';
} }
window.location.href = logoutRedirectUrl; window.location.href = logoutRedirectUrl;
@ -169,6 +180,7 @@ const UserService = {
getPreferredUsername, getPreferredUsername,
getUserEmail, getUserEmail,
isLoggedIn, isLoggedIn,
isPublicUser,
loginIfNeeded, loginIfNeeded,
onlyGuestTaskCompletion, onlyGuestTaskCompletion,
}; };