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)