diff --git a/spiffworkflow-backend/bin/delete_and_import_all_permissions.py b/spiffworkflow-backend/bin/delete_and_import_all_permissions.py index a55e36e7f..966ec5a11 100644 --- a/spiffworkflow-backend/bin/delete_and_import_all_permissions.py +++ b/spiffworkflow-backend/bin/delete_and_import_all_permissions.py @@ -7,7 +7,8 @@ def main() -> None: """Main.""" app = get_hacked_up_app_for_script() with app.app_context(): - AuthorizationService.delete_all_permissions_and_recreate() + AuthorizationService.delete_all_permissions() + AuthorizationService.import_permissions_from_yaml_file() if __name__ == "__main__": diff --git a/spiffworkflow-backend/bin/spiffworkflow-realm.json b/spiffworkflow-backend/bin/spiffworkflow-realm.json index a30f53c14..3181284e0 100644 --- a/spiffworkflow-backend/bin/spiffworkflow-realm.json +++ b/spiffworkflow-backend/bin/spiffworkflow-realm.json @@ -424,6 +424,7 @@ "enabled" : true, "totp" : false, "emailVerified" : false, + "email": "admin@status.im", "firstName" : "", "lastName" : "", "credentials" : [ { @@ -446,6 +447,7 @@ "enabled" : true, "totp" : false, "emailVerified" : false, + "email": "alex@sartography.com", "credentials" : [ { "id" : "81a61a3b-228d-42b3-b39a-f62d8e7f57ca", "type" : "password", @@ -465,6 +467,7 @@ "enabled" : true, "totp" : false, "emailVerified" : false, + "email": "amir@status.im", "credentials" : [ { "id" : "e589f3ad-bf7b-4756-89f7-7894c03c2831", "type" : "password", @@ -484,6 +487,7 @@ "enabled" : true, "totp" : false, "emailVerified" : false, + "email": "ciadmin1@status.im", "credentials" : [ { "id" : "111b5ea1-c2ab-470a-a16b-2373bc94de7a", "type" : "password", @@ -506,6 +510,7 @@ "enabled" : true, "totp" : false, "emailVerified" : false, + "email": "ciuser1@status.im", "credentials" : [ { "id" : "762f36e9-47af-44da-8520-cf09d752497a", "type" : "password", @@ -528,6 +533,7 @@ "enabled" : true, "totp" : false, "emailVerified" : false, + "email": "core@status.im", "firstName" : "", "lastName" : "", "credentials" : [ { @@ -550,6 +556,7 @@ "enabled" : true, "totp" : false, "emailVerified" : false, + "email": "dan@sartography.com", "credentials" : [ { "id" : "d517c520-f500-4542-80e5-7144daef1e32", "type" : "password", @@ -569,6 +576,7 @@ "enabled" : true, "totp" : false, "emailVerified" : false, + "email": "daniel@sartography.com", "credentials" : [ { "id" : "f240495c-265b-42fc-99db-46928580d07d", "type" : "password", @@ -588,6 +596,7 @@ "enabled" : true, "totp" : false, "emailVerified" : false, + "email": "elizabeth@sartography.com", "credentials" : [ { "id" : "ae951ec8-9fc9-4f1b-b340-bbbe463ae5c2", "type" : "password", @@ -607,6 +616,7 @@ "enabled" : true, "totp" : false, "emailVerified" : false, + "email": "fin@status.im", "firstName" : "", "lastName" : "", "credentials" : [ { @@ -629,6 +639,7 @@ "enabled" : true, "totp" : false, "emailVerified" : false, + "email": "fin1@status.im", "firstName" : "", "lastName" : "", "credentials" : [ { @@ -651,6 +662,7 @@ "enabled" : true, "totp" : false, "emailVerified" : false, + "email": "finance_user1@status.im", "credentials" : [ { "id" : "f14722ec-13a7-4d35-a4ec-0475d405ae58", "type" : "password", @@ -670,6 +682,7 @@ "enabled" : true, "totp" : false, "emailVerified" : false, + "email": "harmeet@status.im", "credentials" : [ { "id" : "89c26090-9bd3-46ac-b038-883d02e3f125", "type" : "password", @@ -689,6 +702,7 @@ "enabled" : true, "totp" : false, "emailVerified" : false, + "email": "j@status.im", "firstName" : "", "lastName" : "", "credentials" : [ { @@ -711,6 +725,7 @@ "enabled" : true, "totp" : false, "emailVerified" : false, + "email": "jakub@status.im", "credentials" : [ { "id" : "ce141fa5-b8d5-4bbe-93e7-22e7119f97c2", "type" : "password", @@ -730,6 +745,7 @@ "enabled" : true, "totp" : false, "emailVerified" : false, + "email": "jarrad@status.im", "credentials" : [ { "id" : "113e0343-1069-476d-83f9-21d98edb9cfa", "type" : "password", @@ -749,6 +765,7 @@ "enabled" : true, "totp" : false, "emailVerified" : false, + "email": "jason@sartography.com", "credentials" : [ { "id" : "40abf32e-f0cc-4a17-8231-1a69a02c1b0b", "type" : "password", @@ -768,6 +785,7 @@ "enabled" : true, "totp" : false, "emailVerified" : false, + "email": "jon@sartography.com", "credentials" : [ { "id" : "8b520e01-5b9b-44ab-9ee8-505bd0831a45", "type" : "password", @@ -787,6 +805,7 @@ "enabled" : true, "totp" : false, "emailVerified" : false, + "email": "kb@sartography.com", "credentials" : [ { "id" : "2c0be363-038f-48f1-86d6-91fdd28657cf", "type" : "password", @@ -806,6 +825,7 @@ "enabled" : true, "totp" : false, "emailVerified" : false, + "email": "lead@status.im", "firstName" : "", "lastName" : "", "credentials" : [ { @@ -828,6 +848,7 @@ "enabled" : true, "totp" : false, "emailVerified" : false, + "email": "lead1@status.im", "firstName" : "", "lastName" : "", "credentials" : [ { @@ -850,6 +871,7 @@ "enabled" : true, "totp" : false, "emailVerified" : false, + "email": "manuchehr@status.im", "credentials" : [ { "id" : "07dabf55-b5d3-4f98-abba-3334086ecf5e", "type" : "password", @@ -869,6 +891,7 @@ "enabled" : true, "totp" : false, "emailVerified" : false, + "email": "mike@sartography.com", "credentials" : [ { "id" : "1ed375fb-0f1a-4c2a-9243-2477242cf7bd", "type" : "password", @@ -888,6 +911,7 @@ "enabled" : true, "totp" : false, "emailVerified" : false, + "email": "natalia@sartography.com", "credentials" : [ { "id" : "b6aa9936-39cc-4931-bfeb-60e6753de5ba", "type" : "password", @@ -907,6 +931,7 @@ "enabled" : true, "totp" : false, "emailVerified" : false, + "email": "sasha@status.im", "credentials" : [ { "id" : "4a170af4-6f0c-4e7b-b70c-e674edf619df", "type" : "password", @@ -926,6 +951,7 @@ "enabled" : true, "totp" : false, "emailVerified" : false, + "email": "service-account@status.im", "serviceAccountClientId" : "spiffworkflow-backend", "credentials" : [ ], "disableableCredentialTypes" : [ ], @@ -943,6 +969,7 @@ "enabled" : true, "totp" : false, "emailVerified" : false, + "email": "service-account-withauth@status.im", "serviceAccountClientId" : "withAuth", "credentials" : [ ], "disableableCredentialTypes" : [ ], diff --git a/spiffworkflow-backend/migrations/versions/b99a4cb94b5b_.py b/spiffworkflow-backend/migrations/versions/67197b02b0c1_.py similarity index 96% rename from spiffworkflow-backend/migrations/versions/b99a4cb94b5b_.py rename to spiffworkflow-backend/migrations/versions/67197b02b0c1_.py index ec3592540..2eb3d107c 100644 --- a/spiffworkflow-backend/migrations/versions/b99a4cb94b5b_.py +++ b/spiffworkflow-backend/migrations/versions/67197b02b0c1_.py @@ -1,8 +1,8 @@ """empty message -Revision ID: b99a4cb94b5b +Revision ID: 67197b02b0c1 Revises: -Create Date: 2022-12-20 10:45:08.295317 +Create Date: 2022-12-20 15:05:31.545567 """ from alembic import op @@ -10,7 +10,7 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = 'b99a4cb94b5b' +revision = '67197b02b0c1' down_revision = None branch_labels = None depends_on = None @@ -72,16 +72,15 @@ def upgrade(): op.create_table('user', sa.Column('id', sa.Integer(), nullable=False), sa.Column('username', sa.String(length=255), nullable=False), - sa.Column('uid', sa.String(length=50), nullable=True), sa.Column('service', sa.String(length=50), nullable=False), sa.Column('service_id', sa.String(length=255), nullable=False), - sa.Column('name', sa.String(length=255), nullable=True), + sa.Column('display_name', sa.String(length=255), nullable=True), sa.Column('email', sa.String(length=255), nullable=True), sa.Column('updated_at_in_seconds', sa.Integer(), nullable=True), sa.Column('created_at_in_seconds', sa.Integer(), nullable=True), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('service', 'service_id', name='service_key'), - sa.UniqueConstraint('uid') + sa.UniqueConstraint('username') ) op.create_table('message_correlation_property', sa.Column('id', sa.Integer(), nullable=False), @@ -176,6 +175,14 @@ def upgrade(): sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('user_id', 'group_id', name='user_group_assignment_unique') ) + op.create_table('user_group_assignment_waiting', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=255), nullable=False), + sa.Column('group_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['group_id'], ['group.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username', 'group_id', name='user_group_assignment_staged_unique') + ) op.create_table('human_task', sa.Column('id', sa.Integer(), nullable=False), sa.Column('process_instance_id', sa.Integer(), nullable=False), @@ -309,6 +316,7 @@ def downgrade(): op.drop_table('message_correlation') op.drop_index(op.f('ix_human_task_completed'), table_name='human_task') op.drop_table('human_task') + op.drop_table('user_group_assignment_waiting') op.drop_table('user_group_assignment') op.drop_table('secret') op.drop_table('refresh_token') diff --git a/spiffworkflow-backend/src/.coverage.jason-Gazelle.473795.719220 b/spiffworkflow-backend/src/.coverage.jason-Gazelle.473795.719220 new file mode 100644 index 000000000..3c5fc7087 Binary files /dev/null and b/spiffworkflow-backend/src/.coverage.jason-Gazelle.473795.719220 differ diff --git a/spiffworkflow-backend/src/.coverage.jason-Gazelle.475245.497833 b/spiffworkflow-backend/src/.coverage.jason-Gazelle.475245.497833 new file mode 100644 index 000000000..214df28dc Binary files /dev/null and b/spiffworkflow-backend/src/.coverage.jason-Gazelle.475245.497833 differ diff --git a/spiffworkflow-backend/src/.coverage.jason-Gazelle.476451.578823 b/spiffworkflow-backend/src/.coverage.jason-Gazelle.476451.578823 new file mode 100644 index 000000000..ef7f5c499 Binary files /dev/null and b/spiffworkflow-backend/src/.coverage.jason-Gazelle.476451.578823 differ diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/__init__.py b/spiffworkflow-backend/src/spiffworkflow_backend/__init__.py index 9599116a2..1e5493496 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/__init__.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/__init__.py @@ -18,6 +18,7 @@ from werkzeug.exceptions import NotFound import spiffworkflow_backend.load_database_models # noqa: F401 from spiffworkflow_backend.config import setup_config +from spiffworkflow_backend.helpers.api_version import V1_API_PATH_PREFIX from spiffworkflow_backend.routes.admin_blueprint.admin_blueprint import admin_blueprint from spiffworkflow_backend.routes.openid_blueprint.openid_blueprint import ( openid_blueprint, @@ -117,7 +118,7 @@ def create_app() -> flask.app.Flask: ] CORS(app, origins=origins_re, max_age=3600) - connexion_app.add_api("api.yml", base_path="/v1.0") + connexion_app.add_api("api.yml", base_path=V1_API_PATH_PREFIX) mail = Mail(app) app.config["MAIL_APP"] = mail diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/development.yml b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/development.yml index e334cc98b..1bbefdaf4 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/development.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/development.yml @@ -10,55 +10,52 @@ groups: admin: users: [ - admin, - jakub, - kb, - alex, - dan, - mike, - jason, - jarrad, - elizabeth, - jon, + admin@status.im, + jakub@status.im, + jarrad@status.im, + kb@sartography.com, + alex@sartography.com, + dan@sartography.com, + mike@sartography.com, + jason@sartography.com, + j@sartography.com, + elizabeth@sartography.com, + jon@sartography.com, ] Finance Team: users: [ - jakub, - alex, - dan, - mike, - jason, - amir, - jarrad, - elizabeth, - jon, - sasha, - fin, - fin1, + jakub@status.im, + amir@status.im, + jarrad@status.im, + sasha@status.im, + fin@sartography.com, + fin1@sartography.com, + alex@sartography.com, + dan@sartography.com, + mike@sartography.com, + jason@sartography.com, + j@sartography.com, + elizabeth@sartography.com, + jon@sartography.com, ] demo: users: [ - core, - fin, - fin1, - harmeet, - jason, - sasha, - manuchehr, - lead, - lead1 + harmeet@status.im, + sasha@status.im, + manuchehr@status.im, + core@status.im, + fin@status.im, + fin1@status.im, + lead@status.im, + lead1@status.im ] - core-contributor: - users: - [ - core, - harmeet, - ] + test: + users: [natalia@sartography.com] admin-ro: users: @@ -66,16 +63,12 @@ groups: j, ] - test: - users: [natalia] - permissions: admin: groups: [admin] users: [] allowed_permissions: [create, read, update, delete] uri: /* - admin-readonly: groups: [admin-ro] users: [] @@ -85,121 +78,93 @@ permissions: groups: [admin-ro] users: [] allowed_permissions: [create, read, update, delete] - uri: /v1.0/process-instances/* + uri: /process-instances/* - tasks-crud: - groups: [everybody] - users: [] - allowed_permissions: [create, read, update, delete] - uri: /v1.0/tasks/* - service-tasks: - groups: [everybody] - users: [] - allowed_permissions: [read] - uri: /v1.0/service-tasks - user-groups-for-current-user: - groups: [everybody] - users: [] - allowed_permissions: [read] - uri: /v1.0/user-groups/for-current-user - - - # read all for everybody + # open system defaults for everybody read-all-process-groups: groups: [everybody] users: [] allowed_permissions: [read] - uri: /v1.0/process-groups/* + uri: /process-groups/* read-all-process-models: groups: [everybody] users: [] allowed_permissions: [read] - uri: /v1.0/process-models/* + uri: /process-models/* + + # basic perms for everybody read-all-process-instances-for-me: groups: [everybody] users: [] allowed_permissions: [read] - uri: /v1.0/process-instances/for-me/* + uri: /process-instances/for-me/* read-process-instance-reports: groups: [everybody] users: [] allowed_permissions: [create, read, update, delete] - uri: /v1.0/process-instances/reports/* + uri: /process-instances/reports/* processes-read: groups: [everybody] users: [] allowed_permissions: [read] - uri: /v1.0/processes + uri: /processes + service-tasks: + groups: [everybody] + users: [] + allowed_permissions: [read] + uri: /service-tasks + tasks-crud: + groups: [everybody] + users: [] + allowed_permissions: [create, read, update, delete] + uri: /tasks/* + user-groups-for-current-user: + groups: [everybody] + users: [] + allowed_permissions: [read] + uri: /user-groups/for-current-user - manage-procurement-admin: - groups: ["Project Lead"] - users: [] - allowed_permissions: [create, read, update, delete] - uri: /v1.0/process-groups/manage-procurement:* - manage-procurement-admin-slash: - groups: ["Project Lead"] - users: [] - allowed_permissions: [create, read, update, delete] - uri: /v1.0/process-groups/manage-procurement/* - manage-procurement-admin-models: - groups: ["Project Lead"] - users: [] - allowed_permissions: [create, read, update, delete] - uri: /v1.0/process-models/manage-procurement:* - manage-procurement-admin-models-slash: - groups: ["Project Lead"] - users: [] - allowed_permissions: [create, read, update, delete] - uri: /v1.0/process-models/manage-procurement/* - manage-procurement-admin-instances: - groups: ["Project Lead"] - users: [] - allowed_permissions: [create, read, update, delete] - uri: /v1.0/process-instances/manage-procurement:* - manage-procurement-admin-instances-slash: - groups: ["Project Lead"] - users: [] - allowed_permissions: [create, read, update, delete] - uri: /v1.0/process-instances/manage-procurement/* - finance-admin: groups: ["Finance Team"] users: [] allowed_permissions: [create, read, update, delete] - uri: /v1.0/process-groups/manage-procurement:procurement:* + uri: /process-groups/manage-procurement:procurement:* manage-revenue-streams-instances: - groups: ["core-contributor", "demo"] + groups: ["demo"] users: [] - allowed_permissions: [create, read] - uri: /v1.0/process-instances/manage-revenue-streams:product-revenue-streams:customer-contracts-trade-terms/* - + allowed_permissions: [create] + uri: /process-instances/manage-revenue-streams:product-revenue-streams:customer-contracts-trade-terms/* manage-procurement-invoice-instances: - groups: ["core-contributor", "demo"] + groups: ["demo"] users: [] - allowed_permissions: [create, read] - uri: /v1.0/process-instances/manage-procurement:procurement:core-contributor-invoice-management:* - + allowed_permissions: [create] + uri: /process-instances/manage-procurement:procurement:core-contributor-invoice-management:* manage-procurement-instances: - groups: ["core-contributor", "demo"] + groups: ["demo"] users: [] - allowed_permissions: [create, read] - uri: /v1.0/process-instances/manage-procurement:vendor-lifecycle-management:* + allowed_permissions: [create] + uri: /process-instances/manage-procurement:vendor-lifecycle-management:* + + manage-revenue-streams-instances-for-me: + groups: ["demo"] + users: [] + allowed_permissions: [read] + uri: /process-instances/for-me/manage-revenue-streams:product-revenue-streams:customer-contracts-trade-terms/* + manage-procurement-invoice-instances-for-me: + groups: ["demo"] + users: [] + allowed_permissions: [read] + uri: /process-instances/for-me/manage-procurement:procurement:core-contributor-invoice-management:* + manage-procurement-instances-for-me: + groups: ["demo"] + users: [] + allowed_permissions: [read] + uri: /process-instances/for-me/manage-procurement:vendor-lifecycle-management:* create-test-instances: groups: ["test"] users: [] allowed_permissions: [create, read] - uri: /v1.0/process-instances/misc:test:* - - core1-admin-instances: - groups: ["core-contributor", "Finance Team"] - users: [] - allowed_permissions: [create, read] - uri: /v1.0/process-instances/misc:category_number_one:process-model-with-form:* - core1-admin-instances-slash: - groups: ["core-contributor", "Finance Team"] - users: [] - allowed_permissions: [create, read] - uri: /v1.0/process-instances/misc:category_number_one:process-model-with-form/* + uri: /process-instances/misc:test:* diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/example.yml b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/example.yml index 79bfed81d..248a400b4 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/example.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/example.yml @@ -2,14 +2,17 @@ default_group: everybody users: admin: + service: local_open_id email: admin@spiffworkflow.org password: admin preferred_username: Admin nelson: + service: local_open_id email: nelson@spiffworkflow.org password: nelson preferred_username: Nelson malala: + service: local_open_id email: malala@spiffworkflow.org password: malala preferred_username: Malala @@ -18,17 +21,17 @@ groups: admin: users: [ - admin, + admin@spiffworkflow.org, ] Education: users: [ - malala + malala@spiffworkflow.org ] President: users: [ - nelson + nelson@spiffworkflow.org ] permissions: @@ -44,45 +47,44 @@ permissions: groups: [everybody] users: [] allowed_permissions: [create, read, update, delete] - uri: /v1.0/tasks/* + uri: /tasks/* # Everyone can see everything (all groups, and processes are visible) read-all-process-groups: groups: [ everybody ] users: [ ] allowed_permissions: [ read ] - uri: /v1.0/process-groups/* + uri: /process-groups/* read-all-process-models: groups: [ everybody ] users: [ ] allowed_permissions: [ read ] - uri: /v1.0/process-models/* + uri: /process-models/* read-all-process-instance: groups: [ everybody ] users: [ ] allowed_permissions: [ read ] - uri: /v1.0/process-instances/* + uri: /process-instances/* read-process-instance-reports: groups: [ everybody ] users: [ ] allowed_permissions: [ read ] - uri: /v1.0/process-instances/reports/* + uri: /process-instances/reports/* processes-read: groups: [ everybody ] users: [ ] allowed_permissions: [ read ] - uri: /v1.0/processes - - # Members of the Education group can change they processes work. + uri: /processes + # Members of the Education group can change the processes under "education". education-admin: groups: ["Education", "President"] users: [] allowed_permissions: [create, read, update, delete] - uri: /v1.0/process-groups/education:* + uri: /process-groups/education:* # Anyone can start an education process. education-everybody: groups: [everybody] users: [] allowed_permissions: [create, read] - uri: /v1.0/process-instances/misc:category_number_one:process-model-with-form/* + uri: /process-instances/misc:category_number_one:process-model-with-form/* diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/staging.yml b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/staging.yml index 20635ea2e..e1be926fb 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/staging.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/staging.yml @@ -4,57 +4,48 @@ groups: admin: users: [ - admin, - jakub, - kb, - alex, - dan, - mike, - jason, - j, - jarrad, - elizabeth, - jon, - natalia, + admin@status.im, + jakub@status.im, + jarrad@status.im, + kb@sartography.com, + alex@sartography.com, + dan@sartography.com, + mike@sartography.com, + jason@sartography.com, + j@sartography.com, + elizabeth@sartography.com, + jon@sartography.com, ] Finance Team: users: [ - jakub, - alex, - dan, - mike, - jason, - j, - amir, - jarrad, - elizabeth, - jon, - natalia, - sasha, - fin, - fin1, + jakub@status.im, + amir@status.im, + jarrad@status.im, + sasha@status.im, + fin@sartography.com, + fin1@sartography.com, + alex@sartography.com, + dan@sartography.com, + mike@sartography.com, + jason@sartography.com, + j@sartography.com, + elizabeth@sartography.com, + jon@sartography.com, ] demo: users: [ - core, - fin, - fin1, - harmeet, - sasha, - manuchehr, - lead, - lead1 - ] - - core-contributor: - users: - [ - core, - harmeet, + harmeet@status.im, + sasha@status.im, + manuchehr@status.im, + core@status.im, + fin@status.im, + fin1@status.im, + lead@status.im, + lead1@status.im ] permissions: @@ -67,104 +58,86 @@ permissions: groups: [admin] users: [] allowed_permissions: [create, read, update, delete] - uri: /v1.0/process-instances/* + uri: /process-instances/* - tasks-crud: - groups: [everybody] - users: [] - allowed_permissions: [create, read, update, delete] - uri: /v1.0/tasks/* - - service-tasks: - groups: [everybody] - users: [] - allowed_permissions: [read] - uri: /v1.0/service-tasks - user-groups-for-current-user: - groups: [everybody] - users: [] - allowed_permissions: [read] - uri: /v1.0/user-groups/for-current-user - - - # read all for everybody + # open system defaults for everybody read-all-process-groups: groups: [everybody] users: [] allowed_permissions: [read] - uri: /v1.0/process-groups/* + uri: /process-groups/* read-all-process-models: groups: [everybody] users: [] allowed_permissions: [read] - uri: /v1.0/process-models/* + uri: /process-models/* + + # basic perms for everybody read-all-process-instances-for-me: groups: [everybody] users: [] allowed_permissions: [read] - uri: /v1.0/process-instances/for-me/* - manage-process-instance-reports: + uri: /process-instances/for-me/* + read-process-instance-reports: groups: [everybody] users: [] allowed_permissions: [create, read, update, delete] - uri: /v1.0/process-instances/reports/* + uri: /process-instances/reports/* processes-read: groups: [everybody] users: [] allowed_permissions: [read] - uri: /v1.0/processes - - - manage-procurement-admin-instances: - groups: ["Project Lead"] - users: [] - allowed_permissions: [create, read, update, delete] - uri: /v1.0/process-instances/manage-procurement:* - manage-procurement-admin-instances-slash: - groups: ["Project Lead"] - users: [] - allowed_permissions: [create, read, update, delete] - uri: /v1.0/process-instances/manage-procurement/* - manage-procurement-admin-instance-logs: - groups: ["Project Lead"] + uri: /processes + service-tasks: + groups: [everybody] users: [] allowed_permissions: [read] - uri: /v1.0/logs/manage-procurement:* - manage-procurement-admin-instance-logs-slash: - groups: ["Project Lead"] + uri: /service-tasks + tasks-crud: + groups: [everybody] + users: [] + allowed_permissions: [create, read, update, delete] + uri: /tasks/* + user-groups-for-current-user: + groups: [everybody] users: [] allowed_permissions: [read] - uri: /v1.0/logs/manage-procurement/* + uri: /user-groups/for-current-user manage-revenue-streams-instances: - groups: ["core-contributor", "demo"] + groups: ["demo"] users: [] - allowed_permissions: [create, read] - uri: /v1.0/process-instances/manage-revenue-streams:product-revenue-streams:customer-contracts-trade-terms/* - manage-revenue-streams-instance-logs: - groups: ["core-contributor", "demo"] - users: [] - allowed_permissions: [read] - uri: /v1.0/logs/manage-revenue-streams:product-revenue-streams:customer-contracts-trade-terms/* - + allowed_permissions: [create] + uri: /process-instances/manage-revenue-streams:product-revenue-streams:customer-contracts-trade-terms/* manage-procurement-invoice-instances: - groups: ["core-contributor", "demo"] + groups: ["demo"] users: [] - allowed_permissions: [create, read] - uri: /v1.0/process-instances/manage-procurement:procurement:core-contributor-invoice-management:* - manage-procurement-invoice-instance-logs: - groups: ["core-contributor", "demo"] - users: [] - allowed_permissions: [read] - uri: /v1.0/logs/manage-procurement:procurement:core-contributor-invoice-management:* - + allowed_permissions: [create] + uri: /process-instances/manage-procurement:procurement:core-contributor-invoice-management:* manage-procurement-instances: - groups: ["core-contributor", "demo"] + groups: ["demo"] users: [] - allowed_permissions: [create, read] - uri: /v1.0/process-instances/manage-procurement:vendor-lifecycle-management:* - manage-procurement-instance-logs: - groups: ["core-contributor", "demo"] + allowed_permissions: [create] + uri: /process-instances/manage-procurement:vendor-lifecycle-management:* + + manage-revenue-streams-instances-for-me: + groups: ["demo"] users: [] allowed_permissions: [read] - uri: /v1.0/logs/manage-procurement:vendor-lifecycle-management:* + uri: /process-instances/for-me/manage-revenue-streams:product-revenue-streams:customer-contracts-trade-terms/* + manage-procurement-invoice-instances-for-me: + groups: ["demo"] + users: [] + allowed_permissions: [read] + uri: /process-instances/for-me/manage-procurement:procurement:core-contributor-invoice-management:* + manage-procurement-instances-for-me: + groups: ["demo"] + users: [] + allowed_permissions: [read] + uri: /process-instances/for-me/manage-procurement:vendor-lifecycle-management:* + + create-test-instances: + groups: ["test"] + users: [] + allowed_permissions: [create, read] + uri: /process-instances/misc:test:* diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/terraform_deployed_environment.yml b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/terraform_deployed_environment.yml index fc118b900..083b529d1 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/terraform_deployed_environment.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/terraform_deployed_environment.yml @@ -4,58 +4,52 @@ groups: admin: users: [ - admin, - jakub, - kb, - alex, - dan, - mike, - jason, - j, - jarrad, - elizabeth, - jon, + admin@status.im, + jakub@status.im, + jarrad@status.im, + kb@sartography.com, + alex@sartography.com, + dan@sartography.com, + mike@sartography.com, + jason@sartography.com, + j@sartography.com, + elizabeth@sartography.com, + jon@sartography.com, ] Finance Team: users: [ - jakub, - alex, - dan, - mike, - jason, - j, - amir, - jarrad, - elizabeth, - jon, - sasha, - fin, - fin1, + jakub@status.im, + amir@status.im, + jarrad@status.im, + sasha@status.im, + fin@sartography.com, + fin1@sartography.com, + alex@sartography.com, + dan@sartography.com, + mike@sartography.com, + jason@sartography.com, + j@sartography.com, + elizabeth@sartography.com, + jon@sartography.com, ] demo: users: [ - core, - fin, - fin1, - harmeet, - sasha, - manuchehr, - lead, - lead1 + harmeet@status.im, + sasha@status.im, + manuchehr@status.im, + core@status.im, + fin@status.im, + fin1@status.im, + lead@status.im, + lead1@status.im ] - core-contributor: - users: - [ - core, - harmeet, - ] test: - users: [natalia] + users: [natalia@sartography.com] permissions: admin: @@ -64,109 +58,91 @@ permissions: allowed_permissions: [create, read, update, delete] uri: /* - tasks-crud: - groups: [everybody] - users: [] - allowed_permissions: [create, read, update, delete] - uri: /v1.0/tasks/* - - service-tasks: - groups: [everybody] - users: [] - allowed_permissions: [read] - uri: /v1.0/service-tasks - user-groups-for-current-user: - groups: [everybody] - users: [] - allowed_permissions: [read] - uri: /v1.0/user-groups/for-current-user - - - # read all for everybody + # open system defaults for everybody read-all-process-groups: groups: [everybody] users: [] allowed_permissions: [read] - uri: /v1.0/process-groups/* + uri: /process-groups/* read-all-process-models: groups: [everybody] users: [] allowed_permissions: [read] - uri: /v1.0/process-models/* + uri: /process-models/* + + # basic perms for everybody read-all-process-instances-for-me: groups: [everybody] users: [] allowed_permissions: [read] - uri: /v1.0/process-instances/for-me/* + uri: /process-instances/for-me/* read-process-instance-reports: groups: [everybody] users: [] allowed_permissions: [create, read, update, delete] - uri: /v1.0/process-instances/reports/* + uri: /process-instances/reports/* processes-read: groups: [everybody] users: [] allowed_permissions: [read] - uri: /v1.0/processes + uri: /processes + service-tasks: + groups: [everybody] + users: [] + allowed_permissions: [read] + uri: /service-tasks + tasks-crud: + groups: [everybody] + users: [] + allowed_permissions: [create, read, update, delete] + uri: /tasks/* + user-groups-for-current-user: + groups: [everybody] + users: [] + allowed_permissions: [read] + uri: /user-groups/for-current-user - manage-procurement-admin: - groups: ["Project Lead"] - users: [] - allowed_permissions: [create, read, update, delete] - uri: /v1.0/process-groups/manage-procurement:* - manage-procurement-admin-slash: - groups: ["Project Lead"] - users: [] - allowed_permissions: [create, read, update, delete] - uri: /v1.0/process-groups/manage-procurement/* - manage-procurement-admin-models: - groups: ["Project Lead"] - users: [] - allowed_permissions: [create, read, update, delete] - uri: /v1.0/process-models/manage-procurement:* - manage-procurement-admin-models-slash: - groups: ["Project Lead"] - users: [] - allowed_permissions: [create, read, update, delete] - uri: /v1.0/process-models/manage-procurement/* - manage-procurement-admin-instances: - groups: ["Project Lead"] - users: [] - allowed_permissions: [create, read, update, delete] - uri: /v1.0/process-instances/manage-procurement:* - manage-procurement-admin-instances-slash: - groups: ["Project Lead"] - users: [] - allowed_permissions: [create, read, update, delete] - uri: /v1.0/process-instances/manage-procurement/* - finance-admin: groups: ["Finance Team"] users: [] allowed_permissions: [create, read, update, delete] - uri: /v1.0/process-groups/manage-procurement:procurement:* + uri: /process-groups/manage-procurement:procurement:* manage-revenue-streams-instances: - groups: ["core-contributor", "demo"] + groups: ["demo"] users: [] - allowed_permissions: [create, read] - uri: /v1.0/process-instances/manage-revenue-streams:product-revenue-streams:customer-contracts-trade-terms/* - + allowed_permissions: [create] + uri: /process-instances/manage-revenue-streams:product-revenue-streams:customer-contracts-trade-terms/* manage-procurement-invoice-instances: - groups: ["core-contributor", "demo"] + groups: ["demo"] users: [] - allowed_permissions: [create, read] - uri: /v1.0/process-instances/manage-procurement:procurement:core-contributor-invoice-management:* - + allowed_permissions: [create] + uri: /process-instances/manage-procurement:procurement:core-contributor-invoice-management:* manage-procurement-instances: - groups: ["core-contributor", "demo"] + groups: ["demo"] users: [] - allowed_permissions: [create, read] - uri: /v1.0/process-instances/manage-procurement:vendor-lifecycle-management:* + allowed_permissions: [create] + uri: /process-instances/manage-procurement:vendor-lifecycle-management:* + + manage-revenue-streams-instances-for-me: + groups: ["demo"] + users: [] + allowed_permissions: [read] + uri: /process-instances/for-me/manage-revenue-streams:product-revenue-streams:customer-contracts-trade-terms/* + manage-procurement-invoice-instances-for-me: + groups: ["demo"] + users: [] + allowed_permissions: [read] + uri: /process-instances/for-me/manage-procurement:procurement:core-contributor-invoice-management:* + manage-procurement-instances-for-me: + groups: ["demo"] + users: [] + allowed_permissions: [read] + uri: /process-instances/for-me/manage-procurement:vendor-lifecycle-management:* create-test-instances: groups: ["test"] users: [] allowed_permissions: [create, read] - uri: /v1.0/process-instances/misc:test:* + uri: /process-instances/misc:test:* diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/testing.yml b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/testing.yml index c678205df..79a137104 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/testing.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/testing.yml @@ -1,5 +1,12 @@ default_group: everybody +users: + testadmin1: + service: https://testing/openid/thing + email: testadmin1@spiffworkflow.org + password: admin + preferred_username: El administrador de la muerte + groups: admin: users: [testadmin1, testadmin2] @@ -14,7 +21,7 @@ permissions: admin: groups: [admin] users: [] - allowed_permissions: [create, read, update, delete, list, instantiate] + allowed_permissions: [create, read, update, delete] uri: /* read-all: @@ -27,29 +34,29 @@ permissions: groups: [everybody] users: [] allowed_permissions: [create, read, update, delete] - uri: /v1.0/tasks/* + uri: /tasks/* # TODO: all uris should really have the same structure finance-admin-group: groups: ["Finance Team"] users: [testuser4] allowed_permissions: [create, read, update, delete] - uri: /v1.0/process-groups/finance/* + uri: /process-groups/finance/* finance-admin-model: groups: ["Finance Team"] users: [testuser4] allowed_permissions: [create, read, update, delete] - uri: /v1.0/process-models/finance/* + uri: /process-models/finance/* finance-admin-model-lanes: groups: ["Finance Team"] users: [testuser4] allowed_permissions: [create, read, update, delete] - uri: /v1.0/process-models/finance:model_with_lanes/* + uri: /process-models/finance:model_with_lanes/* finance-admin-instance-run: groups: ["Finance Team"] users: [testuser4] allowed_permissions: [create, read, update, delete] - uri: /v1.0/process-instances/* + uri: /process-instances/* diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/helpers/api_version.py b/spiffworkflow-backend/src/spiffworkflow_backend/helpers/api_version.py new file mode 100644 index 000000000..607b6c16b --- /dev/null +++ b/spiffworkflow-backend/src/spiffworkflow_backend/helpers/api_version.py @@ -0,0 +1,2 @@ +"""Api_version.""" +V1_API_PATH_PREFIX = "/v1.0" diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/group.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/group.py index 3b7edd6ce..980fc9302 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/group.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/group.py @@ -27,6 +27,9 @@ class GroupModel(FlaskBpmnGroupModel): identifier = db.Column(db.String(255)) user_group_assignments = relationship("UserGroupAssignmentModel", cascade="delete") + user_group_assignments_waiting = relationship( # type: ignore + "UserGroupAssignmentWaitingModel", cascade="delete" + ) users = relationship( # type: ignore "UserModel", viewonly=True, diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/permission_assignment.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/permission_assignment.py index 63295f74e..04dfb5fac 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/permission_assignment.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/permission_assignment.py @@ -32,14 +32,6 @@ class Permission(enum.Enum): update = "update" delete = "delete" - # maybe read to GET process_model/process-instances instead? - list = "list" - - # maybe use create instead on - # POST http://localhost:7000/v1.0/process-models/category_number_one/call-activity/process-instances/* - # POST http://localhost:7000/v1.0/process-models/category_number_one/call-activity/process-instances/332/run - instantiate = "instantiate" # this is something you do to a process model - class PermissionAssignmentModel(SpiffworkflowBaseDBModel): """PermissionAssignmentModel.""" diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py index 5fa09896d..f3dc69bd3 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py @@ -1,22 +1,15 @@ """User.""" from __future__ import annotations -from typing import Any - import jwt import marshmallow from flask import current_app -from flask_bpmn.api.api_error import ApiError from flask_bpmn.models.db import db from flask_bpmn.models.db import SpiffworkflowBaseDBModel from marshmallow import Schema from sqlalchemy.orm import relationship -from sqlalchemy.orm import validates from spiffworkflow_backend.models.group import GroupModel -from spiffworkflow_backend.services.authentication_service import ( - AuthenticationProviderTypes, -) class UserNotFoundError(Exception): @@ -28,14 +21,15 @@ class UserModel(SpiffworkflowBaseDBModel): __tablename__ = "user" __table_args__ = (db.UniqueConstraint("service", "service_id", name="service_key"),) - id = db.Column(db.Integer, primary_key=True) - # server and service id must be unique, not username. - username = db.Column(db.String(255), nullable=False, unique=False) - uid = db.Column(db.String(50), unique=True) - service = db.Column(db.String(50), nullable=False, unique=False) + username = db.Column( + db.String(255), nullable=False, unique=True + ) # should always be a unique value + service = db.Column( + db.String(50), nullable=False, unique=False + ) # not 'openid' -- google, aws service_id = db.Column(db.String(255), nullable=False, unique=False) - name = db.Column(db.String(255)) + display_name = db.Column(db.String(255)) email = db.Column(db.String(255)) updated_at_in_seconds: int = db.Column(db.Integer) created_at_in_seconds: int = db.Column(db.Integer) @@ -49,21 +43,6 @@ class UserModel(SpiffworkflowBaseDBModel): ) principal = relationship("PrincipalModel", uselist=False) # type: ignore - @validates("service") - def validate_service(self, key: str, value: Any) -> str: - """Validate_service.""" - try: - ap_type = getattr(AuthenticationProviderTypes, value, None) - except Exception as e: - raise ValueError(f"invalid service type: {value}") from e - if ap_type is not None: - ap_value: str = ap_type.value - return ap_value - raise ApiError( - error_code="invalid_service", - message=f"Could not validate service with value: {value}", - ) - def encode_auth_token(self) -> str: """Generate the Auth Token. diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/user_group_assignment_waiting.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/user_group_assignment_waiting.py new file mode 100644 index 000000000..ac2747c85 --- /dev/null +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/user_group_assignment_waiting.py @@ -0,0 +1,34 @@ +"""UserGroupAssignment.""" +from flask_bpmn.models.db import db +from flask_bpmn.models.db import SpiffworkflowBaseDBModel +from sqlalchemy import ForeignKey +from sqlalchemy.orm import relationship + +from spiffworkflow_backend.models.group import GroupModel + + +class UserGroupAssignmentWaitingModel(SpiffworkflowBaseDBModel): + """When a user is assigned to a group, but that username does not exist. + + We cache it here to be applied in the event the user does log in to the system. + """ + + MATCH_ALL_USERS = "*" + __tablename__ = "user_group_assignment_waiting" + __table_args__ = ( + db.UniqueConstraint( + "username", "group_id", name="user_group_assignment_staged_unique" + ), + ) + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(255), nullable=False) + group_id = db.Column(ForeignKey(GroupModel.id), nullable=False) + + group = relationship("GroupModel", overlaps="groups,user_group_assignments_waiting,users") # type: ignore + + def is_match_all(self) -> bool: + """Is_match_all.""" + if self.username == self.MATCH_ALL_USERS: + return True + return False diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/admin_blueprint/admin_blueprint.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/admin_blueprint/admin_blueprint.py index f1223ae0d..5cb0ae89b 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/admin_blueprint/admin_blueprint.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/admin_blueprint/admin_blueprint.py @@ -141,7 +141,7 @@ def process_model_save(process_model_id: str, file_name: str) -> Union[str, Resp @admin_blueprint.route("/process-models//run", methods=["GET"]) def process_model_run(process_model_id: str) -> Union[str, Response]: """Process_model_run.""" - user = UserService.create_user("internal", "Mr. Test", username="Mr. Test") + user = UserService.create_user("Mr. Test", "internal", "Mr. Test") process_instance = ( ProcessInstanceService.create_process_instance_from_process_model_identifier( process_model_id, user diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/openid_blueprint.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/openid_blueprint.py index f812ab034..f25100eed 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/openid_blueprint.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/openid_blueprint.py @@ -111,6 +111,7 @@ def token() -> dict: "iat": time.time(), "exp": time.time() + 86400, # Expire after a day. "sub": user_name, + "email": user_details["email"], "preferred_username": user_details.get("preferred_username", user_name), }, client_secret, diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/user.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/user.py index ad98fbbc6..9b63f9035 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/user.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/user.py @@ -76,7 +76,7 @@ def verify_token( except ApiError as ae: # API Error is only thrown in the token is outdated. # Try to refresh the token user = UserService.get_user_by_service_and_service_id( - "open_id", decoded_token["sub"] + decoded_token["iss"], decoded_token["sub"] ) if user: refresh_token = AuthenticationService.get_refresh_token(user.id) @@ -105,10 +105,12 @@ def verify_token( ) from e if ( - user_info is not None and "error" not in user_info + 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 == "open_id") + UserModel.query.filter(UserModel.service == user_info["iss"]) .filter(UserModel.service_id == user_info["sub"]) .first() ) @@ -341,9 +343,5 @@ def get_user_from_decoded_internal_token(decoded_token: dict) -> Optional[UserMo ) if user: return user - user = UserModel( - username=service_id, - service=service, - service_id=service_id, - ) + user = UserService.create_user(service_id, service, service_id) return user diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/scripts/add_user_to_group.py b/spiffworkflow-backend/src/spiffworkflow_backend/scripts/add_user_to_group.py deleted file mode 100644 index d3c777118..000000000 --- a/spiffworkflow-backend/src/spiffworkflow_backend/scripts/add_user_to_group.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Get_env.""" -from typing import Any - -from spiffworkflow_backend.models.group import GroupModel -from spiffworkflow_backend.models.group import GroupNotFoundError -from spiffworkflow_backend.models.script_attributes_context import ( - ScriptAttributesContext, -) -from spiffworkflow_backend.models.user import UserModel -from spiffworkflow_backend.models.user import UserNotFoundError -from spiffworkflow_backend.scripts.script import Script -from spiffworkflow_backend.services.user_service import UserService - - -class AddUserToGroup(Script): - """AddUserToGroup.""" - - def get_description(self) -> str: - """Get_description.""" - return """Add a given user to a given group.""" - - def run( - self, - script_attributes_context: ScriptAttributesContext, - *args: Any, - **kwargs: Any, - ) -> Any: - """Run.""" - username = args[0] - group_identifier = args[1] - user = UserModel.query.filter_by(username=username).first() - if user is None: - raise UserNotFoundError( - f"Script 'add_user_to_group' could not find a user with username: {username}" - ) - - group = GroupModel.query.filter_by(identifier=group_identifier).first() - if group is None: - raise GroupNotFoundError( - f"Script 'add_user_to_group' could not find group with identifier '{group_identifier}'." - ) - - UserService.add_user_to_group(user, group) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/scripts/fact_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/scripts/fact_service.py index ee86a84a7..6e8a23c2c 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/scripts/fact_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/scripts/fact_service.py @@ -10,6 +10,11 @@ from spiffworkflow_backend.scripts.script import Script class FactService(Script): """FactService.""" + @staticmethod + def requires_privileged_permissions() -> bool: + """We have deemed this function safe to run without elevated permissions.""" + return False + def get_description(self) -> str: """Get_description.""" return """Just your basic class that can pull in data from a few api endpoints and diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_all_permissions.py b/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_all_permissions.py new file mode 100644 index 000000000..7cdcf3601 --- /dev/null +++ b/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_all_permissions.py @@ -0,0 +1,56 @@ +"""Get_env.""" +from collections import OrderedDict +from typing import Any + +from spiffworkflow_backend.models.group import GroupModel +from spiffworkflow_backend.models.permission_assignment import PermissionAssignmentModel +from spiffworkflow_backend.models.permission_target import PermissionTargetModel +from spiffworkflow_backend.models.principal import PrincipalModel +from spiffworkflow_backend.models.script_attributes_context import ( + ScriptAttributesContext, +) +from spiffworkflow_backend.scripts.script import Script + + +class GetAllPermissions(Script): + """GetAllPermissions.""" + + def get_description(self) -> str: + """Get_description.""" + return """Get all permissions currently in the system.""" + + def run( + self, + script_attributes_context: ScriptAttributesContext, + *args: Any, + **kwargs: Any, + ) -> Any: + """Run.""" + permission_assignments = ( + PermissionAssignmentModel.query.join( + PrincipalModel, + PrincipalModel.id == PermissionAssignmentModel.principal_id, + ) + .join(GroupModel, GroupModel.id == PrincipalModel.group_id) + .join( + PermissionTargetModel, + PermissionTargetModel.id + == PermissionAssignmentModel.permission_target_id, + ) + .add_columns( + PermissionAssignmentModel.permission, + PermissionTargetModel.uri, + GroupModel.identifier.label("group_identifier"), + ) + ) + + permissions: OrderedDict[tuple[str, str], list[str]] = OrderedDict() + for pa in permission_assignments: + permissions.setdefault((pa.group_identifier, pa.uri), []).append( + pa.permission + ) + + return [ + {"group_identifier": k[0], "uri": k[1], "permissions": sorted(v)} + for k, v in permissions.items() + ] diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_current_user.py b/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_current_user.py index a1a1b47e9..66d21a4ca 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_current_user.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_current_user.py @@ -12,6 +12,11 @@ from spiffworkflow_backend.scripts.script import Script class GetCurrentUser(Script): """GetCurrentUser.""" + @staticmethod + def requires_privileged_permissions() -> bool: + """We have deemed this function safe to run without elevated permissions.""" + return False + def get_description(self) -> str: """Get_description.""" return """Return the current user.""" diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_env.py b/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_env.py index cd586ae00..7a6b0f44c 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_env.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_env.py @@ -10,6 +10,11 @@ from spiffworkflow_backend.scripts.script import Script class GetEnv(Script): """GetEnv.""" + @staticmethod + def requires_privileged_permissions() -> bool: + """We have deemed this function safe to run without elevated permissions.""" + return False + def get_description(self) -> str: """Get_description.""" return """Returns the current environment - ie testing, staging, production.""" diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_frontend_url.py b/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_frontend_url.py index 9490df95a..b128214ab 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_frontend_url.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_frontend_url.py @@ -12,6 +12,11 @@ from spiffworkflow_backend.scripts.script import Script class GetFrontendUrl(Script): """GetFrontendUrl.""" + @staticmethod + def requires_privileged_permissions() -> bool: + """We have deemed this function safe to run without elevated permissions.""" + return False + def get_description(self) -> str: """Get_description.""" return """Return the url to the frontend.""" diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_group_members.py b/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_group_members.py index 243a8c524..8b179a6dd 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_group_members.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_group_members.py @@ -12,6 +12,11 @@ from spiffworkflow_backend.scripts.script import Script class GetGroupMembers(Script): """GetGroupMembers.""" + @staticmethod + def requires_privileged_permissions() -> bool: + """We have deemed this function safe to run without elevated permissions.""" + return False + def get_description(self) -> str: """Get_description.""" return """Return the list of usernames of the users in the given group.""" diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_localtime.py b/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_localtime.py index 689b86d8c..7c688e56f 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_localtime.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_localtime.py @@ -14,6 +14,11 @@ from spiffworkflow_backend.scripts.script import Script class GetLocaltime(Script): """GetLocaltime.""" + @staticmethod + def requires_privileged_permissions() -> bool: + """We have deemed this function safe to run without elevated permissions.""" + return False + def get_description(self) -> str: """Get_description.""" return """Converts a Datetime object into a Datetime object for a specific timezone. diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_process_info.py b/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_process_info.py index 45c70d6ba..138a19ac8 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_process_info.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_process_info.py @@ -10,6 +10,11 @@ from spiffworkflow_backend.scripts.script import Script class GetProcessInfo(Script): """GetProcessInfo.""" + @staticmethod + def requires_privileged_permissions() -> bool: + """We have deemed this function safe to run without elevated permissions.""" + return False + def get_description(self) -> str: """Get_description.""" return """Returns a dictionary of information about the currently running process.""" diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/scripts/refresh_permissions.py b/spiffworkflow-backend/src/spiffworkflow_backend/scripts/refresh_permissions.py new file mode 100644 index 000000000..8c97fe60d --- /dev/null +++ b/spiffworkflow-backend/src/spiffworkflow_backend/scripts/refresh_permissions.py @@ -0,0 +1,39 @@ +"""Get_env.""" +from typing import Any + +from spiffworkflow_backend.models.script_attributes_context import ( + ScriptAttributesContext, +) +from spiffworkflow_backend.scripts.script import Script +from spiffworkflow_backend.services.authorization_service import AuthorizationService + + +class RecreatePermissions(Script): + """RecreatePermissions.""" + + def get_description(self) -> str: + """Get_description.""" + return """Add permissions using a dict. + group_info: [ + { + 'name': group_identifier, + 'users': array_of_users, + 'permissions': [ + { + 'actions': array_of_actions - create, read, etc, + 'uri': target_uri + } + ] + } + ] + """ + + def run( + self, + script_attributes_context: ScriptAttributesContext, + *args: Any, + **kwargs: Any, + ) -> Any: + """Run.""" + group_info = args[0] + AuthorizationService.refresh_permissions(group_info) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/scripts/script.py b/spiffworkflow-backend/src/spiffworkflow_backend/scripts/script.py index b744694a2..9e5836d6f 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/scripts/script.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/scripts/script.py @@ -10,9 +10,12 @@ from typing import Callable from flask_bpmn.api.api_error import ApiError +from spiffworkflow_backend.models.process_instance import ProcessInstanceModel +from spiffworkflow_backend.models.process_instance import ProcessInstanceNotFoundError from spiffworkflow_backend.models.script_attributes_context import ( ScriptAttributesContext, ) +from spiffworkflow_backend.services.authorization_service import AuthorizationService # Generally speaking, having some global in a flask app is TERRIBLE. # This is here, because after loading the application this will never change under @@ -20,6 +23,10 @@ from spiffworkflow_backend.models.script_attributes_context import ( SCRIPT_SUB_CLASSES = None +class ScriptUnauthorizedForUserError(Exception): + """ScriptUnauthorizedForUserError.""" + + class Script: """Provides an abstract class that defines how scripts should work, this must be extended in all Script Tasks.""" @@ -43,6 +50,15 @@ class Script: + "does not properly implement the run function.", ) + @staticmethod + def requires_privileged_permissions() -> bool: + """It seems safer to default to True and make safe functions opt in for any user to run them. + + To give access to script for a given user, add a 'create' permission with following target-uri: + '/can-run-privileged-script/{script_name}' + """ + return True + @staticmethod def generate_augmented_list( script_attributes_context: ScriptAttributesContext, @@ -71,18 +87,50 @@ class Script: that we created. """ instance = subclass() - return lambda *ar, **kw: subclass.run( - instance, - script_attributes_context, - *ar, - **kw, - ) + + def check_script_permission() -> None: + """Check_script_permission.""" + if subclass.requires_privileged_permissions(): + script_function_name = get_script_function_name(subclass) + uri = f"/can-run-privileged-script/{script_function_name}" + process_instance = ProcessInstanceModel.query.filter_by( + id=script_attributes_context.process_instance_id + ).first() + if process_instance is None: + raise ProcessInstanceNotFoundError( + f"Could not find a process instance with id '{script_attributes_context.process_instance_id}' " + f"when running script '{script_function_name}'" + ) + user = process_instance.process_initiator + has_permission = AuthorizationService.user_has_permission( + user=user, permission="create", target_uri=uri + ) + if not has_permission: + raise ScriptUnauthorizedForUserError( + f"User {user.username} does not have access to run privileged script '{script_function_name}'" + ) + + def run_script_if_allowed(*ar: Any, **kw: Any) -> Any: + """Run_script_if_allowed.""" + check_script_permission() + return subclass.run( + instance, + script_attributes_context, + *ar, + **kw, + ) + + return run_script_if_allowed + + def get_script_function_name(subclass: type[Script]) -> str: + """Get_script_function_name.""" + return subclass.__module__.split(".")[-1] execlist = {} subclasses = Script.get_all_subclasses() for x in range(len(subclasses)): subclass = subclasses[x] - execlist[subclass.__module__.split(".")[-1]] = make_closure( + execlist[get_script_function_name(subclass)] = make_closure( subclass, script_attributes_context=script_attributes_context ) return execlist diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py index 95c1eaa89..fd2bdb898 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py @@ -93,7 +93,7 @@ class AuthenticationService: + f"?state={state}&" + "response_type=code&" + f"client_id={self.client_id()}&" - + "scope=openid&" + + "scope=openid profile email&" + f"redirect_uri={return_redirect_url}" ) return login_redirect_url diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py index a2f41ac10..cd125ee53 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py @@ -1,10 +1,14 @@ """Authorization_service.""" import inspect import re +from dataclasses import dataclass from hashlib import sha256 from hmac import compare_digest from hmac import HMAC +from typing import Any from typing import Optional +from typing import Set +from typing import TypedDict from typing import Union import jwt @@ -19,6 +23,7 @@ from SpiffWorkflow.task import Task as SpiffTask # type: ignore from sqlalchemy import or_ from sqlalchemy import text +from spiffworkflow_backend.helpers.api_version import V1_API_PATH_PREFIX from spiffworkflow_backend.models.group import GroupModel from spiffworkflow_backend.models.human_task import HumanTaskModel from spiffworkflow_backend.models.permission_assignment import PermissionAssignmentModel @@ -45,6 +50,34 @@ class UserDoesNotHaveAccessToTaskError(Exception): """UserDoesNotHaveAccessToTaskError.""" +class InvalidPermissionError(Exception): + """InvalidPermissionError.""" + + +@dataclass +class PermissionToAssign: + """PermissionToAssign.""" + + permission: str + target_uri: str + + +PATH_SEGMENTS_FOR_PERMISSION_ALL = [ + "/logs", + "/process-instances", + "/process-instance-suspend", + "/process-instance-terminate", + "/task-data", +] + + +class DesiredPermissionDict(TypedDict): + """DesiredPermissionDict.""" + + group_identifiers: Set[str] + permission_assignments: list[PermissionAssignmentModel] + + class AuthorizationService: """Determine whether a user has permission to perform their request.""" @@ -75,6 +108,7 @@ class AuthorizationService: ) -> bool: """Has_permission.""" principal_ids = [p.id for p in principals] + target_uri_normalized = target_uri.removeprefix(V1_API_PATH_PREFIX) permission_assignments = ( PermissionAssignmentModel.query.filter( @@ -84,10 +118,12 @@ class AuthorizationService: .join(PermissionTargetModel) .filter( or_( - text(f"'{target_uri}' LIKE permission_target.uri"), + text(f"'{target_uri_normalized}' LIKE permission_target.uri"), # to check for exact matches as well # see test_user_can_access_base_path_when_given_wildcard_permission unit test - text(f"'{target_uri}' = replace(permission_target.uri, '/%', '')"), + text( + f"'{target_uri_normalized}' = replace(replace(permission_target.uri, '/%', ''), ':%', '')" + ), ) ) .all() @@ -127,17 +163,15 @@ class AuthorizationService: return cls.has_permission(principals, permission, target_uri) @classmethod - def delete_all_permissions_and_recreate(cls) -> None: - """Delete_all_permissions_and_recreate.""" + def delete_all_permissions(cls) -> None: + """Delete_all_permissions_and_recreate. EXCEPT For permissions for the current user?""" for model in [PermissionAssignmentModel, PermissionTargetModel]: db.session.query(model).delete() # cascading to principals doesn't seem to work when attempting to delete all so do it like this instead for group in GroupModel.query.all(): db.session.delete(group) - db.session.commit() - cls.import_permissions_from_yaml_file() @classmethod def associate_user_with_group(cls, user: UserModel, group: GroupModel) -> None: @@ -155,7 +189,7 @@ class AuthorizationService: @classmethod def import_permissions_from_yaml_file( cls, raise_if_missing_user: bool = False - ) -> None: + ) -> DesiredPermissionDict: """Import_permissions_from_yaml_file.""" if current_app.config["SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME"] is None: raise ( @@ -169,13 +203,16 @@ class AuthorizationService: permission_configs = yaml.safe_load(file) default_group = None + unique_user_group_identifiers: Set[str] = set() if "default_group" in permission_configs: default_group_identifier = permission_configs["default_group"] default_group = GroupService.find_or_create_group(default_group_identifier) + unique_user_group_identifiers.add(default_group_identifier) if "groups" in permission_configs: for group_identifier, group_config in permission_configs["groups"].items(): group = GroupService.find_or_create_group(group_identifier) + unique_user_group_identifiers.add(group_identifier) for username in group_config["users"]: user = UserModel.query.filter_by(username=username).first() if user is None: @@ -188,26 +225,25 @@ class AuthorizationService: continue cls.associate_user_with_group(user, group) + permission_assignments = [] if "permissions" in permission_configs: for _permission_identifier, permission_config in permission_configs[ "permissions" ].items(): uri = permission_config["uri"] - uri_with_percent = re.sub(r"\*", "%", uri) - permission_target = PermissionTargetModel.query.filter_by( - uri=uri_with_percent - ).first() - if permission_target is None: - permission_target = PermissionTargetModel(uri=uri_with_percent) - db.session.add(permission_target) - db.session.commit() + permission_target = cls.find_or_create_permission_target(uri) for allowed_permission in permission_config["allowed_permissions"]: if "groups" in permission_config: for group_identifier in permission_config["groups"]: group = GroupService.find_or_create_group(group_identifier) - cls.create_permission_for_principal( - group.principal, permission_target, allowed_permission + unique_user_group_identifiers.add(group_identifier) + permission_assignments.append( + cls.create_permission_for_principal( + group.principal, + permission_target, + allowed_permission, + ) ) if "users" in permission_config: for username in permission_config["users"]: @@ -218,14 +254,35 @@ class AuthorizationService: .filter(UserModel.username == username) .first() ) - cls.create_permission_for_principal( - principal, permission_target, allowed_permission + permission_assignments.append( + cls.create_permission_for_principal( + principal, permission_target, allowed_permission + ) ) if default_group is not None: for user in UserModel.query.all(): cls.associate_user_with_group(user, default_group) + return { + "group_identifiers": unique_user_group_identifiers, + "permission_assignments": permission_assignments, + } + + @classmethod + def find_or_create_permission_target(cls, uri: str) -> PermissionTargetModel: + """Find_or_create_permission_target.""" + uri_with_percent = re.sub(r"\*", "%", uri) + target_uri_normalized = uri_with_percent.removeprefix(V1_API_PATH_PREFIX) + permission_target: Optional[ + PermissionTargetModel + ] = PermissionTargetModel.query.filter_by(uri=target_uri_normalized).first() + if permission_target is None: + permission_target = PermissionTargetModel(uri=target_uri_normalized) + db.session.add(permission_target) + db.session.commit() + return permission_target + @classmethod def create_permission_for_principal( cls, @@ -449,33 +506,48 @@ class AuthorizationService: @classmethod def create_user_from_sign_in(cls, user_info: dict) -> UserModel: """Create_user_from_sign_in.""" + """Name, family_name, given_name, middle_name, nickname, preferred_username,""" + """Profile, picture, website, gender, birthdate, zoneinfo, locale, and updated_at. """ + """Email.""" is_new_user = False user_model = ( - UserModel.query.filter(UserModel.service == "open_id") + UserModel.query.filter(UserModel.service == user_info["iss"]) .filter(UserModel.service_id == user_info["sub"]) .first() ) + email = display_name = username = "" + if "email" in user_info: + username = user_info["email"] + email = user_info["email"] + else: # we fall back to the sub, which may be very ugly. + username = user_info["sub"] + "@" + user_info["iss"] + + if "preferred_username" in user_info: + display_name = user_info["preferred_username"] + elif "nickname" in user_info: + display_name = user_info["nickname"] + elif "name" in user_info: + display_name = user_info["name"] if user_model is None: current_app.logger.debug("create_user in login_return") is_new_user = True - name = username = email = "" - if "name" in user_info: - name = user_info["name"] - if "username" in user_info: - username = user_info["username"] - elif "preferred_username" in user_info: - username = user_info["preferred_username"] - if "email" in user_info: - email = user_info["email"] user_model = UserService().create_user( - service="open_id", - service_id=user_info["sub"], - name=name, username=username, + service=user_info["iss"], + service_id=user_info["sub"], email=email, + display_name=display_name, ) + else: + # Update with the latest information + user_model.username = username + user_model.email = email + user_model.display_name = display_name + user_model.service = user_info["iss"] + user_model.service_id = user_info["sub"] + # this may eventually get too slow. # when it does, be careful about backgrounding, because # the user will immediately need permissions to use the site. @@ -490,6 +562,212 @@ class AuthorizationService: # this cannot be None so ignore mypy return user_model # type: ignore + @classmethod + def get_permissions_to_assign( + cls, + permission_set: str, + process_related_path_segment: str, + target_uris: list[str], + ) -> list[PermissionToAssign]: + """Get_permissions_to_assign.""" + permissions = permission_set.split(",") + if permission_set == "all": + permissions = ["create", "read", "update", "delete"] + + permissions_to_assign: list[PermissionToAssign] = [] + + # we were thinking that if you can start an instance, you ought to be able to view your own instances. + if permission_set == "start": + target_uri = f"/process-instances/{process_related_path_segment}" + permissions_to_assign.append( + PermissionToAssign(permission="create", target_uri=target_uri) + ) + target_uri = f"/process-instances/for-me/{process_related_path_segment}" + permissions_to_assign.append( + PermissionToAssign(permission="read", target_uri=target_uri) + ) + + else: + if permission_set == "all": + for path_segment in PATH_SEGMENTS_FOR_PERMISSION_ALL: + target_uris.append(f"{path_segment}/{process_related_path_segment}") + + for target_uri in target_uris: + for permission in permissions: + permissions_to_assign.append( + PermissionToAssign(permission=permission, target_uri=target_uri) + ) + + return permissions_to_assign + + @classmethod + def explode_permissions( + cls, permission_set: str, target: str + ) -> list[PermissionToAssign]: + """Explodes given permissions to and returns list of PermissionToAssign objects. + + These can be used to then iterate through and inserted into the database. + Target Macros: + ALL + * gives access to ALL api endpoints - useful to give admin-like permissions + PG:[process_group_identifier] + * affects given process-group and all sub process-groups and process-models + PM:[process_model_identifier] + * affects given process-model + BASIC + * Basic access to complete tasks and use the site + + Permission Macros: + all + * create, read, update, delete + start + * create process-instances (aka instantiate or start a process-model) + * only works with PG and PM target macros + """ + permissions_to_assign: list[PermissionToAssign] = [] + permissions = permission_set.split(",") + if permission_set == "all": + permissions = ["create", "read", "update", "delete"] + + if target.startswith("PG:"): + process_group_identifier = ( + target.removeprefix("PG:").replace("/", ":").removeprefix(":") + ) + process_related_path_segment = f"{process_group_identifier}:*" + if process_group_identifier == "ALL": + process_related_path_segment = "*" + target_uris = [ + f"/process-groups/{process_related_path_segment}", + f"/process-models/{process_related_path_segment}", + ] + permissions_to_assign = ( + permissions_to_assign + + cls.get_permissions_to_assign( + permission_set, process_related_path_segment, target_uris + ) + ) + + elif target.startswith("PM:"): + process_model_identifier = ( + target.removeprefix("PM:").replace("/", ":").removeprefix(":") + ) + process_related_path_segment = f"{process_model_identifier}/*" + + if process_model_identifier == "ALL": + process_related_path_segment = "*" + + target_uris = [f"/process-models/{process_related_path_segment}"] + permissions_to_assign = ( + permissions_to_assign + + cls.get_permissions_to_assign( + permission_set, process_related_path_segment, target_uris + ) + ) + + elif permission_set == "start": + raise InvalidPermissionError( + "Permission 'start' is only available for macros PM and PG." + ) + + elif target.startswith("BASIC"): + permissions_to_assign.append( + PermissionToAssign( + permission="read", target_uri="/process-instances/for-me" + ) + ) + permissions_to_assign.append( + PermissionToAssign(permission="read", target_uri="/processes") + ) + permissions_to_assign.append( + PermissionToAssign(permission="read", target_uri="/service-tasks") + ) + permissions_to_assign.append( + PermissionToAssign( + permission="read", target_uri="/user-groups/for-current-user" + ) + ) + + for permission in ["create", "read", "update", "delete"]: + permissions_to_assign.append( + PermissionToAssign( + permission=permission, target_uri="/process-instances/reports/*" + ) + ) + permissions_to_assign.append( + PermissionToAssign(permission=permission, target_uri="/tasks/*") + ) + elif target == "ALL": + for permission in permissions: + permissions_to_assign.append( + PermissionToAssign(permission=permission, target_uri="/*") + ) + elif target.startswith("/"): + for permission in permissions: + permissions_to_assign.append( + PermissionToAssign(permission=permission, target_uri=target) + ) + else: + raise InvalidPermissionError( + f"Target uri '{target}' with permission set '{permission_set}' is invalid. " + f"The target uri must either be a macro of PG, PM, BASIC, or ALL or an api uri." + ) + + return permissions_to_assign + + @classmethod + def add_permission_from_uri_or_macro( + cls, group_identifier: str, permission: str, target: str + ) -> list[PermissionAssignmentModel]: + """Add_permission_from_uri_or_macro.""" + group = GroupService.find_or_create_group(group_identifier) + permissions_to_assign = cls.explode_permissions(permission, target) + permission_assignments = [] + for permission_to_assign in permissions_to_assign: + permission_target = cls.find_or_create_permission_target( + permission_to_assign.target_uri + ) + permission_assignments.append( + cls.create_permission_for_principal( + group.principal, permission_target, permission_to_assign.permission + ) + ) + return permission_assignments + + @classmethod + def refresh_permissions(cls, group_info: list[dict[str, Any]]) -> None: + """Adds new permission assignments and deletes old ones.""" + initial_permission_assignments = PermissionAssignmentModel.query.all() + result = cls.import_permissions_from_yaml_file() + desired_permission_assignments = result["permission_assignments"] + desired_group_identifiers = result["group_identifiers"] + + for group in group_info: + for username in group["users"]: + GroupService.add_user_to_group_or_add_to_waiting( + username, group["name"] + ) + for permission in group["permissions"]: + for crud_op in permission["actions"]: + desired_permission_assignments.extend( + cls.add_permission_from_uri_or_macro( + group_identifier=group["name"], + target=permission["uri"], + permission=crud_op, + ) + ) + desired_group_identifiers.add(group["name"]) + + for ipa in initial_permission_assignments: + if ipa not in desired_permission_assignments: + db.session.delete(ipa) + + groups_to_delete = GroupModel.query.filter( + GroupModel.identifier.not_in(desired_group_identifiers) + ).all() + for gtd in groups_to_delete: + db.session.delete(gtd) + db.session.commit() + class KeycloakAuthorization: """Interface with Keycloak server.""" diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/group_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/group_service.py index aa560009e..911d41ac4 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/group_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/group_service.py @@ -4,6 +4,7 @@ from typing import Optional from flask_bpmn.models.db import db from spiffworkflow_backend.models.group import GroupModel +from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.services.user_service import UserService @@ -22,3 +23,15 @@ class GroupService: db.session.commit() UserService.create_principal(group.id, id_column_name="group_id") return group + + @classmethod + def add_user_to_group_or_add_to_waiting( + cls, username: str, group_identifier: str + ) -> None: + """Add_user_to_group_or_add_to_waiting.""" + group = cls.find_or_create_group(group_identifier) + user = UserModel.query.filter_by(username=username).first() + if user: + UserService.add_user_to_group(user, group) + else: + UserService.add_waiting_group_assignment(username, group) 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 24dbd497b..bd588a373 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_processor.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_processor.py @@ -151,6 +151,7 @@ class CustomBpmnScriptEngine(PythonScriptEngine): # type: ignore "time": time, "decimal": decimal, "_strptime": _strptime, + "enumerate": enumerate, } # This will overwrite the standard builtins diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_report_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_report_service.py index 82a35fc5c..18c1bef6b 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_report_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_report_service.py @@ -414,13 +414,16 @@ class ProcessInstanceReportService: ) if report_filter.with_tasks_assigned_to_my_group is True: - group_model_join_conditions = [GroupModel.id == HumanTaskModel.lane_assignment_id] + group_model_join_conditions = [ + GroupModel.id == HumanTaskModel.lane_assignment_id + ] if report_filter.user_group_identifier: - group_model_join_conditions.append(GroupModel.identifier == report_filter.user_group_identifier) + group_model_join_conditions.append( + GroupModel.identifier == report_filter.user_group_identifier + ) process_instance_query = process_instance_query.join(HumanTaskModel) process_instance_query = process_instance_query.join( - GroupModel, - and_(*group_model_join_conditions) + GroupModel, and_(*group_model_join_conditions) ) process_instance_query = process_instance_query.join( UserGroupAssignmentModel, diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py index 3427b47bb..0dec5e44d 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py @@ -17,7 +17,8 @@ from spiffworkflow_backend.models.task import MultiInstanceType from spiffworkflow_backend.models.task import Task from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.services.authorization_service import AuthorizationService -from spiffworkflow_backend.services.git_service import GitService, GitCommandError +from spiffworkflow_backend.services.git_service import GitCommandError +from spiffworkflow_backend.services.git_service import GitService from spiffworkflow_backend.services.process_instance_processor import ( ProcessInstanceProcessor, ) @@ -38,7 +39,7 @@ class ProcessInstanceService: """Get_process_instance_from_spec.""" try: current_git_revision = GitService.get_current_revision() - except GitCommandError as ge: + except GitCommandError: current_git_revision = "" process_instance_model = ProcessInstanceModel( status=ProcessInstanceStatus.not_started.value, diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_model_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_model_service.py index 67be986e1..714cd7991 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_model_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_model_service.py @@ -224,10 +224,10 @@ class ProcessModelService(FileSystemService): new_process_model_list = [] for process_model in process_models: uri = f"/v1.0/process-instances/{process_model.id.replace('/', ':')}" - result = AuthorizationService.user_has_permission( + has_permission = AuthorizationService.user_has_permission( user=user, permission="create", target_uri=uri ) - if result: + if has_permission: new_process_model_list.append(process_model) return new_process_model_list diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/user_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/user_service.py index 0e6cf1cbe..20412e549 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/user_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/user_service.py @@ -13,6 +13,9 @@ from spiffworkflow_backend.models.human_task_user import HumanTaskUserModel from spiffworkflow_backend.models.principal import PrincipalModel from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.models.user_group_assignment import UserGroupAssignmentModel +from spiffworkflow_backend.models.user_group_assignment_waiting import ( + UserGroupAssignmentWaitingModel, +) class UserService: @@ -21,11 +24,11 @@ class UserService: @classmethod def create_user( cls, + username: str, service: str, service_id: str, - name: Optional[str] = "", - username: Optional[str] = "", email: Optional[str] = "", + display_name: Optional[str] = "", ) -> UserModel: """Create_user.""" user_model: Optional[UserModel] = ( @@ -41,8 +44,8 @@ class UserService: username=username, service=service, service_id=service_id, - name=name, email=email, + display_name=display_name, ) db.session.add(user_model) @@ -55,6 +58,7 @@ class UserService: message=f"Could not add user {username}", ) from e cls.create_principal(user_model.id) + UserService().apply_waiting_group_assignments(user_model) return user_model else: @@ -69,45 +73,12 @@ class UserService: ) ) - @classmethod - def find_or_create_user( - cls, - service: str, - service_id: str, - name: Optional[str] = None, - username: Optional[str] = None, - email: Optional[str] = None, - ) -> UserModel: - """Find_or_create_user.""" - user_model: UserModel - try: - user_model = cls.create_user( - service=service, - service_id=service_id, - name=name, - username=username, - email=email, - ) - except ApiError: - user_model = ( - UserModel.query.filter(UserModel.service == service) - .filter(UserModel.service_id == service_id) - .first() - ) - return user_model - # Returns true if the current user is logged in. @staticmethod def has_user() -> bool: """Has_user.""" return "token" in g and bool(g.token) and "user" in g and bool(g.user) - # Returns true if the given user uid is different from the current user's uid. - @staticmethod - def is_different_user(uid: str) -> bool: - """Is_different_user.""" - return UserService.has_user() and uid is not None and uid is not g.user.uid - @staticmethod def current_user() -> Any: """Current_user.""" @@ -117,20 +88,6 @@ class UserService: ) return g.user - @staticmethod - def in_list(uids: list[str]) -> bool: - """Returns true if the current user's id is in the given list of ids. - - False if there is no user, or the user is not in the list. - """ - if ( - UserService.has_user() - ): # If someone is logged in, lock tasks that don't belong to them. - user = UserService.current_user() - if user.uid in uids: - return True - return False - @staticmethod def get_principal_by_user_id(user_id: int) -> PrincipalModel: """Get_principal_by_user_id.""" @@ -173,8 +130,57 @@ class UserService: @classmethod def add_user_to_group(cls, user: UserModel, group: GroupModel) -> None: """Add_user_to_group.""" - ugam = UserGroupAssignmentModel(user_id=user.id, group_id=group.id) - db.session.add(ugam) + exists = ( + UserGroupAssignmentModel() + .query.filter_by(user_id=user.id) + .filter_by(group_id=group.id) + .count() + ) + if not exists: + ugam = UserGroupAssignmentModel(user_id=user.id, group_id=group.id) + db.session.add(ugam) + db.session.commit() + + @classmethod + def add_waiting_group_assignment(cls, username: str, group: GroupModel) -> None: + """Add_waiting_group_assignment.""" + wugam = ( + UserGroupAssignmentWaitingModel() + .query.filter_by(username=username) + .filter_by(group_id=group.id) + .first() + ) + if not wugam: + wugam = UserGroupAssignmentWaitingModel( + username=username, group_id=group.id + ) + db.session.add(wugam) + db.session.commit() + if wugam.is_match_all(): + for user in UserModel.query.all(): + cls.add_user_to_group(user, group) + + @classmethod + def apply_waiting_group_assignments(cls, user: UserModel) -> None: + """Apply_waiting_group_assignments.""" + waiting = ( + UserGroupAssignmentWaitingModel() + .query.filter(UserGroupAssignmentWaitingModel.username == user.username) + .all() + ) + for assignment in waiting: + cls.add_user_to_group(user, assignment.group) + db.session.delete(assignment) + wildcard = ( + UserGroupAssignmentWaitingModel() + .query.filter( + UserGroupAssignmentWaitingModel.username + == UserGroupAssignmentWaitingModel.MATCH_ALL_USERS + ) + .all() + ) + for assignment in wildcard: + cls.add_user_to_group(user, assignment.group) db.session.commit() @staticmethod diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/helpers/base_test.py b/spiffworkflow-backend/tests/spiffworkflow_backend/helpers/base_test.py index 52f1889e9..8a314f2cf 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/helpers/base_test.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/helpers/base_test.py @@ -41,7 +41,7 @@ class BaseTest: if isinstance(user, UserModel): return user - user = UserService.create_user("internal", username, username=username) + user = UserService.create_user(username, "internal", username) if isinstance(user, UserModel): return user @@ -324,13 +324,9 @@ class BaseTest: permission_names: Optional[list[str]] = None, ) -> UserModel: """Add_permissions_to_user.""" - permission_target = PermissionTargetModel.query.filter_by( - uri=target_uri - ).first() - if permission_target is None: - permission_target = PermissionTargetModel(uri=target_uri) - db.session.add(permission_target) - db.session.commit() + permission_target = AuthorizationService.find_or_create_permission_target( + target_uri + ) if permission_names is None: permission_names = [member.name for member in Permission] diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_openid_blueprint.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_openid_blueprint.py index 20a0bb67b..ce1655cb9 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_openid_blueprint.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_openid_blueprint.py @@ -1,4 +1,7 @@ """Test_authentication.""" +import base64 + +import jwt from flask import Flask from flask.testing import FlaskClient from tests.spiffworkflow_backend.helpers.base_test import BaseTest @@ -44,13 +47,16 @@ class TestFlaskOpenId(BaseTest): client: FlaskClient, with_db_and_bpmn_file_cleanup: None, ) -> None: + """Test_get_token.""" + code = "testadmin1:1234123412341234" + """It should be possible to get a token.""" - code = ( - "c3BpZmZ3b3JrZmxvdy1iYWNrZW5kOkpYZVFFeG0wSmhRUEx1bWdIdElJcWY1MmJEYWxIejBx" - ) + backend_basic_auth_string = code + backend_basic_auth_bytes = bytes(backend_basic_auth_string, encoding="ascii") + backend_basic_auth = base64.b64encode(backend_basic_auth_bytes) headers = { "Content-Type": "application/x-www-form-urlencoded", - "Authorization": f"Basic {code}", + "Authorization": f"Basic {backend_basic_auth.decode('utf-8')}", } data = { "grant_type": "authorization_code", @@ -59,3 +65,13 @@ class TestFlaskOpenId(BaseTest): } response = client.post("/openid/token", data=data, headers=headers) assert response + assert response.is_json + assert "access_token" in response.json + assert "id_token" in response.json + assert "refresh_token" in response.json + + decoded_token = jwt.decode( + response.json["id_token"], options={"verify_signature": False} + ) + assert "iss" in decoded_token + assert "email" in decoded_token diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/scripts/test_get_all_permissions.py b/spiffworkflow-backend/tests/spiffworkflow_backend/scripts/test_get_all_permissions.py new file mode 100644 index 000000000..3c3bce506 --- /dev/null +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/scripts/test_get_all_permissions.py @@ -0,0 +1,60 @@ +"""Test_get_localtime.""" +from flask.app import Flask +from flask.testing import FlaskClient +from tests.spiffworkflow_backend.helpers.base_test import BaseTest + +from spiffworkflow_backend.models.script_attributes_context import ( + ScriptAttributesContext, +) +from spiffworkflow_backend.models.user import UserModel +from spiffworkflow_backend.scripts.get_all_permissions import GetAllPermissions +from spiffworkflow_backend.services.authorization_service import AuthorizationService + + +class TestGetAllPermissions(BaseTest): + """TestGetAllPermissions.""" + + def test_can_get_all_permissions( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + with_super_admin_user: UserModel, + ) -> None: + """Test_can_get_all_permissions.""" + self.find_or_create_user("test_user") + + # now that we have everything, try to clear it out... + script_attributes_context = ScriptAttributesContext( + task=None, + environment_identifier="testing", + process_instance_id=1, + process_model_identifier="my_test_user", + ) + AuthorizationService.add_permission_from_uri_or_macro( + permission="start", target="PG:hey:group", group_identifier="my_test_group" + ) + AuthorizationService.add_permission_from_uri_or_macro( + permission="all", target="/tasks", group_identifier="my_test_group" + ) + + expected_permissions = [ + { + "group_identifier": "my_test_group", + "uri": "/process-instances/hey:group:%", + "permissions": ["create"], + }, + { + "group_identifier": "my_test_group", + "uri": "/process-instances/for-me/hey:group:%", + "permissions": ["read"], + }, + { + "group_identifier": "my_test_group", + "uri": "/tasks", + "permissions": ["create", "delete", "read", "update"], + }, + ] + + permissions = GetAllPermissions().run(script_attributes_context) + assert permissions == expected_permissions diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/scripts/test_save_process_instance_metadata.py b/spiffworkflow-backend/tests/spiffworkflow_backend/scripts/test_save_process_instance_metadata.py index 96eb62970..5f66fa72d 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/scripts/test_save_process_instance_metadata.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/scripts/test_save_process_instance_metadata.py @@ -24,7 +24,6 @@ class TestSaveProcessInstanceMetadata(BaseTest): with_super_admin_user: UserModel, ) -> None: """Test_can_save_process_instance_metadata.""" - initiator_user = self.find_or_create_user("initiator_user") self.create_process_group( client, with_super_admin_user, "test_group", "test_group" ) @@ -34,7 +33,7 @@ class TestSaveProcessInstanceMetadata(BaseTest): process_model_source_directory="save_process_instance_metadata", ) process_instance = self.create_process_instance_from_process_model( - process_model=process_model, user=initiator_user + process_model=process_model, user=with_super_admin_user ) processor = ProcessInstanceProcessor(process_instance) processor.do_engine_steps(save=True) 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 05375754d..a0c140f14 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_authorization_service.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_authorization_service.py @@ -4,9 +4,12 @@ from flask import Flask from flask.testing import FlaskClient from tests.spiffworkflow_backend.helpers.base_test import BaseTest +from spiffworkflow_backend.models.group import GroupModel from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.models.user import UserNotFoundError from spiffworkflow_backend.services.authorization_service import AuthorizationService +from spiffworkflow_backend.services.authorization_service import InvalidPermissionError +from spiffworkflow_backend.services.group_service import GroupService from spiffworkflow_backend.services.process_instance_processor import ( ProcessInstanceProcessor, ) @@ -14,6 +17,7 @@ from spiffworkflow_backend.services.process_instance_service import ( ProcessInstanceService, ) from spiffworkflow_backend.services.process_model_service import ProcessModelService +from spiffworkflow_backend.services.user_service import UserService class TestAuthorizationService(BaseTest): @@ -134,8 +138,339 @@ class TestAuthorizationService(BaseTest): human_task.task_name, processor.bpmn_process_instance ) finance_user = AuthorizationService.create_user_from_sign_in( - {"username": "testuser2", "sub": "open_id"} + { + "username": "testuser2", + "sub": "testuser2", + "iss": "https://test.stuff", + "email": "testuser2", + } ) ProcessInstanceService.complete_form_task( processor, spiff_task, {}, finance_user, human_task ) + + def test_explode_permissions_all_on_process_group( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + ) -> None: + """Test_explode_permissions_all_on_process_group.""" + expected_permissions = [ + ("/logs/some-process-group:some-process-model:*", "create"), + ("/logs/some-process-group:some-process-model:*", "delete"), + ("/logs/some-process-group:some-process-model:*", "read"), + ("/logs/some-process-group:some-process-model:*", "update"), + ("/process-groups/some-process-group:some-process-model:*", "create"), + ("/process-groups/some-process-group:some-process-model:*", "delete"), + ("/process-groups/some-process-group:some-process-model:*", "read"), + ("/process-groups/some-process-group:some-process-model:*", "update"), + ( + "/process-instance-suspend/some-process-group:some-process-model:*", + "create", + ), + ( + "/process-instance-suspend/some-process-group:some-process-model:*", + "delete", + ), + ( + "/process-instance-suspend/some-process-group:some-process-model:*", + "read", + ), + ( + "/process-instance-suspend/some-process-group:some-process-model:*", + "update", + ), + ( + "/process-instance-terminate/some-process-group:some-process-model:*", + "create", + ), + ( + "/process-instance-terminate/some-process-group:some-process-model:*", + "delete", + ), + ( + "/process-instance-terminate/some-process-group:some-process-model:*", + "read", + ), + ( + "/process-instance-terminate/some-process-group:some-process-model:*", + "update", + ), + ("/process-instances/some-process-group:some-process-model:*", "create"), + ("/process-instances/some-process-group:some-process-model:*", "delete"), + ("/process-instances/some-process-group:some-process-model:*", "read"), + ("/process-instances/some-process-group:some-process-model:*", "update"), + ("/process-models/some-process-group:some-process-model:*", "create"), + ("/process-models/some-process-group:some-process-model:*", "delete"), + ("/process-models/some-process-group:some-process-model:*", "read"), + ("/process-models/some-process-group:some-process-model:*", "update"), + ("/task-data/some-process-group:some-process-model:*", "create"), + ("/task-data/some-process-group:some-process-model:*", "delete"), + ("/task-data/some-process-group:some-process-model:*", "read"), + ("/task-data/some-process-group:some-process-model:*", "update"), + ] + permissions_to_assign = AuthorizationService.explode_permissions( + "all", "PG:/some-process-group/some-process-model" + ) + permissions_to_assign_tuples = sorted( + [(p.target_uri, p.permission) for p in permissions_to_assign] + ) + assert permissions_to_assign_tuples == expected_permissions + + def test_explode_permissions_start_on_process_group( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + ) -> None: + """Test_explode_permissions_start_on_process_group.""" + expected_permissions = [ + ( + "/process-instances/for-me/some-process-group:some-process-model:*", + "read", + ), + ("/process-instances/some-process-group:some-process-model:*", "create"), + ] + permissions_to_assign = AuthorizationService.explode_permissions( + "start", "PG:/some-process-group/some-process-model" + ) + permissions_to_assign_tuples = sorted( + [(p.target_uri, p.permission) for p in permissions_to_assign] + ) + assert permissions_to_assign_tuples == expected_permissions + + def test_explode_permissions_all_on_process_model( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + ) -> None: + """Test_explode_permissions_all_on_process_model.""" + expected_permissions = [ + ("/logs/some-process-group:some-process-model/*", "create"), + ("/logs/some-process-group:some-process-model/*", "delete"), + ("/logs/some-process-group:some-process-model/*", "read"), + ("/logs/some-process-group:some-process-model/*", "update"), + ( + "/process-instance-suspend/some-process-group:some-process-model/*", + "create", + ), + ( + "/process-instance-suspend/some-process-group:some-process-model/*", + "delete", + ), + ( + "/process-instance-suspend/some-process-group:some-process-model/*", + "read", + ), + ( + "/process-instance-suspend/some-process-group:some-process-model/*", + "update", + ), + ( + "/process-instance-terminate/some-process-group:some-process-model/*", + "create", + ), + ( + "/process-instance-terminate/some-process-group:some-process-model/*", + "delete", + ), + ( + "/process-instance-terminate/some-process-group:some-process-model/*", + "read", + ), + ( + "/process-instance-terminate/some-process-group:some-process-model/*", + "update", + ), + ("/process-instances/some-process-group:some-process-model/*", "create"), + ("/process-instances/some-process-group:some-process-model/*", "delete"), + ("/process-instances/some-process-group:some-process-model/*", "read"), + ("/process-instances/some-process-group:some-process-model/*", "update"), + ("/process-models/some-process-group:some-process-model/*", "create"), + ("/process-models/some-process-group:some-process-model/*", "delete"), + ("/process-models/some-process-group:some-process-model/*", "read"), + ("/process-models/some-process-group:some-process-model/*", "update"), + ("/task-data/some-process-group:some-process-model/*", "create"), + ("/task-data/some-process-group:some-process-model/*", "delete"), + ("/task-data/some-process-group:some-process-model/*", "read"), + ("/task-data/some-process-group:some-process-model/*", "update"), + ] + permissions_to_assign = AuthorizationService.explode_permissions( + "all", "PM:/some-process-group/some-process-model" + ) + permissions_to_assign_tuples = sorted( + [(p.target_uri, p.permission) for p in permissions_to_assign] + ) + assert permissions_to_assign_tuples == expected_permissions + + def test_explode_permissions_start_on_process_model( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + ) -> None: + """Test_explode_permissions_start_on_process_model.""" + expected_permissions = [ + ( + "/process-instances/for-me/some-process-group:some-process-model/*", + "read", + ), + ("/process-instances/some-process-group:some-process-model/*", "create"), + ] + permissions_to_assign = AuthorizationService.explode_permissions( + "start", "PM:/some-process-group/some-process-model" + ) + permissions_to_assign_tuples = sorted( + [(p.target_uri, p.permission) for p in permissions_to_assign] + ) + assert permissions_to_assign_tuples == expected_permissions + + def test_explode_permissions_basic( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + ) -> None: + """Test_explode_permissions_basic.""" + expected_permissions = [ + ("/process-instances/for-me", "read"), + ("/process-instances/reports/*", "create"), + ("/process-instances/reports/*", "delete"), + ("/process-instances/reports/*", "read"), + ("/process-instances/reports/*", "update"), + ("/processes", "read"), + ("/service-tasks", "read"), + ("/tasks/*", "create"), + ("/tasks/*", "delete"), + ("/tasks/*", "read"), + ("/tasks/*", "update"), + ("/user-groups/for-current-user", "read"), + ] + permissions_to_assign = AuthorizationService.explode_permissions("all", "BASIC") + permissions_to_assign_tuples = sorted( + [(p.target_uri, p.permission) for p in permissions_to_assign] + ) + assert permissions_to_assign_tuples == expected_permissions + + def test_explode_permissions_all( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + ) -> None: + """Test_explode_permissions_all.""" + expected_permissions = [ + ("/*", "create"), + ("/*", "delete"), + ("/*", "read"), + ("/*", "update"), + ] + permissions_to_assign = AuthorizationService.explode_permissions("all", "ALL") + permissions_to_assign_tuples = sorted( + [(p.target_uri, p.permission) for p in permissions_to_assign] + ) + assert permissions_to_assign_tuples == expected_permissions + + def test_explode_permissions_with_target_uri( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + ) -> None: + """Test_explode_permissions_with_target_uri.""" + expected_permissions = [ + ("/hey/model", "create"), + ("/hey/model", "delete"), + ("/hey/model", "read"), + ("/hey/model", "update"), + ] + permissions_to_assign = AuthorizationService.explode_permissions( + "all", "/hey/model" + ) + permissions_to_assign_tuples = sorted( + [(p.target_uri, p.permission) for p in permissions_to_assign] + ) + assert permissions_to_assign_tuples == expected_permissions + + def test_granting_access_to_group_gives_access_to_group_and_subgroups( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + ) -> None: + """Test_granting_access_to_group_gives_access_to_group_and_subgroups.""" + user = self.find_or_create_user(username="user_one") + user_group = GroupService.find_or_create_group("group_one") + UserService.add_user_to_group(user, user_group) + AuthorizationService.add_permission_from_uri_or_macro( + user_group.identifier, "read", "PG:hey" + ) + 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_explode_permissions_with_invalid_target_uri( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + ) -> None: + """Test_explode_permissions_with_invalid_target_uri.""" + with pytest.raises(InvalidPermissionError): + AuthorizationService.explode_permissions("all", "BAD_MACRO") + + def test_explode_permissions_with_start_to_incorrect_target( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + ) -> None: + """Test_explode_permissions_with_start_to_incorrect_target.""" + with pytest.raises(InvalidPermissionError): + AuthorizationService.explode_permissions("start", "/hey/model") + + def test_can_refresh_permissions( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + ) -> None: + """Test_can_refresh_permissions.""" + user = self.find_or_create_user(username="user_one") + admin_user = self.find_or_create_user(username="testadmin1") + + # this group is not mentioned so it will get deleted + GroupService.find_or_create_group("group_two") + assert GroupModel.query.filter_by(identifier="group_two").first() is not None + + group_info = [ + { + "users": ["user_one"], + "name": "group_one", + "permissions": [{"actions": ["create", "read"], "uri": "PG:hey"}], + } + ] + AuthorizationService.refresh_permissions(group_info) + assert GroupModel.query.filter_by(identifier="group_two").first() is None + assert GroupModel.query.filter_by(identifier="group_one").first() is not None + self.assert_user_has_permission(admin_user, "create", "/anything-they-want") + 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") + self.assert_user_has_permission(user, "create", "/v1.0/process-groups/hey:yo") + + group_info = [ + { + "users": ["user_one"], + "name": "group_one", + "permissions": [{"actions": ["read"], "uri": "PG:hey"}], + } + ] + AuthorizationService.refresh_permissions(group_info) + assert GroupModel.query.filter_by(identifier="group_one").first() is not None + 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") + self.assert_user_has_permission( + user, "create", "/v1.0/process-groups/hey:yo", expected_result=False + ) + self.assert_user_has_permission(admin_user, "create", "/anything-they-want") diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_user_service.py b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_user_service.py new file mode 100644 index 000000000..959975d5b --- /dev/null +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_user_service.py @@ -0,0 +1,54 @@ +"""Process Model.""" +from flask.app import Flask +from flask.testing import FlaskClient +from tests.spiffworkflow_backend.helpers.base_test import BaseTest + +from spiffworkflow_backend.models.user_group_assignment_waiting import ( + UserGroupAssignmentWaitingModel, +) +from spiffworkflow_backend.services.group_service import GroupService +from spiffworkflow_backend.services.user_service import UserService + + +class TestUserService(BaseTest): + """TestUserService.""" + + def test_assigning_a_group_to_a_user_before_the_user_is_created( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + ) -> None: + """Test_waiting_group_assignments.""" + a_test_group = GroupService.find_or_create_group("aTestGroup") + UserService.add_waiting_group_assignment("initiator_user", a_test_group) + initiator_user = self.find_or_create_user("initiator_user") + assert initiator_user.groups[0] == a_test_group + + def test_assigning_a_group_to_all_users_updates_new_users( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + ) -> None: + """Test_waiting_group_assignments.""" + everybody_group = GroupService.find_or_create_group("everybodyGroup") + UserService.add_waiting_group_assignment( + UserGroupAssignmentWaitingModel.MATCH_ALL_USERS, everybody_group + ) + initiator_user = self.find_or_create_user("initiator_user") + assert initiator_user.groups[0] == everybody_group + + def test_assigning_a_group_to_all_users_updates_existing_users( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + ) -> None: + """Test_waiting_group_assignments.""" + initiator_user = self.find_or_create_user("initiator_user") + everybody_group = GroupService.find_or_create_group("everybodyGroup") + UserService.add_waiting_group_assignment( + UserGroupAssignmentWaitingModel.MATCH_ALL_USERS, everybody_group + ) + assert initiator_user.groups[0] == everybody_group diff --git a/spiffworkflow-frontend/src/routes/ProcessModelEditDiagram.tsx b/spiffworkflow-frontend/src/routes/ProcessModelEditDiagram.tsx index b0a242ddf..a6fd282f9 100644 --- a/spiffworkflow-frontend/src/routes/ProcessModelEditDiagram.tsx +++ b/spiffworkflow-frontend/src/routes/ProcessModelEditDiagram.tsx @@ -56,6 +56,8 @@ export default function ProcessModelEditDiagram() { const [processSearchEventBus, setProcessSearchEventBus] = useState(null); const [processSearchElement, setProcessSearchElement] = useState(null); const [processes, setProcesses] = useState([]); + const [displaySaveFileMessage, setDisplaySaveFileMessage] = + useState(false); const handleShowMarkdownEditor = () => setShowMarkdownEditor(true); @@ -79,10 +81,10 @@ export default function ProcessModelEditDiagram() { interface ScriptUnitTestResult { result: boolean; - context: object; - error: string; - line_number: number; - offset: number; + context?: object; + error?: string; + line_number?: number; + offset?: number; } const [currentScriptUnitTest, setCurrentScriptUnitTest] = @@ -157,6 +159,7 @@ export default function ProcessModelEditDiagram() { }; const navigateToProcessModelFile = (_result: any) => { + setDisplaySaveFileMessage(true); if (!params.file_name) { const fileNameWithExtension = `${newFileName}.${searchParams.get( 'file_type' @@ -167,9 +170,8 @@ export default function ProcessModelEditDiagram() { } }; - const [displaySaveFileMessage, setDisplaySaveFileMessage] = - useState(false); const saveDiagram = (bpmnXML: any, fileName = params.file_name) => { + setDisplaySaveFileMessage(false); setErrorMessage(null); setBpmnXmlForDiagramRendering(bpmnXML); @@ -204,7 +206,6 @@ export default function ProcessModelEditDiagram() { // after saving the file, make sure we null out newFileName // so it does not get used over the params setNewFileName(''); - setDisplaySaveFileMessage(true); }; const onDeleteFile = (fileName = params.file_name) => { @@ -477,6 +478,21 @@ export default function ProcessModelEditDiagram() { const runCurrentUnitTest = () => { if (currentScriptUnitTest && scriptElement) { + let inputJson = ''; + let expectedJson = ''; + try { + inputJson = JSON.parse(currentScriptUnitTest.inputJson.value); + expectedJson = JSON.parse( + currentScriptUnitTest.expectedOutputJson.value + ); + } catch (e) { + setScriptUnitTestResult({ + result: false, + error: 'The JSON provided contains a formatting error.', + }); + return; + } + resetUnitTextResult(); HttpService.makeCallToBackend({ path: `/process-models/${modifiedProcessModelId}/script-unit-tests/run`, @@ -485,31 +501,29 @@ export default function ProcessModelEditDiagram() { postBody: { bpmn_task_identifier: (scriptElement as any).id, python_script: scriptText, - input_json: JSON.parse(currentScriptUnitTest.inputJson.value), - expected_output_json: JSON.parse( - currentScriptUnitTest.expectedOutputJson.value - ), + input_json: inputJson, + expected_output_json: expectedJson, }, }); } }; const unitTestFailureElement = () => { - if ( - scriptUnitTestResult && - scriptUnitTestResult.result === false && - !scriptUnitTestResult.line_number - ) { - let errorStringElement = null; - if (scriptUnitTestResult.error) { - errorStringElement = ( - - Received error when running script:{' '} - {JSON.stringify(scriptUnitTestResult.error)} - - ); + if (scriptUnitTestResult && scriptUnitTestResult.result === false) { + let errorMessage = ''; + if (scriptUnitTestResult.context) { + errorMessage = 'Unexpected result. Please see the comparison below.'; + } else if (scriptUnitTestResult.line_number) { + errorMessage = `Error encountered running the script. Please check the code around line ${scriptUnitTestResult.line_number}`; + } else { + errorMessage = `Error encountered running the script. ${JSON.stringify( + scriptUnitTestResult.error + )}`; } + let errorStringElement = {errorMessage}; + let errorContextElement = null; + if (scriptUnitTestResult.context) { errorStringElement = ( Unexpected result. Please see the comparison below. @@ -580,16 +594,22 @@ export default function ProcessModelEditDiagram() { ); } - const inputJson = JSON.stringify( - JSON.parse(currentScriptUnitTest.inputJson.value), - null, - ' ' - ); - const outputJson = JSON.stringify( - JSON.parse(currentScriptUnitTest.expectedOutputJson.value), - null, - ' ' - ); + let inputJson = currentScriptUnitTest.inputJson.value; + let outputJson = currentScriptUnitTest.expectedOutputJson.value; + try { + inputJson = JSON.stringify( + JSON.parse(currentScriptUnitTest.inputJson.value), + null, + ' ' + ); + outputJson = JSON.stringify( + JSON.parse(currentScriptUnitTest.expectedOutputJson.value), + null, + ' ' + ); + } catch (e) { + // Attemping to format the json failed -- it's invalid. + } return (