added permission service to frontend to allow checking for permissions w/ burnettk

This commit is contained in:
jasquat 2022-11-15 14:40:35 -05:00
parent 7444e0a62c
commit b62955deaa
12 changed files with 285 additions and 35 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<ErrorForDisplay | null>(
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 (
<div className="cds--white">
<ErrorContext.Provider value={errorContextValueArray}>
<BrowserRouter>
<NavigationBar />
<Content>
{errorTag}
<ErrorBoundary>
<Routes>
<Route path="/" element={<HomePageRoutes />} />
<Route path="/tasks/*" element={<HomePageRoutes />} />
<Route path="/admin/*" element={<AdminRoutes />} />
</Routes>
</ErrorBoundary>
</Content>
</BrowserRouter>
</ErrorContext.Provider>
{/* @ts-ignore */}
<AbilityContext.Provider value={ability}>
<ErrorContext.Provider value={errorContextValueArray}>
<BrowserRouter>
<NavigationBar />
<Content>
{errorTag}
<ErrorBoundary>
<Routes>
<Route path="/" element={<HomePageRoutes />} />
<Route path="/tasks/*" element={<HomePageRoutes />} />
<Route path="/admin/*" element={<AdminRoutes />} />
</Routes>
</ErrorBoundary>
</Content>
</BrowserRouter>
</ErrorContext.Provider>
</AbilityContext.Provider>
</div>
);
}

View File

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

View File

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

View File

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

View File

@ -107,7 +107,7 @@ export default function AdminRoutes() {
/>
<Route path="process-instances" element={<ProcessInstanceList />} />
<Route path="messages" element={<MessageInstanceList />} />
<Route path="/configuration/*" element={<Configuration />} />
<Route path="configuration/*" element={<Configuration />} />
</Routes>
);
}

View File

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

View File

@ -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<boolean>(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() {
<h1>Process Model: {processModel.display_name}</h1>
<p className="process-description">{processModel.description}</p>
<Stack orientation="horizontal" gap={3}>
<Button onClick={processInstanceCreateAndRun} variant="primary">
Run
</Button>
<Button
href={`/admin/process-models/${modifiedProcessModelId}/edit`}
variant="secondary"
<Can
I="POST"
a={`/v1.0${targetUris.processModelPath}${targetUris.processInstancesPath}`}
ability={ability}
>
Edit process model
</Button>
<Button onClick={processInstanceCreateAndRun} variant="primary">
Run
</Button>
</Can>
<Can
I="PUT"
a={`/v1.0${targetUris.processModelPath}`}
ability={ability}
>
<Button
href={`/admin/process-models/${modifiedProcessModelId}/edit`}
variant="secondary"
>
Edit process model
</Button>
</Can>
</Stack>
<br />
<br />
{processInstanceResultTag()}
<br />
<ProcessInstanceListTable
filtersEnabled={false}
processModelFullIdentifier={processModel.id}
perPageOptions={[2, 5, 25]}
/>
{processInstanceRunResultTag()}
<br />
<Can
I="GET"
a={`/v1.0${targetUris.processInstancesPath}`}
ability={ability}
>
<ProcessInstanceListTable
filtersEnabled={false}
processModelFullIdentifier={processModel.id}
perPageOptions={[2, 5, 25]}
/>
<br />
</Can>
{processModelButtons()}
</>
);