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
schema:
type: string
- name: backend_only
in: query
required: false
schema:
type: boolean
get:
operationId: spiffworkflow_backend.routes.authentication_controller.logout
summary: Logout authenticated user
@ -909,11 +914,6 @@ paths:
get:
operationId: spiffworkflow_backend.routes.extensions_controller.extension_list
summary: Returns the list of available extensions
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/AwesomeUnspecifiedPayload"
tags:
- Extensions
responses:
@ -2530,12 +2530,12 @@ paths:
schema:
$ref: "#/components/schemas/Workflow"
/messages/{message_name}:
/messages/{modified_message_name}:
parameters:
- name: message_name
- name: modified_message_name
in: path
required: true
description: The unique name of the message.
description: The message_name, modified to replace slashes (/) with colons
schema:
type: string
- name: execution_mode
@ -2556,14 +2556,125 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/Workflow"
$ref: "#/components/schemas/AwesomeUnspecifiedPayload"
responses:
"200":
description: One task
content:
application/json:
schema:
$ref: "#/components/schemas/Workflow"
properties:
task_data:
$ref: "#/components/schemas/AwesomeUnspecifiedPayload"
process_instance:
$ref: "#/components/schemas/AwesomeUnspecifiedPayload"
/public/messages/form/{modified_message_name}:
parameters:
- name: modified_message_name
in: path
required: true
description: The message_name, modified to replace slashes (/) with colons
schema:
type: string
get:
tags:
- Messages
operationId: spiffworkflow_backend.routes.public_controller.message_form_show
summary: Gets the form associated with the given message name.
responses:
"200":
description: The json schema form.
content:
application/json:
schema:
$ref: "#/components/schemas/AwesomeUnspecifiedPayload"
/public/messages/submit/{modified_message_name}:
parameters:
- name: modified_message_name
in: path
required: true
description: The message_name, modified to replace slashes (/) with colons
schema:
type: string
- name: execution_mode
in: query
required: false
description: Either run in "synchronous" or "asynchronous" mode.
schema:
type: string
enum:
- synchronous
- asynchronous
post:
tags:
- Messages
operationId: spiffworkflow_backend.routes.public_controller.message_form_submit
summary: Instantiate and run a given process model with a message start event matching given name
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/AwesomeUnspecifiedPayload"
responses:
"200":
description: One task
content:
application/json:
schema:
properties:
task_data:
$ref: "#/components/schemas/AwesomeUnspecifiedPayload"
process_instance:
$ref: "#/components/schemas/AwesomeUnspecifiedPayload"
/public/tasks/{process_instance_id}/{task_guid}:
parameters:
- name: task_guid
in: path
required: true
description: The unique id of an existing process group.
schema:
type: string
- name: process_instance_id
in: path
required: true
description: The unique id of an existing process instance.
schema:
type: integer
- name: execution_mode
in: query
required: false
description: Either run in "synchronous" or "asynchronous" mode.
schema:
type: string
enum:
- synchronous
- asynchronous
put:
tags:
- Tasks
operationId: spiffworkflow_backend.routes.public_controller.form_submit
summary: Update the form data for a tasks
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/AwesomeUnspecifiedPayload"
responses:
"200":
description: One task
content:
application/json:
schema:
$ref: "#/components/schemas/Task"
"202":
description: "ok: true"
content:
application/json:
schema:
$ref: "#/components/schemas/OkTrue"
/logs/{modified_process_model_identifier}/{process_instance_id}:
parameters:
@ -3775,9 +3886,9 @@ components:
# it will fail validation and not pass the request to the controller. that is generally not desirable
# until we take a closer look at the schemas in here.
AwesomeUnspecifiedPayload:
properties:
anythingyouwant:
type: string
# we know that task_submit submits no body at all, and None is not an object, as this so helpfully tells us
# type: "object"
additionalProperties: {}
ReportMetadata:
properties:
columns:

View File

@ -143,6 +143,7 @@ config_from_env("SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_ABSOLUTE_PATH")
config_from_env("SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME")
# FIXME: do not default this but we will need to coordinate release of it since it is a breaking change
config_from_env("SPIFFWORKFLOW_BACKEND_DEFAULT_USER_GROUP", default="everybody")
config_from_env("SPIFFWORKFLOW_BACKEND_DEFAULT_PUBLIC_USER_GROUP", default="spiff_public")
### sentry
config_from_env("SPIFFWORKFLOW_BACKEND_SENTRY_DSN", default="")

View File

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

View File

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

View File

@ -65,6 +65,13 @@ class MessageInstanceModel(SpiffworkflowBaseDBModel):
def validate_status(self, key: str, value: Any) -> Any:
return self.validate_enum_field(key, value, MessageStatuses)
@classmethod
def split_modified_message_name(cls, modified_message_name: str) -> tuple[str, str]:
message_name_array = modified_message_name.split(":")
message_name = message_name_array.pop()
process_group_identifier = "/".join(message_name_array)
return (message_name, process_group_identifier)
def correlates(self, other: Any, expression_engine: PythonScriptEngine) -> bool:
"""Returns true if the this Message correlates with the given message.

View File

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

View File

@ -1,6 +1,8 @@
from __future__ import annotations
import math
import random
import string
import time
from dataclasses import dataclass
from typing import Any
@ -91,3 +93,46 @@ class UserModel(SpiffworkflowBaseDBModel):
user_as_json_string = current_app.json.dumps(self)
user_dict: dict[str, Any] = current_app.json.loads(user_as_json_string)
return user_dict
@classmethod
def generate_random_username(cls, prefix: str = "public") -> str:
adjectives = [
"fluffy",
"cuddly",
"tiny",
"joyful",
"sweet",
"gentle",
"cheerful",
"adorable",
"whiskered",
"silky",
]
animals = [
"panda",
"kitten",
"puppy",
"bunny",
"chick",
"duckling",
"chipmunk",
"hedgehog",
"lamb",
"fawn",
"otter",
"calf",
"penguin",
"koala",
"giraffe",
"monkey",
"fox",
"raccoon",
"squirrel",
"owl",
]
fuzz = "".join(random.SystemRandom().choice(string.ascii_lowercase + string.digits) for _ in range(7))
# this is not for cryptographic purposes
adjective = random.choice(adjectives) # noqa: S311
animal = random.choice(animals) # noqa: S311
username = f"{prefix}{adjective}{animal}{fuzz}"
return username

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.models.group import SPIFF_GUEST_GROUP
from spiffworkflow_backend.models.group import SPIFF_NO_AUTH_GROUP
from spiffworkflow_backend.models.group import GroupModel
from spiffworkflow_backend.models.service_account import ServiceAccountModel
from spiffworkflow_backend.models.task import TaskModel # noqa: F401
from spiffworkflow_backend.models.user import SPIFF_GUEST_USER
from spiffworkflow_backend.models.user import SPIFF_NO_AUTH_USER
from spiffworkflow_backend.models.user import UserModel
from spiffworkflow_backend.services.authentication_service import AuthenticationService
from spiffworkflow_backend.services.authorization_service import PUBLIC_AUTHENTICATION_EXCLUSION_LIST
from spiffworkflow_backend.services.authorization_service import AuthorizationService
from spiffworkflow_backend.services.user_service import UserService
@ -76,6 +78,13 @@ def verify_token(token: str | None = None, force_run: bool | None = False) -> di
user_model = _get_user_model_from_token(decoded_token)
elif token_info["api_key"] is not None:
user_model = _get_user_model_from_api_key(token_info["api_key"])
else:
# if there is no token in the request, hit the database to see if this path allows unauthed access
# we could choose to put all of the APIs that can be accessed unauthed behind a certain path.
# if we did that, we would not have to hit the db on *every* tokenless request
api_function_full_path, _ = AuthorizationService.get_fully_qualified_api_function_from_request()
if api_function_full_path and api_function_full_path in PUBLIC_AUTHENTICATION_EXCLUSION_LIST:
_check_if_request_is_public()
if user_model:
g.user = user_model
@ -213,13 +222,17 @@ def login_api_return(code: str, state: str, session_state: str) -> str:
return access_token
def logout(id_token: str, authentication_identifier: str, redirect_url: str | None) -> Response:
def logout(id_token: str, authentication_identifier: str, redirect_url: str | None, backend_only: bool = False) -> Response:
if redirect_url is None:
redirect_url = ""
AuthenticationService.set_user_has_logged_out()
return AuthenticationService().logout(
redirect_url=redirect_url, id_token=id_token, authentication_identifier=authentication_identifier
)
if backend_only:
return redirect(redirect_url)
else:
return AuthenticationService().logout(
redirect_url=redirect_url, id_token=id_token, authentication_identifier=authentication_identifier
)
def logout_return() -> Response:
@ -460,3 +473,26 @@ def _get_authentication_identifier_from_request() -> str:
authentication_identifier: str = request.headers["SpiffWorkflow-Authentication-Identifier"]
return authentication_identifier
return "default"
def _check_if_request_is_public() -> None:
permission_string = AuthorizationService.get_permission_from_http_method(request.method)
if permission_string:
public_group = GroupModel.query.filter_by(
identifier=current_app.config.get("SPIFFWORKFLOW_BACKEND_DEFAULT_PUBLIC_USER_GROUP")
).first()
if public_group is not None:
has_permission = AuthorizationService.has_permission(
principals=[public_group.principal],
permission=permission_string,
target_uri=request.path,
)
if has_permission:
g.user = UserService.create_public_user()
g.token = g.user.encode_auth_token(
{"public": True},
)
tld = current_app.config["THREAD_LOCAL_DATA"]
tld.new_access_token = g.token
tld.new_id_token = g.token
tld.new_authentication_identifier = _get_authentication_identifier_from_request()

View File

@ -1,15 +1,11 @@
"""APIs for dealing with process groups, process models, and process instances."""
import json
from typing import Any
import flask.wrappers
from flask import g
from flask import jsonify
from flask import make_response
from flask.wrappers import Response
from spiffworkflow_backend import db
from spiffworkflow_backend.exceptions.api_error import ApiError
from spiffworkflow_backend.models.message_instance import MessageInstanceModel
from spiffworkflow_backend.models.process_instance import ProcessInstanceModel
from spiffworkflow_backend.models.process_instance import ProcessInstanceModelSchema
@ -63,43 +59,11 @@ def message_instance_list(
# -H 'content-type: application/json' \
# --data-raw '{"payload":{"sure": "yes", "food": "spicy"}}'
def message_send(
message_name: str,
modified_message_name: str,
body: dict[str, Any],
execution_mode: str | None = None,
) -> flask.wrappers.Response:
process_instance = None
# Create the send message
message_instance = MessageInstanceModel(
message_type="send",
name=message_name,
payload=body,
user_id=g.user.id,
)
db.session.add(message_instance)
db.session.commit()
try:
receiver_message = MessageService.correlate_send_message(message_instance, execution_mode=execution_mode)
except Exception as e:
db.session.delete(message_instance)
db.session.commit()
raise e
if not receiver_message:
db.session.delete(message_instance)
db.session.commit()
raise (
ApiError(
error_code="message_not_accepted",
message=(
"No running process instances correlate with the given message"
f" name of '{message_name}'. And this message name is not"
" currently associated with any process Start Event. Nothing"
" to do."
),
status_code=400,
)
)
receiver_message = MessageService.run_process_model_from_message(modified_message_name, body, execution_mode)
process_instance = ProcessInstanceModel.query.filter_by(id=receiver_message.process_instance_id).first()
response_json = {
"task_data": process_instance.get_data(),

View File

@ -1,18 +1,28 @@
import json
import uuid
from typing import Any
from uuid import UUID
import flask.wrappers
import sentry_sdk
from flask import Blueprint
from flask import current_app
from flask import g
from flask import jsonify
from flask import make_response
from flask.wrappers import Response
from SpiffWorkflow.task import Task as SpiffTask # type: ignore
from SpiffWorkflow.util.task import TaskState # type: ignore
from sqlalchemy import and_
from sqlalchemy import or_
from spiffworkflow_backend.background_processing.celery_tasks.process_instance_task_producer import (
queue_enabled_for_process_model,
)
from spiffworkflow_backend.data_migrations.process_instance_migrator import ProcessInstanceMigrator
from spiffworkflow_backend.exceptions.api_error import ApiError
from spiffworkflow_backend.exceptions.process_entity_not_found_error import ProcessEntityNotFoundError
from spiffworkflow_backend.models.db import db
from spiffworkflow_backend.models.human_task import HumanTaskModel
from spiffworkflow_backend.models.human_task_user import HumanTaskUserModel
from spiffworkflow_backend.models.principal import PrincipalModel
@ -21,12 +31,19 @@ from spiffworkflow_backend.models.process_instance_file_data import ProcessInsta
from spiffworkflow_backend.models.process_model import ProcessModelInfo
from spiffworkflow_backend.models.reference_cache import ReferenceCacheModel
from spiffworkflow_backend.models.reference_cache import ReferenceSchema
from spiffworkflow_backend.models.task import TaskModel # noqa: F401
from spiffworkflow_backend.services.authentication_service import AuthenticationService # noqa: F401
from spiffworkflow_backend.services.authorization_service import AuthorizationService
from spiffworkflow_backend.services.git_service import GitCommandError
from spiffworkflow_backend.services.git_service import GitService
from spiffworkflow_backend.services.jinja_service import JinjaService
from spiffworkflow_backend.services.process_caller_service import ProcessCallerService
from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor
from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceQueueService
from spiffworkflow_backend.services.process_instance_service import ProcessInstanceService
from spiffworkflow_backend.services.process_model_service import ProcessModelService
from spiffworkflow_backend.services.task_service import TaskModelError
from spiffworkflow_backend.services.task_service import TaskService
process_api_blueprint = Blueprint("process_api", __name__)
@ -357,3 +374,186 @@ def _get_process_model_for_instantiation(
status_code=400,
)
return process_model
def _prepare_form_data(
form_file: str, process_model: ProcessModelInfo, task_model: TaskModel | None = None, revision: str | None = None
) -> dict:
try:
form_contents = GitService.get_file_contents_for_revision_if_git_revision(
process_model=process_model,
revision=revision,
file_name=form_file,
)
except GitCommandError as exception:
raise (
ApiError(
error_code="git_error_loading_form",
message=(
f"Could not load form schema from: {form_file}. Was git history rewritten such that revision"
f" '{revision}' no longer exists? Error was: {str(exception)}"
),
status_code=400,
)
) from exception
if task_model and task_model.data is not None:
try:
form_contents = JinjaService.render_jinja_template(form_contents, task=task_model)
except TaskModelError as wfe:
wfe.add_note(f"Error in Json Form File '{form_file}'")
api_error = ApiError.from_workflow_exception("instructions_error", str(wfe), exp=wfe)
api_error.file_name = form_file
raise api_error from wfe
try:
# form_contents is a str
hot_dict: dict = json.loads(form_contents)
return hot_dict
except Exception as exception:
raise (
ApiError(
error_code="error_loading_form",
message=f"Could not load form schema from: {form_file}. Error was: {str(exception)}",
status_code=400,
)
) from exception
def _task_submit_shared(
process_instance_id: int,
task_guid: str,
body: dict[str, Any],
execution_mode: str | None = None,
) -> dict:
principal = _find_principal_or_raise()
process_instance = _find_process_instance_by_id_or_raise(process_instance_id)
if not process_instance.can_submit_task():
raise ApiError(
error_code="process_instance_not_runnable",
message=(
f"Process Instance ({process_instance.id}) has status "
f"{process_instance.status} which does not allow tasks to be submitted."
),
status_code=400,
)
# we're dequeing twice in this function.
# tried to wrap the whole block in one dequeue, but that has the confusing side-effect that every exception
# in the block causes the process instance to go into an error state. for example, when
# AuthorizationService.assert_user_can_complete_task raises. this would have been solvable, but this seems simpler,
# and the cost is not huge given that this function is not the most common code path in the world.
with ProcessInstanceQueueService.dequeued(process_instance):
ProcessInstanceMigrator.run(process_instance)
processor = ProcessInstanceProcessor(
process_instance, workflow_completed_handler=ProcessInstanceService.schedule_next_process_model_cycle
)
spiff_task = _get_spiff_task_from_processor(task_guid, processor)
AuthorizationService.assert_user_can_complete_task(process_instance.id, str(spiff_task.id), principal.user)
if spiff_task.state != TaskState.READY:
raise (
ApiError(
error_code="invalid_state",
message="You may not update a task unless it is in the READY state.",
status_code=400,
)
)
human_task = _find_human_task_or_raise(
process_instance_id=process_instance_id,
task_guid=task_guid,
only_tasks_that_can_be_completed=True,
)
with sentry_sdk.start_span(op="task", description="complete_form_task"):
with ProcessInstanceQueueService.dequeued(process_instance):
ProcessInstanceService.complete_form_task(
processor=processor,
spiff_task=spiff_task,
data=body,
user=g.user,
human_task=human_task,
execution_mode=execution_mode,
)
# currently task_model has the potential to be None. This should be removable once
# we backfill the human_task table for task_guid and make that column not nullable
task_model: TaskModel | None = human_task.task_model
if task_model is None:
task_model = TaskModel.query.filter_by(guid=human_task.task_id).first()
# delete draft data when we submit a task to ensure cycling back to the task contains the
# most up-to-date data
task_draft_data = TaskService.task_draft_data_from_task_model(task_model)
if task_draft_data is not None:
db.session.delete(task_draft_data)
db.session.commit()
next_human_task_assigned_to_me = TaskService.next_human_task_for_user(process_instance_id, principal.user_id)
if next_human_task_assigned_to_me:
return {"next_task_assigned_to_me": HumanTaskModel.to_task(next_human_task_assigned_to_me)}
# a guest user completed a task, it has a guest_confirmation message to display to them,
# and there is nothing else for them to do
spiff_task_extensions = spiff_task.task_spec.extensions
if "guestConfirmation" in spiff_task_extensions and spiff_task_extensions["guestConfirmation"]:
guest_confirmation = JinjaService.render_jinja_template(spiff_task_extensions["guestConfirmation"], task_model)
return {"guest_confirmation": guest_confirmation}
if processor.next_task():
task = ProcessInstanceService.spiff_task_to_api_task(processor, processor.next_task())
task.process_model_uses_queued_execution = queue_enabled_for_process_model(process_instance)
return {"next_task": task}
# next_task always returns something, even if the instance is complete, so we never get here
return {
"ok": True,
"process_model_identifier": process_instance.process_model_identifier,
"process_instance_id": process_instance_id,
}
def _find_human_task_or_raise(
process_instance_id: int,
task_guid: str,
only_tasks_that_can_be_completed: bool = False,
) -> HumanTaskModel:
if only_tasks_that_can_be_completed:
human_task_query = HumanTaskModel.query.filter_by(
process_instance_id=process_instance_id,
task_id=task_guid,
completed=False,
)
else:
human_task_query = HumanTaskModel.query.filter_by(process_instance_id=process_instance_id, task_id=task_guid)
human_task: HumanTaskModel = human_task_query.first()
if human_task is None:
raise (
ApiError(
error_code="no_human_task",
message=f"Cannot find a task to complete for task id '{task_guid}' and process instance {process_instance_id}.",
status_code=500,
)
)
return human_task
def _get_spiff_task_from_processor(
task_guid: str,
processor: ProcessInstanceProcessor,
) -> SpiffTask:
task_uuid = uuid.UUID(task_guid)
spiff_task = processor.bpmn_process_instance.get_task_from_id(task_uuid)
if spiff_task is None:
raise (
ApiError(
error_code="empty_task",
message="Processor failed to obtain task.",
status_code=500,
)
)
return spiff_task

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 os
import uuid
from collections import OrderedDict
from collections.abc import Generator
from typing import Any
@ -20,15 +19,11 @@ from SpiffWorkflow.bpmn.workflow import BpmnWorkflow # type: ignore
from SpiffWorkflow.task import Task as SpiffTask # type: ignore
from SpiffWorkflow.util.task import TaskState # type: ignore
from sqlalchemy import and_
from sqlalchemy import asc
from sqlalchemy import desc
from sqlalchemy import func
from sqlalchemy.orm import aliased
from sqlalchemy.orm.util import AliasedClass
from spiffworkflow_backend.background_processing.celery_tasks.process_instance_task_producer import (
queue_enabled_for_process_model,
)
from spiffworkflow_backend.data_migrations.process_instance_migrator import ProcessInstanceMigrator
from spiffworkflow_backend.exceptions.api_error import ApiError
from spiffworkflow_backend.exceptions.error import HumanTaskAlreadyCompletedError
@ -46,7 +41,6 @@ from spiffworkflow_backend.models.process_instance import ProcessInstanceModelSc
from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus
from spiffworkflow_backend.models.process_instance import ProcessInstanceTaskDataCannotBeUpdatedError
from spiffworkflow_backend.models.process_instance_event import ProcessInstanceEventType
from spiffworkflow_backend.models.process_model import ProcessModelInfo
from spiffworkflow_backend.models.task import Task
from spiffworkflow_backend.models.task import TaskModel
from spiffworkflow_backend.models.task_definition import TaskDefinitionModel
@ -58,11 +52,11 @@ from spiffworkflow_backend.routes.process_api_blueprint import _find_principal_o
from spiffworkflow_backend.routes.process_api_blueprint import _find_process_instance_by_id_or_raise
from spiffworkflow_backend.routes.process_api_blueprint import _find_process_instance_for_me_or_raise
from spiffworkflow_backend.routes.process_api_blueprint import _get_process_model
from spiffworkflow_backend.routes.process_api_blueprint import _prepare_form_data
from spiffworkflow_backend.routes.process_api_blueprint import _task_submit_shared
from spiffworkflow_backend.services.authorization_service import AuthorizationService
from spiffworkflow_backend.services.error_handling_service import ErrorHandlingService
from spiffworkflow_backend.services.file_system_service import FileSystemService
from spiffworkflow_backend.services.git_service import GitCommandError
from spiffworkflow_backend.services.git_service import GitService
from spiffworkflow_backend.services.jinja_service import JinjaService
from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor
from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceIsAlreadyLockedError
@ -71,7 +65,6 @@ from spiffworkflow_backend.services.process_instance_service import ProcessInsta
from spiffworkflow_backend.services.process_instance_tmp_service import ProcessInstanceTmpService
from spiffworkflow_backend.services.process_model_service import ProcessModelService
from spiffworkflow_backend.services.spec_file_service import SpecFileService
from spiffworkflow_backend.services.task_service import TaskModelError
from spiffworkflow_backend.services.task_service import TaskService
@ -557,7 +550,12 @@ def task_submit(
execution_mode: str | None = None,
) -> flask.wrappers.Response:
with sentry_sdk.start_span(op="controller_action", description="tasks_controller.task_submit"):
return _task_submit_shared(process_instance_id, task_guid, body, execution_mode=execution_mode)
response_item = _task_submit_shared(process_instance_id, task_guid, body, execution_mode=execution_mode)
if "next_task_assigned_to_me" in response_item:
response_item = response_item["next_task_assigned_to_me"]
elif "next_task" in response_item:
response_item = response_item["next_task"]
return make_response(jsonify(response_item), 200)
def process_instance_progress(
@ -567,7 +565,7 @@ def process_instance_progress(
process_instance = _find_process_instance_for_me_or_raise(process_instance_id, include_actions=True)
principal = _find_principal_or_raise()
next_human_task_assigned_to_me = _next_human_task_for_user(process_instance_id, principal.user_id)
next_human_task_assigned_to_me = TaskService.next_human_task_for_user(process_instance_id, principal.user_id)
if next_human_task_assigned_to_me:
response["task"] = HumanTaskModel.to_task(next_human_task_assigned_to_me)
# this may not catch all times we should redirect to instance show page
@ -869,110 +867,6 @@ def task_save_draft(
)
def _task_submit_shared(
process_instance_id: int,
task_guid: str,
body: dict[str, Any],
execution_mode: str | None = None,
) -> flask.wrappers.Response:
principal = _find_principal_or_raise()
process_instance = _find_process_instance_by_id_or_raise(process_instance_id)
if not process_instance.can_submit_task():
raise ApiError(
error_code="process_instance_not_runnable",
message=(
f"Process Instance ({process_instance.id}) has status "
f"{process_instance.status} which does not allow tasks to be submitted."
),
status_code=400,
)
# we're dequeing twice in this function.
# tried to wrap the whole block in one dequeue, but that has the confusing side-effect that every exception
# in the block causes the process instance to go into an error state. for example, when
# AuthorizationService.assert_user_can_complete_task raises. this would have been solvable, but this seems simpler,
# and the cost is not huge given that this function is not the most common code path in the world.
with ProcessInstanceQueueService.dequeued(process_instance):
ProcessInstanceMigrator.run(process_instance)
processor = ProcessInstanceProcessor(
process_instance, workflow_completed_handler=ProcessInstanceService.schedule_next_process_model_cycle
)
spiff_task = _get_spiff_task_from_process_instance(task_guid, process_instance, processor=processor)
AuthorizationService.assert_user_can_complete_task(process_instance.id, str(spiff_task.id), principal.user)
if spiff_task.state != TaskState.READY:
raise (
ApiError(
error_code="invalid_state",
message="You may not update a task unless it is in the READY state.",
status_code=400,
)
)
human_task = _find_human_task_or_raise(
process_instance_id=process_instance_id,
task_guid=task_guid,
only_tasks_that_can_be_completed=True,
)
with sentry_sdk.start_span(op="task", description="complete_form_task"):
with ProcessInstanceQueueService.dequeued(process_instance):
ProcessInstanceService.complete_form_task(
processor=processor,
spiff_task=spiff_task,
data=body,
user=g.user,
human_task=human_task,
execution_mode=execution_mode,
)
# currently task_model has the potential to be None. This should be removable once
# we backfill the human_task table for task_guid and make that column not nullable
task_model: TaskModel | None = human_task.task_model
if task_model is None:
task_model = TaskModel.query.filter_by(guid=human_task.task_id).first()
# delete draft data when we submit a task to ensure cycling back to the task contains the
# most up-to-date data
task_draft_data = TaskService.task_draft_data_from_task_model(task_model)
if task_draft_data is not None:
db.session.delete(task_draft_data)
db.session.commit()
next_human_task_assigned_to_me = _next_human_task_for_user(process_instance_id, principal.user_id)
if next_human_task_assigned_to_me:
return make_response(jsonify(HumanTaskModel.to_task(next_human_task_assigned_to_me)), 200)
# a guest user completed a task, it has a guest_confirmation message to display to them,
# and there is nothing else for them to do
spiff_task_extensions = spiff_task.task_spec.extensions
if (
"allowGuest" in spiff_task_extensions
and spiff_task_extensions["allowGuest"] == "true"
and "guestConfirmation" in spiff_task.task_spec.extensions
):
return make_response(jsonify({"guest_confirmation": spiff_task.task_spec.extensions["guestConfirmation"]}), 200)
if processor.next_task():
task = ProcessInstanceService.spiff_task_to_api_task(processor, processor.next_task())
task.process_model_uses_queued_execution = queue_enabled_for_process_model(process_instance)
return make_response(jsonify(task), 200)
# next_task always returns something, even if the instance is complete, so we never get here
return Response(
json.dumps(
{
"ok": True,
"process_model_identifier": process_instance.process_model_identifier,
"process_instance_id": process_instance_id,
}
),
status=202,
mimetype="application/json",
)
def _get_tasks(
processes_started_by_user: bool = True,
has_lane_assignment_id: bool = True,
@ -1069,71 +963,6 @@ def _get_tasks(
return make_response(jsonify(response_json), 200)
def _prepare_form_data(
form_file: str, task_model: TaskModel, process_model: ProcessModelInfo, revision: str | None = None
) -> dict:
if task_model.data is None:
return {}
try:
file_contents = GitService.get_file_contents_for_revision_if_git_revision(
process_model=process_model,
revision=revision,
file_name=form_file,
)
except GitCommandError as exception:
raise (
ApiError(
error_code="git_error_loading_form",
message=(
f"Could not load form schema from: {form_file}. Was git history rewritten such that revision"
f" '{revision}' no longer exists? Error was: {str(exception)}"
),
status_code=400,
)
) from exception
try:
form_contents = JinjaService.render_jinja_template(file_contents, task=task_model)
except TaskModelError as wfe:
wfe.add_note(f"Error in Json Form File '{form_file}'")
api_error = ApiError.from_workflow_exception("instructions_error", str(wfe), exp=wfe)
api_error.file_name = form_file
raise api_error from wfe
try:
# form_contents is a str
hot_dict: dict = json.loads(form_contents)
return hot_dict
except Exception as exception:
raise (
ApiError(
error_code="error_loading_form",
message=f"Could not load form schema from: {form_file}. Error was: {str(exception)}",
status_code=400,
)
) from exception
def _get_spiff_task_from_process_instance(
task_guid: str,
process_instance: ProcessInstanceModel,
processor: ProcessInstanceProcessor,
) -> SpiffTask:
task_uuid = uuid.UUID(task_guid)
spiff_task = processor.bpmn_process_instance.get_task_from_id(task_uuid)
if spiff_task is None:
raise (
ApiError(
error_code="empty_task",
message="Processor failed to obtain task.",
status_code=500,
)
)
return spiff_task
# originally from: https://bitcoden.com/answers/python-nested-dictionary-update-value-where-any-nested-key-matches
def _update_form_schema_with_task_data_as_needed(in_dict: dict, task_data: dict) -> None:
for k, value in in_dict.items():
@ -1218,32 +1047,6 @@ def _get_potential_owner_usernames(assigned_user: AliasedClass) -> Any:
return potential_owner_usernames_from_group_concat_or_similar
def _find_human_task_or_raise(
process_instance_id: int,
task_guid: str,
only_tasks_that_can_be_completed: bool = False,
) -> HumanTaskModel:
if only_tasks_that_can_be_completed:
human_task_query = HumanTaskModel.query.filter_by(
process_instance_id=process_instance_id,
task_id=task_guid,
completed=False,
)
else:
human_task_query = HumanTaskModel.query.filter_by(process_instance_id=process_instance_id, task_id=task_guid)
human_task: HumanTaskModel = human_task_query.first()
if human_task is None:
raise (
ApiError(
error_code="no_human_task",
message=f"Cannot find a task to complete for task id '{task_guid}' and process instance {process_instance_id}.",
status_code=500,
)
)
return human_task
def _munge_form_ui_schema_based_on_hidden_fields_in_task_data(form_ui_schema: dict | None, task_data: dict) -> None:
if form_ui_schema is None:
return
@ -1269,14 +1072,3 @@ def _get_task_model_from_guid_or_raise(task_guid: str, process_instance_id: int)
status_code=400,
)
return task_model
def _next_human_task_for_user(process_instance_id: int, user_id: int) -> HumanTaskModel | None:
next_human_task: HumanTaskModel | None = (
HumanTaskModel.query.filter_by(process_instance_id=process_instance_id, completed=False)
.order_by(asc(HumanTaskModel.id)) # type: ignore
.join(HumanTaskUserModel)
.filter_by(user_id=user_id)
.first()
)
return next_human_task

View File

@ -1,6 +1,7 @@
import inspect
import re
from dataclasses import dataclass
from typing import Any
import yaml
from flask import current_app
@ -77,27 +78,33 @@ PATH_SEGMENTS_FOR_PERMISSION_ALL = [
{"path": "/task-data", "relevant_permissions": ["read", "update"]},
]
AUTHENTICATION_EXCLUSION_LIST = {
"authentication_begin": "spiffworkflow_backend.routes.service_tasks_controller",
"authentication_callback": "spiffworkflow_backend.routes.service_tasks_controller",
"authentication_options": "spiffworkflow_backend.routes.authentication_controller",
"github_webhook_receive": "spiffworkflow_backend.routes.webhooks_controller",
"login": "spiffworkflow_backend.routes.authentication_controller",
"login_api_return": "spiffworkflow_backend.routes.authentication_controller",
"login_return": "spiffworkflow_backend.routes.authentication_controller",
"login_with_access_token": "spiffworkflow_backend.routes.authentication_controller",
"logout": "spiffworkflow_backend.routes.authentication_controller",
"logout_return": "spiffworkflow_backend.routes.authentication_controller",
"status": "spiffworkflow_backend.routes.health_controller",
"task_allows_guest": "spiffworkflow_backend.routes.tasks_controller",
"test_raise_error": "spiffworkflow_backend.routes.debug_controller",
"url_info": "spiffworkflow_backend.routes.debug_controller",
"webhook": "spiffworkflow_backend.routes.webhooks_controller",
AUTHENTICATION_EXCLUSION_LIST = [
"spiffworkflow_backend.routes.authentication_controller.authentication_options",
"spiffworkflow_backend.routes.authentication_controller.login",
"spiffworkflow_backend.routes.authentication_controller.login_api_return",
"spiffworkflow_backend.routes.authentication_controller.login_return",
"spiffworkflow_backend.routes.authentication_controller.login_with_access_token",
"spiffworkflow_backend.routes.authentication_controller.logout",
"spiffworkflow_backend.routes.authentication_controller.logout_return",
"spiffworkflow_backend.routes.debug_controller.test_raise_error",
"spiffworkflow_backend.routes.debug_controller.url_info",
"spiffworkflow_backend.routes.health_controller.status",
"spiffworkflow_backend.routes.service_tasks_controller.authentication_begin",
"spiffworkflow_backend.routes.service_tasks_controller.authentication_callback",
"spiffworkflow_backend.routes.tasks_controller.task_allows_guest",
"spiffworkflow_backend.routes.webhooks_controller.github_webhook_receive",
"spiffworkflow_backend.routes.webhooks_controller.webhook",
# swagger api calls
"console_ui_home": "connexion.apis.flask_api",
"console_ui_static_files": "connexion.apis.flask_api",
"get_json_spec": "connexion.apis.flask_api",
}
"connexion.apis.flask_api.console_ui_home",
"connexion.apis.flask_api.console_ui_static_files",
"connexion.apis.flask_api.get_json_spec",
]
# these are api calls that are allowed to generate a public jwt when called
PUBLIC_AUTHENTICATION_EXCLUSION_LIST = [
"spiffworkflow_backend.routes.public_controller.message_form_show",
"spiffworkflow_backend.routes.public_controller.message_form_submit",
]
class AuthorizationService:
@ -250,6 +257,17 @@ class AuthorizationService:
db.session.commit()
return permission_assignment
@classmethod
def get_fully_qualified_api_function_from_request(cls) -> tuple[str | None, Any]:
api_view_function = current_app.view_functions[request.endpoint]
module = inspect.getmodule(api_view_function)
api_function_name = api_view_function.__name__ if api_view_function else None
controller_name = module.__name__ if module is not None else None
function_full_path = None
if api_function_name:
function_full_path = f"{controller_name}.{api_function_name}"
return (function_full_path, module)
@classmethod
def should_disable_auth_for_request(cls) -> bool:
if request.method == "OPTIONS":
@ -262,17 +280,10 @@ class AuthorizationService:
if not request.endpoint:
return True
api_view_function = current_app.view_functions[request.endpoint]
module = inspect.getmodule(api_view_function)
api_function_name = api_view_function.__name__ if api_view_function else None
controller_name = module.__name__ if module is not None else None
api_function_full_path, module = cls.get_fully_qualified_api_function_from_request()
if (
api_function_name
and (
api_function_name in AUTHENTICATION_EXCLUSION_LIST
and controller_name
and controller_name in AUTHENTICATION_EXCLUSION_LIST[api_function_name]
)
api_function_full_path
and (api_function_full_path in AUTHENTICATION_EXCLUSION_LIST)
or (module == openid_blueprint or module == scaffold) # don't check permissions for static assets
):
return True
@ -292,6 +303,22 @@ class AuthorizationService:
return None
@classmethod
def check_permission_for_request(cls) -> None:
permission_string = cls.get_permission_from_http_method(request.method)
if permission_string:
has_permission = cls.user_has_permission(
user=g.user,
permission=permission_string,
target_uri=request.path,
)
if has_permission:
return None
raise NotAuthorizedError(
f"User {g.user.username} is not authorized to perform requested action: {permission_string} - {request.path}",
)
@classmethod
def check_for_permission(cls, decoded_token: dict | None) -> None:
if cls.should_disable_auth_for_request():
@ -308,19 +335,7 @@ class AuthorizationService:
if cls.request_allows_guest_access(decoded_token):
return None
permission_string = cls.get_permission_from_http_method(request.method)
if permission_string:
has_permission = AuthorizationService.user_has_permission(
user=g.user,
permission=permission_string,
target_uri=request.path,
)
if has_permission:
return None
raise NotAuthorizedError(
f"User {g.user.username} is not authorized to perform requested action: {permission_string} - {request.path}",
)
cls.check_permission_for_request()
@classmethod
def request_is_excluded_from_permission_check(cls) -> bool:

View File

@ -38,12 +38,12 @@ class JinjaHelpers:
class JinjaService:
@classmethod
def render_instructions_for_end_user(cls, task: TaskModel | SpiffTask, extensions: dict | None = None) -> str:
def render_instructions_for_end_user(cls, task: TaskModel | SpiffTask | None = None, extensions: dict | None = None) -> str:
"""Assure any instructions for end user are processed for jinja syntax."""
if extensions is None:
if isinstance(task, TaskModel):
extensions = TaskService.get_extensions_from_task_model(task)
elif hasattr(task.task_spec, "extensions"):
elif task and hasattr(task.task_spec, "extensions"):
extensions = task.task_spec.extensions
if extensions and "instructionsForEndUser" in extensions:
if extensions["instructionsForEndUser"]:

View File

@ -1,5 +1,7 @@
import os
from typing import Any
from flask import g
from SpiffWorkflow.bpmn import BpmnEvent # type: ignore
from SpiffWorkflow.bpmn.specs.event_definitions.message import CorrelationProperty # type: ignore
from SpiffWorkflow.bpmn.specs.mixins import StartEventMixin # type: ignore
@ -9,6 +11,7 @@ from spiffworkflow_backend.background_processing.celery_tasks.process_instance_t
queue_process_instance_if_appropriate,
)
from spiffworkflow_backend.background_processing.celery_tasks.process_instance_task_producer import should_queue_process_instance
from spiffworkflow_backend.exceptions.api_error import ApiError
from spiffworkflow_backend.helpers.spiff_enum import ProcessInstanceExecutionMode
from spiffworkflow_backend.models.db import db
from spiffworkflow_backend.models.message_instance import MessageInstanceModel
@ -145,40 +148,6 @@ class MessageService:
return process_instance_receive
@classmethod
def _cancel_non_matching_start_events(
cls, processor_receive: ProcessInstanceProcessor, message_triggerable_process_model: MessageTriggerableProcessModel
) -> None:
"""Cancel any start event that does not match the start event that triggered this.
After that SpiffWorkflow and the WorkflowExecutionService can figure it out.
"""
start_tasks = processor_receive.bpmn_process_instance.get_tasks(spec_class=StartEventMixin)
for start_task in start_tasks:
if not isinstance(start_task.task_spec.event_definition, MessageEventDefinition):
start_task.cancel()
elif start_task.task_spec.event_definition.name != message_triggerable_process_model.message_name:
start_task.cancel()
@staticmethod
def get_process_instance_for_message_instance(
message_instance_receive: MessageInstanceModel,
) -> ProcessInstanceModel:
process_instance_receive: ProcessInstanceModel = ProcessInstanceModel.query.filter_by(
id=message_instance_receive.process_instance_id
).first()
if process_instance_receive is None:
raise MessageServiceError(
(
(
"Process instance cannot be found for queued message:"
f" {message_instance_receive.id}. Tried with id"
f" {message_instance_receive.process_instance_id}"
),
)
)
return process_instance_receive
@staticmethod
def process_message_receive(
process_instance_receive: ProcessInstanceModel,
@ -221,3 +190,113 @@ class MessageService:
message_instance_receive.status = MessageStatuses.completed.value
db.session.add(message_instance_receive)
db.session.commit()
@classmethod
def find_message_triggerable_process_model(cls, modified_message_name: str) -> MessageTriggerableProcessModel:
message_name, process_group_identifier = MessageInstanceModel.split_modified_message_name(modified_message_name)
potential_matches = MessageTriggerableProcessModel.query.filter_by(message_name=message_name).all()
actual_matches = []
for potential_match in potential_matches:
pgi, _ = potential_match.process_model_identifier.rsplit("/", 1)
if pgi.startswith(process_group_identifier):
actual_matches.append(potential_match)
if len(actual_matches) == 0:
raise (
ApiError(
error_code="message_triggerable_process_model_not_found",
message=(
f"Could not find a message triggerable process model for {modified_message_name} in the scope of group"
f" {process_group_identifier}"
),
status_code=400,
)
)
if len(actual_matches) > 1:
message_names = [f"{m.process_model_identifier} - {m.message_name}" for m in actual_matches]
raise (
ApiError(
error_code="multiple_message_triggerable_process_models_found",
message=f"Found {len(actual_matches)}. Expected 1. Found entries: {message_names}",
status_code=400,
)
)
mtp: MessageTriggerableProcessModel = actual_matches[0]
return mtp
@classmethod
def run_process_model_from_message(
cls,
modified_message_name: str,
body: dict[str, Any],
execution_mode: str | None = None,
) -> MessageInstanceModel:
message_name, _process_group_identifier = MessageInstanceModel.split_modified_message_name(modified_message_name)
# Create the send message
# TODO: support the full message id - including process group - in message instance
message_instance = MessageInstanceModel(
message_type="send",
name=message_name,
payload=body,
user_id=g.user.id,
)
db.session.add(message_instance)
db.session.commit()
try:
receiver_message = cls.correlate_send_message(message_instance, execution_mode=execution_mode)
except Exception as e:
db.session.delete(message_instance)
db.session.commit()
raise e
if not receiver_message:
db.session.delete(message_instance)
db.session.commit()
raise (
ApiError(
error_code="message_not_accepted",
message=(
"No running process instances correlate with the given message"
f" name of '{modified_message_name}'. And this message name is not"
" currently associated with any process Start Event. Nothing"
" to do."
),
status_code=400,
)
)
return receiver_message
@classmethod
def _cancel_non_matching_start_events(
cls, processor_receive: ProcessInstanceProcessor, message_triggerable_process_model: MessageTriggerableProcessModel
) -> None:
"""Cancel any start event that does not match the start event that triggered this.
After that SpiffWorkflow and the WorkflowExecutionService can figure it out.
"""
start_tasks = processor_receive.bpmn_process_instance.get_tasks(spec_class=StartEventMixin)
for start_task in start_tasks:
if not isinstance(start_task.task_spec.event_definition, MessageEventDefinition):
start_task.cancel()
elif start_task.task_spec.event_definition.name != message_triggerable_process_model.message_name:
start_task.cancel()
@staticmethod
def get_process_instance_for_message_instance(
message_instance_receive: MessageInstanceModel,
) -> ProcessInstanceModel:
process_instance_receive: ProcessInstanceModel = ProcessInstanceModel.query.filter_by(
id=message_instance_receive.process_instance_id
).first()
if process_instance_receive is None:
raise MessageServiceError(
(
(
"Process instance cannot be found for queued message:"
f" {message_instance_receive.id}. Tried with id"
f" {message_instance_receive.process_instance_id}"
),
)
)
return process_instance_receive

View File

@ -10,6 +10,7 @@ from SpiffWorkflow.bpmn.workflow import BpmnWorkflow # type: ignore
from SpiffWorkflow.exceptions import WorkflowException # type: ignore
from SpiffWorkflow.task import Task as SpiffTask # type: ignore
from SpiffWorkflow.util.task import TaskState # type: ignore
from sqlalchemy import asc
from spiffworkflow_backend.models.bpmn_process import BpmnProcessModel
from spiffworkflow_backend.models.bpmn_process import BpmnProcessNotFoundError
@ -17,6 +18,7 @@ from spiffworkflow_backend.models.bpmn_process_definition import BpmnProcessDefi
from spiffworkflow_backend.models.db import SpiffworkflowBaseDBModel
from spiffworkflow_backend.models.db import db
from spiffworkflow_backend.models.human_task import HumanTaskModel
from spiffworkflow_backend.models.human_task_user import HumanTaskUserModel
from spiffworkflow_backend.models.json_data import JsonDataDict
from spiffworkflow_backend.models.json_data import JsonDataModel
from spiffworkflow_backend.models.process_instance import ProcessInstanceModel
@ -707,6 +709,17 @@ class TaskService:
def get_name_for_display(cls, entity: TaskDefinitionModel | BpmnProcessDefinitionModel) -> str:
return entity.bpmn_name or entity.bpmn_identifier
@classmethod
def next_human_task_for_user(cls, process_instance_id: int, user_id: int) -> HumanTaskModel | None:
next_human_task: HumanTaskModel | None = (
HumanTaskModel.query.filter_by(process_instance_id=process_instance_id, completed=False)
.order_by(asc(HumanTaskModel.id)) # type: ignore
.join(HumanTaskUserModel)
.filter_by(user_id=user_id)
.first()
)
return next_human_task
@classmethod
def _task_subprocess(cls, spiff_task: SpiffTask) -> tuple[str | None, BpmnWorkflow | None]:
top_level_workflow = spiff_task.workflow.top_workflow

View File

@ -285,3 +285,12 @@ class UserService:
user = cls.create_user(username, "spiff_system_service", "spiff_system_service_id")
return user
@classmethod
def create_public_user(cls) -> UserModel:
username = UserModel.generate_random_username()
user = UserService.create_user(username, "spiff_public_service", username)
cls.add_user_to_group_or_add_to_waiting(
user.username, current_app.config["SPIFFWORKFLOW_BACKEND_DEFAULT_PUBLIC_USER_GROUP"]
)
return user

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
def logged_in_headers(
user: UserModel, _redirect_url: str = "http://some/frontend/url", extra_token_payload: dict | None = None
) -> dict[str, str]:
def logged_in_headers(user: UserModel, extra_token_payload: dict | None = None) -> dict[str, str]:
return {"Authorization": "Bearer " + user.encode_auth_token(extra_token_payload)}
def create_group_and_model_with_bpmn(

View File

@ -14,6 +14,7 @@ from spiffworkflow_backend.services.service_account_service import ServiceAccoun
from spiffworkflow_backend.services.user_service import UserService
from tests.spiffworkflow_backend.helpers.base_test import BaseTest
from tests.spiffworkflow_backend.helpers.test_data import load_test_spec
class TestAuthentication(BaseTest):
@ -152,3 +153,51 @@ class TestAuthentication(BaseTest):
assert response.status_code == 500
assert response.json is not None
assert response.json["message"].startswith("InvalidRedirectUrlError:")
def test_can_access_public_endpoints_and_get_token(
self,
app: Flask,
client: FlaskClient,
with_db_and_bpmn_file_cleanup: None,
) -> None:
group_info: list[GroupPermissionsDict] = [
{
"users": [],
"name": app.config["SPIFFWORKFLOW_BACKEND_DEFAULT_PUBLIC_USER_GROUP"],
"permissions": [{"actions": ["create", "read"], "uri": "/public/*"}],
}
]
AuthorizationService.refresh_permissions(group_info, group_permissions_only=True)
process_model = load_test_spec(
process_model_id="test_group/message-start-event-with-form",
process_model_source_directory="message-start-event-with-form",
)
process_group_identifier, _ = process_model.modified_process_model_identifier().rsplit(":", 1)
url = f"/v1.0/public/messages/form/{process_group_identifier}:bounty_start"
response = client.get(url)
assert response.status_code == 200
headers_dict = dict(response.headers)
assert "Set-Cookie" in headers_dict
cookie = headers_dict["Set-Cookie"]
cookie_split = cookie.split(";")
access_token = [cookie for cookie in cookie_split if cookie.startswith("access_token=")][0]
assert access_token is not None
re_result = re.match(r"^access_token=[\w_\.-]+$", access_token)
assert re_result is not None
response = client.get(
url,
headers={"Authorization": "Bearer " + access_token.split("=")[1]},
)
assert response.status_code == 200
# make sure we do not create and set a new cookie with this request
headers_dict = dict(response.headers)
assert "Set-Cookie" not in headers_dict
response = client.get(
"/v1.0/process-groups",
headers={"Authorization": "Bearer " + access_token.split("=")[1]},
)
assert response.status_code == 403

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 React from 'react';
import { createBrowserRouter, Outlet, RouterProvider } from 'react-router-dom';
import { AbilityContext } from './contexts/Can';
import APIErrorProvider from './contexts/APIErrorContext';
import ContainerForExtensions from './ContainerForExtensions';
import PublicRoutes from './routes/PublicRoutes';
export default function App() {
const ability = defineAbility(() => {});
return (
<div className="cds--white">
<APIErrorProvider>
<AbilityContext.Provider value={ability}>
<ContainerForExtensions />
</AbilityContext.Provider>
</APIErrorProvider>
</div>
);
const routeComponents = () => {
return [
{ path: 'public/*', element: <PublicRoutes /> },
{
path: '*',
element: <ContainerForExtensions />,
},
];
};
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 { createBrowserRouter, RouterProvider, Outlet } from 'react-router-dom';
import { Routes, Route } from 'react-router-dom';
import React, { useEffect, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
@ -100,15 +100,17 @@ export default function ContainerForExtensions() {
]);
const routeComponents = () => {
return [
{
path: '*',
element: <BaseRoutes extensionUxElements={extensionUxElements} />,
},
{ path: 'editor/*', element: <EditorRoutes /> },
{ path: 'extensions/:page_identifier', element: <Extension /> },
{ path: 'login', element: <Login /> },
];
return (
<Routes>
<Route
path="*"
element={<BaseRoutes extensionUxElements={extensionUxElements} />}
/>
<Route path="editor/*" element={<EditorRoutes />} />
<Route path="extensions/:page_identifier" element={<Extension />} />
<Route path="login" element={<Login />} />
</Routes>
);
};
const backendIsDownPage = () => {
@ -120,31 +122,20 @@ export default function ContainerForExtensions() {
return [];
}
if (backendIsUp) {
return <Outlet />;
return routeComponents();
}
return backendIsDownPage();
};
const layout = () => {
return (
<>
<NavigationBar extensionUxElements={extensionUxElements} />
<Content className={contentClassName}>
<ScrollToTop />
<ErrorBoundary FallbackComponent={ErrorBoundaryFallback}>
{innerComponents()}
</ErrorBoundary>
</Content>
</>
);
};
const router = createBrowserRouter([
{
path: '*',
Component: layout,
children: routeComponents(),
},
]);
return <RouterProvider router={router} />;
return (
<>
<NavigationBar extensionUxElements={extensionUxElements} />
<Content className={contentClassName}>
<ScrollToTop />
<ErrorBoundary FallbackComponent={ErrorBoundaryFallback}>
{innerComponents()}
</ErrorBoundary>
</Content>
</>
);
}

View File

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

View File

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

View File

@ -503,3 +503,15 @@ export interface ElementForArray {
key: string;
component: ReactElement | null;
}
export interface PublicTaskForm {
form_schema: any;
form_ui_schema: any;
instructions_for_end_user?: string;
}
export interface PublicTaskSubmitResponse {
form: PublicTaskForm;
task_guid: string;
process_instance_id: number;
confirmation_message_markdown: string;
}

View File

@ -32,7 +32,7 @@ export default function BaseRoutes({ extensionUxElements }: OwnProps) {
);
};
if (extensionUxElements) {
if (extensionUxElements !== null) {
const extensionRoutes = ExtensionUxElementMap({
displayLocation: 'routes',
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) {
if (onUnauthorized) {
onUnauthorized(result);
} else if (UserService.isPublicUser()) {
window.location.href = '/public/sign_out';
} else {
// Hopefully we can make this service a hook and use the error message context directly
// eslint-disable-next-line no-alert

View File

@ -43,6 +43,30 @@ const checkPathForTaskShowParams = (
return null;
};
// required for logging out
const getIdToken = () => {
return getCookie('id_token');
};
const getAccessToken = () => {
return getCookie('access_token');
};
const getAuthenticationIdentifier = () => {
return getCookie('authentication_identifier');
};
const isLoggedIn = () => {
return !!getAccessToken();
};
const isPublicUser = () => {
const idToken = getIdToken();
if (idToken) {
const idObject = jwt(idToken);
return (idObject as any).public;
}
return false;
};
const doLogin = (
authenticationOption?: AuthenticationOption,
redirectUrl?: string | null
@ -64,21 +88,6 @@ const doLogin = (
window.location.href = url;
};
// required for logging out
const getIdToken = () => {
return getCookie('id_token');
};
const getAccessToken = () => {
return getCookie('access_token');
};
const getAuthenticationIdentifier = () => {
return getCookie('authentication_identifier');
};
const isLoggedIn = () => {
return !!getAccessToken();
};
const doLogout = () => {
const idToken = getIdToken();
@ -88,6 +97,8 @@ const doLogout = () => {
// edge case. if the user is already logged out, just take them somewhere that will force them to sign in.
if (idToken === null) {
logoutRedirectUrl = SIGN_IN_PATH;
} else if (isPublicUser()) {
logoutRedirectUrl += '&backend_only=true';
}
window.location.href = logoutRedirectUrl;
@ -169,6 +180,7 @@ const UserService = {
getPreferredUsername,
getUserEmail,
isLoggedIn,
isPublicUser,
loginIfNeeded,
onlyGuestTaskCompletion,
};