diff --git a/config/default.json b/config/default.json index 5dcc1fa2..13b28e3a 100644 --- a/config/default.json +++ b/config/default.json @@ -39,8 +39,8 @@ "url": "localhost:9092" }, "analyticsKey": "", - "VALID_ISSUERS": "[\"https:\/\/topcoder-newauth.auth0.com\/\",\"https:\/\/api.topcoder-dev.com\"]", - "validIssuers": "[\"https:\/\/topcoder-newauth.auth0.com\/\",\"https:\/\/api.topcoder-dev.com\"]", + "VALID_ISSUERS": "[\"https:\/\/topcoder-newauth.auth0.com\/\",\"https:\/\/api.topcoder-dev.com\",\"https:\/\/topcoder-dev.auth0.com\/\"]", + "validIssuers": "[\"https:\/\/topcoder-newauth.auth0.com\/\",\"https:\/\/api.topcoder-dev.com\",\"https:\/\/topcoder-dev.auth0.com\/\"]", "jwksUri": "", "busApiUrl": "http://api.topcoder-dev.com/v5", "messageApiUrl": "http://api.topcoder-dev.com/v5", diff --git a/docs/permissions.html b/docs/permissions.html new file mode 100644 index 00000000..16be0bc6 --- /dev/null +++ b/docs/permissions.html @@ -0,0 +1,1299 @@ + + + + + + + Permissions + + + +
+
+

Permissions

+

List of all the possible user permissions inside Topcoder Project Service

+
+

Legend:

+ +
+ +
+
+

+ Project +

+
+
+
+
+
+ Create Project +
+
CREATE_PROJECT
+
+
+
+
+
+ +
+ Any Logged-in User +
+ +
+ all:connect_project + all:projects + write:projects +
+
+
+
+
+
+ Create Project as a "manager" +
+
CREATE_PROJECT_AS_MANAGER
+
When user creates a project they become a project member. + If user has this permission they would join project with "manager" + project role, otherwise with "customer".
+
+
+
+
+ +
+ Connect Admin + administrator + Connect Manager + Connect Account Manager + Connect Copilot Manager + Business Development Representative + Presales + Account Executive + Program Manager + Solution Architect + Project Manager +
+ +
+ all:connect_project + all:projects + write:projects +
+
+
+
+
+
+ Read Project +
+
READ_PROJECT
+
+
+
+
+ Any Project Member +
+ +
+ Connect Admin + administrator + Connect Manager + Connect Account Manager + Connect Copilot Manager + Business Development Representative + Presales + Account Executive + Program Manager + Solution Architect + Project Manager +
+ +
+ all:connect_project + all:projects + read:projects +
+
+
+
+
+
+ Read Any Project +
+
READ_PROJECT_ANY
+
Read any project, even when not a member.
+
+
+
+
+ +
+ Connect Admin + administrator + Connect Manager + Connect Account Manager + Connect Copilot Manager + Business Development Representative + Presales + Account Executive + Program Manager + Solution Architect + Project Manager +
+ +
+ all:connect_project + all:projects + read:projects +
+
+
+
+
+
+ Update Project +
+
UPDATE_PROJECT
+
There are additional limitations on editing some parts of the project.
+
+
+
+ Any Project Member +
+ +
+ Connect Admin + administrator + Connect Manager + Connect Account Manager + Connect Copilot Manager + Business Development Representative + Presales + Account Executive + Program Manager + Solution Architect + Project Manager +
+ +
+ all:connect_project + all:projects + write:projects +
+
+
+
+
+
+ Update Project property "directProjectId" +
+
UPDATE_PROJECT_DIRECT_PROJECT_ID
+
+
+
+
+
+ +
+ Connect Manager + administrator +
+ +
+ all:connect_project + all:projects + write:projects +
+
+
+
+
+
+ Delete Project +
+
DELETE_PROJECT
+
Has different set of permission unlike to update.
+
+
+
+ owner + manager + program_manager + project_manager + solution_architect +
+ +
+ Connect Admin + administrator + Connect Manager + Connect Account Manager + Connect Copilot Manager + Business Development Representative + Presales + Account Executive + Program Manager + Solution Architect + Project Manager +
+ +
+ all:connect_project + all:projects + write:projects +
+
+
+
+
+

+ Project Member +

+
+
+
+
+
+ Read Project Member +
+
READ_PROJECT_MEMBER
+
+
+
+
+ Any Project Member +
+ +
+ Connect Admin + administrator + Connect Manager + Connect Account Manager + Connect Copilot Manager + Business Development Representative + Presales + Account Executive + Program Manager + Solution Architect + Project Manager +
+ +
+ all:connect_project + all:project-members + read:project-members +
+
+
+
+
+
+ Read Project Member Details +
+
READ_PROJECT_MEMBER_DETAILS
+
Who can see user details (PII) like email, first name and last name.
+
+
+
+
+ +
+ administrator +
+ +
+ all:connect_project + all:project-members + read:project-members +
+
+
+
+
+
+ Create Project Member (own) +
+
CREATE_PROJECT_MEMBER_OWN
+
Who can add themselves as project members.
+
+
+
+
+ +
+ Connect Admin + administrator + Connect Manager + Connect Account Manager + Connect Copilot Manager + Business Development Representative + Presales + Account Executive + Program Manager + Solution Architect + Project Manager +
+ +
+ all:connect_project + all:project-members + write:project-members +
+
+
+
+
+
+ Create Project Member (not own) +
+
CREATE_PROJECT_MEMBER_NOT_OWN
+
Who can add other users as project members.
+
+
+
+
+ +
+ Connect Admin + administrator +
+ +
+ all:connect_project + all:project-members + write:project-members +
+
+
+
+
+
+ Update Project Member (customer) +
+
UPDATE_PROJECT_MEMBER_CUSTOMER
+
Who can update project members with "customer" role.
+
+
+
+ Any Project Member +
+ +
+ Connect Admin + administrator +
+ +
+ all:connect_project + all:project-members + write:project-members +
+
+
+
+
+
+ Update Project Member (non-customer) +
+
UPDATE_PROJECT_MEMBER_NON_CUSTOMER
+
Who can update project members with non "customer" role.
+
+
+
+ manager + account_manager + program_manager + account_executive + solution_architect + project_manager +
+ +
+ Connect Admin + administrator +
+ +
+ all:connect_project + all:project-members + write:project-members +
+
+
+
+
+
+ Update Project Member (to copilot) +
+
UPDATE_PROJECT_MEMBER_TO_COPILOT
+
Who can update project member role to "copilot".
+
+
+
+
+ +
+ Connect Admin + administrator + Connect Copilot Manager +
+ +
+ all:connect_project + all:project-members + write:project-members +
+
+
+
+
+
+ Delete Project Member (customer) +
+
DELETE_PROJECT_MEMBER_CUSTOMER
+
Who can delete project members with "customer" role.
+
+
+
+ Any Project Member +
+ +
+ Connect Admin + administrator +
+ +
+ all:connect_project + all:project-members + write:project-members +
+
+
+
+
+
+ Delete Project Member (non-customer) +
+
DELETE_PROJECT_MEMBER_NON_CUSTOMER
+
Who can delete project members with non "customer" role.
+
+
+
+ manager + account_manager + program_manager + account_executive + solution_architect + project_manager +
+ +
+ Connect Admin + administrator +
+ +
+ all:connect_project + all:project-members + write:project-members +
+
+
+
+
+

+ Project Invite +

+
+
+
+
+
+ Read Project Invite (own) +
+
READ_PROJECT_INVITE_OWN
+
Who can view own invite.
+
+
+
+
+ +
+ Any Logged-in User +
+ +
+ all:connect_project + all:project-members + read:project-members +
+
+
+
+
+
+ Read Project Invite (not own) +
+
READ_PROJECT_INVITE_NOT_OWN
+
Who can view invites of other users.
+
+
+
+ Any Project Member +
+ +
+ Connect Admin + administrator + Connect Manager + Connect Account Manager + Connect Copilot Manager + Business Development Representative + Presales + Account Executive + Program Manager + Solution Architect + Project Manager +
+ +
+ all:connect_project + all:project-members + read:project-members +
+
+
+
+
+
+ Create Project Invite (customer) +
+
CREATE_PROJECT_INVITE_CUSTOMER
+
Who can invite project members with "customer" role.
+
+
+
+ Any Project Member +
+ +
+ Connect Admin + administrator + Connect Manager + Connect Account Manager + Connect Copilot Manager + Business Development Representative + Presales + Account Executive + Program Manager + Solution Architect + Project Manager +
+ +
+ all:connect_project + all:project-members + write:project-members +
+
+
+
+
+
+ Create Project Invite (non-customer) +
+
CREATE_PROJECT_INVITE_NON_CUSTOMER
+
Who can invite project members with non "customer" role.
+
+
+
+ manager + account_manager + program_manager + account_executive + solution_architect + project_manager +
+ +
+ Connect Admin + administrator +
+ +
+ all:connect_project + all:project-members + write:project-members +
+
+
+
+
+
+ Create Project Invite (copilot) +
+
CREATE_PROJECT_INVITE_COPILOT_DIRECTLY
+
Who can invite user with "copilot" role directly without requesting.
+
+
+
+
+ +
+ Connect Admin + administrator + Connect Copilot Manager +
+ +
+ all:connect_project + all:project-members + write:project-members +
+
+
+
+
+
+ Update Project Invite (own) +
+
UPDATE_PROJECT_INVITE_OWN
+
Who can update own invite.
+
+
+
+
+ +
+ Any Logged-in User +
+ +
+ all:connect_project + all:project-members + write:project-members +
+
+
+
+
+
+ Update Project Invite (not own) +
+
UPDATE_PROJECT_INVITE_NOT_OWN
+
Who can update invites for other members.
+
+
+
+
+ +
+ Connect Admin + administrator +
+ +
+ all:connect_project + all:project-members + write:project-members +
+
+
+
+
+
+ Update Project Invite (requested) +
+
UPDATE_PROJECT_INVITE_REQUESTED
+
Who can update requested invites.
+
+
+
+
+ +
+ Connect Admin + administrator + Connect Copilot Manager +
+ +
+ all:connect_project + all:project-members + write:project-members +
+
+
+
+
+
+ Delete Project Member (own) +
+
DELETE_PROJECT_INVITE_OWN
+
Who can delete own invite.
+
+
+
+
+ +
+ Any Logged-in User +
+ +
+ all:connect_project + all:project-members + write:project-members +
+
+
+
+
+
+ Delete Project Invite (not own, customer) +
+
DELETE_PROJECT_INVITE_NOT_OWN_CUSTOMER
+
Who can delete invites for other members with "customer" role.
+
+
+
+ Any Project Member +
+ +
+ Connect Admin + administrator +
+ +
+ all:connect_project + all:project-members + write:project-members +
+
+
+
+
+
+ Delete Project Invite (not own, non-customer) +
+
DELETE_PROJECT_INVITE_NOT_OWN_NON_CUSTOMER
+
Who can delete project invites for other members with non "customer" role.
+
+
+
+ manager + account_manager + program_manager + account_executive + solution_architect + project_manager +
+ +
+ Connect Admin + administrator +
+ +
+ all:connect_project + all:project-members + write:project-members +
+
+
+
+
+
+ Delete Project Invite (requested) +
+
DELETE_PROJECT_INVITE_REQUESTED
+
Who can delete requested invites.
+
+
+
+
+ +
+ Connect Admin + administrator + Connect Copilot Manager +
+ +
+ all:connect_project + all:project-members + write:project-members +
+
+
+
+
+

+ Deprecated +

+
+
+
+
+
+ +
+
ROLES_COPILOT_AND_ABOVE
+
+
+
+
+ program_manager + solution_architect + project_manager + manager + copilot +
+ +
+ Connect Admin + administrator +
+ +
+
+
+
+ +

+ Roles Matrix +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Project \ Topcoder
Connect Manager
Connect Admin
administrator
Connect Account Manager
Business Development Representative
Presales
Connect Copilot
Account Executive
Program Manager
Solution Architect
Project Manager
Topcoder User
customer + ✅ + + ✅ + + ✅ + + ✅ + + ✅ + + ✅ + + ✅ + + ✅ + + ✅ + + ✅ + + ✅ + + ✅ +
manager + ✅ + + ✅ + + ✅ + + + + + + + + + + + + + + + + + + +
copilot + + + + + + + + + + + + + ✅ + + + + + + + + + + +
account_manager + ✅ + + + + + + ✅ + + ✅ + + ✅ + + + + ✅ + + ✅ + + ✅ + + ✅ + + +
account_executive + + + + + + + + + + + + + + + ✅ + + + + + + + + +
project_manager + + + + + + + + + + + + + + + + + + + + + ✅ + + +
solution_architect + + + + + + + + + + + + + + + + + + + ✅ + + + + +
program_manager + + + + + + + + + + + + + + + + + ✅ + + + + + + +
+
- means default Project Role if user with according Topcoder Role directly joins the project (if they are allowed to join directly). If user has multiple Topcoder Roles then the most left Topcoder Role on the table would define default Project Role. +
+
+ + diff --git a/package.json b/package.json index 293f416f..b64fbca4 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,9 @@ "data:export": "NODE_ENV=development LOG_LEVEL=info node --require dotenv/config --require babel-core/register scripts/data/export", "data:import": "NODE_ENV=development LOG_LEVEL=info node --require dotenv/config --require babel-core/register scripts/data/import", "local:run-docker": "docker-compose -f ./local/full/docker-compose.yml up -d", - "local:init": "npm run sync:all && npm run data:import" + "local:init": "npm run sync:all && npm run data:import", + "generate:doc:permissions": "babel-node scripts/permissions-doc", + "generate:doc:permissions:dev": "nodemon --watch scripts/permissions-doc --watch src --ext js,jsx,hbs --exec babel-node scripts/permissions-doc" }, "repository": { "type": "git", diff --git a/scripts/permissions-doc/index.js b/scripts/permissions-doc/index.js new file mode 100644 index 00000000..2c9b50cc --- /dev/null +++ b/scripts/permissions-doc/index.js @@ -0,0 +1,152 @@ +/** + * Generate a permissions.html document using the permission config from the Topcoder Connect App. + * + * Run by: `npm run generate:doc:permissions` + * + * For development purpose, run by `npm run generate:doc:permissions:dev` which would regenerate HTML on every update. + */ +import _ from 'lodash'; +import fs from 'fs'; +import path from 'path'; +import handlebars from 'handlebars'; +import { + PERMISSION, + PROJECT_TO_TOPCODER_ROLES_MATRIX, + DEFAULT_PROJECT_ROLE, +} from '../../src/permissions/constants'; +import { + PROJECT_MEMBER_ROLE, +} from '../../src/constants'; +import util from '../../src/util'; + +const docTemplatePath = path.resolve(__dirname, './template.hbs'); +const outputDocPath = path.resolve(__dirname, '../../docs/permissions.html'); + +handlebars.registerHelper('istrue', value => value === true); + +/** + * Normalize all the project and topcoder role lists to the list of strings. + * + * - `projectRoles` can be `true` -> full list of Project Roles + * - `projectRoles` may contain an object for `owner` role -> `owner` (string) + * - `topcoderRoles` can be `true` -> full list of Topcoder Roles + * + * @param {Object} rule permission rule + * + * @returns {Object} permission rule with all the roles as strings + */ +function normalizePermissionRule(rule) { + const normalizedRule = _.cloneDeep(rule); + + if (_.isArray(normalizedRule.projectRoles)) { + normalizedRule.projectRoles = normalizedRule.projectRoles.map((role) => { + if (_.isEqual(role, { role: PROJECT_MEMBER_ROLE.CUSTOMER, isPrimary: true })) { + return 'owner'; + } + + return role; + }); + } + + return normalizedRule; +} + +/** + * Normalize permission object which has "simple" and "full" shape into a "full" shape for consistency + * + * @param {Object} permission permission object + * + * @returns {Objects} permission object in the "full" shape with "allowRule" and "denyRule" + */ +function normalizePermission(permission) { + let normalizedPermission = permission; + + if (!normalizedPermission.allowRule) { + normalizedPermission = { + meta: permission.meta, + allowRule: _.omit(permission, 'meta'), + }; + } + + if (normalizedPermission.allowRule) { + normalizedPermission.allowRule = normalizePermissionRule(normalizedPermission.allowRule); + } + + if (normalizedPermission.denyRule) { + normalizedPermission.denyRule = normalizePermissionRule(normalizedPermission.denyRule); + } + + return normalizedPermission; +} + +/** + * @returns {Object} project/topcoder roles matrix + */ +function getNormalizedRolesMatrix() { + const topcoderRolesAll = _.values(_.map(DEFAULT_PROJECT_ROLE, 'topcoderRole')); + const projectRolesAll = _.keys(PROJECT_TO_TOPCODER_ROLES_MATRIX); + + const isDefaultRole = (topcoderRole, projectRole) => + util.getDefaultProjectRole({ roles: [topcoderRole] }) === projectRole; + + const isAllowedRole = (topcoderRole, projectRole) => + (PROJECT_TO_TOPCODER_ROLES_MATRIX[projectRole] || []).includes(topcoderRole); + + const columns = ['Project \\ Topcoder'].concat(topcoderRolesAll); + const rows = projectRolesAll.map(projectRole => ({ + rowHeader: projectRole, + cells: topcoderRolesAll.map(topcoderRole => ({ + isAllowed: isAllowedRole(topcoderRole, projectRole), + isDefault: isDefaultRole(topcoderRole, projectRole), + })), + })); + + // Uncomment if you want to switch columns and rows + // const columns = ['Topcoder \\ Project'].concat(topcoderRolesAll); + // const rows = topcoderRolesAll.map(topcoderRole => ({ + // rowHeader: topcoderRole, + // cells: projectRolesAll.map(projectRole => ({ + // isAllowed: isAllowedRole(topcoderRole, projectRole), + // isDefault: isDefaultRole(topcoderRole, projectRole), + // })), + // })); + + return { + columns, + rows, + }; +} + +const templateStr = fs.readFileSync(docTemplatePath).toString(); +const renderDocument = handlebars.compile(templateStr); + +const permissionKeys = _.keys(PERMISSION); +// prepare permissions without modifying data in constant `PERMISSION` +const allPermissions = permissionKeys.map((key) => { + // add `key` to meta + const meta = _.assign({}, PERMISSION[key].meta, { + key, + }); + + // update `meta` to one with `key` + return _.assign({}, PERMISSION[key], { + meta, + }); +}); +const groupsObj = _.groupBy(allPermissions, 'meta.group'); +const groups = _.toPairs(groupsObj).map(([title, permissions]) => ({ + title, + anchor: `section-${title.toLowerCase().replace(' ', '-')}`, + permissions, +})); + +groups.forEach((group) => { + group.permissions = group.permissions.map(normalizePermission); // eslint-disable-line no-param-reassign +}); + +const data = { + groups, + rolesMatrix: getNormalizedRolesMatrix(), +}; + +fs.writeFileSync(outputDocPath, renderDocument(data)); diff --git a/scripts/permissions-doc/template.hbs b/scripts/permissions-doc/template.hbs new file mode 100644 index 00000000..41d49140 --- /dev/null +++ b/scripts/permissions-doc/template.hbs @@ -0,0 +1,198 @@ + + + + + + + Permissions + + + +
+
+

Permissions

+

List of all the possible user permissions inside Topcoder Project Service

+
+

Legend:

+ +
+ + {{#each groups}} +
+
+

+ {{title}} +

+
+
+ {{#each permissions}} +
+
+
+ {{meta.title}} +
+
{{meta.key}}
+
{{meta.description}}
+
+
+
+ {{#if (istrue allowRule.projectRoles)}} + Any Project Member + {{else}} + {{#each allowRule.projectRoles}} + {{this}} + {{/each}} + {{/if}} + {{#each denyRule.projectRoles}} + {{this}} + {{/each}} +
+ +
+ {{#if (istrue allowRule.topcoderRoles)}} + Any Logged-in User + {{else}} + {{#each allowRule.topcoderRoles}} + {{this}} + {{/each}} + {{/if}} + {{#each denyRule.topcoderRoles}} + {{this}} + {{/each}} +
+ +
+ {{#each allowRule.scopes}} + {{this}} + {{/each}} + {{#each denyRule.scopes}} + {{this}} + {{/each}} +
+
+
+ {{/each}} + {{/each}} + +

+ Roles Matrix +

+ + + + + {{#each rolesMatrix.columns}} + + {{/each}} + + + + {{#each rolesMatrix.rows}} + + + {{#each this.cells}} + + {{/each}} + + {{/each}} + + + + + + +
{{this}}
{{this.rowHeader}} + {{#if this.isAllowed}}✅{{/if}} +
+
- means default Project Role if user with according Topcoder Role directly joins the project (if they are allowed to join directly). If user has multiple Topcoder Roles then the most left Topcoder Role on the table would define default Project Role. +
+
+ + diff --git a/src/constants.js b/src/constants.js index 57dc1b87..fb358a05 100644 --- a/src/constants.js +++ b/src/constants.js @@ -65,6 +65,7 @@ export const USER_ROLE = { PROGRAM_MANAGER: 'Program Manager', SOLUTION_ARCHITECT: 'Solution Architect', PROJECT_MANAGER: 'Project Manager', + TOPCODER_USER: 'Topcoder User', }; export const ADMIN_ROLES = [USER_ROLE.CONNECT_ADMIN, USER_ROLE.TOPCODER_ADMIN]; @@ -266,8 +267,18 @@ export const REGEX = { URL: /^(http(s?):\/\/)?(www\.)?[a-zA-Z0-9\.\-\_]+(\.[a-zA-Z]{2,15})+(\:[0-9]{2,5})?(\/[a-zA-Z0-9\_\-\s\.\/\?\%\#\&\=;]*)?$/, // eslint-disable-line }; -export const TOKEN_SCOPES = { +export const M2M_SCOPES = { CONNECT_PROJECT_ADMIN: 'all:connect_project', + PROJECTS: { + ALL: 'all:projects', + READ: 'read:projects', + WRITE: 'write:projects', + }, + PROJECT_MEMBERS: { + ALL: 'all:project-members', + READ: 'read:project-members', + WRITE: 'write:project-members', + }, }; export const TIMELINE_REFERENCES = { diff --git a/src/events/busApi.js b/src/events/busApi.js index 1a334220..a6af87c6 100644 --- a/src/events/busApi.js +++ b/src/events/busApi.js @@ -224,7 +224,7 @@ module.exports = (app, logger) => { projectName: project.name, refCode: _.get(project, 'details.utm.code'), projectUrl: connectProjectUrl(project.id), - userId: req.authUser.userId, + userId: member.userId, initiatorUserId: req.authUser.userId, }, logger); }).catch(err => null); // eslint-disable-line no-unused-vars @@ -269,7 +269,7 @@ module.exports = (app, logger) => { projectName: project.name, refCode: _.get(project, 'details.utm.code'), projectUrl: connectProjectUrl(project.id), - userId: req.authUser.userId, + userId: member.userId, initiatorUserId: req.authUser.userId, }, logger); } @@ -312,7 +312,7 @@ module.exports = (app, logger) => { projectName: project.name, refCode: _.get(project, 'details.utm.code'), projectUrl: connectProjectUrl(project.id), - userId: req.authUser.userId, + userId: updated.userId, initiatorUserId: req.authUser.userId, }, logger); } diff --git a/src/models/projectEstimationItem.js b/src/models/projectEstimationItem.js index 0e0aeb87..5266faac 100644 --- a/src/models/projectEstimationItem.js +++ b/src/models/projectEstimationItem.js @@ -33,7 +33,7 @@ const permissionsConfigs = [ // Project Copilots can get only 'community' type of Project Estimation Items { - permission: { projectRoles: PROJECT_MEMBER_ROLE.COPILOT }, + permission: { projectRoles: [PROJECT_MEMBER_ROLE.COPILOT] }, types: [ESTIMATION_TYPE.COMMUNITY], }, ]; diff --git a/src/permissions/constants.js b/src/permissions/constants.js index 9033fc1e..d989b725 100644 --- a/src/permissions/constants.js +++ b/src/permissions/constants.js @@ -1,71 +1,513 @@ /** - * Definitions of permissions which could be used with util methods - * `util.hasPermission` or `util.hasPermissionForProject`. + * User permission policies. + * Can be used with `hasPermission` method. * - * We can define permission using two logics: - * 1. **WHAT** can be done with such a permission. Such constants may have names like: - * - `VIEW_PROJECT` - * - `EDIT_MILESTONE` + * PERMISSION GUIDELINES + * + * All the permission name and meaning should define **WHAT** can be done having such permission + * but not **WHO** can do it. + * + * Examples of CORRECT permission naming and meaning: + * - `READ_PROJECT` + * - `UPDATE_MILESTONE` * - `DELETE_WORK` - * and os on. - * 2. **WHO** can do actions with such a permission. Such constants **MUST** start from the prefix `ROLES_`, examples: - * - `ROLES_COPILOT_AND_ABOVE` - * - `ROLES_PROJECT_MEMBERS` - * - `ROLES_ADMINS` + * + * Examples of INCORRECT permissions naming and meaning: + * - `COPILOT_AND_MANAGER` + * - `PROJECT_MEMBERS` + * - `ADMINS` + * + * The same time **internally only** in this file, constants like `COPILOT_AND_ABOVE`, + * `PROJECT_MEMBERS`, `ADMINS` could be used to define permissions. + * + * NAMING GUIDELINES + * + * There are unified prefixes to indicate what kind of permissions. + * If no prefix is suitable, please, feel free to use a new prefix. + * + * CREATE_ - create somethings + * READ_ - read something + * UPDATE_ - update something + * DELETE_ - delete something + * + * MANAGE_ - means combination of 3 operations CREATE/UPDATE/DELETE. + * usually should be used, when READ operation is allowed to everyone + * while 3 manage operations require additional permissions + * ACCESS_ - means combination of all 4 operations READ/CREATE/UPDATE/DELETE. + * usually should be used, when by default users cannot even READ something + * and if someone can READ, then also can do other kind of operations. + * + * ANTI-PERMISSIONS + * + * If it's technically impossible to create permission rules for some situation in "allowed" manner, + * in such case we can create permission rules, which would disallow somethings. + * - Create such rules ONLY IF CREATING ALLOW RULE IS IMPOSSIBLE. + * - Add a comment to such rules explaining why allow-rule cannot be created. */ import _ from 'lodash'; -import { + import { PROJECT_MEMBER_ROLE, - PROJECT_MEMBER_MANAGER_ROLES, - ADMIN_ROLES, USER_ROLE, + ADMIN_ROLES as TOPCODER_ROLES_ADMINS, + MANAGER_ROLES as TOPCODER_ROLES_MANAGERS_AND_ADMINS, + M2M_SCOPES, } from '../constants'; +const PROJECT_ROLES_ALL = _.values(PROJECT_MEMBER_ROLE); +const PROJECT_ROLES_MANAGEMENT = _.difference(PROJECT_ROLES_ALL, [ + PROJECT_MEMBER_ROLE.COPILOT, + PROJECT_MEMBER_ROLE.CUSTOMER, + PROJECT_MEMBER_ROLE.OBSERVER, +]); + +const ALL = true; + +const SCOPES_PROJECTS_READ = [ + M2M_SCOPES.CONNECT_PROJECT_ADMIN, + M2M_SCOPES.PROJECTS.ALL, + M2M_SCOPES.PROJECTS.READ, +]; + +const SCOPES_PROJECTS_WRITE = [ + M2M_SCOPES.CONNECT_PROJECT_ADMIN, + M2M_SCOPES.PROJECTS.ALL, + M2M_SCOPES.PROJECTS.WRITE, +]; + +const SCOPES_PROJECT_MEMBERS_READ = [ + M2M_SCOPES.CONNECT_PROJECT_ADMIN, + M2M_SCOPES.PROJECT_MEMBERS.ALL, + M2M_SCOPES.PROJECT_MEMBERS.READ, +]; + +const SCOPES_PROJECT_MEMBERS_WRITE = [ + M2M_SCOPES.CONNECT_PROJECT_ADMIN, + M2M_SCOPES.PROJECT_MEMBERS.ALL, + M2M_SCOPES.PROJECT_MEMBERS.WRITE, +]; + export const PERMISSION = { // eslint-disable-line import/prefer-default-export - /** - * Permissions defined by logic: **WHO** can do actions with such a permission. + /* + * Project */ - ROLES_COPILOT_AND_ABOVE: { - topcoderRoles: ADMIN_ROLES, + CREATE_PROJECT: { + meta: { + title: 'Create Project', + group: 'Project', + }, + topcoderRoles: ALL, + scopes: SCOPES_PROJECTS_WRITE, + }, + + CREATE_PROJECT_AS_MANAGER: { + meta: { + title: 'Create Project as a "manager"', + group: 'Project', + description: `When user creates a project they become a project member. + If user has this permission they would join project with "${PROJECT_MEMBER_ROLE.MANAGER}" + project role, otherwise with "${PROJECT_MEMBER_ROLE.CUSTOMER}".`, + }, + topcoderRoles: TOPCODER_ROLES_MANAGERS_AND_ADMINS, + scopes: SCOPES_PROJECTS_WRITE, + }, + + READ_PROJECT: { + meta: { + title: 'Read Project', + group: 'Project', + }, + topcoderRoles: TOPCODER_ROLES_MANAGERS_AND_ADMINS, + projectRoles: ALL, + scopes: SCOPES_PROJECTS_READ, + }, + + READ_PROJECT_ANY: { + meta: { + title: 'Read Any Project', + group: 'Project', + description: 'Read any project, even when not a member.', + }, + topcoderRoles: TOPCODER_ROLES_MANAGERS_AND_ADMINS, + scopes: SCOPES_PROJECTS_READ, + }, + + UPDATE_PROJECT: { + meta: { + title: 'Update Project', + group: 'Project', + description: 'There are additional limitations on editing some parts of the project.', + }, + topcoderRoles: TOPCODER_ROLES_MANAGERS_AND_ADMINS, + projectRoles: ALL, + scopes: SCOPES_PROJECTS_WRITE, + }, + + UPDATE_PROJECT_DIRECT_PROJECT_ID: { + meta: { + title: 'Update Project property "directProjectId"', + group: 'Project', + }, + topcoderRoles: [ + USER_ROLE.MANAGER, + USER_ROLE.TOPCODER_ADMIN, + ], + scopes: SCOPES_PROJECTS_WRITE, + }, + + DELETE_PROJECT: { + meta: { + title: 'Delete Project', + group: 'Project', + description: 'Has different set of permission unlike to update.', + }, + topcoderRoles: TOPCODER_ROLES_MANAGERS_AND_ADMINS, projectRoles: [ + // primary customer user, usually the one who created the project + { role: PROJECT_MEMBER_ROLE.CUSTOMER, isPrimary: true }, + PROJECT_MEMBER_ROLE.MANAGER, PROJECT_MEMBER_ROLE.PROGRAM_MANAGER, - PROJECT_MEMBER_ROLE.SOLUTION_ARCHITECT, PROJECT_MEMBER_ROLE.PROJECT_MANAGER, - PROJECT_MEMBER_ROLE.MANAGER, - PROJECT_MEMBER_ROLE.COPILOT, + PROJECT_MEMBER_ROLE.SOLUTION_ARCHITECT, ], + scopes: SCOPES_PROJECTS_WRITE, }, - /** - * Permissions defined by logic: **WHAT** can be done with such a permission. - */ - /* - * Update invite permissions + * Project Member */ - UPDATE_NOT_OWN_INVITE: { - topcoderRoles: [USER_ROLE.TOPCODER_ADMIN, USER_ROLE.CONNECT_ADMIN], + READ_PROJECT_MEMBER: { + meta: { + title: 'Read Project Member', + group: 'Project Member', + }, + topcoderRoles: TOPCODER_ROLES_MANAGERS_AND_ADMINS, + projectRoles: ALL, + scopes: SCOPES_PROJECT_MEMBERS_READ, + }, + + READ_PROJECT_MEMBER_DETAILS: { + meta: { + title: 'Read Project Member Details', + group: 'Project Member', + description: 'Who can see user details (PII) like email, first name and last name.', + }, + topcoderRoles: [ + USER_ROLE.TOPCODER_ADMIN, + ], + scopes: SCOPES_PROJECT_MEMBERS_READ, + }, + + CREATE_PROJECT_MEMBER_OWN: { + meta: { + title: 'Create Project Member (own)', + group: 'Project Member', + description: 'Who can add themselves as project members.', + }, + topcoderRoles: TOPCODER_ROLES_MANAGERS_AND_ADMINS, + scopes: SCOPES_PROJECT_MEMBERS_WRITE, }, - UPDATE_REQUESTED_INVITE: { - topcoderRoles: [USER_ROLE.TOPCODER_ADMIN, USER_ROLE.CONNECT_ADMIN, USER_ROLE.COPILOT_MANAGER], + CREATE_PROJECT_MEMBER_NOT_OWN: { + meta: { + title: 'Create Project Member (not own)', + group: 'Project Member', + description: 'Who can add other users as project members.', + }, + topcoderRoles: TOPCODER_ROLES_ADMINS, + scopes: SCOPES_PROJECT_MEMBERS_WRITE, + }, + + UPDATE_PROJECT_MEMBER_CUSTOMER: { + meta: { + title: 'Update Project Member (customer)', + group: 'Project Member', + description: 'Who can update project members with "customer" role.', + }, + topcoderRoles: TOPCODER_ROLES_ADMINS, + projectRoles: ALL, + scopes: SCOPES_PROJECT_MEMBERS_WRITE, + }, + + UPDATE_PROJECT_MEMBER_NON_CUSTOMER: { + meta: { + title: 'Update Project Member (non-customer)', + group: 'Project Member', + description: 'Who can update project members with non "customer" role.', + }, + topcoderRoles: TOPCODER_ROLES_ADMINS, + projectRoles: PROJECT_ROLES_MANAGEMENT, + scopes: SCOPES_PROJECT_MEMBERS_WRITE, + }, + + UPDATE_PROJECT_MEMBER_TO_COPILOT: { + meta: { + title: 'Update Project Member (to copilot)', + group: 'Project Member', + description: 'Who can update project member role to "copilot".', + }, + topcoderRoles: [ + ...TOPCODER_ROLES_ADMINS, + USER_ROLE.COPILOT_MANAGER, + ], + scopes: SCOPES_PROJECT_MEMBERS_WRITE, + }, + + DELETE_PROJECT_MEMBER_CUSTOMER: { + meta: { + title: 'Delete Project Member (customer)', + group: 'Project Member', + description: 'Who can delete project members with "customer" role.', + }, + topcoderRoles: TOPCODER_ROLES_ADMINS, + projectRoles: ALL, + scopes: SCOPES_PROJECT_MEMBERS_WRITE, + }, + + DELETE_PROJECT_MEMBER_NON_CUSTOMER: { + meta: { + title: 'Delete Project Member (non-customer)', + group: 'Project Member', + description: 'Who can delete project members with non "customer" role.', + }, + topcoderRoles: TOPCODER_ROLES_ADMINS, + projectRoles: PROJECT_ROLES_MANAGEMENT, + scopes: SCOPES_PROJECT_MEMBERS_WRITE, }, /* - * Delete invite permissions + * Project Invite */ - DELETE_CUSTOMER_INVITE: { - topcoderRoles: [USER_ROLE.TOPCODER_ADMIN, USER_ROLE.CONNECT_ADMIN], - projectRoles: _.values(PROJECT_MEMBER_ROLE), // any project member + READ_PROJECT_INVITE_OWN: { + meta: { + title: 'Read Project Invite (own)', + group: 'Project Invite', + description: 'Who can view own invite.', + }, + topcoderRoles: ALL, + scopes: SCOPES_PROJECT_MEMBERS_READ, + }, + + READ_PROJECT_INVITE_NOT_OWN: { + meta: { + title: 'Read Project Invite (not own)', + group: 'Project Invite', + description: 'Who can view invites of other users.', + }, + topcoderRoles: TOPCODER_ROLES_MANAGERS_AND_ADMINS, + projectRoles: ALL, + scopes: SCOPES_PROJECT_MEMBERS_READ, + }, + + CREATE_PROJECT_INVITE_CUSTOMER: { + meta: { + title: 'Create Project Invite (customer)', + group: 'Project Invite', + description: 'Who can invite project members with "customer" role.', + }, + topcoderRoles: TOPCODER_ROLES_MANAGERS_AND_ADMINS, + projectRoles: ALL, + scopes: SCOPES_PROJECT_MEMBERS_WRITE, + }, + + CREATE_PROJECT_INVITE_NON_CUSTOMER: { + meta: { + title: 'Create Project Invite (non-customer)', + group: 'Project Invite', + description: 'Who can invite project members with non "customer" role.', + }, + topcoderRoles: TOPCODER_ROLES_ADMINS, + projectRoles: PROJECT_ROLES_MANAGEMENT, + scopes: SCOPES_PROJECT_MEMBERS_WRITE, + }, + + CREATE_PROJECT_INVITE_COPILOT_DIRECTLY: { + meta: { + title: 'Create Project Invite (copilot)', + group: 'Project Invite', + description: 'Who can invite user with "copilot" role directly without requesting.', + }, + topcoderRoles: [ + ...TOPCODER_ROLES_ADMINS, + USER_ROLE.COPILOT_MANAGER, + ], + scopes: SCOPES_PROJECT_MEMBERS_WRITE, + }, + + UPDATE_PROJECT_INVITE_OWN: { + meta: { + title: 'Update Project Invite (own)', + group: 'Project Invite', + description: 'Who can update own invite.', + }, + topcoderRoles: ALL, + scopes: SCOPES_PROJECT_MEMBERS_WRITE, + }, + + UPDATE_PROJECT_INVITE_NOT_OWN: { + meta: { + title: 'Update Project Invite (not own)', + group: 'Project Invite', + description: 'Who can update invites for other members.', + }, + topcoderRoles: TOPCODER_ROLES_ADMINS, + scopes: SCOPES_PROJECT_MEMBERS_WRITE, + }, + + UPDATE_PROJECT_INVITE_REQUESTED: { + meta: { + title: 'Update Project Invite (requested)', + group: 'Project Invite', + description: 'Who can update requested invites.', + }, + topcoderRoles: [ + ...TOPCODER_ROLES_ADMINS, + USER_ROLE.COPILOT_MANAGER, + ], + scopes: SCOPES_PROJECT_MEMBERS_WRITE, }, - DELETE_NON_CUSTOMER_INVITE: { - topcoderRoles: [USER_ROLE.TOPCODER_ADMIN, USER_ROLE.CONNECT_ADMIN], - projectRoles: PROJECT_MEMBER_MANAGER_ROLES, + DELETE_PROJECT_INVITE_OWN: { + meta: { + title: 'Delete Project Member (own)', + group: 'Project Invite', + description: 'Who can delete own invite.', + }, + topcoderRoles: ALL, + scopes: SCOPES_PROJECT_MEMBERS_WRITE, }, - DELETE_REQUESTED_INVITE: { - topcoderRoles: [USER_ROLE.TOPCODER_ADMIN, USER_ROLE.CONNECT_ADMIN, USER_ROLE.COPILOT_MANAGER], + DELETE_PROJECT_INVITE_NOT_OWN_CUSTOMER: { + meta: { + title: 'Delete Project Invite (not own, customer)', + group: 'Project Invite', + description: 'Who can delete invites for other members with "customer" role.', + }, + topcoderRoles: TOPCODER_ROLES_ADMINS, + projectRoles: ALL, + scopes: SCOPES_PROJECT_MEMBERS_WRITE, + }, + + DELETE_PROJECT_INVITE_NOT_OWN_NON_CUSTOMER: { + meta: { + title: 'Delete Project Invite (not own, non-customer)', + group: 'Project Invite', + description: 'Who can delete project invites for other members with non "customer" role.', + }, + topcoderRoles: TOPCODER_ROLES_ADMINS, + projectRoles: PROJECT_ROLES_MANAGEMENT, + scopes: SCOPES_PROJECT_MEMBERS_WRITE, + }, + + DELETE_PROJECT_INVITE_REQUESTED: { + meta: { + title: 'Delete Project Invite (requested)', + group: 'Project Invite', + description: 'Who can delete requested invites.', + }, + topcoderRoles: [ + ...TOPCODER_ROLES_ADMINS, + USER_ROLE.COPILOT_MANAGER, + ], + scopes: SCOPES_PROJECT_MEMBERS_WRITE, + }, + + /** + * Permissions defined by logic: **WHO** can do actions with such a permission. + */ + ROLES_COPILOT_AND_ABOVE: { + meta: { + group: 'Deprecated', + }, + topcoderRoles: TOPCODER_ROLES_ADMINS, + projectRoles: [ + PROJECT_MEMBER_ROLE.PROGRAM_MANAGER, + PROJECT_MEMBER_ROLE.SOLUTION_ARCHITECT, + PROJECT_MEMBER_ROLE.PROJECT_MANAGER, + PROJECT_MEMBER_ROLE.MANAGER, + PROJECT_MEMBER_ROLE.COPILOT, + ], }, }; +export const PROJECT_TO_TOPCODER_ROLES_MATRIX = { + [PROJECT_MEMBER_ROLE.CUSTOMER]: _.values(USER_ROLE), + [PROJECT_MEMBER_ROLE.MANAGER]: [ + USER_ROLE.TOPCODER_ADMIN, + USER_ROLE.CONNECT_ADMIN, + USER_ROLE.MANAGER, + ], + [PROJECT_MEMBER_ROLE.COPILOT]: [ + USER_ROLE.COPILOT, + ], + [PROJECT_MEMBER_ROLE.ACCOUNT_MANAGER]: [ + USER_ROLE.MANAGER, + USER_ROLE.TOPCODER_ACCOUNT_MANAGER, + USER_ROLE.BUSINESS_DEVELOPMENT_REPRESENTATIVE, + USER_ROLE.PRESALES, + USER_ROLE.ACCOUNT_EXECUTIVE, + USER_ROLE.PROGRAM_MANAGER, + USER_ROLE.SOLUTION_ARCHITECT, + USER_ROLE.PROJECT_MANAGER, + ], + [PROJECT_MEMBER_ROLE.ACCOUNT_EXECUTIVE]: [ + USER_ROLE.ACCOUNT_EXECUTIVE, + ], + [PROJECT_MEMBER_ROLE.PROJECT_MANAGER]: [ + USER_ROLE.PROJECT_MANAGER, + ], + [PROJECT_MEMBER_ROLE.SOLUTION_ARCHITECT]: [ + USER_ROLE.SOLUTION_ARCHITECT, + ], + [PROJECT_MEMBER_ROLE.PROGRAM_MANAGER]: [ + USER_ROLE.PROGRAM_MANAGER, + ], +}; + +/** + * This list determines default Project Role by Topcoder Role. + * + * - The order of items in this list is IMPORTANT. + * - To determine default Project Role we have to go from TOP to END + * and find the first record which has the Topcoder Role of the user. + * - Always define default Project Role which is allowed for such Topcoder Role + * as per `PROJECT_TO_TOPCODER_ROLES_MATRIX` + */ +export const DEFAULT_PROJECT_ROLE = [ + { + topcoderRole: USER_ROLE.MANAGER, + projectRole: PROJECT_MEMBER_ROLE.MANAGER, + }, { + topcoderRole: USER_ROLE.CONNECT_ADMIN, + projectRole: PROJECT_MEMBER_ROLE.MANAGER, + }, { + topcoderRole: USER_ROLE.TOPCODER_ADMIN, + projectRole: PROJECT_MEMBER_ROLE.MANAGER, + }, { + topcoderRole: USER_ROLE.TOPCODER_ACCOUNT_MANAGER, + projectRole: PROJECT_MEMBER_ROLE.ACCOUNT_MANAGER, + }, { + topcoderRole: USER_ROLE.BUSINESS_DEVELOPMENT_REPRESENTATIVE, + projectRole: PROJECT_MEMBER_ROLE.ACCOUNT_MANAGER, + }, { + topcoderRole: USER_ROLE.PRESALES, + projectRole: PROJECT_MEMBER_ROLE.ACCOUNT_MANAGER, + }, { + topcoderRole: USER_ROLE.COPILOT, + projectRole: PROJECT_MEMBER_ROLE.COPILOT, + }, { + topcoderRole: USER_ROLE.ACCOUNT_EXECUTIVE, + projectRole: PROJECT_MEMBER_ROLE.ACCOUNT_EXECUTIVE, + }, { + topcoderRole: USER_ROLE.PROGRAM_MANAGER, + projectRole: PROJECT_MEMBER_ROLE.PROGRAM_MANAGER, + }, { + topcoderRole: USER_ROLE.SOLUTION_ARCHITECT, + projectRole: PROJECT_MEMBER_ROLE.SOLUTION_ARCHITECT, + }, { + topcoderRole: USER_ROLE.PROJECT_MANAGER, + projectRole: PROJECT_MEMBER_ROLE.PROJECT_MANAGER, + }, { + topcoderRole: USER_ROLE.TOPCODER_USER, + projectRole: PROJECT_MEMBER_ROLE.CUSTOMER, + }, +]; diff --git a/src/permissions/copilotAndAbove.js b/src/permissions/copilotAndAbove.js index d6e8b21c..896665be 100644 --- a/src/permissions/copilotAndAbove.js +++ b/src/permissions/copilotAndAbove.js @@ -15,13 +15,11 @@ module.exports = req => new Promise((resolve, reject) => { return models.ProjectMember.getActiveProjectMembers(projectId) .then((members) => { - const hasPermission = util.hasPermission(PERMISSION.ROLES_COPILOT_AND_ABOVE, req.authUser, members); - - // TODO should we really do this? - // if no, we can replace `getActiveProjectMembers + util.hasPermission` with one `util.hasPermissionForProject` req.context = req.context || {}; req.context.currentProjectMembers = members; + const hasPermission = util.hasPermissionByReq(PERMISSION.ROLES_COPILOT_AND_ABOVE, req); + if (!hasPermission) { // the copilot or manager is not a registered project member return reject(new Error('You do not have permissions to perform this action')); diff --git a/src/permissions/generalPermission.js b/src/permissions/generalPermission.js new file mode 100644 index 00000000..ca7c8295 --- /dev/null +++ b/src/permissions/generalPermission.js @@ -0,0 +1,73 @@ + +/** + * General method to check that user has permissions to call particular route. + * + * This "middleware" uses unified permissions rules to check access. + * + * - `permissions` can be an array of permissions rules or one permission rule object + * + * Usage: + * 1. One permission + * ```js + * Authorizer.setPolicy('project.view', generalPermission(PERMISSION.VIEW_PROJECT)); + * ``` + * + * where `PERMISSION.VIEW_PROJECT` is defined as any object which could be processed by + * the method `util.hasPermission`. + * + * 2. Multiple permissions + * ```js + * Authorizer.setPolicy('project.view', generalPermission([ + * PERMISSION.READ_PROJECT_INVITE_OWN, + * PERMISSION.READ_PROJECT_INVITE_NOT_OWN, + * ])); + * ``` + * + * In this case if user who is making request has at least of one listed permissions access would be allowed. + */ +import _ from 'lodash'; +import util from '../util'; +import models from '../models'; + +/** + * @param {Object|Array} permissions permission object or array of permissions + */ +module.exports = permissions => async (req) => { + const projectId = _.parseInt(req.params.projectId); + + // if one of the `permission` requires to know Project Members, but current route doesn't belong to any project + // this means such `permission` most likely has been applied by mistake, so we throw an error + const permissionsRequireProjectMembers = _.isArray(permissions) + ? _.some(permissions, permission => util.hasPermissionByReq(permission, req)) + : util.isPermissionRequireProjectMembers(permissions); + + if (_.isUndefined(req.params.projectId) && permissionsRequireProjectMembers) { + throw new Error('Permissions for this route requires Project Members' + + ', but this route doesn\'t have "projectId".'); + + // if we have `projectId`, then retrieve project members no matter if `permission` requires them or no + // as we often need them inside `context.currentProjectMembers`, so we always load them for consistency + } if (!_.isUndefined(req.params.projectId)) { + try { + const projectMembers = await models.ProjectMember.getActiveProjectMembers(projectId); + req.context = req.context || {}; + req.context.currentProjectMembers = projectMembers; + } catch (err) { + // if we could not load members this usually means that project doesn't exists + // anyway we proceed without members, which could lead to 2 situations: + // - if user doesn't have permissions to access endpoint without us knowing if he is a member or no, + // then for such a user request would fail with 403 + // - if user has permissions to access endpoint even we don't know if he is a member or no, + // then code would proceed and endpoint would decide to throw 404 if project doesn't exist + // or perform endpoint operation if loading project members above failed because of some other reason + } + } + + const hasPermission = _.isArray(permissions) + ? _.some(permissions, permission => util.hasPermissionByReq(permission, req)) + : util.hasPermissionByReq(permissions, req); + + if (!hasPermission) { + throw new Error('You do not have permissions to perform this action'); + } +}; diff --git a/src/permissions/index.js b/src/permissions/index.js index 30c3dad3..0d8dcf67 100644 --- a/src/permissions/index.js +++ b/src/permissions/index.js @@ -3,8 +3,6 @@ const Authorizer = require('tc-core-library-js').Authorizer; const projectView = require('./project.view'); const projectEdit = require('./project.edit'); -const projectDelete = require('./project.delete'); -const projectMemberDelete = require('./projectMember.delete'); const projectAdmin = require('./admin.ops'); const projectAttachmentUpdate = require('./project.updateAttachment'); const projectAttachmentDownload = require('./project.downloadAttachment'); @@ -12,26 +10,56 @@ const connectManagerOrAdmin = require('./connectManagerOrAdmin.ops'); const copilotAndAbove = require('./copilotAndAbove'); const workManagementPermissions = require('./workManagementForTemplate'); const projectSettingEdit = require('./projectSetting.edit'); -const projectMemberInviteView = require('./projectMemberInvite.view'); -const projectAnyAuthUser = require('./project.anyAuthUser'); + +const generalPermission = require('./generalPermission'); +const { PERMISSION } = require('./constants'); module.exports = () => { Authorizer.setDeniedStatusCode(403); - // anyone can create a project - Authorizer.setPolicy('project.create', true); - Authorizer.setPolicy('project.view', projectView); - Authorizer.setPolicy('project.edit', projectEdit); - Authorizer.setPolicy('project.delete', projectDelete); - Authorizer.setPolicy('project.addMember', projectView); - Authorizer.setPolicy('project.viewMember', projectView); - Authorizer.setPolicy('project.removeMember', projectMemberDelete); + Authorizer.setPolicy('project.create', generalPermission(PERMISSION.CREATE_PROJECT)); + Authorizer.setPolicy('project.view', generalPermission(PERMISSION.READ_PROJECT)); + Authorizer.setPolicy('project.edit', generalPermission(PERMISSION.UPDATE_PROJECT)); + Authorizer.setPolicy('project.delete', generalPermission(PERMISSION.DELETE_PROJECT)); + + Authorizer.setPolicy('projectMember.create', generalPermission([ + PERMISSION.CREATE_PROJECT_MEMBER_OWN, // actually this permission includes the second permission and is enough + PERMISSION.CREATE_PROJECT_MEMBER_NOT_OWN, + ])); + Authorizer.setPolicy('projectMember.view', generalPermission(PERMISSION.READ_PROJECT_MEMBER)); + Authorizer.setPolicy('projectMember.edit', generalPermission([ + PERMISSION.UPDATE_PROJECT_MEMBER_CUSTOMER, // actually this permission includes the second permission and is enough + PERMISSION.UPDATE_PROJECT_MEMBER_NON_CUSTOMER, + ])); + Authorizer.setPolicy('projectMember.delete', generalPermission([ + PERMISSION.DELETE_PROJECT_MEMBER_CUSTOMER, // actually this permission includes the second permission and is enough + PERMISSION.DELETE_PROJECT_MEMBER_NON_CUSTOMER, + ])); + + Authorizer.setPolicy('projectMemberInvite.create', generalPermission([ + PERMISSION.CREATE_PROJECT_INVITE_CUSTOMER, // actually this permission includes the second permission and is enough + PERMISSION.CREATE_PROJECT_INVITE_NON_CUSTOMER, + ])); + Authorizer.setPolicy('projectMemberInvite.view', generalPermission([ + PERMISSION.READ_PROJECT_INVITE_OWN, // actually this permission includes the second permission and is enough + PERMISSION.READ_PROJECT_INVITE_NOT_OWN, + ])); + Authorizer.setPolicy('projectMemberInvite.edit', generalPermission([ + PERMISSION.UPDATE_PROJECT_INVITE_OWN, // actually this permission includes the second permission and is enough + PERMISSION.UPDATE_PROJECT_INVITE_NOT_OWN, + ])); + Authorizer.setPolicy('projectMemberInvite.delete', generalPermission([ + PERMISSION.DELETE_PROJECT_INVITE_OWN, // actually this permission includes the second permission and is enough + PERMISSION.DELETE_PROJECT_INVITE_NOT_OWN_CUSTOMER, + PERMISSION.DELETE_PROJECT_INVITE_NOT_OWN_NON_CUSTOMER, + ])); + Authorizer.setPolicy('project.addAttachment', projectEdit); Authorizer.setPolicy('project.updateAttachment', projectAttachmentUpdate); Authorizer.setPolicy('project.removeAttachment', projectAttachmentUpdate); Authorizer.setPolicy('project.downloadAttachment', projectAttachmentDownload); Authorizer.setPolicy('project.listAttachment', projectView); - Authorizer.setPolicy('project.updateMember', projectEdit); + Authorizer.setPolicy('project.admin', projectAdmin); Authorizer.setPolicy('projectTemplate.create', projectAdmin); @@ -86,12 +114,6 @@ module.exports = () => { Authorizer.setPolicy('metadata.list', true); // anyone can view all metadata - Authorizer.setPolicy('projectMemberInvite.create', projectView); - Authorizer.setPolicy('projectMemberInvite.edit', projectAnyAuthUser); - Authorizer.setPolicy('projectMemberInvite.delete', projectAnyAuthUser); - Authorizer.setPolicy('projectMemberInvite.get', projectMemberInviteView); - Authorizer.setPolicy('projectMemberInvite.list', projectMemberInviteView); - Authorizer.setPolicy('form.create', projectAdmin); Authorizer.setPolicy('form.edit', projectAdmin); Authorizer.setPolicy('form.delete', projectAdmin); diff --git a/src/permissions/project.anyAuthUser.js b/src/permissions/project.anyAuthUser.js deleted file mode 100644 index f0a90ae2..00000000 --- a/src/permissions/project.anyAuthUser.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Allow any logged-in users to access project based URL. - * - * The main purpose of using this policy is to populate `req.context.currentProjectMembers`. - * - * NOTE - * This policy can be only applied for routes with projectId. - */ - -import _ from 'lodash'; -import models from '../models'; - -module.exports = (req) => { - if (_.isUndefined(req.params.projectId)) { - return Promise.reject(new Error('Policy "project.anyAuthUser" cannot be used for route without "projectId".')); - } - - const projectId = _.parseInt(req.params.projectId); - - return models.ProjectMember.getActiveProjectMembers(projectId) - .then((members) => { - req.context = req.context || {}; - req.context.currentProjectMembers = members; - - return true; - }); -}; diff --git a/src/permissions/project.delete.js b/src/permissions/project.delete.js deleted file mode 100644 index f6dd9943..00000000 --- a/src/permissions/project.delete.js +++ /dev/null @@ -1,37 +0,0 @@ -import _ from 'lodash'; -import util from '../util'; -import models from '../models'; -import { PROJECT_MEMBER_ROLE } from '../constants'; - -/** - * Super admin, Topcoder Managers are allowed to edit any project - * Rest can add members only if they are currently part of the project team. - * @param {Object} freq the express request instance - * @return {Promise} Returns a promise - */ -module.exports = freq => new Promise((resolve, reject) => { - const projectId = _.parseInt(freq.params.projectId); - return models.ProjectMember.getActiveProjectMembers(projectId) - .then((members) => { - const req = freq; - req.context = req.context || {}; - req.context.currentProjectMembers = members; - // check if auth user has acecss to this project - const hasAccess = util.hasAdminRole(req) || - !_.isUndefined(_.find(members, m => m.userId === req.authUser.userId && - ((m.role === PROJECT_MEMBER_ROLE.CUSTOMER && m.isPrimary) || - [ - PROJECT_MEMBER_ROLE.MANAGER, - PROJECT_MEMBER_ROLE.PROGRAM_MANAGER, - PROJECT_MEMBER_ROLE.PROJECT_MANAGER, - PROJECT_MEMBER_ROLE.SOLUTION_ARCHITECT, - ].includes(m.role) - ))); - - if (!hasAccess) { - // user is not an admin nor is a registered project member - return reject(new Error('You do not have permissions to perform this action')); - } - return resolve(true); - }); -}); diff --git a/src/permissions/projectMember.delete.js b/src/permissions/projectMember.delete.js deleted file mode 100644 index eb0a7bc0..00000000 --- a/src/permissions/projectMember.delete.js +++ /dev/null @@ -1,45 +0,0 @@ -import _ from 'lodash'; -import util from '../util'; -import models from '../models'; -import { - PROJECT_MEMBER_ROLE, -} from '../constants'; - - -/** - * Super admin, Topcoder Managers are allowed to edit any project - * Rest can add members only if they are currently part of the project team. - * @param {Object} freq the express request instance - * @return {Promise} Returns a promise - */ - -module.exports = freq => new Promise((resolve, reject) => { - const projectId = _.parseInt(freq.params.projectId); - return models.ProjectMember.getActiveProjectMembers(projectId) - .then((members) => { - const req = freq; - req.context = req.context || {}; - req.context.currentProjectMembers = members; - const authMember = _.find(members, m => m.userId === req.authUser.userId); - const prjMemberId = _.parseInt(req.params.id); - const memberToBeRemoved = _.find(members, m => m.id === prjMemberId); - // check if auth user has acecss to this project - const hasAccess = util.hasAdminRole(req) - || (authMember && memberToBeRemoved && ([ - PROJECT_MEMBER_ROLE.ACCOUNT_MANAGER, - PROJECT_MEMBER_ROLE.MANAGER, - PROJECT_MEMBER_ROLE.PROGRAM_MANAGER, - PROJECT_MEMBER_ROLE.PROJECT_MANAGER, - PROJECT_MEMBER_ROLE.SOLUTION_ARCHITECT, - ].includes(authMember.role) || - (authMember.role === PROJECT_MEMBER_ROLE.CUSTOMER && authMember.isPrimary && - memberToBeRemoved.role === PROJECT_MEMBER_ROLE.CUSTOMER) || - memberToBeRemoved.userId === req.authUser.userId)); - - if (!hasAccess) { - // user is not an admin nor is a registered project member - return reject(new Error('You do not have permissions to perform this action')); - } - return resolve(true); - }); -}); diff --git a/src/permissions/projectMemberInvite.view.js b/src/permissions/projectMemberInvite.view.js deleted file mode 100644 index 138555cf..00000000 --- a/src/permissions/projectMemberInvite.view.js +++ /dev/null @@ -1,36 +0,0 @@ - -import _ from 'lodash'; -import util from '../util'; -import models from '../models'; -import { MANAGER_ROLES } from '../constants'; - -/** - * Check user can view project member invite or not. - * Users who can view the project can see all invites. Logged-in user can only see invitations - * for himself/herself. - * @param {Object} freq the express request instance - * @return {Promise} Returns a promise - */ -module.exports = freq => new Promise((resolve) => { - const req = freq; - const projectId = _.parseInt(freq.params.projectId); - const currentUserId = freq.authUser.userId; - let hasAccess; - return models.ProjectMember.getActiveProjectMembers(projectId) - .then((members) => { - req.context = req.context || {}; - // check if auth user has acecss to this project - hasAccess = util.hasAdminRole(req) - || util.hasRoles(req, MANAGER_ROLES) - || !_.isUndefined(_.find(members, m => m.userId === currentUserId)); - if (hasAccess) { - // if user can "view" the project, he/she can see all invites - // save this info into request. - req.context.inviteType = 'all'; - } else { - // user can only see invitations for himself/herself in this project - req.context.inviteType = 'list'; - } - return resolve(true); - }); -}); diff --git a/src/permissions/workManagementForTemplate.js b/src/permissions/workManagementForTemplate.js index 12c97c30..f37255ab 100644 --- a/src/permissions/workManagementForTemplate.js +++ b/src/permissions/workManagementForTemplate.js @@ -40,7 +40,7 @@ module.exports = policy => req => new Promise((resolve, reject) => { // TODO REMOVE THIS!!! // TEMPORARY let all the Topcoder managers to do all the work management // if there are no permission records in the DB for the template - return util.hasPermission({ topcoderRoles: MANAGER_ROLES }, req.authUser); + return util.hasPermissionByReq({ topcoderRoles: MANAGER_ROLES }, req); // return false; } diff --git a/src/routes/milestones/update.js b/src/routes/milestones/update.js index 95c06edd..6704a964 100644 --- a/src/routes/milestones/update.js +++ b/src/routes/milestones/update.js @@ -184,8 +184,8 @@ module.exports = [ } if (entityToUpdate.completionDate || entityToUpdate.actualStartDate) { - if (!util.hasPermission({ topcoderRoles: ADMIN_ROLES }, req.authUser)) { - const apiErr = new Error('You are not authorised to perform this action'); + if (!util.hasPermissionByReq({ topcoderRoles: ADMIN_ROLES }, req)) { + const apiErr = new Error('You are not authorized to perform this action'); apiErr.status = 403; return Promise.reject(apiErr); } diff --git a/src/routes/permissions/get.js b/src/routes/permissions/get.js index d22acbee..fbc4ce9a 100644 --- a/src/routes/permissions/get.js +++ b/src/routes/permissions/get.js @@ -47,11 +47,7 @@ module.exports = [ // find all allowed permissions workManagementPermissions.forEach((workManagementPermission) => { - const isAllowed = util.hasPermission( - workManagementPermission.permission, - req.authUser, - req.context.currentProjectMembers, - ); + const isAllowed = util.hasPermissionByReq(workManagementPermission.permission, req); if (isAllowed) { allowPermissions[workManagementPermission.policy] = true; diff --git a/src/routes/projectMemberInvites/create.js b/src/routes/projectMemberInvites/create.js index ebe2da1c..d35ded1f 100644 --- a/src/routes/projectMemberInvites/create.js +++ b/src/routes/projectMemberInvites/create.js @@ -7,10 +7,16 @@ import config from 'config'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import models from '../../models'; import util from '../../util'; -import { PROJECT_MEMBER_ROLE, PROJECT_MEMBER_MANAGER_ROLES, - MANAGER_ROLES, INVITE_STATUS, EVENT, RESOURCES, USER_ROLE, - MAX_PARALLEL_REQUEST_QTY, CONNECT_NOTIFICATION_EVENT } from '../../constants'; +import { + PROJECT_MEMBER_ROLE, + INVITE_STATUS, + EVENT, + RESOURCES, + MAX_PARALLEL_REQUEST_QTY, + CONNECT_NOTIFICATION_EVENT, +} from '../../constants'; import { createEvent } from '../../services/busApi'; +import { PERMISSION, PROJECT_TO_TOPCODER_ROLES_MATRIX } from '../../permissions/constants'; const ALLOWED_FIELDS = _.keys(models.ProjectMemberInvite.rawAttributes).concat(['handle']); @@ -267,8 +273,11 @@ module.exports = [ return next(err); } - if (!util.hasRoles(req, MANAGER_ROLES) && invite.role !== PROJECT_MEMBER_ROLE.CUSTOMER) { - const err = new Error(`You are not allowed to invite user as ${invite.role}`); + if ( + invite.role !== PROJECT_MEMBER_ROLE.CUSTOMER && + !util.hasPermissionByReq(PERMISSION.CREATE_PROJECT_INVITE_NON_CUSTOMER, req) + ) { + const err = new Error(`You are not allowed to invite user as ${invite.role}.`); err.status = 403; return next(err); } @@ -308,10 +317,11 @@ module.exports = [ } return isPresent; })); - // permission: - // user has to have constants.MANAGER_ROLES role - // to be invited as PROJECT_MEMBER_ROLE.MANAGER - if (_.includes(PROJECT_MEMBER_MANAGER_ROLES, invite.role)) { + + // for each user invited by `handle` (userId) we have to load they Topcoder Roles, + // so we can check if such a user can be invited with desired Project Role + // for customers we don't check it to avoid extra call, as any Topcoder user can be invited as customer + if (invite.role !== PROJECT_MEMBER_ROLE.CUSTOMER) { _.forEach(inviteUserIds, (userId) => { req.log.info(userId); promises.push(util.getUserRoles(userId, req.log, req.id)); @@ -331,21 +341,28 @@ module.exports = [ promises.push(Promise.resolve()); } return Promise.all(promises).then((rolesList) => { - if (!!inviteUserIds && _.includes(PROJECT_MEMBER_MANAGER_ROLES, invite.role)) { - req.log.debug('Checking if userId is allowed as manager'); + if (inviteUserIds && invite.role !== PROJECT_MEMBER_ROLE.CUSTOMER) { + req.log.debug('Checking if users are allowed to be invited with desired Project Role.'); const forbidUserList = []; _.zip(inviteUserIds, rolesList).forEach((data) => { const [userId, roles] = data; - req.log.debug(roles); - if (roles && !util.hasIntersection(MANAGER_ROLES, roles)) { + if (roles) { + req.log.debug(`Got user (id: ${userId}) Topcoder roles: ${roles.join(', ')}.`); + + if (!util.hasPermission({ topcoderRoles: PROJECT_TO_TOPCODER_ROLES_MATRIX[invite.role] }, { roles })) { + forbidUserList.push(userId); + } + } else { + req.log.debug(`Didn't get any Topcoder roles for user (id: ${userId}).`); forbidUserList.push(userId); } }); if (forbidUserList.length > 0) { - const message = 'cannot be added with a Manager role to the project'; + const message = `cannot be invited with a "${invite.role}" role to the project`; failed = _.concat(failed, _.map(forbidUserList, id => _.assign({}, { handle: getUserHandleById(id, inviteUsers), message }))); + req.log.debug(`Users with id(s) ${forbidUserList.join(', ')} ${message}`); inviteUserIds = _.filter(inviteUserIds, userId => !_.includes(forbidUserList, userId)); } } @@ -354,9 +371,9 @@ module.exports = [ const data = { projectId, role: invite.role, - // invite directly if user is admin or copilot manager + // invite copilots directly if user has permissions status: (invite.role !== PROJECT_MEMBER_ROLE.COPILOT || - util.hasRoles(req, [USER_ROLE.CONNECT_ADMIN, USER_ROLE.COPILOT_MANAGER])) + util.hasPermissionByReq(PERMISSION.CREATE_PROJECT_INVITE_COPILOT_DIRECTLY, req)) ? INVITE_STATUS.PENDING : INVITE_STATUS.REQUESTED, createdBy: req.authUser.userId, diff --git a/src/routes/projectMemberInvites/create.spec.js b/src/routes/projectMemberInvites/create.spec.js index 3cc20468..7034a374 100644 --- a/src/routes/projectMemberInvites/create.spec.js +++ b/src/routes/projectMemberInvites/create.spec.js @@ -698,6 +698,31 @@ describe('Project Member Invite create', () => { }); }); + it('should invite a user as "manager" using M2M token with "write:project-members" scope', (done) => { + util.getUserRoles.restore(); + sandbox.stub(util, 'getUserRoles', () => Promise.resolve([USER_ROLE.MANAGER])); + request(server) + .post(`/v5/projects/${project1.id}/invites`) + .set({ + Authorization: `Bearer ${testUtil.m2m['write:project-members']}`, + }) + .send({ + handles: ['test_manager1'], + role: 'manager', + }) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + const resJson = res.body.success[0]; + should.exist(resJson); + resJson.role.should.equal('manager'); + resJson.projectId.should.equal(project1.id); + resJson.userId.should.equal(40051333); + server.services.pubsub.publish.calledWith('project.member.invite.created').should.be.true; + done(); + }); + }); + it('should return 201 if try to create account_manager with MANAGER_ROLES', (done) => { util.getUserRoles.restore(); sandbox.stub(util, 'getUserRoles', () => Promise.resolve([USER_ROLE.MANAGER])); @@ -744,13 +769,15 @@ describe('Project Member Invite create', () => { const resJson = res.body.failed[0]; should.exist(resJson); const errorMessage = _.get(resJson, 'message', ''); - sinon.assert.match(errorMessage, /.*cannot be added with a Manager role to the project/); + sinon.assert.match(errorMessage, /.*cannot be invited with a "account_manager" role to the project/); done(); } }); }); it('should return 201 if try to create customer with COPILOT', (done) => { + util.getUserRoles.restore(); + sandbox.stub(util, 'getUserRoles', () => Promise.resolve(['Connect Copilot'])); request(server) .post(`/v5/projects/${project1.id}/invites`) .set({ @@ -959,10 +986,6 @@ describe('Project Member Invite create', () => { testUtil.wait(() => { createEventSpy.callCount.should.be.eql(3); - createEventSpy.getCalls().forEach((call) => { - console.log(call.args) // eslint-disable-line - }); - createEventSpy.calledWith(BUS_API_EVENT.PROJECT_MEMBER_INVITE_CREATED, sinon.match({ resource: RESOURCES.PROJECT_MEMBER_INVITE, projectId: project1.id, diff --git a/src/routes/projectMemberInvites/delete.js b/src/routes/projectMemberInvites/delete.js index 4dd961bc..2ec3536f 100644 --- a/src/routes/projectMemberInvites/delete.js +++ b/src/routes/projectMemberInvites/delete.js @@ -39,19 +39,19 @@ module.exports = [ if ( invite.status === INVITE_STATUS.REQUESTED - && !util.hasPermission(PERMISSION.DELETE_REQUESTED_INVITE, req.authUser, req.context.currentProjectMembers) + && !util.hasPermissionByReq(PERMISSION.DELETE_PROJECT_INVITE_REQUESTED, req) ) { error = 'You don\'t have permissions to cancel requested invites.'; } else if ( invite.role !== PROJECT_MEMBER_ROLE.CUSTOMER && !ownInvite - && !util.hasPermission(PERMISSION.DELETE_NON_CUSTOMER_INVITE, req.authUser, req.context.currentProjectMembers) + && !util.hasPermissionByReq(PERMISSION.DELETE_PROJECT_INVITE_NOT_OWN_NON_CUSTOMER, req) ) { error = 'You don\'t have permissions to cancel invites to Topcoder Team for other users.'; } else if ( invite.role === PROJECT_MEMBER_ROLE.CUSTOMER && !ownInvite - && !util.hasPermission(PERMISSION.DELETE_CUSTOMER_INVITE, req.authUser, req.context.currentProjectMembers) + && !util.hasPermissionByReq(PERMISSION.DELETE_PROJECT_INVITE_NOT_OWN_CUSTOMER, req) ) { error = 'You don\'t have permissions to cancel invites to Customer Team for other users.'; } diff --git a/src/routes/projectMemberInvites/delete.spec.js b/src/routes/projectMemberInvites/delete.spec.js index da337807..05c34564 100644 --- a/src/routes/projectMemberInvites/delete.spec.js +++ b/src/routes/projectMemberInvites/delete.spec.js @@ -345,6 +345,16 @@ describe('Project member invite delete', () => { .end(() => done()); }); + it('should return 204 if cancel invitation using M2M token with "write:project-members" scope', (done) => { + request(server) + .delete(`/v5/projects/${project1.id}/invites/6`) + .set({ + Authorization: `Bearer ${testUtil.m2m['write:project-members']}`, + }) + .expect(204) + .end(() => done()); + }); + describe('Bus api', () => { let createEventSpy; diff --git a/src/routes/projectMemberInvites/get.js b/src/routes/projectMemberInvites/get.js index 05552f82..e9b69b6e 100644 --- a/src/routes/projectMemberInvites/get.js +++ b/src/routes/projectMemberInvites/get.js @@ -6,6 +6,7 @@ import validate from 'express-validation'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import models from '../../models'; import util from '../../util'; +import { PERMISSION } from '../../permissions/constants'; const ALLOWED_FIELDS = _.keys(models.ProjectMemberInvite.rawAttributes).concat(['handle']); @@ -23,7 +24,7 @@ const permissions = tcMiddleware.permissions; module.exports = [ validate(schema), - permissions('projectMemberInvite.get'), + permissions('projectMemberInvite.view'), (req, res, next) => { const projectId = _.parseInt(req.params.projectId); const inviteId = _.parseInt(req.params.inviteId); @@ -61,8 +62,8 @@ module.exports = [ return next(err); } - if (req.context.inviteType === 'list') { - // user can only his/her own invite with specific id + // if user doesn't have permission to view all invites, then get only invite for the current user + if (!util.hasPermissionByReq(PERMISSION.READ_PROJECT_INVITE_NOT_OWN, req)) { esSearchParam.query.nested.query.filtered.filter.bool.must.push({ bool: { should: [ @@ -78,8 +79,11 @@ module.exports = [ if (data.length === 0) { req.log.debug('No project member invite found in ES'); let getInvitePromise; - if (req.context.inviteType === 'all') { + // if user can read all invites, then get all + if (util.hasPermissionByReq(PERMISSION.READ_PROJECT_INVITE_NOT_OWN, req)) { getInvitePromise = models.ProjectMemberInvite.getPendingInviteByIdForUser(projectId, inviteId); + + // otherwise, get invitation only for current user } else { getInvitePromise = models.ProjectMemberInvite.getPendingInviteByIdForUser( projectId, inviteId, email, currentUserId); diff --git a/src/routes/projectMemberInvites/get.spec.js b/src/routes/projectMemberInvites/get.spec.js index 50596887..8284ba00 100644 --- a/src/routes/projectMemberInvites/get.spec.js +++ b/src/routes/projectMemberInvites/get.spec.js @@ -189,6 +189,29 @@ describe('GET Project Member Invite', () => { }); }); + it('should return the invite using M2M token with "read:project-members" scope', (done) => { + request(server) + .get(`/v5/projects/${project1.id}/invites/1`) + .set({ + Authorization: `Bearer ${testUtil.m2m['read:project-members']}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + should.exist(resJson.projectId); + resJson.id.should.be.eql(1); + resJson.userId.should.be.eql(testUtil.userIds.member); + resJson.status.should.be.eql(INVITE_STATUS.PENDING); + done(); + } + }); + }); + it('should return the invite if this invitation is for logged-in user', (done) => { request(server) .get(`/v5/projects/${project1.id}/invites/2`) diff --git a/src/routes/projectMemberInvites/list.js b/src/routes/projectMemberInvites/list.js index da65500c..e10bf9a8 100644 --- a/src/routes/projectMemberInvites/list.js +++ b/src/routes/projectMemberInvites/list.js @@ -6,6 +6,7 @@ import validate from 'express-validation'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import models from '../../models'; import util from '../../util'; +import { PERMISSION } from '../../permissions/constants'; const ALLOWED_FIELDS = _.keys(models.ProjectMemberInvite.rawAttributes).concat(['handle']); @@ -23,7 +24,7 @@ const permissions = tcMiddleware.permissions; module.exports = [ validate(schema), - permissions('projectMemberInvite.list'), + permissions('projectMemberInvite.view'), (req, res, next) => { const projectId = _.parseInt(req.params.projectId); const currentUserId = req.authUser.userId; @@ -58,9 +59,8 @@ module.exports = [ }, }; - if (req.context.inviteType === 'list') { - // user has no "view" project permission - // try to search from es, add search by user id or email + // if user doesn't have permission to view all invites, then get only invites for the current user + if (!util.hasPermissionByReq(PERMISSION.READ_PROJECT_INVITE_NOT_OWN, req)) { esSearchParam.query.nested.query.filtered.filter.bool.must.push({ bool: { should: [ @@ -84,11 +84,11 @@ module.exports = [ .then((data) => { if (data.length === 0) { req.log.debug('No project member invites found in ES'); - // if user has "view" project permission, get all invites - if (req.context.inviteType === 'all') { + // if user can read all invites, then get all + if (util.hasPermissionByReq(PERMISSION.READ_PROJECT_INVITE_NOT_OWN, req)) { return models.ProjectMemberInvite.getPendingOrRequestedProjectInvitesForUser(projectId); } - // get invitation only for user + // otherwise, get invitation only for current user return models.ProjectMemberInvite.getPendingOrRequestedProjectInvitesForUser( projectId, currentUserEmail, currentUserId); } diff --git a/src/routes/projectMemberInvites/list.spec.js b/src/routes/projectMemberInvites/list.spec.js index a19e1066..bcce0e8a 100644 --- a/src/routes/projectMemberInvites/list.spec.js +++ b/src/routes/projectMemberInvites/list.spec.js @@ -166,6 +166,30 @@ describe('GET Project Member Invites', () => { }); }); + it('should get invites using M2M token with "read:project-members" scope', (done) => { + request(server) + .get(`/v5/projects/${project1.id}/invites`) + .set({ + Authorization: `Bearer ${testUtil.m2m['read:project-members']}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + resJson.should.be.an('array'); + resJson.length.should.be.eql(2); + // check invitations + _.filter(resJson, inv => inv.id === 1).length.should.be.eql(1); + _.filter(resJson, inv => inv.id === 2).length.should.be.eql(1); + done(); + } + }); + }); + it('should return only pending/requested invitation if user can view the project', (done) => { request(server) .get(`/v5/projects/${project2.id}/invites`) diff --git a/src/routes/projectMemberInvites/update.js b/src/routes/projectMemberInvites/update.js index 4adb9f8b..972ceb0e 100644 --- a/src/routes/projectMemberInvites/update.js +++ b/src/routes/projectMemberInvites/update.js @@ -59,13 +59,13 @@ module.exports = [ if ( invite.status === INVITE_STATUS.REQUESTED - && !util.hasPermission(PERMISSION.UPDATE_REQUESTED_INVITE, req.authUser, req.context.currentProjectMembers) + && !util.hasPermissionByReq(PERMISSION.UPDATE_PROJECT_INVITE_REQUESTED, req) ) { error = 'You don\'t have permissions to update requested invites.'; } else if ( invite.status !== INVITE_STATUS.REQUESTED && !ownInvite - && !util.hasPermission(PERMISSION.UPDATE_NOT_OWN_INVITE, req.authUser, req.context.currentProjectMembers) + && !util.hasPermissionByReq(PERMISSION.UPDATE_PROJECT_INVITE_NOT_OWN, req) ) { error = 'You don\'t have permissions to update invites for other users.'; } diff --git a/src/routes/projectMemberInvites/update.spec.js b/src/routes/projectMemberInvites/update.spec.js index bd1b6c3c..5c6c2657 100644 --- a/src/routes/projectMemberInvites/update.spec.js +++ b/src/routes/projectMemberInvites/update.spec.js @@ -158,7 +158,20 @@ describe('Project member invite update', () => { updatedAt: '2016-06-30 00:33:07+00', }); - return Promise.all([pm, invite4, invite5, invite6]); + const invite7 = models.ProjectMemberInvite.create({ + id: 7, + projectId: project2.id, + userId: testUtil.userIds.romit, + email: null, + role: PROJECT_MEMBER_ROLE.CUSTOMER, + status: INVITE_STATUS.PENDING, + createdBy: 1, + updatedBy: 1, + createdAt: '2016-06-30 00:33:07+00', + updatedAt: '2016-06-30 00:33:07+00', + }); + + return Promise.all([pm, invite4, invite5, invite6, invite7]); }); Promise.all([p1, p2]).then(() => done()); @@ -360,6 +373,20 @@ describe('Project member invite update', () => { .end(() => done()); }); + it('should return 200 if accept invitation using M2M token with "write:project-members" scope', (done) => { + request(server) + .patch(`/v5/projects/${project1.id}/invites/7`) + .set({ + Authorization: `Bearer ${testUtil.m2m['write:project-members']}`, + }) + .send({ + status: INVITE_STATUS.ACCEPTED, + }) + .expect('Content-Type', /json/) + .expect(200) + .end(() => done()); + }); + describe('Bus api', () => { let createEventSpy; diff --git a/src/routes/projectMembers/create.js b/src/routes/projectMembers/create.js index 6b69fe4f..1d15991a 100644 --- a/src/routes/projectMembers/create.js +++ b/src/routes/projectMembers/create.js @@ -3,8 +3,8 @@ import Joi from 'joi'; import validate from 'express-validation'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import util from '../../util'; -import { INVITE_STATUS, MANAGER_ROLES, PROJECT_MEMBER_ROLE, USER_ROLE } from '../../constants'; import models from '../../models'; +import { PROJECT_TO_TOPCODER_ROLES_MATRIX, PERMISSION } from '../../permissions/constants'; /** * API to add a project member. @@ -15,150 +15,74 @@ const permissions = tcMiddleware.permissions; const createProjectMemberValidations = { body: Joi.object().keys({ - role: Joi.any() - .valid( - PROJECT_MEMBER_ROLE.MANAGER, - PROJECT_MEMBER_ROLE.ACCOUNT_MANAGER, - PROJECT_MEMBER_ROLE.COPILOT, - PROJECT_MEMBER_ROLE.PROJECT_MANAGER, - PROJECT_MEMBER_ROLE.PROGRAM_MANAGER, - PROJECT_MEMBER_ROLE.SOLUTION_ARCHITECT, - PROJECT_MEMBER_ROLE.ACCOUNT_EXECUTIVE, - ), + userId: Joi.number().optional(), + role: Joi.string().valid(_.keys(PROJECT_TO_TOPCODER_ROLES_MATRIX)), }), }; module.exports = [ // handles request validations validate(createProjectMemberValidations), - permissions('project.addMember'), - (req, res, next) => { - let targetRole; - if (_.get(req, 'body.role')) { - targetRole = _.get(req, 'body.role'); + permissions('projectMember.create'), + async (req, res, next) => { + try { + // by default, we would add the current user as a member + let addUserId = req.authUser.userId; + let addUser = req.authUser; - if (PROJECT_MEMBER_ROLE.MANAGER === targetRole && - !util.hasRoles(req, [USER_ROLE.TOPCODER_ADMIN, USER_ROLE.CONNECT_ADMIN, USER_ROLE.MANAGER])) { - const err = new Error(`Only admin or manager is able to join as ${targetRole}`); - err.status = 401; - return next(err); - } + // if `userId` is provided in the request body then we should add this user as a member + if (_.get(req, 'body.userId') && _.get(req, 'body.userId') !== req.authUser.userId) { + addUserId = _.get(req, 'body.userId'); - if (PROJECT_MEMBER_ROLE.SOLUTION_ARCHITECT === targetRole && - !util.hasRoles(req, [USER_ROLE.SOLUTION_ARCHITECT])) { - const err = new Error(`Only solution architect is able to join as ${targetRole}`); - err.status = 401; - return next(err); - } + // check if current user has permissions to add other users + if (!util.hasPermissionByReq(PERMISSION.CREATE_PROJECT_MEMBER_NOT_OWN, req)) { + const err = new Error('You don\'t have permissions to add other users as a project member.'); + err.status = 403; + throw err; + } - if (PROJECT_MEMBER_ROLE.PROJECT_MANAGER === targetRole && - !util.hasRoles(req, [USER_ROLE.PROJECT_MANAGER])) { - const err = new Error(`Only project manager is able to join as ${targetRole}`); - err.status = 401; - return next(err); + // if we are adding another user, we have to get that user roles for checking permissions + try { + const addUserRoles = await util.getUserRoles(addUserId, req.log, req.id); + addUser = { + roles: addUserRoles, + }; + } catch (e) { + throw new Error(`Cannot get user roles: "${e.message}".`); + } } - if (PROJECT_MEMBER_ROLE.PROGRAM_MANAGER === targetRole && - !util.hasRoles(req, [USER_ROLE.PROGRAM_MANAGER])) { - const err = new Error(`Only program manager is able to join as ${targetRole}`); - err.status = 401; - return next(err); - } + const targetRole = _.get(req, 'body.role', util.getDefaultProjectRole(addUser)); - if (PROJECT_MEMBER_ROLE.ACCOUNT_EXECUTIVE === targetRole && - !util.hasRoles(req, [USER_ROLE.ACCOUNT_EXECUTIVE])) { - const err = new Error(`Only account executive is able to join as ${targetRole}`); - err.status = 401; - return next(err); + if (!targetRole) { + throw new Error('Cannot automatically detect role for a new member.'); } - if (PROJECT_MEMBER_ROLE.ACCOUNT_MANAGER === targetRole && - !util.hasRoles(req, [ - USER_ROLE.MANAGER, - USER_ROLE.TOPCODER_ACCOUNT_MANAGER, - USER_ROLE.BUSINESS_DEVELOPMENT_REPRESENTATIVE, - USER_ROLE.PRESALES, - USER_ROLE.ACCOUNT_EXECUTIVE, - USER_ROLE.PROGRAM_MANAGER, - USER_ROLE.SOLUTION_ARCHITECT, - USER_ROLE.PROJECT_MANAGER, - ])) { - const err = new Error( - // eslint-disable-next-line max-len - `Only manager, account manager, business development representative, account executive, program manager, project manager, solution architect, or presales are able to join as ${targetRole}`, - ); + if (!util.matchPermissionRule({ topcoderRoles: PROJECT_TO_TOPCODER_ROLES_MATRIX[targetRole] }, addUser)) { + const err = new Error(`User doesn't have required roles to be added to the project as "${targetRole}".`); err.status = 401; - return next(err); + throw err; } - if (targetRole === PROJECT_MEMBER_ROLE.COPILOT && !util.hasRoles(req, [USER_ROLE.COPILOT])) { - const err = new Error(`Only copilot is able to join as ${targetRole}`); - err.status = 401; - return next(err); - } - } else if (util.hasRoles(req, [USER_ROLE.MANAGER, USER_ROLE.CONNECT_ADMIN, USER_ROLE.TOPCODER_ADMIN])) { - targetRole = PROJECT_MEMBER_ROLE.MANAGER; - } else if (util.hasRoles(req, [ - USER_ROLE.TOPCODER_ACCOUNT_MANAGER, - USER_ROLE.BUSINESS_DEVELOPMENT_REPRESENTATIVE, - USER_ROLE.PRESALES, - ])) { - targetRole = PROJECT_MEMBER_ROLE.ACCOUNT_MANAGER; - } else if (util.hasRoles(req, [USER_ROLE.COPILOT, USER_ROLE.CONNECT_ADMIN])) { - targetRole = PROJECT_MEMBER_ROLE.COPILOT; - } else if (util.hasRoles(req, [USER_ROLE.ACCOUNT_EXECUTIVE])) { - targetRole = PROJECT_MEMBER_ROLE.ACCOUNT_EXECUTIVE; - } else if (util.hasRoles(req, [USER_ROLE.PROGRAM_MANAGER])) { - targetRole = PROJECT_MEMBER_ROLE.PROGRAM_MANAGER; - } else if (util.hasRoles(req, [USER_ROLE.SOLUTION_ARCHITECT])) { - targetRole = PROJECT_MEMBER_ROLE.SOLUTION_ARCHITECT; - } else if (util.hasRoles(req, [USER_ROLE.PROJECT_MANAGER])) { - targetRole = PROJECT_MEMBER_ROLE.PROJECT_MANAGER; - } else { - const err = new Error('Only copilot or manager is able to call this endpoint'); - err.status = 401; - return next(err); - } + const projectId = req.params.projectId; - const projectId = _.parseInt(req.params.projectId); + const member = { + projectId, + role: targetRole, + userId: addUserId, + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }; - const member = { - projectId, - role: targetRole, - userId: req.authUser.userId, - createdBy: req.authUser.userId, - updatedBy: req.authUser.userId, - }; + let newMember; + await models.sequelize.transaction(async (transaction) => { + // Kafka event is emitted inside `addUserToProject` + newMember = await util.addUserToProject(req, member, transaction); + }); - let promise = Promise.resolve(); - if (member.role === PROJECT_MEMBER_ROLE.MANAGER) { - promise = util.getUserRoles(member.userId, req.log, req.id); + return res.status(201).json(newMember); + } catch (err) { + return next(err); } - - req.log.debug('creating member', member); - return promise.then((memberRoles) => { - req.log.debug(memberRoles); - if (member.role === PROJECT_MEMBER_ROLE.MANAGER - && (!memberRoles || !util.hasIntersection(MANAGER_ROLES, memberRoles))) { - const err = new Error('This user can\'t be added as a Manager to the project'); - err.status = 400; - return next(err); - } - - return util.addUserToProject(req, member) // Kafka event is emitted inside `addUserToProject` - .then(newMember => - models.ProjectMemberInvite.getPendingInviteByEmailOrUserId(projectId, null, newMember.userId) - .then((invite) => { - if (!invite) { - return res.status(201).json(newMember); - } - return invite.update({ - status: INVITE_STATUS.ACCEPTED, - }) - .then(() => res.status(201).json(newMember)); - }), - ); - }) - .catch(err => next(err)); }, ]; diff --git a/src/routes/projectMembers/create.spec.js b/src/routes/projectMembers/create.spec.js index 152a476b..ea4c2f81 100644 --- a/src/routes/projectMembers/create.spec.js +++ b/src/routes/projectMembers/create.spec.js @@ -4,6 +4,7 @@ import chai from 'chai'; import sinon from 'sinon'; import request from 'supertest'; +import config from 'config'; import models from '../../models'; import util from '../../util'; import server from '../../app'; @@ -39,7 +40,16 @@ describe('Project Members create', () => { lastActivityUserId: '1', }).then((p) => { project1 = p; - done(); + return models.ProjectMember.create({ + userId: testUtil.userIds.member2, + projectId: project1.id, + role: 'manager', + isPrimary: true, + createdBy: 1, + updatedBy: 1, + }).then(() => { + done(); + }); }); }); }); @@ -78,7 +88,7 @@ describe('Project Members create', () => { email: 'test_copilot1@email.com', }; const testRoleName = { - roleName: USER_ROLE.COPILOT_MANAGER, + roleName: USER_ROLE.COPILOT, }; const ret = { status: 200, @@ -224,6 +234,64 @@ describe('Project Members create', () => { }); }); + it('should add another user as "manager" using M2M token with "write:project-members" scope', (done) => { + const mockHttpClient = _.merge(testUtil.mockHttpClient, { + get: () => Promise.resolve({ + status: 200, + data: { + id: 'requesterId', + version: 'v3', + result: { + success: true, + status: 200, + content: [{ + roleName: USER_ROLE.MANAGER, + }], + }, + }, + }), + post: () => Promise.resolve({ + status: 200, + data: { + id: 'requesterId', + version: 'v3', + result: { + success: true, + status: 200, + content: {}, + }, + }, + }), + }); + sandbox.stub(util, 'getHttpClient', () => mockHttpClient); + request(server) + .post(`/v5/projects/${project1.id}/members/`) + .set({ + Authorization: `Bearer ${testUtil.m2m['write:project-members']}`, + }) + .send({ + userId: testUtil.userIds.manager, + role: 'manager', + }) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + resJson.role.should.equal('manager'); + resJson.isPrimary.should.be.truthy; + resJson.projectId.should.equal(project1.id); + resJson.userId.should.equal(40051334); + resJson.createdBy.should.equal(config.DEFAULT_M2M_USERID); + server.services.pubsub.publish.calledWith('project.member.added').should.be.true; + done(); + } + }); + }); + it('should return 201 and register admin as manager', (done) => { const mockHttpClient = _.merge(testUtil.mockHttpClient, { get: () => Promise.resolve({ @@ -375,7 +443,7 @@ describe('Project Members create', () => { email: 'test_copilot1@email.com', }; const testRoleName = { - roleName: USER_ROLE.COPILOT_MANAGER, + roleName: USER_ROLE.COPILOT, }; const ret = { status: 200, @@ -402,7 +470,7 @@ describe('Project Members create', () => { request(server) .post(`/v5/projects/${project1.id}/invites`) .set({ - Authorization: `Bearer ${testUtil.jwts.admin}`, + Authorization: `Bearer ${testUtil.jwts.member2}`, }) .send({ handles: ['test_copilot1'], @@ -470,8 +538,8 @@ describe('Project Members create', () => { projectId: project1.id, projectName: project1.name, projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, - userId: 40051336, - initiatorUserId: 40051336, + userId: 40051332, + initiatorUserId: testUtil.userIds.connectAdmin, })).should.be.true; done(); }); diff --git a/src/routes/projectMembers/delete.js b/src/routes/projectMembers/delete.js index d13a8f71..be7fd707 100644 --- a/src/routes/projectMembers/delete.js +++ b/src/routes/projectMembers/delete.js @@ -5,6 +5,7 @@ import { middleware as tcMiddleware } from 'tc-core-library-js'; import models from '../../models'; import util from '../../util'; import { EVENT, RESOURCES, PROJECT_MEMBER_ROLE } from '../../constants'; +import { PERMISSION } from '../../permissions/constants'; /** * API to delete a project member. @@ -13,7 +14,7 @@ import { EVENT, RESOURCES, PROJECT_MEMBER_ROLE } from '../../constants'; const permissions = tcMiddleware.permissions; module.exports = [ - permissions('project.removeMember'), + permissions('projectMember.delete'), (req, res, next) => { const projectId = _.parseInt(req.params.projectId); const memberRecordId = _.parseInt(req.params.id); @@ -29,6 +30,16 @@ module.exports = [ err.status = 404; return Promise.reject(err); } + + if ( + member.userId !== req.authUser.userId && + member.role !== PROJECT_MEMBER_ROLE.CUSTOMER && + !util.hasPermissionByReq(PERMISSION.DELETE_PROJECT_MEMBER_NON_CUSTOMER, req) + ) { + const err = new Error('You don\'t have permissions to delete other members with non-customer role.'); + err.status = 403; + return Promise.reject(err); + } return member.update({ deletedBy: req.authUser.userId }); }) .then(member => member.destroy({ logging: console.log })) // eslint-disable-line no-console diff --git a/src/routes/projectMembers/delete.spec.js b/src/routes/projectMembers/delete.spec.js index dd1556ae..3834e39b 100644 --- a/src/routes/projectMembers/delete.spec.js +++ b/src/routes/projectMembers/delete.spec.js @@ -105,7 +105,7 @@ describe('Project members delete', () => { .expect(403, done); }); - it('should return 403 if user not found', (done) => { + it('should return 404 if user not found', (done) => { request(server) .delete(`/v5/projects/${project1.id}/members/8888888`) .set({ @@ -116,7 +116,7 @@ describe('Project members delete', () => { projectId: project1.id, role: 'customer', }) - .expect(403, done); + .expect(404, done); }); it('should return 204 if copilot user has access to the project', (done) => { @@ -259,6 +259,45 @@ describe('Project members delete', () => { }); }); + it('should remove manager from project using M2M token with "write:project-members" scope', (done) => { + const mockHttpClient = _.merge(testUtil.mockHttpClient, { + post: () => Promise.resolve({ + status: 200, + data: { + id: 'requesterId', + version: 'v3', + result: { + success: true, + status: 200, + content: {}, + }, + }, + }), + }); + const postSpy = sinon.spy(mockHttpClient, 'post'); + sandbox.stub(util, 'getHttpClient', () => mockHttpClient); + request(server) + .delete(`/v5/projects/${project1.id}/members/${member2.id}`) + .set({ + Authorization: `Bearer ${testUtil.m2m['write:project-members']}`, + }) + .expect(204) + .end((err) => { + expectAfterDelete(project1.id, member2.id, err, () => { + const removedMember = { + projectId: project1.id, + userId: 40051334, + role: 'manager', + isPrimary: true, + }; + server.services.pubsub.publish.calledWith('project.member.removed', + sinon.match(removedMember)).should.be.true; + postSpy.should.have.been.calledOnce; + done(); + }); + }); + }); + it('should return 204 if manager is removed from the project (without direct project id)', (done) => { const mockHttpClient = _.merge(testUtil.mockHttpClient, { post: () => Promise.resolve({ @@ -403,8 +442,8 @@ describe('Project members delete', () => { projectId: project1.id, projectName: project1.name, projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, - userId: 40051334, - initiatorUserId: 40051334, + userId: member1.userId, + initiatorUserId: testUtil.userIds.manager, })).should.be.true; done(); diff --git a/src/routes/projectMembers/get.js b/src/routes/projectMembers/get.js index e30b7054..0610a85a 100644 --- a/src/routes/projectMembers/get.js +++ b/src/routes/projectMembers/get.js @@ -28,7 +28,7 @@ const schema = { module.exports = [ // handles request validations validate(schema), - permissions('project.viewMember'), + permissions('projectMember.view'), (req, res, next) => { const projectId = _.parseInt(req.params.projectId); const memberRecordId = _.parseInt(req.params.id); diff --git a/src/routes/projectMembers/get.spec.js b/src/routes/projectMembers/get.spec.js index bde598a1..5956d027 100644 --- a/src/routes/projectMembers/get.spec.js +++ b/src/routes/projectMembers/get.spec.js @@ -166,6 +166,27 @@ describe('GET project member', () => { }); }); + it('should return member using using M2M token with "read:project-members" scope', (done) => { + request(server) + .get(`/v5/projects/${projectId}/members/${memberId}`) + .set({ + Authorization: `Bearer ${testUtil.m2m['read:project-members']}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body; + resJson.userId.should.be.eql(_.parseInt(copilotUser.userId)); + resJson.role.should.be.eql('copilot'); + resJson.projectId.should.be.eql(projectId); + should.exist(resJson.createdAt); + should.exist(resJson.updatedAt); + should.not.exist(resJson.deletedBy); + should.not.exist(resJson.deletedAt); + + done(); + }); + }); + it('should return 200 for admin when retrieve member with id=2', (done) => { request(server) .get(`/v5/projects/${projectId}/members/${memberId2}`) diff --git a/src/routes/projectMembers/list.js b/src/routes/projectMembers/list.js index cd694a64..f3e3b340 100644 --- a/src/routes/projectMembers/list.js +++ b/src/routes/projectMembers/list.js @@ -30,7 +30,7 @@ const schema = { module.exports = [ validate(schema), - permissions('project.viewMember'), + permissions('projectMember.view'), (req, res, next) => { const projectId = _.parseInt(req.params.projectId); const fields = req.query.fields ? req.query.fields.split(',') : []; diff --git a/src/routes/projectMembers/list.spec.js b/src/routes/projectMembers/list.spec.js index 19228b50..d9ef80d5 100644 --- a/src/routes/projectMembers/list.spec.js +++ b/src/routes/projectMembers/list.spec.js @@ -151,6 +151,28 @@ describe('LIST project members', () => { }); }); + it('should return member using using M2M token with "read:project-members" scope', (done) => { + request(server) + .get(`/v5/projects/${id}/members`) + .set({ + Authorization: `Bearer ${testUtil.m2m['read:project-members']}`, + }) + .expect(200) + .end((err, res) => { + const resJson = res.body; + resJson.should.have.length(2); + resJson[0].userId.should.be.eql(copilotUser.userId); + resJson[0].role.should.be.eql('copilot'); + resJson[0].projectId.should.be.eql(id); + should.exist(resJson[0].createdAt); + should.exist(resJson[0].updatedAt); + should.not.exist(resJson[0].deletedBy); + should.not.exist(resJson[0].deletedAt); + + done(); + }); + }); + it('should return 200 for admin with filter', (done) => { request(server) .get(`/v5/projects/${id}/members?role=customer`) diff --git a/src/routes/projectMembers/update.js b/src/routes/projectMembers/update.js index e35a55e2..fffe69f3 100644 --- a/src/routes/projectMembers/update.js +++ b/src/routes/projectMembers/update.js @@ -5,7 +5,8 @@ import Joi from 'joi'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import models from '../../models'; import util from '../../util'; -import { EVENT, RESOURCES, PROJECT_MEMBER_ROLE, PROJECT_MEMBER_MANAGER_ROLES, MANAGER_ROLES } from '../../constants'; +import { EVENT, RESOURCES, PROJECT_MEMBER_ROLE } from '../../constants'; +import { PERMISSION, PROJECT_TO_TOPCODER_ROLES_MATRIX } from '../../permissions/constants'; /** * API to update a project member. @@ -35,7 +36,7 @@ const updateProjectMemberValdiations = { module.exports = [ // handles request validations validate(updateProjectMemberValdiations), - permissions('project.updateMember'), + permissions('projectMember.edit'), /** * Update a projectMember if the user has access */ @@ -64,7 +65,16 @@ module.exports = [ projectMember = _member; previousValue = _.clone(projectMember.get({ plain: true })); _.assign(projectMember, updatedProps); - // newValue = projectMember.get({ plain: true }); + + if ( + previousValue.userId !== req.authUser.userId && + previousValue.role !== PROJECT_MEMBER_ROLE.CUSTOMER && + !util.hasPermissionByReq(PERMISSION.UPDATE_PROJECT_MEMBER_NON_CUSTOMER, req) + ) { + const err = new Error('You don\'t have permission to update a non-customer member.'); + err.status = 403; + return Promise.reject(err); + } // no updates if no change if (updatedProps.role === previousValue.role && @@ -74,11 +84,18 @@ module.exports = [ } return util.getUserRoles(projectMember.userId, req.log, req.id).then((roles) => { - if (_.includes(PROJECT_MEMBER_MANAGER_ROLES, updatedProps.role) - && !util.hasIntersection(MANAGER_ROLES, roles)) { - const err = new Error('User role can not be updated to Manager role'); + if ( + previousValue.role !== updatedProps.role && + !util.matchPermissionRule( + { topcoderRoles: PROJECT_TO_TOPCODER_ROLES_MATRIX[updatedProps.role] }, + { roles }, + ) + ) { + const err = new Error( + `User doesn't have required Topcoder roles to have project role "${updatedProps.role}".`, + ); err.status = 401; - return Promise.reject(err); + throw err; } projectMember.updatedBy = req.authUser.userId; diff --git a/src/routes/projectMembers/update.spec.js b/src/routes/projectMembers/update.spec.js index 28c3d666..8874e3b0 100644 --- a/src/routes/projectMembers/update.spec.js +++ b/src/routes/projectMembers/update.spec.js @@ -183,7 +183,7 @@ describe('Project members update', () => { request(server) .patch(`/v5/projects/${project1.id}/members/${member2.id}`) .set({ - Authorization: `Bearer ${testUtil.jwts.copilot}`, + Authorization: `Bearer ${testUtil.jwts.manager}`, }) .send({ role: 'customer', @@ -198,7 +198,7 @@ describe('Project members update', () => { should.exist(resJson); resJson.role.should.equal('customer'); resJson.isPrimary.should.be.true; - resJson.updatedBy.should.equal(40051332); + resJson.updatedBy.should.equal(testUtil.userIds.manager); server.services.pubsub.publish.calledWith('project.member.updated').should.be.true; done(); } @@ -232,7 +232,7 @@ describe('Project members update', () => { request(server) .patch(`/v5/projects/${project1.id}/members/${member2.id}`) .set({ - Authorization: `Bearer ${testUtil.jwts.copilot}`, + Authorization: `Bearer ${testUtil.jwts.manager}`, }) .send(body) .expect('Content-Type', /json/) @@ -245,7 +245,7 @@ describe('Project members update', () => { should.exist(resJson); resJson.role.should.equal(body.role); resJson.isPrimary.should.be.false; - resJson.updatedBy.should.equal(40051332); + resJson.updatedBy.should.equal(testUtil.userIds.manager); server.services.pubsub.publish.calledWith('project.member.updated').should.be.true; done(); } @@ -273,7 +273,7 @@ describe('Project members update', () => { request(server) .patch(`/v5/projects/${project1.id}/members/${member2.id}`) .set({ - Authorization: `Bearer ${testUtil.jwts.copilot}`, + Authorization: `Bearer ${testUtil.jwts.manager}`, }) .send(body) .expect('Content-Type', /json/) @@ -286,7 +286,7 @@ describe('Project members update', () => { should.exist(resJson); resJson.role.should.equal(body.role); resJson.isPrimary.should.be.false; - resJson.updatedBy.should.equal(40051332); + resJson.updatedBy.should.equal(testUtil.userIds.manager); deleteSpy.should.have.been.calledOnce; server.services.pubsub.publish.calledWith('project.member.updated').should.be.true; done(); @@ -421,6 +421,18 @@ describe('Project members update', () => { it('should return 200 if valid user(become copilot) and data', (done) => { const mockHttpClient = _.merge(testUtil.mockHttpClient, { + get: () => Promise.resolve({ + status: 200, + data: { + id: 'requesterId', + version: 'v3', + result: { + success: true, + status: 200, + content: [{ roleName: 'Connect Copilot' }], + }, + }, + }), post: () => Promise.resolve({ status: 200, data: { @@ -441,7 +453,7 @@ describe('Project members update', () => { request(server) .patch(`/v5/projects/${project1.id}/members/${member1.id}`) .set({ - Authorization: `Bearer ${testUtil.jwts.copilot}`, + Authorization: `Bearer ${testUtil.jwts.manager}`, }) .send({ role: 'copilot', @@ -458,7 +470,7 @@ describe('Project members update', () => { resJson.role.should.equal('copilot'); resJson.isPrimary.should.be.true; resJson.updatedAt.should.not.equal('2016-06-30 00:33:07+00'); - resJson.updatedBy.should.equal(40051332); + resJson.updatedBy.should.equal(testUtil.userIds.manager); postSpy.should.have.been.calledOnce; done(); } @@ -496,7 +508,7 @@ describe('Project members update', () => { request(server) .patch(`/v5/projects/${project1.id}/members/${member2.id}`) .set({ - Authorization: `Bearer ${testUtil.jwts.copilot}`, + Authorization: `Bearer ${testUtil.jwts.manager}`, }) .send({ role: 'customer', @@ -523,7 +535,7 @@ describe('Project members update', () => { projectName: project1.name, projectUrl: `https://local.topcoder-dev.com/projects/${project1.id}`, userId: 40051332, - initiatorUserId: 40051332, + initiatorUserId: testUtil.userIds.manager, })).should.be.true; done(); diff --git a/src/routes/projects/create.js b/src/routes/projects/create.js index 567be713..716de4d7 100644 --- a/src/routes/projects/create.js +++ b/src/routes/projects/create.js @@ -7,10 +7,11 @@ import config from 'config'; import moment from 'moment'; import models from '../../models'; -import { PROJECT_MEMBER_ROLE, MANAGER_ROLES, PROJECT_STATUS, PROJECT_PHASE_STATUS, +import { PROJECT_MEMBER_ROLE, PROJECT_STATUS, PROJECT_PHASE_STATUS, EVENT, RESOURCES, REGEX, WORKSTREAM_STATUS, ATTACHMENT_TYPES } from '../../constants'; import fieldLookupValidation from '../../middlewares/fieldLookupValidation'; import util from '../../util'; +import { PERMISSION } from '../../permissions/constants'; const traverse = require('traverse'); @@ -387,7 +388,7 @@ module.exports = [ (req, res, next) => { const project = req.body; // by default connect admin and managers joins projects as manager - const userRole = util.hasRoles(req, MANAGER_ROLES) + const userRole = util.hasPermissionByReq(PERMISSION.CREATE_PROJECT_AS_MANAGER, req) ? PROJECT_MEMBER_ROLE.MANAGER : PROJECT_MEMBER_ROLE.CUSTOMER; // set defaults diff --git a/src/routes/projects/create.spec.js b/src/routes/projects/create.spec.js index 9f83df71..ea8df85b 100644 --- a/src/routes/projects/create.spec.js +++ b/src/routes/projects/create.spec.js @@ -1,5 +1,6 @@ /* eslint-disable no-unused-expressions */ import _ from 'lodash'; +import config from 'config'; import chai from 'chai'; import moment from 'moment'; import sinon from 'sinon'; @@ -454,6 +455,62 @@ describe('Project create', () => { }); }); + it('should create project successfully using M2M token with "write:projects" scope', (done) => { + const validBody = _.cloneDeep(body); + validBody.templateId = 3; + const mockHttpClient = _.merge(testUtil.mockHttpClient, { + post: () => Promise.resolve({ + status: 200, + data: { + id: 'requesterId', + version: 'v3', + result: { + success: true, + status: 200, + content: { + projectId: 128, + }, + }, + }, + }), + }); + sandbox.stub(util, 'getHttpClient', () => mockHttpClient); + request(server) + .post('/v5/projects') + .set({ + Authorization: `Bearer ${testUtil.m2m['write:projects']}`, + }) + .send(validBody) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + should.exist(resJson.billingAccountId); + should.exist(resJson.name); + resJson.status.should.be.eql('in_review'); + resJson.type.should.be.eql(body.type); + resJson.version.should.be.eql('v3'); + resJson.members.should.have.lengthOf(1); + resJson.members[0].role.should.be.eql('manager'); + resJson.members[0].userId.should.be.eql(config.DEFAULT_M2M_USERID); + resJson.members[0].projectId.should.be.eql(resJson.id); + resJson.members[0].isPrimary.should.be.truthy; + resJson.bookmarks.should.have.lengthOf(1); + resJson.bookmarks[0].title.should.be.eql('title1'); + resJson.bookmarks[0].address.should.be.eql('http://www.address.com'); + // Check that activity fields are set + resJson.lastActivityUserId.should.be.eql(config.DEFAULT_M2M_USERID.toString()); + resJson.lastActivityAt.should.be.not.null; + server.services.pubsub.publish.calledWith('project.draft-created').should.be.true; + done(); + } + }); + }); + it('should return 201 if valid user and data (without template id: backward compatibility)', (done) => { const validBody = _.cloneDeep(body); const mockHttpClient = _.merge(testUtil.mockHttpClient, { diff --git a/src/routes/projects/delete.spec.js b/src/routes/projects/delete.spec.js index 8047a21d..20dbea78 100644 --- a/src/routes/projects/delete.spec.js +++ b/src/routes/projects/delete.spec.js @@ -149,5 +149,17 @@ describe('Project delete test', () => { expectAfterDelete(project1.id, err, done); }); }); + + it('should remove project successfully using M2M token with "write:projects" scope', (done) => { + request(server) + .delete(`/v5/projects/${project1.id}`) + .set({ + Authorization: `Bearer ${testUtil.m2m['write:projects']}`, + }) + .expect(204) + .end((err) => { + expectAfterDelete(project1.id, err, done); + }); + }); }); }); diff --git a/src/routes/projects/get.spec.js b/src/routes/projects/get.spec.js index 172776b9..a12e46f6 100644 --- a/src/routes/projects/get.spec.js +++ b/src/routes/projects/get.spec.js @@ -250,6 +250,30 @@ describe('GET Project', () => { }); }); + it('should return the project using M2M token with "read:projects" scope', (done) => { + request(server) + .get(`/v5/projects/${project1.id}/?fields=id%2Cname%2Cstatus%2Cmembers.role%2Cmembers.id%2Cmembers.userId`) + .set({ + Authorization: `Bearer ${testUtil.m2m['read:projects']}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + should.not.exist(resJson.deletedAt); + should.not.exist(resJson.billingAccountId); + should.exist(resJson.name); + resJson.status.should.be.eql('draft'); + resJson.members.should.have.lengthOf(2); + done(); + } + }); + }); + it('should return project with "members", "invites", and "attachments" by default when data comes from ES', (done) => { request(server) .get(`/v5/projects/${data[0].id}`) diff --git a/src/routes/projects/list.js b/src/routes/projects/list.js index 33514e58..a9ea8445 100755 --- a/src/routes/projects/list.js +++ b/src/routes/projects/list.js @@ -2,8 +2,9 @@ import _ from 'lodash'; import config from 'config'; import models from '../../models'; -import { MANAGER_ROLES, INVITE_STATUS, PROJECT_MEMBER_NON_CUSTOMER_ROLES } from '../../constants'; +import { INVITE_STATUS, PROJECT_MEMBER_NON_CUSTOMER_ROLES } from '../../constants'; import util from '../../util'; +import { PERMISSION } from '../../permissions/constants'; const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); @@ -612,9 +613,7 @@ module.exports = [ }; req.log.info(criteria); // TODO refactor (DRY) code below so we don't repeat the same logic for admins and non-admin users - if (!memberOnly - && (util.hasAdminRole(req) - || util.hasRoles(req, MANAGER_ROLES))) { + if (!memberOnly && util.hasPermission(PERMISSION.READ_PROJECT_ANY, req.authUser)) { // admins & topcoder managers can see all projects return retrieveProjects(req, criteria, sort, req.query.fields) .then((result) => { diff --git a/src/routes/projects/list.spec.js b/src/routes/projects/list.spec.js index e5ac9145..e454a181 100644 --- a/src/routes/projects/list.spec.js +++ b/src/routes/projects/list.spec.js @@ -383,6 +383,28 @@ describe('LIST Project', () => { }); }); + it('should return the project using M2M token with "read:projects" scope', (done) => { + request(server) + .get('/v5/projects/?status=draft') + .set({ + Authorization: `Bearer ${testUtil.m2m['read:projects']}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + resJson.should.have.lengthOf(1); + // since project 2 is indexed with id 2 + resJson[0].id.should.equal(project2.id); + done(); + } + }); + }); + it('should return the project when project that is in reviewed state in which the copilot is its member or has been invited', (done) => { request(server) .get('/v5/projects') diff --git a/src/routes/projects/update.js b/src/routes/projects/update.js index e714d064..5dcfa46b 100644 --- a/src/routes/projects/update.js +++ b/src/routes/projects/update.js @@ -10,10 +10,10 @@ import { PROJECT_MEMBER_ROLE, EVENT, RESOURCES, - USER_ROLE, REGEX, } from '../../constants'; import util from '../../util'; +import { PERMISSION } from '../../permissions/constants'; const traverse = require('traverse'); @@ -143,7 +143,7 @@ const validateUpdates = (existingProject, updatedProps, req) => { // } } if (_.has(updatedProps, 'directProjectId') && - !util.hasRoles(req, [USER_ROLE.MANAGER, USER_ROLE.TOPCODER_ADMIN])) { + !util.hasPermissionByReq(PERMISSION.UPDATE_PROJECT_DIRECT_PROJECT_ID, req)) { errors.push('Don\'t have permission to update \'directProjectId\' property'); } if ((existingProject.status !== PROJECT_STATUS.DRAFT) && (updatedProps.status === PROJECT_STATUS.DRAFT)) { diff --git a/src/routes/projects/update.spec.js b/src/routes/projects/update.spec.js index e198659e..da3d605a 100644 --- a/src/routes/projects/update.spec.js +++ b/src/routes/projects/update.spec.js @@ -1,4 +1,5 @@ /* eslint-disable no-unused-expressions */ +import config from 'config'; import chai from 'chai'; import sinon from 'sinon'; import request from 'supertest'; @@ -13,6 +14,7 @@ import { PROJECT_STATUS, BUS_API_EVENT, CONNECT_NOTIFICATION_EVENT, + M2M_SCOPES, } from '../../constants'; const should = chai.should(); @@ -190,6 +192,32 @@ describe('Project', () => { }); }); + it(`should return the project using M2M token with "${M2M_SCOPES.PROJECTS.WRITE}" scope`, (done) => { + request(server) + .patch(`/v5/projects/${project1.id}`) + .set({ + Authorization: `Bearer ${testUtil.m2m[M2M_SCOPES.PROJECTS.WRITE]}`, + }) + .send({ + name: 'updateProject name by M2M', + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + resJson.name.should.equal('updateProject name by M2M'); + resJson.updatedAt.should.not.equal('2016-06-30 00:33:07+00'); + resJson.updatedBy.should.equal(config.DEFAULT_M2M_USERID); + server.services.pubsub.publish.calledWith('project.updated').should.be.true; + done(); + } + }); + }); + it('should return 200 if valid user and data', (done) => { request(server) .patch(`/v5/projects/${project1.id}`) diff --git a/src/tests/util.js b/src/tests/util.js index 6bbd1217..4983280f 100644 --- a/src/tests/util.js +++ b/src/tests/util.js @@ -2,6 +2,7 @@ import models from '../models'; import elasticsearchSync from '../../migrations/elasticsearch_sync'; +import { M2M_SCOPES } from '../constants'; const jwt = require('jsonwebtoken'); @@ -34,6 +35,15 @@ export default { // userId = 40158431, [ 'Topcoder user' ], handle: 'romitchoudhary', email: 'romit.choudhary@rivigo.com' romit: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJyb21pdGNob3VkaGFyeSIsImV4cCI6MTU2MjkxOTc5MSwidXNlcklkIjoiNDAxNTg0MzEiLCJpYXQiOjE1NjI5MTkxOTEsImVtYWlsIjoicm9taXQuY2hvdWRoYXJ5QHJpdmlnby5jb20iLCJqdGkiOiJlMmM1ZTc2NS03OTI5LTRiNzgtYjI2OS1iZDRlODA0NDI4YjMifQ.P1CoydCJuQ8Hv_b0-a8V7Wu0pgIt9qv4NYyB7FTbua0', }, + m2m: { + [M2M_SCOPES.CONNECT_PROJECT_ADMIN]: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3RvcGNvZGVyLWRldi5hdXRoMC5jb20vIiwic3ViIjoidGVzdEBjbGllbnRzIiwiYXVkIjoiaHR0cHM6Ly9tMm0udG9wY29kZXItZGV2LmNvbS8iLCJpYXQiOjE1ODc3MzI0NTksImV4cCI6MjU4NzgxODg1OSwiYXpwIjoidGVzdCIsInNjb3BlIjoiYWxsOmNvbm5lY3RfcHJvamVjdCIsImd0eSI6ImNsaWVudC1jcmVkZW50aWFscyJ9.q34b2IC1pw3ksl5RtnSEW5_HGwN0asx2MD3LV9-Wffg', + [M2M_SCOPES.PROJECTS.ALL]: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3RvcGNvZGVyLWRldi5hdXRoMC5jb20vIiwic3ViIjoidGVzdEBjbGllbnRzIiwiYXVkIjoiaHR0cHM6Ly9tMm0udG9wY29kZXItZGV2LmNvbS8iLCJpYXQiOjE1ODc3MzI0NTksImV4cCI6MjU4NzgxODg1OSwiYXpwIjoidGVzdCIsInNjb3BlIjoiYWxsOnByb2plY3RzIiwiZ3R5IjoiY2xpZW50LWNyZWRlbnRpYWxzIn0.ixFXMCsBmIN9mQ9Z3s-Apkg20A3d86Pm9RouL7bZMV4', + [M2M_SCOPES.PROJECTS.READ]: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3RvcGNvZGVyLWRldi5hdXRoMC5jb20vIiwic3ViIjoidGVzdEBjbGllbnRzIiwiYXVkIjoiaHR0cHM6Ly9tMm0udG9wY29kZXItZGV2LmNvbS8iLCJpYXQiOjE1ODc3MzI0NTksImV4cCI6MjU4NzgxODg1OSwiYXpwIjoidGVzdCIsInNjb3BlIjoicmVhZDpwcm9qZWN0cyIsImd0eSI6ImNsaWVudC1jcmVkZW50aWFscyJ9.IpYgfbem-eR6tGjBoxQBPDw6YIulBTZLBn48NuyJT_g', + [M2M_SCOPES.PROJECTS.WRITE]: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3RvcGNvZGVyLWRldi5hdXRoMC5jb20vIiwic3ViIjoidGVzdEBjbGllbnRzIiwiYXVkIjoiaHR0cHM6Ly9tMm0udG9wY29kZXItZGV2LmNvbS8iLCJpYXQiOjE1ODc3MzI0NTksImV4cCI6MjU4NzgxODg1OSwiYXpwIjoidGVzdCIsInNjb3BlIjoid3JpdGU6cHJvamVjdHMiLCJndHkiOiJjbGllbnQtY3JlZGVudGlhbHMifQ.cAMbmnSKXB8Xl4s4Nlo1LduPySBcvKz2Ygilq5b0OD0', + [M2M_SCOPES.PROJECT_MEMBERS.ALL]: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3RvcGNvZGVyLWRldi5hdXRoMC5jb20vIiwic3ViIjoidGVzdEBjbGllbnRzIiwiYXVkIjoiaHR0cHM6Ly9tMm0udG9wY29kZXItZGV2LmNvbS8iLCJpYXQiOjE1ODc3MzI0NTksImV4cCI6MjU4NzgxODg1OSwiYXpwIjoidGVzdCIsInNjb3BlIjoiYWxsOnByb2plY3QtbWVtYmVycyIsImd0eSI6ImNsaWVudC1jcmVkZW50aWFscyJ9.6KNEtsb1Y9F8wS5LPgJbCi4dThaIH9v1mMJEGoXWTug', + [M2M_SCOPES.PROJECT_MEMBERS.READ]: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3RvcGNvZGVyLWRldi5hdXRoMC5jb20vIiwic3ViIjoidGVzdEBjbGllbnRzIiwiYXVkIjoiaHR0cHM6Ly9tMm0udG9wY29kZXItZGV2LmNvbS8iLCJpYXQiOjE1ODc3MzI0NTksImV4cCI6MjU4NzgxODg1OSwiYXpwIjoidGVzdCIsInNjb3BlIjoicmVhZDpwcm9qZWN0LW1lbWJlcnMiLCJndHkiOiJjbGllbnQtY3JlZGVudGlhbHMifQ.7qoDXT76_aQ3xggzMnb6qk49HD4GtD-ePDGAEtinh_U', + [M2M_SCOPES.PROJECT_MEMBERS.WRITE]: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3RvcGNvZGVyLWRldi5hdXRoMC5jb20vIiwic3ViIjoidGVzdEBjbGllbnRzIiwiYXVkIjoiaHR0cHM6Ly9tMm0udG9wY29kZXItZGV2LmNvbS8iLCJpYXQiOjE1ODc3MzI0NTksImV4cCI6MjU4NzgxODg1OSwiYXpwIjoidGVzdCIsInNjb3BlIjoid3JpdGU6cHJvamVjdC1tZW1iZXJzIiwiZ3R5IjoiY2xpZW50LWNyZWRlbnRpYWxzIn0.FOF8Ej8vOkjCrihPEHR4tG2LNwwV180oHaxMpFgxb7Y', + }, userIds: { member: 40051331, copilot: 40051332, diff --git a/src/util.js b/src/util.js index fa21b0d2..cc418f24 100644 --- a/src/util.js +++ b/src/util.js @@ -23,19 +23,24 @@ import models from './models'; import { ADMIN_ROLES, - TOKEN_SCOPES, + M2M_SCOPES, EVENT, PROJECT_MEMBER_ROLE, VALUE_TYPE, ESTIMATION_TYPE, RESOURCES, USER_ROLE, + INVITE_STATUS, } from './constants'; +import { PERMISSION, DEFAULT_PROJECT_ROLE } from './permissions/constants'; const tcCoreLibAuth = require('tc-core-library-js').auth; const m2m = tcCoreLibAuth.m2m(config); +/** + * @type {projectServiceUtils} + */ const util = _.cloneDeep(require('tc-core-library-js').util(config)); const ssoRefCodes = JSON.parse(config.get('SSO_REFCODES')); @@ -43,7 +48,7 @@ const ssoRefCodes = JSON.parse(config.get('SSO_REFCODES')); // the client modifies the config object, so always passed the cloned object let esClient = null; -_.assignIn(util, { +const projectServiceUtils = { /** * Build API error * @param {string} message the API error message @@ -170,7 +175,7 @@ _.assignIn(util, { const isMachineToken = _.get(req, 'authUser.isMachine', false); const tokenScopes = _.get(req, 'authUser.scopes', []); if (isMachineToken) { - if (_.indexOf(tokenScopes, TOKEN_SCOPES.CONNECT_PROJECT_ADMIN) >= 0) return true; + if (_.indexOf(tokenScopes, M2M_SCOPES.CONNECT_PROJECT_ADMIN) >= 0) return true; return false; } let roles = _.get(req, 'authUser.roles', []); @@ -187,7 +192,7 @@ _.assignIn(util, { const isMachineToken = _.get(req, 'authUser.isMachine', false); const tokenScopes = _.get(req, 'authUser.scopes', []); if (isMachineToken) { - if (_.indexOf(tokenScopes, TOKEN_SCOPES.CONNECT_PROJECT_ADMIN) >= 0) return true; + if (_.indexOf(tokenScopes, M2M_SCOPES.CONNECT_PROJECT_ADMIN) >= 0) return true; return false; } let authRoles = _.get(req, 'authUser.roles', []); @@ -213,7 +218,7 @@ _.assignIn(util, { const isMachineToken = _.get(req, 'authUser.isMachine', false); const tokenScopes = _.get(req, 'authUser.scopes', []); if (isMachineToken) { - if (_.indexOf(tokenScopes, TOKEN_SCOPES.CONNECT_PROJECT_ADMIN) >= 0) return true; + if (_.indexOf(tokenScopes, M2M_SCOPES.CONNECT_PROJECT_ADMIN) >= 0) return true; return false; } let roles = _.get(req, 'authUser.roles', []); @@ -274,7 +279,7 @@ _.assignIn(util, { */ addUserDetailsFieldsIfAllowed: (fields, req) => { // Only Topcoder Admins can get email - if (util.hasPermission({ topcoderRoles: [USER_ROLE.TOPCODER_ADMIN] }, req.authUser)) { + if (util.hasPermissionByReq(PERMISSION.READ_PROJECT_MEMBER_DETAILS, req)) { return _.concat(fields, ['email', 'firstName', 'lastName']); } @@ -652,7 +657,7 @@ _.assignIn(util, { // clone data to avoid mutations const dataClone = _.cloneDeep(data); - const isAdmin = util.hasPermission({ topcoderRoles: [USER_ROLE.TOPCODER_ADMIN] }, req.authUser); + const isAdmin = util.hasPermissionByReq({ topcoderRoles: [USER_ROLE.TOPCODER_ADMIN] }, req); const currentUserId = req.authUser.userId; const currentUserEmail = req.authUser.email; @@ -728,7 +733,7 @@ _.assignIn(util, { let memberDetailFields = ['handle']; // Only Topcoder admins can get emails, first and last name for users - if (util.hasPermission({ topcoderRoles: [USER_ROLE.TOPCODER_ADMIN] }, req.authUser)) { + if (util.hasPermissionByReq({ topcoderRoles: [USER_ROLE.TOPCODER_ADMIN] }, req)) { memberDetailFields = memberDetailFields.concat(['email', 'firstName', 'lastName']); } @@ -853,8 +858,9 @@ _.assignIn(util, { * Add userId to project * @param {object} req Request object that should contain project info and user info * @param {object} member the member to be added to project + * @param {sequalize.Transaction} transaction */ - addUserToProject: Promise.coroutine(function* (req, member) { // eslint-disable-line + addUserToProject: Promise.coroutine(function* (req, member, transaction) { // eslint-disable-line const members = req.context.currentProjectMembers; // check if member is already registered @@ -869,23 +875,40 @@ _.assignIn(util, { let newMember = null; // register member - return models.ProjectMember.create(member) + return models.ProjectMember.create(member, { transaction }) .then((_newMember) => { newMember = _newMember.get({ plain: true }); - // publish event - req.app.services.pubsub.publish( - EVENT.ROUTING_KEY.PROJECT_MEMBER_ADDED, - newMember, - { correlationId: req.id }, - ); - // emit the event - util.sendResourceToKafkaBus( - req, - EVENT.ROUTING_KEY.PROJECT_MEMBER_ADDED, - RESOURCES.PROJECT_MEMBER, - newMember); - return newMember; + // we have to remove all pending invites for the member if any, as we can add a member directly without invite + return models.ProjectMemberInvite.getPendingInviteByEmailOrUserId(member.projectId, null, newMember.userId) + .then((invite) => { + if (invite) { + return invite.update({ + status: INVITE_STATUS.CANCELED, + }, { + transaction, + }); + } + + return Promise.resolve(); + }).then(() => { + // TODO Should we also send Kafka event in case we removed some invite above? + + // publish event + req.app.services.pubsub.publish( + EVENT.ROUTING_KEY.PROJECT_MEMBER_ADDED, + newMember, + { correlationId: req.id }, + ); + // emit the event + util.sendResourceToKafkaBus( + req, + EVENT.ROUTING_KEY.PROJECT_MEMBER_ADDED, + RESOURCES.PROJECT_MEMBER, + newMember); + + return newMember; + }); }) .catch((err) => { req.log.error('Unable to register ', err); @@ -1133,36 +1156,81 @@ _.assignIn(util, { * If we define a rule with `projectRoles` list, we also should provide `projectMembers` * - the list of project members. * + * `permissionRule.projectRoles` may be equal to `true` which means user is a project member with any role + * + * `permissionRule.topcoderRoles` may be equal to `true` which means user is a logged-in user + * * @param {Object} permissionRule permission rule - * @param {Array} permissionRule.projectRoles the list of project roles of the user - * @param {Array} permissionRule.topcoderRoles the list of Topcoder roles of the user + * @param {Array|Array|Boolean} permissionRule.projectRoles the list of project roles of the user + * @param {Array|Boolean} permissionRule.topcoderRoles the list of Topcoder roles of the user * @param {Object} user user for whom we check permissions * @param {Object} user.roles list of user roles - * @param {Object} user.isMachine `true` - if it's machine, `false` - real user * @param {Object} user.scopes scopes of user token * @param {Array} projectMembers (optional) list of project members - required to check `topcoderRoles` * * @returns {Boolean} true, if has permission */ matchPermissionRule: (permissionRule, user, projectMembers) => { - const member = _.find(projectMembers, { userId: user.userId }); let hasProjectRole = false; let hasTopcoderRole = false; + let hasScope = false; - if (permissionRule) { - if (permissionRule.projectRoles - && permissionRule.projectRoles.length > 0 - && !!member - ) { - hasProjectRole = _.includes(permissionRule.projectRoles, member.role); + // if no rule defined, no access by default + if (!permissionRule) { + return false; + } + + // check Project Roles + if (permissionRule.projectRoles && projectMembers) { + const userId = !_.isNumber(user.userId) ? parseInt(user.userId, 10) : user.userId; + const member = _.find(projectMembers, { userId }); + + // check if user has one of allowed Project roles + if (permissionRule.projectRoles.length > 0) { + // as we support `projectRoles` as strings and as objects like: + // { role: "...", isPrimary: true } we have normalize them to a common shape + const normalizedProjectRoles = permissionRule.projectRoles.map(rule => ( + _.isString(rule) ? { role: rule } : rule + )); + + hasProjectRole = member && _.some(normalizedProjectRoles, rule => ( + // checks that common properties are equal + _.isMatch(member, rule) + )); + + // `projectRoles === true` means that we check if user is a member of the project + // with any role + } else if (permissionRule.projectRoles === true) { + hasProjectRole = !!member; } + } - if (permissionRule.topcoderRoles && permissionRule.topcoderRoles.length > 0) { - hasTopcoderRole = util.hasRoles({ authUser: user }, permissionRule.topcoderRoles); + // check Topcoder Roles + if (permissionRule.topcoderRoles) { + // check if user has one of allowed Topcoder roles + if (permissionRule.topcoderRoles.length > 0) { + hasTopcoderRole = _.intersection( + _.get(user, 'roles', []).map(role => role.toLowerCase()), + permissionRule.topcoderRoles.map(role => role.toLowerCase()), + ).length > 0; + + // `topcoderRoles === true` means that we check if user is has any Topcoder role + // basically this equals to logged-in user, as all the Topcoder users + // have at least one role `Topcoder User` + } else if (permissionRule.topcoderRoles === true) { + hasTopcoderRole = _.get(user, 'roles', []).length > 0; } } - return hasProjectRole || hasTopcoderRole; + // check M2M scopes + if (permissionRule.scopes) { + hasScope = _.intersection( + _.get(user, 'scopes', []), + permissionRule.scopes, + ).length > 0; + } + + return hasProjectRole || hasTopcoderRole || hasScope; }, /** @@ -1209,13 +1277,18 @@ _.assignIn(util, { * @param {Object} permission permission or permissionRule * @param {Object} user user for whom we check permissions * @param {Object} user.roles list of user roles - * @param {Object} user.isMachine `true` - if it's machine, `false` - real user * @param {Object} user.scopes scopes of user token * @param {Array} projectMembers (optional) list of project members - required to check `topcoderRoles` * * @returns {Boolean} true, if has permission */ hasPermission: (permission, user, projectMembers) => { + if (!permission) { + return false; + } + + console.log('hasPermission', permission, user); + const allowRule = permission.allowRule ? permission.allowRule : permission; const denyRule = permission.denyRule ? permission.denyRule : null; @@ -1225,6 +1298,34 @@ _.assignIn(util, { return allow && !deny; }, + hasPermissionByReq: (permission, req) => { + // as it's very easy to forget "req" argument, throw error to make debugging easier + if (!req) { + throw new Error('Method "hasPermissionByReq" requires "req" argument.'); + } + + return util.hasPermission(permission, _.get(req, 'authUser'), _.get(req, 'context.currentProjectMembers')); + }, + + /** + * Check if permission requires us to provide the list Project Members or no. + * + * @param {Object} permission permission or permissionRule + */ + isPermissionRequireProjectMembers: (permission) => { + if (!permission) { + return false; + } + + const allowRule = permission.allowRule ? permission.allowRule : permission; + const denyRule = permission.denyRule ? permission.denyRule : null; + + const allowRuleRequiresProjectMembers = _.get(allowRule, 'projectRoles.length') > 0; + const denyRuleRequiresProjectMembers = _.get(denyRule, 'projectRoles.length') > 0; + + return allowRuleRequiresProjectMembers || denyRuleRequiresProjectMembers; + }, + /** * Check if user has permission for the project by `projectId`. * @@ -1266,7 +1367,6 @@ _.assignIn(util, { * @param {Object} permission permission or permissionRule * @param {Object} user user for whom we check permissions * @param {Object} user.roles list of user roles - * @param {Object} user.isMachine `true` - if it's machine, `false` - real user * @param {Object} user.scopes scopes of user token * @param {Number} projectId project id to check permissions for * @@ -1292,6 +1392,26 @@ _.assignIn(util, { return markupKey ? _.includes(_.values(ESTIMATION_TYPE), markupKey) : false; }, + /** + * Get default Project Role for a user by they Topcoder Roles. + * + * @param {Object} user user + * @param {Array} user.roles user Topcoder roles + * + * @returns {String} project role + */ + getDefaultProjectRole: (user) => { + for (let i = 0; i < DEFAULT_PROJECT_ROLE.length; i += 1) { + const rule = DEFAULT_PROJECT_ROLE[i]; + + if (util.hasPermission({ topcoderRoles: [rule.topcoderRole] }, user)) { + return rule.projectRole; + } + } + + return undefined; + }, + /** * Validate if `fields` list has only allowed values from `allowedFields` or throws error. * @@ -1361,6 +1481,8 @@ _.assignIn(util, { }); }, -}); +}; + +_.assignIn(util, projectServiceUtils); export default util;