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 -}) => ( -