Skip to content

Commit 6a65e69

Browse files
authored
Merge pull request #1692 from CVEProject/cb_1612_conversation_updates
Conversation Edits
2 parents 74bc27e + 24e4fbb commit 6a65e69

File tree

8 files changed

+472
-3
lines changed

8 files changed

+472
-3
lines changed

schemas/conversation/conversation.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@
4646
"format": "date-time",
4747
"description": "Timestamp when the message was posted"
4848
},
49+
"edited_at": {
50+
"type": "string",
51+
"format": "date-time",
52+
"description": "Timestamp when the message was last edited"
53+
},
54+
"editor_id": {
55+
"type": "string",
56+
"description": "UUID of the user who last edited the message"
57+
},
4958
"last_updated": {
5059
"type": "string",
5160
"format": "date-time",
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"$id": "https://cve.mitre.org/schema/conversation/update-conversation-response.json",
4+
"type": "object",
5+
"title": "Update Conversation Response",
6+
"description": "JSON Schema for the response when updating a conversation",
7+
"properties": {
8+
"message": {
9+
"type": "string",
10+
"description": "Response message"
11+
},
12+
"conversation": {
13+
"$ref": "conversation.json",
14+
"description": "The updated conversation message"
15+
}
16+
}
17+
}

src/controller/org.controller/index.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,6 +1078,81 @@ router.post('/registry/org/:shortname/user/:username/revoke-role',
10781078
registryUserController.REVOKE_ROLE
10791079
)
10801080

1081+
router.put('/registry/org/:shortname/conversation/:index',
1082+
/*
1083+
#swagger.tags = ['Registry Organization']
1084+
#swagger.operationId = 'registryUserUpdateConversation'
1085+
#swagger.summary = "Update the conversation at the given index for the given organization (accessible to Secretariat or Org Admin)"
1086+
#swagger.description = "
1087+
<h2>Access Control</h2>
1088+
<p>User must belong to an organization with the <b>Secretariat</b> role or be an <b>Admin</b> of the organization</p>
1089+
<h2>Expected Behavior</h2>
1090+
<p><b>Admin User:</b> Allowed to update only the message body of any conversation posted by them</p>
1091+
<p><b>Secretariat:</b> Allowed to update the message body and/or visibility of any conversation</p>"
1092+
#swagger.parameters['shortname'] = { description: 'The shortname of the organization' }
1093+
#swagger.parameters['index'] = { description: 'The index of the conversation to update' }
1094+
#swagger.parameters['$ref'] = [
1095+
'#/components/parameters/apiEntityHeader',
1096+
'#/components/parameters/apiUserHeader',
1097+
'#/components/parameters/apiSecretHeader'
1098+
]
1099+
#swagger.responses[200] = {
1100+
description: 'Returns the updated conversation',
1101+
content: {
1102+
"application/json": {
1103+
schema: { $ref: '../schemas/conversation/update-conversation-response.json' }
1104+
}
1105+
}
1106+
}
1107+
#swagger.responses[400] = {
1108+
description: 'Bad Request',
1109+
content: {
1110+
"application/json": {
1111+
schema: { $ref: '../schemas/errors/bad-request.json' }
1112+
}
1113+
}
1114+
}
1115+
#swagger.responses[401] = {
1116+
description: 'Not Authenticated',
1117+
content: {
1118+
"application/json": {
1119+
schema: { $ref: '../schemas/errors/generic.json' }
1120+
}
1121+
}
1122+
}
1123+
#swagger.responses[403] = {
1124+
description: 'Forbidden',
1125+
content: {
1126+
"application/json": {
1127+
schema: { $ref: '../schemas/errors/generic.json' }
1128+
}
1129+
}
1130+
}
1131+
#swagger.responses[404] = {
1132+
description: 'Not Found',
1133+
content: {
1134+
"application/json": {
1135+
schema: { $ref: '../schemas/errors/generic.json' }
1136+
}
1137+
}
1138+
}
1139+
#swagger.responses[500] = {
1140+
description: 'Internal Server Error',
1141+
content: {
1142+
"application/json": {
1143+
schema: { $ref: '../schemas/errors/generic.json' }
1144+
}
1145+
}
1146+
}
1147+
*/
1148+
mw.useRegistry(),
1149+
mw.validateUser,
1150+
mw.onlyOrgWithPartnerRole,
1151+
parseError,
1152+
parsePostParams,
1153+
registryOrgController.EDIT_CONVERSATION
1154+
)
1155+
10811156
router.get('/org',
10821157
/*
10831158
#swagger.tags = ['Organization']

src/controller/registry-org.controller/error.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,27 @@ class RegistryOrgControllerError extends idrErr.IDRError {
9292
return err
9393
}
9494

95+
conversationDne (shortname, index) {
96+
const err = {}
97+
err.error = 'CONVERSATION_DNE'
98+
err.message = `The conversation at index ${index} does not exist for the ${shortname} organization.`
99+
return err
100+
}
101+
102+
notAllowedToEditConversation () {
103+
const err = {}
104+
err.error = 'NOT_ALLOWED_TO_EDIT_CONVERSATION'
105+
err.message = 'You must be the original author or Secretariat to edit this conversation.'
106+
return err
107+
}
108+
109+
notAllowedToChangeConversationVisibility () {
110+
const err = {}
111+
err.error = 'NOT_ALLOWED_TO_CHANGE_CONVERSATION_VISIBILITY'
112+
err.message = 'Only the Secretariat is allowed to change the visibility of a conversation.'
113+
return err
114+
}
115+
95116
invalidConversationObject () {
96117
const err = {}
97118
err.error = 'BAD_INPUT'

src/controller/registry-org.controller/registry-org.controller.js

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -594,12 +594,97 @@ async function createUserByOrg (req, res, next) {
594594
}
595595
}
596596

597+
/**
598+
* Updates the conversation at the provided index for the given organization.
599+
*
600+
* @async
601+
* @function editConversationForOrg
602+
* @param {object} req - The Express request object, containing the organization shortname in `req.ctx.params.shortname` and conversation updates in `req.ctx.body`.
603+
* @param {object} res - The Express response object.
604+
* @param {function} next - The next middleware function.
605+
* @returns {Promise<void>} - A promise that resolves when the response is sent.
606+
* @description User must be the original author of the conversation or the Secretariat role.
607+
* The original author is allowed to update the conversation message body.
608+
* Secretariat is allowed to update the conversation message body and visibility.
609+
* Called by PUT /api/registry/org/:shortname/conversation/:index
610+
*/
611+
async function editConversationForOrg (req, res, next) {
612+
const orgRepo = req.ctx.repositories.getBaseOrgRepository()
613+
const userRepo = req.ctx.repositories.getBaseUserRepository()
614+
const conversationRepo = req.ctx.repositories.getConversationRepository()
615+
const requesterUsername = req.ctx.user
616+
const orgShortName = req.ctx.params.shortname
617+
const index = req.params.index
618+
const incomingParameters = req.ctx.body
619+
let returnValue
620+
621+
const session = await mongoose.startSession({ causalConsistency: false })
622+
try {
623+
// Check if org exists
624+
const orgUUID = await orgRepo.getOrgUUID(orgShortName, {}, false)
625+
if (!orgUUID) {
626+
logger.info({ uuid: req.ctx.uuid, message: 'The conversation could not be edited because ' + orgShortName + ' organization does not exist.' })
627+
return res.status(404).json(error.orgDnePathParam(orgShortName))
628+
}
629+
630+
try {
631+
session.startTransaction()
632+
// Fetch conversation
633+
const conversation = await conversationRepo.findByTargetUUIDAndIndex(orgUUID, index, { session })
634+
if (!conversation) {
635+
logger.info({ uuid: req.ctx.uuid, message: `The conversation at index ${index} does not exist for the ${orgShortName} organization.` })
636+
return res.status(404).json(error.conversationDne(orgShortName, index))
637+
}
638+
639+
// Check if user has permissions to edit conversation
640+
const isSecretariat = await orgRepo.isSecretariatByShortName(req.ctx.org, { session })
641+
const userUUID = await userRepo.getUserUUID(requesterUsername, req.ctx.org, { session })
642+
if (conversation.author_id !== userUUID && !isSecretariat) {
643+
logger.info({ uuid: req.ctx.uuid, message: 'The user does not have permission to edit this conversation.' })
644+
return res.status(403).json(error.notAllowedToEditConversation())
645+
}
646+
647+
// Check if user has permission to change visibility of conversation
648+
if (incomingParameters.visibility && !isSecretariat) {
649+
logger.info({ uuid: req.ctx.uuid, message: 'Only the Secretariat is allowed to change the visibility of a conversation.' })
650+
return res.status(403).json(error.notAllowedToChangeConversationVisibility())
651+
}
652+
653+
// Make the edit
654+
returnValue = await conversationRepo.editConversation(conversation.UUID, incomingParameters, userUUID, { session })
655+
await session.commitTransaction()
656+
} catch (error) {
657+
await session.abortTransaction()
658+
throw error
659+
} finally {
660+
await session.endSession()
661+
}
662+
663+
const responseMessage = {
664+
message: 'The conversation was successfully updated.',
665+
updated: returnValue
666+
}
667+
668+
const payload = {
669+
action: 'update_org_conversation',
670+
change: `Conversation at index ${index} for org ${orgShortName} was successfully updated.`,
671+
req_UUID: req.ctx.uuid,
672+
org_UUID: orgUUID
673+
}
674+
logger.info(JSON.stringify(payload))
675+
return res.status(200).json(responseMessage)
676+
} catch (err) {
677+
next(err)
678+
}
679+
}
680+
597681
module.exports = {
598682
ALL_ORGS: getAllOrgs,
599683
SINGLE_ORG: getOrg,
600684
CREATE_ORG: createOrg,
601685
UPDATE_ORG: updateOrg,
602686
DELETE_ORG: deleteOrg,
603687
USER_ALL: getUsers,
604-
USER_CREATE_SINGLE: createUserByOrg
688+
USER_CREATE_SINGLE: createUserByOrg,
689+
EDIT_CONVERSATION: editConversationForOrg
605690
}

src/model/conversation.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ const schema = {
1212
author_role: String,
1313
visibility: String,
1414
body: String,
15-
posted_at: Date
15+
posted_at: Date,
16+
edited_at: Date,
17+
editor_id: String
1618
}
1719

1820
const ConversationSchema = new mongoose.Schema(schema, { collection: 'Conversation', timestamps: { createdAt: 'posted_at', updatedAt: 'last_updated' } })

src/repositories/conversationRepository.js

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,27 @@ class ConversationRepository extends BaseRepository {
3636
}
3737

3838
async getAllByTargetUUID (targetUUID, isSecretariat, options = {}) {
39-
const conversations = await ConversationModel.find({ target_uuid: targetUUID }, null, options)
39+
const conversations = await ConversationModel.find({ target_uuid: targetUUID }, null, {
40+
...options,
41+
sort: {
42+
posted_at: 1,
43+
UUID: 1
44+
}
45+
})
4046
return conversations.map(convo => convo.toObject()).filter(conv => isSecretariat || conv.visibility === 'public')
4147
}
4248

49+
async findByTargetUUIDAndIndex (targetUUID, index, options = {}) {
50+
const conversation = await ConversationModel.find({ target_uuid: targetUUID }, null, {
51+
...options,
52+
sort: {
53+
posted_at: 1,
54+
UUID: 1
55+
}
56+
}).skip(index).limit(1)
57+
return conversation[0]
58+
}
59+
4360
async createConversation (targetUUID, body, user, isSecretariat, options = {}) {
4461
const { getUserFullName } = require('../utils/utils')
4562
const newUUID = uuid.v4()
@@ -57,13 +74,29 @@ class ConversationRepository extends BaseRepository {
5774
author_id: user.UUID,
5875
author_name: getUserFullName(user),
5976
author_role: isSecretariat ? 'Secretariat' : 'Partner',
77+
editor_id: null,
78+
edited_at: null,
6079
visibility: !isSecretariat ? 'public' : (['public', 'private'].includes(body.visibility?.toLowerCase()) ? body.visibility.toLowerCase() : 'private'),
6180
body: body.body
6281
}
6382
const newConversation = new ConversationModel(conversationObj)
6483
const result = await newConversation.save(options)
6584
return result.toObject()
6685
}
86+
87+
async editConversation (UUID, incomingParameters, userUUID, options = {}) {
88+
const conversation = await this.findOneByUUID(UUID, options)
89+
if (incomingParameters?.body) {
90+
conversation.body = incomingParameters.body
91+
}
92+
if (incomingParameters?.visibility) {
93+
conversation.visibility = incomingParameters.visibility
94+
}
95+
conversation.editor_id = userUUID
96+
conversation.edited_at = Date.now()
97+
const result = await conversation.save(options)
98+
return result.toObject()
99+
}
67100
}
68101

69102
module.exports = ConversationRepository

0 commit comments

Comments
 (0)