From 20d1bd0a3194192fdbe7e513a8e7133078814620 Mon Sep 17 00:00:00 2001 From: Sherfin Shamsudeen Date: Thu, 7 Oct 2021 14:05:36 +0530 Subject: [PATCH 01/88] Bugfix/stop propagation when table selector is clicked (#926) * Stop propagation when table selector checkbox is clicked * Reposition onClick handler of table selector checkbox --- frontend/src/Editor/Components/Table/Table.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/Editor/Components/Table/Table.jsx b/frontend/src/Editor/Components/Table/Table.jsx index 92156c9917..731c0c758c 100644 --- a/frontend/src/Editor/Components/Table/Table.jsx +++ b/frontend/src/Editor/Components/Table/Table.jsx @@ -630,6 +630,7 @@ export function Table({ marginTop: 8, marginLeft: 10, }} + onClick={(event) => event.stopPropagation()} {...rest} /> From 93911d0538e39f9fccf926632bd849380469f145 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dainius=20Luk=C5=A1a?= Date: Thu, 7 Oct 2021 19:24:59 +0300 Subject: [PATCH 02/88] Display no versions message (#933) --- frontend/src/Editor/SaveAndPreview.jsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/Editor/SaveAndPreview.jsx b/frontend/src/Editor/SaveAndPreview.jsx index 732cdf81b5..1ff2288e02 100644 --- a/frontend/src/Editor/SaveAndPreview.jsx +++ b/frontend/src/Editor/SaveAndPreview.jsx @@ -151,6 +151,11 @@ class SaveAndPreview extends React.Component { ) : (
+ {!versions?.length && !showVersionForm && !isLoading && ( +
+ No versions yet. +
+ )} {versions.map((version) => ( From 1ffe7442dc10c09cd3ac91575d45dee0c9241b7a Mon Sep 17 00:00:00 2001 From: Joshua T Date: Thu, 7 Oct 2021 21:58:09 +0530 Subject: [PATCH 03/88] Make Manage Organization Users link more prominent (#931) --- frontend/src/Editor/ManageAppUsers.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/Editor/ManageAppUsers.jsx b/frontend/src/Editor/ManageAppUsers.jsx index 2c7308835b..69219ccd13 100644 --- a/frontend/src/Editor/ManageAppUsers.jsx +++ b/frontend/src/Editor/ManageAppUsers.jsx @@ -297,7 +297,7 @@ class ManageAppUsers extends React.Component { - + Manage Organization Users From f0cd921c06c6fdf5b6b6d9f2dc7364b749021d3f Mon Sep 17 00:00:00 2001 From: Arpit Date: Thu, 7 Oct 2021 22:04:45 +0530 Subject: [PATCH 04/88] Feature: alert event toast color (#928) --- .../src/Editor/Inspector/EventManager.jsx | 56 ++++++++++++++++--- frontend/src/_helpers/appUtils.js | 2 +- 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/frontend/src/Editor/Inspector/EventManager.jsx b/frontend/src/Editor/Inspector/EventManager.jsx index 9e736426cb..d41bed3e4c 100644 --- a/frontend/src/Editor/Inspector/EventManager.jsx +++ b/frontend/src/Editor/Inspector/EventManager.jsx @@ -24,6 +24,29 @@ export const EventManager = ({ return { name: action.name, value: action.id }; }); + let alertTypes = [ + { + name: 'Info', + id: 'info', + }, + { + name: 'Success', + id: 'success', + }, + { + name: 'Warning', + id: 'warning', + }, + { + name: 'Danger', + id: 'error', + }, + ]; + + let alertOptions = alertTypes.map((alert) => { + return { name: alert.name, value: alert.id }; + }); + excludeEvents = excludeEvents || []; /* Filter events based on excludesEvents ( a list of event ids to exclude ) */ @@ -128,16 +151,31 @@ export const EventManager = ({
Action options
{event.actionId === 'show-alert' && ( -
-
Message
-
- handlerChanged(index, 'message', value)} - /> + <> +
+
Message
+
+ handlerChanged(index, 'message', value)} + /> +
-
+
+
Alert Type
+
+ handlerChanged(index, 'alertType', value)} + filterOptions={fuzzySearch} + placeholder="Select.." + /> +
+
+ )} {event.actionId === 'open-webpage' && ( diff --git a/frontend/src/_helpers/appUtils.js b/frontend/src/_helpers/appUtils.js index b2a86b028a..f1d0928311 100644 --- a/frontend/src/_helpers/appUtils.js +++ b/frontend/src/_helpers/appUtils.js @@ -125,7 +125,7 @@ function executeAction(_ref, event, mode) { switch (event.actionId) { case 'show-alert': { const message = resolveReferences(event.message, _ref.state.currentState); - toast(message, { hideProgressBar: true }); + toast(message, { hideProgressBar: true, type: event.alertType }); return new Promise(function (resolve, reject) { resolve(); }); From 7eff50db6c0d70dffe97d3bf6bac535a83e6fba7 Mon Sep 17 00:00:00 2001 From: Pockerman20 <90500919+Pockerman20@users.noreply.github.com> Date: Fri, 8 Oct 2021 00:07:23 +0530 Subject: [PATCH 05/88] Changed Editing version -> App version in Editor.jsx (#939) --- frontend/src/Editor/Editor.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/Editor/Editor.jsx b/frontend/src/Editor/Editor.jsx index 4a1438f0c6..b224fa83cb 100644 --- a/frontend/src/Editor/Editor.jsx +++ b/frontend/src/Editor/Editor.jsx @@ -575,7 +575,7 @@ class Editor extends React.Component { value={this.state.app.name} /> )} - {this.state.editingVersion && `Editing version: ${this.state.editingVersion.name}`} + {this.state.editingVersion && `App version: ${this.state.editingVersion.name}`}
Date: Fri, 8 Oct 2021 11:32:20 +0530 Subject: [PATCH 06/88] Button and Modal text fix (#861) * button text fix * Modal dynamic text fix * resolve the review comments --- frontend/src/Editor/Components/Button.jsx | 4 +--- frontend/src/Editor/Components/Modal.jsx | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/frontend/src/Editor/Components/Button.jsx b/frontend/src/Editor/Components/Button.jsx index 5ef061188d..cfcec96817 100644 --- a/frontend/src/Editor/Components/Button.jsx +++ b/frontend/src/Editor/Components/Button.jsx @@ -3,8 +3,6 @@ import { resolveReferences, resolveWidgetFieldValue } from '@/_helpers/utils'; var tinycolor = require('tinycolor2'); export const Button = function Button({ id, width, height, component, onComponentClick, currentState }) { - console.log('currentState', currentState); - const [loadingState, setLoadingState] = useState(false); useEffect(() => { @@ -51,7 +49,7 @@ export const Button = function Button({ id, width, height, component, onComponen onComponentClick(id, component); }} > - {text} + {resolveReferences(text, currentState)} ); }; diff --git a/frontend/src/Editor/Components/Modal.jsx b/frontend/src/Editor/Components/Modal.jsx index bca2245599..64969d6df3 100644 --- a/frontend/src/Editor/Components/Modal.jsx +++ b/frontend/src/Editor/Components/Modal.jsx @@ -4,7 +4,7 @@ import Button from 'react-bootstrap/Button'; import { SubCustomDragLayer } from '../SubCustomDragLayer'; import { SubContainer } from '../SubContainer'; import { ConfigHandle } from '../ConfigHandle'; -import { resolveWidgetFieldValue } from '../../_helpers/utils'; +import { resolveWidgetFieldValue } from '@/_helpers/utils'; export const Modal = function Modal({ id, component, height, containerProps, currentState, darkMode }) { const [show, showModal] = useState(false); @@ -50,7 +50,7 @@ export const Modal = function Modal({ id, component, height, containerProps, cur )} - {title} + {resolveWidgetFieldValue(title, currentState)}
{versions.map((version) => ( diff --git a/frontend/src/Editor/SubContainer.jsx b/frontend/src/Editor/SubContainer.jsx index dc725e58cb..8897f60a2d 100644 --- a/frontend/src/Editor/SubContainer.jsx +++ b/frontend/src/Editor/SubContainer.jsx @@ -306,8 +306,11 @@ export const SubContainer = ({ {Object.keys(boxes).length === 0 && !appLoading && !isDragging && (
- Drag components from the right sidebar and drop here. - Check out our guide on adding widgets. + Drag components from the right sidebar and drop here. Check out our{' '} + + guide + {' '} + on adding widgets.
)} diff --git a/frontend/src/HomePage/HomePage.jsx b/frontend/src/HomePage/HomePage.jsx index b205f85bf1..8739df7d5a 100644 --- a/frontend/src/HomePage/HomePage.jsx +++ b/frontend/src/HomePage/HomePage.jsx @@ -81,10 +81,15 @@ class HomePage extends React.Component { createApp = () => { let _self = this; _self.setState({ creatingApp: true }); - appService.createApp().then((data) => { - console.log(data); - _self.props.history.push(`/apps/${data.id}`); - }); + appService + .createApp() + .then((data) => { + console.log(data); + _self.props.history.push(`/apps/${data.id}`); + }) + .catch(({ error }) => { + toast.error(error, { hideProgressBar: true, position: 'top-center' }); + }); }; deleteApp = (app) => { @@ -104,12 +109,23 @@ class HomePage extends React.Component { this.props.history.push(`/apps/${data.id}`); }) .catch(({ _error }) => { - toast.error('Could not clone the app.', { hideProgressBar: true, position: 'top-center' }); + toast.error('Could not clone the app.', { + hideProgressBar: true, + position: 'top-center', + }); this.setState({ isCloningApp: false }); console.log(_error); }); }; + isAppEditable = (app) => { + return app.app_group_permissions.some((p) => p.update); + }; + + isAppDeletable = (app) => { + return app.app_group_permissions.some((p) => p.delete); + }; + executeAppDeletion = () => { this.setState({ isDeletingApp: true }); appService @@ -129,7 +145,10 @@ class HomePage extends React.Component { this.fetchFolders(); }) .catch(({ error }) => { - toast.error('Could not delete the app.', { hideProgressBar: true, position: 'top-center' }); + toast.error('Could not delete the app.', { + hideProgressBar: true, + position: 'top-center', + }); this.setState({ isDeletingApp: false, appToBeDeleted: null, @@ -144,16 +163,8 @@ class HomePage extends React.Component { }; render() { - const { - apps, - currentUser, - isLoading, - creatingApp, - meta, - currentFolder, - showAppDeletionConfirmation, - isDeletingApp, - } = this.state; + const { apps, isLoading, creatingApp, meta, currentFolder, showAppDeletionConfirmation, isDeletingApp } = + this.state; return (
))} diff --git a/frontend/src/ManageGroupPermissionResources/ManageGroupPermissionResources.jsx b/frontend/src/ManageGroupPermissionResources/ManageGroupPermissionResources.jsx new file mode 100644 index 0000000000..5c125a8aa7 --- /dev/null +++ b/frontend/src/ManageGroupPermissionResources/ManageGroupPermissionResources.jsx @@ -0,0 +1,522 @@ +import React from 'react'; +import SelectSearch, { fuzzySearch } from 'react-select-search'; +import { groupPermissionService } from '../_services/groupPermission.service'; +import 'react-toastify/dist/ReactToastify.css'; +import { Header } from '@/_components'; +import { toast } from 'react-toastify'; +import { Link } from 'react-router-dom'; + +class ManageGroupPermissionResources extends React.Component { + constructor(props) { + super(props); + + this.state = { + isLoadingGroup: true, + isLoadingApps: true, + isAddingApps: false, + isLoadingUsers: true, + isAddingUsers: false, + groupPermission: null, + usersInGroup: [], + appsInGroup: [], + usersNotInGroup: [], + appsNotInGroup: [], + selectedAppIds: [], + selectedUserIds: [], + removeAppIds: [], + currentTab: 'apps', + }; + } + + componentDidMount() { + const groupPermissionId = this.props.match.params.id; + + this.fetchGroupAndResources(groupPermissionId); + } + + humanizeIfDefaultGroupName = (groupName) => { + switch (groupName) { + case 'all_users': + return 'All Users'; + case 'admin': + return 'Admin'; + default: + return groupName; + } + }; + + fetchGroupAndResources = (groupPermissionId) => { + groupPermissionService.getGroup(groupPermissionId).then((data) => { + this.setState({ + groupPermission: data, + isLoadingGroup: false, + }); + + this.fetchUsersNotInGroup(groupPermissionId); + this.fetchUsersInGroup(groupPermissionId); + + this.fetchAppsNotInGroup(groupPermissionId); + this.fetchAppsInGroup(groupPermissionId); + }); + }; + + fetchUsersNotInGroup = (groupPermissionId) => { + groupPermissionService.getUsersNotInGroup(groupPermissionId).then((data) => { + this.setState({ + usersNotInGroup: data.users, + }); + }); + }; + + fetchUsersInGroup = (groupPermissionId) => { + groupPermissionService.getUsersInGroup(groupPermissionId).then((data) => { + this.setState({ + usersInGroup: data.users, + isLoadingUsers: false, + }); + }); + }; + + fetchAppsNotInGroup = (groupPermissionId) => { + groupPermissionService.getAppsNotInGroup(groupPermissionId).then((data) => { + this.setState({ + appsNotInGroup: data.apps, + }); + }); + }; + + fetchAppsInGroup = (groupPermissionId) => { + groupPermissionService.getAppsInGroup(groupPermissionId).then((data) => { + this.setState({ + appsInGroup: data.apps, + isLoadingApps: false, + }); + }); + }; + + updateAppGroupPermission = (app, groupPermissionId, action) => { + const appGroupPermission = app.app_group_permissions.find( + (permission) => permission.group_permission_id == groupPermissionId + ); + + let actionParams = { read: true, update: action == 'edit' }; + + groupPermissionService + .updateAppGroupPermission(groupPermissionId, appGroupPermission.id, actionParams) + .then(() => { + toast.success('App permissions updated', { + hideProgressBar: true, + position: 'top-center', + }); + + this.fetchAppsInGroup(groupPermissionId); + }) + .catch(({ error }) => { + toast.error(error, { hideProgressBar: true, position: 'top-center' }); + }); + }; + + canAppGroupPermission = (app, groupPermissionId, action) => { + let appGroupPermission; + switch (action) { + case 'edit': + appGroupPermission = this.findAppGroupPermission(app, groupPermissionId); + return appGroupPermission['read'] && appGroupPermission['update']; + case 'view': + appGroupPermission = this.findAppGroupPermission(app, groupPermissionId); + + return appGroupPermission['read'] && !appGroupPermission['update']; + default: + return false; + } + }; + + findAppGroupPermission = (app, groupPermissionId) => { + const appGroupPermission = app.app_group_permissions.find( + (permission) => permission.group_permission_id == groupPermissionId + ); + + return appGroupPermission; + }; + + setSelectedUsers = (value) => { + this.setState({ + selectedUserIds: value, + }); + }; + + setSelectedApps = (value) => { + this.setState({ + selectedAppIds: value, + }); + }; + + addSelectedAppsToGroup = (groupPermissionId, selectedAppIds) => { + this.setState({ isAddingApps: true }); + const updateParams = { + selectedAppIds, + }; + groupPermissionService + .update(groupPermissionId, updateParams) + .then(() => { + this.setState({ + selectedAppIds: [], + isLoadingApps: true, + isAddingApps: false, + }); + this.fetchAppsNotInGroup(groupPermissionId); + this.fetchAppsInGroup(groupPermissionId); + }) + .then(() => { + toast.success('Apps added to the group', { + hideProgressBar: true, + position: 'top-center', + }); + }) + .catch(({ error }) => { + toast.error(error, { hideProgressBar: true, position: 'top-center' }); + }); + }; + + removeAppFromGroup = (groupPermissionId, appId) => { + const updateParams = { + removeAppIds: [appId], + }; + groupPermissionService + .update(groupPermissionId, updateParams) + .then(() => { + this.setState({ removeAppIds: [], isLoadingApps: true }); + this.fetchAppsNotInGroup(groupPermissionId); + this.fetchAppsInGroup(groupPermissionId); + }) + .then(() => { + toast.success('Apps removed from the group', { + hideProgressBar: true, + position: 'top-center', + }); + }) + .catch(({ error }) => { + toast.error(error, { hideProgressBar: true, position: 'top-center' }); + }); + }; + + addSelectedUsersToGroup = (groupPermissionId, selectedUserIds) => { + this.setState({ isAddingUsers: true }); + const updateParams = { + selectedUserIds, + }; + groupPermissionService + .update(groupPermissionId, updateParams) + .then(() => { + this.setState({ + selectedUserIds: [], + isLoadingUsers: true, + isAddingUsers: false, + }); + this.fetchUsersNotInGroup(groupPermissionId); + this.fetchUsersInGroup(groupPermissionId); + }) + .then(() => { + toast.success('Users added to the group', { + hideProgressBar: true, + position: 'top-center', + }); + }) + .catch(({ error }) => { + toast.error(error, { hideProgressBar: true, position: 'top-center' }); + }); + }; + + removeUserFromGroup = (groupPermissionId, userId) => { + const updateParams = { + removeUserIds: [userId], + }; + groupPermissionService + .update(groupPermissionId, updateParams) + .then(() => { + this.setState({ removeUserIds: [], isLoadingUsers: true }); + this.fetchUsersNotInGroup(groupPermissionId); + this.fetchUsersInGroup(groupPermissionId); + }) + .then(() => { + toast.success('Users removed from the group', { + hideProgressBar: true, + position: 'top-center', + }); + }) + .catch(({ error }) => { + toast.error(error, { hideProgressBar: true, position: 'top-center' }); + }); + }; + + render() { + const { + isLoadingGroup, + isLoadingApps, + isAddingApps, + isLoadingUsers, + isAddingUsers, + appsInGroup, + appsNotInGroup, + usersInGroup, + usersNotInGroup, + groupPermission, + currentTab, + selectedAppIds, + selectedUserIds, + } = this.state; + + const appSelectOptions = appsNotInGroup.map((app) => { + return { name: app.name, value: app.id }; + }); + const userSelectOptions = usersNotInGroup.map((user) => { + return { name: `${user.first_name} ${user.last_name}`, value: user.id }; + }); + + return ( +
+
+ +
+
+
+
+
+
+ {isLoadingGroup ? ( +
    +
  1. + User groups +
  2. +
+ ) : ( +
    +
  1. + User groups +
  2. +
  3. + {this.humanizeIfDefaultGroupName(groupPermission.group)} +
  4. +
+ )} +
+
+
+
+ +
+
+
+ +
+
+
+
+
+ this.setSelectedApps(value)} + printOptions="on-focus" + placeholder="Select apps to add to the group" + /> +
+
+
this.addSelectedAppsToGroup(groupPermission.id, selectedAppIds)} + > + Add +
+
+
+
+
+
+
- {currentUser.role !== 'viewer' && ( + {!isLoading && this.isAppEditable(app) && ( renderTooltip({ props, text: 'Open in app builder' })} + overlay={(props) => + renderTooltip({ + props, + text: 'Open in app builder', + }) + } > Edit @@ -320,13 +336,15 @@ class HomePage extends React.Component { )} - this.deleteApp(app)} - cloneApp={() => this.cloneApp(app)} - /> + {this.isAppDeletable(app) && ( + this.deleteApp(app)} + cloneApp={() => this.cloneApp(app)} + /> + )}
+ + + + + + + + + {isLoadingApps ? ( + + + + + + ) : ( + appsInGroup.map((app) => ( + + + + + + )) + )} + +
NamePermissions
+
+
+
+
+
+
+
+
{app.name} +
+ + +
+
+ {groupPermission.group != 'admin' && ( + { + this.removeAppFromGroup(groupPermission.id, app.id); + }} + > + Delete + + )} +
+
+ + +
+
+
+ this.setSelectedUsers(value)} + printOptions="on-focus" + placeholder="Select users to add to the group" + /> +
+
+
this.addSelectedUsersToGroup(groupPermission.id, selectedUserIds)} + > + Add +
+
+
+
+
+
+ + + + + + + + + + {isLoadingUsers ? ( + + + + + + ) : ( + usersInGroup.map((user) => ( + + + + + + )) + )} + +
NameEmail
+
+
+
+
+
+
+
+
{`${user.first_name} ${user.last_name}`}{user.email} + {groupPermission.group != 'all_users' && ( + { + this.removeUserFromGroup(groupPermission.id, user.id); + }} + > + Delete + + )} +
+
+
+
+ + + + + + + + ); + } +} + +export { ManageGroupPermissionResources }; diff --git a/frontend/src/ManageGroupPermissionResources/index.js b/frontend/src/ManageGroupPermissionResources/index.js new file mode 100644 index 0000000000..9626850461 --- /dev/null +++ b/frontend/src/ManageGroupPermissionResources/index.js @@ -0,0 +1 @@ +export * from './ManageGroupPermissionResources'; diff --git a/frontend/src/ManageGroupPermissions/ManageGroupPermissions.jsx b/frontend/src/ManageGroupPermissions/ManageGroupPermissions.jsx new file mode 100644 index 0000000000..aad141df55 --- /dev/null +++ b/frontend/src/ManageGroupPermissions/ManageGroupPermissions.jsx @@ -0,0 +1,239 @@ +import React from 'react'; +import { authenticationService } from '@/_services'; +import { groupPermissionService } from '../_services/groupPermission.service'; +import 'react-toastify/dist/ReactToastify.css'; +import { Header } from '@/_components'; +import { toast } from 'react-toastify'; +import { Link } from 'react-router-dom'; + +class ManageGroupPermissions extends React.Component { + constructor(props) { + super(props); + + this.state = { + currentUser: authenticationService.currentUserValue, + isLoading: true, + groups: [], + creatingGroup: false, + showNewGroupForm: false, + newGroupName: null, + }; + } + + componentDidMount() { + this.fetchGroups(); + } + + fetchGroups = () => { + this.setState({ + isLoading: true, + }); + + groupPermissionService.getGroups().then((data) => { + this.setState({ + groups: data.group_permissions, + isLoading: false, + }); + }); + }; + + changeNewGroupName = (value) => { + this.setState({ + newGroupName: value, + }); + }; + + humanizeifDefaultGroupName = (groupName) => { + switch (groupName) { + case 'all_users': + return 'All Users'; + case 'admin': + return 'Admin'; + default: + return groupName; + } + }; + + createGroup = () => { + this.setState({ creatingGroup: true }); + groupPermissionService + .create(this.state.newGroupName) + .then(() => { + this.setState({ + creatingGroup: false, + showNewGroupForm: false, + newGroup: null, + }); + toast.success('Group has been created', { + hideProgressBar: true, + position: 'top-center', + }); + this.fetchGroups(); + }) + .catch(({ error }) => { + toast.error(error, { hideProgressBar: true, position: 'top-center' }); + this.setState({ + creatingGroup: false, + showNewGroupForm: true, + newGroup: {}, + }); + }); + }; + + deleteGroup = (groupPermissionId) => { + groupPermissionService + .del(groupPermissionId) + .then(() => { + toast.success('Group has been deleted', { + hideProgressBar: true, + position: 'top-center', + }); + this.fetchGroups(); + }) + .catch(({ error }) => { + toast.error(error, { hideProgressBar: true, position: 'top-center' }); + }); + }; + + render() { + const { isLoading, showNewGroupForm, creatingGroup, groups } = this.state; + return ( +
+
+ +
+
+
+
+
+
+
    +
  1. + User groups +
  2. +
+
+
+ {!showNewGroupForm && ( +
this.setState({ showNewGroupForm: true })}> + Create new group +
+ )} +
+
+
+
+ +
+ {showNewGroupForm && ( +
+
+
+

Add new group

+
+
+
+
+
+
+ { + this.changeNewGroupName(e.target.value); + }} + /> +
+
+
+
+ + +
+
+
+
+
+ )} + {!showNewGroupForm && ( +
+
+
+ + + + + + + + + {isLoading ? ( + + {Array.from(Array(2)).map((index) => ( + + + + + + ))} + + ) : ( + + {groups.map((permissionGroup) => ( + + + + + ))} + + )} +
Name
+
+
+
+
+
+
+
+
+ + {this.humanizeifDefaultGroupName(permissionGroup.group)} + + + {permissionGroup.group != 'admin' && permissionGroup.group != 'all_users' && ( + this.deleteGroup(permissionGroup.id)}>Delete + )} +
+
+
+
+ )} +
+
+
+ ); + } +} + +export { ManageGroupPermissions }; diff --git a/frontend/src/ManageGroupPermissions/index.js b/frontend/src/ManageGroupPermissions/index.js new file mode 100644 index 0000000000..e6cbcec332 --- /dev/null +++ b/frontend/src/ManageGroupPermissions/index.js @@ -0,0 +1 @@ +export * from './ManageGroupPermissions'; diff --git a/frontend/src/ManageOrgUsers/ManageOrgUsers.jsx b/frontend/src/ManageOrgUsers/ManageOrgUsers.jsx index 9eb28960bb..d5ed4c99c2 100644 --- a/frontend/src/ManageOrgUsers/ManageOrgUsers.jsx +++ b/frontend/src/ManageOrgUsers/ManageOrgUsers.jsx @@ -2,7 +2,6 @@ import React from 'react'; import { authenticationService, organizationService, organizationUserService } from '@/_services'; import 'react-toastify/dist/ReactToastify.css'; import { Header } from '@/_components'; -import SelectSearch, { fuzzySearch } from 'react-select-search'; import { toast } from 'react-toastify'; import { history } from '@/_helpers'; import { CopyToClipboard } from 'react-copy-to-clipboard'; @@ -17,8 +16,6 @@ class ManageOrgUsers extends React.Component { showNewUserForm: false, creatingUser: false, newUser: {}, - role: '', - idChangingRole: null, archivingUser: null, fields: {}, errors: {}, @@ -27,9 +24,10 @@ class ManageOrgUsers extends React.Component { validateEmail(email) { console.log(email); - const re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + const re = + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; return re.test(String(email).toLowerCase()); - } + } handleValidation() { let fields = this.state.fields; @@ -53,14 +51,11 @@ class ManageOrgUsers extends React.Component { if (!fields['email']) { errors['email'] = 'This field is required'; } else if (!this.validateEmail(fields['email'])) { - errors['email'] = 'Email is not valid'; + errors['email'] = 'Email is not valid'; } - if (!fields['role']) { - errors['role'] = 'This field is required'; - } - + this.setState({ errors: errors }); - return Object.keys(errors).length === 0; + return Object.keys(errors).length === 0; } componentDidMount() { @@ -89,20 +84,6 @@ class ManageOrgUsers extends React.Component { }); }; - changeNewUserRole = (id, role) => { - this.setState({ idChangingRole: id }); - organizationUserService - .changeRole(id, role) - .then(() => { - toast.success('User role has been updated', { hideProgressBar: true, position: 'top-center' }); - this.setState({ idChangingRole: null }); - }) - .catch(({ error }) => { - toast.error(error, { hideProgressBar: true, position: 'top-center' }); - this.setState({ idChangingRole: null }); - }); - }; - archiveOrgUser = (id) => { this.setState({ archivingUser: id }); @@ -124,7 +105,9 @@ class ManageOrgUsers extends React.Component { if (this.handleValidation()) { let fields = {}; - Object.keys(fields).map(key => { fields[key] = '' }) + Object.keys(fields).map((key) => { + fields[key] = ''; + }); this.setState({ creatingUser: true, @@ -151,12 +134,6 @@ class ManageOrgUsers extends React.Component { } }; - dropdownVal = (role) => { - this.setState({ - fields: { ...this.state.fields, role }, - }); - }; - logout = () => { authenticationService.logout(); history.push('/login'); @@ -169,16 +146,7 @@ class ManageOrgUsers extends React.Component { }; render() { - const { - isLoading, - role, - showNewUserForm, - creatingUser, - users, - errors, - idChangingRole, - archivingUser, - } = this.state; + const { isLoading, showNewUserForm, creatingUser, users, archivingUser } = this.state; return (
@@ -252,23 +220,6 @@ class ManageOrgUsers extends React.Component { {this.state.errors['email']}
-
- -
- { - return { name: role, value: role.toLowerCase() }; - })} - search={true} - value={role} - name="role" - onChange={this.dropdownVal} - filterOptions={fuzzySearch} - placeholder="Select.." - /> - {errors.role} -
-
@@ -298,9 +250,6 @@ class ManageOrgUsers extends React.Component { Name Email - -
Role
- Status @@ -351,24 +300,6 @@ class ManageOrgUsers extends React.Component { {user.email} - -
- { - return { name: role, value: role.toLowerCase() }; - })} - value={user.role} - search={false} - disabled={idChangingRole === user.id} - onChange={(value) => { - this.changeNewUserRole(user.id, value); - }} - filterOptions={fuzzySearch} - placeholder="Select.." - /> - {idChangingRole === user.id && Updating role...} -
-
-

Settings

+

Profile Settings

diff --git a/frontend/src/_components/Header.jsx b/frontend/src/_components/Header.jsx index 428a435715..3744d9851c 100644 --- a/frontend/src/_components/Header.jsx +++ b/frontend/src/_components/Header.jsx @@ -1,18 +1,10 @@ -import React, { useState, useEffect } from 'react'; -import cx from 'classnames'; +import React from 'react'; import { Link } from 'react-router-dom'; import { authenticationService } from '@/_services'; import { history } from '@/_helpers'; import { DarkModeToggle } from './DarkModeToggle'; export const Header = function Header({ switchDarkMode, darkMode }) { - const [pathName, setPathName] = useState(document.location.pathname); - - useEffect(() => { - setPathName(document.location.pathname); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [document.location.pathname]); - function logout() { authenticationService.logout(); history.push('/login'); @@ -22,7 +14,7 @@ export const Header = function Header({ switchDarkMode, darkMode }) { history.push('/settings'); } - const { first_name, last_name } = authenticationService.currentUserValue; + const { first_name, last_name, admin } = authenticationService.currentUserValue; return (
@@ -36,25 +28,6 @@ export const Header = function Header({ switchDarkMode, darkMode }) { -
    -
  • - - - - - Apps - -
  • - -
  • - - - - - Users - -
  • -
@@ -75,12 +48,22 @@ export const Header = function Header({ switchDarkMode, darkMode }) {
- - Settings - - + {admin && ( + + Manage Users + + )} + {admin && ( + + Manage Groups + + )} + + Profile + + Logout - +
diff --git a/frontend/src/_services/groupPermission.service.js b/frontend/src/_services/groupPermission.service.js new file mode 100644 index 0000000000..3de153a98a --- /dev/null +++ b/frontend/src/_services/groupPermission.service.js @@ -0,0 +1,120 @@ +import config from 'config'; +import { authHeader, handleResponse } from '@/_helpers'; + +export const groupPermissionService = { + create, + update, + del, + getGroup, + getGroups, + getAppsInGroup, + getAppsNotInGroup, + getUsersInGroup, + getUsersNotInGroup, + updateAppGroupPermission, +}; + +function create(group) { + const body = { + group, + }; + + const requestOptions = { + method: 'POST', + headers: authHeader(), + body: JSON.stringify(body), + }; + return fetch(`${config.apiUrl}/group_permissions`, requestOptions).then(handleResponse); +} + +function update(groupPermissionId, params) { + const body = { + add_apps: params.selectedAppIds, + remove_apps: params.removeAppIds, + add_users: params.selectedUserIds, + remove_users: params.removeUserIds, + }; + + const requestOptions = { + method: 'PUT', + headers: authHeader(), + body: JSON.stringify(body), + }; + return fetch(`${config.apiUrl}/group_permissions/${groupPermissionId}`, requestOptions).then(handleResponse); +} + +function del(groupPermissionId) { + const requestOptions = { + method: 'DELETE', + headers: authHeader(), + }; + return fetch(`${config.apiUrl}/group_permissions/${groupPermissionId}`, requestOptions).then(handleResponse); +} + +function getGroup(groupPermissionId) { + const requestOptions = { + method: 'GET', + headers: authHeader(), + }; + return fetch(`${config.apiUrl}/group_permissions/${groupPermissionId}`, requestOptions).then(handleResponse); +} + +function getGroups() { + const requestOptions = { + method: 'GET', + headers: authHeader(), + }; + return fetch(`${config.apiUrl}/group_permissions`, requestOptions).then(handleResponse); +} + +function getAppsInGroup(groupPermissionId) { + const requestOptions = { + method: 'GET', + headers: authHeader(), + }; + return fetch(`${config.apiUrl}/group_permissions/${groupPermissionId}/apps`, requestOptions).then(handleResponse); +} + +function getAppsNotInGroup(groupPermissionId) { + const requestOptions = { + method: 'GET', + headers: authHeader(), + }; + return fetch(`${config.apiUrl}/group_permissions/${groupPermissionId}/addable_apps`, requestOptions).then( + handleResponse + ); +} + +function getUsersInGroup(groupPermissionId) { + const requestOptions = { + method: 'GET', + headers: authHeader(), + }; + return fetch(`${config.apiUrl}/group_permissions/${groupPermissionId}/users`, requestOptions).then(handleResponse); +} + +function getUsersNotInGroup(groupPermissionId) { + const requestOptions = { + method: 'GET', + headers: authHeader(), + }; + return fetch(`${config.apiUrl}/group_permissions/${groupPermissionId}/addable_users`, requestOptions).then( + handleResponse + ); +} + +function updateAppGroupPermission(groupPermissionId, appGroupPermissionId, actions) { + const body = { + actions, + }; + + const requestOptions = { + method: 'PUT', + headers: authHeader(), + body: JSON.stringify(body), + }; + return fetch( + `${config.apiUrl}/group_permissions/${groupPermissionId}/app_group_permissions/${appGroupPermissionId}`, + requestOptions + ).then(handleResponse); +} diff --git a/frontend/src/_services/organization_user.service.js b/frontend/src/_services/organization_user.service.js index d147461f61..b82d43d6b4 100644 --- a/frontend/src/_services/organization_user.service.js +++ b/frontend/src/_services/organization_user.service.js @@ -7,18 +7,18 @@ export const organizationUserService = { changeRole, }; -function create(first_name, last_name, email, role) { +function create(first_name, last_name, email) { const body = { first_name, last_name, email, - role, }; const requestOptions = { method: 'POST', headers: authHeader(), body: JSON.stringify(body) }; return fetch(`${config.apiUrl}/organization_users`, requestOptions).then(handleResponse); } +// Deprecated function changeRole(id, role) { const body = { role, diff --git a/server/migrations/1632382322381-CreateGroupPermissions.ts b/server/migrations/1632382322381-CreateGroupPermissions.ts new file mode 100644 index 0000000000..638354e748 --- /dev/null +++ b/server/migrations/1632382322381-CreateGroupPermissions.ts @@ -0,0 +1,70 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableForeignKey, + TableUnique, +} from "typeorm"; + +export class CreateGroupPermissions1632382322381 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: "group_permissions", + columns: [ + { + name: "id", + type: "uuid", + isGenerated: true, + default: "gen_random_uuid()", + isPrimary: true, + }, + { + name: "organization_id", + type: "uuid", + isNullable: false, + }, + { + name: "group", + type: "varchar", + isNullable: false, + }, + { + name: "created_at", + type: "timestamp", + isNullable: false, + default: "now()", + }, + { + name: "updated_at", + type: "timestamp", + isNullable: false, + default: "now()", + }, + ], + }), + true + ); + + await queryRunner.createForeignKey( + "group_permissions", + new TableForeignKey({ + columnNames: ["organization_id"], + referencedColumnNames: ["id"], + referencedTableName: "organizations", + onDelete: "CASCADE", + }) + ); + + await queryRunner.createUniqueConstraint( + "group_permissions", + new TableUnique({ + columnNames: ["organization_id", "group"], + }) + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("group_permissions"); + } +} diff --git a/server/migrations/1632383798339-CreateUserGroupPermissions.ts b/server/migrations/1632383798339-CreateUserGroupPermissions.ts new file mode 100644 index 0000000000..f8f62b0a3a --- /dev/null +++ b/server/migrations/1632383798339-CreateUserGroupPermissions.ts @@ -0,0 +1,74 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableForeignKey, +} from "typeorm"; + +export class CreateUserGroupPermissions1632383798339 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: "user_group_permissions", + columns: [ + { + name: "id", + type: "uuid", + isGenerated: true, + default: "gen_random_uuid()", + isPrimary: true, + }, + { + name: "user_id", + type: "uuid", + isNullable: false, + }, + { + name: "group_permission_id", + type: "uuid", + isNullable: false, + }, + { + name: "created_at", + type: "timestamp", + isNullable: false, + default: "now()", + }, + { + name: "updated_at", + type: "timestamp", + isNullable: false, + default: "now()", + }, + ], + }), + true + ); + + await queryRunner.createForeignKey( + "user_group_permissions", + new TableForeignKey({ + columnNames: ["user_id"], + referencedColumnNames: ["id"], + referencedTableName: "users", + onDelete: "CASCADE", + }) + ); + + await queryRunner.createForeignKey( + "user_group_permissions", + new TableForeignKey({ + columnNames: ["group_permission_id"], + referencedColumnNames: ["id"], + referencedTableName: "group_permissions", + onDelete: "CASCADE", + }) + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("user_group_permissions"); + } +} diff --git a/server/migrations/1632384954344-CreateAppGroupPermissions.ts b/server/migrations/1632384954344-CreateAppGroupPermissions.ts new file mode 100644 index 0000000000..2baa58fd78 --- /dev/null +++ b/server/migrations/1632384954344-CreateAppGroupPermissions.ts @@ -0,0 +1,92 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableForeignKey, +} from "typeorm"; + +export class CreateAppGroupPermissions1632384954344 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: "app_group_permissions", + columns: [ + { + name: "id", + type: "uuid", + isGenerated: true, + default: "gen_random_uuid()", + isPrimary: true, + }, + { + name: "app_id", + type: "uuid", + isNullable: false, + }, + { + name: "group_permission_id", + type: "uuid", + isNullable: false, + }, + { + name: "read", + type: "boolean", + default: false, + isNullable: false, + }, + { + name: "update", + type: "boolean", + default: false, + isNullable: false, + }, + { + name: "delete", + type: "boolean", + default: false, + isNullable: false, + }, + { + name: "created_at", + type: "timestamp", + isNullable: false, + default: "now()", + }, + { + name: "updated_at", + type: "timestamp", + isNullable: false, + default: "now()", + }, + ], + }), + true + ); + + await queryRunner.createForeignKey( + "app_group_permissions", + new TableForeignKey({ + columnNames: ["app_id"], + referencedColumnNames: ["id"], + referencedTableName: "apps", + onDelete: "CASCADE", + }) + ); + + await queryRunner.createForeignKey( + "app_group_permissions", + new TableForeignKey({ + columnNames: ["group_permission_id"], + referencedColumnNames: ["id"], + referencedTableName: "group_permissions", + onDelete: "CASCADE", + }) + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("app_group_permissions"); + } +} diff --git a/server/migrations/1632468258787-PopulateUserGroupsFromOrganizationRoles.ts b/server/migrations/1632468258787-PopulateUserGroupsFromOrganizationRoles.ts new file mode 100644 index 0000000000..32d40578aa --- /dev/null +++ b/server/migrations/1632468258787-PopulateUserGroupsFromOrganizationRoles.ts @@ -0,0 +1,137 @@ +import { EntityManager, In, MigrationInterface, QueryRunner } from "typeorm"; +import { Organization } from "../src/entities/organization.entity"; +import { GroupPermission } from "../src/entities/group_permission.entity"; +import { AppGroupPermission } from "../src/entities/app_group_permission.entity"; +import { UserGroupPermission } from "../src/entities/user_group_permission.entity"; +import { App } from "../src/entities/app.entity"; + +export class PopulateUserGroupsFromOrganizationRoles1632468258787 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + const entityManager = queryRunner.manager; + const OrganizationRepository = entityManager.getRepository(Organization); + + const organizations = await OrganizationRepository.find({ + relations: ["users"], + }); + + for (let organization of organizations) { + const groupPermissions = await setupInitialGroupPermissions( + entityManager, + organization + ); + await setupUserAndAppGroupPermissions( + entityManager, + organization, + groupPermissions + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + const entityManager = queryRunner.manager; + + entityManager + .createQueryBuilder() + .delete() + .from(GroupPermission) + .execute(); + + entityManager + .createQueryBuilder() + .delete() + .from(AppGroupPermission) + .execute(); + + entityManager + .createQueryBuilder() + .delete() + .from(UserGroupPermission) + .execute(); + } +} + +async function setupInitialGroupPermissions( + entityManager: EntityManager, + organization: Organization +): Promise> { + const existingRoles = ["admin", "developer", "viewer"]; + const groupsToCreate = ["all_users", ...existingRoles]; + const createdGroupPermissions = []; + + const groupPermissionRepository = + entityManager.getRepository(GroupPermission); + + for (const group of groupsToCreate) { + const groupPermission = groupPermissionRepository.create({ + organizationId: organization.id, + group: group, + }); + await groupPermissionRepository.save(groupPermission); + createdGroupPermissions.push(groupPermission); + } + + return createdGroupPermissions; +} + +async function setupUserAndAppGroupPermissions( + entityManager: EntityManager, + organization: Organization, + createdGroupPermissions: Array +): Promise { + const userGroupPermissionRepository = + entityManager.getRepository(UserGroupPermission); + + const appGroupPermissionRepository = + entityManager.getRepository(AppGroupPermission); + + const appRepository = entityManager.getRepository(App); + + const organizationApps = await appRepository.find({ + organizationId: organization.id, + }); + + for (const groupPermission of createdGroupPermissions) { + const usersForGroup = organization.users.filter( + (u) => + u.organizationUsers[0].role == groupPermission.group || groupPermission.group == "all_users" + ); + + for (const user of usersForGroup) { + const userGroupPermission = userGroupPermissionRepository.create({ + groupPermissionId: groupPermission.id, + userId: user.id, + }); + await userGroupPermissionRepository.save(userGroupPermission); + } + + const permissions = determinePermissionsForGroup(groupPermission.group); + + for (const app of organizationApps) { + const appGroupPermission = appGroupPermissionRepository.create({ + groupPermissionId: groupPermission.id, + appId: app.id, + ...permissions, + }); + await appGroupPermissionRepository.save(appGroupPermission); + } + } +} + +function determinePermissionsForGroup(group: string): { + read: boolean; + update: boolean; + delete: boolean; +} { + switch (group) { + case "all_users": + return { read: true, update: false, delete: false }; + case "admin": + return { read: true, update: true, delete: true }; + case "developer": + return { read: true, update: true, delete: true }; + case "viewer": + return { read: true, update: false, delete: false }; + } +} diff --git a/server/src/app.module.ts b/server/src/app.module.ts index cd1d07f9cb..212fba686d 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -16,7 +16,6 @@ import { CaslModule } from './modules/casl/casl.module'; import { EmailService } from '@services/email.service'; import { MetaModule } from './modules/meta/meta.module'; import { AppController } from './controllers/app.controller'; -import { AppService } from './services/app.service'; import { AuthModule } from './modules/auth/auth.module'; import { UsersModule } from './modules/users/users.module'; import { AppConfigModule } from './modules/app_config/app_config.module'; @@ -27,6 +26,7 @@ import { DataQueriesModule } from './modules/data_queries/data_queries.module'; import { DataSourcesModule } from './modules/data_sources/data_sources.module'; import { OrganizationsModule } from './modules/organizations/organizations.module'; import { join } from 'path'; +import { GroupPermissionsModule } from './modules/group_permissions/group_permissions.module'; const imports = [ ConfigModule.forRoot({ @@ -63,6 +63,7 @@ const imports = [ OrganizationsModule, CaslModule, MetaModule, + GroupPermissionsModule, ]; if (process.env.SERVE_CLIENT !== 'false') { @@ -86,7 +87,7 @@ if (process.env.APM_VENDOR == 'sentry') { @Module({ imports, controllers: [AppController], - providers: [AppService, EmailService, SeedsService], + providers: [EmailService, SeedsService], }) export class AppModule implements OnModuleInit, OnApplicationBootstrap { constructor(private connection: Connection) {} diff --git a/server/src/controllers/app_users.controller.ts b/server/src/controllers/app_users.controller.ts index d08c0e8ed5..eced2f52e0 100644 --- a/server/src/controllers/app_users.controller.ts +++ b/server/src/controllers/app_users.controller.ts @@ -13,6 +13,7 @@ export class AppUsersController { private appsAbilityFactory: AppsAbilityFactory ) {} + // TODO: remove deprecated @UseGuards(JwtAuthGuard) @Post() async create(@Request() req) { @@ -22,7 +23,7 @@ export class AppUsersController { const { role } = params; const app = await this.appsService.find(appId); - const ability = await this.appsAbilityFactory.appsActions(req.user, {}); + const ability = await this.appsAbilityFactory.appsActions(req.user, { id: appId }); if (!ability.can('createUsers', app)) { throw new ForbiddenException('you do not have permissions to perform this action'); diff --git a/server/src/controllers/apps.controller.ts b/server/src/controllers/apps.controller.ts index ecad5cd452..09e32b3fe3 100644 --- a/server/src/controllers/apps.controller.ts +++ b/server/src/controllers/apps.controller.ts @@ -16,6 +16,7 @@ import { decamelizeKeys } from 'humps'; import { AppsAbilityFactory } from 'src/modules/casl/abilities/apps-ability.factory'; import { AppAuthGuard } from 'src/modules/auth/app-auth.guard'; import { FoldersService } from '@services/folders.service'; +import { App } from 'src/entities/app.entity'; @Controller('apps') export class AppsController { @@ -28,12 +29,12 @@ export class AppsController { @UseGuards(JwtAuthGuard) @Post() async create(@Request() req) { - const app = await this.appsService.create(req.user); const ability = await this.appsAbilityFactory.appsActions(req.user, {}); - if (!ability.can('createApp', app)) { - throw new ForbiddenException('you do not have permissions to perform this action'); + if (!ability.can('createApp', App)) { + throw new ForbiddenException('You do not have permissions to perform this action'); } + const app = await this.appsService.create(req.user); await this.appsService.update(req.user, app.id, { slug: app.id, @@ -46,6 +47,11 @@ export class AppsController { @Get(':id') async show(@Request() req, @Param() params) { const app = await this.appsService.find(params.id); + const ability = await this.appsAbilityFactory.appsActions(req.user, params); + + if (!ability.can('viewApp', app)) { + throw new ForbiddenException('You do not have permissions to perform this action'); + } const response = decamelizeKeys(app); const seralizedQueries = []; @@ -68,10 +74,10 @@ export class AppsController { async appFromSlug(@Request() req, @Param() params) { if (req.user) { const app = await this.appsService.findBySlug(params.slug); - const ability = await this.appsAbilityFactory.appsActions(req.user, {}); + const ability = await this.appsAbilityFactory.appsActions(req.user, { id: app.id }); if (!ability.can('viewApp', app)) { - throw new ForbiddenException('you do not have permissions to perform this action'); + throw new ForbiddenException('You do not have permissions to perform this action'); } } @@ -92,10 +98,10 @@ export class AppsController { @Put(':id') async update(@Request() req, @Param() params) { const app = await this.appsService.find(params.id); - const ability = await this.appsAbilityFactory.appsActions(req.user, {}); + const ability = await this.appsAbilityFactory.appsActions(req.user, params); if (!ability.can('updateParams', app)) { - throw new ForbiddenException('you do not have permissions to perform this action'); + throw new ForbiddenException('You do not have permissions to perform this action'); } const result = await this.appsService.update(req.user, params.id, req.body.app); @@ -108,10 +114,10 @@ export class AppsController { @Post(':id/clone') async clone(@Request() req, @Param() params) { const existingApp = await this.appsService.find(params.id); - const ability = await this.appsAbilityFactory.appsActions(req.user, {}); + const ability = await this.appsAbilityFactory.appsActions(req.user, params); if (!ability.can('cloneApp', existingApp)) { - throw new ForbiddenException('you do not have permissions to perform this action'); + throw new ForbiddenException('You do not have permissions to perform this action'); } const result = await this.appsService.clone(existingApp, req.user); @@ -124,7 +130,7 @@ export class AppsController { @Delete(':id') async delete(@Request() req, @Param() params) { const app = await this.appsService.find(params.id); - const ability = await this.appsAbilityFactory.appsActions(req.user, {}); + const ability = await this.appsAbilityFactory.appsActions(req.user, params); if (!ability.can('deleteApp', app)) { throw new ForbiddenException('Only administrators are allowed to delete apps.'); @@ -172,14 +178,15 @@ export class AppsController { return decamelizeKeys(response); } + // deprecated @UseGuards(JwtAuthGuard) @Get(':id/users') async fetchUsers(@Request() req, @Param() params) { const app = await this.appsService.find(params.id); - const ability = await this.appsAbilityFactory.appsActions(req.user, {}); + const ability = await this.appsAbilityFactory.appsActions(req.user, params); if (!ability.can('fetchUsers', app)) { - throw new ForbiddenException('you do not have permissions to perform this action'); + throw new ForbiddenException('You do not have permissions to perform this action'); } const result = await this.appsService.fetchUsers(req.user, params.id); @@ -190,10 +197,10 @@ export class AppsController { @Get(':id/versions') async fetchVersions(@Request() req, @Param() params) { const app = await this.appsService.find(params.id); - const ability = await this.appsAbilityFactory.appsActions(req.user, {}); + const ability = await this.appsAbilityFactory.appsActions(req.user, params); if (!ability.can('fetchVersions', app)) { - throw new ForbiddenException('you do not have permissions to perform this action'); + throw new ForbiddenException('You do not have permissions to perform this action'); } const result = await this.appsService.fetchVersions(req.user, params.id); @@ -206,10 +213,10 @@ export class AppsController { const versionName = req.body['versionName']; const app = await this.appsService.find(params.id); - const ability = await this.appsAbilityFactory.appsActions(req.user, {}); + const ability = await this.appsAbilityFactory.appsActions(req.user, params); if (!ability.can('createVersions', app)) { - throw new ForbiddenException('you do not have permissions to perform this action'); + throw new ForbiddenException('You do not have permissions to perform this action'); } const appUser = await this.appsService.createVersion(req.user, app, versionName); @@ -220,10 +227,10 @@ export class AppsController { @Get(':id/versions/:versionId') async version(@Request() req, @Param() params) { const app = await this.appsService.find(params.id); - const ability = await this.appsAbilityFactory.appsActions(req.user, {}); + const ability = await this.appsAbilityFactory.appsActions(req.user, params); if (!ability.can('fetchVersions', app)) { - throw new ForbiddenException('you do not have permissions to perform this action'); + throw new ForbiddenException('You do not have permissions to perform this action'); } const appVersion = await this.appsService.findVersion(params.versionId); @@ -237,10 +244,10 @@ export class AppsController { const definition = req.body['definition']; const version = await this.appsService.findVersion(params.versionId); - const ability = await this.appsAbilityFactory.appsActions(req.user, {}); + const ability = await this.appsAbilityFactory.appsActions(req.user, params); if (!ability.can('updateVersions', version.app)) { - throw new ForbiddenException('you do not have permissions to perform this action'); + throw new ForbiddenException('You do not have permissions to perform this action'); } const appUser = await this.appsService.updateVersion(req.user, version, definition); diff --git a/server/src/controllers/data_queries.controller.ts b/server/src/controllers/data_queries.controller.ts index 5097b22431..9e22fbd746 100644 --- a/server/src/controllers/data_queries.controller.ts +++ b/server/src/controllers/data_queries.controller.ts @@ -32,7 +32,9 @@ export class DataQueriesController { @Get() async index(@Request() req, @Query() query) { const app = await this.appsService.find(query.app_id); - const ability = await this.appsAbilityFactory.appsActions(req.user, {}); + const ability = await this.appsAbilityFactory.appsActions(req.user, { + id: query.app_id, + }); if (!ability.can('getQueries', app)) { throw new ForbiddenException('you do not have permissions to perform this action'); @@ -61,7 +63,9 @@ export class DataQueriesController { const appId = req.body.app_id; const app = await this.appsService.find(appId); - const ability = await this.appsAbilityFactory.appsActions(req.user, {}); + const ability = await this.appsAbilityFactory.appsActions(req.user, { + id: appId, + }); if (!ability.can('createQuery', app)) { throw new ForbiddenException('you do not have permissions to perform this action'); @@ -88,7 +92,9 @@ export class DataQueriesController { const dataQueryId = params.id; const dataQuery = await this.dataQueriesService.findOne(dataQueryId); - const ability = await this.appsAbilityFactory.appsActions(req.user, {}); + const ability = await this.appsAbilityFactory.appsActions(req.user, { + id: dataQuery.appId, + }); if (!ability.can('updateQuery', dataQuery.app)) { throw new ForbiddenException('you do not have permissions to perform this action'); @@ -104,7 +110,9 @@ export class DataQueriesController { const dataQueryId = params.id; const dataQuery = await this.dataQueriesService.findOne(dataQueryId); - const ability = await this.appsAbilityFactory.appsActions(req.user, {}); + const ability = await this.appsAbilityFactory.appsActions(req.user, { + id: dataQuery.appId, + }); if (!ability.can('deleteQuery', dataQuery.app)) { throw new ForbiddenException('you do not have permissions to perform this action'); @@ -123,7 +131,9 @@ export class DataQueriesController { const dataQuery = await this.dataQueriesService.findOne(dataQueryId); if (req.user) { - const ability = await this.appsAbilityFactory.appsActions(req.user, {}); + const ability = await this.appsAbilityFactory.appsActions(req.user, { + id: dataQuery.appId, + }); if (!ability.can('runQuery', dataQuery.app)) { throw new ForbiddenException('you do not have permissions to perform this action'); @@ -166,7 +176,9 @@ export class DataQueriesController { }; if (dataQueryEntity.dataSource) { - const ability = await this.appsAbilityFactory.appsActions(req.user, {}); + const ability = await this.appsAbilityFactory.appsActions(req.user, { + id: dataQueryEntity.dataSource.appId, + }); if (!ability.can('previewQuery', dataQueryEntity.dataSource.app)) { throw new ForbiddenException('you do not have permissions to perform this action'); diff --git a/server/src/controllers/data_sources.controller.ts b/server/src/controllers/data_sources.controller.ts index a0f3d7a4a8..9d5a0833b2 100644 --- a/server/src/controllers/data_sources.controller.ts +++ b/server/src/controllers/data_sources.controller.ts @@ -19,7 +19,9 @@ export class DataSourcesController { @Get() async index(@Request() req, @Query() query) { const app = await this.appsService.find(query.app_id); - const ability = await this.appsAbilityFactory.appsActions(req.user, {}); + const ability = await this.appsAbilityFactory.appsActions(req.user, { + id: app.id, + }); if (!ability.can('getDataSources', app)) { throw new ForbiddenException('you do not have permissions to perform this action'); @@ -38,7 +40,9 @@ export class DataSourcesController { const appId = req.body.app_id; const app = await this.appsService.find(appId); - const ability = await this.appsAbilityFactory.appsActions(req.user, {}); + const ability = await this.appsAbilityFactory.appsActions(req.user, { + id: appId, + }); if (!ability.can('createDataSource', app)) { throw new ForbiddenException('you do not have permissions to perform this action'); @@ -57,7 +61,9 @@ export class DataSourcesController { const dataSource = await this.dataSourcesService.findOne(dataSourceId); const app = await this.appsService.find(dataSource.appId); - const ability = await this.appsAbilityFactory.appsActions(req.user, {}); + const ability = await this.appsAbilityFactory.appsActions(req.user, { + id: app.id, + }); if (!ability.can('updateDataSource', app)) { throw new ForbiddenException('you do not have permissions to perform this action'); @@ -90,7 +96,9 @@ export class DataSourcesController { const dataSource = await this.dataSourcesService.findOne(dataSourceId); const app = await this.appsService.find(dataSource.appId); - const ability = await this.appsAbilityFactory.appsActions(req.user, {}); + const ability = await this.appsAbilityFactory.appsActions(req.user, { + id: app.id, + }); if (!ability.can('authorizeOauthForSource', app)) { throw new ForbiddenException('you do not have permissions to perform this action'); diff --git a/server/src/controllers/group_permissions.controller.ts b/server/src/controllers/group_permissions.controller.ts new file mode 100644 index 0000000000..2e70df219a --- /dev/null +++ b/server/src/controllers/group_permissions.controller.ts @@ -0,0 +1,109 @@ +import { Controller, Post, Get, Put, Delete, Request, UseGuards, Param } from '@nestjs/common'; + +import { decamelizeKeys } from 'humps'; +import { JwtAuthGuard } from '../../src/modules/auth/jwt-auth.guard'; +import { GroupPermissionsService } from '../services/group_permissions.service'; +import { PoliciesGuard } from 'src/modules/casl/policies.guard'; +import { CheckPolicies } from 'src/modules/casl/check_policies.decorator'; +import { AppAbility } from 'src/modules/casl/casl-ability.factory'; +import { User } from 'src/entities/user.entity'; + +@Controller('group_permissions') +export class GroupPermissionsController { + constructor(private groupPermissionsService: GroupPermissionsService) {} + + @UseGuards(JwtAuthGuard, PoliciesGuard) + @CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', User)) + @Post() + async create(@Request() req) { + const groupPermission = await this.groupPermissionsService.create(req.user, req.body.group); + + return decamelizeKeys(groupPermission); + } + + @UseGuards(JwtAuthGuard, PoliciesGuard) + @CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', User)) + @Get(':id') + async show(@Request() req, @Param() params) { + const groupPermission = await this.groupPermissionsService.findOne(req.user, params.id); + + return decamelizeKeys(groupPermission); + } + + @UseGuards(JwtAuthGuard, PoliciesGuard) + @CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', User)) + @Put(':id/app_group_permissions/:appGroupPermissionId') + async updateAppGroupPermission(@Request() req, @Param() params) { + const groupPermission = await this.groupPermissionsService.updateAppGroupPermission( + req.user, + params.id, + params.appGroupPermissionId, + req.body.actions + ); + + return decamelizeKeys(groupPermission); + } + + @UseGuards(JwtAuthGuard, PoliciesGuard) + @CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', User)) + @Put(':id') + async update(@Request() req, @Param() params) { + const groupPermission = await this.groupPermissionsService.update(req.user, params.id, req.body); + + return decamelizeKeys(groupPermission); + } + + @UseGuards(JwtAuthGuard, PoliciesGuard) + @CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', User)) + @Get() + async index(@Request() req) { + const groupPermissions = await this.groupPermissionsService.findAll(req.user); + + return decamelizeKeys({ groupPermissions }); + } + + @UseGuards(JwtAuthGuard, PoliciesGuard) + @CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', User)) + @Delete(':id') + async destroy(@Request() req, @Param() params) { + const groupPermission = await this.groupPermissionsService.destroy(req.user, params.id); + + return decamelizeKeys(groupPermission); + } + + @UseGuards(JwtAuthGuard, PoliciesGuard) + @CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', User)) + @Get(':id/apps') + async apps(@Request() req, @Param() params) { + const apps = await this.groupPermissionsService.findApps(req.user, params.id); + + return decamelizeKeys({ apps }); + } + + @UseGuards(JwtAuthGuard, PoliciesGuard) + @CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', User)) + @Get(':id/addable_apps') + async addableApps(@Request() req, @Param() params) { + const apps = await this.groupPermissionsService.findAddableApps(req.user, params.id); + + return decamelizeKeys({ apps }); + } + + @UseGuards(JwtAuthGuard, PoliciesGuard) + @CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', User)) + @Get(':id/users') + async users(@Request() req, @Param() params) { + const users = await this.groupPermissionsService.findUsers(req.user, params.id); + + return decamelizeKeys({ users }); + } + + @UseGuards(JwtAuthGuard, PoliciesGuard) + @CheckPolicies((ability: AppAbility) => ability.can('accessGroupPermission', User)) + @Get(':id/addable_users') + async addableUsers(@Request() req, @Param() params) { + const users = await this.groupPermissionsService.findAddableUsers(req.user, params.id); + + return decamelizeKeys({ users }); + } +} diff --git a/server/src/entities/app.entity.ts b/server/src/entities/app.entity.ts index 61399a6a3b..4f846b3898 100644 --- a/server/src/entities/app.entity.ts +++ b/server/src/entities/app.entity.ts @@ -9,11 +9,17 @@ import { OneToMany, AfterLoad, BaseEntity, + ManyToMany, + JoinTable, + AfterInsert, + getRepository, } from 'typeorm'; import { User } from './user.entity'; import { AppVersion } from './app_version.entity'; import { DataQuery } from './data_query.entity'; import { DataSource } from './data_source.entity'; +import { GroupPermission } from './group_permission.entity'; +import { AppGroupPermission } from './app_group_permission.entity'; @Entity({ name: 'apps' }) export class App extends BaseEntity { @@ -45,17 +51,47 @@ export class App extends BaseEntity { @JoinColumn({ name: 'user_id' }) user: User; - @OneToMany(() => AppVersion, (appVersion) => appVersion.app, { eager: true, onDelete: 'CASCADE' }) + @OneToMany(() => AppVersion, (appVersion) => appVersion.app, { + eager: true, + onDelete: 'CASCADE', + }) appVersions: AppVersion[]; - @OneToMany(() => DataQuery, (dataQuery) => dataQuery.app, { onDelete: 'CASCADE' }) + @OneToMany(() => DataQuery, (dataQuery) => dataQuery.app, { + onDelete: 'CASCADE', + }) dataQueries: DataQuery[]; - @OneToMany(() => DataSource, (dataSource) => dataSource.app, { onDelete: 'CASCADE' }) + @OneToMany(() => DataSource, (dataSource) => dataSource.app, { + onDelete: 'CASCADE', + }) dataSources: DataSource[]; + @ManyToMany(() => GroupPermission) + @JoinTable({ + name: 'app_group_permissions', + joinColumn: { + name: 'app_id', + }, + inverseJoinColumn: { + name: 'group_permission_id', + }, + }) + groupPermissions: GroupPermission[]; + + @OneToMany(() => AppGroupPermission, (appGroupPermission) => appGroupPermission.app, { onDelete: 'CASCADE' }) + appGroupPermissions: AppGroupPermission[]; + public editingVersion; + @AfterInsert() + updateSlug() { + if (!this.slug) { + const appRepository = getRepository(App); + appRepository.update(this.id, { slug: this.id }); + } + } + @AfterLoad() async afterLoad(): Promise { if (this.currentVersionId) { diff --git a/server/src/entities/app_group_permission.entity.ts b/server/src/entities/app_group_permission.entity.ts new file mode 100644 index 0000000000..83e289af45 --- /dev/null +++ b/server/src/entities/app_group_permission.entity.ts @@ -0,0 +1,47 @@ +import { + BaseEntity, + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { GroupPermission } from './group_permission.entity'; +import { App } from './app.entity'; + +@Entity({ name: 'app_group_permissions' }) +export class AppGroupPermission extends BaseEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'app_id' }) + appId: string; + + @Column({ name: 'group_permission_id' }) + groupPermissionId: string; + + @Column({ default: false }) + read: boolean; + + @Column({ default: false }) + update: boolean; + + @Column({ default: false }) + delete: boolean; + + @CreateDateColumn({ default: () => 'now()', name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ default: () => 'now()', name: 'updated_at' }) + updatedAt: Date; + + @ManyToOne(() => App, (app) => app.id) + @JoinColumn({ name: 'app_id' }) + app: App; + + @ManyToOne(() => GroupPermission, (groupPermission) => groupPermission.id) + @JoinColumn({ name: 'group_permission_id' }) + groupPermission: GroupPermission; +} diff --git a/server/src/entities/group_permission.entity.ts b/server/src/entities/group_permission.entity.ts new file mode 100644 index 0000000000..d0d8ffd514 --- /dev/null +++ b/server/src/entities/group_permission.entity.ts @@ -0,0 +1,70 @@ +import { + BaseEntity, + Column, + CreateDateColumn, + Entity, + JoinColumn, + JoinTable, + ManyToMany, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { App } from './app.entity'; +import { AppGroupPermission } from './app_group_permission.entity'; +import { Organization } from './organization.entity'; +import { User } from './user.entity'; +import { UserGroupPermission } from './user_group_permission.entity'; + +@Entity({ name: 'group_permissions' }) +export class GroupPermission extends BaseEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'organization_id' }) + organizationId: string; + + @Column() + group: string; + + @CreateDateColumn({ default: () => 'now()', name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ default: () => 'now()', name: 'updated_at' }) + updatedAt: Date; + + @ManyToOne(() => Organization, (organization) => organization.id) + @JoinColumn({ name: 'organization_id' }) + organization: Organization; + + @OneToMany(() => UserGroupPermission, (userGroupPermission) => userGroupPermission.groupPermission) + userGroupPermission: UserGroupPermission[]; + + @OneToMany(() => AppGroupPermission, (appGroupPermission) => appGroupPermission.groupPermission) + appGroupPermission: AppGroupPermission[]; + + @ManyToMany(() => User) + @JoinTable({ + name: 'user_group_permissions', + joinColumn: { + name: 'group_permission_id', + }, + inverseJoinColumn: { + name: 'user_id', + }, + }) + users: Promise; + + @ManyToMany(() => App) + @JoinTable({ + name: 'app_group_permissions', + joinColumn: { + name: 'group_permission_id', + }, + inverseJoinColumn: { + name: 'app_id', + }, + }) + apps: Promise; +} diff --git a/server/src/entities/organization.entity.ts b/server/src/entities/organization.entity.ts index e49c9c2aa7..b81630b723 100644 --- a/server/src/entities/organization.entity.ts +++ b/server/src/entities/organization.entity.ts @@ -1,4 +1,14 @@ -import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + JoinColumn, +} from 'typeorm'; +import { GroupPermission } from './group_permission.entity'; +import { User } from './user.entity'; @Entity({ name: 'organizations' }) export class Organization { @@ -16,4 +26,12 @@ export class Organization { @UpdateDateColumn({ default: () => 'now()', name: 'updated_at' }) updatedAt: Date; + + @OneToMany(() => GroupPermission, (groupPermission) => groupPermission.organization, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'organization_id' }) + groupPermissions: GroupPermission[]; + + @OneToMany(() => User, (user) => user.organization) + @JoinColumn({ name: 'organization_id' }) + users: User[]; } diff --git a/server/src/entities/user.entity.ts b/server/src/entities/user.entity.ts index f0de62cf00..32ebd084d4 100644 --- a/server/src/entities/user.entity.ts +++ b/server/src/entities/user.entity.ts @@ -9,12 +9,15 @@ import { OneToMany, ManyToOne, JoinColumn, - AfterLoad, BaseEntity, + ManyToMany, + JoinTable, } from 'typeorm'; +import { GroupPermission } from './group_permission.entity'; import { Organization } from './organization.entity'; const bcrypt = require('bcrypt'); import { OrganizationUser } from './organization_user.entity'; +import { UserGroupPermission } from './user_group_permission.entity'; @Entity({ name: 'users' }) export class User extends BaseEntity { @@ -63,14 +66,18 @@ export class User extends BaseEntity { @JoinColumn({ name: 'organization_id' }) organization: Organization; - public isAdmin; - public isDeveloper; - public role; + @ManyToMany(() => GroupPermission) + @JoinTable({ + name: 'user_group_permissions', + joinColumn: { + name: 'user_id', + }, + inverseJoinColumn: { + name: 'group_permission_id', + }, + }) + groupPermissions: Promise; - @AfterLoad() - computeUserRole(): void { - this.isAdmin = this.organizationUsers[0].role === 'admin'; - this.isDeveloper = this.organizationUsers[0].role === 'developer'; - this.role = this.organizationUsers[0].role; - } + @OneToMany(() => UserGroupPermission, (userGroupPermission) => userGroupPermission.user, { onDelete: 'CASCADE' }) + userGroupPermissions: UserGroupPermission[]; } diff --git a/server/src/entities/user_group_permission.entity.ts b/server/src/entities/user_group_permission.entity.ts new file mode 100644 index 0000000000..d0047eee2f --- /dev/null +++ b/server/src/entities/user_group_permission.entity.ts @@ -0,0 +1,38 @@ +import { + BaseEntity, + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { GroupPermission } from './group_permission.entity'; +import { User } from './user.entity'; + +@Entity({ name: 'user_group_permissions' }) +export class UserGroupPermission extends BaseEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id' }) + userId: string; + + @Column({ name: 'group_permission_id' }) + groupPermissionId: string; + + @CreateDateColumn({ default: () => 'now()', name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ default: () => 'now()', name: 'updated_at' }) + updatedAt: Date; + + @ManyToOne(() => User, (user) => user.id) + @JoinColumn({ name: 'user_id' }) + user: User; + + @ManyToOne(() => GroupPermission, (groupPermission) => groupPermission.id) + @JoinColumn({ name: 'group_permission_id' }) + groupPermission: GroupPermission; +} diff --git a/server/src/modules/apps/apps.module.ts b/server/src/modules/apps/apps.module.ts index 9ae5c5a5d8..7ad4f1b80a 100644 --- a/server/src/modules/apps/apps.module.ts +++ b/server/src/modules/apps/apps.module.ts @@ -18,6 +18,9 @@ import { Folder } from 'src/entities/folder.entity'; import { FolderApp } from 'src/entities/folder_app.entity'; import { DataSource } from 'src/entities/data_source.entity'; import { AppCloneService } from '@services/app_clone.service'; +import { GroupPermission } from 'src/entities/group_permission.entity'; +import { AppGroupPermission } from 'src/entities/app_group_permission.entity'; +import { UserGroupPermission } from 'src/entities/user_group_permission.entity'; @Module({ imports: [ @@ -32,6 +35,9 @@ import { AppCloneService } from '@services/app_clone.service'; User, Organization, DataSource, + GroupPermission, + AppGroupPermission, + UserGroupPermission, ]), CaslModule, ], diff --git a/server/src/modules/auth/app-auth.guard.ts b/server/src/modules/auth/app-auth.guard.ts index e11aa7d9c1..6eda5022e1 100644 --- a/server/src/modules/auth/app-auth.guard.ts +++ b/server/src/modules/auth/app-auth.guard.ts @@ -12,7 +12,7 @@ export class AppAuthGuard extends AuthGuard('jwt') { const request = context.switchToHttp().getRequest(); // unauthenticated users should be able to to view public apps - if (request.route.path === '/api/apps/slugs/:slug') { + if (request.route.path === '/apps/slugs/:slug') { const app = await this.appsService.findBySlug(request.params.slug); if (app.isPublic === true) { return true; diff --git a/server/src/modules/auth/auth.module.ts b/server/src/modules/auth/auth.module.ts index baa0487b4c..eb289f1bed 100644 --- a/server/src/modules/auth/auth.module.ts +++ b/server/src/modules/auth/auth.module.ts @@ -13,12 +13,13 @@ import { OrganizationsService } from 'src/services/organizations.service'; import { OrganizationUsersService } from 'src/services/organization_users.service'; import { ConfigService } from '@nestjs/config'; import { EmailService } from '@services/email.service'; +import { GroupPermission } from 'src/entities/group_permission.entity'; @Module({ imports: [ UsersModule, PassportModule, - TypeOrmModule.forFeature([User, Organization, OrganizationUser]), + TypeOrmModule.forFeature([User, Organization, OrganizationUser, GroupPermission]), JwtModule.registerAsync({ useFactory: (config: ConfigService) => { return { diff --git a/server/src/modules/auth/query-auth.guard.ts b/server/src/modules/auth/query-auth.guard.ts index b8e744cad7..ebcc7b6c23 100644 --- a/server/src/modules/auth/query-auth.guard.ts +++ b/server/src/modules/auth/query-auth.guard.ts @@ -12,7 +12,7 @@ export class QueryAuthGuard extends AuthGuard('jwt') { const request = context.switchToHttp().getRequest(); // unauthenticated users should be able to to run queries of public apps - if (request.route.path === '/api/data_queries/:id/run') { + if (request.route.path === '/data_queries/:id/run') { const dataQuery = await this.dataQueriesService.findOne(request.params.id); const app = dataQuery.app; diff --git a/server/src/modules/casl/abilities/apps-ability.factory.ts b/server/src/modules/casl/abilities/apps-ability.factory.ts index b4adf7244d..b3d6e747cd 100644 --- a/server/src/modules/casl/abilities/apps-ability.factory.ts +++ b/server/src/modules/casl/abilities/apps-ability.factory.ts @@ -3,6 +3,7 @@ import { InferSubjects, AbilityBuilder, Ability, AbilityClass, ExtractSubjectTyp import { Injectable } from '@nestjs/common'; import { App } from 'src/entities/app.entity'; import { AppVersion } from 'src/entities/app_version.entity'; +import { UsersService } from 'src/services/users.service'; type Actions = | 'authorizeOauthForSource' @@ -32,21 +33,36 @@ export type AppsAbility = Ability<[Actions, Subjects]>; @Injectable() export class AppsAbilityFactory { + constructor(private usersService: UsersService) {} + async appsActions(user: User, params: any) { const { can, build } = new AbilityBuilder>(Ability as AbilityClass); - // Only admins can update app params such as name, friendly url & visibility - if (user.isAdmin) { - can('updateParams', App, { organizationId: user.organizationId }); + if (await this.usersService.userCan(user, 'create', 'App')) { can('createUsers', App, { organizationId: user.organizationId }); - can('deleteApp', App, { organizationId: user.organizationId }); - } - - // Only developers and admins can create new versions - if (user.isAdmin || user.isDeveloper) { can('createApp', App); can('cloneApp', App, { organizationId: user.organizationId }); + } + if (await this.usersService.userCan(user, 'read', 'App', params.id)) { + can('viewApp', App, { organizationId: user.organizationId }); + can('updateParams', App, { organizationId: user.organizationId }); + + can('fetchUsers', App, { organizationId: user.organizationId }); + can('fetchVersions', App, { organizationId: user.organizationId }); + + can('runQuery', App, { organizationId: user.organizationId }); + can('getQueries', App, { organizationId: user.organizationId }); + can('previewQuery', App, { organizationId: user.organizationId }); + + // policies for datasources + can('getDataSources', App, { organizationId: user.organizationId }); + can('authorizeOauthForSource', App, { + organizationId: user.organizationId, + }); + } + + if (await this.usersService.userCan(user, 'update', 'App', params.id)) { can('createVersions', App, { organizationId: user.organizationId }); can('updateVersions', App, { organizationId: user.organizationId }); @@ -58,24 +74,12 @@ export class AppsAbilityFactory { can('createDataSource', App, { organizationId: user.organizationId }); } - // All organization users can view the app users - can('fetchUsers', App, { organizationId: user.organizationId }); - can('fetchVersions', App, { organizationId: user.organizationId }); + if (await this.usersService.userCan(user, 'delete', 'App', params.id)) { + can('deleteApp', App, { organizationId: user.organizationId }); + } - // Can view public apps can('viewApp', App, { isPublic: true }); - can('viewApp', App, { organizationId: user.organizationId }); - - // if app belongs to org, queries can be run - can('runQuery', App, { organizationId: user.organizationId }); - can('getQueries', App, { organizationId: user.organizationId }); - can('previewQuery', App, { organizationId: user.organizationId }); - - // policies for datasources - can('getDataSources', App, { organizationId: user.organizationId }); - can('authorizeOauthForSource', App, { - organizationId: user.organizationId, - }); + can('runQuery', App, { isPublic: true }); return build({ detectSubjectType: (item) => item.constructor as ExtractSubjectType, diff --git a/server/src/modules/casl/casl-ability.factory.spec.ts b/server/src/modules/casl/casl-ability.factory.spec.ts deleted file mode 100644 index 613b677bb6..0000000000 --- a/server/src/modules/casl/casl-ability.factory.spec.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { CaslAbilityFactory } from './casl-ability.factory'; - -describe('CaslAbilityFactory', () => { - it('should be defined', () => { - expect(new CaslAbilityFactory()).toBeDefined(); - }); -}); diff --git a/server/src/modules/casl/casl-ability.factory.ts b/server/src/modules/casl/casl-ability.factory.ts index 7d806197a1..2181e4c2ec 100644 --- a/server/src/modules/casl/casl-ability.factory.ts +++ b/server/src/modules/casl/casl-ability.factory.ts @@ -2,9 +2,9 @@ import { User } from 'src/entities/user.entity'; import { OrganizationUser } from 'src/entities/organization_user.entity'; import { InferSubjects, AbilityBuilder, Ability, AbilityClass, ExtractSubjectType } from '@casl/ability'; import { Injectable } from '@nestjs/common'; -import { OrganizationUsersService } from '@services/organization_users.service'; +import { UsersService } from '@services/users.service'; -type Actions = 'changeRole' | 'archiveUser' | 'inviteUser'; +type Actions = 'changeRole' | 'archiveUser' | 'inviteUser' | 'accessGroupPermission'; type Subjects = InferSubjects | 'all'; @@ -12,29 +12,21 @@ export type AppAbility = Ability<[Actions, Subjects]>; @Injectable() export class CaslAbilityFactory { - constructor(private organizationUsersService: OrganizationUsersService) {} + constructor(private usersService: UsersService) {} async organizationUserActions(user: User, params: any) { const { can, build } = new AbilityBuilder>(Ability as AbilityClass); - const currentUserBelongsToSameOrg = await this.isSameOrganisation(user, params); - - if (user.isAdmin) can('inviteUser', User); - if (user.isAdmin && currentUserBelongsToSameOrg) { + const isAdmin = await this.usersService.hasGroup(user, 'admin'); + if (isAdmin) { + can('inviteUser', User); can('archiveUser', User); can('changeRole', User); + can('accessGroupPermission', User); } return build({ detectSubjectType: (item) => item.constructor as ExtractSubjectType, }); } - - async isSameOrganisation(currentUser, params) { - if (!params.id) return false; - const organizationUser = await this.organizationUsersService.findOne(params.id); - if (!organizationUser) return false; - - return organizationUser.organizationId === currentUser.organizationId; - } } diff --git a/server/src/modules/data_queries/data_queries.module.ts b/server/src/modules/data_queries/data_queries.module.ts index 551c1a0124..3deaa0a82d 100644 --- a/server/src/modules/data_queries/data_queries.module.ts +++ b/server/src/modules/data_queries/data_queries.module.ts @@ -15,10 +15,29 @@ import { AppVersion } from 'src/entities/app_version.entity'; import { AppUser } from 'src/entities/app_user.entity'; import { FolderApp } from 'src/entities/folder_app.entity'; import { AppCloneService } from '@services/app_clone.service'; +import { GroupPermission } from 'src/entities/group_permission.entity'; +import { AppGroupPermission } from 'src/entities/app_group_permission.entity'; +import { UsersService } from '@services/users.service'; +import { User } from 'src/entities/user.entity'; +import { OrganizationUser } from 'src/entities/organization_user.entity'; +import { Organization } from 'src/entities/organization.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([App, AppVersion, AppUser, DataQuery, Credential, DataSource, FolderApp]), + TypeOrmModule.forFeature([ + App, + AppVersion, + AppUser, + DataQuery, + Credential, + DataSource, + FolderApp, + GroupPermission, + AppGroupPermission, + User, + OrganizationUser, + Organization, + ]), CaslModule, ], providers: [ @@ -28,6 +47,7 @@ import { AppCloneService } from '@services/app_clone.service'; DataSourcesService, AppsService, AppCloneService, + UsersService, ], controllers: [DataQueriesController], }) diff --git a/server/src/modules/data_sources/data_sources.module.ts b/server/src/modules/data_sources/data_sources.module.ts index 2194ced1ce..ef6072ac52 100644 --- a/server/src/modules/data_sources/data_sources.module.ts +++ b/server/src/modules/data_sources/data_sources.module.ts @@ -15,10 +15,29 @@ import { DataQueriesService } from '@services/data_queries.service'; import { DataQuery } from 'src/entities/data_query.entity'; import { FolderApp } from 'src/entities/folder_app.entity'; import { AppCloneService } from '@services/app_clone.service'; +import { GroupPermission } from 'src/entities/group_permission.entity'; +import { AppGroupPermission } from 'src/entities/app_group_permission.entity'; +import { UsersService } from '@services/users.service'; +import { User } from 'src/entities/user.entity'; +import { OrganizationUser } from 'src/entities/organization_user.entity'; +import { Organization } from 'src/entities/organization.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([DataSource, DataQuery, Credential, App, AppVersion, AppUser, FolderApp]), + TypeOrmModule.forFeature([ + DataSource, + DataQuery, + Credential, + App, + AppVersion, + AppUser, + FolderApp, + GroupPermission, + AppGroupPermission, + User, + OrganizationUser, + Organization, + ]), CaslModule, ], providers: [ @@ -28,6 +47,7 @@ import { AppCloneService } from '@services/app_clone.service'; AppsService, DataQueriesService, AppCloneService, + UsersService, ], controllers: [DataSourcesController], }) diff --git a/server/src/modules/folders/folders.module.ts b/server/src/modules/folders/folders.module.ts index 9c9bdf1c56..75b6839ec5 100644 --- a/server/src/modules/folders/folders.module.ts +++ b/server/src/modules/folders/folders.module.ts @@ -5,10 +5,14 @@ import { Folder } from '../../entities/folder.entity'; import { FoldersController } from '../../controllers/folders.controller'; import { FoldersService } from '../../services/folders.service'; import { App } from 'src/entities/app.entity'; +import { UsersService } from '@services/users.service'; +import { User } from 'src/entities/user.entity'; +import { OrganizationUser } from 'src/entities/organization_user.entity'; +import { Organization } from 'src/entities/organization.entity'; @Module({ controllers: [FoldersController], - imports: [TypeOrmModule.forFeature([App, Folder, FolderApp])], - providers: [FoldersService], + imports: [TypeOrmModule.forFeature([App, Folder, FolderApp, User, OrganizationUser, Organization])], + providers: [FoldersService, UsersService], }) export class FoldersModule {} diff --git a/server/src/modules/group_permissions/group_permissions.module.ts b/server/src/modules/group_permissions/group_permissions.module.ts new file mode 100644 index 0000000000..85473dd9f9 --- /dev/null +++ b/server/src/modules/group_permissions/group_permissions.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { GroupPermission } from '../../../src/entities/group_permission.entity'; +import { UserGroupPermission } from 'src/entities/user_group_permission.entity'; +import { AppGroupPermission } from 'src/entities/app_group_permission.entity'; +import { GroupPermissionsController } from '../../controllers/group_permissions.controller'; +import { GroupPermissionsService } from '../../services/group_permissions.service'; +import { CaslModule } from '../casl/casl.module'; +import { UsersService } from '@services/users.service'; +import { User } from 'src/entities/user.entity'; +import { OrganizationUser } from 'src/entities/organization_user.entity'; +import { Organization } from 'src/entities/organization.entity'; +import { App } from 'src/entities/app.entity'; + +@Module({ + controllers: [GroupPermissionsController], + imports: [ + TypeOrmModule.forFeature([ + GroupPermission, + UserGroupPermission, + AppGroupPermission, + User, + OrganizationUser, + Organization, + App, + ]), + CaslModule, + ], + providers: [GroupPermissionsService, UsersService], +}) +export class GroupPermissionsModule {} diff --git a/server/src/modules/organizations/organizations.module.ts b/server/src/modules/organizations/organizations.module.ts index 7ab5123cfa..0d67116467 100644 --- a/server/src/modules/organizations/organizations.module.ts +++ b/server/src/modules/organizations/organizations.module.ts @@ -10,9 +10,10 @@ import { OrganizationUsersController } from '@controllers/organization_users.con import { UsersService } from 'src/services/users.service'; import { CaslModule } from '../casl/casl.module'; import { EmailService } from '@services/email.service'; +import { GroupPermission } from 'src/entities/group_permission.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Organization, OrganizationUser, User]), CaslModule], + imports: [TypeOrmModule.forFeature([Organization, OrganizationUser, User, GroupPermission]), CaslModule], providers: [OrganizationsService, OrganizationUsersService, UsersService, EmailService], controllers: [OrganizationsController, OrganizationUsersController], }) diff --git a/server/src/services/app.service.ts b/server/src/services/app.service.ts deleted file mode 100644 index 927d7cca0b..0000000000 --- a/server/src/services/app.service.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class AppService { - getHello(): string { - return 'Hello World!'; - } -} diff --git a/server/src/services/app_clone.service.ts b/server/src/services/app_clone.service.ts index 1d919e3f72..8556d505e6 100644 --- a/server/src/services/app_clone.service.ts +++ b/server/src/services/app_clone.service.ts @@ -7,6 +7,8 @@ import { AppVersion } from 'src/entities/app_version.entity'; import { DataSource } from 'src/entities/data_source.entity'; import { DataQuery } from 'src/entities/data_query.entity'; import { Credential } from 'src/entities/credential.entity'; +import { GroupPermission } from 'src/entities/group_permission.entity'; +import { AppGroupPermission } from 'src/entities/app_group_permission.entity'; @Injectable() export class AppCloneService { @@ -18,6 +20,7 @@ export class AppCloneService { await this.entityManager.transaction(async (manager) => { clonedApp = await this.createClonedAppForUser(manager, existingApp, user); await this.buildClonedAppAssociations(manager, clonedApp, existingApp); + await this.createAdminGroupPermissions(manager, clonedApp); }); return clonedApp; @@ -40,7 +43,7 @@ export class AppCloneService { return newApp; } - async buildClonedAppAssociations(manager, newApp: App, existingApp: App) { + async buildClonedAppAssociations(manager: EntityManager, newApp: App, existingApp: App) { const dataSourceMapping = {}; const newDefinition = existingApp.editingVersion?.definition; @@ -90,13 +93,13 @@ export class AppCloneService { }); } - async cloneOptionsWithNewCredentials(manager, options) { + async cloneOptionsWithNewCredentials(manager: EntityManager, options: any) { for (const key of Object.keys(options)) { if ('credential_id' in options[key]) { const existingCredential = await manager.findOne(Credential, { id: options[key]['credential_id'], }); - const newCredential = await manager.create(Credential, { + const newCredential = manager.create(Credential, { valueCiphertext: existingCredential.valueCiphertext, }); await manager.save(newCredential); @@ -106,4 +109,29 @@ export class AppCloneService { return options; } + + async createAdminGroupPermissions(manager: EntityManager, app: App) { + const orgDefaultGroupPermissions = await manager.find(GroupPermission, { + where: { + organizationId: app.organizationId, + group: 'admin', + }, + }); + + const adminPermissions = { + read: true, + update: true, + delete: true, + }; + + for (const groupPermission of orgDefaultGroupPermissions) { + const appGroupPermission = manager.create(AppGroupPermission, { + groupPermissionId: groupPermission.id, + appId: app.id, + ...adminPermissions, + }); + + return await manager.save(AppGroupPermission, appGroupPermission); + } + } } diff --git a/server/src/services/apps.service.ts b/server/src/services/apps.service.ts index 0e58737557..d7801b100a 100644 --- a/server/src/services/apps.service.ts +++ b/server/src/services/apps.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { App } from 'src/entities/app.entity'; -import { Repository } from 'typeorm'; +import { createQueryBuilder, Repository } from 'typeorm'; import { User } from 'src/entities/user.entity'; import { AppUser } from 'src/entities/app_user.entity'; import { AppVersion } from 'src/entities/app_version.entity'; @@ -10,6 +10,10 @@ import { DataSource } from 'src/entities/data_source.entity'; import { DataQuery } from 'src/entities/data_query.entity'; // eslint-disable-next-line @typescript-eslint/no-unused-vars import { AppCloneService } from './app_clone.service'; +import { GroupPermission } from 'src/entities/group_permission.entity'; +import { AppGroupPermission } from 'src/entities/app_group_permission.entity'; +import { UserGroupPermission } from 'src/entities/user_group_permission.entity'; +import { UsersService } from './users.service'; @Injectable() export class AppsService { @@ -32,7 +36,14 @@ export class AppsService { @InjectRepository(FolderApp) private folderAppsRepository: Repository, - private AppCloneService: AppCloneService + @InjectRepository(GroupPermission) + private groupPermissionsRepository: Repository, + + @InjectRepository(AppGroupPermission) + private appGroupPermissionsRepository: Repository, + + private AppCloneService: AppCloneService, + private usersService: UsersService ) {} async find(id: string): Promise { @@ -77,35 +88,99 @@ export class AppsService { }) ); + await this.createAdminGroupPermissions(app); + return app; } + async createAdminGroupPermissions(app: App) { + const orgDefaultGroupPermissions = await this.groupPermissionsRepository.find({ + where: { + organizationId: app.organizationId, + group: 'admin', + }, + }); + + for (const groupPermission of orgDefaultGroupPermissions) { + const appGroupPermission = this.appGroupPermissionsRepository.create({ + groupPermissionId: groupPermission.id, + appId: app.id, + ...this.determineDefaultAppGroupPermissions(groupPermission.group), + }); + + await this.appGroupPermissionsRepository.save(appGroupPermission); + } + } + + determineDefaultAppGroupPermissions(group: string): { + read: boolean; + update: boolean; + delete: boolean; + } { + switch (group) { + case 'all_users': + return { read: true, update: false, delete: false }; + case 'admin': + return { read: true, update: true, delete: true }; + default: + throw `${group} is not a default group`; + } + } + async clone(existingApp: App, user: User): Promise { const clonedApp = await this.AppCloneService.perform(existingApp, user); return clonedApp; } - async count(user: User) { - return await this.appsRepository.count({ - where: { + async count(user: User): Promise { + return await createQueryBuilder(App, 'apps') + .innerJoin('apps.groupPermissions', 'group_permissions') + .innerJoin('apps.appGroupPermissions', 'app_group_permissions') + .innerJoin( + UserGroupPermission, + 'user_group_permissions', + 'app_group_permissions.group_permission_id = user_group_permissions.group_permission_id' + ) + .where('user_group_permissions.user_id = :userId', { userId: user.id }) + .andWhere('app_group_permissions.read = :value', { value: true }) + .orWhere('apps.is_public = :value AND apps.organization_id = :organizationId', { + value: true, organizationId: user.organizationId, - }, - }); + }) + .getCount(); } async all(user: User, page: number): Promise { - return await this.appsRepository.find({ - relations: ['user'], - where: { + const viewableAppsQb = await createQueryBuilder(App, 'apps') + .innerJoin('apps.groupPermissions', 'group_permissions') + .innerJoinAndSelect('apps.appGroupPermissions', 'app_group_permissions') + .innerJoin( + UserGroupPermission, + 'user_group_permissions', + 'app_group_permissions.group_permission_id = user_group_permissions.group_permission_id' + ) + .where('user_group_permissions.user_id = :userId', { userId: user.id }) + .andWhere('app_group_permissions.read = :value', { value: true }) + .orWhere('apps.is_public = :value AND apps.organization_id = :organizationId', { + value: true, organizationId: user.organizationId, - }, - take: 10, - skip: 10 * (page - 1), - order: { - createdAt: 'DESC', - }, - }); + }); + + // FIXME: + // TypeORM gives error when using query builder with order by + // https://github.com/typeorm/typeorm/issues/8213 + // hence sorting results in memory + if (page) { + const viewableApps = await viewableAppsQb + .take(10) + .skip(10 * (page - 1)) + .getMany(); + + return viewableApps.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + } + + return await viewableAppsQb.orderBy('apps.created_at', 'DESC').getMany(); } async update(user: User, appId: string, params: any) { diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 22ed319869..f3f2abcfe5 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -20,7 +20,6 @@ export class AuthService { async validateUser(email: string, password: string): Promise { const user = await this.usersService.findByEmail(email); - if (!user) return null; const isVerified = await bcrypt.compare(password, user.password); @@ -39,7 +38,7 @@ export class AuthService { email: user.email, first_name: user.firstName, last_name: user.lastName, - role: user.role, + admin: await this.usersService.hasGroup(user, 'admin'), }; } else { throw new UnauthorizedException('Invalid credentials'); @@ -54,9 +53,9 @@ export class AuthService { const { email } = params; const organization = await this.organizationsService.create('Untitled organization'); - const user = await this.usersService.create({ email }, organization); + const user = await this.usersService.create({ email }, organization, ['all_users', 'admin']); // eslint-disable-next-line @typescript-eslint/no-unused-vars - const organizationUser = await this.organizationUsersService.create(user, organization, 'admin'); + const organizationUser = await this.organizationUsersService.create(user, organization); this.emailService.sendWelcomeEmail(user.email, user.firstName, user.invitationToken); @@ -75,7 +74,10 @@ export class AuthService { if (!user) { throw new NotFoundException('Invalid token'); } else { - this.usersService.update(user.id, { password, forgotPasswordToken: null }); + this.usersService.update(user.id, { + password, + forgotPasswordToken: null, + }); } } } diff --git a/server/src/services/folders.service.ts b/server/src/services/folders.service.ts index 290deb4b3c..aef1ae4525 100644 --- a/server/src/services/folders.service.ts +++ b/server/src/services/folders.service.ts @@ -2,9 +2,11 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { App } from 'src/entities/app.entity'; import { FolderApp } from 'src/entities/folder_app.entity'; -import { Repository } from 'typeorm'; +import { UserGroupPermission } from 'src/entities/user_group_permission.entity'; +import { createQueryBuilder, Repository } from 'typeorm'; import { User } from '../../src/entities/user.entity'; import { Folder } from '../entities/folder.entity'; +import { UsersService } from './users.service'; @Injectable() export class FoldersService { @@ -14,7 +16,8 @@ export class FoldersService { @InjectRepository(FolderApp) private folderAppsRepository: Repository, @InjectRepository(App) - private appsRepository: Repository + private appsRepository: Repository, + private usersService: UsersService ) {} async create(user: User, folderName): Promise { @@ -29,15 +32,47 @@ export class FoldersService { } async all(user: User): Promise { - return await this.foldersRepository.find({ - where: { + if (await this.usersService.hasGroup(user, 'admin')) { + return await this.foldersRepository.find({ + where: { + organizationId: user.organizationId, + }, + relations: ['folderApps'], + order: { + name: 'ASC', + }, + }); + } + + const allViewableApps = await createQueryBuilder(App, 'apps') + .select('apps.id') + .innerJoin('apps.groupPermissions', 'group_permissions') + .innerJoin('apps.appGroupPermissions', 'app_group_permissions') + .innerJoin( + UserGroupPermission, + 'user_group_permissions', + 'app_group_permissions.group_permission_id = user_group_permissions.group_permission_id' + ) + .where('user_group_permissions.user_id = :userId', { userId: user.id }) + .andWhere('app_group_permissions.read = :value', { value: true }) + .orWhere('apps.is_public = :value and apps.organization_id = :organizationId', { + value: true, organizationId: user.organizationId, - }, - relations: ['folderApps'], - order: { - name: 'ASC', - }, - }); + }) + .getMany(); + const allViewableAppIds = allViewableApps.map((app) => app.id); + + return await createQueryBuilder(Folder, 'folders') + .innerJoinAndSelect('folders.folderApps', 'folder_apps') + .where('folder_apps.app_id IN(:...allViewableAppIds)', { + allViewableAppIds, + }) + .andWhere('folders.organization_id = :organizationId', { + organizationId: user.organizationId, + }) + .orWhere('folder_apps.app_id IS NULL') + .orderBy('folders.name', 'ASC') + .getMany(); } async findOne(folderId: string): Promise { @@ -45,15 +80,32 @@ export class FoldersService { } async userAppCount(user: User, folder: Folder) { - const result = await this.foldersRepository - .createQueryBuilder('folder') - .where('id = :id', { id: folder.id }) - .loadRelationCountAndMap('folder.appCount', 'folder.apps', 'apps', (qb) => - qb.andWhere('apps.user_id = :user_id', { user_id: user.id }) - ) - .getMany(); + const folderApps = await this.folderAppsRepository.find({ + where: { + folderId: folder.id, + }, + }); + const folderAppIds = folderApps.map((folderApp) => folderApp.appId); - return result[0].appCount; + if (folderAppIds.length == 0) { + return 0; + } + + return await createQueryBuilder(App, 'apps') + .innerJoin('apps.groupPermissions', 'group_permissions') + .innerJoinAndSelect('apps.appGroupPermissions', 'app_group_permissions') + .innerJoin( + UserGroupPermission, + 'user_group_permissions', + 'app_group_permissions.group_permission_id = user_group_permissions.group_permission_id' + ) + .where('user_group_permissions.user_id = :userId', { userId: user.id }) + .andWhere('app_group_permissions.read = :value', { value: true }) + .andWhere('app_group_permissions.app_id IN(:...folderAppIds)', { + folderAppIds, + }) + .orWhere('apps.is_public = :value', { value: true }) + .getCount(); } async getAppsFor(user: User, folder: Folder, page: number): Promise { @@ -62,22 +114,37 @@ export class FoldersService { folderId: folder.id, }, }); + const folderAppIds = folderApps.map((folderApp) => folderApp.appId); - const apps = await this.appsRepository.findByIds( - folderApps.map((folderApp) => folderApp.appId), - { - where: { - user, - }, - relations: ['user'], - take: 10, - skip: 10 * (page - 1), - order: { - createdAt: 'DESC', - }, - } - ); + let viewableApps: App[]; - return apps; + if (folderAppIds.length == 0) { + viewableApps = []; + } else { + viewableApps = await createQueryBuilder(App, 'apps') + .innerJoin('apps.groupPermissions', 'group_permissions') + .innerJoinAndSelect('apps.appGroupPermissions', 'app_group_permissions') + .innerJoin( + UserGroupPermission, + 'user_group_permissions', + 'app_group_permissions.group_permission_id = user_group_permissions.group_permission_id' + ) + .where('user_group_permissions.user_id = :userId', { userId: user.id }) + .andWhere('app_group_permissions.read = :value', { value: true }) + .andWhere('app_group_permissions.app_id IN(:...folderAppIds)', { + folderAppIds, + }) + .orWhere('apps.is_public = :value', { value: true }) + .take(10) + .skip(10 * (page - 1)) + // .orderBy('apps.created_at', 'DESC') + .getMany(); + } + + // FIXME: + // TypeORM gives error when using query builder with order by + // https://github.com/typeorm/typeorm/issues/8213 + // hence sorting results in memory + return viewableApps.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); } } diff --git a/server/src/services/group_permissions.service.ts b/server/src/services/group_permissions.service.ts new file mode 100644 index 0000000000..097650897e --- /dev/null +++ b/server/src/services/group_permissions.service.ts @@ -0,0 +1,239 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, createQueryBuilder, getManager, In, Not } from 'typeorm'; +import { User } from 'src/entities/user.entity'; +import { GroupPermission } from 'src/entities/group_permission.entity'; +import { App } from 'src/entities/app.entity'; +import { AppGroupPermission } from 'src/entities/app_group_permission.entity'; +import { UserGroupPermission } from 'src/entities/user_group_permission.entity'; +import { UsersService } from './users.service'; + +@Injectable() +export class GroupPermissionsService { + constructor( + @InjectRepository(GroupPermission) + private groupPermissionsRepository: Repository, + + @InjectRepository(AppGroupPermission) + private appGroupPermissionsRepository: Repository, + + @InjectRepository(UserGroupPermission) + private userGroupPermissionsRepository: Repository, + + @InjectRepository(User) + private userRepository: Repository, + + @InjectRepository(App) + private appRepository: Repository, + + private usersService: UsersService + ) {} + + async create(user: User, group: string): Promise { + return this.groupPermissionsRepository.save( + this.groupPermissionsRepository.create({ + organizationId: user.organizationId, + group: group, + }) + ); + } + + async destroy(user: User, groupPermissionId: string) { + let result; + + const groupPermission = await this.groupPermissionsRepository.findOne({ + id: groupPermissionId, + }); + + if (groupPermission.group == 'admin' || groupPermission.group == 'all_users') { + throw new BadRequestException('Cannot delete default group'); + } + getManager().transaction(async (manager) => { + const relationalEntitiesToBeDeleted = [AppGroupPermission, UserGroupPermission]; + + for (const entityToDelete of relationalEntitiesToBeDeleted) { + const entities = await manager.find(entityToDelete, { + where: { groupPermissionId }, + }); + + for (const entity of entities) { + await manager.delete(entityToDelete, entity.id); + } + } + + result = await manager.delete(GroupPermission, { + organizationId: user.organizationId, + id: groupPermissionId, + }); + }); + return result; + } + + async updateAppGroupPermission(user: User, groupPermissionId: string, appGroupPermissionId: string, actions: any) { + const appGroupPermission = await this.appGroupPermissionsRepository.findOne({ + id: appGroupPermissionId, + groupPermissionId: groupPermissionId, + }); + const groupPermission = await this.groupPermissionsRepository.findOne({ + id: appGroupPermission.groupPermissionId, + }); + + if (groupPermission.organizationId !== user.organizationId) { + throw new BadRequestException(); + } + if (groupPermission.group == 'admin') { + throw new BadRequestException('Cannot update admin group'); + } + + return this.appGroupPermissionsRepository.update(appGroupPermissionId, actions); + } + + async update(user: User, groupPermissionId: string, body: any) { + const groupPermission = await this.groupPermissionsRepository.findOne({ + id: groupPermissionId, + organizationId: user.organizationId, + }); + + await this.appGroupPermissionsRepository.manager.transaction(async (manager) => { + if (body.remove_apps) { + if (groupPermission.group == 'admin') { + throw new BadRequestException('Cannot update admin group'); + } + for (const appId of body.remove_apps) { + manager.delete(AppGroupPermission, { + appId: appId, + groupPermissionId: groupPermissionId, + }); + } + } + + if (body.add_apps) { + if (groupPermission.group == 'admin') { + throw new BadRequestException('Cannot update admin group'); + } + for (const appId of body.add_apps) { + manager.save( + AppGroupPermission, + manager.create(AppGroupPermission, { + appId: appId, + groupPermissionId: groupPermissionId, + read: true, + }) + ); + } + } + }); + + await this.userGroupPermissionsRepository.manager.transaction(async (manager) => { + if (body.remove_users) { + for (const userId of body.remove_users) { + const params = { + removeGroups: [groupPermission.group], + }; + await this.usersService.update(userId, params, manager); + } + } + + if (body.add_users) { + for (const userId of body.add_users) { + const params = { + addGroups: [groupPermission.group], + }; + await this.usersService.update(userId, params, manager); + } + } + }); + + return this.groupPermissionsRepository.findOne({ id: groupPermissionId }); + } + + async findOne(user: User, groupPermissionId: string): Promise { + return this.groupPermissionsRepository.findOne({ + organizationId: user.organizationId, + id: groupPermissionId, + }); + } + + async findAll(user: User): Promise { + return this.groupPermissionsRepository.find({ + organizationId: user.organizationId, + }); + } + + async findApps(user: User, groupPermissionId: string): Promise { + return createQueryBuilder(App, 'apps') + .innerJoinAndSelect('apps.groupPermissions', 'group_permissions') + .innerJoinAndSelect('apps.appGroupPermissions', 'app_group_permissions') + .where('group_permissions.id = :groupPermissionId', { + groupPermissionId, + }) + .andWhere('group_permissions.organization_id = :organizationId', { + organizationId: user.organizationId, + }) + .andWhere('app_group_permissions.group_permission_id = :groupPermissionId', { groupPermissionId }) + .orderBy('apps.created_at', 'DESC') + .getMany(); + } + + async findAddableApps(user: User, groupPermissionId: string): Promise { + const groupPermission = await this.groupPermissionsRepository.findOne({ + id: groupPermissionId, + organizationId: user.organizationId, + }); + + const appsInGroup = await groupPermission.apps; + const appsInGroupIds = appsInGroup.map((u) => u.id); + + return await this.appRepository.find({ + where: { + id: Not(In(appsInGroupIds)), + organizationId: user.organizationId, + }, + loadEagerRelations: false, + relations: ['groupPermissions', 'appGroupPermissions'], + }); + } + + async findUsers(user: User, groupPermissionId: string): Promise { + return createQueryBuilder(User, 'users') + .innerJoinAndSelect('users.groupPermissions', 'group_permissions') + .innerJoinAndSelect('users.userGroupPermissions', 'user_group_permissions') + .where('group_permissions.id = :groupPermissionId', { + groupPermissionId, + }) + .andWhere('group_permissions.organization_id = :organizationId', { + organizationId: user.organizationId, + }) + .andWhere('user_group_permissions.group_permission_id = :groupPermissionId', { groupPermissionId }) + .orderBy('users.created_at', 'DESC') + .getMany(); + } + + async findAddableUsers(user: User, groupPermissionId: string): Promise { + const groupPermission = await this.groupPermissionsRepository.findOne({ + id: groupPermissionId, + organizationId: user.organizationId, + }); + + const userInGroup = await groupPermission.users; + const usersInGroupIds = userInGroup.map((u) => u.id); + + const adminUsers = await createQueryBuilder(UserGroupPermission, 'user_group_permissions') + .innerJoin( + GroupPermission, + 'group_permissions', + 'group_permissions.id = user_group_permissions.group_permission_id' + ) + .where('group_permissions.group = :group', { group: 'admin' }) + .andWhere('group_permissions.organization_id = :organizationId', { + organizationId: user.organizationId, + }) + .getMany(); + const adminUserIds = adminUsers.map((u) => u.userId); + + return await this.userRepository.find({ + id: Not(In([...usersInGroupIds, ...adminUserIds])), + organizationId: user.organizationId, + }); + } +} diff --git a/server/src/services/organization_users.service.ts b/server/src/services/organization_users.service.ts index cee481bb40..6c877dcf9c 100644 --- a/server/src/services/organization_users.service.ts +++ b/server/src/services/organization_users.service.ts @@ -28,8 +28,8 @@ export class OrganizationUsersService { email: params['email'], }; - const user = await this.usersService.create(userParams, currentUser.organization); - const organizationUser = await this.create(user, currentUser.organization, params.role); + const user = await this.usersService.create(userParams, currentUser.organization, ['all_users']); + const organizationUser = await this.create(user, currentUser.organization); this.emailService.sendOrganizationUserWelcomeEmail( user.email, @@ -41,12 +41,12 @@ export class OrganizationUsersService { return organizationUser; } - async create(user: User, organization: Organization, role: string): Promise { + async create(user: User, organization: Organization): Promise { return await this.organizationUsersRepository.save( this.organizationUsersRepository.create({ user, organization, - role, + role: 'all_users', createdAt: new Date(), updatedAt: new Date(), }) diff --git a/server/src/services/organizations.service.ts b/server/src/services/organizations.service.ts index 6747426743..fad7689c75 100644 --- a/server/src/services/organizations.service.ts +++ b/server/src/services/organizations.service.ts @@ -3,6 +3,8 @@ import { InjectRepository } from '@nestjs/typeorm'; import { OrganizationUser } from '../entities/organization_user.entity'; import { Repository } from 'typeorm'; import { Organization } from 'src/entities/organization.entity'; +import { UsersService } from './users.service'; +import { GroupPermission } from 'src/entities/group_permission.entity'; @Injectable() export class OrganizationsService { @@ -10,17 +12,40 @@ export class OrganizationsService { @InjectRepository(Organization) private organizationsRepository: Repository, @InjectRepository(OrganizationUser) - private organizationUsersRepository: Repository + private organizationUsersRepository: Repository, + @InjectRepository(GroupPermission) + private groupPermissionsRepository: Repository, + private usersService: UsersService ) {} async create(name: string): Promise { - return this.organizationsRepository.save( + const organization = await this.organizationsRepository.save( this.organizationsRepository.create({ name, createdAt: new Date(), updatedAt: new Date(), }) ); + + await this.createDefaultGroupPermissionsForOrganization(organization); + + return organization; + } + + async createDefaultGroupPermissionsForOrganization(organization: Organization) { + const defaultGroups = ['all_users', 'admin']; + const createdGroupPermissions = []; + + for (const group of defaultGroups) { + const groupPermission = this.groupPermissionsRepository.create({ + organizationId: organization.id, + group: group, + }); + await this.groupPermissionsRepository.save(groupPermission); + createdGroupPermissions.push(groupPermission); + } + + return createdGroupPermissions; } async fetchUsers(user: any): Promise { @@ -42,7 +67,7 @@ export class OrganizationsService { status: orgUser.status, }; - if (user.isAdmin && orgUser.user.invitationToken) + if (this.usersService.hasGroup(user, 'admin') && orgUser.user.invitationToken) serializedUser['invitationToken'] = orgUser.user.invitationToken; serializedUsers.push(serializedUser); diff --git a/server/src/services/seeds.service.ts b/server/src/services/seeds.service.ts index 10cd4bbb04..978433bbee 100644 --- a/server/src/services/seeds.service.ts +++ b/server/src/services/seeds.service.ts @@ -3,44 +3,74 @@ import { EntityManager } from 'typeorm/entity-manager/EntityManager'; import { User } from '../entities/user.entity'; import { Organization } from '../entities/organization.entity'; import { OrganizationUser } from '../entities/organization_user.entity'; +import { GroupPermission } from 'src/entities/group_permission.entity'; +import { UserGroupPermission } from 'src/entities/user_group_permission.entity'; @Injectable() export class SeedsService { constructor(private readonly entityManager: EntityManager) {} async perform(): Promise { - const defaultUser = await this.entityManager.findOne(User, { - email: 'dev@tooljet.io', - }); + await this.entityManager.transaction(async (manager) => { + const defaultUser = await manager.findOne(User, { + email: 'dev@tooljet.io', + }); - if (defaultUser) { - console.log('Default user already present. Skipping seed.'); - return; + if (defaultUser) { + console.log('Default user already present. Skipping seed.'); + return; + } + + const organization = manager.create(Organization, { + name: 'My organization', + }); + + await manager.save(organization); + + const user = manager.create(User, { + firstName: 'The', + lastName: 'Developer', + email: 'dev@tooljet.io', + password: 'password', + organizationId: organization.id, + }); + + await manager.save(user); + + // TODO: Remove role usage + const organizationUser = manager.create(OrganizationUser, { + organizationId: organization.id, + userId: user.id, + role: 'all_users', + status: 'active', + }); + + await manager.save(organizationUser); + + await this.createDefaultUserGroups(manager, user); + }); + } + + async createDefaultUserGroups(manager: EntityManager, user: User): Promise { + const defaultGroups = ['all_users', 'admin']; + for (const group of defaultGroups) { + await this.createGroupAndAssociateUser(group, manager, user); } + } - const organization = this.entityManager.create(Organization, { - name: 'My organization', + async createGroupAndAssociateUser(group: string, manager: EntityManager, user: User): Promise { + const groupPermission = manager.create(GroupPermission, { + organizationId: user.organizationId, + group: group, }); - await this.entityManager.save(organization); + await manager.save(groupPermission); - const user = this.entityManager.create(User, { - firstName: 'The', - lastName: 'Developer', - email: 'dev@tooljet.io', - password: 'password', - organizationId: organization.id, - }); - - await this.entityManager.save(user); - - const organizationUser = this.entityManager.create(OrganizationUser, { - organizationId: organization.id, + const userGroupPermission = manager.create(UserGroupPermission, { + groupPermissionId: groupPermission.id, userId: user.id, - role: 'admin', - status: 'active', }); - await this.entityManager.save(organizationUser); + await manager.save(userGroupPermission); } } diff --git a/server/src/services/users.service.ts b/server/src/services/users.service.ts index 601392bf0a..cee3c6d7c5 100644 --- a/server/src/services/users.service.ts +++ b/server/src/services/users.service.ts @@ -2,8 +2,12 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { User } from '../entities/user.entity'; import { Organization } from 'src/entities/organization.entity'; -import { Repository } from 'typeorm'; +import { createQueryBuilder, EntityManager, getManager, getRepository, In, Repository } from 'typeorm'; import { OrganizationUser } from '../entities/organization_user.entity'; +import { AppGroupPermission } from 'src/entities/app_group_permission.entity'; +import { UserGroupPermission } from 'src/entities/user_group_permission.entity'; +import { GroupPermission } from 'src/entities/group_permission.entity'; +import { BadRequestException } from '@nestjs/common'; const uuid = require('uuid'); const bcrypt = require('bcrypt'); @@ -35,14 +39,15 @@ export class UsersService { }); } - async create(userParams: any, organization): Promise { + async create(userParams: any, organization: Organization, groups?: string[]): Promise { const password = uuid.v4(); const invitationToken = uuid.v4(); const { email, firstName, lastName } = userParams; + let user: User; - return this.usersRepository.save( - this.usersRepository.create({ + await getManager().transaction(async (manager) => { + user = manager.create(User, { email, firstName, lastName, @@ -51,8 +56,28 @@ export class UsersService { organizationId: organization.id, createdAt: new Date(), updatedAt: new Date(), - }) - ); + }); + await manager.save(user); + + for (const group of groups) { + const orgGroupPermission = await manager.findOne(GroupPermission, { + organizationId: organization.id, + group: group, + }); + + if (orgGroupPermission) { + const userGroupPermission = manager.create(UserGroupPermission, { + groupPermissionId: orgGroupPermission.id, + userId: user.id, + }); + manager.save(userGroupPermission); + } else { + throw new BadRequestException(`${group} group does not exist for current organization`); + } + } + }); + + return user; } async setupAccountFromInvitationToken(params: any) { @@ -65,19 +90,30 @@ export class UsersService { if (user) { // beforeUpdate hook will not trigger if using update method of repository - await this.usersRepository.save(Object.assign(user, { firstName, lastName, password, invitationToken: null })); + await this.usersRepository.save( + Object.assign(user, { + firstName, + lastName, + password, + invitationToken: null, + }) + ); const organizationUser = user.organizationUsers[0]; - this.organizationUsersRepository.update(organizationUser.id, { status: 'active' }); + this.organizationUsersRepository.update(organizationUser.id, { + status: 'active', + }); if (newSignup) { - this.organizationsRepository.update(user.organizationId, { name: organization }); + this.organizationsRepository.update(user.organizationId, { + name: organization, + }); } } } - async update(userId: string, params: any) { - const { forgotPasswordToken, password, firstName, lastName } = params; + async update(userId: string, params: any, manager?: EntityManager) { + const { forgotPasswordToken, password, firstName, lastName, addGroups, removeGroups } = params; const hashedPassword = password ? bcrypt.hashSync(password, 10) : undefined; @@ -93,6 +129,157 @@ export class UsersService { updateableParams[key] === undefined ? delete updateableParams[key] : {} ); - return await this.usersRepository.update(userId, updateableParams); + let user: User; + + const performUpdateInTransaction = async (manager) => { + await manager.update(User, userId, { ...updateableParams }); + user = await manager.findOne(User, { id: userId }); + + await this.removeUserGroupPermissionsIfExists(manager, user, removeGroups); + + await this.addUserGroupPermissions(manager, user, addGroups); + }; + + if (manager) { + await performUpdateInTransaction(manager); + } else { + await getManager().transaction(async (manager) => { + await performUpdateInTransaction(manager); + }); + } + + return user; + } + + async addUserGroupPermissions(manager: EntityManager, user: User, addGroups: string[]) { + if (addGroups) { + const orgGroupPermissions = await this.groupPermissionsForOrganization(user.organizationId); + + for (const group of addGroups) { + const orgGroupPermission = orgGroupPermissions.find((permission) => permission.group == group); + + if (orgGroupPermission) { + const userGroupPermission = manager.create(UserGroupPermission, { + groupPermissionId: orgGroupPermission.id, + userId: user.id, + }); + manager.save(userGroupPermission); + } else { + throw new BadRequestException(`${group} group does not exist for current organization`); + } + } + } + } + + async removeUserGroupPermissionsIfExists(manager: EntityManager, user: User, removeGroups: string[]) { + if (removeGroups) { + await this.throwErrorIfRemovingLastActiveAdmin(user, removeGroups); + if (removeGroups.includes('all_users')) { + throw new BadRequestException('Cannot remove user from default group.'); + } + + const groupPermissions = await manager.find(GroupPermission, { + group: In(removeGroups), + organizationId: user.organizationId, + }); + const groupIdsToMaybeRemove = groupPermissions.map((permission) => permission.id); + + await manager.delete(UserGroupPermission, { + groupPermissionId: In(groupIdsToMaybeRemove), + userId: user.id, + }); + } + } + + async throwErrorIfRemovingLastActiveAdmin(user: User, removeGroups: string[]) { + const removingAdmin = removeGroups.includes('admin'); + if (!removingAdmin) return; + + const result = await createQueryBuilder(User, 'users') + .innerJoin('users.groupPermissions', 'group_permissions') + .innerJoin('users.organizationUsers', 'organization_users') + .where('organization_users.user_id != :userId', { userId: user.id }) + .andWhere('organization_users.status = :status', { status: 'active' }) + .andWhere('group_permissions.group = :group', { group: 'admin' }) + .andWhere('group_permissions.organization_id = :organizationId', { + organizationId: user.organizationId, + }) + .getCount(); + + if (result == 0) throw new BadRequestException('Atleast one active admin is required.'); + } + + async hasGroup(user: User, group: string, organizationId?: string): Promise { + // Currently user can be part of single organization and + // the organization id is present on the user itself + const orgId = organizationId || user.organizationId; + + const result = await createQueryBuilder(GroupPermission, 'group_permissions') + .innerJoin('group_permissions.userGroupPermission', 'user_group_permissions') + .where('group_permissions.organization_id = :organizationId', { + organizationId: orgId, + }) + .andWhere('group_permissions.group = :group ', { group }) + .andWhere('user_group_permissions.user_id = :userId', { userId: user.id }) + .getCount(); + + return result > 0; + } + + async userCan(user: User, action: string, entityName: string, resourceId?: string): Promise { + switch (entityName) { + case 'App': + if (action == 'create') { + return await this.hasGroup(user, 'admin'); + } else { + return this.canAnyGroupPerformAction(action, await this.appGroupPermissions(user, resourceId)); + } + + default: + return false; + } + } + + canAnyGroupPerformAction(action: string, permissions: AppGroupPermission[]): boolean { + return permissions.some((p) => p[action.toLowerCase()]); + } + + async groupPermissions(user: User, organizationId?: string): Promise { + const orgUserGroupPermissions = await this.userGroupPermissions(user, organizationId); + const groupIds = orgUserGroupPermissions.map((p) => p.groupPermissionId); + const groupPermissionRepository = getRepository(GroupPermission); + + return await groupPermissionRepository.findByIds(groupIds); + } + + async groupPermissionsForOrganization(organizationId: string) { + const groupPermissionRepository = getRepository(GroupPermission); + + return await groupPermissionRepository.find({ organizationId }); + } + + async appGroupPermissions(user: User, appId: string, organizationId?: string): Promise { + const orgUserGroupPermissions = await this.userGroupPermissions(user, organizationId); + const groupIds = orgUserGroupPermissions.map((p) => p.groupPermissionId); + const appGroupPermissionRepository = getRepository(AppGroupPermission); + + return await appGroupPermissionRepository.find({ + groupPermissionId: In(groupIds), + appId: appId, + }); + } + + async userGroupPermissions(user: User, organizationId?: string): Promise { + // Currently user can be part of single organization + // and hence we can use organization_id on user entity + const orgId = organizationId || user.organizationId; + + return await createQueryBuilder(UserGroupPermission, 'user_group_permissions') + .innerJoin('user_group_permissions.groupPermission', 'group_permissions') + .where('group_permissions.organization_id = :organizationId', { + organizationId: orgId, + }) + .andWhere('user_group_permissions.user_id = :userId', { userId: user.id }) + .getMany(); } } diff --git a/server/test/controllers/app.e2e-spec.ts b/server/test/controllers/app.e2e-spec.ts index 32e1bd522d..c36723d078 100644 --- a/server/test/controllers/app.e2e-spec.ts +++ b/server/test/controllers/app.e2e-spec.ts @@ -4,14 +4,10 @@ import { INestApplication } from '@nestjs/common'; import { Repository } from 'typeorm'; import { User } from 'src/entities/user.entity'; import { clearDB, createUser, createNestAppInstance } from '../test.helper'; -import { Organization } from 'src/entities/organization.entity'; -import { OrganizationUser } from 'src/entities/organization_user.entity'; describe('Authentication', () => { let app: INestApplication; let userRepository: Repository; - let organizationRepository: Repository; - let organizationUsersRepository: Repository; beforeEach(async () => { await clearDB(); @@ -22,34 +18,34 @@ describe('Authentication', () => { app = await createNestAppInstance(); userRepository = app.get('UserRepository'); - organizationRepository = app.get('OrganizationRepository'); - organizationUsersRepository = app.get('OrganizationUserRepository'); }); it('should create new users', async () => { const response = await request(app.getHttpServer()).post('/signup').send({ email: 'test@tooljet.io' }); - expect(response.statusCode).toBe(201); const id = response.body['id']; const user = await userRepository.findOne(id, { relations: ['organization'] }); expect(user.organization.name).toBe('Untitled organization'); - const orgUser = user.organizationUsers[0]; - expect(orgUser.role).toBe('admin'); + + const groupPermissions = await user.groupPermissions; + const groupNames = groupPermissions.map((x) => x.group); + + expect(new Set(['all_users', 'admin'])).toEqual(new Set(groupNames)); }); - it(`authenticate if valid credentials`, async () => { - return request(app.getHttpServer()) + it('authenticate if valid credentials', async () => { + await request(app.getHttpServer()) .post('/authenticate') .send({ email: 'admin@tooljet.io', password: 'password' }) .expect(201); }); - it(`throw 401 if invalid credentials`, async () => { - return request(app.getHttpServer()) + it('throw 401 if invalid credentials', async () => { + await request(app.getHttpServer()) .post('/authenticate') - .send({ email: 'adnin@tooljet.io', password: 'pwd' }) + .send({ email: 'amdin@tooljet.io', password: 'pwd' }) .expect(401); }); diff --git a/server/test/controllers/app_users.e2e-spec.ts b/server/test/controllers/app_users.e2e-spec.ts index 6699c65097..2d8f875e05 100644 --- a/server/test/controllers/app_users.e2e-spec.ts +++ b/server/test/controllers/app_users.e2e-spec.ts @@ -17,14 +17,19 @@ describe('app_users controller', () => { await request(app.getHttpServer()).post('/app_users').expect(401); }); - it('should be able to create a new app user if admin of same organization', async () => { - const adminUserData = await createUser(app, { email: 'admin@tooljet.io', role: 'admin' }); + xit('should be able to create a new app user if admin of same organization', async () => { + const adminUserData = await createUser(app, { + email: 'admin@tooljet.io', + groups: ['all_users', 'admin'], + }); const developerUserData = await createUser(app, { email: 'dev@tooljet.io', - role: 'developer', + groups: ['all_users', 'developer'], organization: adminUserData.organization, }); - const application = await createApplication(app, { user: adminUserData.user }); + const application = await createApplication(app, { + user: adminUserData.user, + }); const response = await request(app.getHttpServer()) .post(`/app_users`) @@ -32,22 +37,31 @@ describe('app_users controller', () => { .send({ app_id: application.id, org_user_id: developerUserData.orgUser.id, - role: 'admin', + groups: ['all_users', 'admin'], }); expect(response.statusCode).toBe(201); }); it('should not be able to create new app user if admin of another organization', async () => { - const adminUserData = await createUser(app, { email: 'admin@tooljet.io', role: 'admin' }); + const adminUserData = await createUser(app, { + email: 'admin@tooljet.io', + groups: ['all_users', 'admin'], + }); // eslint-disable-next-line @typescript-eslint/no-unused-vars const developerUserData = await createUser(app, { email: 'dev@tooljet.io', - role: 'developer', + groups: ['all_users', 'developer'], organization: adminUserData.organization, }); - const anotherOrgAdminUserData = await createUser(app, { email: 'another@tooljet.io', role: 'admin' }); - const application = await createApplication(app, { name: 'name', user: adminUserData.user }); + const anotherOrgAdminUserData = await createUser(app, { + email: 'another@tooljet.io', + groups: ['all_users', 'admin'], + }); + const application = await createApplication(app, { + name: 'name', + user: adminUserData.user, + }); const response = await request(app.getHttpServer()) .post(`/app_users`) @@ -55,24 +69,30 @@ describe('app_users controller', () => { .send({ app_id: application.id, org_user_id: adminUserData.orgUser.id, - role: 'admin', + groups: ['all_users', 'admin'], }); expect(response.statusCode).toBe(403); }); it('should not allow developers and viewers to create app users', async () => { - const adminUserData = await createUser(app, { email: 'admin@tooljet.io', role: 'admin' }); - const application = await createApplication(app, { name: 'name', user: adminUserData.user }); + const adminUserData = await createUser(app, { + email: 'admin@tooljet.io', + groups: ['all_users', 'admin'], + }); + const application = await createApplication(app, { + name: 'name', + user: adminUserData.user, + }); const developerUserData = await createUser(app, { email: 'dev@tooljet.io', - role: 'developer', + groups: ['all_users', 'developer'], organization: adminUserData.organization, }); const viewerUserData = await createUser(app, { email: 'viewer@tooljet.io', - role: 'viewer', + groups: ['all_users', 'viewer'], organization: adminUserData.organization, }); @@ -82,7 +102,7 @@ describe('app_users controller', () => { .send({ app_id: application.id, org_user_id: viewerUserData.orgUser.id, - role: 'admin', + groups: ['all_users', 'admin'], }); expect(response.statusCode).toBe(403); @@ -92,7 +112,7 @@ describe('app_users controller', () => { .send({ app_id: application.id, org_user_id: developerUserData.orgUser.id, - role: 'admin', + groups: ['all_users', 'admin'], }); await application.reload(); diff --git a/server/test/controllers/apps.e2e-spec.ts b/server/test/controllers/apps.e2e-spec.ts index a51d0f33cc..fed98eeaf7 100644 --- a/server/test/controllers/apps.e2e-spec.ts +++ b/server/test/controllers/apps.e2e-spec.ts @@ -9,12 +9,15 @@ import { createApplicationVersion, createDataQuery, createDataSource, + createAppGroupPermission, } from '../test.helper'; import { App } from 'src/entities/app.entity'; import { AppVersion } from 'src/entities/app_version.entity'; import { DataQuery } from 'src/entities/data_query.entity'; import { DataSource } from 'src/entities/data_source.entity'; import { AppUser } from 'src/entities/app_user.entity'; +import { getRepository } from 'typeorm'; +import { GroupPermission } from 'src/entities/group_permission.entity'; describe('apps controller', () => { let app: INestApplication; @@ -35,20 +38,20 @@ describe('apps controller', () => { describe('/apps', () => { describe('authorization', () => { - it('should be able to create app if user is either admin or developer', async () => { + it('should be able to create app if user has admin group', async () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const organization = adminUserData.organization; const developerUserData = await createUser(app, { email: 'developer@tooljet.io', - role: 'developer', + groups: ['all_users', 'developer'], organization, }); const viewerUserData = await createUser(app, { email: 'viewer@tooljet.io', - role: 'viewer', + groups: ['all_users', 'viewer'], organization, }); @@ -58,27 +61,27 @@ describe('apps controller', () => { }); await createApplicationVersion(app, application); - for (const userData of [adminUserData, developerUserData]) { + for (const userData of [viewerUserData, developerUserData]) { const response = await request(app.getHttpServer()) .post(`/apps`) .set('Authorization', authHeaderForUser(userData.user)); - expect(response.statusCode).toBe(201); - expect(response.body.name).toBe('Untitled app'); + expect(response.statusCode).toBe(403); } const response = await request(app.getHttpServer()) .post(`/apps`) - .set('Authorization', authHeaderForUser(viewerUserData.user)); + .set('Authorization', authHeaderForUser(adminUserData.user)); - expect(response.statusCode).toBe(403); + expect(response.statusCode).toBe(201); + expect(response.body.name).toBe('Untitled app'); }); }); it('should create app with default values', async () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const response = await request(app.getHttpServer()) @@ -97,21 +100,21 @@ describe('apps controller', () => { }); describe('/apps/:id/clone', () => { - it('should be able to clone the app if user is admin or developer', async () => { + it('should be able to clone the app if user group is admin', async () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const developerUserData = await createUser(app, { email: 'dev@tooljet.io', - role: 'developer', + groups: ['all_users', 'developer'], organization: adminUserData.organization, }); const viewerUserData = await createUser(app, { email: 'viewer@tooljet.io', - role: 'viewer', + groups: ['all_users', 'viewer'], organization: adminUserData.organization, }); @@ -126,19 +129,15 @@ describe('apps controller', () => { expect(response.statusCode).toBe(201); - let appId = response.body.id; - let clonedApplication = await App.findOne({ id: appId }); + const appId = response.body.id; + const clonedApplication = await App.findOne({ id: appId }); expect(clonedApplication.name).toBe('App to clone'); response = await request(app.getHttpServer()) .post(`/apps/${application.id}/clone`) .set('Authorization', authHeaderForUser(developerUserData.user)); - expect(response.statusCode).toBe(201); - - appId = response.body.id; - clonedApplication = await App.findOne({ id: appId }); - expect(clonedApplication.name).toBe('App to clone'); + expect(response.statusCode).toBe(403); response = await request(app.getHttpServer()) .post(`/apps/${application.id}/clone`) @@ -150,11 +149,11 @@ describe('apps controller', () => { it('should not be able to clone the app if app is of another organization', async () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const anotherOrgAdminUserData = await createUser(app, { email: 'another@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const application = await createApplication(app, { name: 'name', @@ -173,7 +172,7 @@ describe('apps controller', () => { it('should be able to update name of the app if admin of same organization', async () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const application = await createApplication(app, { user: adminUserData.user, @@ -192,11 +191,11 @@ describe('apps controller', () => { it('should not be able to update name of the app if admin of another organization', async () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const anotherOrgAdminUserData = await createUser(app, { email: 'another@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const application = await createApplication(app, { name: 'name', @@ -213,10 +212,10 @@ describe('apps controller', () => { expect(application.name).toBe('name'); }); - it('should not allow developers and viewers to change the name of apps', async () => { + it('should not allow custom groups without app create permission to change the name of apps', async () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const application = await createApplication(app, { name: 'name', @@ -225,12 +224,12 @@ describe('apps controller', () => { const developerUserData = await createUser(app, { email: 'dev@tooljet.io', - role: 'developer', + groups: ['all_users', 'developer'], organization: adminUserData.organization, }); const viewerUserData = await createUser(app, { email: 'viewer@tooljet.io', - role: 'viewer', + groups: ['all_users', 'viewer'], organization: adminUserData.organization, }); @@ -254,7 +253,7 @@ describe('apps controller', () => { it('should be possible for the admin to delete an app, cascaded with its versions, queries and data sources', async () => { const admin = await createUser(app, { email: 'adminForDelete@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const application = await createApplication(app, { name: 'AppTObeDeleted', @@ -287,7 +286,7 @@ describe('apps controller', () => { it('should not be possible for non-admin user to delete an app, cascaded with its versions, queries and data sources', async () => { const developer = await createUser(app, { email: 'developer@tooljet.io', - role: 'developer', + groups: ['all_users', 'developer'], }); const application = await createApplication(app, { name: 'AppTObeDeleted', @@ -318,15 +317,16 @@ describe('apps controller', () => { }); }); + // TODO: Remove deprecated endpoint describe('/apps/:id/users', () => { - it('should not be able to fetch app users if admin of another organization', async () => { + xit('should not be able to fetch app users if admin of another organization', async () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const anotherOrgAdminUserData = await createUser(app, { email: 'another@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const application = await createApplication(app, { name: 'name', @@ -340,20 +340,20 @@ describe('apps controller', () => { expect(response.statusCode).toBe(403); }); - it('should be able to fetch app users if admin/developer/viewer of same organization', async () => { + xit('should be able to fetch app users if group is admin/developer/viewer of same organization', async () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const organization = adminUserData.organization; const developerUserData = await createUser(app, { email: 'developer@tooljet.io', - role: 'developer', + groups: ['all_users', 'developer'], organization, }); const viewerUserData = await createUser(app, { email: 'viewer@tooljet.io', - role: 'viewer', + groups: ['all_users', 'viewer'], organization, }); @@ -376,20 +376,15 @@ describe('apps controller', () => { describe('/apps/:id/versions', () => { describe('get versions', () => { describe('authorization', () => { - it('should be able to fetch app versions if admin/developer/viewer of same organization', async () => { + it('should be able to fetch app versions with app read permission group', async () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const organization = adminUserData.organization; - const developerUserData = await createUser(app, { + const defaultUserData = await createUser(app, { email: 'developer@tooljet.io', - role: 'developer', - organization, - }); - const viewerUserData = await createUser(app, { - email: 'viewer@tooljet.io', - role: 'viewer', + groups: ['all_users'], organization, }); @@ -399,7 +394,16 @@ describe('apps controller', () => { }); await createApplicationVersion(app, application); - for (const userData of [adminUserData, developerUserData, viewerUserData]) { + const allUserGroup = await getRepository(GroupPermission).findOne({ + group: 'all_users', + }); + await createAppGroupPermission(app, application, allUserGroup.id, { + read: true, + update: false, + delete: false, + }); + + for (const userData of [adminUserData, defaultUserData]) { const response = await request(app.getHttpServer()) .get(`/apps/${application.id}/versions`) .set('Authorization', authHeaderForUser(userData.user)); @@ -416,11 +420,11 @@ describe('apps controller', () => { it('should not be able to fetch app versions if user of another organization', async () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const anotherOrgAdminUserData = await createUser(app, { email: 'another@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const application = await createApplication(app, { name: 'name', @@ -435,20 +439,30 @@ describe('apps controller', () => { expect(response.statusCode).toBe(403); }); - it('should be able to create a new app version if admin or developer of same organization', async () => { + it('should be able to create a new app version if group is admin or has app update permission group in same organization', async () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const developerUserData = await createUser(app, { email: 'dev@tooljet.io', - role: 'developer', + groups: ['all_users', 'developer'], organization: adminUserData.organization, }); const application = await createApplication(app, { user: adminUserData.user, }); + // setup app permissions for developer + const developerUserGroup = await getRepository(GroupPermission).findOne({ + group: 'developer', + }); + await createAppGroupPermission(app, application, developerUserGroup.id, { + read: false, + update: true, + delete: false, + }); + for (const userData of [adminUserData, developerUserData]) { const response = await request(app.getHttpServer()) .post(`/apps/${application.id}/versions`) @@ -464,11 +478,11 @@ describe('apps controller', () => { it('should not be able to create app versions if user of another organization', async () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const anotherOrgAdminUserData = await createUser(app, { email: 'another@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const application = await createApplication(app, { name: 'name', @@ -486,14 +500,14 @@ describe('apps controller', () => { expect(response.statusCode).toBe(403); }); - it('should not be able to fetch app versions if user is a viewer', async () => { + it('should not be able to create app versions if user does not have app create permission group', async () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const viewerUserData = await createUser(app, { email: 'viewer@tooljet.io', - role: 'viewer', + groups: ['all_users'], organization: adminUserData.organization, }); const application = await createApplication(app, { @@ -517,7 +531,7 @@ describe('apps controller', () => { it('should return null when no previous versions exists', async () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const application = await createApplication(app, { user: adminUserData.user, @@ -543,7 +557,7 @@ describe('apps controller', () => { it('should return previous version definition when previous versions exists', async () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const application = await createApplication(app, { @@ -587,14 +601,14 @@ describe('apps controller', () => { describe('/apps/:id/versions/:version_id', () => { describe('get app version', () => { describe('authorization', () => { - it('should be able to get app version if admin or developer of same organization', async () => { + it('should be able to get app version by users having app read permission within same organization', async () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const developerUserData = await createUser(app, { email: 'dev@tooljet.io', - role: 'developer', + groups: ['all_users'], organization: adminUserData.organization, }); const application = await createApplication(app, { @@ -602,6 +616,15 @@ describe('apps controller', () => { }); const version = await createApplicationVersion(app, application); + const allUserGroup = await getRepository(GroupPermission).findOne({ + group: 'all_users', + }); + await createAppGroupPermission(app, application, allUserGroup.id, { + read: true, + update: false, + delete: false, + }); + for (const userData of [adminUserData, developerUserData]) { const response = await request(app.getHttpServer()) .get(`/apps/${application.id}/versions/${version.id}`) @@ -611,36 +634,14 @@ describe('apps controller', () => { } }); - it('should be able to get app version if viewers of same organization', async () => { - const adminUserData = await createUser(app, { - email: 'admin@tooljet.io', - role: 'admin', - }); - const viewerUserData = await createUser(app, { - email: 'dev@tooljet.io', - role: 'viewer', - organization: adminUserData.organization, - }); - const application = await createApplication(app, { - user: adminUserData.user, - }); - const version = await createApplicationVersion(app, application); - - const response = await request(app.getHttpServer()) - .get(`/apps/${application.id}/versions/${version.id}`) - .set('Authorization', authHeaderForUser(viewerUserData.user)); - - expect(response.statusCode).toBe(200); - }); - it('should not be able to get app versions if user of another organization', async () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const anotherOrgAdminUserData = await createUser(app, { email: 'another@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const application = await createApplication(app, { name: 'name', @@ -658,14 +659,14 @@ describe('apps controller', () => { }); describe('update app version', () => { - it('should be able to update app version if admin or developer of same organization', async () => { + it('should be able to update app version if has group admin or app update permission group in same organization', async () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const developerUserData = await createUser(app, { email: 'dev@tooljet.io', - role: 'developer', + groups: ['all_users', 'developer'], organization: adminUserData.organization, }); const application = await createApplication(app, { @@ -673,6 +674,14 @@ describe('apps controller', () => { }); const version = await createApplicationVersion(app, application); + // setup app permissions for developer + const developerUserGroup = await getRepository(GroupPermission).findOne({ group: 'developer' }); + await createAppGroupPermission(app, application, developerUserGroup.id, { + read: false, + update: true, + delete: false, + }); + for (const userData of [adminUserData, developerUserData]) { const response = await request(app.getHttpServer()) .put(`/apps/${application.id}/versions/${version.id}`) @@ -686,14 +695,14 @@ describe('apps controller', () => { } }); - it('should not be able to update app version if viewers of same organization', async () => { + it('should not be able to update app version if no app create permission within same organization', async () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const viewerUserData = await createUser(app, { email: 'dev@tooljet.io', - role: 'viewer', + groups: ['all_users'], organization: adminUserData.organization, }); const application = await createApplication(app, { @@ -714,11 +723,11 @@ describe('apps controller', () => { it('should not be able to update app versions if user of another organization', async () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const anotherOrgAdminUserData = await createUser(app, { email: 'another@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const application = await createApplication(app, { name: 'name', @@ -745,29 +754,48 @@ describe('apps controller', () => { By view app endpoint, we assume the apps/slugs/:id endpoint */ describe('/apps/slugs/:slug', () => { - it('should be able to fetch app using slug if member of an organization', async () => { + it('should be able to fetch app using slug if has read permission within an organization', async () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const developerUserData = await createUser(app, { email: 'developer@tooljet.io', - role: 'developer', + groups: ['all_users', 'developer'], organization: adminUserData.organization, }); const viewerUserData = await createUser(app, { email: 'viewer@tooljet.io', - role: 'viewer', + groups: ['all_users', 'viewer'], organization: adminUserData.organization, }); const application = await createApplication(app, { name: 'name', user: adminUserData.user, + slug: 'foo', + }); + // setup app permissions for developer + const developerUserGroup = await getRepository(GroupPermission).findOne({ + group: 'developer', + }); + await createAppGroupPermission(app, application, developerUserGroup.id, { + read: true, + update: true, + delete: false, + }); + // setup app permissions for viewer + const viewerUserGroup = await getRepository(GroupPermission).findOne({ + group: 'viewer', + }); + await createAppGroupPermission(app, application, viewerUserGroup.id, { + read: true, + update: false, + delete: false, }); for (const userData of [adminUserData, developerUserData, viewerUserData]) { const response = await request(app.getHttpServer()) - .get(`/apps/slugs/${application.id}`) + .get('/apps/slugs/foo') .set('Authorization', authHeaderForUser(userData.user)); expect(response.statusCode).toBe(200); @@ -777,19 +805,20 @@ describe('apps controller', () => { it('should not be able to fetch app using slug if member of another organization', async () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const anotherOrgAdminUserData = await createUser(app, { email: 'another@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); - const application = await createApplication(app, { + await createApplication(app, { name: 'name', user: adminUserData.user, + slug: 'foo', }); const response = await request(app.getHttpServer()) - .get(`/apps/slugs/${application.id}`) + .get('/apps/slugs/foo') .set('Authorization', authHeaderForUser(anotherOrgAdminUserData.user)); expect(response.statusCode).toBe(403); @@ -798,16 +827,17 @@ describe('apps controller', () => { it('should be able to fetch app using slug if a public app ( even if unauthenticated )', async () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); - const application = await createApplication(app, { + await createApplication(app, { name: 'name', user: adminUserData.user, + slug: 'foo', isPublic: true, }); - const response = await request(app.getHttpServer()).get(`/apps/slugs/${application.id}`); + const response = await request(app.getHttpServer()).get('/apps/slugs/foo'); expect(response.statusCode).toBe(200); }); diff --git a/server/test/controllers/data_queries.e2e-spec.ts b/server/test/controllers/data_queries.e2e-spec.ts index 1d47403b4c..fe144fef4c 100644 --- a/server/test/controllers/data_queries.e2e-spec.ts +++ b/server/test/controllers/data_queries.e2e-spec.ts @@ -8,7 +8,10 @@ import { createNestAppInstance, createDataQuery, createDataSource, + createAppGroupPermission, } from '../test.helper'; +import { getRepository } from 'typeorm'; +import { GroupPermission } from 'src/entities/group_permission.entity'; describe('data queries controller', () => { let app: INestApplication; @@ -21,20 +24,50 @@ describe('data queries controller', () => { app = await createNestAppInstance(); }); - it('should be able to update queries of an app only if admin/developer of same organization', async () => { - const adminUserData = await createUser(app, { email: 'admin@tooljet.io', role: 'admin' }); + it('should be able to update queries of an app only if group is admin or group has app update permission', async () => { + const adminUserData = await createUser(app, { + email: 'admin@tooljet.io', + groups: ['all_users', 'admin'], + }); const developerUserData = await createUser(app, { email: 'developer@tooljet.io', - role: 'developer', + groups: ['all_users', 'developer'], organization: adminUserData.organization, }); const viewerUserData = await createUser(app, { email: 'viewer@tooljet.io', - role: 'viewer', + groups: ['all_users', 'viewer'], organization: adminUserData.organization, }); - const anotherOrgAdminUserData = await createUser(app, { email: 'another@tooljet.io', role: 'admin' }); - const application = await createApplication(app, { name: 'name', user: adminUserData.user }); + const anotherOrgAdminUserData = await createUser(app, { + email: 'another@tooljet.io', + groups: ['all_users', 'admin'], + }); + + const application = await createApplication(app, { + name: 'name', + user: adminUserData.user, + }); + + // setup app permissions for developer + const developerUserGroup = await getRepository(GroupPermission).findOne({ + group: 'developer', + }); + await createAppGroupPermission(app, application, developerUserGroup.id, { + read: true, + update: true, + delete: false, + }); + + // setup app permissions for viewer + const viewerUserGroup = await getRepository(GroupPermission).findOne({ + group: 'viewer', + }); + await createAppGroupPermission(app, application, viewerUserGroup.id, { + read: true, + update: false, + delete: false, + }); const dataQuery = await createDataQuery(app, { application, @@ -81,27 +114,37 @@ describe('data queries controller', () => { it('should be able to delete queries of an app only if admin/developer of same organization', async () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const developerUserData = await createUser(app, { email: 'developer@tooljet.io', - role: 'developer', + groups: ['all_users', 'developer'], organization: adminUserData.organization, }); const viewerUserData = await createUser(app, { email: 'viewer@tooljet.io', - role: 'viewer', + groups: ['all_users', 'viewer'], organization: adminUserData.organization, }); const anotherOrgAdminUserData = await createUser(app, { email: 'another@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const application = await createApplication(app, { name: 'name', user: adminUserData.user, }); + // setup app permissions for developer + const developerUserGroup = await getRepository(GroupPermission).findOne({ + group: 'developer', + }); + await createAppGroupPermission(app, application, developerUserGroup.id, { + read: true, + update: true, + delete: false, + }); + for (const userData of [adminUserData, developerUserData]) { const dataQuery = await createDataQuery(app, { application, @@ -154,20 +197,39 @@ describe('data queries controller', () => { } }); - it('should be able to get queries of an app only if the user belongs to the same organization', async () => { - const adminUserData = await createUser(app, { email: 'admin@tooljet.io', role: 'admin' }); + it('should be able to get queries only if the user has app read permission and belongs to the same organization', async () => { + const adminUserData = await createUser(app, { + email: 'admin@tooljet.io', + groups: ['all_users', 'admin'], + }); const developerUserData = await createUser(app, { email: 'developer@tooljet.io', - role: 'developer', + groups: ['all_users', 'developer'], organization: adminUserData.organization, }); const viewerUserData = await createUser(app, { email: 'viewer@tooljet.io', - role: 'viewer', + groups: ['all_users', 'viewer'], organization: adminUserData.organization, }); - const application = await createApplication(app, { name: 'name', user: adminUserData.user }); - const anotherOrgAdminUserData = await createUser(app, { email: 'another@tooljet.io', role: 'admin' }); + const application = await createApplication(app, { + name: 'name', + user: adminUserData.user, + }); + const anotherOrgAdminUserData = await createUser(app, { + email: 'another@tooljet.io', + groups: ['all_users', 'admin'], + }); + + // setup app permissions for developer + const developerUserGroup = await getRepository(GroupPermission).findOne({ + group: 'developer', + }); + await createAppGroupPermission(app, application, developerUserGroup.id, { + read: true, + update: true, + delete: false, + }); await createDataQuery(app, { application, @@ -175,7 +237,7 @@ describe('data queries controller', () => { options: { method: 'get' }, }); - for (const userData of [adminUserData, developerUserData, viewerUserData]) { + for (const userData of [adminUserData, developerUserData]) { const response = await request(app.getHttpServer()) .get(`/data_queries?app_id=${application.id}`) .set('Authorization', authHeaderForUser(userData.user)); @@ -184,28 +246,53 @@ describe('data queries controller', () => { expect(response.body.data_queries.length).toBe(1); } + let response = await request(app.getHttpServer()) + .get(`/data_queries?app_id=${application.id}`) + .set('Authorization', authHeaderForUser(viewerUserData.user)); + + expect(response.statusCode).toBe(403); + // Forbidden if user of another organization - const response = await request(app.getHttpServer()) + response = await request(app.getHttpServer()) .get(`/data_queries?app_id=${application.id}`) .set('Authorization', authHeaderForUser(anotherOrgAdminUserData.user)); expect(response.statusCode).toBe(403); }); - it('should be able to create queries for an app only if the user is admin/developer and belongs to the same organization', async () => { - const adminUserData = await createUser(app, { email: 'admin@tooljet.io', role: 'admin' }); + it('should be able to create queries for an app only if the user has admin group or update permission', async () => { + const adminUserData = await createUser(app, { + email: 'admin@tooljet.io', + groups: ['all_users', 'admin'], + }); const developerUserData = await createUser(app, { email: 'developer@tooljet.io', - role: 'developer', + groups: ['all_users', 'developer'], organization: adminUserData.organization, }); const viewerUserData = await createUser(app, { email: 'viewer@tooljet.io', - role: 'viewer', + groups: ['all_users', 'viewer'], organization: adminUserData.organization, }); - const application = await createApplication(app, { name: 'name', user: adminUserData.user }); - const anotherOrgAdminUserData = await createUser(app, { email: 'another@tooljet.io', role: 'admin' }); + const application = await createApplication(app, { + name: 'name', + user: adminUserData.user, + }); + const anotherOrgAdminUserData = await createUser(app, { + email: 'another@tooljet.io', + groups: ['all_users', 'admin'], + }); + + // setup app permissions for developer + const developerUserGroup = await getRepository(GroupPermission).findOne({ + group: 'developer', + }); + await createAppGroupPermission(app, application, developerUserGroup.id, { + read: true, + update: true, + delete: false, + }); const queryParams = { app_id: application.id, @@ -234,9 +321,18 @@ describe('data queries controller', () => { }); it('should not be able to create queries if datasource belongs to another app', async () => { - const adminUserData = await createUser(app, { email: 'admin@tooljet.io', role: 'admin' }); - const application = await createApplication(app, { name: 'name', user: adminUserData.user }); - const anotherApplication = await createApplication(app, { name: 'name', user: adminUserData.user }); + const adminUserData = await createUser(app, { + email: 'admin@tooljet.io', + groups: ['all_users', 'admin'], + }); + const application = await createApplication(app, { + name: 'name', + user: adminUserData.user, + }); + const anotherApplication = await createApplication(app, { + name: 'name', + user: adminUserData.user, + }); const dataSource = await createDataSource(app, { name: 'name', kind: 'postgres', @@ -276,18 +372,24 @@ describe('data queries controller', () => { }); it('should be able to run queries of an app if the user belongs to the same organization', async () => { - const adminUserData = await createUser(app, { email: 'admin@tooljet.io', role: 'admin' }); + const adminUserData = await createUser(app, { + email: 'admin@tooljet.io', + groups: ['all_users', 'admin'], + }); const developerUserData = await createUser(app, { email: 'developer@tooljet.io', - role: 'developer', + groups: ['all_users', 'developer'], organization: adminUserData.organization, }); const viewerUserData = await createUser(app, { email: 'viewer@tooljet.io', - role: 'viewer', + groups: ['all_users', 'viewer'], organization: adminUserData.organization, }); - const application = await createApplication(app, { name: 'name', user: adminUserData.user }); + const application = await createApplication(app, { + name: 'name', + user: adminUserData.user, + }); const dataQuery = await createDataQuery(app, { application, @@ -301,6 +403,26 @@ describe('data queries controller', () => { }, }); + // setup app permissions for developer + const developerUserGroup = await getRepository(GroupPermission).findOne({ + group: 'developer', + }); + await createAppGroupPermission(app, application, developerUserGroup.id, { + read: true, + update: true, + delete: false, + }); + + // setup app permissions for viewer + const viewerUserGroup = await getRepository(GroupPermission).findOne({ + group: 'viewer', + }); + await createAppGroupPermission(app, application, viewerUserGroup.id, { + read: true, + update: false, + delete: false, + }); + for (const userData of [adminUserData, developerUserData, viewerUserData]) { const response = await request(app.getHttpServer()) .post(`/data_queries/${dataQuery.id}/run`) @@ -312,9 +434,18 @@ describe('data queries controller', () => { }); it('should not be able to run queries of an app if the user belongs to another organization', async () => { - const adminUserData = await createUser(app, { email: 'admin@tooljet.io', role: 'admin' }); - const anotherOrgAdminUserData = await createUser(app, { email: 'another@tooljet.io', role: 'admin' }); - const application = await createApplication(app, { name: 'name', user: adminUserData.user }); + const adminUserData = await createUser(app, { + email: 'admin@tooljet.io', + groups: ['all_users', 'admin'], + }); + const anotherOrgAdminUserData = await createUser(app, { + email: 'another@tooljet.io', + groups: ['all_users', 'admin'], + }); + const application = await createApplication(app, { + name: 'name', + user: adminUserData.user, + }); const dataQuery = await createDataQuery(app, { application, @@ -336,8 +467,15 @@ describe('data queries controller', () => { }); it('should be able to run queries of an app if a public app ( even if an unauthenticated user )', async () => { - const adminUserData = await createUser(app, { email: 'admin@tooljet.io', role: 'admin' }); - const application = await createApplication(app, { name: 'name', user: adminUserData.user, isPublic: true }); + const adminUserData = await createUser(app, { + email: 'admin@tooljet.io', + groups: ['all_users', 'admin'], + }); + const application = await createApplication(app, { + name: 'name', + user: adminUserData.user, + isPublic: true, + }); const dataQuery = await createDataQuery(app, { application, kind: 'restapi', @@ -357,8 +495,15 @@ describe('data queries controller', () => { }); it('should not be able to run queries if app not not public and user is not authenticated', async () => { - const adminUserData = await createUser(app, { email: 'admin@tooljet.io', role: 'admin' }); - const application = await createApplication(app, { name: 'name', user: adminUserData.user, isPublic: false }); + const adminUserData = await createUser(app, { + email: 'admin@tooljet.io', + groups: ['all_users', 'admin'], + }); + const application = await createApplication(app, { + name: 'name', + user: adminUserData.user, + isPublic: false, + }); const dataQuery = await createDataQuery(app, { application, kind: 'restapi', diff --git a/server/test/controllers/data_sources.e2e-spec.ts b/server/test/controllers/data_sources.e2e-spec.ts index f36a61154d..d1acc501a8 100644 --- a/server/test/controllers/data_sources.e2e-spec.ts +++ b/server/test/controllers/data_sources.e2e-spec.ts @@ -7,8 +7,11 @@ import { createUser, createNestAppInstance, createDataSource, + createAppGroupPermission, } from '../test.helper'; import { Credential } from 'src/entities/credential.entity'; +import { getRepository } from 'typeorm'; +import { GroupPermission } from 'src/entities/group_permission.entity'; describe('data sources controller', () => { let app: INestApplication; @@ -21,30 +24,39 @@ describe('data sources controller', () => { app = await createNestAppInstance(); }); - it('should be able to create data sources of an app only if admin/developer of same organization', async () => { + it('should be able to create data sources only if user has admin group or app update permission in same organization', async () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const developerUserData = await createUser(app, { email: 'developer@tooljet.io', - role: 'developer', + groups: ['all_users', 'developer'], organization: adminUserData.organization, }); const viewerUserData = await createUser(app, { email: 'viewer@tooljet.io', - role: 'viewer', + groups: ['all_users'], organization: adminUserData.organization, }); const anotherOrgAdminUserData = await createUser(app, { email: 'another@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const application = await createApplication(app, { name: 'name', user: adminUserData.user, }); + const developerUserGroup = await getRepository(GroupPermission).findOne({ + group: 'developer', + }); + await createAppGroupPermission(app, application, developerUserGroup.id, { + read: false, + update: true, + delete: false, + }); + const dataSourceParams = { name: 'name', options: [{ key: 'foo', value: 'bar', encrypted: 'true' }], @@ -75,29 +87,37 @@ describe('data sources controller', () => { } }); - it('should be able to update data sources of an app only if admin/developer of same organization', async () => { + it('should be able to update data sources only if user has group admin or app update permission in same organization', async () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const developerUserData = await createUser(app, { email: 'developer@tooljet.io', - role: 'developer', + groups: ['all_users', 'developer'], organization: adminUserData.organization, }); const viewerUserData = await createUser(app, { email: 'viewer@tooljet.io', - role: 'viewer', + groups: ['all_users', 'viewer'], organization: adminUserData.organization, }); const anotherOrgAdminUserData = await createUser(app, { email: 'another@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const application = await createApplication(app, { name: 'name', user: adminUserData.user, }); + const developerUserGroup = await getRepository(GroupPermission).findOne({ + group: 'developer', + }); + await createAppGroupPermission(app, application, developerUserGroup.id, { + read: false, + update: true, + delete: false, + }); const dataSource = await createDataSource(app, { name: 'name', options: [{ key: 'foo', value: 'bar', encrypted: 'true' }], @@ -146,19 +166,19 @@ describe('data sources controller', () => { } }); - it('should be able to list (get) datasources for an app only if admin/developer of same organization', async () => { + it('should be able to list (get) datasources for an app by all users of same organization', async () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const developerUserData = await createUser(app, { email: 'developer@tooljet.io', - role: 'developer', + groups: ['all_users'], organization: adminUserData.organization, }); const viewerUserData = await createUser(app, { email: 'viewer@tooljet.io', - role: 'viewer', + groups: ['all_users'], organization: adminUserData.organization, }); const application = await createApplication(app, { @@ -167,16 +187,25 @@ describe('data sources controller', () => { }); const anotherOrgAdminUserData = await createUser(app, { email: 'another@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const dataSource = await createDataSource(app, { + await createDataSource(app, { name: 'name', kind: 'postgres', application: application, user: adminUserData.user, }); + const allUserGroup = await getRepository(GroupPermission).findOne({ + group: 'all_users', + organizationId: adminUserData.organization.id, + }); + await createAppGroupPermission(app, application, allUserGroup.id, { + read: true, + update: true, + delete: false, + }); + for (const userData of [adminUserData, developerUserData, viewerUserData]) { const response = await request(app.getHttpServer()) .get(`/data_sources?app_id=${application.id}`) @@ -197,11 +226,11 @@ describe('data sources controller', () => { it('should not be able to authorize OAuth code for a REST API source if user of another organization', async () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const anotherOrgAdminUserData = await createUser(app, { email: 'another@tooljet.io', - role: 'admin', + groups: ['all_users', 'admin'], }); const application = await createApplication(app, { name: 'name', diff --git a/server/test/controllers/group_permissions.e2e-spec.ts b/server/test/controllers/group_permissions.e2e-spec.ts new file mode 100644 index 0000000000..5f8de22098 --- /dev/null +++ b/server/test/controllers/group_permissions.e2e-spec.ts @@ -0,0 +1,656 @@ +import * as request from 'supertest'; +import { INestApplication } from '@nestjs/common'; +import { authHeaderForUser, clearDB, createUser, createNestAppInstance, createApplication } from '../test.helper'; +import { getManager } from 'typeorm'; +import { AppGroupPermission } from 'src/entities/app_group_permission.entity'; +import { UserGroupPermission } from 'src/entities/user_group_permission.entity'; +import { GroupPermission } from 'src/entities/group_permission.entity'; + +describe('group permissions controller', () => { + let nestApp: INestApplication; + + beforeEach(async () => { + await clearDB(); + }); + + beforeAll(async () => { + nestApp = await createNestAppInstance(); + }); + + describe('POST /group_permissions', () => { + it('should not allow non admin to create group permission', async () => { + const { + organization: { defaultUser }, + } = await setupOrganizations(nestApp); + const response = await request(nestApp.getHttpServer()) + .post('/group_permissions') + .set('Authorization', authHeaderForUser(defaultUser)) + .send({ group: 'avengers' }); + + expect(response.statusCode).toBe(403); + }); + + it('should be able to create group permission for authenticated admin', async () => { + const { + organization: { adminUser, organization }, + } = await setupOrganizations(nestApp); + const response = await request(nestApp.getHttpServer()) + .post('/group_permissions') + .set('Authorization', authHeaderForUser(adminUser)) + .send({ group: 'avengers' }); + + expect(response.statusCode).toBe(201); + expect(response.body.group).toBe('avengers'); + expect(response.body.organization_id).toBe(organization.id); + expect(response.body.id).toBeDefined(); + expect(response.body.created_at).toBeDefined(); + expect(response.body.updated_at).toBeDefined(); + }); + + it('should validate uniqueness of group permission group name', async () => { + const { + organization: { adminUser }, + } = await setupOrganizations(nestApp); + let response = await request(nestApp.getHttpServer()) + .post('/group_permissions') + .set('Authorization', authHeaderForUser(adminUser)) + .send({ group: 'avengers' }); + + expect(response.statusCode).toBe(201); + + response = await request(nestApp.getHttpServer()) + .post('/group_permissions') + .set('Authorization', authHeaderForUser(adminUser)) + .send({ group: 'avengers' }); + + // FIXME: setup postgres error codes and handle error gracefully + expect(response.statusCode).toBe(500); + }); + + it('should allow different organization to have same group name', async () => { + const { + organization: { adminUser }, + anotherOrganization: { anotherAdminUser }, + } = await setupOrganizations(nestApp); + + let response = await request(nestApp.getHttpServer()) + .post('/group_permissions') + .set('Authorization', authHeaderForUser(adminUser)) + .send({ group: 'avengers' }); + + expect(response.statusCode).toBe(201); + + response = await request(nestApp.getHttpServer()) + .post('/group_permissions') + .set('Authorization', authHeaderForUser(anotherAdminUser)) + .send({ group: 'avengers' }); + + expect(response.statusCode).toBe(201); + }); + }); + + describe('GET /group_permissions/:id', () => { + it('should not allow unauthenicated admin', async () => { + const { + organization: { defaultUser }, + } = await setupOrganizations(nestApp); + const response = await request(nestApp.getHttpServer()) + .get('/group_permissions/id') + .set('Authorization', authHeaderForUser(defaultUser)); + + expect(response.statusCode).toBe(403); + }); + + it('should get group permission for authenticated admin within organization', async () => { + const { + organization: { adminUser, organization }, + } = await setupOrganizations(nestApp); + + let response = await request(nestApp.getHttpServer()) + .post('/group_permissions') + .set('Authorization', authHeaderForUser(adminUser)) + .send({ group: 'avengers' }); + + const groupPermissionId = response.body.id; + + response = await request(nestApp.getHttpServer()) + .get(`/group_permissions/${groupPermissionId}`) + .set('Authorization', authHeaderForUser(adminUser)); + + expect(response.statusCode).toBe(200); + expect(response.body.group).toBe('avengers'); + expect(response.body.organization_id).toBe(organization.id); + expect(response.body.id).toBeDefined(); + expect(response.body.created_at).toBeDefined(); + expect(response.body.updated_at).toBeDefined(); + }); + + it('should not get group permission for authenticated admin not within organization', async () => { + const { + organization: { adminUser }, + anotherOrganization: { anotherAdminUser }, + } = await setupOrganizations(nestApp); + + let response = await request(nestApp.getHttpServer()) + .post('/group_permissions') + .set('Authorization', authHeaderForUser(adminUser)) + .send({ group: 'avengers' }); + + const groupPermissionId = response.body.id; + + response = await request(nestApp.getHttpServer()) + .post(`/group_permissions/${groupPermissionId}`) + .set('Authorization', authHeaderForUser(anotherAdminUser)) + .send({ group: 'avengers' }); + + expect(response.statusCode).toBe(404); + }); + }); + + describe('PUT /group_permissions/:id', () => { + it('should not allow unauthenicated admin', async () => { + const { + organization: { defaultUser }, + } = await setupOrganizations(nestApp); + const response = await request(nestApp.getHttpServer()) + .put('/group_permissions/id') + .set('Authorization', authHeaderForUser(defaultUser)) + .send({ group: 'avengers' }); + + expect(response.statusCode).toBe(403); + }); + + it('should allow admin to add and remove apps to group permission', async () => { + const { + organization: { adminUser, app }, + } = await setupOrganizations(nestApp); + + let response = await request(nestApp.getHttpServer()) + .post('/group_permissions') + .set('Authorization', authHeaderForUser(adminUser)) + .send({ group: 'avengers' }); + + const groupPermissionId = response.body.id; + + response = await request(nestApp.getHttpServer()) + .put(`/group_permissions/${groupPermissionId}`) + .set('Authorization', authHeaderForUser(adminUser)) + .send({ add_apps: [app.id] }); + + expect(response.statusCode).toBe(200); + + const manager = getManager(); + let appsInGroup = await manager.find(AppGroupPermission, { + where: { groupPermissionId }, + }); + + expect(appsInGroup).toHaveLength(1); + + const addedApp = appsInGroup[0]; + + expect(addedApp.appId).toBe(app.id); + expect(addedApp.read).toBe(true); + expect(addedApp.update).toBe(false); + expect(addedApp.delete).toBe(false); + + response = await request(nestApp.getHttpServer()) + .put(`/group_permissions/${groupPermissionId}`) + .set('Authorization', authHeaderForUser(adminUser)) + .send({ remove_apps: [app.id] }); + + expect(response.statusCode).toBe(200); + + appsInGroup = await manager.find(AppGroupPermission, { + where: { groupPermissionId }, + }); + + expect(appsInGroup).toHaveLength(0); + }); + + it('should allow admin to add and remove users to group permission', async () => { + const { + organization: { adminUser, defaultUser }, + } = await setupOrganizations(nestApp); + + let response = await request(nestApp.getHttpServer()) + .post('/group_permissions') + .set('Authorization', authHeaderForUser(adminUser)) + .send({ group: 'avengers' }); + + const groupPermissionId = response.body.id; + + response = await request(nestApp.getHttpServer()) + .put(`/group_permissions/${groupPermissionId}`) + .set('Authorization', authHeaderForUser(adminUser)) + .send({ add_users: [defaultUser.id] }); + + expect(response.statusCode).toBe(200); + + const manager = getManager(); + let usersInGroup = await manager.find(UserGroupPermission, { + where: { groupPermissionId }, + }); + + expect(usersInGroup).toHaveLength(1); + + const addedUser = usersInGroup[0]; + + expect(addedUser.userId).toBe(defaultUser.id); + + response = await request(nestApp.getHttpServer()) + .put(`/group_permissions/${groupPermissionId}`) + .set('Authorization', authHeaderForUser(adminUser)) + .send({ remove_users: [defaultUser.id] }); + + expect(response.statusCode).toBe(200); + + usersInGroup = await manager.find(UserGroupPermission, { + where: { groupPermissionId }, + }); + + expect(usersInGroup).toHaveLength(0); + }); + + it('should not allow to remove users from admin group permission without any atleast one active admin', async () => { + const { + organization: { adminUser, defaultUser }, + } = await setupOrganizations(nestApp); + + const manager = getManager(); + const adminGroupPermission = await manager.findOne(GroupPermission, { + group: 'admin', + }); + + const response = await request(nestApp.getHttpServer()) + .put(`/group_permissions/${adminGroupPermission.id}`) + .set('Authorization', authHeaderForUser(adminUser)) + .send({ remove_users: [defaultUser.id] }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toBe('Atleast one active admin is required.'); + }); + + it('should not allow to remove any users from all_users group permission', async () => { + const { + organization: { adminUser, defaultUser }, + } = await setupOrganizations(nestApp); + + const manager = getManager(); + const adminGroupPermission = await manager.findOne(GroupPermission, { + group: 'all_users', + }); + + const response = await request(nestApp.getHttpServer()) + .put(`/group_permissions/${adminGroupPermission.id}`) + .set('Authorization', authHeaderForUser(adminUser)) + .send({ remove_users: [defaultUser.id] }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toBe('Cannot remove user from default group.'); + }); + }); + + describe('GET /group_permissions', () => { + it('should not allow unauthenicated admin', async () => { + const { + organization: { defaultUser }, + } = await setupOrganizations(nestApp); + const response = await request(nestApp.getHttpServer()) + .get('/group_permissions') + .set('Authorization', authHeaderForUser(defaultUser)); + + expect(response.statusCode).toBe(403); + }); + + it('should allow admin to list group permission', async () => { + const { + organization: { adminUser, defaultUser, app, organization }, + } = await setupOrganizations(nestApp); + + // create group permission + let response = await request(nestApp.getHttpServer()) + .post('/group_permissions') + .set('Authorization', authHeaderForUser(adminUser)) + .send({ group: 'avengers' }); + + expect(response.statusCode).toBe(201); + + const groupPermissionId = response.body.id; + + // add apps and users to group permission + response = await request(nestApp.getHttpServer()) + .put(`/group_permissions/${groupPermissionId}`) + .set('Authorization', authHeaderForUser(adminUser)) + .send({ add_apps: [app.id], add_users: [defaultUser.id] }); + + expect(response.statusCode).toBe(200); + + // list group permission + response = await request(nestApp.getHttpServer()) + .get('/group_permissions') + .set('Authorization', authHeaderForUser(adminUser)); + expect(response.statusCode).toBe(200); + + const groupPermissions = response.body.group_permissions; + const groups = groupPermissions.map((gp) => gp.group); + const organizationId = [...new Set(groupPermissions.map((gp) => gp.organization_id))]; + + expect(new Set(groups)).toEqual(new Set(['avengers', 'all_users', 'admin'])); + expect(organizationId).toEqual([organization.id]); + }); + }); + + describe('GET /group_permissions/:id/apps', () => { + it('should not allow unauthenicated admin', async () => { + const { + organization: { defaultUser }, + } = await setupOrganizations(nestApp); + const response = await request(nestApp.getHttpServer()) + .get('/group_permissions/id/apps') + .set('Authorization', authHeaderForUser(defaultUser)); + + expect(response.statusCode).toBe(403); + }); + + it('should allow admin to list apps in group permission', async () => { + const { + organization: { adminUser, organization }, + } = await setupOrganizations(nestApp); + + const manager = getManager(); + const adminGroupPermission = await manager.findOne(GroupPermission, { + group: 'admin', + organizationId: organization.id, + }); + + const response = await request(nestApp.getHttpServer()) + .get(`/group_permissions/${adminGroupPermission.id}/apps`) + .set('Authorization', authHeaderForUser(adminUser)); + + expect(response.statusCode).toBe(200); + + const apps = response.body.apps; + const sampleApp = apps[0]; + + expect(apps).toHaveLength(1); + expect(sampleApp.organization_id).toBe(organization.id); + expect(sampleApp.name).toBe('sample app'); + + expect(sampleApp.group_permissions).toHaveLength(1); + expect(sampleApp.group_permissions[0].group).toBe('admin'); + + expect(sampleApp.app_group_permissions).toHaveLength(1); + expect(sampleApp.app_group_permissions[0].group_permission_id).toBe(sampleApp.group_permissions[0].id); + expect(sampleApp.app_group_permissions[0].read).toBe(true); + expect(sampleApp.app_group_permissions[0].update).toBe(true); + expect(sampleApp.app_group_permissions[0].delete).toBe(true); + }); + }); + + describe('GET /group_permissions/:id/addable_apps', () => { + it('should not allow unauthenicated admin', async () => { + const { + organization: { defaultUser }, + } = await setupOrganizations(nestApp); + const response = await request(nestApp.getHttpServer()) + .get('/group_permissions/id/addable_apps') + .set('Authorization', authHeaderForUser(defaultUser)); + + expect(response.statusCode).toBe(403); + }); + + it('should allow admin to list apps not in group permission', async () => { + const { + organization: { adminUser, organization }, + } = await setupOrganizations(nestApp); + + // create group permission + let response = await request(nestApp.getHttpServer()) + .post('/group_permissions') + .set('Authorization', authHeaderForUser(adminUser)) + .send({ group: 'avengers' }); + + expect(response.statusCode).toBe(201); + + const groupPermissionId = response.body.id; + + response = await request(nestApp.getHttpServer()) + .get(`/group_permissions/${groupPermissionId}/addable_apps`) + .set('Authorization', authHeaderForUser(adminUser)); + + expect(response.statusCode).toBe(200); + + const apps = response.body.apps; + const sampleApp = apps[0]; + + expect(apps).toHaveLength(1); + expect(sampleApp.organization_id).toBe(organization.id); + expect(sampleApp.name).toBe('sample app'); + expect(sampleApp.group_permissions).toHaveLength(2); + + const adminGroupPermission = sampleApp.group_permissions.find((a) => a.group == 'admin'); + const adminAppGroupPermission = sampleApp.app_group_permissions.find( + (a) => a.group_permission_id == adminGroupPermission.id + ); + expect(adminAppGroupPermission.read).toBe(true); + expect(adminAppGroupPermission.update).toBe(true); + expect(adminAppGroupPermission.delete).toBe(true); + + const userGroupPermission = sampleApp.group_permissions.find((a) => a.group == 'all_users'); + const userAppGroupPermission = sampleApp.app_group_permissions.find( + (a) => a.group_permission_id == userGroupPermission.id + ); + expect(userAppGroupPermission.read).toBe(true); + expect(userAppGroupPermission.update).toBe(false); + expect(userAppGroupPermission.delete).toBe(false); + }); + }); + + describe('GET /group_permissions/:id/users', () => { + it('should not allow unauthenicated admin', async () => { + const { + organization: { defaultUser }, + } = await setupOrganizations(nestApp); + const response = await request(nestApp.getHttpServer()) + .get('/group_permissions/id/users') + .set('Authorization', authHeaderForUser(defaultUser)); + + expect(response.statusCode).toBe(403); + }); + + it('should allow admin to list users in group permission', async () => { + const { + organization: { adminUser, organization }, + } = await setupOrganizations(nestApp); + + const manager = getManager(); + const adminGroupPermission = await manager.findOne(GroupPermission, { + group: 'admin', + organizationId: organization.id, + }); + + const response = await request(nestApp.getHttpServer()) + .get(`/group_permissions/${adminGroupPermission.id}/users`) + .set('Authorization', authHeaderForUser(adminUser)); + + expect(response.statusCode).toBe(200); + + const users = response.body.users; + const user = users[0]; + + expect(users).toHaveLength(1); + expect(user.organization_id).toBe(organization.id); + expect(user.email).toBe('admin@tooljet.io'); + }); + }); + + describe('GET /group_permissions/:id/addable_users', () => { + it('should not allow unauthenicated admin', async () => { + const { + organization: { defaultUser }, + } = await setupOrganizations(nestApp); + const response = await request(nestApp.getHttpServer()) + .get('/group_permissions/id/addable_users') + .set('Authorization', authHeaderForUser(defaultUser)); + + expect(response.statusCode).toBe(403); + }); + + it('should allow admin to list users not in group permission', async () => { + const { + organization: { adminUser, organization }, + } = await setupOrganizations(nestApp); + + const manager = getManager(); + const adminGroupPermission = await manager.findOne(GroupPermission, { + group: 'admin', + organizationId: organization.id, + }); + const groupPermissionId = adminGroupPermission.id; + const response = await request(nestApp.getHttpServer()) + .get(`/group_permissions/${groupPermissionId}/addable_users`) + .set('Authorization', authHeaderForUser(adminUser)); + + expect(response.statusCode).toBe(200); + + const users = response.body.users; + const user = users[0]; + + expect(users).toHaveLength(1); + expect(user.organization_id).toBe(organization.id); + expect(user.email).toBe('developer@tooljet.io'); + }); + }); + + describe('PUT /group_permissions/:id/app_group_permissions/:appGroupPermisionId', () => { + it('should not allow unauthenicated admin', async () => { + const { + organization: { defaultUser }, + } = await setupOrganizations(nestApp); + const response = await request(nestApp.getHttpServer()) + .put('/group_permissions/id/app_group_permissions/id') + .set('Authorization', authHeaderForUser(defaultUser)) + .send({ read: true }); + + expect(response.statusCode).toBe(403); + }); + + it('should allow admin to update app group permission', async () => { + const { + organization: { adminUser, organization }, + } = await setupOrganizations(nestApp); + + const manager = getManager(); + const adminGroupPermission = await manager.findOne(GroupPermission, { + where: { + organizationId: organization.id, + group: 'all_users', + }, + }); + const groupPermissionId = adminGroupPermission.id; + const appGroupPermission = await manager.findOne(AppGroupPermission, { + groupPermissionId, + }); + const appGroupPermissionId = appGroupPermission.id; + + expect(appGroupPermission.read).toBe(true); + expect(appGroupPermission.update).toBe(false); + + const response = await request(nestApp.getHttpServer()) + .put(`/group_permissions/${groupPermissionId}/app_group_permissions/${appGroupPermissionId}`) + .set('Authorization', authHeaderForUser(adminUser)) + .send({ actions: { read: false, update: true } }); + + expect(response.statusCode).toBe(200); + + await appGroupPermission.reload(); + + expect(appGroupPermission.read).toBe(false); + expect(appGroupPermission.update).toBe(true); + }); + + it('should not allow admin to update app group permission of different organization', async () => { + const { + organization: { organization }, + anotherOrganization: { anotherAdminUser }, + } = await setupOrganizations(nestApp); + + const manager = getManager(); + const adminGroupPermission = await manager.findOne(GroupPermission, { + where: { + organizationId: organization.id, + group: 'all_users', + }, + }); + const groupPermissionId = adminGroupPermission.id; + const appGroupPermission = await manager.findOne(AppGroupPermission, { + groupPermissionId, + }); + const appGroupPermissionId = appGroupPermission.id; + + expect(appGroupPermission.read).toBe(true); + expect(appGroupPermission.update).toBe(false); + + const response = await request(nestApp.getHttpServer()) + .put(`/group_permissions/${groupPermissionId}/app_group_permissions/${appGroupPermissionId}`) + .set('Authorization', authHeaderForUser(anotherAdminUser)) + .send({ actions: { read: false, update: true } }); + + expect(response.statusCode).toBe(400); + }); + }); + + async function setupOrganizations(nestApp) { + const adminUserData = await createUser(nestApp, { + email: 'admin@tooljet.io', + groups: ['all_users', 'admin'], + }); + const adminUser = adminUserData.user; + const organization = adminUserData.organization; + const defaultUserData = await createUser(nestApp, { + email: 'developer@tooljet.io', + groups: ['all_users'], + organization, + }); + const defaultUser = defaultUserData.user; + + const app = await createApplication(nestApp, { + user: adminUser, + name: 'sample app', + isPublic: false, + }); + + const anotherAdminUserData = await createUser(nestApp, { + email: 'another_admin@tooljet.io', + groups: ['all_users', 'admin'], + }); + const anotherAdminUser = anotherAdminUserData.user; + const anotherOrganization = anotherAdminUserData.organization; + const anotherDefaultUserData = await createUser(nestApp, { + email: 'another_developer@tooljet.io', + groups: ['all_users'], + anotherOrganization, + }); + const anotherDefaultUser = anotherDefaultUserData.user; + + const anotherApp = await createApplication(nestApp, { + user: anotherAdminUser, + name: 'another app', + isPublic: false, + }); + + return { + organization: { adminUser, defaultUser, organization, app }, + anotherOrganization: { + anotherAdminUser, + anotherDefaultUser, + anotherOrganization, + anotherApp, + }, + }; + } + + afterAll(async () => { + await nestApp.close(); + }); +}); diff --git a/server/test/controllers/organization_users.e2e-spec.ts b/server/test/controllers/organization_users.e2e-spec.ts index 64f41acbba..0c788c286a 100644 --- a/server/test/controllers/organization_users.e2e-spec.ts +++ b/server/test/controllers/organization_users.e2e-spec.ts @@ -16,65 +16,65 @@ describe('organization users controller', () => { it('should allow only admin to be able to invite new users', async () => { // setup a pre existing user of different organization - await createUser(app, { email: 'someUser@tooljet.io', role: 'admin' }); + await createUser(app, { email: 'someUser@tooljet.io', groups: ['admin', 'all_users'] }); // setup organization and user setup to test against const adminUserData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', + groups: ['admin', 'all_users'], }); const organization = adminUserData.organization; const developerUserData = await createUser(app, { email: 'developer@tooljet.io', - role: 'developer', + groups: ['developer', 'all_users'], organization, }); const viewerUserData = await createUser(app, { email: 'viewer@tooljet.io', - role: 'viewer', + groups: ['viewer', 'all_users'], organization, }); await request(app.getHttpServer()) .post(`/organization_users`) .set('Authorization', authHeaderForUser(adminUserData.user)) - .send({ email: 'test@tooljet.io', role: 'Viewer' }) + .send({ email: 'test@tooljet.io', groups: ['Viewer', 'all_users'] }) .expect(201); await request(app.getHttpServer()) .post(`/organization_users`) .set('Authorization', authHeaderForUser(developerUserData.user)) - .send({ email: 'test2@tooljet.io', role: 'Viewer' }) + .send({ email: 'test2@tooljet.io', groups: ['Viewer', 'all_users'] }) .expect(403); await request(app.getHttpServer()) .post(`/organization_users`) .set('Authorization', authHeaderForUser(viewerUserData.user)) - .send({ email: 'test3@tooljet.io', role: 'Viewer' }) + .send({ email: 'test3@tooljet.io', groups: ['Viewer', 'all_users'] }) .expect(403); }); it('should allow only authenticated users to archive org users', async () => { - await request(app.getHttpServer()).post('/organization_users/random-id/archive').expect(401); + await request(app.getHttpServer()).post('/organization_users/random-id/archive/').expect(401); }); it('should allow only admin users to archive org users', async () => { const adminUserData = await createUser(app, { email: 'admin@tooljet.io', - role: 'admin', + groups: ['admin', 'all_users'], }); const organization = adminUserData.organization; const developerUserData = await createUser(app, { email: 'developer@tooljet.io', - role: 'developer', + groups: ['developer', 'all_users'], organization, }); const viewerUserData = await createUser(app, { email: 'viewer@tooljet.io', - role: 'viewer', + groups: ['viewer', 'all_users'], organization, }); @@ -103,107 +103,6 @@ describe('organization users controller', () => { expect(developerUserData.orgUser.status).toBe('archived'); }); - it('should allow only admin users to change role of org users', async () => { - const adminUserData = await createUser(app, { - email: 'admin@tooljet.io', - role: 'admin', - }); - const organization = adminUserData.organization; - const developerUserData = await createUser(app, { - email: 'developer@tooljet.io', - role: 'developer', - organization, - }); - const viewerUserData = await createUser(app, { - email: 'viewer@tooljet.io', - role: 'viewer', - organization, - }); - - let response = await request(app.getHttpServer()) - .post(`/organization_users/${viewerUserData.orgUser.id}/change_role`) - .set('Authorization', authHeaderForUser(developerUserData.user)) - .send({ role: 'developer' }) - .expect(403); - - await viewerUserData.orgUser.reload(); - expect(viewerUserData.orgUser.role).toBe('viewer'); - - response = await request(app.getHttpServer()) - .post(`/organization_users/${viewerUserData.orgUser.id}/change_role`) - .set('Authorization', authHeaderForUser(viewerUserData.user)) - .send({ role: 'viewer' }) - .expect(403); - - await developerUserData.orgUser.reload(); - expect(developerUserData.orgUser.role).toBe('developer'); - - response = await request(app.getHttpServer()) - .post(`/organization_users/${developerUserData.orgUser.id}/change_role`) - .set('Authorization', authHeaderForUser(adminUserData.user)) - .send({ role: 'viewer' }) - .expect(201); - - await developerUserData.orgUser.reload(); - expect(developerUserData.orgUser.role).toBe('viewer'); - }); - - it('should allow only admin users to change role of org users', async () => { - const adminUserData = await createUser(app, { - email: 'admin@tooljet.io', - role: 'admin', - }); - const developerUserData = await createUser(app, { - email: 'developer@tooljet.io', - role: 'developer', - }); - - const response = await request(app.getHttpServer()) - .post(`/organization_users/${developerUserData.orgUser.id}/change_role`) - .set('Authorization', authHeaderForUser(adminUserData.user)) - .send({ role: 'viewer' }) - .expect(403); - - await developerUserData.orgUser.reload(); - expect(developerUserData.orgUser.role).toBe('developer'); - }); - - it('should not allow to change role from admin when no other admins are present', async () => { - const adminUserData = await createUser(app, { - email: 'admin@tooljet.io', - role: 'admin', - status: 'active', - }); - - const response = await request(app.getHttpServer()) - .post(`/organization_users/${adminUserData.orgUser.id}/change_role`) - .set('Authorization', authHeaderForUser(adminUserData.user)) - .send({ role: 'viewer' }) - .expect(400); - - await adminUserData.orgUser.reload(); - expect(adminUserData.orgUser.role).toBe('admin'); - }); - - it('should allow only admin users to archive org users', async () => { - const adminUserData = await createUser(app, { - email: 'admin@tooljet.io', - role: 'admin', - }); - const developerUserData = await createUser(app, { - email: 'developer@tooljet.io', - role: 'developer', - }); - - const response = await request(app.getHttpServer()) - .post(`/organization_users/${developerUserData.orgUser.id}/archive`) - .set('Authorization', authHeaderForUser(adminUserData.user)) - .expect(403); - - await developerUserData.orgUser.reload(); - expect(developerUserData.orgUser.status).toBe('invited'); - }); - afterAll(async () => { await app.close(); }); diff --git a/server/src/services/data_queries.service.spec.ts b/server/test/services/data_queries.service.spec.ts similarity index 97% rename from server/src/services/data_queries.service.spec.ts rename to server/test/services/data_queries.service.spec.ts index ddf48ab02c..5d0bfc8684 100644 --- a/server/src/services/data_queries.service.spec.ts +++ b/server/test/services/data_queries.service.spec.ts @@ -2,7 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AppModule } from '../../src/app.module'; import { DataQueriesModule } from '../../src/modules/data_queries/data_queries.module'; import { DataSourcesModule } from '../../src/modules/data_sources/data_sources.module'; -import { DataQueriesService } from './data_queries.service'; +import { DataQueriesService } from '../../src/services/data_queries.service'; describe('DataQueriesService', () => { let service: DataQueriesService; diff --git a/server/test/services/users.service.spec.ts b/server/test/services/users.service.spec.ts new file mode 100644 index 0000000000..6cabf00dc1 --- /dev/null +++ b/server/test/services/users.service.spec.ts @@ -0,0 +1,289 @@ +import { + clearDB, + createUser, + createNestAppInstance, + createApplication, + createAppGroupPermission, + createUserGroupPermissions, + createGroupPermission, +} from '../test.helper'; +import { UsersService } from '../../src/services/users.service'; +import { INestApplication } from '@nestjs/common'; +import { getManager } from 'typeorm'; +import { User } from 'src/entities/user.entity'; +import { UserGroupPermission } from 'src/entities/user_group_permission.entity'; +import { GroupPermission } from 'src/entities/group_permission.entity'; + +describe('UsersService', () => { + let nestApp: INestApplication; + let service: UsersService; + + beforeEach(async () => { + await clearDB(); + }); + + beforeAll(async () => { + nestApp = await createNestAppInstance(); + service = nestApp.get(UsersService); + }); + + describe('.create', () => { + it('should create user', async () => { + const { adminUser } = await setupOrganization(nestApp); + + await service.create( + { + email: 'john@example.com', + firstName: 'John', + lastName: 'Wick', + }, + adminUser.organization, + ['all_users'] + ); + + const manager = getManager(); + const newUser = await manager.findOne(User, { email: 'john@example.com' }); + expect(newUser.firstName).toEqual('John'); + expect(newUser.lastName).toEqual('Wick'); + expect(newUser.organizationId).toBe(adminUser.organizationId); + + // expect default group permission is associated + const userGroups = await manager.find(UserGroupPermission, { userId: newUser.id }); + expect(userGroups).toHaveLength(1); + + const groupPermission = await manager.findOne(GroupPermission, { id: userGroups[0].groupPermissionId }); + expect(groupPermission.group).toEqual('all_users'); + expect(groupPermission.organizationId).toEqual(adminUser.organizationId); + }); + }); + + describe('.update', () => { + it('should update user', async () => { + const { defaultUser } = await setupOrganization(nestApp); + + await service.update(defaultUser.id, { firstName: 'Updated Name' }); + await defaultUser.reload(); + + expect(defaultUser.firstName).toEqual('Updated Name'); + }); + + it('should throw error when adding non existent user groups', async () => { + const { defaultUser } = await setupOrganization(nestApp); + + await expect(service.update(defaultUser.id, { addGroups: ['admin', 'non-existent'] })).rejects.toThrow( + 'non-existent group does not exist for current organization' + ); + }); + + it('should add user groups', async () => { + const { defaultUser } = await setupOrganization(nestApp); + await createGroupPermission(nestApp, { organizationId: defaultUser.organizationId, group: 'new-group' }); + + await service.update(defaultUser.id, { addGroups: ['new-group'] }); + await defaultUser.reload(); + + const userGroups = (await defaultUser.groupPermissions).map((groupPermission) => groupPermission.group); + + expect(userGroups.includes('new-group')).toBeTruthy; + }); + + it('should not add duplicate user groups', async () => { + const { defaultUser } = await setupOrganization(nestApp); + await createGroupPermission(nestApp, { organizationId: defaultUser.organizationId, group: 'new-group' }); + + await service.update(defaultUser.id, { addGroups: ['new-group'] }); + await defaultUser.reload(); + + await service.update(defaultUser.id, { addGroups: ['new-group', 'new-group'] }); + await defaultUser.reload(); + + const allUserGroups = (await defaultUser.groupPermissions).map((x) => x.group); + expect(new Set(allUserGroups)).toEqual(new Set(['all_users', 'new-group'])); + }); + + it('should remove user groups', async () => { + const { defaultUser } = await setupOrganization(nestApp); + await createGroupPermission(nestApp, { organizationId: defaultUser.organizationId, group: 'new-group' }); + + await service.update(defaultUser.id, { addGroups: ['new-group'] }); + await defaultUser.reload(); + expect(await defaultUser.groupPermissions).toHaveLength(2); + + await service.update(defaultUser.id, { removeGroups: ['new-group'] }); + await defaultUser.reload(); + const allUserGroups = (await defaultUser.groupPermissions).map((x) => x.group); + expect(new Set(allUserGroups)).toEqual(new Set(['all_users'])); + }); + + it('should remove user groups only if it exists', async () => { + const { defaultUser } = await setupOrganization(nestApp); + await createGroupPermission(nestApp, { organizationId: defaultUser.organizationId, group: 'new-group' }); + + await service.update(defaultUser.id, { addGroups: ['new-group'] }); + await defaultUser.reload(); + expect(await defaultUser.groupPermissions).toHaveLength(2); + + await service.update(defaultUser.id, { removeGroups: ['new-group', 'new-group', 'non-existent'] }); + await defaultUser.reload(); + const allUserGroups = (await defaultUser.groupPermissions).map((x) => x.group); + expect(new Set(allUserGroups)).toEqual(new Set(['all_users'])); + }); + + it('should throw error when trying to remove admin user group if there is only one admin', async () => { + const { adminUser } = await setupOrganization(nestApp); + + await expect(service.update(adminUser.id, { removeGroups: ['admin'] })).rejects.toThrow( + 'Atleast one active admin is required.' + ); + }); + }); + + describe('.groupPermissions', () => { + it('should return group permissions for the user', async () => { + const { adminUser, defaultUser } = await setupOrganization(nestApp); + + await createGroupPermission(nestApp, { organizationId: adminUser.organizationId, group: 'group1' }); + await service.update(adminUser.id, { addGroups: ['group1'] }); + await adminUser.reload(); + + await createGroupPermission(nestApp, { organizationId: defaultUser.organizationId, group: 'group2' }); + await service.update(defaultUser.id, { addGroups: ['group2'] }); + await defaultUser.reload(); + + let groupPermissions = (await service.groupPermissions(adminUser)).map((x) => x.group); + expect(new Set(groupPermissions)).toEqual(new Set(['all_users', 'admin', 'group1'])); + + groupPermissions = (await service.groupPermissions(defaultUser)).map((x) => x.group); + expect(new Set(groupPermissions)).toEqual(new Set(['all_users', 'group2'])); + }); + }); + + describe('.appGroupPermissions', () => { + it('should return app group permissions for the user', async () => { + const { defaultUser, app } = await setupOrganization(nestApp); + const groupPermissionIdsFromApp = (await service.appGroupPermissions(defaultUser, app.id)).map( + (x) => x.groupPermissionId + ); + + const groupPermissionIds = (await service.groupPermissions(defaultUser)) + .filter((x) => x.group == 'admin') + .map((x) => x.id); + + expect(new Set(groupPermissionIdsFromApp)).toEqual(new Set(groupPermissionIds)); + }); + }); + + describe('.groupPermissionsForOrganization', () => { + it('should return all group permissions within organization', async () => { + const { defaultUser } = await setupOrganization(nestApp); + const groupPermissions = (await service.groupPermissionsForOrganization(defaultUser.organizationId)).map( + (x) => x.group + ); + + expect(new Set(groupPermissions)).toEqual(new Set(['all_users', 'admin'])); + }); + }); + + describe('.hasGroup', () => { + it('should return false if user has group', async () => { + const { adminUser } = await setupOrganization(nestApp); + expect(await service.hasGroup(adminUser, 'admin')).toBeTruthy(); + }); + + it('should return true if user has group', async () => { + const { adminUser } = await setupOrganization(nestApp); + expect(await service.hasGroup(adminUser, 'superduper-admin')).toBeFalsy(); + }); + }); + + describe('.userCan', () => { + describe('perform action on invalid entity', () => { + it('should return false', async () => { + const { adminUser, app } = await setupOrganization(nestApp); + + expect(await service.userCan(adminUser, 'create', 'Ice cream', app.id)).toEqual(false); + expect(await service.userCan(adminUser, 'read', 'Ice cream', app.id)).toEqual(false); + expect(await service.userCan(adminUser, 'update', 'Ice cream', app.id)).toEqual(false); + expect(await service.userCan(adminUser, 'delete', 'Ice cream', app.id)).toEqual(false); + }); + }); + + describe("perform action on 'App' entity", () => { + it('should return boolean based on permissible actions', async () => { + const { adminUser, app } = await setupOrganization(nestApp); + + expect(await service.userCan(adminUser, 'create', 'App', app.id)).toEqual(true); + expect(await service.userCan(adminUser, 'read', 'App', app.id)).toEqual(true); + expect(await service.userCan(adminUser, 'update', 'App', app.id)).toEqual(true); + expect(await service.userCan(adminUser, 'delete', 'App', app.id)).toEqual(true); + }); + + it('should allow actions with custom groups based on app permissions', async () => { + const { defaultUser, app } = await setupOrganization(nestApp); + const userGroups = await createUserGroupPermissions(nestApp, defaultUser, ['developer']); + const developerUserGroup = userGroups[0]; + await createAppGroupPermission(nestApp, app, developerUserGroup.groupPermissionId, { + read: true, + update: true, + delete: false, + }); + + expect(await service.userCan(defaultUser, 'create', 'App', app.id)).toEqual(false); + expect(await service.userCan(defaultUser, 'read', 'App', app.id)).toEqual(true); + expect(await service.userCan(defaultUser, 'update', 'App', app.id)).toEqual(true); + expect(await service.userCan(defaultUser, 'delete', 'App', app.id)).toEqual(false); + }); + + it('should opt the permissible group among multiple groups', async () => { + const { defaultUser, app } = await setupOrganization(nestApp); + const userGroups = await createUserGroupPermissions(nestApp, defaultUser, ['updater', 'deleter']); + + const updaterUserGroup = userGroups[0]; + await createAppGroupPermission(nestApp, app, updaterUserGroup.groupPermissionId, { + read: true, + update: true, + delete: false, + }); + + const deleterUserGroup = userGroups[1]; + await createAppGroupPermission(nestApp, app, deleterUserGroup.groupPermissionId, { + read: false, + update: false, + delete: true, + }); + + expect(await service.userCan(defaultUser, 'create', 'App', app.id)).toEqual(false); + expect(await service.userCan(defaultUser, 'read', 'App', app.id)).toEqual(true); + expect(await service.userCan(defaultUser, 'update', 'App', app.id)).toEqual(true); + expect(await service.userCan(defaultUser, 'delete', 'App', app.id)).toEqual(true); + }); + }); + }); + + async function setupOrganization(nestApp) { + const adminUserData = await createUser(nestApp, { + email: 'admin@tooljet.io', + groups: ['all_users', 'admin'], + }); + const adminUser = adminUserData.user; + const organization = adminUserData.organization; + const defaultUserData = await createUser(nestApp, { + email: 'developer@tooljet.io', + groups: ['all_users'], + organization, + }); + const defaultUser = defaultUserData.user; + + const app = await createApplication(nestApp, { + user: adminUser, + name: 'sample app', + isPublic: false, + }); + + return { adminUser, defaultUser, app }; + } + + afterAll(async () => { + await nestApp.close(); + }); +}); diff --git a/server/test/test.helper.ts b/server/test/test.helper.ts index 7d999666d6..0e34f65fdd 100644 --- a/server/test/test.helper.ts +++ b/server/test/test.helper.ts @@ -9,12 +9,14 @@ import { App } from 'src/entities/app.entity'; import { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { AppModule } from 'src/app.module'; -import { AppUser } from 'src/entities/app_user.entity'; import { AppVersion } from 'src/entities/app_version.entity'; import { DataQuery } from 'src/entities/data_query.entity'; import { DataSource } from 'src/entities/data_source.entity'; import { DataSourcesService } from 'src/services/data_sources.service'; import { DataSourcesModule } from 'src/modules/data_sources/data_sources.module'; +import { GroupPermission } from 'src/entities/group_permission.entity'; +import { UserGroupPermission } from 'src/entities/user_group_permission.entity'; +import { AppGroupPermission } from 'src/entities/app_group_permission.entity'; export async function createNestAppInstance() { let app: INestApplication; @@ -48,18 +50,17 @@ export async function clearDB() { } } -export async function createApplication(app, { name, user, isPublic }: any) { +export async function createApplication(nestApp, { name, user, isPublic, slug }: any) { let appRepository: Repository; - appRepository = app.get('AppRepository'); - let appUsersRepository: Repository; - appUsersRepository = app.get('AppUserRepository'); + appRepository = nestApp.get('AppRepository'); - user = user || (await (await createUser(app, {})).user); + user = user || (await (await createUser(nestApp, {})).user); const newApp = await appRepository.save( appRepository.create({ name, user, + slug, isPublic: isPublic || false, organizationId: user.organization.id, createdAt: new Date(), @@ -67,22 +68,15 @@ export async function createApplication(app, { name, user, isPublic }: any) { }) ); - await appUsersRepository.save( - appUsersRepository.create({ - app: newApp, - user, - role: 'admin', - createdAt: new Date(), - updatedAt: new Date(), - }) - ); + await maybeCreateAdminAppGroupPermissions(nestApp, newApp); + await maybeCreateAllUsersAppGroupPermissions(nestApp, newApp); return newApp; } -export async function createApplicationVersion(app, application) { +export async function createApplicationVersion(nestApp, application) { let appVersionsRepository: Repository; - appVersionsRepository = app.get('AppVersionRepository'); + appVersionsRepository = nestApp.get('AppVersionRepository'); return await appVersionsRepository.save( appVersionsRepository.create({ @@ -92,14 +86,14 @@ export async function createApplicationVersion(app, application) { ); } -export async function createUser(app, { firstName, lastName, email, role, organization, status }: any) { +export async function createUser(nestApp, { firstName, lastName, email, groups, organization, status }: any) { let userRepository: Repository; let organizationRepository: Repository; let organizationUsersRepository: Repository; - userRepository = app.get('UserRepository'); - organizationRepository = app.get('OrganizationRepository'); - organizationUsersRepository = app.get('OrganizationUserRepository'); + userRepository = nestApp.get('UserRepository'); + organizationRepository = nestApp.get('OrganizationRepository'); + organizationUsersRepository = nestApp.get('OrganizationUserRepository'); organization = organization || @@ -127,21 +121,180 @@ export async function createUser(app, { firstName, lastName, email, role, organi organizationUsersRepository.create({ user: user, organization, - role: role || 'admin', status: status || 'invited', + role: 'all_users', createdAt: new Date(), updatedAt: new Date(), }) ); + await maybeCreateDefaultGroupPermissions(nestApp, user.organizationId); + await createUserGroupPermissions( + nestApp, + user, + groups || ['all_users', 'admin'] // default groups + ); + return { organization, user, orgUser }; } -export async function createDataSource(nestInstance, { name, application, kind, options }: any) { - let dataSourceRepository: Repository; - dataSourceRepository = nestInstance.get('DataSourceRepository'); +export async function createUserGroupPermissions(nestApp, user, groups) { + const groupPermissionRepository: Repository = nestApp.get('GroupPermissionRepository'); - const dataSourcesService = nestInstance.select(DataSourcesModule).get(DataSourcesService); + const userGroupPermissionRepository: Repository = nestApp.get('UserGroupPermissionRepository'); + + let userGroupPermissions = []; + + for (const group of groups) { + let groupPermission: GroupPermission; + + if (group == 'admin' || group == 'all_users') { + groupPermission = await groupPermissionRepository.findOneOrFail({ + where: { + organizationId: user.organizationId, + group: group, + }, + }); + } else { + groupPermission = groupPermissionRepository.create({ + organizationId: user.organizationId, + group: group, + }); + await groupPermissionRepository.save(groupPermission); + } + + const userGroupPermission = userGroupPermissionRepository.create({ + groupPermissionId: groupPermission.id, + userId: user.id, + }); + await userGroupPermissionRepository.save(userGroupPermission); + userGroupPermissions.push(userGroupPermission); + } + + return userGroupPermissions; +} + +export async function createAppGroupPermission(nestApp, app, groupId, permissions) { + const appGroupPermissionRepository: Repository = nestApp.get('AppGroupPermissionRepository'); + + const appGroupPermission = appGroupPermissionRepository.create({ + groupPermissionId: groupId, + appId: app.id, + ...permissions, + }); + await appGroupPermissionRepository.save(appGroupPermission); + + return appGroupPermission; +} + +export async function createGroupPermission(nestApp, params) { + const groupPermissionRepository: Repository = nestApp.get('GroupPermissionRepository'); + let groupPermission = groupPermissionRepository.create({ + ...params, + }); + await groupPermissionRepository.save(groupPermission); + + return groupPermission; +} + +export async function maybeCreateDefaultGroupPermissions(nestApp, organizationId) { + const groupPermissionRepository: Repository = nestApp.get('GroupPermissionRepository'); + + const defaultGroups = ['all_users', 'admin']; + + for (let group of defaultGroups) { + const orgDefaultGroupPermissions = await groupPermissionRepository.find({ + where: { + organizationId: organizationId, + group: group, + }, + }); + + if (orgDefaultGroupPermissions.length == 0) { + const groupPermission = groupPermissionRepository.create({ + organizationId: organizationId, + group: group, + }); + await groupPermissionRepository.save(groupPermission); + } + } +} + +export async function maybeCreateAdminAppGroupPermissions(nestApp, app) { + const groupPermissionRepository: Repository = nestApp.get('GroupPermissionRepository'); + const appGroupPermissionRepository: Repository = nestApp.get('AppGroupPermissionRepository'); + + const orgAdminGroupPermissions = await groupPermissionRepository.findOne({ + organizationId: app.organizationId, + group: 'admin', + }); + + if (orgAdminGroupPermissions) { + const adminGroupPermissions = { + read: true, + update: true, + delete: true, + }; + + const appGroupPermission = appGroupPermissionRepository.create({ + groupPermissionId: orgAdminGroupPermissions.id, + appId: app.id, + ...adminGroupPermissions, + }); + await appGroupPermissionRepository.save(appGroupPermission); + } +} + +export async function maybeCreateAllUsersAppGroupPermissions(nestApp, app) { + const groupPermissionRepository: Repository = nestApp.get('GroupPermissionRepository'); + const appGroupPermissionRepository: Repository = nestApp.get('AppGroupPermissionRepository'); + + const orgGroupPermissions = await groupPermissionRepository.findOne({ + organizationId: app.organizationId, + group: 'all_users', + }); + + if (orgGroupPermissions) { + const permissions = { + read: true, + update: false, + delete: false, + }; + + const appGroupPermission = appGroupPermissionRepository.create({ + groupPermissionId: orgGroupPermissions.id, + appId: app.id, + ...permissions, + }); + await appGroupPermissionRepository.save(appGroupPermission); + } +} + +export async function addAllUsersGroupToUser(nestApp, user) { + const groupPermissionRepository: Repository = nestApp.get('GroupPermissionRepository'); + const userGroupPermissionRepository: Repository = nestApp.get('UserGroupPermissionRepository'); + + const orgDefaultGroupPermissions = await groupPermissionRepository.findOne({ + where: { + organizationId: user.organizationId, + group: 'all_users', + }, + }); + + const userGroupPermission = userGroupPermissionRepository.create({ + groupPermissionId: orgDefaultGroupPermissions.id, + userId: user.id, + }); + await userGroupPermissionRepository.save(userGroupPermission); + + return user; +} + +export async function createDataSource(nestApp, { name, application, kind, options }: any) { + let dataSourceRepository: Repository; + dataSourceRepository = nestApp.get('DataSourceRepository'); + + const dataSourcesService = nestApp.select(DataSourcesModule).get(DataSourcesService); return await dataSourceRepository.save( dataSourceRepository.create({ @@ -155,9 +308,9 @@ export async function createDataSource(nestInstance, { name, application, kind, ); } -export async function createDataQuery(nestInstance, { application, kind, dataSource, options }: any) { +export async function createDataQuery(nestApp, { application, kind, dataSource, options }: any) { let dataQueryRepository: Repository; - dataQueryRepository = nestInstance.get('DataQueryRepository'); + dataQueryRepository = nestApp.get('DataQueryRepository'); return await dataQueryRepository.save( dataQueryRepository.create({ From d3e376a4c9a74fa80f74e29c2e354baa1859171d Mon Sep 17 00:00:00 2001 From: Jhan Silva Date: Mon, 11 Oct 2021 20:31:43 -0700 Subject: [PATCH 23/88] [docs] display Kubernetes GKE deployment page (#986) --- docs/docs/deployment/client.md | 16 +++---- docs/docs/deployment/env-vars.md | 65 +++++++++++++------------- docs/docs/deployment/heroku.md | 9 ++-- docs/docs/deployment/kubernetes-gke.md | 27 ++++++----- docs/docs/deployment/kubernetes.md | 12 ++--- 5 files changed, 67 insertions(+), 62 deletions(-) diff --git a/docs/docs/deployment/client.md b/docs/docs/deployment/client.md index 2380fb915e..c6760a2a75 100644 --- a/docs/docs/deployment/client.md +++ b/docs/docs/deployment/client.md @@ -1,5 +1,5 @@ --- -sidebar_position: 6 +sidebar_position: 7 --- # Deploying ToolJet client @@ -15,14 +15,14 @@ For example: `TOOLJET_SERVER_URL=https://server.tooljet.io npm run build && fire ::: 1. Initialize firebase project - ```bash - firebase init - ``` - Select Firebase Hosting and set build as the static file directory + ```bash + firebase init + ``` + Select Firebase Hosting and set build as the static file directory 2. Deploy client to Firebase - ```bash - firebase deploy - ``` + ```bash + firebase deploy + ``` :::tip If you want to run ToolJet on your local machine, please checkout the setup section of the contributing guide: [link](/docs/contributing-guide/setup/docker) diff --git a/docs/docs/deployment/env-vars.md b/docs/docs/deployment/env-vars.md index 9737bd8b16..cd383d2f8b 100644 --- a/docs/docs/deployment/env-vars.md +++ b/docs/docs/deployment/env-vars.md @@ -1,5 +1,5 @@ --- -sidebar_position: 7 +sidebar_position: 8 --- # Environment variables @@ -10,30 +10,28 @@ Both the ToolJet server and client requires some environment variables to start #### ToolJet host ( required ) -| variable | description | -| ----------- | ----------- | +| variable | description | +| ------------ | --------------------------------------------------------------- | | TOOLJET_HOST | the public URL of ToolJet client ( eg: https://app.tooljet.io ) | - - #### Database configuration ( required ) ToolJet server uses PostgreSQL as the database. -| variable | description | -| ----------- | ----------- | -| PG_HOST | postgres database host | -| PG_DB | name of the database | -| PG_USER | username | -| PG_PASS | password | +| variable | description | +| -------- | ---------------------- | +| PG_HOST | postgres database host | +| PG_DB | name of the database | +| PG_USER | username | +| PG_PASS | password | #### Lockbox configuration ( required ) + ToolJet server uses lockbox to encrypt datasource credentials. You should set the environment variable `LOCKBOX_MASTER_KEY` with a 32 byte hexadecimal string. - #### Application Secret ( required ) -ToolJet server uses a secure 64 byte hexadecimal string to encrypt session cookies. You should set the environment variable `SECRET_KEY_BASE`. +ToolJet server uses a secure 64 byte hexadecimal string to encrypt session cookies. You should set the environment variable `SECRET_KEY_BASE`. :::tip If you have `openssl` installed, you can run the following commands to generate the the value for `LOCKBOX_MASTER_KEY` and `SECRET_KEY_BASE`. @@ -42,7 +40,6 @@ For `LOCKBOX_MASTER_KEY` use `openssl rand -hex 32` For `SECRET_KEY_BASE` use `openssl rand -hex 64` ::: - #### Disabling signups ( optional ) If want to restrict the signups and allow new users only by invitations, set the environment variable `DISABLE_SIGNUPS` to `true`. @@ -53,7 +50,7 @@ You will still be able to see the signup page but won't be able to successfully #### Serve client as a server end-point ( optional ) -By default, the `SERVE_CLIENT` variable will be set to `false` and the server won't serve the client at its `/` end-point. +By default, the `SERVE_CLIENT` variable will be set to `false` and the server won't serve the client at its `/` end-point. You can set `SERVE_CLIENT` to `true` and the server will attempt to serve the client at its root end-point (`/`). #### SMTP configuration ( optional ) @@ -61,7 +58,7 @@ You can set `SERVE_CLIENT` to `true` and the server will attempt to serve the cl ToolJet uses SMTP services to send emails ( Eg: invitation email when you add new users to your organization ). | variable | description | -|--------------------|-------------------------------------------| +| ------------------ | ----------------------------------------- | | DEFAULT_FROM_EMAIL | from email for the email fired by ToolJet | | SMTP_USERNAME | username | | SMTP_PASSWORD | password | @@ -72,34 +69,34 @@ ToolJet uses SMTP services to send emails ( Eg: invitation email when you add ne If your ToolJet installation requires Slack as a datasource, you need to create a Slack app and set the following environment variables: -| variable | description | -| ----------- | ----------- | -| SLACK_CLIENT_ID | client id of the slack app | -| SLACK_CLIENT_SECRET | client secret of the slack app | +| variable | description | +| ------------------- | ------------------------------ | +| SLACK_CLIENT_ID | client id of the slack app | +| SLACK_CLIENT_SECRET | client secret of the slack app | #### Google OAuth ( optional ) If your ToolJet installation needs access to datasources such as Google sheets, you need to create OAuth credentials from Google Cloud Console. -| variable | description | -| ----------- | ----------- | -| GOOGLE_CLIENT_ID | client id | -| GOOGLE_CLIENT_SECRET | client secret | +| variable | description | +| -------------------- | ------------- | +| GOOGLE_CLIENT_ID | client id | +| GOOGLE_CLIENT_SECRET | client secret | #### Google maps configuration ( optional ) If your ToolJet installation requires `Maps` widget, you need to create an API key for Google Maps API. -| variable | description | -| ----------- | ----------- | +| variable | description | +| ------------------- | ------------------- | | GOOGLE_MAPS_API_KEY | Google maps API key | #### APM VENDOR ( optional ) Specify application monitoring vendor. Currently supported values - `sentry`. -| variable | description | -| ----------- | ----------- | +| variable | description | +| ---------- | ----------------------------------------- | | APM VENDOR | Application performance monitoring vendor | #### SENTRY DNS ( optional ) @@ -112,21 +109,23 @@ Prints logs for sentry. Supported values: `true` | `false` Default value is `false` #### Server URL ( optional) + This is used to set up for CSP headers and put trace info to be used with APM vendors. -| variable | description | -| ----------- | ----------- | +| variable | description | +| ------------------ | ----------------------------------------------------------- | | TOOLJET_SERVER_URL | the URL of ToolJet server ( eg: https://server.tooljet.io ) | - #### RELEASE VERSION ( optional) + Once set any APM provider that supports segregation with releases will track it. ## ToolJet client #### Server URL ( optionally required ) + This is required when client is built separately. -| variable | description | -| ----------- | ----------- | +| variable | description | +| ------------------ | ----------------------------------------------------------- | | TOOLJET_SERVER_URL | the URL of ToolJet server ( eg: https://server.tooljet.io ) | diff --git a/docs/docs/deployment/heroku.md b/docs/docs/deployment/heroku.md index cff2876481..945e1e56a4 100644 --- a/docs/docs/deployment/heroku.md +++ b/docs/docs/deployment/heroku.md @@ -1,5 +1,5 @@ --- -sidebar_position: 2 +sidebar_position: 3 sidebar_label: Heroku --- @@ -8,12 +8,13 @@ sidebar_label: Heroku Follow the steps below to deploy ToolJet on Heroku: 1. Click the button below to start one click deployment. -[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/tooljet/tooljet/tree/main) + [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/tooljet/tooljet/tree/main) 2. Navigate to Heroku dashboard and go to resources tab to verify that the dyno is turned on. -3. Go to settings tab on Heroku dashboard and select `reveal config vars` to configure additional environment variables that your installation might need. +3. Go to settings tab on Heroku dashboard and select `reveal config vars` to configure additional environment variables that your installation might need. + + Read [environment variables reference](/docs/deployment/env-vars) - Read [environment variables reference](/docs/deployment/env-vars) 4. Open the app. 5. The default username of the admin is dev@tooljet.io and password is `password`. diff --git a/docs/docs/deployment/kubernetes-gke.md b/docs/docs/deployment/kubernetes-gke.md index 16651ca86b..8d233f40fd 100644 --- a/docs/docs/deployment/kubernetes-gke.md +++ b/docs/docs/deployment/kubernetes-gke.md @@ -1,5 +1,5 @@ --- -sidebar_position: 4 +sidebar_position: 6 sidebar_label: Kubernetes (GKE) --- @@ -11,31 +11,36 @@ You should setup a PostgreSQL database manually to be used by ToolJet. We recomm Follow the steps below to deploy ToolJet on a GKE Kubernetes cluster. -1. Create an SSL certificate. +1. Create an SSL certificate. + ```bash curl -LO https://raw.githubusercontent.com/ToolJet/ToolJet/main/deploy/kubernetes/GKE/certificate.yaml ``` Change the domain name to the domain/subdomain that you wish to use for ToolJet installation. -2. Reserve a static IP address using `gcloud` cli +2. Reserve a static IP address using `gcloud` cli + ```bash gcloud compute addresses create tj-static-ip --global ``` 3. Create k8s deployment + ```bash curl -LO https://raw.githubusercontent.com/ToolJet/ToolJet/main/deploy/kubernetes/GKE/deployment.yaml ``` Make sure to edit the environment variables in the `deployment.yaml`. You can check out the available options [here](https://docs.tooljet.io/docs/deployment/env-vars). -4. Create k8s service +4. Create k8s service + ```bash curl -LO https://raw.githubusercontent.com/ToolJet/ToolJet/main/deploy/kubernetes/GKE/service.yaml ``` 5. Create k8s ingress + ```bash curl -LO https://raw.githubusercontent.com/ToolJet/ToolJet/main/deploy/kubernetes/GKE/ingress.yaml ``` @@ -52,11 +57,11 @@ kubectl apply -f certificate.yaml, deployment.yaml, service.yaml, ingress.yaml It might take a few minutes to provision the managed certificates. [Managed certificates documentation](https://cloud.google.com/kubernetes-engine/docs/how-to/managed-certs). ::: -You will be able to access your ToolJet installation once the pods, service and the ingress is running. +You will be able to access your ToolJet installation once the pods, service and the ingress is running. -If you want to seed the database with a sample user, please SSH into a pod and run: -`npm run db:seed --prefix server`. -This seeds the database with a default user with the following credentials: - -email: `dev@tooljet.io` -password: `password` \ No newline at end of file +If you want to seed the database with a sample user, please SSH into a pod and run: +`npm run db:seed --prefix server`. +This seeds the database with a default user with the following credentials: + +email: `dev@tooljet.io` +password: `password` diff --git a/docs/docs/deployment/kubernetes.md b/docs/docs/deployment/kubernetes.md index 8a484af1ca..e33ccbbd9a 100644 --- a/docs/docs/deployment/kubernetes.md +++ b/docs/docs/deployment/kubernetes.md @@ -1,5 +1,5 @@ --- -sidebar_position: 4 +sidebar_position: 5 sidebar_label: Kubernetes --- @@ -12,11 +12,11 @@ You should setup a PostgreSQL database manually to be used by ToolJet. Follow the steps below to deploy ToolJet on a Kubernetes cluster. 1. Setup a PostgreSQL database - ToolJet uses a postgres database as the persistent storage for storing data related to users and apps. We do not have plans to support other databases such as MySQL. + ToolJet uses a postgres database as the persistent storage for storing data related to users and apps. We do not have plans to support other databases such as MySQL. 2. Create a Kubernetes secret with name `server`. For the minimal setup, ToolJet requires `pg_host`, `pg_db`, `pg_user`, `pg_password`, `secret_key_base` & `lockbox_key` keys in the secret. - Read [environment variables reference](/docs/deployment/env-vars) + Read [environment variables reference](/docs/deployment/env-vars) 3. Create a Kubernetes deployment @@ -35,9 +35,9 @@ The file given above is just a template and might not suit production environmen ``` 5. Create a Kubernetes services to publish the Kubernetes deployment that you've created. This step varies with cloud providers. We have a [template](https://raw.githubusercontent.com/ToolJet/ToolJet/main/deploy/kubernetes/service.yaml) for exposing the ToolJet server as a service using an AWS loadbalancer. -Examples: -Application load balancing on Amazon EKS: https://docs.aws.amazon.com/eks/latest/userguide/alb-ingress.html -GKE Ingress for HTTP(S) Load Balancing: https://cloud.google.com/kubernetes-engine/docs/concepts/ingress + Examples: + Application load balancing on Amazon EKS: https://docs.aws.amazon.com/eks/latest/userguide/alb-ingress.html + GKE Ingress for HTTP(S) Load Balancing: https://cloud.google.com/kubernetes-engine/docs/concepts/ingress :::tip If you want to serve ToolJet client from services such as Firebase or Netlify, please read the client deployment documentation [here](/docs/deployment/client). From a0403a48046b031e4daaad6075e2f7b40a5a3308 Mon Sep 17 00:00:00 2001 From: Elisha Hollander Date: Tue, 12 Oct 2021 06:33:34 +0300 Subject: [PATCH 24/88] Code refactoring (#977) * remove `componentData` * declare `x`, `y` as `var`s * remove testing for `!isTesting` * Update frontend/src/Editor/CustomDragLayer.jsx --- frontend/src/Editor/Container.jsx | 1 - frontend/src/Editor/CustomDragLayer.jsx | 2 +- frontend/src/Editor/DataSourceManager/TestConnection.jsx | 4 ++-- frontend/src/Editor/SubCustomDragLayer.jsx | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/frontend/src/Editor/Container.jsx b/frontend/src/Editor/Container.jsx index 115164ff8b..fb2aff4563 100644 --- a/frontend/src/Editor/Container.jsx +++ b/frontend/src/Editor/Container.jsx @@ -116,7 +116,6 @@ export const Container = ({ deltaY = delta.y; } - componentData = item.component; left = Math.round(currentLayoutOptions.left + deltaX); top = Math.round(currentLayoutOptions.top + deltaY); diff --git a/frontend/src/Editor/CustomDragLayer.jsx b/frontend/src/Editor/CustomDragLayer.jsx index 5bb94bb01e..6e3549cfc9 100644 --- a/frontend/src/Editor/CustomDragLayer.jsx +++ b/frontend/src/Editor/CustomDragLayer.jsx @@ -19,7 +19,7 @@ function getItemStyles(delta, item, initialOffset, currentOffset, currentLayout) display: 'none', }; } - let { x, y } = currentOffset; + let x, y; let id = item.id; diff --git a/frontend/src/Editor/DataSourceManager/TestConnection.jsx b/frontend/src/Editor/DataSourceManager/TestConnection.jsx index 7d75336a0d..1e535f30f3 100644 --- a/frontend/src/Editor/DataSourceManager/TestConnection.jsx +++ b/frontend/src/Editor/DataSourceManager/TestConnection.jsx @@ -11,7 +11,7 @@ export const TestConnection = ({ kind, options, onConnectionTestFailed }) => { useEffect(() => { if (isTesting) { setButtonText('Testing connection...'); - } else if (!isTesting && connectionStatus === 'success') { + } else if (connectionStatus === 'success') { setButtonText('Connection verified'); } else { setButtonText('Test Connection'); @@ -54,7 +54,7 @@ export const TestConnection = ({ kind, options, onConnectionTestFailed }) => {