Skip to content

Commit 4b779a0

Browse files
Fixing RBAC feature + parameters (#813)
* fixed race condition, to work on other points in PR * Fixes for complex parameter types in forms * Added special case for handling cross-db label access * handling fixed grants without non-fixed grants * Added error handling to RBAC extension * Added back async modifier * bug fixin grbac --------- Co-authored-by: Alfred Rubin <[email protected]> Co-authored-by: Niels de Jong <[email protected]>
1 parent 2211bb7 commit 4b779a0

File tree

8 files changed

+170
-41
lines changed

8 files changed

+170
-41
lines changed

src/extensions/forms/FormsReportConfig.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export const FORMS = {
4646
label: 'Clear parameters after submit',
4747
type: SELECTION_TYPES.LIST,
4848
values: [true, false],
49-
default: false,
49+
default: true,
5050
},
5151
hasResetButton: {
5252
label: 'Has Reset Button',

src/extensions/forms/chart/NeoForm.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ const NeoForm = (props: ChartProps) => {
2626
const hasResetButton = settings?.hasResetButton ?? true;
2727
const hasSubmitButton = settings?.hasSubmitButton ?? true;
2828
const hasSubmitMessage = settings?.hasSubmitMessage ?? true;
29-
const clearParametersAfterSubmit = settings?.clearParametersAfterSubmit ?? false;
29+
const clearParametersAfterSubmit = settings?.clearParametersAfterSubmit ?? true;
3030
const [submitButtonActive, setSubmitButtonActive] = React.useState(true);
3131
const [status, setStatus] = React.useState(FormStatus.DATA_ENTRY);
3232
const [formResults, setFormResults] = React.useState([]);

src/extensions/forms/settings/NeoFormCardSettingsModal.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import React from 'react';
44
import { Button, Dialog } from '@neo4j-ndl/react';
55
import ParameterSelectCardSettings from '../../../chart/parameter/ParameterSelectCardSettings';
66
import NeoCardSettingsFooter from '../../../card/settings/CardSettingsFooter';
7+
import { objMerge } from '../../../utils/ObjectManipulation';
78

89
const NeoFormCardSettingsModal = ({ open, setOpen, index, formFields, setFormFields, database, extensions }) => {
910
const [advancedSettingsOpen, setAdvancedSettingsOpen] = React.useState(false);
@@ -24,15 +25,19 @@ const NeoFormCardSettingsModal = ({ open, setOpen, index, formFields, setFormFie
2425
query={formFields[index].query}
2526
type={'select'}
2627
database={database}
27-
settings={formFields[index].settings}
28+
settings={objMerge({ inputMode: 'cypher' }, formFields[index].settings)}
2829
extensions={extensions}
2930
onReportSettingUpdate={(key, value) => {
3031
const newFormFields = [...formFields];
3132
newFormFields[index].settings[key] = value;
33+
if (key == 'type') {
34+
newFormFields[index].type = value;
35+
}
3236
setFormFields(newFormFields);
3337
}}
3438
onQueryUpdate={(query) => {
3539
const newFormFields = [...formFields];
40+
3641
newFormFields[index].query = query;
3742
setFormFields(newFormFields);
3843
}}

src/extensions/rbac/RBACManagementMenu.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export const RBACManagementMenu = ({ anchorEl, MenuOpen, handleClose, createNoti
1919
if (!MenuOpen) {
2020
return;
2121
}
22-
const query = `SHOW ROLES YIELD role WHERE role <> "PUBLIC" return role`;
22+
const query = `SHOW PRIVILEGES YIELD role, action WHERE role <> "PUBLIC" RETURN role, 'dbms_actions' in collect(action)`;
2323
runCypherQuery(
2424
driver,
2525
'system',
@@ -32,7 +32,8 @@ export const RBACManagementMenu = ({ anchorEl, MenuOpen, handleClose, createNoti
3232
createNotification('Unable to retrieve roles', records[0].error);
3333
return;
3434
}
35-
setRoles(records.map((record) => record._fields[0]));
35+
// Only display roles which are not able to do 'dbms_actions', i.e. they are not admins.
36+
setRoles(records.filter((r) => r._fields[1] == false).map((record) => record._fields[0]));
3637
}
3738
);
3839
}, [MenuOpen]);
@@ -71,7 +72,7 @@ export const RBACManagementMenu = ({ anchorEl, MenuOpen, handleClose, createNoti
7172
</Menu>
7273

7374
<RBACManagementModal
74-
open={isModalOpen == true}
75+
open={isModalOpen}
7576
handleClose={() => {
7677
setIsModalOpen(false);
7778
}}

src/extensions/rbac/RBACManagementModal.tsx

Lines changed: 86 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ export const RBACManagementModal = ({ open, handleClose, currentRole, createNoti
2626
const [labels, setLabels] = useState([]);
2727
const [allowList, setAllowList] = useState([]);
2828
const [denyList, setDenyList] = useState([]);
29+
const [fixedAllowList, setFixedAllowList] = useState([]);
30+
const [fixedDenyList, setFixedDenyList] = useState([]);
31+
const [denyCompleted, setDenyCompleted] = useState(false);
32+
const [allowCompleted, setAllowCompleted] = useState(false);
33+
const [usersCompleted, setUsersCompleted] = useState(false);
34+
const [failed, setFailed] = useState(false);
2935

3036
useEffect(() => {
3137
if (!open) {
@@ -35,10 +41,23 @@ export const RBACManagementModal = ({ open, handleClose, currentRole, createNoti
3541
setSelectedDatabase('');
3642
return;
3743
}
44+
setDenyCompleted(false);
45+
setAllowCompleted(false);
46+
setUsersCompleted(false);
47+
setFailed(false);
3848
retrieveDatabaseList(driver, setDatabases);
3949
retrieveNeo4jUsers(driver, currentRole, setNeo4jUsers, setSelectedUsers);
4050
}, [open]);
4151

52+
useEffect(() => {
53+
console.log([denyCompleted, allowCompleted, usersCompleted, failed]);
54+
if (failed !== false) {
55+
createNotification('Unable to update privileges', `${failed}`);
56+
} else if (denyCompleted && allowCompleted && usersCompleted) {
57+
createNotification('Success', `Access for role '${currentRole}' updated.`);
58+
}
59+
}, [denyCompleted, allowCompleted, usersCompleted, failed]);
60+
4261
const parseLabelsList = (database, records) => {
4362
const allLabels = records.map((record) => record._fields[0]).filter((l) => l !== '_Neodash_Dashboard');
4463
retrieveAllowAndDenyLists(
@@ -49,6 +68,8 @@ export const RBACManagementModal = ({ open, handleClose, currentRole, createNoti
4968
setLabels,
5069
setAllowList,
5170
setDenyList,
71+
setFixedAllowList,
72+
setFixedDenyList,
5273
setLoaded
5374
);
5475
};
@@ -58,16 +79,49 @@ export const RBACManagementModal = ({ open, handleClose, currentRole, createNoti
5879
retrieveLabelsList(driver, selectedOption.value, (records) => parseLabelsList(selectedOption.value, records));
5980
};
6081

61-
const handleSave = () => {
62-
updateUsers(driver, currentRole, neo4jUsers, selectedUsers);
82+
const handleSave = async () => {
83+
createNotification('Updating', `Access for role '${currentRole}' is being updated, please wait...`);
84+
console.log(selectedUsers);
85+
updateUsers(
86+
driver,
87+
currentRole,
88+
neo4jUsers,
89+
selectedUsers,
90+
() => setUsersCompleted(true),
91+
(failReason) => setFailed(`Operation 'ROLE-USER ASSIGNMENT' failed.\n Reason: ${failReason}`)
92+
);
93+
6394
if (selectedDatabase) {
64-
createNotification('Updating', `Access for role '${currentRole}' is being updated, please wait...`);
65-
updatePrivileges(driver, selectedDatabase, currentRole, labels, denyList, Operation.DENY, createNotification);
66-
updatePrivileges(driver, selectedDatabase, currentRole, labels, allowList, Operation.GRANT, createNotification);
95+
const nonFixedDenyList = denyList.filter((n) => !fixedDenyList.includes(n));
96+
const nonFixedAllowList = allowList.filter((n) => !fixedDenyList.includes(n));
97+
updatePrivileges(
98+
driver,
99+
selectedDatabase,
100+
currentRole,
101+
labels,
102+
nonFixedDenyList,
103+
Operation.DENY,
104+
() => setDenyCompleted(true),
105+
(failReason) => setFailed(`Operation 'DENY LABEL ACCESS' failed.\n Reason: ${failReason}`)
106+
).then(() => {
107+
updatePrivileges(
108+
driver,
109+
selectedDatabase,
110+
currentRole,
111+
labels,
112+
nonFixedAllowList,
113+
Operation.GRANT,
114+
() => setAllowCompleted(true),
115+
(failReason) => setFailed(`Operation 'ALLOW LABEL ACCESS' failed.\n Reason: ${failReason}`)
116+
);
117+
});
67118
} else {
68-
createNotification('Success', `Users have been updated for role '${currentRole}'.`);
119+
// Since there is no database selected, we don't run the DENY/ALLOW queries.
120+
// We just mark them as completed so the success message shows up.
121+
setDenyCompleted(true);
122+
setAllowCompleted(true);
69123
}
70-
124+
console.log([denyCompleted, allowCompleted, usersCompleted, failed]);
71125
handleClose();
72126
};
73127

@@ -138,7 +192,17 @@ export const RBACManagementModal = ({ open, handleClose, currentRole, createNoti
138192
value: allowList.map((nodelabel) => ({ value: nodelabel, label: nodelabel })),
139193
options: labels.map((nodelabel) => ({ value: nodelabel, label: nodelabel })),
140194
isMulti: true,
141-
onChange: (val) => setAllowList(val.map((v) => v.value)),
195+
onChange: (val) => {
196+
// Make sure that only database-specific label access rules can be changed from this UI.
197+
if (fixedAllowList.every((v) => val.map((selected) => selected.value).includes(v))) {
198+
setAllowList(val.map((v) => v.value));
199+
} else {
200+
createNotification(
201+
'Label cannot be removed',
202+
'The selected label is allowed access across all databases. You cannot remove this privilege using this interface.'
203+
);
204+
}
205+
},
142206
}}
143207
/>
144208
</div>
@@ -154,9 +218,21 @@ export const RBACManagementModal = ({ open, handleClose, currentRole, createNoti
154218
placeholder: 'Select labels',
155219
isClearable: false,
156220
value: denyList.map((nodelabel) => ({ value: nodelabel, label: nodelabel })),
157-
options: labels.map((nodelabel) => ({ value: nodelabel, label: nodelabel })),
221+
options: labels
222+
.filter((l) => l !== '*')
223+
.map((nodelabel) => ({ value: nodelabel, label: nodelabel })),
158224
isMulti: true,
159-
onChange: (val) => setDenyList(val.map((v) => v.value)),
225+
onChange: (val) => {
226+
// Make sure that only database-specific label access rules can be changed from this UI.
227+
if (fixedDenyList.every((v) => val.map((selected) => selected.value).includes(v))) {
228+
setDenyList(val.map((v) => v.value));
229+
} else {
230+
createNotification(
231+
'Label cannot be removed',
232+
'The selected label is denied access across all databases. You cannot remove this privilege using this interface.'
233+
);
234+
}
235+
},
160236
}}
161237
/>
162238
</div>

src/extensions/rbac/RBACUtils.ts

Lines changed: 70 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,16 @@ export enum Operation {
1515
* @param newLabels list of new labels in the database, for which priveleges are changed.
1616
* @param operation The operation, either 'GRANT' or 'DENY'
1717
*/
18-
export const updatePrivileges = (
18+
export async function updatePrivileges(
1919
driver,
2020
database,
2121
role,
2222
allLabels,
2323
newLabels,
2424
operation: Operation,
25-
createNotification
26-
) => {
25+
onSuccess,
26+
onFail
27+
) {
2728
// TODO - should we also drop cross-database DENYs (`ON GRAPH *`) to catch the true full set?
2829
// TODO - there
2930
// 1. Special case for '*'. Create it if needed to be there, otherwise revoke it.
@@ -49,7 +50,7 @@ export const updatePrivileges = (
4950
{},
5051
1000,
5152
(status) => {
52-
if (status == QueryStatus.NO_DATA || QueryStatus.COMPLETE) {
53+
if (status == QueryStatus.NO_DATA || status == QueryStatus.COMPLETE) {
5354
// TODO: Neo4j is very slow in updating after the previous query, even though it is technically a finished query.
5455
// We build in an artificial delay...
5556
const timeout = setTimeout(() => {
@@ -67,21 +68,38 @@ export const updatePrivileges = (
6768
),
6869
{},
6970
1000,
70-
() => {
71-
if (status == QueryStatus.NO_DATA || QueryStatus.COMPLETE) {
72-
createNotification('Success', `Access for role '${role}' updated.`);
71+
(status) => {
72+
if (status == QueryStatus.NO_DATA || status == QueryStatus.COMPLETE) {
73+
onSuccess();
74+
}
75+
},
76+
(records) => {
77+
if (records && records[0] && records[0].error) {
78+
onFail(records[0].error);
7379
}
7480
}
7581
);
82+
} else {
83+
onSuccess();
7684
}
7785
}, 1000);
7886
}
87+
},
88+
(records) => {
89+
if (records && records[0] && records[0].error) {
90+
onFail(records[0].error);
91+
}
7992
}
8093
);
8194
}
95+
},
96+
(records) => {
97+
if (records && records[0] && records[0].error) {
98+
onFail(records[0].error);
99+
}
82100
}
83101
);
84-
};
102+
}
85103

86104
/**
87105
* Generic query builder for adding/removing grants/denies for a list of labels.
@@ -120,6 +138,8 @@ export const retrieveAllowAndDenyLists = (
120138
setLabels,
121139
setAllowList,
122140
setDenyList,
141+
setFixedAllowList,
142+
setFixedDenyList,
123143
setLoaded
124144
) => {
125145
runCypherQuery(
@@ -131,7 +151,7 @@ export const retrieveAllowAndDenyLists = (
131151
AND role = $rolename
132152
AND action = 'match'
133153
AND segment STARTS WITH 'NODE('
134-
RETURN access, collect(substring(segment, 5, size(segment)-6)) as nodes`,
154+
RETURN access, collect(substring(segment, 5, size(segment)-6)) as nodes, graph = "*" as fixed`,
135155
{ rolename: currentRole, database: database },
136156
1000,
137157
(status) => {
@@ -142,12 +162,21 @@ export const retrieveAllowAndDenyLists = (
142162
},
143163
(records) => {
144164
// Extract granted and denied label list from the result of the SHOW PRIVILEGES query
145-
const grants = records.filter((r) => r._fields[0] == 'GRANTED');
146-
const denies = records.filter((r) => r._fields[0] == 'DENIED');
165+
const grants = records.filter((r) => r._fields[0] == 'GRANTED' && r._fields[2] == false);
166+
const denies = records.filter((r) => r._fields[0] == 'DENIED' && r._fields[2] == false);
147167
const grantedLabels = grants[0] ? [...new Set(grants[0]._fields[1])] : [];
148168
const deniedLabels = denies[0] ? [...new Set(denies[0]._fields[1])] : [];
149-
setAllowList(grantedLabels);
150-
setDenyList(deniedLabels);
169+
170+
// Do the same for fixed grants (those stored under the '*' graph permission)
171+
const fixedGrants = records.filter((r) => r._fields[0] == 'GRANTED' && r._fields[2] == true);
172+
const fixedDenies = records.filter((r) => r._fields[0] == 'DENIED' && r._fields[2] == true);
173+
const fixedGrantedLabels = fixedGrants[0] ? [...new Set(fixedGrants[0]._fields[1])] : [];
174+
const fixedDeniedLabels = fixedDenies[0] ? [...new Set(fixedDenies[0]._fields[1])] : [];
175+
176+
setAllowList([...new Set(grantedLabels.concat(fixedGrantedLabels))]);
177+
setDenyList([...new Set(deniedLabels.concat(fixedDeniedLabels))]);
178+
setFixedAllowList(fixedGrantedLabels);
179+
setFixedDenyList(fixedDeniedLabels);
151180

152181
// Here we build a set of all POSSIBLE labels, that includes the list in the database, plus those in denies and grants.
153182
const possibleLabels = [...new Set(allLabels.concat(grantedLabels).concat(deniedLabels))];
@@ -226,25 +255,42 @@ export function retrieveDatabaseList(driver, setDatabases: React.Dispatch<React.
226255
* @param allUsers list of all users.
227256
* @param selectedUsers list of users to have the role after the operation completes.
228257
*/
229-
export const updateUsers = async (driver, currentRole, allUsers, selectedUsers) => {
258+
export async function updateUsers(driver, currentRole, allUsers, selectedUsers, onSuccess, onFail) {
230259
// 1. Build the query that removes all users from the role.
260+
let globalStatus = -1;
231261
await runCypherQuery(
232262
driver,
233263
'system',
234264
`REVOKE ROLE ${currentRole} FROM ${allUsers.join(',')}`,
235265
{},
236266
1000,
237267
(status) => {
238-
if (status == QueryStatus.NO_DATA || QueryStatus.COMPLETE) {
239-
// TODO: Neo4j is very slow in updating after the previous query, even though it is technically a finished query.
240-
// We build in an artificial delay...
241-
const timeout = setTimeout(() => {
242-
// 2. Re-assign only selected users to the role.
243-
if (selectedUsers.length > 0) {
244-
runCypherQuery(driver, 'system', `GRANT ROLE ${currentRole} TO ${selectedUsers.join(',')}`);
245-
}
246-
}, 1000);
268+
globalStatus = status;
269+
},
270+
(records) => {
271+
if (records && records[0] && records[0].error) {
272+
onFail(records[0].error);
247273
}
248274
}
249275
);
250-
};
276+
if (globalStatus == QueryStatus.NO_DATA || globalStatus == QueryStatus.COMPLETE) {
277+
// TODO: Neo4j is very slow in updating after the previous query, even though it is technically a finished query.
278+
// We build in an artificial delay...
279+
if (selectedUsers.length > 0) {
280+
await runCypherQuery(
281+
driver,
282+
'system',
283+
`GRANT ROLE ${currentRole} TO ${selectedUsers.join(',')}`,
284+
{},
285+
1000,
286+
(status) => {
287+
if (status == QueryStatus.NO_DATA || QueryStatus.COMPLETE) {
288+
onSuccess();
289+
}
290+
}
291+
);
292+
} else {
293+
onSuccess();
294+
}
295+
}
296+
}

src/modal/ExportModal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export const NeoExportModal = ({ dashboard }) => {
1414
return (
1515
<>
1616
<Tooltip title='Export' aria-label='export' disableInteractive>
17-
<IconButton className='n-mx-1' onClick={() => setOpen(true)} aria-label='Export' title='Export'>
17+
<IconButton className='n-mx-1' onClick={() => setOpen(true)} aria-label='Export'>
1818
<DocumentArrowDownIconOutline />
1919
</IconButton>
2020
</Tooltip>

0 commit comments

Comments
 (0)