-
Notifications
You must be signed in to change notification settings - Fork 3
[PROD RELEASE] - Access & fixes #72
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d9bef24
c31eade
693ddee
03f87d2
c1f9ebb
4c8babf
6b341d0
7f5523a
08959ab
fde64b7
91238f7
d3d7edb
9fd80dc
7aed2cb
28aae51
d7412e1
993b455
9ba1668
8e32d2f
2d1d3a4
a044ab5
2bbaaf2
ead041d
eac2859
49d4905
21aa616
7e60b55
3036456
67568ef
f19aa1e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,18 @@ | |
| * The configuration file. | ||
| */ | ||
|
|
||
| const communicationSecureFields = ( | ||
| process.env.COMMUNICATION_SECURE_FIELDS | ||
| ? process.env.COMMUNICATION_SECURE_FIELDS.split(',') | ||
| : ['email', 'loginCount', 'lastLoginDate'] | ||
| ) | ||
| .map(field => field.trim()) | ||
| .filter(field => field.length > 0) | ||
|
|
||
| if (!communicationSecureFields.includes('email')) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [ |
||
| communicationSecureFields.unshift('email') | ||
| } | ||
|
|
||
| module.exports = { | ||
| LOG_LEVEL: process.env.LOG_LEVEL || 'debug', | ||
| PORT: process.env.PORT || 3000, | ||
|
|
@@ -76,9 +88,7 @@ module.exports = { | |
|
|
||
| // Member identifiable info fields, copilots, admins, or M2M can get these fields | ||
| // Anyone in the constants.AUTOCOMPLETE_ROLES will have access to these fields | ||
| COMMUNICATION_SECURE_FIELDS: process.env.COMMUNICATION_SECURE_FIELDS | ||
| ? process.env.COMMUNICATION_SECURE_FIELDS.split(',') | ||
| : ['email', 'loginCount', 'lastLoginDate'], | ||
| COMMUNICATION_SECURE_FIELDS: communicationSecureFields, | ||
|
|
||
| // Member identifiable info traits that are public, anyone can get these fields | ||
| MEMBER_PUBLIC_TRAITS: process.env.MEMBER_PUBLIC_TRAITS | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| const _ = require('lodash') | ||
| const helper = require('./helper') | ||
| const logger = require('./logger') | ||
| const prismaManager = require('./prisma') | ||
|
|
||
| const resourcesPrisma = prismaManager.getResourcesClient() | ||
| const COPILOT_RESOURCE_ROLE_NAME_LOWER = 'copilot' | ||
|
|
||
| function hasCopilotRole (currentUser) { | ||
| if (!currentUser || !currentUser.roles) { | ||
| return false | ||
| } | ||
|
|
||
| return helper.checkIfExists(['copilot'], currentUser.roles) | ||
| } | ||
|
|
||
| function shouldLimitCopilotEmailAccess (currentUser) { | ||
| if (!currentUser || currentUser.isMachine) { | ||
| return false | ||
| } | ||
|
|
||
| if (helper.hasAdminRole(currentUser)) { | ||
| return false | ||
| } | ||
|
|
||
| return hasCopilotRole(currentUser) | ||
| } | ||
|
|
||
| function normalizeUserId (userId) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [ |
||
| if (userId === null || userId === undefined) { | ||
| return null | ||
| } | ||
|
|
||
| const asNumber = Number(userId) | ||
| if (!Number.isNaN(asNumber) && Number.isFinite(asNumber)) { | ||
| return String(asNumber) | ||
| } | ||
|
|
||
| const asString = String(userId).trim() | ||
| return asString.length > 0 ? asString : null | ||
| } | ||
|
|
||
| async function getCopilotAccessibleMemberIdSet (currentUser, memberUserIds) { | ||
| const copilotUserId = normalizeUserId(currentUser && currentUser.userId) | ||
| const accessibleMemberIds = new Set() | ||
|
|
||
| if (!copilotUserId) { | ||
| return accessibleMemberIds | ||
| } | ||
|
|
||
| accessibleMemberIds.add(copilotUserId) | ||
|
|
||
| const targetMemberIds = _.uniq( | ||
| (memberUserIds || []) | ||
| .map(id => normalizeUserId(id)) | ||
| .filter(Boolean) | ||
| ) | ||
|
|
||
| if (targetMemberIds.length === 0) { | ||
| return accessibleMemberIds | ||
| } | ||
|
|
||
| try { | ||
| const copilotResources = await resourcesPrisma.resource.findMany({ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [ |
||
| where: { | ||
| memberId: copilotUserId, | ||
| resourceRole: { nameLower: COPILOT_RESOURCE_ROLE_NAME_LOWER } | ||
| }, | ||
| select: { challengeId: true } | ||
| }) | ||
| const challengeIds = _.uniq( | ||
| copilotResources | ||
| .map(resource => resource.challengeId) | ||
| .filter(challengeId => challengeId !== null && challengeId !== undefined) | ||
| ) | ||
|
|
||
| if (challengeIds.length === 0) { | ||
| return accessibleMemberIds | ||
| } | ||
|
|
||
| const sharedResources = await resourcesPrisma.resource.findMany({ | ||
| where: { | ||
| memberId: { in: targetMemberIds }, | ||
| challengeId: { in: challengeIds } | ||
| }, | ||
| select: { memberId: true } | ||
| }) | ||
|
|
||
| _.forEach(sharedResources, resource => { | ||
| const memberId = normalizeUserId(resource.memberId) | ||
| if (memberId) { | ||
| accessibleMemberIds.add(memberId) | ||
| } | ||
| }) | ||
| } catch (error) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [ |
||
| logger.error( | ||
| `Unable to compute copilot email visibility for userId=${copilotUserId}: ${error.message}` | ||
| ) | ||
| } | ||
|
|
||
| return accessibleMemberIds | ||
| } | ||
|
|
||
| async function canCopilotAccessMemberEmail (currentUser, memberUserId) { | ||
| if (!shouldLimitCopilotEmailAccess(currentUser)) { | ||
| return true | ||
| } | ||
|
|
||
| const normalizedMemberUserId = normalizeUserId(memberUserId) | ||
| if (!normalizedMemberUserId) { | ||
| return false | ||
| } | ||
|
|
||
| const accessibleMemberIds = await getCopilotAccessibleMemberIdSet(currentUser, [normalizedMemberUserId]) | ||
| return accessibleMemberIds.has(normalizedMemberUserId) | ||
| } | ||
|
|
||
| async function stripUnauthorizedCopilotEmails (currentUser, members) { | ||
| if (!shouldLimitCopilotEmailAccess(currentUser) || !Array.isArray(members) || members.length === 0) { | ||
| return members | ||
| } | ||
|
|
||
| const membersWithEmail = members.filter(member => ( | ||
| member && member.email !== undefined && member.email !== null | ||
| )) | ||
|
|
||
| if (membersWithEmail.length === 0) { | ||
| return members | ||
| } | ||
|
|
||
| const accessibleMemberIds = await getCopilotAccessibleMemberIdSet( | ||
| currentUser, | ||
| membersWithEmail.map(member => member.userId) | ||
| ) | ||
|
|
||
| _.forEach(membersWithEmail, member => { | ||
| const memberUserId = normalizeUserId(member.userId) | ||
| if (!memberUserId || !accessibleMemberIds.has(memberUserId)) { | ||
| delete member.email | ||
| } | ||
| }) | ||
|
|
||
| return members | ||
| } | ||
|
|
||
| module.exports = { | ||
| shouldLimitCopilotEmailAccess, | ||
| canCopilotAccessMemberEmail, | ||
| stripUnauthorizedCopilotEmails | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -150,22 +150,17 @@ function hasAutocompleteRole (authUser) { | |
| } | ||
|
|
||
| /** | ||
| * Check if the user has a role which can download profile | ||
| * Check if the user has a role that grants access to sensitive member data | ||
| * @param {Object} authUser the user | ||
| * @returns {Boolean} whether the user has PM role | ||
| * @returns {Boolean} whether the user has any "sensitive data" roles | ||
| */ | ||
| function hasProfileDownloadableRole (authUser) { | ||
| function hasSensitiveDataRole (authUser) { | ||
| if (!authUser || !authUser.roles) { | ||
| return false | ||
| } | ||
| for (let i = 0; i < authUser.roles.length; i += 1) { | ||
| for (let j = 0; j < constants.PROFILE_DOWNLOAD_ROLES.length; j += 1) { | ||
| if (authUser.roles[i].toLowerCase() === constants.PROFILE_DOWNLOAD_ROLES[j].toLowerCase()) { | ||
| return true | ||
| } | ||
| } | ||
| } | ||
| return false | ||
|
|
||
| const allowedRolesLower = constants.SENSITIVE_DATA_ROLES.map(r => r.toLowerCase()) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [ |
||
| return authUser.roles.some(r => allowedRolesLower.includes(r.toLowerCase())) | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -372,12 +367,8 @@ function canDownloadProfile (currentUser, member) { | |
| if (currentUser.isMachine) { | ||
| return true | ||
| } | ||
| // Admin can download | ||
| if (hasAdminRole(currentUser)) { | ||
| return true | ||
| } | ||
| // PM can download | ||
| if (hasProfileDownloadableRole(currentUser)) { | ||
| // Admin or Talent Manager can download | ||
| if (hasSensitiveDataRole(currentUser)) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [ |
||
| return true | ||
| } | ||
| // Member can download their own profile | ||
|
|
@@ -647,7 +638,7 @@ module.exports = { | |
| hasAdminRole, | ||
| hasAutocompleteRole, | ||
| hasSearchByEmailRole, | ||
| hasProfileDownloadableRole, | ||
| hasSensitiveDataRole, | ||
| getMemberByHandle, | ||
| uploadPhotoToS3, | ||
| postBusEvent, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| const { PrismaPg } = require("@prisma/adapter-pg"); | ||
| // Use the package-scoped generated clients to avoid cross-package overrides in the monorepo | ||
| const { | ||
| PrismaClient: MembersPrismaClient, | ||
|
|
@@ -22,8 +23,18 @@ const { | |
| const { | ||
| PrismaClient: EngagementsPrismaClient, | ||
| } = require("@topcoder/engagements-api-v6/packages/engagements-prisma-client"); | ||
| const { PrismaPg } = require("@prisma/adapter-pg"); | ||
| const { Pool } = require("pg"); | ||
|
|
||
| const extractSchemaFromUrl = (dbUrl) => { | ||
| if (!dbUrl) { | ||
| return null; | ||
| } | ||
| try { | ||
| const url = new URL(dbUrl); | ||
| return url.searchParams.get("schema"); | ||
| } catch (error) { | ||
| return null; | ||
| } | ||
| }; | ||
|
|
||
| const skillsDbUrl = process.env.SKILLS_DB_URL; | ||
| const challengesDbUrl = process.env.CHALLENGES_DB_URL; | ||
|
|
@@ -44,6 +55,17 @@ const clientOptions = { | |
| ], | ||
| }; | ||
|
|
||
| const createPgAdapter = (dbUrl) => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [ |
||
| const schema = extractSchemaFromUrl(dbUrl); | ||
| const args = [{ connectionString: dbUrl }]; | ||
|
|
||
| if (schema) { | ||
| args.push({ schema }); | ||
| } | ||
|
|
||
| return new PrismaPg(...args); | ||
| }; | ||
|
|
||
| let membersClient; | ||
| const getMembersClient = () => { | ||
| if (!membersClient) { | ||
|
|
@@ -97,21 +119,17 @@ const getAcademyClient = () => { | |
| }; | ||
|
|
||
| let engagementsClient; | ||
| let engagementsPool; | ||
| const getEngagementsClient = () => { | ||
| if (!engagementsClient) { | ||
| if (!engagementsDbUrl) { | ||
| throw new Error( | ||
| "ENGAGEMENTS_DB_URL must be set for engagements Prisma client", | ||
| ); | ||
| } | ||
| if (!engagementsPool) { | ||
| engagementsPool = new Pool({ connectionString: engagementsDbUrl }); | ||
| } | ||
| const adapter = new PrismaPg(engagementsPool); | ||
|
|
||
| engagementsClient = new EngagementsPrismaClient({ | ||
| ...clientOptions, | ||
| adapter, | ||
| adapter: createPgAdapter(engagementsDbUrl), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [ |
||
| }); | ||
| } | ||
| return engagementsClient; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -111,8 +111,9 @@ function buildMemberSkills (skillList) { | |
| const events = _.orderBy(first.skill.skillEvents || [], 'createdAt', 'desc') | ||
| const grouped = _.groupBy(events, 'sourceType.name') | ||
| ret.lastUsedDate = events[0].createdAt | ||
|
|
||
| ret.activity = _.mapValues(grouped, (v, k) => ({ | ||
| sources: _.uniqBy(v, 'sourceId').map(s => s.sourceId) | ||
| sources: v, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [❗❗ |
||
| })) | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[⚠️
maintainability]The change from
PROFILE_DOWNLOAD_ROLEStoSENSITIVE_DATA_ROLESsuggests a broader scope for this constant. Ensure that all usages ofPROFILE_DOWNLOAD_ROLESare reviewed to confirm they align with the new intent of handling sensitive data access.