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 <jasquat@users.noreply.github.com>
This commit is contained in:
jasquat 2023-09-15 10:10:57 -04:00 committed by GitHub
parent 9db12d0c1e
commit 8f3f1d620f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 873 additions and 378 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -515,5 +515,6 @@ class TestAuthorizationService(BaseTest):
("/secrets/*", "delete"),
("/secrets/*", "read"),
("/secrets/*", "update"),
("/service-accounts", "create"),
]
)

View File

@ -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 (
<div className="cds--white">
{/* @ts-ignore */}
<AbilityContext.Provider value={ability}>
<APIErrorProvider>
<BrowserRouter>
<NavigationBar />
<Content className={contentClassName}>
<ScrollToTop />
<ErrorBoundary>
<Routes>
<Route path="/*" element={<HomePageRoutes />} />
<Route path="/about" element={<About />} />
<Route path="/tasks/*" element={<HomePageRoutes />} />
<Route path="/admin/*" element={<AdminRoutes />} />
<Route path="/editor/*" element={<EditorRoutes />} />
<Route
path="/extensions/:page_identifier"
element={<Extension />}
/>
</Routes>
</ErrorBoundary>
</Content>
<ContainerForExtensions />
</BrowserRouter>
</APIErrorProvider>
</AbilityContext.Provider>

View File

@ -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 (
<>
<NavigationBar extensionUxElements={extensionUxElements} />
<Content className={contentClassName}>
<ScrollToTop />
<ErrorBoundary>
<Routes>
<Route path="/*" element={<HomePageRoutes />} />
<Route path="/about" element={<About />} />
<Route path="/tasks/*" element={<HomePageRoutes />} />
<Route
path="/admin/*"
element={
<AdminRoutes extensionUxElements={extensionUxElements} />
}
/>
<Route path="/editor/*" element={<EditorRoutes />} />
<Route
path="/extensions/:page_identifier"
element={<Extension />}
/>
</Routes>
</ErrorBoundary>
</Content>
</>
);
}

View File

@ -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()}</>;
}

View File

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

View File

@ -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<string>('');
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 <a href={navItemPage}>{uxElement.label}</a>;
@ -196,10 +135,11 @@ export default function NavigationBar() {
<a target="_blank" href={documentationUrl} rel="noreferrer">
Documentation
</a>
{extensionNavigationElementsForDisplayLocation(
'user_profile_item',
extensionUserProfileElement
)}
<ExtensionUxElementForDisplay
displayLocation="user_profile_item"
elementCallback={extensionUserProfileElement}
extensionUxElements={extensionUxElements}
/>
{!UserService.authenticationDisabled() ? (
<>
<hr />
@ -345,10 +285,11 @@ export default function NavigationBar() {
</HeaderMenuItem>
</Can>
{configurationElement()}
{extensionNavigationElementsForDisplayLocation(
'header_menu_item',
extensionHeaderMenuItemElement
)}
<ExtensionUxElementForDisplay
displayLocation="header_menu_item"
elementCallback={extensionHeaderMenuItemElement}
extensionUxElements={extensionUxElements}
/>
</>
);
};

View File

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

View File

@ -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={<ProcessInstanceList variant="all" />}
/>
<Route path="configuration/*" element={<Configuration />} />
<Route
path="configuration/*"
element={
<Configuration extensionUxElements={extensionUxElements} />
}
/>
<Route
path="process-models/:process_model_id/form-builder"
element={<JsonSchemaFormBuilder />}

View File

@ -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<number>(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 <Tab onClick={() => navigate(navItemPage)}>{uxElement.label}</Tab>;
};
// wow, if you do not check to see if the permissions are loaded, then in safari,
// you will get {null} inside the <TabList> 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
</Tab>
</Can>
<ExtensionUxElementForDisplay
displayLocation="configuration_tab_item"
elementCallback={configurationExtensionTab}
extensionUxElements={extensionUxElements}
/>
</TabList>
</Tabs>
<br />
@ -70,6 +105,7 @@ export default function Configuration() {
<Route path="secrets/new" element={<SecretNew />} />
<Route path="secrets/:key" element={<SecretShow />} />
<Route path="authentications" element={<AuthenticationList />} />
<Route path="extension/:page_identifier" element={<Extension />} />;
</Routes>
</>
);

View File

@ -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<ProcessModel | null>(null);
const [formData, setFormData] = useState<any>(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(
<div data-color-mode="light" className="with-bottom-margin">
<MDEditor.Markdown
linkTarget="_blank"
linkTarget={mdEditorLinkTarget}
source={markdownContentsToRender.join('\n')}
/>
</div>
);
}
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(
<CustomForm
@ -221,7 +277,15 @@ export default function Extension() {
onSubmit={handleFormSubmit}
schema={JSON.parse(formSchemaFile.file_contents)}
uiSchema={JSON.parse(formUiSchemaFile.file_contents)}
/>
>
<Button
type="submit"
id="submit-button"
disabled={formButtonsDisabled}
>
{submitButtonText}
</Button>
</CustomForm>
);
}
}