diff --git a/src/spiffworkflow_backend/models/principal.py b/src/spiffworkflow_backend/models/principal.py index fbe05930..c7efa860 100644 --- a/src/spiffworkflow_backend/models/principal.py +++ b/src/spiffworkflow_backend/models/principal.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from flask_bpmn.models.db import db from flask_bpmn.models.db import SpiffworkflowBaseDBModel from sqlalchemy import ForeignKey +from sqlalchemy.orm import relationship from sqlalchemy.schema import CheckConstraint from spiffworkflow_backend.models.group import GroupModel @@ -28,3 +29,6 @@ class PrincipalModel(SpiffworkflowBaseDBModel): id = db.Column(db.Integer, primary_key=True) user_id = db.Column(ForeignKey(UserModel.id), nullable=True, unique=True) group_id = db.Column(ForeignKey(GroupModel.id), nullable=True, unique=True) + + user = relationship("UserModel", viewonly=True) + group = relationship("GroupModel", viewonly=True) diff --git a/src/spiffworkflow_backend/routes/process_api_blueprint.py b/src/spiffworkflow_backend/routes/process_api_blueprint.py index 00a6460a..af73c3cd 100644 --- a/src/spiffworkflow_backend/routes/process_api_blueprint.py +++ b/src/spiffworkflow_backend/routes/process_api_blueprint.py @@ -28,12 +28,14 @@ from lxml import etree # type: ignore from lxml.builder import ElementMaker # type: ignore from SpiffWorkflow.task import Task as SpiffTask # type: ignore from SpiffWorkflow.task import TaskState +from sqlalchemy import asc from sqlalchemy import desc from spiffworkflow_backend.exceptions.process_entity_not_found_error import ( ProcessEntityNotFoundError, ) from spiffworkflow_backend.models.active_task import ActiveTaskModel +from spiffworkflow_backend.models.active_task_user import ActiveTaskUserModel from spiffworkflow_backend.models.file import FileSchema from spiffworkflow_backend.models.message_instance import MessageInstanceModel from spiffworkflow_backend.models.message_model import MessageModel @@ -918,11 +920,11 @@ def process_instance_report_show( def task_list_my_tasks(page: int = 1, per_page: int = 100) -> flask.wrappers.Response: """Task_list_my_tasks.""" principal = find_principal_or_raise() - # TODO: use join table active_tasks = ( - ActiveTaskModel.query.filter_by(assigned_principal_id=principal.id) - .order_by(desc(ActiveTaskModel.id)) # type: ignore + ActiveTaskModel.query.order_by(desc(ActiveTaskModel.id)) # type: ignore .join(ProcessInstanceModel) + .join(ActiveTaskUserModel) + .filter_by(user_id=principal.user_id) # just need this add_columns to add the process_model_identifier. Then add everything back that was removed. .add_columns( ProcessInstanceModel.process_model_identifier, @@ -1085,18 +1087,15 @@ def task_submit( ) -> flask.wrappers.Response: """Task_submit_user_data.""" principal = find_principal_or_raise() - active_task_assigned_to_me = find_active_task_by_id_or_raise( - process_instance_id, task_id, principal.id - ) - - process_instance = find_process_instance_by_id_or_raise( - active_task_assigned_to_me.process_instance_id - ) + process_instance = find_process_instance_by_id_or_raise(process_instance_id) processor = ProcessInstanceProcessor(process_instance) spiff_task = get_spiff_task_from_process_instance( task_id, process_instance, processor=processor ) + AuthorizationService.assert_user_can_complete_spiff_task( + processor, spiff_task, principal.user + ) if spiff_task.state != TaskState.READY: raise ( @@ -1110,10 +1109,6 @@ def task_submit( if terminate_loop and spiff_task.is_looping(): spiff_task.terminate_loop() - # TODO: support repeating fields - # Extract the details specific to the form submitted - # form_data = WorkflowService().extract_form_data(body, spiff_task) - ProcessInstanceService.complete_form_task(processor, spiff_task, body, g.user) # If we need to update all tasks, then get the next ready task and if it a multi-instance with the same @@ -1128,10 +1123,13 @@ def task_submit( ProcessInstanceService.update_task_assignments(processor) - # TODO: update - next_active_task_assigned_to_me = ActiveTaskModel.query.filter_by( - assigned_principal_id=principal.id, process_instance_id=process_instance.id - ).first() + next_active_task_assigned_to_me = ( + ActiveTaskModel.query.filter_by(process_instance_id=process_instance_id) + .order_by(asc(ActiveTaskModel.id)) # type: ignore + .join(ActiveTaskUserModel) + .filter_by(user_id=principal.user_id) + .first() + ) if next_active_task_assigned_to_me: return make_response( jsonify(ActiveTaskModel.to_task(next_active_task_assigned_to_me)), 200 @@ -1294,31 +1292,6 @@ def find_principal_or_raise() -> PrincipalModel: return principal # type: ignore -def find_active_task_by_id_or_raise( - process_instance_id: int, task_id: str, principal_id: PrincipalModel -) -> ActiveTaskModel: - """Find_active_task_by_id_or_raise.""" - # TODO: update - active_task_assigned_to_me = ActiveTaskModel.query.filter_by( - process_instance_id=process_instance_id, - task_id=task_id, - # assigned_principal_id=principal_id, - ).first() - if active_task_assigned_to_me is None: - message = ( - f"Task not found for principal user {principal_id} " - f"process_instance_id: {process_instance_id}, task_id: {task_id}" - ) - raise ( - ApiError( - error_code="task_not_found", - message=message, - status_code=400, - ) - ) - return active_task_assigned_to_me # type: ignore - - def find_process_instance_by_id_or_raise( process_instance_id: int, ) -> ProcessInstanceModel: diff --git a/src/spiffworkflow_backend/services/authorization_service.py b/src/spiffworkflow_backend/services/authorization_service.py index 6a423e0a..f777bb36 100644 --- a/src/spiffworkflow_backend/services/authorization_service.py +++ b/src/spiffworkflow_backend/services/authorization_service.py @@ -10,8 +10,10 @@ from flask import g from flask import request from flask_bpmn.api.api_error import ApiError from flask_bpmn.models.db import db +from SpiffWorkflow.task import Task as SpiffTask from sqlalchemy import text +from spiffworkflow_backend.models.active_task import ActiveTaskModel # type: ignore from spiffworkflow_backend.models.group import GroupModel from spiffworkflow_backend.models.permission_assignment import PermissionAssignmentModel from spiffworkflow_backend.models.permission_target import PermissionTargetModel @@ -20,6 +22,9 @@ from spiffworkflow_backend.models.principal import PrincipalModel from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.models.user import UserNotFoundError from spiffworkflow_backend.models.user_group_assignment import UserGroupAssignmentModel +from spiffworkflow_backend.services.process_instance_processor import ( + ProcessInstanceProcessor, +) from spiffworkflow_backend.services.user_service import UserService @@ -27,6 +32,14 @@ class PermissionsFileNotSetError(Exception): """PermissionsFileNotSetError.""" +class ActiveTaskNotFoundError(Exception): + """ActiveTaskNotFoundError.""" + + +class UserDoesNotHaveAccessToTaskError(Exception): + """UserDoesNotHaveAccessToTaskError.""" + + class AuthorizationService: """Determine whether a user has permission to perform their request.""" @@ -364,196 +377,29 @@ class AuthorizationService: "The Authentication token you provided is invalid. You need a new token. ", ) from exception - # def get_bearer_token_from_internal_token(self, internal_token): - # """Get_bearer_token_from_internal_token.""" - # self.decode_auth_token(internal_token) - # print(f"get_user_by_internal_token: {internal_token}") + @staticmethod + def assert_user_can_complete_spiff_task( + processor: ProcessInstanceProcessor, + spiff_task: SpiffTask, + user: UserModel, + ) -> bool: + """Assert_user_can_complete_spiff_task.""" + active_task = ActiveTaskModel.query.filter_by( + task_name=spiff_task.task_spec.name, + process_instance_id=processor.process_instance_model.id, + ).first() + if active_task is None: + raise ActiveTaskNotFoundError( + f"Could find an active task with task name '{spiff_task.task_spec.name}'" + f" for process instance '{processor.process_instance_model.id}'" + ) - # def introspect_token(self, basic_token: str) -> dict: - # """Introspect_token.""" - # ( - # open_id_server_url, - # open_id_client_id, - # open_id_realm_name, - # open_id_client_secret_key, - # ) = AuthorizationService.get_open_id_args() - # - # bearer_token = AuthorizationService().get_bearer_token(basic_token) - # auth_bearer_string = f"Bearer {bearer_token['access_token']}" - # - # headers = { - # "Content-Type": "application/x-www-form-urlencoded", - # "Authorization": auth_bearer_string, - # } - # data = { - # "client_id": open_id_client_id, - # "client_secret": open_id_client_secret_key, - # "token": basic_token, - # } - # request_url = f"{open_id_server_url}/realms/{open_id_realm_name}/protocol/openid-connect/token/introspect" - # - # introspect_response = requests.post(request_url, headers=headers, data=data) - # introspection = json.loads(introspect_response.text) - # - # return introspection - - # def get_permission_by_basic_token(self, basic_token: dict) -> list: - # """Get_permission_by_basic_token.""" - # ( - # open_id_server_url, - # open_id_client_id, - # open_id_realm_name, - # open_id_client_secret_key, - # ) = AuthorizationService.get_open_id_args() - # - # # basic_token = AuthorizationService().refresh_token(basic_token) - # # bearer_token = AuthorizationService().get_bearer_token(basic_token['access_token']) - # bearer_token = AuthorizationService().get_bearer_token(basic_token) - # # auth_bearer_string = f"Bearer {bearer_token['access_token']}" - # auth_bearer_string = f"Bearer {bearer_token}" - # - # headers = { - # "Content-Type": "application/x-www-form-urlencoded", - # "Authorization": auth_bearer_string, - # } - # data = { - # "client_id": open_id_client_id, - # "client_secret": open_id_client_secret_key, - # "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", - # "response_mode": "permissions", - # "audience": open_id_client_id, - # "response_include_resource_name": True, - # } - # request_url = f"{open_id_server_url}/realms/{open_id_realm_name}/protocol/openid-connect/token" - # permission_response = requests.post(request_url, headers=headers, data=data) - # permission = json.loads(permission_response.text) - # return permission - - # def get_auth_status_for_resource_and_scope_by_token( - # self, basic_token: dict, resource: str, scope: str - # ) -> str: - # """Get_auth_status_for_resource_and_scope_by_token.""" - # ( - # open_id_server_url, - # open_id_client_id, - # open_id_realm_name, - # open_id_client_secret_key, - # ) = AuthorizationService.get_open_id_args() - # - # # basic_token = AuthorizationService().refresh_token(basic_token) - # bearer_token = AuthorizationService().get_bearer_token(basic_token) - # auth_bearer_string = f"Bearer {bearer_token['access_token']}" - # - # headers = { - # "Content-Type": "application/x-www-form-urlencoded", - # "Authorization": auth_bearer_string, - # } - # data = { - # "client_id": open_id_client_id, - # "client_secret": open_id_client_secret_key, - # "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", - # "permission": f"{resource}#{scope}", - # "response_mode": "permissions", - # "audience": open_id_client_id, - # } - # request_url = f"{open_id_server_url}/realms/{open_id_realm_name}/protocol/openid-connect/token" - # auth_response = requests.post(request_url, headers=headers, data=data) - # - # print("get_auth_status_for_resource_and_scope_by_token") - # auth_status: str = json.loads(auth_response.text) - # return auth_status - - # def get_permissions_by_token_for_resource_and_scope( - # self, basic_token: str, resource: str|None=None, scope: str|None=None - # ) -> str: - # """Get_permissions_by_token_for_resource_and_scope.""" - # ( - # open_id_server_url, - # open_id_client_id, - # open_id_realm_name, - # open_id_client_secret_key, - # ) = AuthorizationService.get_open_id_args() - # - # # basic_token = AuthorizationService().refresh_token(basic_token) - # # bearer_token = AuthorizationService().get_bearer_token(basic_token['access_token']) - # bearer_token = AuthorizationService().get_bearer_token(basic_token) - # auth_bearer_string = f"Bearer {bearer_token['access_token']}" - # - # headers = { - # "Content-Type": "application/x-www-form-urlencoded", - # "Authorization": auth_bearer_string, - # } - # permision = "" - # if resource is not None and resource != '': - # permision += resource - # if scope is not None and scope != '': - # permision += "#" + scope - # data = { - # "client_id": open_id_client_id, - # "client_secret": open_id_client_secret_key, - # "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", - # "response_mode": "permissions", - # "permission": permision, - # "audience": open_id_client_id, - # "response_include_resource_name": True, - # } - # request_url = f"{open_id_server_url}/realms/{open_id_realm_name}/protocol/openid-connect/token" - # permission_response = requests.post(request_url, headers=headers, data=data) - # permission: str = json.loads(permission_response.text) - # return permission - - # def get_resource_set(self, public_access_token, uri): - # """Get_resource_set.""" - # ( - # open_id_server_url, - # open_id_client_id, - # open_id_realm_name, - # open_id_client_secret_key, - # ) = AuthorizationService.get_open_id_args() - # bearer_token = AuthorizationService().get_bearer_token(public_access_token) - # auth_bearer_string = f"Bearer {bearer_token['access_token']}" - # headers = { - # "Content-Type": "application/json", - # "Authorization": auth_bearer_string, - # } - # data = { - # "matchingUri": "true", - # "deep": "true", - # "max": "-1", - # "exactName": "false", - # "uri": uri, - # } - # - # # f"matchingUri=true&deep=true&max=-1&exactName=false&uri={URI_TO_TEST_AGAINST}" - # request_url = f"{open_id_server_url}/realms/{open_id_realm_name}/authz/protection/resource_set" - # response = requests.get(request_url, headers=headers, data=data) - # - # print("get_resource_set") - - # def get_permission_by_token(self, public_access_token: str) -> dict: - # """Get_permission_by_token.""" - # # TODO: Write a test for this - # ( - # open_id_server_url, - # open_id_client_id, - # open_id_realm_name, - # open_id_client_secret_key, - # ) = AuthorizationService.get_open_id_args() - # bearer_token = AuthorizationService().get_bearer_token(public_access_token) - # auth_bearer_string = f"Bearer {bearer_token['access_token']}" - # headers = { - # "Content-Type": "application/x-www-form-urlencoded", - # "Authorization": auth_bearer_string, - # } - # data = { - # "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", - # "audience": open_id_client_id, - # } - # request_url = f"{open_id_server_url}/realms/{open_id_realm_name}/protocol/openid-connect/token" - # permission_response = requests.post(request_url, headers=headers, data=data) - # permission: dict = json.loads(permission_response.text) - # - # return permission + if user not in active_task.potential_owners: + raise UserDoesNotHaveAccessToTaskError( + f"User {user.username} does not have access to update task'{spiff_task.task_spec.name}'" + f" for process instance '{processor.process_instance_model.id}'" + ) + return True class KeycloakAuthorization: diff --git a/src/spiffworkflow_backend/services/process_instance_service.py b/src/spiffworkflow_backend/services/process_instance_service.py index 429acbbc..5eeb09e9 100644 --- a/src/spiffworkflow_backend/services/process_instance_service.py +++ b/src/spiffworkflow_backend/services/process_instance_service.py @@ -11,7 +11,6 @@ from flask_bpmn.models.db import db from SpiffWorkflow.task import Task as SpiffTask # type: ignore from SpiffWorkflow.util.deep_merge import DeepMerge -from spiffworkflow_backend.models.active_task import ActiveTaskModel # type: ignore from spiffworkflow_backend.models.process_instance import ProcessInstanceApi from spiffworkflow_backend.models.process_instance import ProcessInstanceModel from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus @@ -20,6 +19,7 @@ from spiffworkflow_backend.models.task import Task from spiffworkflow_backend.models.task_event import TaskAction from spiffworkflow_backend.models.task_event import TaskEventModel from spiffworkflow_backend.models.user import UserModel +from spiffworkflow_backend.services.authorization_service import AuthorizationService from spiffworkflow_backend.services.git_service import GitService from spiffworkflow_backend.services.process_instance_processor import ( ProcessInstanceProcessor, @@ -27,14 +27,6 @@ from spiffworkflow_backend.services.process_instance_processor import ( from spiffworkflow_backend.services.process_model_service import ProcessModelService -class ActiveTaskNotFoundError(Exception): - """ActiveTaskNotFoundError.""" - - -class UserDoesNotHaveAccessToTaskError(Exception): - """UserDoesNotHaveAccessToTaskError.""" - - class ProcessInstanceService: """ProcessInstanceService.""" @@ -279,21 +271,9 @@ class ProcessInstanceService: Abstracted here because we need to do it multiple times when completing all tasks in a multi-instance task. """ - active_task = ActiveTaskModel.query.filter_by( - task_name=spiff_task.task_spec.name, - process_instance_id=processor.process_instance_model.id, - ).first() - if active_task is None: - raise ActiveTaskNotFoundError( - f"Could find an active task with task name '{spiff_task.task_spec.name}'" - f" for process instance '{processor.process_instance_model.id}'" - ) - - if user not in active_task.potential_owners: - raise UserDoesNotHaveAccessToTaskError( - f"User {user.username} does not have access to update task'{spiff_task.task_spec.name}'" - f" for process instance '{processor.process_instance_model.id}'" - ) + AuthorizationService.assert_user_can_complete_spiff_task( + processor, spiff_task, user + ) dot_dct = ProcessInstanceService.create_dot_dict(data) spiff_task.update_data(dot_dct) diff --git a/tests/spiffworkflow_backend/unit/test_process_instance_processor.py b/tests/spiffworkflow_backend/unit/test_process_instance_processor.py index f1a15436..dcd29016 100644 --- a/tests/spiffworkflow_backend/unit/test_process_instance_processor.py +++ b/tests/spiffworkflow_backend/unit/test_process_instance_processor.py @@ -7,15 +7,15 @@ from tests.spiffworkflow_backend.helpers.test_data import load_test_spec from spiffworkflow_backend.models.group import GroupModel from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus from spiffworkflow_backend.services.authorization_service import AuthorizationService +from spiffworkflow_backend.services.authorization_service import ( + UserDoesNotHaveAccessToTaskError, +) from spiffworkflow_backend.services.process_instance_processor import ( ProcessInstanceProcessor, ) from spiffworkflow_backend.services.process_instance_service import ( ProcessInstanceService, ) -from spiffworkflow_backend.services.process_instance_service import ( - UserDoesNotHaveAccessToTaskError, -) class TestProcessInstanceProcessor(BaseTest):