diff --git a/migrations/20261005-01-synchronize-org-disabled.js b/migrations/20261005-01-synchronize-org-disabled.js new file mode 100644 index 000000000..6e465b9db --- /dev/null +++ b/migrations/20261005-01-synchronize-org-disabled.js @@ -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. + } +} diff --git a/schemas/registry-org/BaseOrg.json b/schemas/registry-org/BaseOrg.json index 778ec22c5..c6f350063 100644 --- a/schemas/registry-org/BaseOrg.json +++ b/schemas/registry-org/BaseOrg.json @@ -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" diff --git a/schemas/registry-org/create-registry-org-request.json b/schemas/registry-org/create-registry-org-request.json index 11a5137b6..3e37d1d25 100644 --- a/schemas/registry-org/create-registry-org-request.json +++ b/schemas/registry-org/create-registry-org-request.json @@ -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", diff --git a/schemas/registry-org/update-registry-org-request.json b/schemas/registry-org/update-registry-org-request.json index 6d88c9071..f7dc19322 100644 --- a/schemas/registry-org/update-registry-org-request.json +++ b/schemas/registry-org/update-registry-org-request.json @@ -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", diff --git a/src/controller/registry.controller/org.registry.controller.js b/src/controller/registry.controller/org.registry.controller.js index 8c4cd6ac6..bb2228daf 100644 --- a/src/controller/registry.controller/org.registry.controller.js +++ b/src/controller/registry.controller/org.registry.controller.js @@ -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) { @@ -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 }) } diff --git a/src/controller/review-object.controller/review-object.controller.js b/src/controller/review-object.controller/review-object.controller.js index 27929802a..8d196900b 100644 --- a/src/controller/review-object.controller/review-object.controller.js +++ b/src/controller/review-object.controller/review-object.controller.js @@ -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). @@ -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 }) @@ -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() diff --git a/src/middleware/middleware.js b/src/middleware/middleware.js index a4c891449..08132bc3b 100644 --- a/src/middleware/middleware.js +++ b/src/middleware/middleware.js @@ -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()) @@ -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) @@ -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()) } @@ -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()) } diff --git a/src/model/baseorg.js b/src/model/baseorg.js index 86aa46a72..e652b3ef2 100644 --- a/src/model/baseorg.js +++ b/src/model/baseorg.js @@ -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, diff --git a/src/repositories/baseOrgRepository.js b/src/repositories/baseOrgRepository.js index 8a19e576c..5ca22dc3b 100644 --- a/src/repositories/baseOrgRepository.js +++ b/src/repositories/baseOrgRepository.js @@ -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'] @@ -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'] @@ -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 = {} @@ -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', @@ -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 @@ -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) { @@ -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') @@ -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 @@ -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 diff --git a/src/utils/orgDisabled.js b/src/utils/orgDisabled.js new file mode 100644 index 000000000..8f5d2f7f6 --- /dev/null +++ b/src/utils/orgDisabled.js @@ -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 } diff --git a/test/integration-tests/middleware/authenticatedContextTest.js b/test/integration-tests/middleware/authenticatedContextTest.js index 3cf908765..fccf7577e 100644 --- a/test/integration-tests/middleware/authenticatedContextTest.js +++ b/test/integration-tests/middleware/authenticatedContextTest.js @@ -48,8 +48,8 @@ describe('Authenticated request context middleware integration', () => { } class AmbiguousOrgRepo { - async getOrgUUID () { - return authenticatedOrg.UUID + async findOneByShortName () { + return { ...authenticatedOrg, disabled: false } } async findOneByUUID (orgUUID) { @@ -119,8 +119,8 @@ describe('Authenticated request context middleware integration', () => { } class BaseOrgRepo { - async getOrgUUID () { - return authenticatedOrg.UUID + async findOneByShortName () { + return { ...authenticatedOrg, disabled: false } } async findOneByUUID (orgUUID) { @@ -252,6 +252,7 @@ describe('Authenticated request context middleware integration', () => { await cleanupDuplicateShortNameFixtures() await BaseOrg.collection.insertOne({ + disabled: false, UUID: authenticatedOrgUUID, long_name: 'Authenticated Duplicate Short Name CNA', short_name: duplicateShortName, diff --git a/test/integration-tests/registry-org/orgDisabledTest.js b/test/integration-tests/registry-org/orgDisabledTest.js new file mode 100644 index 000000000..14474893c --- /dev/null +++ b/test/integration-tests/registry-org/orgDisabledTest.js @@ -0,0 +1,283 @@ +/* eslint-disable no-unused-expressions */ +const chai = require('chai') +chai.use(require('chai-http')) +const expect = chai.expect +const mongoose = require('mongoose') +const { v4: uuid } = require('uuid') +const app = require('../../../src/index') +const BaseOrg = require('../../../src/model/baseorg') +const Org = require('../../../src/model/org') +const BaseUser = require('../../../src/model/baseuser') +const User = require('../../../src/model/user') +const CveId = require('../../../src/model/cve-id') +const BaseOrgRepository = require('../../../src/repositories/baseOrgRepository') +const ReviewObjectRepository = require('../../../src/repositories/reviewObjectRepository') +const { headers } = require('../constants') +const migration = require('../../../migrations/20261005-01-synchronize-org-disabled') + +describe('Organization disabled state', () => { + let orgs + let users + const cveId = 'CVE-2099-98765' + + beforeEach(() => { + orgs = [] + users = [] + }) + + afterEach(async () => { + await CveId.deleteMany({ cve_id: cveId }) + for (const org of orgs) { + const res = await chai.request(app).delete(`/api/registry/org/${org.short_name}`).set(headers) + expect(res).to.have.status(200) + } + await BaseUser.deleteMany({ UUID: { $in: users } }) + await User.deleteMany({ UUID: { $in: users } }) + }) + + async function createOrg (overrides = {}) { + const body = { short_name: `disabled_${uuid().slice(0, 8)}`, long_name: uuid(), authority: ['CNA'], id_quota: 100, ...overrides } + const res = await chai.request(app).post('/api/registry/org').set(headers).send(body) + expect(res).to.have.status(200) + const org = { ...body, UUID: res.body.created.UUID, disabled: res.body.created.disabled } + orgs.push(org) + return org + } + + async function updateOrg (org, changes = {}, omitted = []) { + const body = { ...org, ...changes } + for (const key of omitted) delete body[key] + return chai.request(app).put(`/api/registry/org/${org.short_name}`).set(headers).send(body) + } + + async function createUser (org, role) { + const res = await chai.request(app).post(`/api/registry/org/${org.short_name}/user`).set(headers).send({ + username: `user_${uuid().slice(0, 8)}`, name: { first: 'Disabled', last: 'Test' }, status: 'active', ...(role ? { role } : {}) + }) + expect(res).to.have.status(200) + users.push(res.body.created.UUID) + return { + ...headers, + 'CVE-API-ORG': org.short_name, + 'CVE-API-USER': res.body.created.username, + 'CVE-API-KEY': res.body.created.secret + } + } + + async function expectState (org, disabled, roles = org.authority, type = 'CNAOrg') { + const res = await chai.request(app).get(`/api/registry/org/${org.short_name}`).set(headers) + expect(res).to.have.status(200) + expect(res.body.disabled).to.equal(disabled) + expect(res.body.authority).to.deep.equal(roles) + const registry = await BaseOrg.findOne({ UUID: org.UUID }).read('primary').lean() + const legacy = await Org.findOne({ UUID: org.UUID }).read('primary').lean() + expect(registry.__t).to.equal(type) + expect(legacy.authority.active_roles).to.deep.equal(disabled ? [] : roles) + expect(legacy).to.not.have.property('disabled') + } + + // Exercise the legacy repository path while using registry endpoints for org fixtures. + async function legacyUpdate (org, parameters) { + const session = await mongoose.startSession({ causalConsistency: false }) + try { + session.startTransaction({ readPreference: 'primary' }) + await new BaseOrgRepository().updateOrg(org.short_name, parameters, { session }, true, null, false, true) + await session.commitTransaction() + } catch (err) { + await session.abortTransaction() + throw err + } finally { + await session.endSession() + } + } + + it('creates ordinary orgs disabled by default with no legacy active roles', async () => { + const org = await createOrg() + await expectState(org, true) + }) + + it('preserves the legacy zero quota when explicitly creating a disabled ADP', async () => { + const org = await createOrg({ authority: ['ADP'], disabled: true, id_quota: 0 }) + await expectState(org, true, ['ADP'], 'ADPOrg') + const legacy = await Org.findOne({ UUID: org.UUID }).read('primary').lean() + expect(legacy.policies.id_quota).to.equal(0) + }) + + it('defaults a missing ordinary org flag to disabled during authentication', async () => { + const org = await createOrg({ disabled: false }) + const auth = await createUser(org) + await BaseOrg.collection.updateOne({ UUID: org.UUID }, { $unset: { disabled: '' } }) + expect(await chai.request(app).get(`/api/registry/org/${org.short_name}`).set(auth)).to.have.status(401) + }) + + it('blocks users and admins on disable and restores access with the same keys on enable', async () => { + const org = await createOrg({ disabled: false }) + const userHeaders = await createUser(org) + const adminHeaders = await createUser(org, 'ADMIN') + for (const auth of [userHeaders, adminHeaders]) { + expect(await chai.request(app).get(`/api/registry/org/${org.short_name}`).set(auth)).to.have.status(200) + } + expect(await updateOrg(org, { disabled: true })).to.have.status(200) + await expectState(org, true) + for (const auth of [userHeaders, adminHeaders]) { + for (const path of [`/api/registry/org/${org.short_name}`, '/api/cve-id']) { + const res = await chai.request(app).get(path).set(auth) + expect(res).to.have.status(401) + expect(res.body).to.deep.equal({ error: 'UNAUTHORIZED', message: 'Unauthorized' }) + } + } + expect(await updateOrg(org, { disabled: false })).to.have.status(200) + await expectState(org, false) + for (const auth of [userHeaders, adminHeaders]) { + expect(await chai.request(app).get(`/api/registry/org/${org.short_name}`).set(auth)).to.have.status(200) + } + }) + + it('returns only public CVE-ID data for disabled credentials', async () => { + const org = await createOrg({ disabled: false }) + const auth = await createUser(org) + await CveId.collection.insertOne({ + cve_id: cveId, + cve_year: '2099', + state: 'RESERVED', + owning_cna: org.UUID, + requested_by: { cna: org.UUID, user: users[0] }, + reserved: new Date() + }) + const enabled = await chai.request(app).get(`/api/cve-id/${cveId}`).set(auth) + expect(enabled).to.have.status(200) + expect(enabled.body).to.have.property('requested_by') + expect(await updateOrg(org, { disabled: true })).to.have.status(200) + const disabled = await chai.request(app).get(`/api/cve-id/${cveId}`).set(auth) + const anonymous = await chai.request(app).get(`/api/cve-id/${cveId}`) + expect(disabled).to.have.status(200) + expect(disabled.body).to.deep.equal(anonymous.body) + expect(disabled.body.owning_cna).to.equal('[REDACTED]') + expect(disabled.body).to.not.have.property('requested_by') + }) + + for (const disabled of [true, false]) { + it(`preserves disabled=${disabled} when registry and legacy name updates omit it`, async () => { + const org = await createOrg({ disabled }) + expect(await updateOrg(org, { long_name: 'Updated disabled test org' }, ['disabled'])).to.have.status(200) + await expectState(org, disabled) + await legacyUpdate(org, { name: 'Another name update' }) + await expectState(org, disabled) + }) + } + + it('retains registry type on legacy role removal and changes type when roles are added back', async () => { + const org = await createOrg({ disabled: false }) + await legacyUpdate(org, { active_roles: { remove: ['CNA'] } }) + await expectState(org, true) + await legacyUpdate(org, { name: 'Still disabled' }) + await expectState(org, true) + await legacyUpdate(org, { active_roles: { add: ['ADP'] } }) + await expectState(org, false, ['ADP'], 'ADPOrg') + }) + + it('keeps legacy roles empty when a disabled registry org changes type', async () => { + const org = await createOrg() + expect(await updateOrg(org, { authority: ['ADP'] })).to.have.status(200) + await expectState(org, true, ['ADP'], 'ADPOrg') + expect(await updateOrg(org, { authority: ['ADP'], disabled: false })).to.have.status(200) + await expectState(org, false, ['ADP'], 'ADPOrg') + }) + + it('creates Secretariat orgs enabled and rejects both ways of disabling them', async () => { + const org = await createOrg({ authority: ['SECRETARIAT'] }) + await expectState(org, false, ['SECRETARIAT'], 'SecretariatOrg') + const res = await updateOrg(org, { disabled: true }) + expect(res).to.have.status(400) + expect(res.body.message).to.equal('Secretariat organizations cannot be disabled.') + let rejection + try { + await legacyUpdate(org, { active_roles: { remove: ['SECRETARIAT'] } }) + } catch (err) { rejection = err } + expect(rejection?.message).to.equal('Secretariat organizations cannot be disabled.') + await expectState(org, false, ['SECRETARIAT'], 'SecretariatOrg') + }) + + it('rejects creating a disabled Secretariat org', async () => { + const shortName = `secretariat_${uuid().slice(0, 8)}` + const res = await chai.request(app).post('/api/registry/org').set(headers).send({ + short_name: shortName, long_name: shortName, authority: ['SECRETARIAT'], id_quota: 100, disabled: true + }) + expect(res).to.have.status(400) + expect(res.body.message).to.equal('Secretariat organizations cannot be disabled.') + expect(await BaseOrg.findOne({ short_name: shortName }).read('primary')).to.equal(null) + expect(await Org.findOne({ short_name: shortName }).read('primary')).to.equal(null) + }) + + it('requires enabling a disabled org when promoting it to Secretariat', async () => { + const org = await createOrg() + expect(await updateOrg(org, { authority: ['SECRETARIAT'] }, ['disabled'])).to.have.status(400) + await expectState(org, true) + expect(await updateOrg(org, { authority: ['SECRETARIAT'], disabled: false })).to.have.status(200) + await expectState(org, false, ['SECRETARIAT'], 'SecretariatOrg') + }) + + it('rejects disabling Secretariat while changing its authority in the same request', async () => { + const org = await createOrg({ authority: ['SECRETARIAT'] }) + expect(await updateOrg(org, { authority: ['CNA'], disabled: true })).to.have.status(400) + await expectState(org, false, ['SECRETARIAT'], 'SecretariatOrg') + }) + + it('allows Secretariat authentication even if an old stored flag is true', async () => { + const org = await createOrg({ authority: ['SECRETARIAT'] }) + const auth = await createUser(org) + await BaseOrg.collection.updateOne({ UUID: org.UUID }, { $set: { disabled: true } }) + expect(await chai.request(app).get('/api/registry/org').set(auth)).to.have.status(200) + // Verify optional authentication directly using an owned reserved ID. + await CveId.collection.insertOne({ cve_id: cveId, cve_year: '2099', state: 'RESERVED', owning_cna: org.UUID, requested_by: { cna: org.UUID, user: users[0] } }) + const owned = await chai.request(app).get(`/api/cve-id/${cveId}`).set(auth) + expect(owned).to.have.status(200) + expect(owned.body).to.have.property('requested_by') + }) + + it('does not re-enable an org when approving an older review snapshot', async () => { + const org = await createOrg({ disabled: true }) + const review = await new ReviewObjectRepository().createReviewOrgObject({ ...org, disabled: false, long_name: 'Reviewed name' }, headers['CVE-API-USER']) + const res = await chai.request(app).put(`/api/review/${review.uuid}/approve`).set(headers).send({}) + expect(res).to.have.status(200) + await expectState(org, true) + }) + + it('rejects disabling Secretariat through review approval and rolls back the review', async () => { + const org = await createOrg({ authority: ['SECRETARIAT'] }) + const review = await new ReviewObjectRepository().createReviewOrgObject(org, headers['CVE-API-USER']) + const res = await chai.request(app).put(`/api/review/${review.uuid}/approve`).set(headers).send({ ...org, disabled: true }) + expect(res).to.have.status(400) + expect(res.body.message).to.equal('Secretariat organizations cannot be disabled.') + await expectState(org, false, ['SECRETARIAT'], 'SecretariatOrg') + const storedReview = await new ReviewObjectRepository().findOneByUUIDWithConversation(review.uuid, true) + expect(storedReview.status).to.equal('pending') + }) + + it('migrates inconsistent flags and legacy roles without affecting registry types', async () => { + const disabled = await createOrg() + const enabled = await createOrg({ disabled: false }) + const secretariat = await createOrg({ authority: ['SECRETARIAT'] }) + await Org.updateOne({ UUID: disabled.UUID }, { $set: { 'authority.active_roles': ['CNA'] } }) + await Org.updateOne({ UUID: enabled.UUID }, { $set: { 'authority.active_roles': [] } }) + await BaseOrg.collection.updateOne({ UUID: secretariat.UUID }, { $set: { disabled: true } }) + await Org.updateOne({ UUID: secretariat.UUID }, { $set: { 'authority.active_roles': [] } }) + // Restrict the production migration to these fixtures, using real collections. + const scopedDb = { + collection (name) { + const collection = mongoose.connection.db.collection(name) + const scope = filter => ({ ...filter, UUID: { $in: orgs.map(org => org.UUID) } }) + return { + updateMany: (filter, update) => collection.updateMany(scope(filter), update), + find: (filter, options) => collection.find(scope(filter), options), + updateOne: (...args) => collection.updateOne(...args) + } + } + } + await migration.up(scopedDb) + await migration.up(scopedDb) + await expectState(disabled, true) + await expectState(enabled, false) + await expectState(secretariat, false, ['SECRETARIAT'], 'SecretariatOrg') + }) +}) diff --git a/test/integration-tests/registry-org/registryOrgCRUDTest.js b/test/integration-tests/registry-org/registryOrgCRUDTest.js index 207fe27be..14ebb2e83 100644 --- a/test/integration-tests/registry-org/registryOrgCRUDTest.js +++ b/test/integration-tests/registry-org/registryOrgCRUDTest.js @@ -925,6 +925,7 @@ describe('Testing /registry/org endpoints', () => { .send({ ...tempOrg, new_short_name: 'temp_org_updated_name', + disabled: false, authority: ['SECRETARIAT'] }) .then((res, err) => { diff --git a/test/integration-tests/registry-org/registryOrgWithJointReviewTest.js b/test/integration-tests/registry-org/registryOrgWithJointReviewTest.js index 6ff18ccdd..561992dee 100644 --- a/test/integration-tests/registry-org/registryOrgWithJointReviewTest.js +++ b/test/integration-tests/registry-org/registryOrgWithJointReviewTest.js @@ -132,7 +132,7 @@ describe('Testing Joint approval', () => { await chai.request(app) .post('/api/registry/org') .set(secretariatHeaders) - .send(testRegistryOrgForReview) + .send({ ...testRegistryOrgForReview, disabled: false }) .then((res, err) => { expect(err).to.be.undefined expect(res).to.have.status(200) @@ -246,7 +246,7 @@ describe('Testing Joint approval', () => { await chai.request(app) .post('/api/registry/org') .set(secretariatHeaders) - .send(testRegistryOrgForReviewWithComments) + .send({ ...testRegistryOrgForReviewWithComments, disabled: false }) .then((res, err) => { expect(err).to.be.undefined expect(res).to.have.status(200) @@ -402,7 +402,7 @@ describe('Testing Joint approval', () => { await chai.request(app) .post('/api/registry/org') .set(secretariatHeaders) - .send(testRegistryOrgForAdvisoryReview) + .send({ ...testRegistryOrgForAdvisoryReview, disabled: false }) .then((res, err) => { expect(err).to.be.undefined expect(res).to.have.status(200) @@ -477,7 +477,7 @@ describe('Testing Joint approval', () => { await chai.request(app) .post('/api/registry/org') .set(secretariatHeaders) - .send(testRegistryOrgForNewShortNameReview) + .send({ ...testRegistryOrgForNewShortNameReview, disabled: false }) .then((res, err) => { expect(err).to.be.undefined expect(res).to.have.status(200) diff --git a/test/integration-tests/registry-org/rootOrgTest.js b/test/integration-tests/registry-org/rootOrgTest.js index 80c61e7ae..b46b98af8 100644 --- a/test/integration-tests/registry-org/rootOrgTest.js +++ b/test/integration-tests/registry-org/rootOrgTest.js @@ -11,6 +11,7 @@ const secretariatHeaders = { ...constants.headers, 'content-type': 'application/ let rootAdminHeaders const testRootOrg = { + disabled: false, short_name: 'root_org_test_4', long_name: 'Root Org Test', authority: ['ROOT'], @@ -117,6 +118,7 @@ describe('Testing ROOT Organization Type', () => { .set(secretariatHeaders) .send({ short_name: 'reporting_org_for_root', + disabled: false, long_name: 'Reporting Organization', authority: ['CNA'], id_quota: 100 diff --git a/test/unit-tests/middleware/validateUserTest.js b/test/unit-tests/middleware/validateUserTest.js index b470b633c..8f5050c9b 100644 --- a/test/unit-tests/middleware/validateUserTest.js +++ b/test/unit-tests/middleware/validateUserTest.js @@ -19,8 +19,8 @@ const cveId5 = 'CVE-2017-4024' const cvePass5 = require('../../schemas/5.0/' + cveId5 + '_published.json') class OrgValidateUserSuccess { - async getOrgUUID () { - return mwFixtures.existentOrg.UUID + async findOneByShortName () { + return { ...mwFixtures.existentOrg, disabled: false } } }