Skip to content

PROD RELEASE - Allow for artifact download in Submission API #371

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

Merged
merged 10 commits into from
Mar 18, 2025
4 changes: 2 additions & 2 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ jobs:
source awsenvconf
./buildenv.sh -e DEV -b dev-submissions-api-deployvar
source buildenvvar
./master_deploy.sh -d ECS -e DEV -t latest -s dev-global-appvar,dev-submissions-api-appvar -i submissions-api
./master_deploy.sh -d ECS -e DEV -t latest -s dev-global-appvar,dev-submissions-api-appvar -i submissions-api -p FARGATE

"build-prod":
<<: *defaults
Expand Down Expand Up @@ -69,7 +69,7 @@ workflows:
context: org-global
filters:
branches:
only: ["develop", "PLAT-3383"]
only: ["develop", "PM-809_artifact-endpoint-update"]
- "build-prod":
context: org-global
filters:
Expand Down
74 changes: 74 additions & 0 deletions src/common/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const errors = require('common-errors')
const { validate: uuidValidate } = require('uuid')
const NodeCache = require('node-cache')
const { axiosInstance } = require('./axiosInstance')
const { UserRoles, ProjectRoles } = require('../constants')

AWS.config.region = config.get('aws.AWS_REGION')
const s3 = new AWS.S3()
Expand Down Expand Up @@ -312,6 +313,44 @@ function setPaginationHeaders (req, res, data) {
res.json(data.rows)
}

/**
* Get challenge resources
* @param {String} challengeId the challenge id
* @param {String} userId specific userId for which to check roles
*/
const getChallengeResources = async (challengeId, userId) => {
let resourcesResponse

// Get map of role id to role name
const resourceRolesMap = await getRoleIdToRoleNameMap()

// Check if role id to role name mapping is available. If not user's role cannot be determined.
if (resourceRolesMap == null || _.size(resourceRolesMap) === 0) {
throw new errors.HttpStatusError(503, `Could not determine the user's role in the challenge with id ${challengeId}`)
}

const resourcesUrl = `${config.RESOURCEAPI_V5_BASE_URL}/resources?challengeId=${challengeId}${userId ? `&memberId=${userId}` : ''}`
try {
resourcesResponse = _.get(await axiosInstance.get(resourcesUrl), 'data', [])
} catch (ex) {
logger.error(`Error while accessing ${resourcesUrl}`)
throw new errors.HttpStatusError(503, `Could not determine the user's role in the challenge with id ${challengeId}`)
}

const resources = {}
_.each((resourcesResponse || []), (resource) => {
if (!resources[resource.memberId]) {
resources[resource.memberId] = {
memberId: resource.memberId,
memberHandle: resource.memberHandle,
roles: []
}
}
resources[resource.memberId].roles.push(resourceRolesMap[resource.roleId])
})
return resources
}

/**
* Function to get challenge by id
* @param {String} challengeId Challenge id
Expand Down Expand Up @@ -506,6 +545,40 @@ async function checkCreateAccess (authUser, memberId, challengeDetails) {
}
}

/**
* Check the user's access to a challenge
* @param {Object} authUser the user
* @param {Array} resources the challenge resources
*/
async function getChallengeAccessLevel (authUser, challengeId) {
if (authUser.isMachine) {
return { hasFullAccess: true }
}

const resources = await getChallengeResources(challengeId, authUser.userId)

// Case Insensitive Role checks
const hasFullAccess = authUser.roles.findIndex(item => UserRoles.Admin.toLowerCase() === item.toLowerCase()) > -1 || _.intersectionWith(_.get(resources[authUser.userId], 'roles', []), [
ProjectRoles.Manager,
ProjectRoles.Copilot,
ProjectRoles.Observer,
ProjectRoles.Client_Manager
], (act, exp) => act.toLowerCase() === exp.toLowerCase()).length > 0

const isReviewer = !hasFullAccess && _.intersectionWith(_.get(resources[authUser.userId], 'roles', []), [
ProjectRoles.Reviewer,
ProjectRoles.Iterative_Reviewer
], (act, exp) => act.toLowerCase() === exp.toLowerCase()).length > 0

const isSubmitter = !hasFullAccess && !isReviewer && _.intersectionWith(_.get(resources[authUser.userId], 'roles', []), [
ProjectRoles.Submitter
], (act, exp) => act.toLowerCase() === exp.toLowerCase()).length > 0

const hasNoAccess = !hasFullAccess && !isReviewer && !isSubmitter

return { hasFullAccess, isReviewer, isSubmitter, hasNoAccess }
}

/**
* Function to check user access to get a submission
* @param authUser Authenticated user
Expand Down Expand Up @@ -922,6 +995,7 @@ module.exports = {
setPaginationHeaders,
getSubmissionPhaseId,
checkCreateAccess,
getChallengeAccessLevel,
checkGetAccess,
checkReviewGetAccess,
createS3ReadStream,
Expand Down
18 changes: 18 additions & 0 deletions src/constants/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const UserRoles = {
Admin: 'Administrator'
}

const ProjectRoles = {
Manager: 'Manager',
Copilot: 'Copilot',
Observer: 'Observer',
Reviewer: 'Reviewer',
Submitter: 'Submitter',
Client_Manager: 'Client Manager',
Iterative_Reviewer: 'Iterative Reviewer'
}

module.exports = {
UserRoles,
ProjectRoles
}
4 changes: 2 additions & 2 deletions src/controllers/ArtifactController.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const ArtifactService = require('../services/ArtifactService')
* @param res the http response
*/
async function downloadArtifact (req, res) {
const result = await ArtifactService.downloadArtifact(req.params.submissionId, req.params.file)
const result = await ArtifactService.downloadArtifact(req.authUser, req.params.submissionId, req.params.file)
res.attachment(result.fileName)
res.send(result.file)
}
Expand All @@ -21,7 +21,7 @@ async function downloadArtifact (req, res) {
* @param res the http response
*/
async function listArtifacts (req, res) {
res.json(await ArtifactService.listArtifacts(req.params.submissionId))
res.json(await ArtifactService.listArtifacts(req.authUser, req.params.submissionId))
}

/**
Expand Down
47 changes: 33 additions & 14 deletions src/services/ArtifactService.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const _ = require('lodash')
const s3 = new AWS.S3()
const logger = require('../common/logger')
const HelperService = require('./HelperService')
const commonHelper = require('../common/helper')

/*
* Function to upload file to S3
Expand Down Expand Up @@ -39,16 +40,29 @@ async function _uploadToS3 (file, name) {
* @param {String} fileName File name which need to be downloaded from S3
* @return {Promise<Object>} File downloaded from S3
*/
async function downloadArtifact (submissionId, fileName) {
async function downloadArtifact (authUser, submissionId, fileName) {
// Check the validness of Submission ID
await HelperService._checkRef({ submissionId })
const artifacts = await s3.listObjects({ Bucket: config.aws.ARTIFACT_BUCKET, Prefix: `${submissionId}/${fileName}` }).promise()
const submission = await HelperService._checkRef({ submissionId })

const { hasFullAccess, isSubmitter, hasNoAccess } = await commonHelper.getChallengeAccessLevel(authUser, submission.challengeId)

if (hasNoAccess || (isSubmitter && submission.memberId.toString() !== authUser.userId.toString())) {
throw new errors.HttpStatusError(403, 'You are not allowed to download this submission artifact.')
}

if (fileName.includes('internal') && !hasFullAccess) {
throw new errors.HttpStatusError(403, 'Could not access artifact.')
}

const prefix = submissionId + '/' + fileName
const artifacts = await s3.listObjects({ Bucket: config.aws.ARTIFACT_BUCKET, Prefix: prefix }).promise()

if (artifacts.Contents.length === 0) {
throw new errors.HttpStatusError(400, `Artifact ${fileName} doesn't exist for ${submissionId}`)
}

const key = submissionId + '/' + fileName + '.zip'
if (!_.includes(_.map(artifacts.Contents, 'Key'), key)) {
const key = _.get(_.find(artifacts.Contents, { Key: `${prefix}.zip` }) || (artifacts.Contents.length === 1 ? artifacts.Contents[0] : {}), 'Key', null)
if (!key) {
throw new errors.HttpStatusError(400, `Artifact ${fileName} doesn't exist for ${submissionId}`)
}

Expand All @@ -59,6 +73,7 @@ async function downloadArtifact (submissionId, fileName) {
}

downloadArtifact.schema = joi.object({
authUser: joi.object().required(),
submissionId: joi.string().uuid().required(),
fileName: joi.string().trim().required()
}).required()
Expand All @@ -68,14 +83,23 @@ downloadArtifact.schema = joi.object({
* @param {String} submissionId Submission ID
* @return {Promise<Object>} List of files present in S3 bucket under submissionId directory
*/
async function listArtifacts (submissionId) {
async function listArtifacts (authUser, submissionId) {
// Check the validness of Submission ID
await HelperService._checkRef({ submissionId })
const submission = await HelperService._checkRef({ submissionId })

const { hasFullAccess, isSubmitter, hasNoAccess } = await commonHelper.getChallengeAccessLevel(authUser, submission.challengeId)

if (hasNoAccess || (isSubmitter && submission.memberId.toString() !== authUser.userId.toString())) {
throw new errors.HttpStatusError(403, 'You are not allowed to access this submission artifact.')
}

const artifacts = await s3.listObjects({ Bucket: config.aws.ARTIFACT_BUCKET, Prefix: submissionId }).promise()
return { artifacts: _.map(artifacts.Contents, (at) => path.parse(at.Key).name) }
const artifactsContents = _.map(artifacts.Contents, (at) => path.parse(at.Key).name)
return { artifacts: hasFullAccess ? artifactsContents : _.filter(artifactsContents, artifactName => !artifactName.includes('internal')) }
}

listArtifacts.schema = joi.object({
authUser: joi.object().required(),
submissionId: joi.string().uuid().required()
}).required()

Expand All @@ -94,7 +118,7 @@ async function createArtifact (files, submissionId, entity) {
logger.info('Creating a new Artifact')
if (files && files.artifact) {
const uFileType = (await FileType.fromBuffer(files.artifact.data)).ext // File type of uploaded file
fileName = `${submissionId}/${files.artifact.name}.${uFileType}`
fileName = `${submissionId}/${files.artifact.name.split('.').slice(0, -1)}.${uFileType}`

// Upload the artifact to S3
await _uploadToS3(files.artifact, fileName)
Expand Down Expand Up @@ -127,11 +151,6 @@ async function deleteArtifact (submissionId, fileName) {
logger.info(`deleteArtifact: deleted artifact ${fileName} of Submission ID: ${submissionId}`)
}

downloadArtifact.schema = joi.object({
submissionId: joi.string().uuid().required(),
fileName: joi.string().trim().required()
}).required()

module.exports = {
downloadArtifact,
listArtifacts,
Expand Down
4 changes: 4 additions & 0 deletions src/services/HelperService.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ async function _checkRef (entity) {
if (!existReviewType) {
throw new errors.HttpStatusError(400, `Review type with ID = ${entity.typeId} does not exist`)
}

return existReviewType
}

if (entity.submissionId) {
Expand All @@ -27,6 +29,8 @@ async function _checkRef (entity) {
if (!existSubmission) {
throw new errors.HttpStatusError(400, `Submission with ID = ${entity.submissionId} does not exist`)
}

return existSubmission
}
}

Expand Down