diff --git a/docs/permissions.html b/docs/permissions.html index a0787cb1..33f4d536 100644 --- a/docs/permissions.html +++ b/docs/permissions.html @@ -636,8 +636,8 @@

all:connect_project - all:project-members - read:project-members + all:project-invites + read:project-invites
@@ -670,8 +670,8 @@

all:connect_project - all:project-members - read:project-members + all:project-invites + read:project-invites
@@ -704,8 +704,8 @@

all:connect_project - all:project-members - write:project-members + all:project-invites + write:project-invites
@@ -734,8 +734,8 @@

all:connect_project - all:project-members - write:project-members + all:project-invites + write:project-invites
@@ -759,8 +759,8 @@

all:connect_project - all:project-members - write:project-members + all:project-invites + write:project-invites
@@ -782,8 +782,8 @@

all:connect_project - all:project-members - write:project-members + all:project-invites + write:project-invites
@@ -806,8 +806,8 @@

all:connect_project - all:project-members - write:project-members + all:project-invites + write:project-invites
@@ -831,8 +831,8 @@

all:connect_project - all:project-members - write:project-members + all:project-invites + write:project-invites
@@ -854,8 +854,8 @@

all:connect_project - all:project-members - write:project-members + all:project-invites + write:project-invites
@@ -879,8 +879,8 @@

all:connect_project - all:project-members - write:project-members + all:project-invites + write:project-invites
@@ -909,8 +909,8 @@

all:connect_project - all:project-members - write:project-members + all:project-invites + write:project-invites
@@ -934,8 +934,8 @@

all:connect_project - all:project-members - write:project-members + all:project-invites + write:project-invites
diff --git a/src/constants.js b/src/constants.js index 9231bc66..97b0371f 100644 --- a/src/constants.js +++ b/src/constants.js @@ -281,6 +281,11 @@ export const M2M_SCOPES = { READ: 'read:project-members', WRITE: 'write:project-members', }, + PROJECT_INVITES: { + ALL: 'all:project-invites', + READ: 'read:project-invites', + WRITE: 'write:project-invites', + }, }; export const TIMELINE_REFERENCES = { diff --git a/src/permissions/constants.js b/src/permissions/constants.js index 1e4b7172..6c65d0d7 100644 --- a/src/permissions/constants.js +++ b/src/permissions/constants.js @@ -117,6 +117,18 @@ const SCOPES_PROJECT_MEMBERS_WRITE = [ M2M_SCOPES.PROJECT_MEMBERS.WRITE, ]; +const SCOPES_PROJECT_INVITES_READ = [ + M2M_SCOPES.CONNECT_PROJECT_ADMIN, + M2M_SCOPES.PROJECT_INVITES.ALL, + M2M_SCOPES.PROJECT_INVITES.READ, +]; + +const SCOPES_PROJECT_INVITES_WRITE = [ + M2M_SCOPES.CONNECT_PROJECT_ADMIN, + M2M_SCOPES.PROJECT_INVITES.ALL, + M2M_SCOPES.PROJECT_INVITES.WRITE, +]; + /** * The full list of possible permission rules in Project Service */ @@ -332,7 +344,7 @@ export const PERMISSION = { // eslint-disable-line import/prefer-default-export description: 'Who can view own invite.', }, topcoderRoles: ALL, - scopes: SCOPES_PROJECT_MEMBERS_READ, + scopes: SCOPES_PROJECT_INVITES_READ, }, READ_PROJECT_INVITE_NOT_OWN: { @@ -343,7 +355,7 @@ export const PERMISSION = { // eslint-disable-line import/prefer-default-export }, topcoderRoles: TOPCODER_ROLES_MANAGERS_AND_ADMINS, projectRoles: ALL, - scopes: SCOPES_PROJECT_MEMBERS_READ, + scopes: SCOPES_PROJECT_INVITES_READ, }, CREATE_PROJECT_INVITE_CUSTOMER: { @@ -354,7 +366,7 @@ export const PERMISSION = { // eslint-disable-line import/prefer-default-export }, topcoderRoles: TOPCODER_ROLES_MANAGERS_AND_ADMINS, projectRoles: ALL, - scopes: SCOPES_PROJECT_MEMBERS_WRITE, + scopes: SCOPES_PROJECT_INVITES_WRITE, }, CREATE_PROJECT_INVITE_NON_CUSTOMER: { @@ -365,7 +377,7 @@ export const PERMISSION = { // eslint-disable-line import/prefer-default-export }, topcoderRoles: TOPCODER_ROLES_ADMINS, projectRoles: PROJECT_ROLES_MANAGEMENT, - scopes: SCOPES_PROJECT_MEMBERS_WRITE, + scopes: SCOPES_PROJECT_INVITES_WRITE, }, CREATE_PROJECT_INVITE_COPILOT_DIRECTLY: { @@ -378,7 +390,7 @@ export const PERMISSION = { // eslint-disable-line import/prefer-default-export ...TOPCODER_ROLES_ADMINS, USER_ROLE.COPILOT_MANAGER, ], - scopes: SCOPES_PROJECT_MEMBERS_WRITE, + scopes: SCOPES_PROJECT_INVITES_WRITE, }, UPDATE_PROJECT_INVITE_OWN: { @@ -388,7 +400,7 @@ export const PERMISSION = { // eslint-disable-line import/prefer-default-export description: 'Who can update own invite.', }, topcoderRoles: ALL, - scopes: SCOPES_PROJECT_MEMBERS_WRITE, + scopes: SCOPES_PROJECT_INVITES_WRITE, }, UPDATE_PROJECT_INVITE_NOT_OWN: { @@ -398,7 +410,7 @@ export const PERMISSION = { // eslint-disable-line import/prefer-default-export description: 'Who can update invites for other members.', }, topcoderRoles: TOPCODER_ROLES_ADMINS, - scopes: SCOPES_PROJECT_MEMBERS_WRITE, + scopes: SCOPES_PROJECT_INVITES_WRITE, }, UPDATE_PROJECT_INVITE_REQUESTED: { @@ -411,7 +423,7 @@ export const PERMISSION = { // eslint-disable-line import/prefer-default-export ...TOPCODER_ROLES_ADMINS, USER_ROLE.COPILOT_MANAGER, ], - scopes: SCOPES_PROJECT_MEMBERS_WRITE, + scopes: SCOPES_PROJECT_INVITES_WRITE, }, DELETE_PROJECT_INVITE_OWN: { @@ -421,7 +433,7 @@ export const PERMISSION = { // eslint-disable-line import/prefer-default-export description: 'Who can delete own invite.', }, topcoderRoles: ALL, - scopes: SCOPES_PROJECT_MEMBERS_WRITE, + scopes: SCOPES_PROJECT_INVITES_WRITE, }, DELETE_PROJECT_INVITE_NOT_OWN_CUSTOMER: { @@ -432,7 +444,7 @@ export const PERMISSION = { // eslint-disable-line import/prefer-default-export }, topcoderRoles: TOPCODER_ROLES_ADMINS, projectRoles: ALL, - scopes: SCOPES_PROJECT_MEMBERS_WRITE, + scopes: SCOPES_PROJECT_INVITES_WRITE, }, DELETE_PROJECT_INVITE_NOT_OWN_NON_CUSTOMER: { @@ -443,7 +455,7 @@ export const PERMISSION = { // eslint-disable-line import/prefer-default-export }, topcoderRoles: TOPCODER_ROLES_ADMINS, projectRoles: PROJECT_ROLES_MANAGEMENT, - scopes: SCOPES_PROJECT_MEMBERS_WRITE, + scopes: SCOPES_PROJECT_INVITES_WRITE, }, DELETE_PROJECT_INVITE_REQUESTED: { @@ -456,7 +468,7 @@ export const PERMISSION = { // eslint-disable-line import/prefer-default-export ...TOPCODER_ROLES_ADMINS, USER_ROLE.COPILOT_MANAGER, ], - scopes: SCOPES_PROJECT_MEMBERS_WRITE, + scopes: SCOPES_PROJECT_INVITES_WRITE, }, /* diff --git a/src/permissions/generalPermission.js b/src/permissions/generalPermission.js index 032d3706..bce44d24 100644 --- a/src/permissions/generalPermission.js +++ b/src/permissions/generalPermission.js @@ -40,7 +40,7 @@ module.exports = permissions => async (req) => { // 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)) + ? _.some(permissions, permission => util.isPermissionRequireProjectMembers(permission)) : util.isPermissionRequireProjectMembers(permissions); if (_.isUndefined(req.params.projectId) && permissionsRequireProjectMembers) { @@ -62,6 +62,7 @@ module.exports = permissions => async (req) => { // - 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 + req.log.error(`Cannot load project members: ${err.message}.`); } } diff --git a/src/routes/projects/get.js b/src/routes/projects/get.js index a8ba19a4..1e0b22c9 100644 --- a/src/routes/projects/get.js +++ b/src/routes/projects/get.js @@ -3,6 +3,7 @@ import config from 'config'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import models from '../../models'; import util from '../../util'; +import { PERMISSION } from '../../permissions/constants'; import permissionUtils from '../../utils/permissions'; const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); @@ -104,7 +105,8 @@ const retrieveProjectFromES = (projectId, req) => { fields = fields ? fields.split(',') : []; fields = util.parseFields(fields, { projects: PROJECT_ATTRIBUTES, - project_members: util.addUserDetailsFieldsIfAllowed(PROJECT_MEMBER_ATTRIBUTES_ES, req), + project_members: util.hasPermissionByReq(PERMISSION.READ_PROJECT_MEMBER, req) + ? util.addUserDetailsFieldsIfAllowed(PROJECT_MEMBER_ATTRIBUTES_ES, req) : null, project_member_invites: PROJECT_MEMBER_INVITE_ATTRIBUTES, project_phases: PROJECT_PHASE_ATTRIBUTES, project_phases_products: PROJECT_PHASE_PRODUCTS_ATTRIBUTES, @@ -116,7 +118,26 @@ const retrieveProjectFromES = (projectId, req) => { const es = util.getElasticSearchClient(); es.search(searchCriteria).then((docs) => { const rows = _.map(docs.hits.hits, single => single._source); // eslint-disable-line no-underscore-dangle - accept(rows[0]); + const project = rows[0]; + if (project && project.invites) { + if (!util.hasPermissionByReq(PERMISSION.READ_PROJECT_INVITE_NOT_OWN, req)) { + let invites; + if (util.hasPermissionByReq(PERMISSION.READ_PROJECT_INVITE_OWN, req)) { + // only include own invites + const currentUserId = req.authUser.userId; + const currentUserEmail = req.authUser.email; + invites = _.filter(project.invites, invite => ( + (invite.userId !== null && invite.userId === currentUserId) || + (invite.email && currentUserEmail && invite.email.toLowerCase() === currentUserEmail.toLowerCase()) + )); + } else { + // return empty invites + invites = []; + } + _.set(project, 'invites', invites); + } + } + accept(project); }).catch(reject); }); }; @@ -144,7 +165,9 @@ const retrieveProjectFromDB = (projectId, req) => { return Promise.reject(apiErr); } // check context for project members - project.members = _.map(req.context.currentProjectMembers, m => _.pick(m, fields.project_members)); + if (util.hasPermissionByReq(PERMISSION.READ_PROJECT_MEMBER, req)) { + project.members = _.map(req.context.currentProjectMembers, m => _.pick(m, fields.project_members)); + } // check if attachments field was requested if (!req.query.fields || _.indexOf(req.query.fields, 'attachments') > -1) { return util.getProjectAttachments(req, project.id); @@ -157,7 +180,17 @@ const retrieveProjectFromDB = (projectId, req) => { if (attachments) { project.attachments = attachments; } - return models.ProjectMemberInvite.getPendingAndReguestedInvitesForProject(projectId); + if (util.hasPermissionByReq(PERMISSION.READ_PROJECT_INVITE_NOT_OWN, req)) { + // include all invites + return models.ProjectMemberInvite.getPendingAndReguestedInvitesForProject(projectId); + } else if (util.hasPermissionByReq(PERMISSION.READ_PROJECT_INVITE_OWN, req)) { + // include only own invites + const currentUserId = req.authUser.userId; + const email = req.authUser.email; + return models.ProjectMemberInvite.getPendingOrRequestedProjectInvitesForUser(projectId, email, currentUserId); + } + // empty + return Promise.resolve([]); }) .then((invites) => { project.invites = invites; diff --git a/src/routes/projects/get.spec.js b/src/routes/projects/get.spec.js index 79681934..be72238b 100644 --- a/src/routes/projects/get.spec.js +++ b/src/routes/projects/get.spec.js @@ -268,7 +268,27 @@ describe('GET Project', () => { should.not.exist(resJson.billingAccountId); should.exist(resJson.name); resJson.status.should.be.eql('draft'); - resJson.members.should.have.lengthOf(2); + should.not.exist(resJson.members); + done(); + } + }); + }); + + it('should return the project with empty invites using M2M token without "read:project-invites" scope', (done) => { + request(server) + .get(`/v5/projects/${project1.id}`) + .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.invites.should.be.empty; done(); } }); diff --git a/src/routes/projects/list.js b/src/routes/projects/list.js index fed69d33..d7b55e91 100755 --- a/src/routes/projects/list.js +++ b/src/routes/projects/list.js @@ -493,6 +493,11 @@ const retrieveProjectsFromDB = (req, criteria, sort, ffields) => { // make sure project.id is part of fields if (_.indexOf(fields.projects, 'id') < 0) fields.projects.push('id'); + // add userId to project_members field so it can be used to check READ_PROJECT_MEMBER permission below. + const addMembersUserId = fields.project_members.length > 0 && _.indexOf(fields.project_members, 'userId') < 0; + if (addMembersUserId) { + fields.project_members.push('userId'); + } const retrieveAttachments = !req.query.fields || req.query.fields.indexOf('attachments') > -1; const retrieveMembers = !req.query.fields || !!fields.project_members.length; @@ -534,7 +539,19 @@ const retrieveProjectsFromDB = (req, criteria, sort, ffields) => { const p = fp; // if values length is 1 it could be either attachments or members if (retrieveMembers) { - p.members = _.filter(allMembers, m => m.projectId === p.id); + const pMembers = _.filter(allMembers, m => m.projectId === p.id); + // check if have permission to read project members + if (util.hasPermission(PERMISSION.READ_PROJECT_MEMBER, req.authUser, pMembers)) { + if (addMembersUserId) { + // remove the userId from the returned members array if it was added before + // as it is only needed for checking permission. + _.forEach(pMembers, (m) => { + const fm = m; + delete fm.userId; + }); + } + p.members = pMembers; + } } if (retrieveAttachments) { p.attachments = _.filter(allAttachments, a => a.projectId === p.id); @@ -563,12 +580,55 @@ const retrieveProjects = (req, criteria, sort, ffields) => { if (_.indexOf(fields.projects, 'id') < 0) { fields.projects.push('id'); } + // add userId to project_members field so it can be used to check READ_PROJECT_MEMBER permission below. + const addMembersUserId = fields.project_members.length > 0 && _.indexOf(fields.project_members, 'userId') < 0; + if (addMembersUserId) { + fields.project_members.push('userId'); + } const searchCriteria = parseElasticSearchCriteria(criteria, fields, order) || {}; return new Promise((accept, reject) => { const es = util.getElasticSearchClient(); es.search(searchCriteria).then((docs) => { const rows = _.map(docs.hits.hits, single => single._source); // eslint-disable-line no-underscore-dangle + if (rows) { + if (!util.hasPermissionByReq(PERMISSION.READ_PROJECT_INVITE_NOT_OWN, req)) { + if (util.hasPermissionByReq(PERMISSION.READ_PROJECT_INVITE_OWN, req)) { + // only include own invites + const currentUserId = req.authUser.userId; + const currentUserEmail = req.authUser.email; + _.forEach(rows, (fp) => { + const invites = _.filter(fp.invites, invite => ( + (invite.userId !== null && invite.userId === currentUserId) || + (invite.email && currentUserEmail && invite.email.toLowerCase() === currentUserEmail.toLowerCase()) + )); + _.set(fp, 'invites', invites); + }); + } else { + // return empty invites + _.forEach(rows, (fp) => { + _.set(fp, 'invites', []); + }); + } + } + _.forEach(rows, (p) => { + const fp = p; + if (fp.members) { + // check if have permission to read project members + if (!util.hasPermission(PERMISSION.READ_PROJECT_MEMBER, req.authUser, fp.members)) { + delete fp.members; + } + if (fp.members && addMembersUserId) { + // remove the userId from the returned members array if it was added before + // as it is only needed for checking permission. + _.forEach(fp.members, (m) => { + const fm = m; + delete fm.userId; + }); + } + } + }); + } accept({ rows, count: docs.hits.total, pageSize: criteria.limit, page: criteria.page }); }).catch(reject); }); diff --git a/src/routes/projects/list.spec.js b/src/routes/projects/list.spec.js index c4ddb30d..7e00c2f8 100644 --- a/src/routes/projects/list.spec.js +++ b/src/routes/projects/list.spec.js @@ -405,6 +405,52 @@ describe('LIST Project', () => { }); }); + it('should return the project with empty invites using M2M token without "read:project-invites" scope', (done) => { + request(server) + .get('/v5/projects') + .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(3); + resJson.forEach((project) => { + project.invites.should.be.empty; + }); + done(); + } + }); + }); + + it('should not include the project members using M2M token without "read:project-members" scope', (done) => { + request(server) + .get('/v5/projects') + .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(3); + resJson.forEach((project) => { + should.not.exist(project.members); + }); + 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') @@ -1130,9 +1176,9 @@ describe('LIST Project', () => { should.exist(resJson); resJson.should.have.lengthOf(1); resJson[0].name.should.equal('test1'); - resJson[0].invites.should.have.lengthOf(2); + resJson[0].invites.should.have.lengthOf(1); resJson[0].invites[0].should.have.property('email'); - resJson[0].invites[1].email.should.equal('h***o@w***d.com'); + resJson[0].invites[0].userId.should.equal(40051335); done(); } }); @@ -1163,7 +1209,7 @@ describe('LIST Project', () => { request(server) .get('/v5/projects/') .set({ - Authorization: `Bearer ${testUtil.jwts.member2}`, + Authorization: `Bearer ${testUtil.jwts.member}`, }) .expect('Content-Type', /json/) .expect(200) @@ -1185,7 +1231,7 @@ describe('LIST Project', () => { request(server) .get('/v5/projects/?fields=members.email,members.id') .set({ - Authorization: `Bearer ${testUtil.jwts.member2}`, + Authorization: `Bearer ${testUtil.jwts.member}`, }) .expect('Content-Type', /json/) .expect(200)