diff --git a/.env.example b/.env.example index 71b587bb2b..924c7578d1 100644 --- a/.env.example +++ b/.env.example @@ -192,3 +192,4 @@ FS_REPO=local # Location of virus scanning binary (ClamAV - https://www.clamav.net/) #CLAMSCAN_BINARY=/usr/bin/clamscan +RESTRICT_CREATE_ORGANISATION=true \ No newline at end of file diff --git a/api/src/routes/HttpRoutes.js b/api/src/routes/HttpRoutes.js index d390390072..c107cd26ce 100644 --- a/api/src/routes/HttpRoutes.js +++ b/api/src/routes/HttpRoutes.js @@ -6,8 +6,8 @@ import Promise from 'bluebird'; import { omit, findIndex } from 'lodash'; import getAuthFromRequest from 'lib/helpers/getAuthFromRequest'; import getScopesFromRequest from 'lib/services/auth/authInfoSelectors/getScopesFromAuthInfo'; -import { SITE_ADMIN } from 'lib/constants/scopes'; import getUserIdFromAuthInfo from 'lib/services/auth/authInfoSelectors/getUserIdFromAuthInfo'; +import { SITE_ADMIN } from 'lib/constants/scopes'; import { jsonSuccess, serverError } from 'api/utils/responses'; import passport from 'api/auth/passport'; import { @@ -209,12 +209,13 @@ restify.serve(router, User, { const authInfo = getAuthFromRequest(req); const scopes = getScopesFromRequest(authInfo); - if ( - findIndex(scopes, item => item === SITE_ADMIN) < 0 && - (req.body._id !== getUserIdFromAuthInfo(authInfo).toString()) - ) { - // Don't allow changing of passwords - req.body = omit(req.body, 'password'); + if (findIndex(scopes, item => item === SITE_ADMIN) < 0) { + // remove scope changes + req.body = omit(req.body, 'scopes'); + if (req.body._id !== getUserIdFromAuthInfo(authInfo).toString()){ + // Don't allow changing of passwords + req.body = omit(req.body, 'password'); + } } next(); diff --git a/api/src/routes/tests/scopeFiltering/downloadLogo-test.js b/api/src/routes/tests/scopeFiltering/downloadLogo-test.js index c5997767bc..79eff31e8b 100644 --- a/api/src/routes/tests/scopeFiltering/downloadLogo-test.js +++ b/api/src/routes/tests/scopeFiltering/downloadLogo-test.js @@ -14,7 +14,7 @@ const TEST_FILE = `${process.cwd()}/api/src/routes/tests/fixtures/favicon.png`; describe('DownloadController.downloadLogo scope filtering', () => { const apiApp = setup(); - const createMasterToken = async () => createOrgToken([ALL], [], masterId); + const createMasterToken = async () => createOrgToken([ALL], [SITE_ADMIN], masterId); const uploadLogo = async () => { const orgId = testId; @@ -54,7 +54,7 @@ describe('DownloadController.downloadLogo scope filtering', () => { expectedCode: 200 }); - it('should not allow action when no scopes are used', async () => { + it('should allow action when no scopes are used', async () => { const token = await createOrgToken([]); await assertAuthorised(token); }); diff --git a/lib/constants/scopes.js b/lib/constants/scopes.js index 89a5c4960d..25d738f7b2 100644 --- a/lib/constants/scopes.js +++ b/lib/constants/scopes.js @@ -40,8 +40,11 @@ export const CLIENT_SCOPES = { // USERS export const SITE_ADMIN = 'site_admin'; +export const SITE_CAN_CREATE_ORG = 'site_can_create_org'; + export const SITE_SCOPES = { [SITE_ADMIN]: 'Site Administrator', + [SITE_CAN_CREATE_ORG]: 'Can create organisation' }; // ORGANISATION_USER diff --git a/lib/services/auth/canCreateOrganisations.js b/lib/services/auth/canCreateOrganisations.js new file mode 100644 index 0000000000..3f73001057 --- /dev/null +++ b/lib/services/auth/canCreateOrganisations.js @@ -0,0 +1,20 @@ +import { SITE_ADMIN, SITE_CAN_CREATE_ORG, ALL } from 'lib/constants/scopes'; +import intersection from 'lodash/intersection'; +import boolean from 'boolean'; +import defaultTo from 'lodash/defaultTo'; + + +export default (activeScopes, { + RESTRICT_CREATE_ORGANISATION = boolean(defaultTo(process.env.RESTRICT_CREATE_ORGANISATION, true)) +}) => { + const viewScope = 'org/all/organisation/manage'; + const requiredScopes = [ + viewScope, + SITE_ADMIN, + SITE_CAN_CREATE_ORG, + ...(RESTRICT_CREATE_ORGANISATION ? [] : [ALL]) + ]; + + const matchingScopes = intersection(requiredScopes, activeScopes); + return matchingScopes.length !== 0; +}; diff --git a/lib/services/auth/canViewModel.js b/lib/services/auth/canViewModel.js index 3208d79df1..a25e3f3c0a 100644 --- a/lib/services/auth/canViewModel.js +++ b/lib/services/auth/canViewModel.js @@ -4,6 +4,7 @@ import intersection from 'lodash/intersection'; export default (modelName, activeScopes) => { const viewScope = `org/all/${modelName}/manage`; const requiredScopes = [viewScope, SITE_ADMIN, ALL]; + const matchingScopes = intersection(requiredScopes, activeScopes); return matchingScopes.length !== 0; }; diff --git a/lib/services/auth/modelFilters/organisation.js b/lib/services/auth/modelFilters/organisation.js index 50e0b884b2..62d2f2845c 100644 --- a/lib/services/auth/modelFilters/organisation.js +++ b/lib/services/auth/modelFilters/organisation.js @@ -1,11 +1,12 @@ import includes from 'lodash/includes'; import get from 'lodash/get'; // import chain from 'lodash/chain'; -import { chain } from 'lodash'; +import { chain, defaultTo } from 'lodash'; import map from 'lodash/map'; import intersection from 'lodash/intersection'; import { SITE_ADMIN, + SITE_CAN_CREATE_ORG, ALL } from 'lib/constants/scopes'; import { @@ -16,6 +17,7 @@ import getOrgFromAuthInfo from 'lib/services/auth/authInfoSelectors/getOrgFromAu import getTokenTypeFromAuthInfo from 'lib/services/auth/authInfoSelectors/getTokenTypeFromAuthInfo'; import NoAccessError from 'lib/errors/NoAccessError'; import Role from 'lib/models/role'; +import boolean from 'boolean'; const getUserViewableOrgs = async (authInfo, scopes) => { let organisationIds = []; @@ -97,9 +99,10 @@ const createFilterQuery = organisationIds => ({ _id: { $in: organisationIds } }); const modelFiltersOrganisation = async ({ actionName, authInfo }) => { + const superAdminOnly = boolean(defaultTo(process.env.RESTRICT_CREATE_ORGANISATION, true)); const scopes = getScopesFromAuthInfo(authInfo); - if (includes(scopes, SITE_ADMIN)) { + if (includes(scopes, SITE_ADMIN) || includes(scopes, SITE_CAN_CREATE_ORG)) { return {}; } @@ -109,6 +112,7 @@ const modelFiltersOrganisation = async ({ actionName, authInfo }) => { return createFilterQuery(organisationIds); } case 'create': { + if (superAdminOnly) throw new NoAccessError(); checkCreationScope(authInfo, scopes); break; } diff --git a/lib/services/auth/tests/modelFilters/organisation-test.js b/lib/services/auth/tests/modelFilters/organisation-test.js index c9650992c2..5134d8e169 100644 --- a/lib/services/auth/tests/modelFilters/organisation-test.js +++ b/lib/services/auth/tests/modelFilters/organisation-test.js @@ -34,15 +34,14 @@ describe('model scope filters organisation', () => { // org admin tests testOrgScopeFilter(modelName, 'view', [ALL], TEST_USER_ORGS_FILTER); - testOrgScopeFilter(modelName, 'create', [ALL], undefined); + testOrgScopeFilterError(modelName, 'create', [ALL], undefined); + // @todo: tests do not use roles properly. Assign a role to the org setting on the user // testOrgScopeFilter(modelName, 'edit', [ALL], TEST_USER_ORGS_FILTER); // testOrgScopeFilter(modelName, 'delete', [ALL], TEST_USER_NO_ORGS_FILTER); testOrgScopeFilter(modelName, 'view', [], TEST_USER_ORGS_FILTER); - testOrgScopeFilter(modelName, 'edit', [], TEST_USER_NO_ORGS_FILTER); testOrgScopeFilterError(modelName, 'create', []); - testOrgScopeFilter(modelName, 'delete', [], TEST_USER_NO_ORGS_FILTER); testOrgScopeFilter( modelName, @@ -50,14 +49,6 @@ describe('model scope filters organisation', () => { [MANAGE_ALL_ORGANISATIONS], TEST_USER_ORGS_FILTER ); - testOrgScopeFilter( - modelName, - 'edit', - [MANAGE_ALL_ORGANISATIONS], - TEST_USER_NO_ORGS_FILTER - ); - testOrgScopeFilter(modelName, 'create', [MANAGE_ALL_ORGANISATIONS], undefined); - testOrgScopeFilter(modelName, 'delete', [MANAGE_ALL_ORGANISATIONS], TEST_USER_NO_ORGS_FILTER); it('should return the correct view filter when using a owner token', async () => { const user = createUser(TEST_OWNER_ID); @@ -84,6 +75,7 @@ describe('model scope filters organisation', () => { testClientBasicScopeFilterError(modelName, 'delete', [ALL]); testClientBasicScopeFilterError(modelName, 'delete', []); + // site admin tests testSiteAdminScopeFilter(modelName, 'view', {}); testSiteAdminScopeFilter(modelName, 'edit', {}); diff --git a/ui/src/containers/SideNav/index.js b/ui/src/containers/SideNav/index.js index 41faad3aea..9481b12ff8 100644 --- a/ui/src/containers/SideNav/index.js +++ b/ui/src/containers/SideNav/index.js @@ -74,7 +74,9 @@ class SideNav extends Component { const activeScopes = this.props.activeScopes.toJS(); const canViewStores = canViewModel('store', activeScopes); const canViewUsers = canViewModel('user', activeScopes); + const canViewOrganisations = canViewModel('organisation', activeScopes); + const canViewClients = canViewModel('client', activeScopes); const canViewRoles = canViewModel('role', activeScopes); const canViewSettings = ( @@ -166,12 +168,14 @@ class SideNav extends Component { export default compose( withStyles(styles), - connect(state => ({ - activeRoute: routeNodeSelector('organisation')(state).route, - activeOrganisationSettings: activeOrganisationSettingsSelector(state), - activeScopes: currentScopesSelector(state), - id: activeOrgIdSelector(state) - })), + connect(state => + ({ + activeRoute: routeNodeSelector('organisation')(state).route, + activeOrganisationSettings: activeOrganisationSettingsSelector(state), + activeScopes: currentScopesSelector(state), + id: activeOrgIdSelector(state) + }) + ), withProps(() => ({ schema: 'organisation' })), diff --git a/ui/src/containers/SiteOrgs/index.js b/ui/src/containers/SiteOrgs/index.js index 4684324891..5ec629745f 100644 --- a/ui/src/containers/SiteOrgs/index.js +++ b/ui/src/containers/SiteOrgs/index.js @@ -10,10 +10,12 @@ import SearchBox from 'ui/containers/SearchBox'; import { withModels, withModel } from 'ui/utils/hocs'; import SiteOrgItem from 'ui/containers/SiteOrgs/SiteOrgItem'; import { addModel as addModelAction } from 'ui/redux/modules/models'; -import { loggedInUserSelector } from 'ui/redux/modules/auth'; +import { loggedInUserSelector, currentScopesSelector } from 'ui/redux/modules/auth'; import ModelList from 'ui/containers/ModelList'; import DeleteButton from 'ui/containers/DeleteButton'; import OrgMemberButton from 'ui/containers/OrgMemberButton'; +import canCreateOrganisationsFn from 'lib/services/auth/canCreateOrganisations'; +import { getAppDataSelector } from 'ui/redux/modules/app'; import { routeNodeSelector } from 'redux-router5'; const schema = 'organisation'; @@ -31,6 +33,8 @@ const enhance = compose( state => ({ searchString: modelQueryStringSelector(schema)(state), authUser: loggedInUserSelector(state), + activeScopes: currentScopesSelector(state), + RESTRICT_CREATE_ORGANISATION: getAppDataSelector('RESTRICT_CREATE_ORGANISATION')(state), organisationId: routeNodeSelector('admin.organisations.id')(state).route.params.organisationId }), { addModel: addModelAction } @@ -51,35 +55,45 @@ const render = ({ params, searchString, handleAdd, - organisationId -}) => ( -
-
-
- All Organisations - - - - - - -
-
+ activeScopes, + RESTRICT_CREATE_ORGANISATION, + organisationId, +}) => { + const activeScopesJs = activeScopes.toJS(); + + const canCreateOrg = canCreateOrganisationsFn(activeScopesJs, { + RESTRICT_CREATE_ORGANISATION + }); -
-
- model.get('name')} - buttons={[OrgMemberButton, DeleteButton]} - ModelForm={SiteOrgItem} /> + return ( +
+
+
+ All Organisations + {canCreateOrg && + + } + + + +
+
+ +
+
+ model.get('name')} + buttons={[OrgMemberButton, DeleteButton]} + ModelForm={SiteOrgItem} /> +
-
-); + ); +}; export default enhance(render); diff --git a/ui/src/containers/SubOrgs/index.js b/ui/src/containers/SubOrgs/index.js index 1be6af8490..d651193033 100644 --- a/ui/src/containers/SubOrgs/index.js +++ b/ui/src/containers/SubOrgs/index.js @@ -5,11 +5,13 @@ import { withProps, compose } from 'recompose'; import { addModel } from 'ui/redux/modules/models'; import { queryStringToQuery, modelQueryStringSelector } from 'ui/redux/modules/search'; import { routeNodeSelector } from 'redux-router5'; -import { loggedInUserId } from 'ui/redux/modules/auth'; +import { loggedInUserId, currentScopesSelector } from 'ui/redux/modules/auth'; import SearchBox from 'ui/containers/SearchBox'; import ModelList from 'ui/containers/ModelList'; import SubOrgForm from 'ui/containers/SubOrgForm'; import { withModels } from 'ui/utils/hocs'; +import canCreateOrganisationsFn from 'lib/services/auth/canCreateOrganisations'; +import { getAppDataSelector } from 'ui/redux/modules/app'; const schema = 'organisation'; const OrgList = compose( @@ -25,7 +27,7 @@ class SubOrgs extends Component { userId: PropTypes.string, organisationId: PropTypes.string, addModel: PropTypes.func, - searchString: PropTypes.string, + searchString: PropTypes.string }; onClickAdd = () => { @@ -40,31 +42,39 @@ class SubOrgs extends Component { }); } - render = () => ( -
-
-
- - - - - - - Organisations -
-
-
-
- model.get('name')} - ModelForm={SubOrgForm} /> + render = () => { + const activeScopesJs = this.props.activeScopes.toJS(); + + const canCreateOrg = canCreateOrganisationsFn(activeScopesJs, { + RESTRICT_CREATE_ORGANISATION: this.props.RESTRICT_CREATE_ORGANISATION + }); + + return ( +
+
+
+ { canCreateOrg && + + } + + + + Organisations +
+
+
+
+ model.get('name')} + ModelForm={SubOrgForm} /> +
-
- ); + ); + } } export default connect((state) => { @@ -74,5 +84,7 @@ export default connect((state) => { organisationId: params.organisationId, userId, searchString: modelQueryStringSelector(schema)(state), + activeScopes: currentScopesSelector(state), + RESTRICT_CREATE_ORGANISATION: getAppDataSelector('RESTRICT_CREATE_ORGANISATION')(state) }; }, { addModel })(SubOrgs); diff --git a/ui/src/containers/UserOrgForm/index.js b/ui/src/containers/UserOrgForm/index.js index 68fa41837b..0fe4bf6196 100644 --- a/ui/src/containers/UserOrgForm/index.js +++ b/ui/src/containers/UserOrgForm/index.js @@ -8,6 +8,9 @@ import UserForm from 'ui/containers/UserForm'; import { activeOrgIdSelector } from 'ui/redux/modules/router'; import Checkbox from 'ui/components/Material/Checkbox'; import { connect } from 'react-redux'; +import { getAppDataSelector } from 'ui/redux/modules/app'; +import { currentScopesSelector } from 'ui/redux/modules/auth'; +import { SITE_ADMIN, SITE_CAN_CREATE_ORG, SITE_SCOPES } from 'lib/constants/scopes'; const ORG_SETTINGS = 'organisationSettings'; @@ -42,6 +45,42 @@ const RolesList = compose(
) ); +const SiteRolesList = compose( + setPropTypes({ + selectedRoles: PropTypes.instanceOf(List).isRequired, + handleRolesChange: PropTypes.func.isRequired, + }), + withProps({ + sort: new Map({ + title: 1, + _id: 1, + }), + filter: new Map({}), + }), +)(({ selectedRoles, handleRolesChange }) => { + const models = new List([ + new Map({ + _id: SITE_CAN_CREATE_ORG, + title: SITE_SCOPES[SITE_CAN_CREATE_ORG] + }) + ]); + return (
+ { + models.map((model) => { + const modelId = model.get('_id'); + const isChecked = selectedRoles.includes(modelId); + return ( + + ); + }).valueSeq() + } +
); +}); + const getDefaultOrgSettings = organisation => fromJS({ organisation, @@ -63,7 +102,9 @@ const enhance = compose( id: PropTypes.string.isRequired, }), connect(state => ({ - organisationId: activeOrgIdSelector(state) + organisationId: activeOrgIdSelector(state), + activeScopes: currentScopesSelector(state), + RESTRICT_CREATE_ORGANISATION: getAppDataSelector('RESTRICT_CREATE_ORGANISATION')(state) })), withProps({ schema: 'user', @@ -108,6 +149,13 @@ const enhance = compose( ); updateOrgSettings('roles', newRoles.toList()); }, + handleSiteRolesChange: ({ model, updateModel }) => (role, checked) => { + const scopes = model.get('scopes', new List()).toSet(); + const newScopes = checked ? + scopes.add(role) : + scopes.delete(role); + updateModel({ path: 'scopes', value: newScopes.toList() }); + }, handleFilterChange: ({ updateOrgSettings }) => (filter) => { updateOrgSettings('filter', filter); @@ -116,13 +164,25 @@ const enhance = compose( ); const render = (props) => { - const { model, organisationId, handleRolesChange, handleFilterChange } = props; + const { + model, + organisationId, + handleRolesChange, + handleFilterChange, + handleSiteRolesChange, + RESTRICT_CREATE_ORGANISATION + } = props; const userOrgSettings = getActiveOrgSettings({ model, organisationId }); const roles = userOrgSettings.get('roles', new List()); const rolesId = uuid.v4(); + const siteRolesId = uuid.v4(); const filterId = uuid.v4(); const filter = fromJS(userOrgSettings.get('filter', new Map({}))); + const siteRoles = model.get('scopes', new List()); + const canEditSiteRoles = RESTRICT_CREATE_ORGANISATION && + props.activeScopes.includes(SITE_ADMIN); + return (
@@ -136,6 +196,19 @@ const render = (props) => {
+ + + {canEditSiteRoles &&
+
+
+ +
+ +
+
+
+
} +
{ clientAssets.client.js, ]; - data.state = {}; + data.state = { + app: { + RESTRICT_CREATE_ORGANISATION: boolean(defaultTo(process.env.RESTRICT_CREATE_ORGANISATION, true)) + } + }; + const html = renderToString(); global.navigator = { userAgent: req.headers['user-agent'] }; res.status(200);