From 92a37b61dedc3855d2e0cb3ae576a8dbce6c4d72 Mon Sep 17 00:00:00 2001 From: jasquat Date: Tue, 15 Nov 2022 14:40:35 -0500 Subject: [PATCH] added permission service to frontend to allow checking for permissions w/ burnettk --- .../config/permissions/development.yml | 12 +++ .../services/authorization_service.py | 10 +- .../unit/test_permissions.py | 26 +++++ spiffworkflow-frontend/package-lock.json | 98 +++++++++++++++++++ spiffworkflow-frontend/package.json | 2 + spiffworkflow-frontend/src/App.tsx | 38 ++++--- .../src/components/PermissionService.tsx | 40 ++++++++ spiffworkflow-frontend/src/contexts/Can.tsx | 6 ++ spiffworkflow-frontend/src/interfaces.ts | 13 +++ .../src/routes/AdminRoutes.tsx | 2 +- .../src/routes/ProcessInstanceLogList.tsx | 1 - .../src/routes/ProcessModelShow.tsx | 72 ++++++++++---- 12 files changed, 285 insertions(+), 35 deletions(-) create mode 100644 spiffworkflow-frontend/src/components/PermissionService.tsx create mode 100644 spiffworkflow-frontend/src/contexts/Can.tsx diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/development.yml b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/development.yml index ccb70abd..d5504b23 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/development.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/development.yml @@ -51,6 +51,18 @@ permissions: allowed_permissions: [create, read, update, delete] uri: /v1.0/tasks/* + process-model-read-all: + groups: [everybody] + users: [] + allowed_permissions: [read] + uri: /v1.0/process-models/* + + process-group-read-all: + groups: [everybody] + users: [] + allowed_permissions: [read] + uri: /v1.0/process-groups/* + # TODO: all uris should really have the same structure finance-admin-group: groups: ["Finance Team"] diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py index 75c17ab8..29ee7884 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py @@ -11,6 +11,7 @@ from flask import request from flask_bpmn.api.api_error import ApiError from flask_bpmn.models.db import db from SpiffWorkflow.task import Task as SpiffTask # type: ignore +from sqlalchemy import or_ from sqlalchemy import text from spiffworkflow_backend.models.active_task import ActiveTaskModel @@ -57,7 +58,14 @@ class AuthorizationService: ) .filter_by(permission=permission) .join(PermissionTargetModel) - .filter(text(f"'{target_uri}' LIKE permission_target.uri")) + .filter( + or_( + text(f"'{target_uri}' 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, '/%', '')"), + ) + ) .all() ) diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_permissions.py b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_permissions.py index 117fd0af..b66f3237 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_permissions.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_permissions.py @@ -163,3 +163,29 @@ class TestPermissions(BaseTest): self.assert_user_has_permission( group_a_admin, "update", f"/{process_group_b_id}" ) + + def test_user_can_access_base_path_when_given_wildcard_permission( + self, app: Flask, with_db_and_bpmn_file_cleanup: None + ) -> None: + """Test_user_can_access_base_path_when_given_wildcard_permission.""" + group_a_admin = self.find_or_create_user() + + permission_target = PermissionTargetModel(uri="/process-models/%") + db.session.add(permission_target) + db.session.commit() + + permission_assignment = PermissionAssignmentModel( + permission_target_id=permission_target.id, + principal_id=group_a_admin.principal.id, + permission="update", + grant_type="permit", + ) + db.session.add(permission_assignment) + db.session.commit() + + self.assert_user_has_permission(group_a_admin, "update", "/process-models/hey") + self.assert_user_has_permission(group_a_admin, "update", "/process-models/") + self.assert_user_has_permission(group_a_admin, "update", "/process-models") + self.assert_user_has_permission( + group_a_admin, "update", "/process-modelshey", expected_result=False + ) diff --git a/spiffworkflow-frontend/package-lock.json b/spiffworkflow-frontend/package-lock.json index e890810f..f21edb5b 100644 --- a/spiffworkflow-frontend/package-lock.json +++ b/spiffworkflow-frontend/package-lock.json @@ -14,6 +14,8 @@ "@carbon/icons-react": "^11.10.0", "@carbon/react": "^1.16.0", "@carbon/styles": "^1.16.0", + "@casl/ability": "^6.3.2", + "@casl/react": "^3.1.0", "@ginkgo-bioworks/react-json-schema-form-builder": "^2.9.0", "@monaco-editor/react": "^4.4.5", "@rjsf/core": "^4.2.0", @@ -2276,6 +2278,26 @@ "@carbon/layout": "^11.7.0" } }, + "node_modules/@casl/ability": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.3.2.tgz", + "integrity": "sha512-ygOlg3WDu39t1ZOVdDfRpPXEiCn7F/a7uLBJIuAE6KksdBogzPszFRAuGULmo4h37fXIyouYUilVIryh0ddTRA==", + "dependencies": { + "@ucast/mongo2js": "^1.3.0" + }, + "funding": { + "url": "https://github.com/stalniy/casl/blob/master/BACKERS.md" + } + }, + "node_modules/@casl/react": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@casl/react/-/react-3.1.0.tgz", + "integrity": "sha512-p4Xmex1Slxz/G0cBtZik+xyOkeOynBUe0UrMFTai6aYkYOb4NyUy3w+9rtnedjcuKijiow2HKJQjnSurLxdc/g==", + "peerDependencies": { + "@casl/ability": "^3.0.0 || ^4.0.0 || ^5.1.0 || ^6.0.0", + "react": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/@codemirror/autocomplete": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.3.0.tgz", @@ -5889,6 +5911,37 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ucast/core": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@ucast/core/-/core-1.10.1.tgz", + "integrity": "sha512-sXKbvQiagjFh2JCpaHUa64P4UdJbOxYeC5xiZFn8y6iYdb0WkismduE+RmiJrIjw/eLDYmIEXiQeIYYowmkcAw==" + }, + "node_modules/@ucast/js": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@ucast/js/-/js-3.0.2.tgz", + "integrity": "sha512-zxNkdIPVvqJjHI7D/iK8Aai1+59yqU+N7bpHFodVmiTN7ukeNiGGpNmmSjQgsUw7eNcEBnPrZHNzp5UBxwmaPw==", + "dependencies": { + "@ucast/core": "^1.0.0" + } + }, + "node_modules/@ucast/mongo": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@ucast/mongo/-/mongo-2.4.2.tgz", + "integrity": "sha512-/zH1TdBJlYGKKD+Wh0oyD+aBvDSWrwHcD8b4tUL9UgHLhzHtkEnMVFuxbw3SRIRsAa01wmy06+LWt+WoZdj1Bw==", + "dependencies": { + "@ucast/core": "^1.4.1" + } + }, + "node_modules/@ucast/mongo2js": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ucast/mongo2js/-/mongo2js-1.3.3.tgz", + "integrity": "sha512-sBPtMUYg+hRnYeVYKL+ATm8FaRPdlU9PijMhGYKgsPGjV9J4Ks41ytIjGayvKUnBOEhiCaKUUnY4qPeifdqATw==", + "dependencies": { + "@ucast/core": "^1.6.1", + "@ucast/js": "^3.0.0", + "@ucast/mongo": "^2.4.0" + } + }, "node_modules/@uiw/copy-to-clipboard": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@uiw/copy-to-clipboard/-/copy-to-clipboard-1.0.12.tgz", @@ -31852,6 +31905,20 @@ "@carbon/layout": "^11.7.0" } }, + "@casl/ability": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.3.2.tgz", + "integrity": "sha512-ygOlg3WDu39t1ZOVdDfRpPXEiCn7F/a7uLBJIuAE6KksdBogzPszFRAuGULmo4h37fXIyouYUilVIryh0ddTRA==", + "requires": { + "@ucast/mongo2js": "^1.3.0" + } + }, + "@casl/react": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@casl/react/-/react-3.1.0.tgz", + "integrity": "sha512-p4Xmex1Slxz/G0cBtZik+xyOkeOynBUe0UrMFTai6aYkYOb4NyUy3w+9rtnedjcuKijiow2HKJQjnSurLxdc/g==", + "requires": {} + }, "@codemirror/autocomplete": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.3.0.tgz", @@ -34534,6 +34601,37 @@ "eslint-visitor-keys": "^3.3.0" } }, + "@ucast/core": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@ucast/core/-/core-1.10.1.tgz", + "integrity": "sha512-sXKbvQiagjFh2JCpaHUa64P4UdJbOxYeC5xiZFn8y6iYdb0WkismduE+RmiJrIjw/eLDYmIEXiQeIYYowmkcAw==" + }, + "@ucast/js": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@ucast/js/-/js-3.0.2.tgz", + "integrity": "sha512-zxNkdIPVvqJjHI7D/iK8Aai1+59yqU+N7bpHFodVmiTN7ukeNiGGpNmmSjQgsUw7eNcEBnPrZHNzp5UBxwmaPw==", + "requires": { + "@ucast/core": "^1.0.0" + } + }, + "@ucast/mongo": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@ucast/mongo/-/mongo-2.4.2.tgz", + "integrity": "sha512-/zH1TdBJlYGKKD+Wh0oyD+aBvDSWrwHcD8b4tUL9UgHLhzHtkEnMVFuxbw3SRIRsAa01wmy06+LWt+WoZdj1Bw==", + "requires": { + "@ucast/core": "^1.4.1" + } + }, + "@ucast/mongo2js": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ucast/mongo2js/-/mongo2js-1.3.3.tgz", + "integrity": "sha512-sBPtMUYg+hRnYeVYKL+ATm8FaRPdlU9PijMhGYKgsPGjV9J4Ks41ytIjGayvKUnBOEhiCaKUUnY4qPeifdqATw==", + "requires": { + "@ucast/core": "^1.6.1", + "@ucast/js": "^3.0.0", + "@ucast/mongo": "^2.4.0" + } + }, "@uiw/copy-to-clipboard": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@uiw/copy-to-clipboard/-/copy-to-clipboard-1.0.12.tgz", diff --git a/spiffworkflow-frontend/package.json b/spiffworkflow-frontend/package.json index 92ba23aa..52fd5d90 100644 --- a/spiffworkflow-frontend/package.json +++ b/spiffworkflow-frontend/package.json @@ -9,6 +9,8 @@ "@carbon/icons-react": "^11.10.0", "@carbon/react": "^1.16.0", "@carbon/styles": "^1.16.0", + "@casl/ability": "^6.3.2", + "@casl/react": "^3.1.0", "@ginkgo-bioworks/react-json-schema-form-builder": "^2.9.0", "@monaco-editor/react": "^4.4.5", "@rjsf/core": "^4.2.0", diff --git a/spiffworkflow-frontend/src/App.tsx b/spiffworkflow-frontend/src/App.tsx index 2d59f0de..68e4b638 100644 --- a/spiffworkflow-frontend/src/App.tsx +++ b/spiffworkflow-frontend/src/App.tsx @@ -3,6 +3,7 @@ import { useMemo, useState } from 'react'; import { Content } from '@carbon/react'; import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { defineAbility } from '@casl/ability'; import ErrorContext from './contexts/ErrorContext'; import NavigationBar from './components/NavigationBar'; @@ -11,6 +12,8 @@ import ErrorBoundary from './components/ErrorBoundary'; import AdminRoutes from './routes/AdminRoutes'; import { ErrorForDisplay } from './interfaces'; +import { AbilityContext } from './contexts/Can'; + export default function App() { const [errorMessage, setErrorMessage] = useState( null @@ -21,6 +24,8 @@ export default function App() { [errorMessage] ); + const ability = defineAbility((can: any) => {}); + let errorTag = null; if (errorMessage) { let sentryLinkTag = null; @@ -46,21 +51,24 @@ export default function App() { return (
- - - - - {errorTag} - - - } /> - } /> - } /> - - - - - + {/* @ts-ignore */} + + + + + + {errorTag} + + + } /> + } /> + } /> + + + + + +
); } diff --git a/spiffworkflow-frontend/src/components/PermissionService.tsx b/spiffworkflow-frontend/src/components/PermissionService.tsx new file mode 100644 index 00000000..400f29da --- /dev/null +++ b/spiffworkflow-frontend/src/components/PermissionService.tsx @@ -0,0 +1,40 @@ +import { AbilityBuilder, Ability } from '@casl/ability'; +import { useContext, useEffect } from 'react'; +import { AbilityContext } from '../contexts/Can'; +import { PermissionCheckResponseBody, PermissionsToCheck } from '../interfaces'; +import HttpService from '../services/HttpService'; + +export const usePermissionFetcher = ( + permissionsToCheck: PermissionsToCheck +) => { + const ability = useContext(AbilityContext); + + useEffect(() => { + const processPermissionResult = (result: PermissionCheckResponseBody) => { + const { can, cannot, rules } = new AbilityBuilder(Ability); + for (const [url, permissionVerbResults] of Object.entries( + result.results + )) { + for (const [permissionVerb, hasPermission] of Object.entries( + permissionVerbResults + )) { + if (hasPermission) { + can(permissionVerb, url); + } else { + cannot(permissionVerb, url); + } + } + } + ability.update(rules); + }; + HttpService.makeCallToBackend({ + path: `/permissions-check`, + httpMethod: 'POST', + successCallback: processPermissionResult, + postBody: { requests_to_check: permissionsToCheck }, + // failureCallback: setErrorMessage, + }); + }); + + return { ability }; +}; diff --git a/spiffworkflow-frontend/src/contexts/Can.tsx b/spiffworkflow-frontend/src/contexts/Can.tsx new file mode 100644 index 00000000..80458fd6 --- /dev/null +++ b/spiffworkflow-frontend/src/contexts/Can.tsx @@ -0,0 +1,6 @@ +import { createContext } from 'react'; +import { AbilityBuilder, Ability } from '@casl/ability'; +import { createContextualCan } from '@casl/react'; + +export const AbilityContext = createContext(new Ability()); +export const Can = createContextualCan(AbilityContext.Consumer); diff --git a/spiffworkflow-frontend/src/interfaces.ts b/spiffworkflow-frontend/src/interfaces.ts index 7c9cb036..6848a3d7 100644 --- a/spiffworkflow-frontend/src/interfaces.ts +++ b/spiffworkflow-frontend/src/interfaces.ts @@ -70,3 +70,16 @@ export interface PaginationObject { export interface CarbonComboBoxSelection { selectedItem: ProcessModel; } + +export interface PermissionsToCheck { + [key: string]: string[]; +} +export interface PermissionCheckResponseBody { + results: PermissionCheckResult; +} +export interface PermissionCheckResult { + [key: string]: PermissionVerbResults; +} +export interface PermissionVerbResults { + [key: string]: boolean; +} diff --git a/spiffworkflow-frontend/src/routes/AdminRoutes.tsx b/spiffworkflow-frontend/src/routes/AdminRoutes.tsx index bd2a64ca..56496823 100644 --- a/spiffworkflow-frontend/src/routes/AdminRoutes.tsx +++ b/spiffworkflow-frontend/src/routes/AdminRoutes.tsx @@ -107,7 +107,7 @@ export default function AdminRoutes() { /> } /> } /> - } /> + } /> ); } diff --git a/spiffworkflow-frontend/src/routes/ProcessInstanceLogList.tsx b/spiffworkflow-frontend/src/routes/ProcessInstanceLogList.tsx index 8bdee861..9f2d65b3 100644 --- a/spiffworkflow-frontend/src/routes/ProcessInstanceLogList.tsx +++ b/spiffworkflow-frontend/src/routes/ProcessInstanceLogList.tsx @@ -74,7 +74,6 @@ export default function ProcessInstanceLogList() { }; if (pagination) { - console.log('params.process_model_id', params.process_model_id); const { page, perPage } = getPageInfoFromSearchParams(searchParams); return (
diff --git a/spiffworkflow-frontend/src/routes/ProcessModelShow.tsx b/spiffworkflow-frontend/src/routes/ProcessModelShow.tsx index 044b787e..d544dedc 100644 --- a/spiffworkflow-frontend/src/routes/ProcessModelShow.tsx +++ b/spiffworkflow-frontend/src/routes/ProcessModelShow.tsx @@ -27,13 +27,20 @@ import { TableBody, // @ts-ignore } from '@carbon/react'; +import { Can } from '@casl/react'; import ProcessBreadcrumb from '../components/ProcessBreadcrumb'; import HttpService from '../services/HttpService'; import ErrorContext from '../contexts/ErrorContext'; import { modifyProcessModelPath } from '../helpers'; -import { ProcessFile, ProcessModel, RecentProcessModel } from '../interfaces'; +import { + PermissionsToCheck, + ProcessFile, + ProcessModel, + RecentProcessModel, +} from '../interfaces'; import ButtonWithConfirmation from '../components/ButtonWithConfirmation'; import ProcessInstanceListTable from '../components/ProcessInstanceListTable'; +import { usePermissionFetcher } from '../components/PermissionService'; const storeRecentProcessModelInLocalStorage = ( processModelForStorage: ProcessModel @@ -96,6 +103,19 @@ export default function ProcessModelShow() { useState(false); const navigate = useNavigate(); + const targetUris = { + processModelPath: `/process-models/${params.process_model_id}`, + processInstancesPath: `/process-instances`, + }; + const permissionRequestData: PermissionsToCheck = { + [`/v1.0${targetUris.processModelPath}`]: ['GET', 'PUT'], + [`/v1.0${targetUris.processInstancesPath}`]: ['GET'], + [`/v1.0${targetUris.processModelPath}${targetUris.processInstancesPath}`]: [ + 'POST', + ], + }; + const { ability } = usePermissionFetcher(permissionRequestData); + const modifiedProcessModelId = modifyProcessModelPath( `${params.process_model_id}` ); @@ -130,7 +150,7 @@ export default function ProcessModelShow() { }); }; - const processInstanceResultTag = () => { + const processInstanceRunResultTag = () => { if (processModel && processInstanceResult) { // FIXME: ensure that the task is actually for the current user as well const processInstanceId = (processInstanceResult as any).id; @@ -491,26 +511,44 @@ export default function ProcessModelShow() {

Process Model: {processModel.display_name}

{processModel.description}

- - + + + + +

- {processInstanceResultTag()} -
- + {processInstanceRunResultTag()}
+ + +
+
{processModelButtons()} );