From 8f3f1d620f0987d542c5bd768b64ae69a777f5d9 Mon Sep 17 00:00:00 2001 From: jasquat <2487833+jasquat@users.noreply.github.com> Date: Fri, 15 Sep 2023 10:10:57 -0400 Subject: [PATCH] Feature/api keys (#489) * some initial work to support user api keys w/ burnettk * some updates to store and use service accounts - migrations do not work in sqlite atm * pyl * minor tweak to the migration * refactored user route * this is working if returning user that created the service account * put back migrations from main w/ burnettk * tests pass with new migration w/ burnettk * do not remove service account permissions on refresh_permissions w/ burnettk * added new component to make some api calls to populate child components and routes w/ burnettk * allow displaying extensions in configuration tab w/ burnettk * removed service accounts controller in favor of extension and encrypt the api keys * add fuzz to username to make deleting and recreating service accounts easier * allow specifying the process id to use when running an extension w/ burnettk * allow extensions to navigate to each other on form submit w/ burnettk * removed commented out debug code --------- Co-authored-by: jasquat --- .../migrations/versions/9d5b6c5c31a5_.py | 52 +++ .../src/spiffworkflow_backend/__init__.py | 4 +- .../src/spiffworkflow_backend/api.yml | 11 + .../load_database_models.py | 3 + .../src/spiffworkflow_backend/models/db.py | 5 + .../models/service_account.py | 55 +++ .../src/spiffworkflow_backend/models/user.py | 13 - .../routes/extensions_controller.py | 22 +- .../src/spiffworkflow_backend/routes/user.py | 380 ++++++++++-------- .../services/authorization_service.py | 14 +- .../services/process_instance_processor.py | 30 +- .../services/service_account_service.py | 54 +++ .../services/user_service.py | 24 ++ .../integration/test_authentication.py | 29 ++ .../integration/test_service_accounts.py | 48 +++ .../unit/test_authorization_service.py | 1 + spiffworkflow-frontend/src/App.tsx | 38 +- .../src/ContainerForExtensions.tsx | 108 +++++ .../ExtensionUxElementForDisplay.tsx | 30 ++ .../src/components/Filters.tsx | 8 +- .../src/components/NavigationBar.tsx | 99 +---- .../src/extension_ui_schema_interfaces.ts | 27 +- .../src/routes/AdminRoutes.tsx | 14 +- .../src/routes/Configuration.tsx | 38 +- .../src/routes/Extension.tsx | 144 +++++-- 25 files changed, 873 insertions(+), 378 deletions(-) create mode 100644 spiffworkflow-backend/migrations/versions/9d5b6c5c31a5_.py create mode 100644 spiffworkflow-backend/src/spiffworkflow_backend/models/service_account.py create mode 100644 spiffworkflow-backend/src/spiffworkflow_backend/services/service_account_service.py create mode 100644 spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_service_accounts.py create mode 100644 spiffworkflow-frontend/src/ContainerForExtensions.tsx create mode 100644 spiffworkflow-frontend/src/components/ExtensionUxElementForDisplay.tsx diff --git a/spiffworkflow-backend/migrations/versions/9d5b6c5c31a5_.py b/spiffworkflow-backend/migrations/versions/9d5b6c5c31a5_.py new file mode 100644 index 00000000..75507b4a --- /dev/null +++ b/spiffworkflow-backend/migrations/versions/9d5b6c5c31a5_.py @@ -0,0 +1,52 @@ +"""empty message + +Revision ID: 9d5b6c5c31a5 +Revises: 55bbdeb6b635 +Create Date: 2023-09-14 08:49:53.619192 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '9d5b6c5c31a5' +down_revision = '55bbdeb6b635' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('service_account', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('created_by_user_id', sa.Integer(), nullable=False), + sa.Column('api_key_hash', sa.String(length=255), nullable=False), + sa.Column('updated_at_in_seconds', sa.Integer(), nullable=True), + sa.Column('created_at_in_seconds', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['created_by_user_id'], ['user.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name', 'created_by_user_id', name='service_account_uniq') + ) + with op.batch_alter_table('service_account', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_service_account_api_key_hash'), ['api_key_hash'], unique=True) + batch_op.create_index(batch_op.f('ix_service_account_created_by_user_id'), ['created_by_user_id'], unique=False) + batch_op.create_index(batch_op.f('ix_service_account_name'), ['name'], unique=False) + batch_op.create_index(batch_op.f('ix_service_account_user_id'), ['user_id'], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('service_account', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_service_account_user_id')) + batch_op.drop_index(batch_op.f('ix_service_account_name')) + batch_op.drop_index(batch_op.f('ix_service_account_created_by_user_id')) + batch_op.drop_index(batch_op.f('ix_service_account_api_key_hash')) + + op.drop_table('service_account') + # ### end Alembic commands ### diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/__init__.py b/spiffworkflow-backend/src/spiffworkflow_backend/__init__.py index 298e4a18..12b5dfec 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/__init__.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/__init__.py @@ -25,7 +25,7 @@ from spiffworkflow_backend.helpers.api_version import V1_API_PATH_PREFIX from spiffworkflow_backend.models.db import db from spiffworkflow_backend.models.db import migrate from spiffworkflow_backend.routes.openid_blueprint.openid_blueprint import openid_blueprint -from spiffworkflow_backend.routes.user import set_new_access_token_in_cookie +from spiffworkflow_backend.routes.user import _set_new_access_token_in_cookie from spiffworkflow_backend.routes.user import verify_token from spiffworkflow_backend.routes.user_blueprint import user_blueprint from spiffworkflow_backend.services.authorization_service import AuthorizationService @@ -182,7 +182,7 @@ def create_app() -> flask.app.Flask: app.before_request(verify_token) app.before_request(AuthorizationService.check_for_permission) - app.after_request(set_new_access_token_in_cookie) + app.after_request(_set_new_access_token_in_cookie) # The default is true, but we want to preserve the order of keys in the json # This is particularly helpful for forms that are generated from json schemas. diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml index 5eed2443..23939bbb 100755 --- a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml @@ -3457,3 +3457,14 @@ components: fiilterable: type: string nullable: false + + ServiceAccountRequest: + properties: + name: + type: string + nullable: false + ServiceAccountApiKey: + properties: + api_key: + type: string + nullable: false diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/load_database_models.py b/spiffworkflow-backend/src/spiffworkflow_backend/load_database_models.py index a4f85d85..dcf71245 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/load_database_models.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/load_database_models.py @@ -94,5 +94,8 @@ from spiffworkflow_backend.models.configuration import ( from spiffworkflow_backend.models.user_property import ( UserPropertyModel, ) # noqa: F401 +from spiffworkflow_backend.models.service_account import ( + ServiceAccountModel, +) # noqa: F401 add_listeners() diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/db.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/db.py index c7e1862a..3b6f8837 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/db.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/db.py @@ -14,6 +14,11 @@ db = SQLAlchemy() migrate = Migrate() +# NOTE: ensure all db models are added to src/spiffworkflow_backend/load_database_models.py so that: +# 1) they will be loaded in time for add_listeners. otherwise they may not auto-update created_at and updated_at times +# 2) database migration code picks them up when migrations are automatically generated + + class SpiffworkflowBaseDBModel(db.Model): # type: ignore __abstract__ = True diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/service_account.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/service_account.py new file mode 100644 index 00000000..3acf96e9 --- /dev/null +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/service_account.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import time +import uuid +from dataclasses import dataclass +from hashlib import sha256 + +from sqlalchemy import ForeignKey +from sqlalchemy.orm import relationship + +from spiffworkflow_backend.models.db import SpiffworkflowBaseDBModel +from spiffworkflow_backend.models.db import db + +# this is designed to be used for the "service" column on the user table, which is designed to hold +# information about which authentiation system is used to authenticate this user. +# in this case, we are authenticating based on X-API-KEY which correlates to a known value in the spiff db. +SPIFF_SERVICE_ACCOUNT_AUTH_SERVICE = "spiff_service_account" +SPIFF_SERVICE_ACCOUNT_AUTH_SERVICE_ID_PREFIX = "service_account_" + + +@dataclass +class ServiceAccountModel(SpiffworkflowBaseDBModel): + __tablename__ = "service_account" + __allow_unmapped__ = True + __table_args__ = (db.UniqueConstraint("name", "created_by_user_id", name="service_account_uniq"),) + + id: int = db.Column(db.Integer, primary_key=True) + name: str = db.Column(db.String(255), nullable=False, unique=False, index=True) + user_id: int = db.Column(ForeignKey("user.id"), nullable=False, index=True) + created_by_user_id: int = db.Column(ForeignKey("user.id"), nullable=False, index=True) + + api_key_hash: str = db.Column(db.String(255), nullable=False, unique=True, index=True) + + user = relationship("UserModel", uselist=False, cascade="delete", foreign_keys=[user_id]) # type: ignore + + updated_at_in_seconds: int = db.Column(db.Integer) + created_at_in_seconds: int = db.Column(db.Integer) + + # only to used when the service account first created to tell the user what the key is + api_key: str | None = None + + @classmethod + def generate_api_key(cls) -> str: + return str(uuid.uuid4()) + + @classmethod + def hash_api_key(cls, unencrypted_api_key: str) -> str: + return sha256(unencrypted_api_key.encode("utf8")).hexdigest() + + @classmethod + def generate_username_for_related_user(cls, service_account_name: str, created_by_user_id: int) -> str: + # add fuzz to username so a user can delete and recreate an api_key with the same name + # also make the username readable so we know where it came from even after the service account is deleted + creation_time_for_fuzz = time.time() + return f"{service_account_name}_{created_by_user_id}_{creation_time_for_fuzz}" diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py index 1f3f3f65..a62353d8 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py @@ -4,9 +4,7 @@ from dataclasses import dataclass from typing import Any import jwt -import marshmallow from flask import current_app -from marshmallow import Schema from sqlalchemy.orm import relationship from spiffworkflow_backend.models.db import SpiffworkflowBaseDBModel @@ -81,14 +79,3 @@ 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 - - -class UserModelSchema(Schema): - class Meta: - model = UserModel - # load_instance = True - # include_relationships = False - # exclude = ("UserGroupAssignment",) - - id = marshmallow.fields.String(required=True) - username = marshmallow.fields.String(required=True) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/extensions_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/extensions_controller.py index 749d59a9..21a94d5d 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/extensions_controller.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/extensions_controller.py @@ -101,10 +101,11 @@ def _run_extension( process_model = _get_process_model(process_model_identifier) except ApiError as ex: if ex.error_code == "process_model_cannot_be_found": + # if process_model_identifier.startswith(current_app.config["SPIFFWORKFLOW_BACKEND_EXTENSIONS_PROCESS_MODEL_PREFIX"]) raise ApiError( error_code="invalid_process_model_extension", message=( - f"Process Model '{process_model_identifier}' cannot be run as an extension. It must be in the" + f"Process Model '{process_model_identifier}' could not be found as an extension. It must be in the" " correct Process Group:" f" {current_app.config['SPIFFWORKFLOW_BACKEND_EXTENSIONS_PROCESS_MODEL_PREFIX']}" ), @@ -124,9 +125,11 @@ def _run_extension( ui_schema_action = None persistence_level = "none" + process_id_to_run = None if body and "ui_schema_action" in body: ui_schema_action = body["ui_schema_action"] persistence_level = ui_schema_action.get("persistence_level", "none") + process_id_to_run = ui_schema_action.get("process_id_to_run", None) process_instance = None if persistence_level == "none": @@ -145,7 +148,9 @@ def _run_extension( processor = None try: processor = ProcessInstanceProcessor( - process_instance, script_engine=CustomBpmnScriptEngine(use_restricted_script_engine=False) + process_instance, + script_engine=CustomBpmnScriptEngine(use_restricted_script_engine=False), + process_id_to_run=process_id_to_run, ) if body and "extension_input" in body: processor.do_engine_steps(save=False, execution_strategy_name="run_current_ready_tasks") @@ -166,12 +171,13 @@ def _run_extension( # we need to recurse through all last tasks if the last task is a call activity or subprocess. if processor is not None: task = processor.bpmn_process_instance.last_task - raise ApiError.from_task( - error_code="unknown_exception", - message=f"An unknown error occurred. Original error: {e}", - status_code=400, - task=task, - ) from e + if task is not None: + raise ApiError.from_task( + error_code="unknown_exception", + message=f"An unknown error occurred. Original error: {e}", + status_code=400, + task=task, + ) from e raise e task_data = {} diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/user.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/user.py index f421b653..d70bf854 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/user.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/user.py @@ -18,6 +18,7 @@ from spiffworkflow_backend.exceptions.api_error import ApiError 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.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 @@ -51,155 +52,35 @@ def verify_token(token: str | None = None, force_run: bool | None = False) -> No ApiError: If not on production and token is not valid, returns an 'invalid_token' 403 error. If on production and user is not authenticated, returns a 'no_user' 403 error. """ - user_info = None if not force_run and AuthorizationService.should_disable_auth_for_request(): return None - if not token and "Authorization" in request.headers: - token = request.headers["Authorization"].removeprefix("Bearer ") - - if not token and "access_token" in request.cookies: - if request.path.startswith(f"{V1_API_PATH_PREFIX}/process-data-file-download/") or request.path.startswith( - f"{V1_API_PATH_PREFIX}/extensions-get-data/" - ): - token = request.cookies["access_token"] + token_info = _find_token_from_headers(token) # This should never be set here but just in case _clear_auth_tokens_from_thread_local_data() - if token: - user_model = None - decoded_token = get_decoded_token(token) + user_model = None + if token_info["token"] is not None: + # import pdb; pdb.set_trace() + user_model = _get_user_model_from_token(token_info["token"]) + elif token_info["api_key"] is not None: + user_model = _get_user_model_from_api_key(token_info["api_key"]) - if decoded_token is not None: - if "token_type" in decoded_token: - token_type = decoded_token["token_type"] - if token_type == "internal": # noqa: S105 - try: - user_model = get_user_from_decoded_internal_token(decoded_token) - except Exception as e: - current_app.logger.error( - f"Exception in verify_token getting user from decoded internal token. {e}" - ) + if user_model: + g.user = user_model - # if the user is forced logged out then stop processing the token - if _force_logout_user_if_necessary(user_model): - return None - - elif "iss" in decoded_token.keys(): - user_info = None - try: - if AuthenticationService.validate_id_or_access_token(token): - user_info = decoded_token - except TokenExpiredError as token_expired_error: - # Try to refresh the token - user = UserService.get_user_by_service_and_service_id(decoded_token["iss"], decoded_token["sub"]) - if user: - refresh_token = AuthenticationService.get_refresh_token(user.id) - if refresh_token: - auth_token: dict = AuthenticationService.get_auth_token_from_refresh_token(refresh_token) - if auth_token and "error" not in auth_token and "id_token" in auth_token: - tld = current_app.config["THREAD_LOCAL_DATA"] - tld.new_access_token = auth_token["id_token"] - tld.new_id_token = auth_token["id_token"] - # We have the user, but this code is a bit convoluted, and will later demand - # a user_info object so it can look up the user. Sorry to leave this crap here. - user_info = { - "sub": user.service_id, - "iss": user.service, - } - - if user_info is None: - raise ApiError( - error_code="invalid_token", - message="Your token is expired. Please Login", - status_code=401, - ) from token_expired_error - - except Exception as e: - raise ApiError( - error_code="fail_get_user_info", - message="Cannot get user info from token", - status_code=401, - ) from e - if ( - user_info is not None and "error" not in user_info and "iss" in user_info - ): # not sure what to test yet - user_model = ( - UserModel.query.filter(UserModel.service == user_info["iss"]) - .filter(UserModel.service_id == user_info["sub"]) - .first() - ) - if user_model is None: - raise ApiError( - error_code="invalid_user", - message="Invalid user. Please log in.", - status_code=401, - ) - # no user_info - else: - raise ApiError( - error_code="no_user_info", - message="Cannot retrieve user info", - status_code=401, - ) - - else: - current_app.logger.debug("token_type not in decode_token in verify_token") - raise ApiError( - error_code="invalid_token", - message="Invalid token. Please log in.", - status_code=401, - ) - - if user_model: - g.user = user_model - - # If the user is valid, store the token for this session - if g.user: + # If the user is valid, store the token for this session + if g.user: + if token_info["token"]: # This is an id token, so we don't have a refresh token yet - g.token = token - get_scope(token) - return None - else: - raise ApiError(error_code="no_user_id", message="Cannot get a user id") + g.token = token_info["token"] + get_scope(token_info["token"]) + return None raise ApiError(error_code="invalid_token", message="Cannot validate token.", status_code=401) -def set_new_access_token_in_cookie( - response: flask.wrappers.Response, -) -> flask.wrappers.Response: - """Checks if a new token has been set in THREAD_LOCAL_DATA and sets cookies if appropriate. - - It will also delete the cookies if the user has logged out. - """ - tld = current_app.config["THREAD_LOCAL_DATA"] - domain_for_frontend_cookie: str | None = re.sub( - r"^https?:\/\/", - "", - current_app.config["SPIFFWORKFLOW_BACKEND_URL_FOR_FRONTEND"], - ) - if domain_for_frontend_cookie and domain_for_frontend_cookie.startswith("localhost"): - domain_for_frontend_cookie = None - - # fixme - we should not be passing the access token back to the client - if hasattr(tld, "new_access_token") and tld.new_access_token: - response.set_cookie("access_token", tld.new_access_token, domain=domain_for_frontend_cookie) - - # id_token is required for logging out since this gets passed back to the openid server - if hasattr(tld, "new_id_token") and tld.new_id_token: - response.set_cookie("id_token", tld.new_id_token, domain=domain_for_frontend_cookie) - - if hasattr(tld, "user_has_logged_out") and tld.user_has_logged_out: - response.set_cookie("id_token", "", max_age=0, domain=domain_for_frontend_cookie) - response.set_cookie("access_token", "", max_age=0, domain=domain_for_frontend_cookie) - - _clear_auth_tokens_from_thread_local_data() - - return response - - def login(redirect_url: str = "/", process_instance_id: int | None = None, task_guid: str | None = None) -> Response: if current_app.config.get("SPIFFWORKFLOW_BACKEND_AUTHENTICATION_DISABLED"): AuthorizationService.create_guest_token( @@ -223,25 +104,13 @@ def login(redirect_url: str = "/", process_instance_id: int | None = None, task_ return redirect(login_redirect_url) -def parse_id_token(token: str) -> Any: - """Parse the id token.""" - parts = token.split(".") - if len(parts) != 3: - raise Exception("Incorrect id token format") - - payload = parts[1] - padded = payload + "=" * (4 - len(payload) % 4) - decoded = base64.b64decode(padded) - return json.loads(decoded) - - def login_return(code: str, state: str, session_state: str = "") -> Response | None: state_dict = ast.literal_eval(base64.b64decode(state).decode("utf-8")) state_redirect_url = state_dict["redirect_url"] auth_token_object = AuthenticationService().get_auth_token_object(code) if "id_token" in auth_token_object: id_token = auth_token_object["id_token"] - user_info = parse_id_token(id_token) + user_info = _parse_id_token(id_token) if AuthenticationService.validate_id_or_access_token(id_token): if user_info and "error" not in user_info: @@ -273,7 +142,7 @@ def login_return(code: str, state: str, session_state: str = "") -> Response | N # FIXME: share more code with login_return and maybe attempt to get a refresh token def login_with_access_token(access_token: str) -> Response: - user_info = parse_id_token(access_token) + user_info = _parse_id_token(access_token) if AuthenticationService.validate_id_or_access_token(access_token): if user_info and "error" not in user_info: @@ -320,22 +189,6 @@ def logout_return() -> Response: return redirect(f"{frontend_url}/") -def get_decoded_token(token: str) -> dict | None: - try: - decoded_token = jwt.decode(token, options={"verify_signature": False}) - except Exception as e: - raise ApiError(error_code="invalid_token", message="Cannot decode token.") from e - else: - if "token_type" in decoded_token or "iss" in decoded_token: - return decoded_token - else: - current_app.logger.error(f"Unknown token type in get_decoded_token: token: {token}") - raise ApiError( - error_code="unknown_token", - message="Unknown token type in get_decoded_token", - ) - - def get_scope(token: str) -> str: scope = "" decoded_token = jwt.decode(token, options={"verify_signature": False}) @@ -344,18 +197,38 @@ def get_scope(token: str) -> str: return scope -def get_user_from_decoded_internal_token(decoded_token: dict) -> UserModel | None: - sub = decoded_token["sub"] - parts = sub.split("::") - service = parts[0].split(":")[1] - service_id = parts[1].split(":")[1] - user: UserModel = ( - UserModel.query.filter(UserModel.service == service).filter(UserModel.service_id == service_id).first() +# this isn't really a private method but it's also not a valid api call so underscoring it +def _set_new_access_token_in_cookie( + response: flask.wrappers.Response, +) -> flask.wrappers.Response: + """Checks if a new token has been set in THREAD_LOCAL_DATA and sets cookies if appropriate. + + It will also delete the cookies if the user has logged out. + """ + tld = current_app.config["THREAD_LOCAL_DATA"] + domain_for_frontend_cookie: str | None = re.sub( + r"^https?:\/\/", + "", + current_app.config["SPIFFWORKFLOW_BACKEND_URL_FOR_FRONTEND"], ) - if user: - return user - user = UserService.create_user(service_id, service, service_id) - return user + if domain_for_frontend_cookie and domain_for_frontend_cookie.startswith("localhost"): + domain_for_frontend_cookie = None + + # fixme - we should not be passing the access token back to the client + if hasattr(tld, "new_access_token") and tld.new_access_token: + response.set_cookie("access_token", tld.new_access_token, domain=domain_for_frontend_cookie) + + # id_token is required for logging out since this gets passed back to the openid server + if hasattr(tld, "new_id_token") and tld.new_id_token: + response.set_cookie("id_token", tld.new_id_token, domain=domain_for_frontend_cookie) + + if hasattr(tld, "user_has_logged_out") and tld.user_has_logged_out: + response.set_cookie("id_token", "", max_age=0, domain=domain_for_frontend_cookie) + response.set_cookie("access_token", "", max_age=0, domain=domain_for_frontend_cookie) + + _clear_auth_tokens_from_thread_local_data() + + return response def _clear_auth_tokens_from_thread_local_data() -> None: @@ -388,3 +261,158 @@ def _force_logout_user_if_necessary(user_model: UserModel | None = None) -> bool tld.user_has_logged_out = True return True return False + + +def _find_token_from_headers(token: str | None) -> dict[str, str | None]: + api_key = None + if not token and "Authorization" in request.headers: + token = request.headers["Authorization"].removeprefix("Bearer ") + + if not token and "access_token" in request.cookies: + if request.path.startswith(f"{V1_API_PATH_PREFIX}/process-data-file-download/") or request.path.startswith( + f"{V1_API_PATH_PREFIX}/extensions-get-data/" + ): + token = request.cookies["access_token"] + + if not token and "X-API-KEY" in request.headers: + api_key = request.headers["X-API-KEY"] + + token_info = {"token": token, "api_key": api_key} + return token_info + + +def _get_user_model_from_api_key(api_key: str) -> UserModel | None: + api_key_hash = ServiceAccountModel.hash_api_key(api_key) + service_account = ServiceAccountModel.query.filter_by(api_key_hash=api_key_hash).first() + user_model = None + if service_account is not None: + user_model = UserModel.query.filter_by(id=service_account.user_id).first() + return user_model + + +def _get_user_model_from_token(token: str) -> UserModel | None: + user_model = None + decoded_token = _get_decoded_token(token) + + if decoded_token is not None: + if "token_type" in decoded_token: + token_type = decoded_token["token_type"] + if token_type == "internal": # noqa: S105 + try: + user_model = _get_user_from_decoded_internal_token(decoded_token) + except Exception as e: + current_app.logger.error( + f"Exception in verify_token getting user from decoded internal token. {e}" + ) + + # if the user is forced logged out then stop processing the token + if _force_logout_user_if_necessary(user_model): + return None + + elif "iss" in decoded_token.keys(): + user_info = None + try: + if AuthenticationService.validate_id_or_access_token(token): + user_info = decoded_token + except TokenExpiredError as token_expired_error: + # Try to refresh the token + user = UserService.get_user_by_service_and_service_id(decoded_token["iss"], decoded_token["sub"]) + if user: + refresh_token = AuthenticationService.get_refresh_token(user.id) + if refresh_token: + auth_token: dict = AuthenticationService.get_auth_token_from_refresh_token(refresh_token) + if auth_token and "error" not in auth_token and "id_token" in auth_token: + tld = current_app.config["THREAD_LOCAL_DATA"] + tld.new_access_token = auth_token["id_token"] + tld.new_id_token = auth_token["id_token"] + # We have the user, but this code is a bit convoluted, and will later demand + # a user_info object so it can look up the user. Sorry to leave this crap here. + user_info = { + "sub": user.service_id, + "iss": user.service, + } + + if user_info is None: + raise ApiError( + error_code="invalid_token", + message="Your token is expired. Please Login", + status_code=401, + ) from token_expired_error + + except Exception as e: + raise ApiError( + error_code="fail_get_user_info", + message="Cannot get user info from token", + status_code=401, + ) from e + if user_info is not None and "error" not in user_info and "iss" in user_info: # not sure what to test yet + user_model = ( + UserModel.query.filter(UserModel.service == user_info["iss"]) + .filter(UserModel.service_id == user_info["sub"]) + .first() + ) + if user_model is None: + raise ApiError( + error_code="invalid_user", + message="Invalid user. Please log in.", + status_code=401, + ) + # no user_info + else: + raise ApiError( + error_code="no_user_info", + message="Cannot retrieve user info", + status_code=401, + ) + + else: + current_app.logger.debug("token_type not in decode_token in verify_token") + raise ApiError( + error_code="invalid_token", + message="Invalid token. Please log in.", + status_code=401, + ) + + return user_model + + +def _get_user_from_decoded_internal_token(decoded_token: dict) -> UserModel | None: + sub = decoded_token["sub"] + parts = sub.split("::") + service = parts[0].split(":")[1] + service_id = parts[1].split(":")[1] + user: UserModel = ( + UserModel.query.filter(UserModel.service == service).filter(UserModel.service_id == service_id).first() + ) + if user: + return user + user = UserService.create_user(service_id, service, service_id) + return user + + +def _get_decoded_token(token: str) -> dict | None: + try: + decoded_token = jwt.decode(token, options={"verify_signature": False}) + except Exception as e: + raise ApiError(error_code="invalid_token", message="Cannot decode token.") from e + else: + if "token_type" in decoded_token or "iss" in decoded_token: + return decoded_token + else: + current_app.logger.error(f"Unknown token type in get_decoded_token: token: {token}") + raise ApiError( + error_code="unknown_token", + message="Unknown token type in get_decoded_token", + ) + + +def _parse_id_token(token: str) -> Any: + """Parse the id token.""" + parts = token.split(".") + if len(parts) != 3: + raise Exception("Incorrect id token format") + + payload = parts[1] + padded = payload + "=" * (4 - len(payload) % 4) + decoded = base64.b64decode(padded) + return json.loads(decoded) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py index 55dd9a7c..4fb5d692 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py @@ -21,6 +21,7 @@ from spiffworkflow_backend.models.permission_assignment import PermissionAssignm from spiffworkflow_backend.models.permission_target import PermissionTargetModel from spiffworkflow_backend.models.principal import MissingPrincipalError from spiffworkflow_backend.models.principal import PrincipalModel +from spiffworkflow_backend.models.service_account import SPIFF_SERVICE_ACCOUNT_AUTH_SERVICE from spiffworkflow_backend.models.task import TaskModel # noqa: F401 from spiffworkflow_backend.models.user import SPIFF_GUEST_USER from spiffworkflow_backend.models.user import UserModel @@ -33,6 +34,7 @@ from spiffworkflow_backend.services.authentication_service import TokenNotProvid from spiffworkflow_backend.services.authentication_service import UserNotLoggedInError from spiffworkflow_backend.services.group_service import GroupService from spiffworkflow_backend.services.user_service import UserService +from sqlalchemy import and_ from sqlalchemy import or_ from sqlalchemy import text @@ -604,6 +606,8 @@ class AuthorizationService: PermissionToAssign(permission="update", target_uri="/authentication/configuration") ) + permissions_to_assign.append(PermissionToAssign(permission="create", target_uri="/service-accounts")) + return permissions_to_assign @classmethod @@ -891,7 +895,15 @@ class AuthorizationService: cls, group_permissions: list[GroupPermissionsDict], group_permissions_only: bool = False ) -> None: """Adds new permission assignments and deletes old ones.""" - initial_permission_assignments = PermissionAssignmentModel.query.all() + initial_permission_assignments = ( + PermissionAssignmentModel.query.outerjoin( + PrincipalModel, + and_(PrincipalModel.id == PermissionAssignmentModel.principal_id, PrincipalModel.user_id.is_not(None)), + ) + .outerjoin(UserModel, UserModel.id == PrincipalModel.user_id) + .filter(or_(UserModel.id.is_(None), UserModel.service != SPIFF_SERVICE_ACCOUNT_AUTH_SERVICE)) # type: ignore + .all() + ) initial_user_to_group_assignments = UserGroupAssignmentModel.query.all() group_permissions = group_permissions + cls.parse_permissions_yaml_into_group_info() added_permissions = cls.add_permissions_from_group_permissions( diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_processor.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_processor.py index 1309abd9..91ae3659 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_processor.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_processor.py @@ -403,16 +403,22 @@ class ProcessInstanceProcessor: validate_only: bool = False, script_engine: PythonScriptEngine | None = None, workflow_completed_handler: WorkflowCompletedHandler | None = None, + process_id_to_run: str | None = None, ) -> None: """Create a Workflow Processor based on the serialized information available in the process_instance model.""" self._script_engine = script_engine or self.__class__._default_script_engine self._workflow_completed_handler = workflow_completed_handler self.setup_processor_with_process_instance( - process_instance_model=process_instance_model, validate_only=validate_only + process_instance_model=process_instance_model, + validate_only=validate_only, + process_id_to_run=process_id_to_run, ) def setup_processor_with_process_instance( - self, process_instance_model: ProcessInstanceModel, validate_only: bool = False + self, + process_instance_model: ProcessInstanceModel, + validate_only: bool = False, + process_id_to_run: str | None = None, ) -> None: tld = current_app.config["THREAD_LOCAL_DATA"] tld.process_instance_id = process_instance_model.id @@ -441,7 +447,7 @@ class ProcessInstanceProcessor: bpmn_process_spec, subprocesses, ) = ProcessInstanceProcessor.get_process_model_and_subprocesses( - process_instance_model.process_model_identifier + process_instance_model.process_model_identifier, process_id_to_run=process_id_to_run ) self.process_model_identifier = process_instance_model.process_model_identifier @@ -471,7 +477,9 @@ class ProcessInstanceProcessor: @classmethod def get_process_model_and_subprocesses( - cls, process_model_identifier: str + cls, + process_model_identifier: str, + process_id_to_run: str | None = None, ) -> tuple[BpmnProcessSpec, IdToBpmnProcessSpecMapping]: process_model_info = ProcessModelService.get_process_model(process_model_identifier) if process_model_info is None: @@ -482,7 +490,7 @@ class ProcessInstanceProcessor: ) ) spec_files = FileSystemService.get_files(process_model_info) - return cls.get_spec(spec_files, process_model_info) + return cls.get_spec(spec_files, process_model_info, process_id_to_run=process_id_to_run) @classmethod def get_bpmn_process_instance_from_process_model(cls, process_model_identifier: str) -> BpmnWorkflow: @@ -1303,11 +1311,15 @@ class ProcessInstanceProcessor: @staticmethod def get_spec( - files: list[File], process_model_info: ProcessModelInfo + files: list[File], + process_model_info: ProcessModelInfo, + process_id_to_run: str | None = None, ) -> tuple[BpmnProcessSpec, IdToBpmnProcessSpecMapping]: """Returns a SpiffWorkflow specification for the given process_instance spec, using the files provided.""" parser = ProcessInstanceProcessor.get_parser() + process_id = process_id_to_run or process_model_info.primary_process_id + for file in files: data = SpecFileService.get_data(process_model_info, file.name) try: @@ -1322,7 +1334,7 @@ class ProcessInstanceProcessor: error_code="invalid_xml", message=f"'{file.name}' is not a valid xml file." + str(xse), ) from xse - if process_model_info.primary_process_id is None or process_model_info.primary_process_id == "": + if process_id is None or process_id == "": raise ( ApiError( error_code="no_primary_bpmn_error", @@ -1332,10 +1344,10 @@ class ProcessInstanceProcessor: ProcessInstanceProcessor.update_spiff_parser_with_all_process_dependency_files(parser) try: - bpmn_process_spec = parser.get_spec(process_model_info.primary_process_id) + bpmn_process_spec = parser.get_spec(process_id) # returns a dict of {process_id: bpmn_process_spec}, otherwise known as an IdToBpmnProcessSpecMapping - subprocesses = parser.get_subprocess_specs(process_model_info.primary_process_id) + subprocesses = parser.get_subprocess_specs(process_id) except ValidationException as ve: raise ApiError( error_code="process_instance_validation_error", diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/service_account_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/service_account_service.py new file mode 100644 index 00000000..ab6fb38e --- /dev/null +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/service_account_service.py @@ -0,0 +1,54 @@ +from spiffworkflow_backend.models.db import db +from spiffworkflow_backend.models.permission_assignment import PermissionAssignmentModel +from spiffworkflow_backend.models.service_account import SPIFF_SERVICE_ACCOUNT_AUTH_SERVICE +from spiffworkflow_backend.models.service_account import SPIFF_SERVICE_ACCOUNT_AUTH_SERVICE_ID_PREFIX +from spiffworkflow_backend.models.service_account import ServiceAccountModel +from spiffworkflow_backend.models.user import UserModel +from spiffworkflow_backend.services.user_service import UserService + + +class ServiceAccountService: + @classmethod + def create_service_account(cls, name: str, service_account_creator: UserModel) -> ServiceAccountModel: + api_key = ServiceAccountModel.generate_api_key() + api_key_hash = ServiceAccountModel.hash_api_key(api_key) + username = ServiceAccountModel.generate_username_for_related_user(name, service_account_creator.id) + service_account_user = UserModel( + username=username, + email=f"{username}@spiff.service.account.example.com", + service=SPIFF_SERVICE_ACCOUNT_AUTH_SERVICE, + service_id=f"{SPIFF_SERVICE_ACCOUNT_AUTH_SERVICE_ID_PREFIX}_{username}", + ) + db.session.add(service_account_user) + service_account = ServiceAccountModel( + name=name, + created_by_user_id=service_account_creator.id, + api_key_hash=api_key_hash, + user=service_account_user, + ) + db.session.add(service_account) + ServiceAccountModel.commit_with_rollback_on_exception() + cls.associated_service_account_with_permissions(service_account_user, service_account_creator) + service_account.api_key = api_key + return service_account + + @classmethod + def associated_service_account_with_permissions( + cls, service_account_user: UserModel, service_account_creator: UserModel + ) -> None: + principal = UserService.create_principal(service_account_user.id) + user_permissions = sorted(UserService.get_permission_targets_for_user(service_account_creator)) + + permission_objects = [] + for user_permission in user_permissions: + permission_objects.append( + PermissionAssignmentModel( + principal_id=principal.id, + permission_target_id=user_permission[0], + permission=user_permission[1], + grant_type=user_permission[2], + ) + ) + + db.session.bulk_save_objects(permission_objects) + ServiceAccountModel.commit_with_rollback_on_exception() diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/user_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/user_service.py index 41221722..52624b25 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/user_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/user_service.py @@ -170,3 +170,27 @@ class UserService: human_task_user = HumanTaskUserModel(user_id=user.id, human_task_id=human_task.id) db.session.add(human_task_user) db.session.commit() + + @classmethod + def get_permission_targets_for_user(cls, user: UserModel, check_groups: bool = True) -> set[tuple[str, str, str]]: + unique_permission_assignments = set() + for permission_assignment in user.principal.permission_assignments: + unique_permission_assignments.add( + ( + permission_assignment.permission_target_id, + permission_assignment.permission, + permission_assignment.grant_type, + ) + ) + + if check_groups: + for group in user.groups: + for permission_assignment in group.principal.permission_assignments: + unique_permission_assignments.add( + ( + permission_assignment.permission_target_id, + permission_assignment.permission, + permission_assignment.grant_type, + ) + ) + return unique_permission_assignments diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_authentication.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_authentication.py index f02b1598..97dfcc4e 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_authentication.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_authentication.py @@ -9,6 +9,8 @@ from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.services.authentication_service import AuthenticationService from spiffworkflow_backend.services.authorization_service import AuthorizationService from spiffworkflow_backend.services.authorization_service import GroupPermissionsDict +from spiffworkflow_backend.services.service_account_service import ServiceAccountService +from spiffworkflow_backend.services.user_service import UserService from tests.spiffworkflow_backend.helpers.base_test import BaseTest @@ -87,3 +89,30 @@ class TestAuthentication(BaseTest): assert sorted(group_identifiers) == ["everybody", "group_one"] self.assert_user_has_permission(user, "read", "/v1.0/process-groups/hey") self.assert_user_has_permission(user, "read", "/v1.0/process-groups/hey:yo") + + def test_does_not_remove_permissions_from_service_accounts_on_refresh( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + with_super_admin_user: UserModel, + ) -> None: + service_account = ServiceAccountService.create_service_account("sa_api_key", with_super_admin_user) + service_account_permissions_before = sorted( + UserService.get_permission_targets_for_user(service_account.user, check_groups=False) + ) + + # make sure running refresh_permissions doesn't remove the user from the group + group_info: list[GroupPermissionsDict] = [ + { + "users": [], + "name": "group_one", + "permissions": [{"actions": ["create", "read"], "uri": "PG:hey"}], + } + ] + AuthorizationService.refresh_permissions(group_info, group_permissions_only=True) + + service_account_permissions_after = sorted( + UserService.get_permission_targets_for_user(service_account.user, check_groups=False) + ) + assert service_account_permissions_before == service_account_permissions_after diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_service_accounts.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_service_accounts.py new file mode 100644 index 00000000..76bf53e4 --- /dev/null +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_service_accounts.py @@ -0,0 +1,48 @@ +import json + +from flask.app import Flask +from flask.testing import FlaskClient +from spiffworkflow_backend.models.user import UserModel +from spiffworkflow_backend.services.service_account_service import ServiceAccountService +from spiffworkflow_backend.services.user_service import UserService + +from tests.spiffworkflow_backend.helpers.base_test import BaseTest + + +class TestServiceAccounts(BaseTest): + def test_can_create_a_service_account( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + with_super_admin_user: UserModel, + ) -> None: + api_key_name = "heyhey" + service_account = ServiceAccountService.create_service_account(api_key_name, with_super_admin_user) + + assert service_account is not None + assert service_account.created_by_user_id == with_super_admin_user.id + assert service_account.name == api_key_name + assert service_account.api_key is not None + + # ci and local set different permissions for the admin user so figure out dynamically + admin_permissions = sorted(UserService.get_permission_targets_for_user(with_super_admin_user)) + service_account_permissions = sorted( + UserService.get_permission_targets_for_user(service_account.user, check_groups=False) + ) + assert admin_permissions == service_account_permissions + + # ensure service account can actually access the api + post_body = { + "key": "secret_key", + "value": "hey_value", + } + response = client.post( + "/v1.0/secrets", + content_type="application/json", + headers={"X-API-KEY": service_account.api_key}, + data=json.dumps(post_body), + ) + assert response.status_code == 201 + assert response.json is not None + assert response.json["key"] == post_body["key"] diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_authorization_service.py b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_authorization_service.py index e77a59d9..8d76d2cf 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_authorization_service.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_authorization_service.py @@ -515,5 +515,6 @@ class TestAuthorizationService(BaseTest): ("/secrets/*", "delete"), ("/secrets/*", "read"), ("/secrets/*", "update"), + ("/service-accounts", "create"), ] ) diff --git a/spiffworkflow-frontend/src/App.tsx b/spiffworkflow-frontend/src/App.tsx index 92f140fe..84b3f773 100644 --- a/spiffworkflow-frontend/src/App.tsx +++ b/spiffworkflow-frontend/src/App.tsx @@ -1,22 +1,11 @@ -// @ts-ignore -import { Content } from '@carbon/react'; - -import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { BrowserRouter } from 'react-router-dom'; import { defineAbility } from '@casl/ability'; import React from 'react'; -import NavigationBar from './components/NavigationBar'; - -import HomePageRoutes from './routes/HomePageRoutes'; -import About from './routes/About'; -import ErrorBoundary from './components/ErrorBoundary'; -import AdminRoutes from './routes/AdminRoutes'; import { AbilityContext } from './contexts/Can'; import UserService from './services/UserService'; import APIErrorProvider from './contexts/APIErrorContext'; -import ScrollToTop from './components/ScrollToTop'; -import EditorRoutes from './routes/EditorRoutes'; -import Extension from './routes/Extension'; +import ContainerForExtensions from './ContainerForExtensions'; export default function App() { if (!UserService.isLoggedIn()) { @@ -26,34 +15,13 @@ export default function App() { const ability = defineAbility(() => {}); - let contentClassName = 'main-site-body-centered'; - if (window.location.pathname.startsWith('/editor/')) { - contentClassName = 'no-center-stuff'; - } - return (
{/* @ts-ignore */} - - - - - - } /> - } /> - } /> - } /> - } /> - } - /> - - - + diff --git a/spiffworkflow-frontend/src/ContainerForExtensions.tsx b/spiffworkflow-frontend/src/ContainerForExtensions.tsx new file mode 100644 index 00000000..05a72506 --- /dev/null +++ b/spiffworkflow-frontend/src/ContainerForExtensions.tsx @@ -0,0 +1,108 @@ +import { Content } from '@carbon/react'; +import { Routes, Route } from 'react-router-dom'; +import React, { useEffect, useState } from 'react'; +import NavigationBar from './components/NavigationBar'; + +import HomePageRoutes from './routes/HomePageRoutes'; +import About from './routes/About'; +import ErrorBoundary from './components/ErrorBoundary'; +import AdminRoutes from './routes/AdminRoutes'; + +import ScrollToTop from './components/ScrollToTop'; +import EditorRoutes from './routes/EditorRoutes'; +import Extension from './routes/Extension'; +import { useUriListForPermissions } from './hooks/UriListForPermissions'; +import { PermissionsToCheck, ProcessFile, ProcessModel } from './interfaces'; +import { usePermissionFetcher } from './hooks/PermissionService'; +import { + ExtensionUiSchema, + UiSchemaUxElement, +} from './extension_ui_schema_interfaces'; +import HttpService from './services/HttpService'; + +export default function ContainerForExtensions() { + const [extensionUxElements, setExtensionNavigationItems] = useState< + UiSchemaUxElement[] | null + >(null); + + let contentClassName = 'main-site-body-centered'; + if (window.location.pathname.startsWith('/editor/')) { + contentClassName = 'no-center-stuff'; + } + const { targetUris } = useUriListForPermissions(); + const permissionRequestData: PermissionsToCheck = { + [targetUris.extensionListPath]: ['GET'], + }; + const { ability, permissionsLoaded } = usePermissionFetcher( + permissionRequestData + ); + + // eslint-disable-next-line sonarjs/cognitive-complexity + useEffect(() => { + if (!permissionsLoaded) { + return; + } + + const processExtensionResult = (processModels: ProcessModel[]) => { + const eni: UiSchemaUxElement[] = processModels + .map((processModel: ProcessModel) => { + const extensionUiSchemaFile = processModel.files.find( + (file: ProcessFile) => file.name === 'extension_uischema.json' + ); + if (extensionUiSchemaFile && extensionUiSchemaFile.file_contents) { + try { + const extensionUiSchema: ExtensionUiSchema = JSON.parse( + extensionUiSchemaFile.file_contents + ); + if (extensionUiSchema.ux_elements) { + return extensionUiSchema.ux_elements; + } + } catch (jsonParseError: any) { + console.error( + `Unable to get navigation items for ${processModel.id}` + ); + } + } + return [] as UiSchemaUxElement[]; + }) + .flat(); + if (eni) { + setExtensionNavigationItems(eni); + } + }; + + if (ability.can('GET', targetUris.extensionListPath)) { + HttpService.makeCallToBackend({ + path: targetUris.extensionListPath, + successCallback: processExtensionResult, + }); + } + }, [targetUris.extensionListPath, permissionsLoaded, ability]); + + return ( + <> + + + + + + } /> + } /> + } /> + + } + /> + } /> + } + /> + + + + + ); +} diff --git a/spiffworkflow-frontend/src/components/ExtensionUxElementForDisplay.tsx b/spiffworkflow-frontend/src/components/ExtensionUxElementForDisplay.tsx new file mode 100644 index 00000000..c4726071 --- /dev/null +++ b/spiffworkflow-frontend/src/components/ExtensionUxElementForDisplay.tsx @@ -0,0 +1,30 @@ +import { UiSchemaUxElement } from '../extension_ui_schema_interfaces'; + +type OwnProps = { + displayLocation: string; + elementCallback: Function; + extensionUxElements?: UiSchemaUxElement[] | null; +}; + +export default function ExtensionUxElementForDisplay({ + displayLocation, + elementCallback, + extensionUxElements, +}: OwnProps) { + if (!extensionUxElements) { + return null; + } + + const mainElement = () => { + return extensionUxElements.map( + (uxElement: UiSchemaUxElement, index: number) => { + if (uxElement.display_location === displayLocation) { + return elementCallback(uxElement, index); + } + return null; + } + ); + }; + + return <>{mainElement()}; +} diff --git a/spiffworkflow-frontend/src/components/Filters.tsx b/spiffworkflow-frontend/src/components/Filters.tsx index 6173ae4f..279315c8 100644 --- a/spiffworkflow-frontend/src/components/Filters.tsx +++ b/spiffworkflow-frontend/src/components/Filters.tsx @@ -1,11 +1,5 @@ -// @ts-ignore import { Filter } from '@carbon/icons-react'; -import { - Button, - Grid, - Column, - // @ts-ignore -} from '@carbon/react'; +import { Button, Grid, Column } from '@carbon/react'; type OwnProps = { showFilterOptions: boolean; diff --git a/spiffworkflow-frontend/src/components/NavigationBar.tsx b/spiffworkflow-frontend/src/components/NavigationBar.tsx index 4fc02da9..d38a03a5 100644 --- a/spiffworkflow-frontend/src/components/NavigationBar.tsx +++ b/spiffworkflow-frontend/src/components/NavigationBar.tsx @@ -24,18 +24,20 @@ import { Can } from '@casl/react'; import logo from '../logo.svg'; import UserService from '../services/UserService'; import { useUriListForPermissions } from '../hooks/UriListForPermissions'; -import { PermissionsToCheck, ProcessModel, ProcessFile } from '../interfaces'; -import { - ExtensionUiSchema, - UiSchemaUxElement, -} from '../extension_ui_schema_interfaces'; +import { PermissionsToCheck } from '../interfaces'; +import { UiSchemaUxElement } from '../extension_ui_schema_interfaces'; import { usePermissionFetcher } from '../hooks/PermissionService'; -import HttpService, { UnauthenticatedError } from '../services/HttpService'; +import { UnauthenticatedError } from '../services/HttpService'; import { DOCUMENTATION_URL, SPIFF_ENVIRONMENT } from '../config'; import appVersionInfo from '../helpers/appVersionInfo'; import { slugifyString } from '../helpers'; +import ExtensionUxElementForDisplay from './ExtensionUxElementForDisplay'; -export default function NavigationBar() { +type OwnProps = { + extensionUxElements?: UiSchemaUxElement[] | null; +}; + +export default function NavigationBar({ extensionUxElements }: OwnProps) { const handleLogout = () => { UserService.doLogout(); }; @@ -46,9 +48,6 @@ export default function NavigationBar() { const location = useLocation(); const [activeKey, setActiveKey] = useState(''); - const [extensionNavigationItems, setExtensionNavigationItems] = useState< - UiSchemaUxElement[] | null - >(null); const { targetUris } = useUriListForPermissions(); @@ -65,9 +64,7 @@ export default function NavigationBar() { [targetUris.processInstanceListForMePath]: ['POST'], [targetUris.processGroupListPath]: ['GET'], }; - const { ability, permissionsLoaded } = usePermissionFetcher( - permissionRequestData - ); + const { ability } = usePermissionFetcher(permissionRequestData); // default to readthedocs and let someone specify an environment variable to override: // @@ -100,48 +97,6 @@ export default function NavigationBar() { setActiveKey(newActiveKey); }, [location]); - // eslint-disable-next-line sonarjs/cognitive-complexity - useEffect(() => { - if (!permissionsLoaded) { - return; - } - - const processExtensionResult = (processModels: ProcessModel[]) => { - const eni: UiSchemaUxElement[] = processModels - .map((processModel: ProcessModel) => { - const extensionUiSchemaFile = processModel.files.find( - (file: ProcessFile) => file.name === 'extension_uischema.json' - ); - if (extensionUiSchemaFile && extensionUiSchemaFile.file_contents) { - try { - const extensionUiSchema: ExtensionUiSchema = JSON.parse( - extensionUiSchemaFile.file_contents - ); - if (extensionUiSchema.ux_elements) { - return extensionUiSchema.ux_elements; - } - } catch (jsonParseError: any) { - console.error( - `Unable to get navigation items for ${processModel.id}` - ); - } - } - return [] as UiSchemaUxElement[]; - }) - .flat(); - if (eni) { - setExtensionNavigationItems(eni); - } - }; - - if (ability.can('GET', targetUris.extensionListPath)) { - HttpService.makeCallToBackend({ - path: targetUris.extensionListPath, - successCallback: processExtensionResult, - }); - } - }, [targetUris.extensionListPath, permissionsLoaded, ability]); - const isActivePage = (menuItemPath: string) => { return activeKey === menuItemPath; }; @@ -155,22 +110,6 @@ export default function NavigationBar() { const userEmail = UserService.getUserEmail(); const username = UserService.getPreferredUsername(); - const extensionNavigationElementsForDisplayLocation = ( - displayLocation: string, - elementCallback: Function - ) => { - if (!extensionNavigationItems) { - return null; - } - - return extensionNavigationItems.map((uxElement: UiSchemaUxElement) => { - if (uxElement.display_location === displayLocation) { - return elementCallback(uxElement); - } - return null; - }); - }; - const extensionUserProfileElement = (uxElement: UiSchemaUxElement) => { const navItemPage = `/extensions${uxElement.page}`; return {uxElement.label}; @@ -196,10 +135,11 @@ export default function NavigationBar() { Documentation - {extensionNavigationElementsForDisplayLocation( - 'user_profile_item', - extensionUserProfileElement - )} + {!UserService.authenticationDisabled() ? ( <>
@@ -345,10 +285,11 @@ export default function NavigationBar() { {configurationElement()} - {extensionNavigationElementsForDisplayLocation( - 'header_menu_item', - extensionHeaderMenuItemElement - )} + ); }; diff --git a/spiffworkflow-frontend/src/extension_ui_schema_interfaces.ts b/spiffworkflow-frontend/src/extension_ui_schema_interfaces.ts index e68cb45c..b9ce6943 100644 --- a/spiffworkflow-frontend/src/extension_ui_schema_interfaces.ts +++ b/spiffworkflow-frontend/src/extension_ui_schema_interfaces.ts @@ -8,30 +8,47 @@ export enum UiSchemaPersistenceLevel { none = 'none', } +export interface UiSchemaLocationSpecificConfig { + highlight_on_tabs?: string[]; +} + export interface UiSchemaUxElement { label: string; page: string; display_location: UiSchemaDisplayLocation; + location_specific_configs?: UiSchemaLocationSpecificConfig; +} + +export interface UiSchemaForm { + form_schema_filename: any; + form_ui_schema_filename: any; + + form_submit_button_label?: string; } export interface UiSchemaAction { api_path: string; - persistence_level?: UiSchemaPersistenceLevel; navigate_to_on_form_submit?: string; + persistence_level?: UiSchemaPersistenceLevel; + process_id_to_run?: string; results_markdown_filename?: string; + search_params_to_inject?: string[]; + + full_api_path?: boolean; } export interface UiSchemaPageDefinition { header: string; api: string; - on_load?: UiSchemaAction; - on_form_submit?: UiSchemaAction; - form_schema_filename?: any; - form_ui_schema_filename?: any; + form?: UiSchemaForm; markdown_instruction_filename?: string; + navigate_instead_of_post_to_api?: boolean; navigate_to_on_form_submit?: string; + on_form_submit?: UiSchemaAction; + on_load?: UiSchemaAction; + open_links_in_new_tab?: boolean; } export interface UiSchemaPage { diff --git a/spiffworkflow-frontend/src/routes/AdminRoutes.tsx b/spiffworkflow-frontend/src/routes/AdminRoutes.tsx index e3915a11..bb2af2c4 100644 --- a/spiffworkflow-frontend/src/routes/AdminRoutes.tsx +++ b/spiffworkflow-frontend/src/routes/AdminRoutes.tsx @@ -23,8 +23,13 @@ import ProcessInterstitialPage from './ProcessInterstitialPage'; import MessageListPage from './MessageListPage'; import DataStorePage from './DataStorePage'; import ErrorDisplay from '../components/ErrorDisplay'; +import { UiSchemaUxElement } from '../extension_ui_schema_interfaces'; -export default function AdminRoutes() { +type OwnProps = { + extensionUxElements?: UiSchemaUxElement[] | null; +}; + +export default function AdminRoutes({ extensionUxElements }: OwnProps) { const location = useLocation(); useEffect(() => {}, [location]); @@ -118,7 +123,12 @@ export default function AdminRoutes() { path="process-instances/all" element={} /> - } /> + + } + /> } diff --git a/spiffworkflow-frontend/src/routes/Configuration.tsx b/spiffworkflow-frontend/src/routes/Configuration.tsx index 6f95085b..0aa2f390 100644 --- a/spiffworkflow-frontend/src/routes/Configuration.tsx +++ b/spiffworkflow-frontend/src/routes/Configuration.tsx @@ -12,8 +12,15 @@ import { useUriListForPermissions } from '../hooks/UriListForPermissions'; import { PermissionsToCheck } from '../interfaces'; import { usePermissionFetcher } from '../hooks/PermissionService'; import { setPageTitle } from '../helpers'; +import { UiSchemaUxElement } from '../extension_ui_schema_interfaces'; +import ExtensionUxElementForDisplay from '../components/ExtensionUxElementForDisplay'; +import Extension from './Extension'; -export default function Configuration() { +type OwnProps = { + extensionUxElements?: UiSchemaUxElement[] | null; +}; + +export default function Configuration({ extensionUxElements }: OwnProps) { const location = useLocation(); const { removeError } = useAPIError(); const [selectedTabIndex, setSelectedTabIndex] = useState(0); @@ -38,6 +45,29 @@ export default function Configuration() { setSelectedTabIndex(newSelectedTabIndex); }, [location, removeError]); + const configurationExtensionTab = ( + uxElement: UiSchemaUxElement, + uxElementIndex: number + ) => { + const navItemPage = `/admin/configuration/extension${uxElement.page}`; + + let pagesToCheck = [uxElement.page]; + if ( + uxElement.location_specific_configs && + uxElement.location_specific_configs.highlight_on_tabs + ) { + pagesToCheck = uxElement.location_specific_configs.highlight_on_tabs; + } + + pagesToCheck.forEach((pageToCheck: string) => { + const pageToCheckNavItem = `/admin/configuration/extension${pageToCheck}`; + if (pageToCheckNavItem === location.pathname) { + setSelectedTabIndex(uxElementIndex + 2); + } + }); + return navigate(navItemPage)}>{uxElement.label}; + }; + // wow, if you do not check to see if the permissions are loaded, then in safari, // you will get {null} inside the which totally explodes carbon (in safari!). // we *think* that null inside a TabList works fine in all other browsers. @@ -61,6 +91,11 @@ export default function Configuration() { Authentications +
@@ -70,6 +105,7 @@ export default function Configuration() { } /> } /> } /> + } />; ); diff --git a/spiffworkflow-frontend/src/routes/Extension.tsx b/spiffworkflow-frontend/src/routes/Extension.tsx index be9dca51..573b6eb5 100644 --- a/spiffworkflow-frontend/src/routes/Extension.tsx +++ b/spiffworkflow-frontend/src/routes/Extension.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from 'react'; +import { Button } from '@carbon/react'; import MDEditor from '@uiw/react-md-editor'; -import { useParams } from 'react-router-dom'; +import { useParams, useSearchParams } from 'react-router-dom'; import { Editor } from '@monaco-editor/react'; import { useUriListForPermissions } from '../hooks/UriListForPermissions'; import { ProcessFile, ProcessModel } from '../interfaces'; @@ -20,6 +21,7 @@ import ErrorDisplay from '../components/ErrorDisplay'; export default function Extension() { const { targetUris } = useUriListForPermissions(); const params = useParams(); + const [searchParams] = useSearchParams(); const [_processModel, setProcessModel] = useState(null); const [formData, setFormData] = useState(null); @@ -40,6 +42,7 @@ export default function Extension() { const { addError, removeError } = useAPIError(); const setConfigsIfDesiredSchemaFile = useCallback( + // eslint-disable-next-line sonarjs/cognitive-complexity (extensionUiSchemaFile: ProcessFile | null, pm: ProcessModel) => { const processLoadResult = (result: any) => { setFormData(result.task_data); @@ -64,10 +67,23 @@ export default function Extension() { const pageDefinition = extensionUiSchema.pages[pageIdentifier]; setUiSchemaPageDefinition(pageDefinition); setProcessModel(pm); + pm.files.forEach((file: ProcessFile) => { + filesByName[file.name] = file; + }); - const postBody: ExtensionPostBody = { extension_input: {} }; - postBody.ui_schema_action = pageDefinition.on_load; if (pageDefinition.on_load) { + const postBody: ExtensionPostBody = { extension_input: {} }; + if (pageDefinition.on_load.search_params_to_inject) { + pageDefinition.on_load.search_params_to_inject.forEach( + (searchParam: string) => { + if (searchParams.get(searchParam) !== undefined) { + postBody.extension_input[searchParam] = + searchParams.get(searchParam); + } + } + ); + } + postBody.ui_schema_action = pageDefinition.on_load; HttpService.makeCallToBackend({ path: `${targetUris.extensionListPath}/${pageDefinition.on_load.api_path}`, successCallback: processLoadResult, @@ -78,7 +94,12 @@ export default function Extension() { } } }, - [targetUris.extensionListPath, params] + [ + targetUris.extensionListPath, + params.page_identifier, + searchParams, + filesByName, + ] ); useEffect(() => { @@ -86,7 +107,6 @@ export default function Extension() { processModels.forEach((pm: ProcessModel) => { let extensionUiSchemaFile: ProcessFile | null = null; pm.files.forEach((file: ProcessFile) => { - filesByName[file.name] = file; if (file.name === 'extension_uischema.json') { extensionUiSchemaFile = file; } @@ -100,21 +120,55 @@ export default function Extension() { successCallback: processExtensionResult, }); }, [ - filesByName, - params, setConfigsIfDesiredSchemaFile, targetUris.extensionListPath, targetUris.extensionPath, ]); - const processSubmitResult = (result: any) => { - setProcessedTaskData(result.task_data); - if (result.rendered_results_markdown) { - setMarkdownToRenderOnSubmit(result.rendered_results_markdown); + const interpolateNavigationString = ( + navigationString: string, + baseData: any + ) => { + let isValid = true; + const data = { backend_base_url: BACKEND_BASE_URL, ...baseData }; + const optionString = navigationString.replace(/{(\w+)}/g, (_, k) => { + const value = data[k]; + if (value === undefined) { + isValid = false; + addError({ + message: `Could not find a value for ${k} in form data.`, + }); + } + return value; + }); + if (!isValid) { + return null; } - setFormButtonsDisabled(false); + return optionString; }; + const processSubmitResult = (result: any) => { + if ( + uiSchemaPageDefinition && + uiSchemaPageDefinition.navigate_to_on_form_submit + ) { + const optionString = interpolateNavigationString( + uiSchemaPageDefinition.navigate_to_on_form_submit, + result.task_data + ); + if (optionString !== null) { + window.location.href = optionString; + } + } else { + setProcessedTaskData(result.task_data); + if (result.rendered_results_markdown) { + setMarkdownToRenderOnSubmit(result.rendered_results_markdown); + } + setFormButtonsDisabled(false); + } + }; + + // eslint-disable-next-line sonarjs/cognitive-complexity const handleFormSubmit = (formObject: any, _event: any) => { if (formButtonsDisabled) { return; @@ -129,34 +183,29 @@ export default function Extension() { if ( uiSchemaPageDefinition && - uiSchemaPageDefinition.navigate_to_on_form_submit + uiSchemaPageDefinition.navigate_instead_of_post_to_api ) { - let isValid = true; - const optionString = - uiSchemaPageDefinition.navigate_to_on_form_submit.replace( - /{(\w+)}/g, - (_, k) => { - const value = dataToSubmit[k]; - if (value === undefined) { - isValid = false; - addError({ - message: `Could not find a value for ${k} in form data.`, - }); - } - return value; - } + let optionString: string | null = ''; + if (uiSchemaPageDefinition.navigate_to_on_form_submit) { + optionString = interpolateNavigationString( + uiSchemaPageDefinition.navigate_to_on_form_submit, + dataToSubmit ); - if (!isValid) { - return; + if (optionString !== null) { + window.location.href = optionString; + setFormButtonsDisabled(false); + } } - const url = `${BACKEND_BASE_URL}/extensions-get-data/${params.page_identifier}/${optionString}`; - window.location.href = url; - setFormButtonsDisabled(false); } else { - const postBody: ExtensionPostBody = { extension_input: dataToSubmit }; + let postBody: ExtensionPostBody = { extension_input: dataToSubmit }; let apiPath = targetUris.extensionPath; if (uiSchemaPageDefinition && uiSchemaPageDefinition.on_form_submit) { - apiPath = `${targetUris.extensionListPath}/${uiSchemaPageDefinition.on_form_submit.api_path}`; + if (uiSchemaPageDefinition.on_form_submit.full_api_path) { + apiPath = `/${uiSchemaPageDefinition.on_form_submit.api_path}`; + postBody = dataToSubmit; + } else { + apiPath = `${targetUris.extensionListPath}/${uiSchemaPageDefinition.on_form_submit.api_path}`; + } postBody.ui_schema_action = uiSchemaPageDefinition.on_form_submit; } @@ -193,22 +242,29 @@ export default function Extension() { markdownContentsToRender.push(markdownToRenderOnLoad); } + let mdEditorLinkTarget: string | undefined = '_blank'; + if (uiSchemaPageDefinition.open_links_in_new_tab === false) { + mdEditorLinkTarget = undefined; + } + if (markdownContentsToRender.length > 0) { componentsToDisplay.push(
); } - if (uiSchemaPageDefinition.form_schema_filename) { - const formSchemaFile = - filesByName[uiSchemaPageDefinition.form_schema_filename]; + const uiSchemaForm = uiSchemaPageDefinition.form; + if (uiSchemaForm) { + const formSchemaFile = filesByName[uiSchemaForm.form_schema_filename]; const formUiSchemaFile = - filesByName[uiSchemaPageDefinition.form_ui_schema_filename]; + filesByName[uiSchemaForm.form_ui_schema_filename]; + const submitButtonText = + uiSchemaForm.form_submit_button_label || 'Submit'; if (formSchemaFile.file_contents && formUiSchemaFile.file_contents) { componentsToDisplay.push( + > + + ); } }