Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions migrations/20261005-01-synchronize-org-disabled.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
module.exports = {
async up (db) {
const registryOrgs = db.collection('BaseOrg')
const legacyOrgs = db.collection('Org')

await registryOrgs.updateMany(
{ authority: 'SECRETARIAT' },
{ $set: { disabled: false } }
)

const orgs = registryOrgs.find({ disabled: { $type: 'bool' } }, {
projection: { UUID: 1, authority: 1, disabled: 1 },
readPreference: 'primary'
})
for await (const org of orgs) {
if (!org.UUID || !Array.isArray(org.authority)) continue
await legacyOrgs.updateOne(
{ UUID: org.UUID },
{ $set: { 'authority.active_roles': org.disabled ? [] : org.authority } }
)
}
},

async down () {
// Previous roles and Secretariat disabled values cannot be safely recovered.
}
}
2 changes: 1 addition & 1 deletion schemas/registry-org/BaseOrg.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
"disabled": {
"type": "boolean",
"default": true,
"description": "Indicates whether the organization is disabled."
"description": "Blocks authenticated access when true while retaining the registry authority and organization type. Defaults to true for ordinary organizations; Secretariat organizations are always enabled."
},
"new_short_name": {
"$ref": "#/definitions/shortName"
Expand Down
2 changes: 1 addition & 1 deletion schemas/registry-org/create-registry-org-request.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"disabled": {
"type": "boolean",
"default": true,
"description": "Indicates whether the organization is disabled. This field can only be modified by the Secretariat."
"description": "Blocks authenticated access when true. Only the Secretariat can supply this field. Ordinary organizations default to disabled; Secretariat organizations are always enabled and reject true."
},
"short_name": {
"type": "string",
Expand Down
2 changes: 1 addition & 1 deletion schemas/registry-org/update-registry-org-request.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
},
"disabled": {
"type": "boolean",
"description": "Indicates whether the organization is disabled. This field can only be modified by the Secretariat."
"description": "Blocks authenticated access when true. Only the Secretariat can supply this field. Omission preserves the stored value. Secretariat organizations cannot be disabled."
},
"short_name": {
"type": "string",
Expand Down
4 changes: 4 additions & 0 deletions src/controller/registry.controller/org.registry.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const conversationErrors = require('../conversation.controller/error')
const convoError = new conversationErrors.ConversationControllerError()
const validateUUID = require('uuid').validate
const authContext = require('../../utils/authContext')
const { DisabledSecretariatError } = require('../../utils/orgDisabled')
const { REGISTRY_FORMAT } = require('../format.constants')

function addUUIDsToSet (uuidSet, values) {
Expand Down Expand Up @@ -395,6 +396,9 @@ async function createOrg (req, res, next) {
await session.commitTransaction()
} catch (createErr) {
await session.abortTransaction()
if (createErr instanceof DisabledSecretariatError) {
return res.status(400).json({ error: 'BAD_INPUT', message: createErr.message })
}
if (createErr.message && createErr.message.includes('Unknown Org type requested')) {
return res.status(400).json({ message: createErr.message })
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const errors = require('./error')
const error = new errors.ReviewObjectControllerError()
const _ = require('lodash')
const authContext = require('../../utils/authContext')
const { DisabledSecretariatError } = require('../../utils/orgDisabled')

/**
* Retrieves the PENDING review object for an organization by identifier (short_name or UUID).
Expand Down Expand Up @@ -115,6 +116,8 @@ async function approveReviewObject (req, res, next) {
const dataToUpdate = (body && Object.keys(body).length)
? _.merge({}, org.toObject(), body)
: reviewObject.new_review_data
// Review snapshots must not undo a later disable/enable operation.
if (!Object.hasOwn(body || {}, 'disabled')) dataToUpdate.disabled = org.disabled

const requestingUserUUID = await authContext.getRequesterUserUUID(req, userRepo, baseOrgRepo, { session })

Expand All @@ -132,6 +135,9 @@ async function approveReviewObject (req, res, next) {
await session.commitTransaction()
} catch (updateErr) {
await session.abortTransaction()
if (updateErr instanceof DisabledSecretariatError) {
return res.status(400).json({ error: 'BAD_INPUT', message: updateErr.message })
}
return res.status(500).json({ message: updateErr.message || 'Failed to approve review object' })
} finally {
await session.endSession()
Expand Down
16 changes: 11 additions & 5 deletions src/middleware/middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const error = new errors.MiddlewareError()
const RepositoryFactory = require('../repositories/repositoryFactory')
const rateLimit = require('express-rate-limit')
const authContext = require('../utils/authContext')
const { isOrgDisabled, DisabledSecretariatError } = require('../utils/orgDisabled')
const validatorPromise = import('@cveproject/cve-validation-library')
.then(({ Validate }) => new Validate())

Expand Down Expand Up @@ -60,8 +61,9 @@ async function optionallyValidateUser (req, res, next) {
let result = null

logger.info({ uuid: req.ctx.uuid, message: 'Authenticating user: ' + user }) // userUUID may be null if user does not exist
orgUUID = await orgRepo.getOrgUUID(org)
if (!orgUUID) {
const requestingOrg = await orgRepo.findOneByShortName(org, { readPreference: 'primary' }, false, { UUID: 1, disabled: 1, authority: 1 })
orgUUID = requestingOrg?.UUID
if (!orgUUID || isOrgDisabled(requestingOrg)) {
authenticated = false
} else {
result = await userRepo.findOneByUserNameAndOrgUUID(user, orgUUID)
Expand Down Expand Up @@ -124,9 +126,10 @@ async function validateUser (req, res, next) {
}

logger.info({ uuid: req.ctx.uuid, message: 'Authenticating user: ' + user }) // userUUID may be null if user does not exist
const orgUUID = await orgRepo.getOrgUUID(org)
if (!orgUUID) {
logger.info({ uuid: req.ctx.uuid, message: org + ' organization does not exist. User authentication FAILED for ' + user })
const requestingOrg = await orgRepo.findOneByShortName(org, { readPreference: 'primary' }, false, { UUID: 1, disabled: 1, authority: 1 })
const orgUUID = requestingOrg?.UUID
if (!orgUUID || isOrgDisabled(requestingOrg)) {
logger.info({ uuid: req.ctx.uuid, message: org + ' organization does not exist or is disabled. User authentication FAILED for ' + user })
return res.status(401).json(error.unauthorized())
}

Expand Down Expand Up @@ -425,6 +428,9 @@ function trimJSONWhitespace (req, res, next) {
}

function errorHandler (err, req, res, next) {
if (err instanceof DisabledSecretariatError) {
return res.status(400).json({ error: 'BAD_INPUT', message: err.message })
}
logger.error(JSON.stringify({ error: err.stack }))
return res.status(500).json(error.serviceNotAvailable())
}
Expand Down
9 changes: 8 additions & 1 deletion src/model/baseorg.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@ const schema = {
UUID: String,
long_name: String,
short_name: String,
disabled: { type: Boolean, default: true },
disabled: {
type: Boolean,
default: function () { return !this.authority?.includes('SECRETARIAT') },
validate: {
validator: function (value) { return value !== true || !this.authority?.includes('SECRETARIAT') },
message: 'Secretariat organizations cannot be disabled.'
}
},
aliases: [String],
authority: [String],
top_level_root: String,
Expand Down
73 changes: 28 additions & 45 deletions src/repositories/baseOrgRepository.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const {
handleAuthorityModelChange
} = require('./baseOrgRepositoryHelpers')
const { normalizeOrgCveWebsiteUpdateDate } = require('../utils/dateOnly')
const { isSecretariatOrg, assertCanSetDisabled, synchronizeOrgDisabled } = require('../utils/orgDisabled')
const RegistryOrgResponseSchema = require('../../schemas/registry-org/get-registry-org-response.json')

const INTERNAL_UNDERSCORE_FIELDS = ['_id', '__t', '__v']
Expand Down Expand Up @@ -1029,6 +1030,9 @@ class BaseOrgRepository extends BaseRepository {
registryObjectRaw.authority = ['CNA']
}

assertCanSetDisabled(registryObjectRaw, registryObjectRaw.disabled)
registryObjectRaw.disabled = isSecretariatOrg(registryObjectRaw) ? false : (registryObjectRaw.disabled ?? true)

if (!legacyObjectRaw.authority?.active_roles) {
legacyObjectRaw.authority = {
active_roles: ['CNA']
Expand Down Expand Up @@ -1143,14 +1147,16 @@ class BaseOrgRepository extends BaseRepository {
_.set(legacyObjectRaw, 'policies.id_quota', CONSTANTS.DEFAULT_ID_QUOTA)
}
if (
legacyObjectRaw.authority.active_roles.length === 1 && (
legacyObjectRaw.authority.active_roles[0] === 'ADP' ||
legacyObjectRaw.authority.active_roles[0] === 'BULK_DOWNLOAD')
registryObjectRaw.authority.length === 1 && (
registryObjectRaw.authority[0] === 'ADP' ||
registryObjectRaw.authority[0] === 'BULK_DOWNLOAD')
) {
// ADPs have quota of 0
_.set(legacyObjectRaw, 'policies.id_quota', 0)
}

synchronizeOrgDisabled(registryObjectRaw, legacyObjectRaw)

// The legacy way of doing this, the way this is written under the hood there is no other way
// This await does not return a value, even though there is a return in it. :shrugg:
let postUpdate = {}
Expand Down Expand Up @@ -1237,45 +1243,16 @@ class BaseOrgRepository extends BaseRepository {
const rolesToRemove = _.flattenDeep(_.compact(_.get(incomingParameters, 'active_roles.remove'))).filter(role => getConstants().ORG_ROLES.includes(role))
const initialRoles = legacyOrg.authority?.active_roles ?? []
const finalRoles = [...new Set([...initialRoles, ...rolesToAdd])].filter(role => !rolesToRemove.includes(role))

let roleChange = false
// Check if final roles match the original roles in the registry org
if (!_.isEqual(finalRoles.sort(), registryOrg.authority.sort())) {
roleChange = true
}

// Update authority and discriminator based on role changes
registryOrg.authority = finalRoles
// Determine the target model based on the new authority
let TargetModel = null
if (finalRoles.includes('SECRETARIAT')) {
TargetModel = SecretariatOrgModel
} else if (finalRoles.includes('CNA')) {
TargetModel = CNAOrgModel
} else if (finalRoles.includes('ADP')) {
TargetModel = ADPOrgModel
} else if (finalRoles.includes('BULK_DOWNLOAD')) {
TargetModel = BulkDownloadModel
} else if (finalRoles.includes('ROOT')) {
TargetModel = RootOrgModel
}

// Save changes - handle possible model type change
if (TargetModel && roleChange) {
const oldId = registryOrg._id
// Remove the old document
await BaseOrgModel.deleteOne({ _id: oldId }, options)
// Create a new document of the correct type, preserving the UUID
const newDocData = registryOrg.toObject()
delete newDocData.__t
newDocData._id = oldId
const newDoc = new TargetModel(newDocData)
// Save the new document (validation will now use the correct schema)
await newDoc.save(options)
// Replace the reference so later code works with the newly saved document
registryOrg = newDoc
if (rolesToAdd.length || rolesToRemove.length) {
const disabled = finalRoles.length === 0
assertCanSetDisabled({ authority: finalRoles }, disabled, registryOrg)
registryOrg.disabled = disabled
// Empty legacy roles disable the org without changing its registry type.
if (!disabled) registryOrg.authority = finalRoles
}
_.set(legacyOrg, 'authority.active_roles', finalRoles)
if (isSecretariatOrg(registryOrg)) registryOrg.disabled = false
synchronizeOrgDisabled(registryOrg, legacyOrg)
registryOrg = await handleAuthorityModelChange(registryOrg, originalRegistryOrgObject.authority, options)

const directRegistryKeys = [
'top_level_root',
Expand Down Expand Up @@ -1444,12 +1421,16 @@ class BaseOrgRepository extends BaseRepository {

if (isLegacyObject) {
legacyObjectRaw = incomingOrgBody
registryObjectRaw = this.convertLegacyToRegistry(incomingOrgBody)
registryObjectRaw = this.convertLegacyToRegistry(incomingOrgBody, registryOrg)
} else {
registryObjectRaw = incomingOrgBody
legacyObjectRaw = this.convertRegistryToLegacy(incomingOrgBody)
}

// A full update that omits disabled must preserve the stored state.
registryObjectRaw.disabled = registryObjectRaw.disabled ?? (isSecretariatOrg(registryOrg) ? false : registryOrg.disabled)
assertCanSetDisabled(registryObjectRaw, registryObjectRaw.disabled, registryOrg)

handleShortNameUpdate(incomingOrg, registryObjectRaw, legacyObjectRaw)

const requestingUser = requestingUserUUID ? await userRepo.findUserByUUID(requestingUserUUID, executeOptions) : null
Expand All @@ -1461,6 +1442,7 @@ class BaseOrgRepository extends BaseRepository {
let { updatedRegistryOrg, updatedLegacyOrg } = await processJointApprovalAndMerge(
registryOrg, legacyOrg, registryObjectRaw, legacyObjectRaw, reviewObject, isSecretariat, executeOptions, requestingUsername, jointApprovalFieldsRegistry, jointApprovalFieldsLegacy
)
synchronizeOrgDisabled(updatedRegistryOrg, updatedLegacyOrg)

const conversationArray = []
if (conversation) {
Expand Down Expand Up @@ -1641,7 +1623,7 @@ class BaseOrgRepository extends BaseRepository {
* @param {object} legacyOrg - The legacy organization object.
* @returns {object} The converted registry organization object.
*/
convertLegacyToRegistry (legacyOrg) {
convertLegacyToRegistry (legacyOrg, existingRegistryOrg) {
let newRoles = []
if (legacyOrg?.authority?.active_roles?.includes('SECRETARIAT')) {
newRoles.push('SECRETARIAT')
Expand All @@ -1652,7 +1634,8 @@ class BaseOrgRepository extends BaseRepository {
long_name: legacyOrg?.name ?? null,
short_name: legacyOrg?.short_name ?? null,
UUID: legacyOrg?.UUID ?? null,
authority: newRoles || ['CNA'],
authority: newRoles?.length ? newRoles : (existingRegistryOrg?.authority || ['CNA']),
disabled: Array.isArray(newRoles) ? newRoles.length === 0 : (existingRegistryOrg?.disabled ?? false),
id_quota: legacyOrg?.policies?.id_quota ?? null,
created: legacyOrg?.time?.created ?? null,
last_updated: legacyOrg?.time?.modified ?? null
Expand All @@ -1671,7 +1654,7 @@ class BaseOrgRepository extends BaseRepository {
short_name: registryOrg?.short_name ?? null,
UUID: registryOrg?.UUID ?? null,
authority: {
active_roles: registryOrg?.authority || ['CNA']
active_roles: registryOrg?.disabled === true ? [] : (registryOrg?.authority || ['CNA'])
},
policies: {
id_quota: registryOrg?.id_quota ?? null
Expand Down
31 changes: 31 additions & 0 deletions src/utils/orgDisabled.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
class DisabledSecretariatError extends Error {
constructor () {
super('Secretariat organizations cannot be disabled.')
this.name = 'DisabledSecretariatError'
}
}

function isSecretariatOrg (org) {
return Array.isArray(org?.authority) && org.authority.includes('SECRETARIAT')
}

function isOrgDisabled (org) {
return org?.disabled === true && !isSecretariatOrg(org)
}

function assertCanSetDisabled (org, disabled, originalOrg) {
if (disabled === true && (isSecretariatOrg(org) || isSecretariatOrg(originalOrg))) {
throw new DisabledSecretariatError()
}
}

// Call after resolving defaults and approved changes, before saving either record.
function synchronizeOrgDisabled (registryOrg, legacyOrg) {
assertCanSetDisabled(registryOrg, registryOrg.disabled)
if (isSecretariatOrg(registryOrg)) registryOrg.disabled = false
legacyOrg.authority = {
active_roles: registryOrg.disabled === true ? [] : [...registryOrg.authority]
}
}

module.exports = { DisabledSecretariatError, isSecretariatOrg, isOrgDisabled, assertCanSetDisabled, synchronizeOrgDisabled }
9 changes: 5 additions & 4 deletions test/integration-tests/middleware/authenticatedContextTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@
}

class AmbiguousOrgRepo {
async getOrgUUID () {
return authenticatedOrg.UUID
async findOneByShortName () {
return { ...authenticatedOrg, disabled: false }
}

async findOneByUUID (orgUUID) {
Expand Down Expand Up @@ -119,8 +119,8 @@
}

class BaseOrgRepo {
async getOrgUUID () {
return authenticatedOrg.UUID
async findOneByShortName () {
return { ...authenticatedOrg, disabled: false }
}

async findOneByUUID (orgUUID) {
Expand Down Expand Up @@ -247,11 +247,12 @@
await CveId.deleteMany({ cve_id: { $in: [authenticatedCveId, legacySecretariatCveId] } })
}

before(async function () {

Check warning on line 250 in test/integration-tests/middleware/authenticatedContextTest.js

View workflow job for this annotation

GitHub Actions / lint-test (24.x)

Unexpected use of Mocha `before` hook for a single test case
this.timeout(10000)
await cleanupDuplicateShortNameFixtures()

await BaseOrg.collection.insertOne({
disabled: false,
UUID: authenticatedOrgUUID,
long_name: 'Authenticated Duplicate Short Name CNA',
short_name: duplicateShortName,
Expand Down Expand Up @@ -342,7 +343,7 @@
])
})

after(async function () {

Check warning on line 346 in test/integration-tests/middleware/authenticatedContextTest.js

View workflow job for this annotation

GitHub Actions / lint-test (24.x)

Unexpected use of Mocha `after` hook for a single test case
this.timeout(10000)
await cleanupDuplicateShortNameFixtures()
})
Expand Down
Loading
Loading