diff --git a/app/api/definitions/openapi.yml b/app/api/definitions/openapi.yml index 1f7f41ff..e30bdf32 100644 --- a/app/api/definitions/openapi.yml +++ b/app/api/definitions/openapi.yml @@ -83,6 +83,8 @@ paths: /api/attack-objects: $ref: 'paths/attack-objects-paths.yml#/paths/~1api~1attack-objects' + /api/attack-objects/{stixId}/deprecation-check: + $ref: 'paths/attack-objects-paths.yml#/paths/~1api~1attack-objects~1{stixId}~1deprecation-check' # Generate next available ATT&CK ID /api/attack-objects/attack-id/next: $ref: 'paths/attack-objects-paths.yml#/paths/~1api~1attack-objects~1attack-id~1next' diff --git a/app/api/definitions/paths/assets-paths.yml b/app/api/definitions/paths/assets-paths.yml index 2cd8af83..07db698e 100644 --- a/app/api/definitions/paths/assets-paths.yml +++ b/app/api/definitions/paths/assets-paths.yml @@ -318,7 +318,7 @@ paths: type: string - name: preserveRelationships in: query - description: 'If true, relationships referencing the revoked object are cloned to point to the revoking object before deletion.' + description: 'Preserve active SROs and domain embedded references on the active latest replacement; retire original SROs as new revisions. Scalar conflicts or inactive referrers fail before writes; unexpected persistence failures are not transactional.' schema: type: boolean default: false @@ -351,4 +351,4 @@ paths: '404': description: 'The asset or revoking object was not found.' '409': - description: 'The asset is already revoked.' + description: 'Already revoked, inactive or stale replacement, embedded-reference conflict, inactive referrer, or concurrent graph write.' diff --git a/app/api/definitions/paths/attack-objects-paths.yml b/app/api/definitions/paths/attack-objects-paths.yml index ffb1e0e0..1196a0e9 100644 --- a/app/api/definitions/paths/attack-objects-paths.yml +++ b/app/api/definitions/paths/attack-objects-paths.yml @@ -1,4 +1,82 @@ paths: + /api/attack-objects/{stixId}/deprecation-check: + get: + summary: Check current relationship blockers before deprecation + operationId: attack-object-deprecation-check + description: | + Authenticated authoritative latest-STIX graph preflight. Active latest SROs other + than subtechnique-of and revoked-by, and current domain embedded references in + either direction, block SDO deprecation, including inbound references from inactive + objects. Existing subtechnique-of and revoked-by SROs are preserved unchanged in + either direction. Historical revisions, retired SROs, attribution, markings and + collection inventory are excluded. Explicit retirement of any SRO itself always + returns empty blockers, including the two preserved types. These deprecation + exemptions do not change new relationship authoring guards or revocation workflows. + A successful check is not a write + authorization: POST rechecks under shared graph-write serialization and returns + 409 with code deprecation_blocked and these same fields at the top level on conflict. + tags: + - ATT&CK Objects + parameters: + - name: stixId + in: path + required: true + schema: + type: string + responses: + '200': + description: Current deprecation eligibility and actionable references. + content: + application/json: + schema: + type: object + required: [stix_id, can_deprecate, blockers] + properties: + stix_id: + type: string + can_deprecate: + type: boolean + blockers: + type: object + required: [sros, embedded] + properties: + sros: + type: array + items: + type: object + required: [stix_id, modified, relationship_type, direction] + properties: + stix_id: + type: string + modified: + type: string + format: date-time + relationship_type: + type: string + direction: + type: string + enum: [inbound, outbound] + embedded: + type: array + items: + type: object + required: [source_ref, target_ref, path, direction] + properties: + source_ref: + type: string + target_ref: + type: string + path: + type: string + description: STIX-relative property path, including array indices. + direction: + type: string + enum: [inbound, outbound] + '401': + description: Authentication required. + '404': + description: No object with this STIX ID exists. + /api/attack-objects: get: summary: 'Get a list of all ATT&CK objects' diff --git a/app/api/definitions/paths/campaigns-paths.yml b/app/api/definitions/paths/campaigns-paths.yml index e4d5af4f..2c701aee 100644 --- a/app/api/definitions/paths/campaigns-paths.yml +++ b/app/api/definitions/paths/campaigns-paths.yml @@ -298,7 +298,7 @@ paths: type: string - name: preserveRelationships in: query - description: 'If true, relationships referencing the revoked object are cloned to point to the revoking object before deletion.' + description: 'Preserve active SROs and domain embedded references on the active latest replacement; retire original SROs as new revisions. Scalar conflicts or inactive referrers fail before writes; unexpected persistence failures are not transactional.' schema: type: boolean default: false @@ -331,4 +331,4 @@ paths: '404': description: 'The campaign or revoking object was not found.' '409': - description: 'The campaign is already revoked.' + description: 'Already revoked, inactive or stale replacement, embedded-reference conflict, inactive referrer, or concurrent graph write.' diff --git a/app/api/definitions/paths/data-components-paths.yml b/app/api/definitions/paths/data-components-paths.yml index d005e6de..ea1b7570 100644 --- a/app/api/definitions/paths/data-components-paths.yml +++ b/app/api/definitions/paths/data-components-paths.yml @@ -364,7 +364,7 @@ paths: type: string - name: preserveRelationships in: query - description: 'If true, relationships referencing the revoked object are cloned to point to the revoking object before deletion.' + description: 'Preserve active SROs and domain embedded references on the active latest replacement; retire original SROs as new revisions. Incoming analytic log-source name/channel values and replacement references are retained. Scalar conflicts or inactive referrers fail before writes; unexpected persistence failures are not transactional.' schema: type: boolean default: false @@ -397,4 +397,4 @@ paths: '404': description: 'The data component or revoking object was not found.' '409': - description: 'The data component is already revoked.' + description: 'Already revoked, inactive or stale replacement, embedded-reference conflict, inactive referrer, or concurrent graph write.' diff --git a/app/api/definitions/paths/data-sources-paths.yml b/app/api/definitions/paths/data-sources-paths.yml index 71e964e6..98b4664a 100644 --- a/app/api/definitions/paths/data-sources-paths.yml +++ b/app/api/definitions/paths/data-sources-paths.yml @@ -334,7 +334,7 @@ paths: type: string - name: preserveRelationships in: query - description: 'If true, relationships referencing the revoked object are cloned to point to the revoking object before deletion.' + description: 'Preserve active SROs and domain embedded references on the active latest replacement; retire original SROs as new revisions. Scalar conflicts or inactive referrers fail before writes. ADM relationship type constraints still apply; failures are not transactional.' schema: type: boolean default: false @@ -367,4 +367,4 @@ paths: '404': description: 'The data source or revoking object was not found.' '409': - description: 'The data source is already revoked.' + description: 'Already revoked, inactive or stale replacement, embedded-reference conflict, inactive referrer, or concurrent graph write.' diff --git a/app/api/definitions/paths/groups-paths.yml b/app/api/definitions/paths/groups-paths.yml index 65442242..ba5c72a3 100644 --- a/app/api/definitions/paths/groups-paths.yml +++ b/app/api/definitions/paths/groups-paths.yml @@ -298,7 +298,7 @@ paths: type: string - name: preserveRelationships in: query - description: 'If true, relationships referencing the revoked object are cloned to point to the revoking object before deletion.' + description: 'Preserve active SROs and domain embedded references on the active latest replacement; retire original SROs as new revisions. Scalar conflicts or inactive referrers fail before writes; unexpected persistence failures are not transactional.' schema: type: boolean default: false @@ -331,4 +331,4 @@ paths: '404': description: 'The group or revoking object was not found.' '409': - description: 'The group is already revoked.' + description: 'Already revoked, inactive or stale replacement, embedded-reference conflict, inactive referrer, or concurrent graph write.' diff --git a/app/api/definitions/paths/matrices-paths.yml b/app/api/definitions/paths/matrices-paths.yml index 0bb64034..773d2e24 100644 --- a/app/api/definitions/paths/matrices-paths.yml +++ b/app/api/definitions/paths/matrices-paths.yml @@ -325,7 +325,7 @@ paths: type: string - name: preserveRelationships in: query - description: 'If true, relationships referencing the revoked object are cloned to point to the revoking object before deletion.' + description: 'Preserve active SROs and domain embedded references on the active latest replacement; retire original SROs as new revisions. Scalar conflicts or inactive referrers fail before writes. ADM relationship type constraints still apply; failures are not transactional.' schema: type: boolean default: false @@ -358,4 +358,4 @@ paths: '404': description: 'The matrix or revoking object was not found.' '409': - description: 'The matrix is already revoked.' + description: 'Already revoked, inactive or stale replacement, embedded-reference conflict, inactive referrer, or concurrent graph write.' diff --git a/app/api/definitions/paths/mitigations-paths.yml b/app/api/definitions/paths/mitigations-paths.yml index f207c7ae..718e1560 100644 --- a/app/api/definitions/paths/mitigations-paths.yml +++ b/app/api/definitions/paths/mitigations-paths.yml @@ -310,7 +310,7 @@ paths: type: string - name: preserveRelationships in: query - description: 'If true, relationships referencing the revoked object are cloned to point to the revoking object before deletion.' + description: 'Preserve active SROs and domain embedded references on the active latest replacement; retire original SROs as new revisions. Scalar conflicts or inactive referrers fail before writes; unexpected persistence failures are not transactional.' schema: type: boolean default: false @@ -343,4 +343,4 @@ paths: '404': description: 'The mitigation or revoking object was not found.' '409': - description: 'The mitigation is already revoked.' + description: 'Already revoked, inactive or stale replacement, embedded-reference conflict, inactive referrer, or concurrent graph write.' diff --git a/app/api/definitions/paths/software-paths.yml b/app/api/definitions/paths/software-paths.yml index 43fab37c..1e96a2af 100644 --- a/app/api/definitions/paths/software-paths.yml +++ b/app/api/definitions/paths/software-paths.yml @@ -320,7 +320,7 @@ paths: type: string - name: preserveRelationships in: query - description: 'If true, relationships referencing the revoked object are cloned to point to the revoking object before deletion.' + description: 'Preserve active SROs and domain embedded references on the active latest replacement; retire original SROs as new revisions. Scalar conflicts or inactive referrers fail before writes; unexpected persistence failures are not transactional.' schema: type: boolean default: false @@ -353,4 +353,4 @@ paths: '404': description: 'The software or revoking object was not found.' '409': - description: 'The software is already revoked.' + description: 'Already revoked, inactive or stale replacement, embedded-reference conflict, inactive referrer, or concurrent graph write.' diff --git a/app/api/definitions/paths/tactics-paths.yml b/app/api/definitions/paths/tactics-paths.yml index dea66611..1d2544de 100644 --- a/app/api/definitions/paths/tactics-paths.yml +++ b/app/api/definitions/paths/tactics-paths.yml @@ -367,7 +367,7 @@ paths: type: string - name: preserveRelationships in: query - description: 'If true, relationships referencing the revoked object are cloned to point to the revoking object before deletion.' + description: 'Preserve active SROs and domain embedded references on the active latest replacement; retire original SROs as new revisions. Scalar conflicts or inactive referrers fail before writes. ADM relationship type constraints still apply; failures are not transactional.' schema: type: boolean default: false @@ -400,4 +400,4 @@ paths: '404': description: 'The tactic or revoking object was not found.' '409': - description: 'The tactic is already revoked.' + description: 'Already revoked, inactive or stale replacement, embedded-reference conflict, inactive referrer, or concurrent graph write.' diff --git a/app/api/definitions/paths/techniques-paths.yml b/app/api/definitions/paths/techniques-paths.yml index 2a4c6ba1..a6099cdf 100644 --- a/app/api/definitions/paths/techniques-paths.yml +++ b/app/api/definitions/paths/techniques-paths.yml @@ -475,7 +475,7 @@ paths: type: string - name: preserveRelationships in: query - description: 'If true, relationships referencing the revoked object are cloned to point to the revoking object before deprecation.' + description: 'Preserve active ordinary SROs by cloning onto the active latest replacement (except subtechnique-of); retire originals as new deprecated revisions. Also substitute inbound domain embedded references and merge outgoing references without losing replacement values. Scalar conflicts or inactive referrers fail before writes; unexpected persistence failures are not transactional.' schema: type: boolean default: false @@ -512,4 +512,4 @@ paths: '404': description: 'The technique or revoking object was not found.' '409': - description: 'The technique is already revoked.' + description: 'Already revoked, inactive or stale replacement, embedded-reference conflict, inactive referrer, or concurrent graph write.' diff --git a/app/controllers/attack-objects-controller.js b/app/controllers/attack-objects-controller.js index d86bf5dc..d2ba96b1 100644 --- a/app/controllers/attack-objects-controller.js +++ b/app/controllers/attack-objects-controller.js @@ -34,3 +34,11 @@ exports.retrieveAll = async function (req, res) { return res.status(500).send('Unable to get ATT&CK objects. Server error.'); } }; + +exports.deprecationCheck = async function (req, res, next) { + try { + return res.status(200).send(await attackObjectsService.deprecationCheck(req.params.stixId)); + } catch (error) { + return next(error); + } +}; diff --git a/app/exceptions/index.js b/app/exceptions/index.js index e9fceb52..4c7939a6 100644 --- a/app/exceptions/index.js +++ b/app/exceptions/index.js @@ -460,6 +460,21 @@ class ObjectHasValidationIssuesError extends CustomError { } } +class LifecycleConflictError extends CustomError { + constructor(message, options) { + super(message, options); + } +} + +class DeprecationBlockedError extends LifecycleConflictError { + constructor(check) { + super('Remove current relationships before deprecating this object', { + code: 'deprecation_blocked', + ...check, + }); + } +} + module.exports = { //** General errors */ NotImplementedError, @@ -481,6 +496,8 @@ module.exports = { //** Revocation errors */ AlreadyRevokedError, SelfRevocationError, + LifecycleConflictError, + DeprecationBlockedError, //** Version control errors */ AlreadyReleasedError, diff --git a/app/lib/domain-references.js b/app/lib/domain-references.js new file mode 100644 index 00000000..be79fb0e --- /dev/null +++ b/app/lib/domain-references.js @@ -0,0 +1,113 @@ +'use strict'; + +const _ = require('lodash'); +const { LifecycleConflictError } = require('../exceptions'); +const excluded = new Set([ + 'created_by_ref', + 'x_mitre_modified_by_ref', + 'object_marking_refs', + 'granular_markings', + 'marking_ref', + 'x_mitre_contents', + 'external_references', +]); +const isReference = (key) => /_refs?$/.test(key); + +function references(stix) { + if (!stix || stix.type === 'relationship' || stix.type === 'marking-definition') return []; + const found = []; + function visit(value, path = '', reference = false) { + if (value?.toObject) value = value.toObject(); + if (typeof value === 'string') { + if (reference && /^[a-z][a-z0-9-]*--[0-9a-f-]+$/i.test(value)) { + found.push({ source_ref: stix.id, target_ref: value, path }); + } + } else if (Array.isArray(value)) { + value.forEach((item, index) => visit(item, `${path}[${index}]`, reference)); + } else if (value && typeof value === 'object') { + for (const [key, child] of Object.entries(value)) { + if (!excluded.has(key)) visit(child, path ? `${path}.${key}` : key, isReference(key)); + } + } + } + visit(stix); + return found; +} + +function addedReferences(stix, previous) { + function signature(ref, object) { + // Include the containing record's metadata (log-source name/channel), + // ignoring only array position so reordering is not a new reference. + const recordPath = ref.path.match(/^(.*\[\d+\])\./)?.[1]; + const record = recordPath ? _.get(object, recordPath) : null; + return { + path: ref.path.replace(/\[\d+\]/g, '[]'), + target: ref.target_ref, + record: record?.toObject ? record.toObject() : record, + }; + } + const retained = references(previous).map((ref) => signature(ref, previous)); + return references(stix).filter((ref) => { + const candidate = signature(ref, stix); + const index = retained.findIndex((existing) => _.isEqual(existing, candidate)); + if (index < 0) return true; + retained.splice(index, 1); + return false; + }); +} + +function substitute(stix, from, to) { + const result = _.cloneDeep(stix); + for (const ref of references(stix)) { + if (ref.target_ref === from) _.set(result, ref.path, to); + } + const arrays = new Set(); + for (const ref of references(result)) { + for (const match of ref.path.matchAll(/\[\d+\]/g)) arrays.add(ref.path.slice(0, match.index)); + } + for (const path of [...arrays].sort((a, b) => b.length - a.length)) { + _.set(result, path, _.uniqWith(_.get(result, path), _.isEqual)); + } + return result; +} + +// Union only reference-bearing properties, preserving the whole containing +// array entry (notably analytic log-source name and channel). +function mergeOutgoing(target, source, from, to) { + const incoming = substitute(source, from, to); + const result = _.cloneDeep(target); + const paths = references(incoming).map((ref) => ref.path); + const carriesReference = (path) => + paths.some((p) => p === path || p.startsWith(`${path}.`) || p.startsWith(`${path}[`)); + function merge(existing, addition, path) { + if (existing === undefined) return _.cloneDeep(addition); + if (Array.isArray(addition) && Array.isArray(existing)) { + const union = _.cloneDeep(existing); + for (const item of addition) { + if (!union.some((other) => _.isEqual(other, item))) union.push(_.cloneDeep(item)); + } + return union; + } + if (_.isPlainObject(addition) && _.isPlainObject(existing)) { + const merged = _.cloneDeep(existing); + for (const [key, value] of Object.entries(addition)) { + const childPath = `${path}.${key}`; + if (carriesReference(childPath)) merged[key] = merge(merged[key], value, childPath); + } + return merged; + } + if (_.isEqual(existing, addition)) return existing; + throw new LifecycleConflictError('Replacement has a conflicting embedded reference', { + code: 'embedded_reference_conflict', + path, + source_ref: from, + target_ref: to, + }); + } + for (const [key, value] of Object.entries(incoming)) { + if (carriesReference(key)) result[key] = merge(result[key], value, key); + } + return result; +} + +module.exports = { references, addedReferences, substitute, mergeOutgoing }; diff --git a/app/lib/error-handler.js b/app/lib/error-handler.js index 3f98cfed..2a7c44d2 100644 --- a/app/lib/error-handler.js +++ b/app/lib/error-handler.js @@ -56,6 +56,7 @@ const { ImmutableStixRevisionError, ObjectHasValidationIssuesError, } = require('../exceptions'); +const { LifecycleConflictError } = require('../exceptions'); exports.bodyParser = function (err, req, res, next) { if (err.name === 'SyntaxError') { @@ -145,6 +146,7 @@ exports.serviceExceptions = function (err, req, res, next) { // Handle 409 Conflict errors (duplicate resources) if ( err instanceof DuplicateIdError || + err instanceof LifecycleConflictError || err instanceof DuplicateEmailError || err instanceof DuplicateNameError || err instanceof AlreadyRevokedError || diff --git a/app/lib/graph-write-lock.js b/app/lib/graph-write-lock.js new file mode 100644 index 00000000..f94bf2b5 --- /dev/null +++ b/app/lib/graph-write-lock.js @@ -0,0 +1,43 @@ +'use strict'; + +const { AsyncLocalStorage } = require('async_hooks'); +const { v4: uuid } = require('uuid'); +const repository = require('../repository/graph-write-lock-repository'); +const { LifecycleConflictError } = require('../exceptions'); +const context = new AsyncLocalStorage(); +let pending = Promise.resolve(); + +exports.isHeld = () => context.getStore()?.active === true; + +exports.run = async function run(operation) { + if (exports.isHeld()) return operation(); + + // Queue this process's writes; Mongo's unique _id excludes other workers. + const previous = pending; + let releaseQueue; + pending = new Promise((resolve) => { + releaseQueue = resolve; + }); + await previous; + const owner = { token: uuid(), active: true }; + let acquired = false; + try { + try { + await repository.acquire(owner.token); + acquired = true; + } catch (error) { + if (error.code !== 11000) throw error; + throw new LifecycleConflictError('Another STIX graph write is in progress', { + code: 'graph_write_conflict', + }); + } + return await context.run(owner, operation); + } finally { + owner.active = false; + try { + if (acquired) await repository.release(owner.token); + } finally { + releaseQueue(); + } + } +}; diff --git a/app/repository/attack-objects-repository.js b/app/repository/attack-objects-repository.js index 9eeb4a3e..8c45c444 100644 --- a/app/repository/attack-objects-repository.js +++ b/app/repository/attack-objects-repository.js @@ -12,6 +12,17 @@ const { const regexValidator = require('../lib/regex'); class AttackObjectsRepository extends BaseRepository { + async retrieveLatestGraphObjects() { + return this.model + .aggregate([ + { $sort: { 'stix.id': 1, 'stix.modified': -1 } }, + { $group: { _id: '$stix.id', document: { $first: '$$ROOT' } } }, + { $replaceRoot: { newRoot: '$document' } }, + { $project: { _id: 0, __v: 0, __t: 0 } }, + ]) + .exec(); + } + async retrieveAll(options) { // Build the query const query = {}; diff --git a/app/repository/graph-write-lock-repository.js b/app/repository/graph-write-lock-repository.js new file mode 100644 index 00000000..2f6b8d59 --- /dev/null +++ b/app/repository/graph-write-lock-repository.js @@ -0,0 +1,16 @@ +'use strict'; + +const mongoose = require('mongoose'); + +// Deliberately no TTL or stale-owner takeover: an expired lease cannot fence +// a paused writer on standalone MongoDB. A crashed owner fails closed until +// an operator stops all API workers and removes this lock document. +const schema = new mongoose.Schema( + { _id: String, token: String, acquired_at: Date }, + { collection: 'graphWriteLocks', versionKey: false }, +); +const GraphWriteLock = mongoose.model('GraphWriteLock', schema); + +exports.acquire = (token) => + GraphWriteLock.create({ _id: 'stix-graph', token, acquired_at: new Date() }); +exports.release = (token) => GraphWriteLock.deleteOne({ _id: 'stix-graph', token }).exec(); diff --git a/app/routes/attack-objects-routes.js b/app/routes/attack-objects-routes.js index f9117488..4a2899ce 100644 --- a/app/routes/attack-objects-routes.js +++ b/app/routes/attack-objects-routes.js @@ -16,4 +16,12 @@ router attackObjectsController.retrieveAll, ); +router + .route('/attack-objects/:stixId/deprecation-check') + .get( + authn.authenticate, + authz.requireRole(authz.visitorOrHigher, authz.readOnlyService), + attackObjectsController.deprecationCheck, + ); + module.exports = router; diff --git a/app/services/meta-classes/base.service.js b/app/services/meta-classes/base.service.js index 1fcde17f..d2ca4bfd 100644 --- a/app/services/meta-classes/base.service.js +++ b/app/services/meta-classes/base.service.js @@ -32,6 +32,9 @@ const { deepFreezeStix } = require('../../lib/import-safety'); const ServiceWithHooks = require('./hooks.service'); const WorkflowResult = require('../../lib/workflow-result'); +const graphWriteLock = require('../../lib/graph-write-lock'); +const lifecycle = require('../stix/lifecycle-service'); +const EventBus = require('../../lib/event-bus'); // Import required repositories const systemConfigurationRepository = require('../../repository/system-configurations-repository'); const identitiesRepository = require('../../repository/identities-repository'); @@ -46,6 +49,20 @@ class BaseService extends ServiceWithHooks { // Initialize caches for identity lookups this.identityCache = new Map(); this.userAccountCache = new Map(); + if (type) this.registerLifecycleType(type); + } + + registerLifecycleType(type) { + EventBus.on(`${type}::lifecycle-create-requested`, ({ data, options }) => + this.create(data, options), + ); + EventBus.on(`${type}::lifecycle-cache-requested`, async ({ stixId, embeddedRelationships }) => { + const document = await this.repository.retrieveLatestByStixId(stixId); + if (!document) throw new NotFoundError({ stix_id: stixId }); + document.workspace = document.workspace || {}; + document.workspace.embedded_relationships = embeddedRelationships; + await this.repository.saveDocument(document); + }); } // ============================ @@ -509,7 +526,10 @@ class BaseService extends ServiceWithHooks { * @returns {Object} The created document (or composed data if dryRun) with warnings array */ async create(data, options) { - options = options || {}; + if (!graphWriteLock.isHeld()) { + return graphWriteLock.run(() => BaseService.prototype.create.call(this, data, options)); + } + options = { ...options }; // ────────────────────────────────────────────── // 1. ANALYZE REQUEST @@ -634,7 +654,7 @@ class BaseService extends ServiceWithHooks { // Check for an existing object (may differ from existingVersion if stix.id was just generated) let existingObject; if (data.stix.id) { - existingObject = await this.repository.retrieveOneById(data.stix.id); + existingObject = await this.repository.retrieveLatestByStixId(data.stix.id); } if (existingObject) { @@ -682,6 +702,7 @@ class BaseService extends ServiceWithHooks { // 4. LIFECYCLE HOOKS // ────────────────────────────────────────────── await this.beforeCreate(data, options); + await lifecycle.assertAuthoring(data, existingObject); // ────────────────────────────────────────────── // 5. VALIDATE WITH ADM @@ -990,6 +1011,11 @@ class BaseService extends ServiceWithHooks { * @returns {Object|null} The updated document (or composed data if dryRun), null if not found */ async updateFull(stixId, stixModified, data, options) { + if (!graphWriteLock.isHeld()) { + return graphWriteLock.run(() => + BaseService.prototype.updateFull.call(this, stixId, stixModified, data, options), + ); + } options = options || {}; // ────────────────────────────────────────────── @@ -1154,6 +1180,11 @@ class BaseService extends ServiceWithHooks { // TODO rename to deleteVersionByStixId and repurpose the existing name for deleting by the document's unique _id async deleteVersionById(stixId, stixModified) { + if (!graphWriteLock.isHeld()) { + return graphWriteLock.run(() => + BaseService.prototype.deleteVersionById.call(this, stixId, stixModified), + ); + } if (!stixId) { throw new MissingParameterError('stixId'); } @@ -1188,289 +1219,158 @@ class BaseService extends ServiceWithHooks { // ============================ /** - * Revokes an object (Object A) in favor of another object (Object B). - * - * Workflow: - * 1. Validate inputs - * 2. Retrieve objects A and B - * 3. Lifecycle hook: beforeRevoke - * 4. Mark Object A as revoked (creates a new version via this.create) - * 5. Create a revoked-by relationship (A → B) - * 6. Handle relationships (transfer to B if preserveRelationships) - * 7. Lifecycle hook: afterRevoke - * 8. Emit revoked event (RelationshipsService deprecates original relationships via event listener) - * 9. Return result - * - * @param {string} stixId - The STIX ID of the object to revoke (Object A) - * @param {Object} data - Request body containing { revoking: { stixId, modified } } - * @param {Object} [options] - Options - * @param {boolean} [options.preserveRelationships] - If true, clone relationships to Object B before deleting - * @param {string} [options.userAccountId] - The authenticated user's account ID - * @returns {Object} Result with revokedObject, revokedByRelationship, relationshipsSummary + * Plan and validate the complete revocation under the shared graph lock. + * Expected conflicts are rejected before any writes. Standalone MongoDB + * cannot roll back unexpected persistence failures; these fail the request, + * never become a successful workflow with a transfer-failed warning. */ async revoke(stixId, data, options = {}) { - logger.info( - `REVOKING ${stixId} in favor of ${data?.revoking?.stixId} (preserveRelationships: ${options.preserveRelationships})`, - ); - - // Lazy-load to avoid circular dependency - const relationshipsService = require('../stix/relationships-service'); - const relationshipsRepository = require('../../repository/relationships-repository'); - - // ────────────────────────────────────────────── - // 1. VALIDATE INPUTS - // ────────────────────────────────────────────── - if (!stixId) { - throw new MissingParameterError('stixId'); - } - if (!data?.revoking?.stixId) { - throw new MissingParameterError('revoking.stixId'); - } - if (!data?.revoking?.modified) { - throw new MissingParameterError('revoking.modified'); - } - if (stixId === data.revoking.stixId) { - throw new SelfRevocationError(); + if (!graphWriteLock.isHeld()) { + return graphWriteLock.run(() => + BaseService.prototype.revoke.call(this, stixId, data, options), + ); } + const { LifecycleConflictError } = require('../../exceptions'); + const relationshipsRepository = require('../../repository/relationships-repository'); + if (!stixId) throw new MissingParameterError('stixId'); + if (!data?.revoking?.stixId) throw new MissingParameterError('revoking.stixId'); + if (!data?.revoking?.modified) throw new MissingParameterError('revoking.modified'); + if (stixId === data.revoking.stixId) throw new SelfRevocationError(); - // ────────────────────────────────────────────── - // 2. RETRIEVE OBJECTS - // ────────────────────────────────────────────── const objectA = await this.repository.retrieveLatestByStixId(stixId); - if (!objectA) { - throw new NotFoundError({ details: `Object A with stixId ${stixId} not found` }); - } - if (objectA.stix.revoked === true) { - throw new AlreadyRevokedError({ details: `Object ${stixId} is already revoked` }); + if (!objectA) throw new NotFoundError({ stix_id: stixId }); + if (objectA.stix.revoked) throw new AlreadyRevokedError({ stix_id: stixId }); + const objectB = await this.repository.retrieveLatestByStixId(data.revoking.stixId); + if (!objectB || objectB.stix.type !== objectA.stix.type) { + throw new NotFoundError({ stix_id: data.revoking.stixId }); } - - const objectB = await this.repository.retrieveOneByVersion( - data.revoking.stixId, - data.revoking.modified, - ); - if (!objectB) { - throw new NotFoundError({ - details: `Object B with stixId ${data.revoking.stixId} and modified ${data.revoking.modified} not found`, - }); - } - if (objectB.stix.type !== this.type) { - throw new BadRequestError({ - details: `Revoking object must be of the same type (${this.type}), got ${objectB.stix.type}`, + if ( + lifecycle.inactive(objectB) || + new Date(objectB.stix.modified).getTime() !== new Date(data.revoking.modified).getTime() + ) { + throw new LifecycleConflictError('Replacement must be its active latest revision', { + code: 'invalid_replacement', + stix_id: objectB.stix.id, }); } - - // ────────────────────────────────────────────── - // 3. LIFECYCLE HOOK: beforeRevoke - // ────────────────────────────────────────────── await this.beforeRevoke(objectA, objectB, options); - // ────────────────────────────────────────────── - // 4. MARK OBJECT A AS REVOKED - // ────────────────────────────────────────────── - // Clone Object A and set revoked = true, then persist directly via the repository. - // We bypass this.create() because the object is already fully composed and validated — - // routing it through create() would strip the revoked flag (which is server-controlled). - const objectAData = objectA.toObject ? objectA.toObject() : { ...objectA }; - delete objectAData._id; - delete objectAData.__v; - delete objectAData.__t; - objectAData.stix.revoked = true; - objectAData.stix.modified = new Date().toISOString(); - // Release-track backrefs are pinned to specific revisions — the new - // revoked revision is not referenced by any track. - if (objectAData.workspace) { - delete objectAData.workspace.release_tracks; - } - if (options.userAccountId) { - objectAData.workspace = objectAData.workspace || {}; - objectAData.workspace.workflow = objectAData.workspace.workflow || {}; - objectAData.workspace.workflow.created_by_user_account = options.userAccountId; - } - - const revokedDocument = await this.repository.save(objectAData); - const result = new WorkflowResult('revoke'); - result.setPrimary(revokedDocument); - - // ────────────────────────────────────────────── - // 5. CREATE REVOKED-BY RELATIONSHIP - // ────────────────────────────────────────────── - // NOTE: This is a direct cross-service write (BaseService → RelationshipsService.create). - // The revoke workflow predates the event-driven architecture and is shared by all SDO types. - // TODO: Migrate to an event-driven pattern for consistency with the conversion workflows. - const now = new Date().toISOString(); - const revokedByRelationship = await relationshipsService.create( - { - workspace: { - workflow: {}, - }, - stix: { - type: 'relationship', - spec_version: '2.1', - relationship_type: 'revoked-by', - source_ref: objectA.stix.id, - target_ref: objectB.stix.id, - created: now, - modified: now, - }, - }, - { userAccountId: options.userAccountId }, - ); - result.addCreated(revokedByRelationship); - - // TODO what if relationshipsService.create fails after we've already marked Object A as revoked? - // We should have error handling to attempt to roll back the revoked status if the relationship - // creation fails, to avoid leaving the system in a broken state where Object A is revoked but - // there's no link to Object B. This could be done with a try/catch around the relationship creation, - // and in the catch block we would attempt to set revoked back to false on Object A and save it again. - // We would also need to handle potential errors in that rollback attempt and log them appropriately. - - // ────────────────────────────────────────────── - // 6. HANDLE RELATIONSHIPS (transfer if preserveRelationships is set) - // ────────────────────────────────────────────── + const embedded = options.preserveRelationships + ? await lifecycle.planEmbeddedTransfer(objectA, objectB) + : []; + const originals = await relationshipsRepository.retrieveAll({ + versions: 'latest', + sourceOrTargetRef: stixId, + }); + const retired = originals.map((document) => { + const changed = lifecycle.revision(document); + changed.stix.x_mitre_deprecated = true; + return changed; + }); + const transferred = []; + const triple = (stix) => `${stix.source_ref}:${stix.relationship_type}:${stix.target_ref}`; if (options.preserveRelationships) { - const existingRelationships = await relationshipsRepository.retrieveAllBySourceOrTarget( - objectA.stix.id, - ); - - // Exclude the revoked-by relationship we just created - const relationshipsToProcess = existingRelationships.filter( - (rel) => rel.stix.id !== revokedByRelationship.stix.id, - ); - // Build a set of relationship triples (source_ref--relationship_type--target_ref) - // that Object B already participates in, so we can skip duplicates. - const objectBRelationships = await relationshipsRepository.retrieveAllBySourceOrTarget( - objectB.stix.id, - ); - const objectBRelTriples = new Set( - objectBRelationships.map( - (r) => `${r.stix.source_ref}--${r.stix.relationship_type}--${r.stix.target_ref}`, - ), - ); - - for (const rel of relationshipsToProcess) { - try { - // Skip subtechnique-of relationships — hierarchy relationships must be managed - // separately via the conversion endpoints, not transferred during revocation. - if (rel.stix.relationship_type === 'subtechnique-of') { - logger.info( - `Skipping subtechnique-of relationship ${rel.stix.id} during preservation (hierarchy relationships are not transferred)`, - ); - result.addWarning({ - message: 'Hierarchy relationship not transferred', - reason: 'subtechnique-of', - relationship: { - id: rel.stix.id, - source_ref: rel.stix.source_ref, - target_ref: rel.stix.target_ref, - relationship_type: rel.stix.relationship_type, - }, - }); - continue; - } - - // TODO here is another use case for a more robust composition layer or a DTO pattern — we are manually cloning and modifying relationship objects, which is error-prone and may not scale well if relationships have more complex fields in the future. A composition layer could handle cloning an existing relationship and substituting references while ensuring all required fields are correctly set. - const relData = { ...rel }; - delete relData._id; - delete relData.__v; - delete relData.__t; - - // Reset timestamps - relData.stix.created = now; - relData.stix.modified = now; - - // Substitute Object B for Object A - if (relData.stix.source_ref === objectA.stix.id) { - relData.stix.source_ref = objectB.stix.id; - } - if (relData.stix.target_ref === objectA.stix.id) { - relData.stix.target_ref = objectB.stix.id; - } - - // Skip if Object B already has an equivalent relationship - const candidateTriple = `${relData.stix.source_ref}--${relData.stix.relationship_type}--${relData.stix.target_ref}`; - if (objectBRelTriples.has(candidateTriple)) { - logger.info( - `Skipping duplicate relationship transfer: ${candidateTriple} already exists on Object B`, - ); - result.addWarning({ - message: 'Duplicate relationship transfer skipped', - skipped: { - id: rel.stix.id, - source_ref: rel.stix.source_ref, - target_ref: rel.stix.target_ref, - relationship_type: rel.stix.relationship_type, - description: rel.stix.description, - }, - existing: { - id: relData.stix.id, - source_ref: relData.stix.source_ref, - target_ref: relData.stix.target_ref, - relationship_type: relData.stix.relationship_type, - description: relData.stix.description, - }, - }); - continue; - } - - // Generate a new STIX ID for the cloned relationship - relData.stix.id = `relationship--${uuid.v4()}`; - - const transferredRel = await relationshipsService.create(relData, { - userAccountId: options.userAccountId, + const existing = await relationshipsRepository.retrieveAll({ + versions: 'latest', + sourceOrTargetRef: objectB.stix.id, + }); + const triples = new Set(existing.map((document) => triple(document.stix))); + for (const original of originals) { + if (original.stix.relationship_type === 'subtechnique-of') { + result.addWarning({ + message: 'Hierarchy relationship not transferred', + reason: 'subtechnique-of', + relationship: { id: original.stix.id }, }); - result.addCreated(transferredRel); - - // Track the newly created triple so subsequent iterations don't create duplicates - objectBRelTriples.add(candidateTriple); - } catch (err) { - logger.warn(`Failed to transfer relationship ${rel.stix.id}: ${err.message}`); + continue; + } + const changed = lifecycle.revision(original); + for (const field of ['source_ref', 'target_ref']) { + if (changed.stix[field] === stixId) changed.stix[field] = objectB.stix.id; + } + if (triples.has(triple(changed.stix))) { result.addWarning({ - message: 'Relationship transfer failed', - relationship: { - id: rel.stix.id, - description: rel.stix.description, - source_ref: rel.stix.source_ref, - target_ref: rel.stix.target_ref, - relationship_type: rel.stix.relationship_type, - }, - error: err.message, + message: 'Duplicate relationship transfer skipped', + skipped: { id: original.stix.id }, }); + continue; } + triples.add(triple(changed.stix)); + changed.stix.id = `relationship--${uuid.v4()}`; + changed.stix.created = changed.stix.modified; + transferred.push(changed); } } + const now = new Date().toISOString(); + const revokedBy = { + workspace: { workflow: {} }, + stix: { + type: 'relationship', + spec_version: '2.1', + id: `relationship--${uuid.v4()}`, + relationship_type: 'revoked-by', + source_ref: stixId, + target_ref: objectB.stix.id, + created: now, + modified: now, + }, + }; + const writeOptions = { userAccountId: options.userAccountId, requireReferences: true }; + for (const document of [...embedded, ...retired, ...transferred, revokedBy]) { + await lifecycle.createRevision(document, { ...writeOptions, dryRun: true }); + } + const revokedData = lifecycle.revision(objectA); + revokedData.stix.revoked = true; + if (options.userAccountId) { + revokedData.workspace = revokedData.workspace || {}; + revokedData.workspace.workflow = revokedData.workspace.workflow || {}; + revokedData.workspace.workflow.created_by_user_account = options.userAccountId; + } + if (options.dryRun) { + result.setPrimary(revokedData); + result.addModified(embedded); + result.addDeprecated(retired); + result.addCreated([...transferred, revokedBy]); + return result.toJSON(); + } - // ────────────────────────────────────────────── - // 7. LIFECYCLE HOOK: afterRevoke - // ────────────────────────────────────────────── + for (const document of embedded) { + result.addModified(await lifecycle.createRevision(document, writeOptions)); + } + for (const document of retired) { + result.addDeprecated(await lifecycle.createRevision(document, writeOptions)); + } + for (const document of transferred) { + result.addCreated(await lifecycle.createRevision(document, writeOptions)); + } + result.addCreated(await lifecycle.createRevision(revokedBy, writeOptions)); + // Refresh workspace metadata changed by inbound migrations before cloning A. + const refreshedA = await this.repository.retrieveLatestByStixId(stixId); + revokedData.workspace = refreshedA.toObject().workspace; + delete revokedData.workspace?.release_tracks; + if (options.userAccountId) { + revokedData.workspace.workflow = revokedData.workspace.workflow || {}; + revokedData.workspace.workflow.created_by_user_account = options.userAccountId; + } + const revokedDocument = await this.repository.save(revokedData); + if (options.preserveRelationships) { + await lifecycle.reconcileEmbeddedMetadata([objectA, objectB, ...embedded]); + const refreshed = await this.repository.retrieveLatestByStixId(stixId); + revokedDocument.workspace = refreshed.workspace; + } + result.setPrimary(revokedDocument); await this.afterRevoke(revokedDocument, objectB, options); - - // ────────────────────────────────────────────── - // 8. EMIT EVENT - // ────────────────────────────────────────────── - // RelationshipsService listens for revoked events and deprecates all relationships - // referencing the revoked object (except those in excludeRelationshipIds). - // EventBus.emit() awaits all listeners, so deprecation completes before we return. - // Handler results (deprecated docs, warnings) are merged into the WorkflowResult. - const excludeRelationshipIds = [revokedByRelationship.stix.id]; - const eventResults = await this.emitRevokedEvent(revokedDocument, objectB, options, { - excludeRelationshipIds, - }); - result.mergeEventResults(eventResults); - - // Revision sync (listening on the revoked event) may have enrolled or - // re-pinned the revoked revision in its tracks — refresh so the response - // carries the resulting backrefs. + result.mergeEventResults(await this.emitRevokedEvent(revokedDocument, objectB, options)); await this._refreshReleaseTrackBackrefs(revokedDocument); - - // ────────────────────────────────────────────── - // 9. RETURN RESULT - // ────────────────────────────────────────────── return result.toJSON(); } // TODO rename to deleteManyByStixId async deleteById(stixId) { + if (!graphWriteLock.isHeld()) { + return graphWriteLock.run(() => BaseService.prototype.deleteById.call(this, stixId)); + } if (!stixId) { throw new MissingParameterError('stixId'); } diff --git a/app/services/meta-classes/hooks.service.js b/app/services/meta-classes/hooks.service.js index 7d239a92..6565896a 100644 --- a/app/services/meta-classes/hooks.service.js +++ b/app/services/meta-classes/hooks.service.js @@ -186,21 +186,18 @@ class ServiceWithHooks { * @param {object} revokedDocument - The revoked document * @param {object} revokingDocument - The revoking document * @param {object} options - Revocation options - * @param {object} [metadata] - Additional event metadata - * @param {string[]} [metadata.excludeRelationshipIds] - Relationship STIX IDs to exclude from deprecation */ - async emitRevokedEvent(revokedDocument, revokingDocument, options, metadata = {}) { - const eventName = `${this.type}::revoked`; + async emitRevokedEvent(revokedDocument, revokingDocument, options) { + const eventName = `${revokedDocument.stix.type}::revoked`; logger.info(`Emitting event '${eventName}' for ${revokedDocument.stix.id}`); - const results = await EventBus.emit(eventName, { + const results = await EventBus.emitRequired(eventName, { stixId: revokedDocument.stix.id, revokedDocument: revokedDocument.toObject ? revokedDocument.toObject() : revokedDocument, revokingDocument: revokingDocument.toObject ? revokingDocument.toObject() : revokingDocument, - type: this.type, + type: revokedDocument.stix.type, options, - excludeRelationshipIds: metadata.excludeRelationshipIds || [], }); logger.info(`Event '${eventName}' emission complete`); diff --git a/app/services/stix/analytics-service.js b/app/services/stix/analytics-service.js index 1f10fd6c..b61bac04 100644 --- a/app/services/stix/analytics-service.js +++ b/app/services/stix/analytics-service.js @@ -76,6 +76,12 @@ class AnalyticsService extends BaseService { const analytic = await analyticsRepository.retrieveLatestByStixId(analyticId); if (!analytic) { + if (options?.requireReferences) { + throw new Exceptions.NotFoundError({ + objectType: 'x-mitre-analytic', + objectId: analyticId, + }); + } logger.warn( `AnalyticsService: Could not find analytic ${analyticId} to add inbound relationship`, ); @@ -122,7 +128,7 @@ class AnalyticsService extends BaseService { // import path; the bundle is the source of truth for stix content. // The framework freeze above would throw here if this gate were // missing — that's intentional. - if (!options?.import) { + if (!options?.import && !options?.requireReferences) { if (!analytic.stix.external_references) { analytic.stix.external_references = []; } @@ -144,6 +150,7 @@ class AnalyticsService extends BaseService { await analyticsRepository.saveDocument(analytic); } catch (error) { + if (options?.requireReferences) throw error; logger.error( `AnalyticsService: Error handling analytics-referenced for ${analyticId}:`, error, @@ -163,19 +170,27 @@ class AnalyticsService extends BaseService { * @returns {Promise} */ static async handleAnalyticsRemoved(payload) { - const { detectionStrategyId, analyticIds } = payload; + const { detectionStrategyId, analyticIds, options } = payload; for (const analyticId of analyticIds) { try { const analytic = await analyticsRepository.retrieveLatestByStixId(analyticId); if (!analytic) { + if (options?.requireReferences) { + throw new Exceptions.NotFoundError({ + objectType: 'x-mitre-analytic', + objectId: analyticId, + }); + } logger.warn( `AnalyticsService: Could not find analytic ${analyticId} to remove inbound relationship`, ); continue; } + if (options?.import) deepFreezeStix(analytic); + if (analytic.workspace?.embedded_relationships) { // Remove inbound embedded_relationship const initialLength = analytic.workspace.embedded_relationships.length; @@ -193,7 +208,7 @@ class AnalyticsService extends BaseService { } // Update external_references (remove URL since no parent) - if (analytic.stix?.external_references) { + if (!options?.import && !options?.requireReferences && analytic.stix?.external_references) { // Remove existing ATT&CK external references analytic.stix.external_references = removeAttackExternalReferences( analytic.stix.external_references, @@ -214,6 +229,7 @@ class AnalyticsService extends BaseService { await analyticsRepository.saveDocument(analytic); } catch (error) { + if (options?.requireReferences) throw error; logger.error( `AnalyticsService: Error handling analytics-removed for ${analyticId}:`, error, @@ -271,8 +287,22 @@ class AnalyticsService extends BaseService { } // Build outbound embedded_relationships for data component references - const dataComponentRefs = - data.stix?.x_mitre_log_source_references?.map((ref) => ref.x_mitre_data_component_ref) || []; + const dataComponentRefs = [ + ...new Set( + data.stix?.x_mitre_log_source_references?.map((ref) => ref.x_mitre_data_component_ref) || + [], + ), + ]; + const oldRefs = new Set( + previousVersion?.stix?.x_mitre_log_source_references?.map( + (ref) => ref.x_mitre_data_component_ref, + ) || [], + ); + options._removedDataComponentRefs ??= new Map(); + options._removedDataComponentRefs.set( + `${data.stix.id}:${new Date(data.stix.modified).getTime()}`, + [...oldRefs].filter((ref) => !dataComponentRefs.includes(ref)), + ); // Preserve non-data-component relationships from the previous persisted version when POST // is creating a new version. Client payloads often omit server-managed workspace metadata. @@ -281,7 +311,8 @@ class AnalyticsService extends BaseService { data.workspace.embedded_relationships || []; const existingNonDataComponentRels = baselineEmbeddedRelationships.filter( - (rel) => !rel.stix_id?.startsWith('x-mitre-data-component--'), + (rel) => + !(rel.direction === 'outbound' && rel.stix_id?.startsWith('x-mitre-data-component--')), ); data.workspace.embedded_relationships = [...existingNonDataComponentRels]; @@ -323,11 +354,22 @@ class AnalyticsService extends BaseService { * @returns {Promise} */ async afterCreate(createdDocument, options) { - // Extract data component IDs from x_mitre_log_source_references - const dataComponentRefs = - createdDocument.stix?.x_mitre_log_source_references?.map( - (ref) => ref.x_mitre_data_component_ref, - ) || []; + const key = `${createdDocument.stix.id}:${new Date(createdDocument.stix.modified).getTime()}`; + const removedRefs = options._removedDataComponentRefs?.get(key) || []; + options._removedDataComponentRefs?.delete(key); + const latest = await this.repository.retrieveLatestByStixId(createdDocument.stix.id); + if ( + new Date(latest.stix.modified).getTime() !== new Date(createdDocument.stix.modified).getTime() + ) { + return; + } + const dataComponentRefs = [ + ...new Set( + createdDocument.stix?.x_mitre_log_source_references?.map( + (ref) => ref.x_mitre_data_component_ref, + ) || [], + ), + ]; if (dataComponentRefs.length > 0) { logger.info( @@ -335,12 +377,25 @@ class AnalyticsService extends BaseService { { stixId: createdDocument.stix.id, dataComponentIds: dataComponentRefs }, ); - await EventBus.emit('x-mitre-analytic::data-components-referenced', { - analyticId: createdDocument.stix.id, - analytic: createdDocument.toObject ? createdDocument.toObject() : createdDocument, - dataComponentIds: dataComponentRefs, - options, - }); + await EventBus[options.requireReferences ? 'emitRequired' : 'emit']( + 'x-mitre-analytic::data-components-referenced', + { + analyticId: createdDocument.stix.id, + analytic: createdDocument.toObject ? createdDocument.toObject() : createdDocument, + dataComponentIds: dataComponentRefs, + options, + }, + ); + } + if (removedRefs.length > 0) { + await EventBus[options.requireReferences ? 'emitRequired' : 'emit']( + 'x-mitre-analytic::data-components-removed', + { + analyticId: createdDocument.stix.id, + dataComponentIds: removedRefs, + options, + }, + ); } } @@ -370,12 +425,17 @@ class AnalyticsService extends BaseService { } // Validate that all referenced data components exist and build outbound relationships - const newDataComponentRefs = - data.stix?.x_mitre_log_source_references?.map((ref) => ref.x_mitre_data_component_ref) || []; + const newDataComponentRefs = [ + ...new Set( + data.stix?.x_mitre_log_source_references?.map((ref) => ref.x_mitre_data_component_ref) || + [], + ), + ]; // Preserve existing non-data-component embedded relationships (e.g., inbound from detection strategies) const existingNonDataComponentRels = (data.workspace.embedded_relationships || []).filter( - (rel) => !rel.stix_id?.startsWith('x-mitre-data-component--'), + (rel) => + !(rel.direction === 'outbound' && rel.stix_id?.startsWith('x-mitre-data-component--')), ); // Build new outbound embedded relationships for data components @@ -407,19 +467,6 @@ class AnalyticsService extends BaseService { ...dataComponentEmbeddedRels, ]; - // Detect changes in data component references for event emission - const oldDataComponentRefs = - existingDocument.stix?.x_mitre_log_source_references?.map( - (ref) => ref.x_mitre_data_component_ref, - ) || []; - - this._addedDataComponentRefs = newDataComponentRefs.filter( - (ref) => !oldDataComponentRefs.includes(ref), - ); - this._removedDataComponentRefs = oldDataComponentRefs.filter( - (ref) => !newDataComponentRefs.includes(ref), - ); - // Check if embedded_relationships changed (specifically inbound detection strategy relationships) const oldEmbeddedRels = existingDocument.workspace?.embedded_relationships || []; const newEmbeddedRels = data.workspace?.embedded_relationships || []; @@ -459,13 +506,22 @@ class AnalyticsService extends BaseService { * Emits domain events for added/removed data component references * * @param {Object} updatedDocument - The updated analytic document - * @param {Object} _previousDocument - The previous version of the analytic (unused) + * @param {Object} previousDocument - The previous version of the analytic * @returns {Promise} */ - // eslint-disable-next-line no-unused-vars - async afterUpdate(updatedDocument, _previousDocument) { - const addedRefs = this._addedDataComponentRefs || []; - const removedRefs = this._removedDataComponentRefs || []; + async afterUpdate(updatedDocument, previousDocument) { + const oldRefs = new Set( + previousDocument.stix?.x_mitre_log_source_references?.map( + (ref) => ref.x_mitre_data_component_ref, + ) || [], + ); + const newRefs = new Set( + updatedDocument.stix?.x_mitre_log_source_references?.map( + (ref) => ref.x_mitre_data_component_ref, + ) || [], + ); + const addedRefs = [...newRefs].filter((ref) => !oldRefs.has(ref)); + const removedRefs = [...oldRefs].filter((ref) => !newRefs.has(ref)); // Emit event for newly referenced data components if (addedRefs.length > 0) { @@ -493,10 +549,6 @@ class AnalyticsService extends BaseService { dataComponentIds: removedRefs, }); } - - // Clean up instance variables - delete this._addedDataComponentRefs; - delete this._removedDataComponentRefs; } /** diff --git a/app/services/stix/attack-objects-service.js b/app/services/stix/attack-objects-service.js index eb2baf5c..81d49f05 100644 --- a/app/services/stix/attack-objects-service.js +++ b/app/services/stix/attack-objects-service.js @@ -8,6 +8,10 @@ const logger = require('../../lib/logger'); const { NotImplementedError, DatabaseError } = require('../../exceptions'); class AttackObjectsService extends BaseService { + async deprecationCheck(stixId) { + return require('./lifecycle-service').deprecationCheck(stixId); + } + /** * Override of base class retrieveAll() because: * 1. Adds special handling for relationships @@ -253,6 +257,12 @@ class AttackObjectsService extends BaseService { * @param {string[]} payload.organizationIdentityHistory */ static async handleOrganizationIdentityChanged(payload) { + const graphWriteLock = require('../../lib/graph-write-lock'); + if (!graphWriteLock.isHeld()) { + return graphWriteLock.run(() => + AttackObjectsService.handleOrganizationIdentityChanged(payload), + ); + } const { previousIdentityRef, newIdentityRef, organizationIdentityHistory } = payload; // Skip propagation on first-time setup (no previous identity to propagate from) diff --git a/app/services/stix/collection-bundles-service/import-bundle.js b/app/services/stix/collection-bundles-service/import-bundle.js index 60924e63..7762b19a 100644 --- a/app/services/stix/collection-bundles-service/import-bundle.js +++ b/app/services/stix/collection-bundles-service/import-bundle.js @@ -721,6 +721,10 @@ async function checkDuplicateCollection(importedCollection, options) { * @returns {Promise} The imported collection */ module.exports = async function importBundle(collection, data, options) { + const graphWriteLock = require('../../../lib/graph-write-lock'); + if (!graphWriteLock.isHeld()) { + return graphWriteLock.run(() => importBundle(collection, data, options)); + } const referenceImportResults = { uniqueReferences: 0, duplicateReferences: 0, diff --git a/app/services/stix/collections-service.js b/app/services/stix/collections-service.js index 4b2b63d5..07d18a5b 100644 --- a/app/services/stix/collections-service.js +++ b/app/services/stix/collections-service.js @@ -3,6 +3,7 @@ const { BaseService } = require('../meta-classes'); const collectionRepository = require('../../repository/collections-repository'); const { Collection: CollectionType } = require('../../lib/types'); +const graphWriteLock = require('../../lib/graph-write-lock'); const attackObjectsService = require('./attack-objects-service'); @@ -194,6 +195,9 @@ class CollectionsService extends BaseService { } async create(data, options = {}) { + if (!graphWriteLock.isHeld()) { + return graphWriteLock.run(() => this.create(data, options)); + } const savedCollection = await super.create(data, options); let insertionErrors = []; @@ -287,6 +291,9 @@ class CollectionsService extends BaseService { } async delete(stixId, deleteAllContents = false) { + if (!graphWriteLock.isHeld()) { + return graphWriteLock.run(() => this.delete(stixId, deleteAllContents)); + } if (!stixId) { throw new MissingParameterError('stixId'); } @@ -314,6 +321,9 @@ class CollectionsService extends BaseService { } async deleteVersionById(stixId, modified, deleteAllContents = false) { + if (!graphWriteLock.isHeld()) { + return graphWriteLock.run(() => this.deleteVersionById(stixId, modified, deleteAllContents)); + } if (!stixId) { throw new MissingParameterError('stixId'); } diff --git a/app/services/stix/data-components-service.js b/app/services/stix/data-components-service.js index 7d2a454a..f2d7758b 100644 --- a/app/services/stix/data-components-service.js +++ b/app/services/stix/data-components-service.js @@ -61,6 +61,12 @@ class DataComponentsService extends BaseService { await dataComponentsRepository.retrieveLatestByStixId(dataComponentId); if (!dataComponent) { + if (payload.options?.requireReferences) { + throw new Exceptions.NotFoundError({ + objectType: 'x-mitre-data-component', + objectId: dataComponentId, + }); + } logger.warn( `DataComponentsService: Could not find data component ${dataComponentId} to add inbound relationship`, ); @@ -95,6 +101,7 @@ class DataComponentsService extends BaseService { await dataComponentsRepository.saveDocument(dataComponent); } catch (error) { + if (payload.options?.requireReferences) throw error; logger.error( `DataComponentsService: Error handling data-components-referenced for ${dataComponentId}:`, error, @@ -121,6 +128,12 @@ class DataComponentsService extends BaseService { await dataComponentsRepository.retrieveLatestByStixId(dataComponentId); if (!dataComponent) { + if (payload.options?.requireReferences) { + throw new Exceptions.NotFoundError({ + objectType: 'x-mitre-data-component', + objectId: dataComponentId, + }); + } logger.warn( `DataComponentsService: Could not find data component ${dataComponentId} to remove inbound relationship`, ); @@ -145,6 +158,7 @@ class DataComponentsService extends BaseService { await dataComponentsRepository.saveDocument(dataComponent); } catch (error) { + if (payload.options?.requireReferences) throw error; logger.error( `DataComponentsService: Error handling data-components-removed for ${dataComponentId}:`, error, @@ -168,7 +182,6 @@ class DataComponentsService extends BaseService { * @throws {Exceptions.NotFoundError} If the referenced data source does not exist * @returns {Promise} */ - // eslint-disable-next-line no-unused-vars async beforeCreate(data, options) { // Initialize embedded_relationships if not present if (!data.workspace) { @@ -195,20 +208,18 @@ class DataComponentsService extends BaseService { const newDataSourceRef = data.stix?.x_mitre_data_source_ref; const oldDataSourceRef = previousVersion?.stix?.x_mitre_data_source_ref; - // Detect changes for event emission - if (previousVersion) { - if (oldDataSourceRef && !newDataSourceRef) { - // Data source reference was removed in this version - this._removedDataSourceRef = oldDataSourceRef; - } else if (!oldDataSourceRef && newDataSourceRef) { - // Data source reference was added in this version - this._addedDataSourceRef = newDataSourceRef; - } else if (oldDataSourceRef && newDataSourceRef && oldDataSourceRef !== newDataSourceRef) { - // Data source reference changed - this._removedDataSourceRef = oldDataSourceRef; - this._addedDataSourceRef = newDataSourceRef; - } - } + // Hook state belongs to this invocation, never the singleton service. + options._removedDataSourceRefs ??= new Map(); + options._removedDataSourceRefs.set( + `${data.stix.id}:${new Date(data.stix.modified).getTime()}`, + oldDataSourceRef && oldDataSourceRef !== newDataSourceRef ? oldDataSourceRef : null, + ); + + const baselineEmbeddedRelationships = + previousVersion?.workspace?.embedded_relationships || data.workspace.embedded_relationships; + data.workspace.embedded_relationships = baselineEmbeddedRelationships.filter( + (rel) => !(rel.direction === 'outbound' && rel.stix_id?.startsWith('x-mitre-data-source--')), + ); if (newDataSourceRef) { const dataSource = await dataSourcesRepository.retrieveLatestByStixId(newDataSourceRef); @@ -246,8 +257,17 @@ class DataComponentsService extends BaseService { * @returns {Promise} */ async afterCreate(createdDocument, options) { - const addedRef = this._addedDataSourceRef; - const removedRef = this._removedDataSourceRef; + const key = `${createdDocument.stix.id}:${new Date(createdDocument.stix.modified).getTime()}`; + const removedRef = options._removedDataSourceRefs?.get(key); + options._removedDataSourceRefs?.delete(key); + // Historical imports must not overwrite the authoritative latest backlinks. + const latest = await this.repository.retrieveLatestByStixId(createdDocument.stix.id); + if ( + new Date(latest.stix.modified).getTime() !== new Date(createdDocument.stix.modified).getTime() + ) { + return; + } + const addedRef = createdDocument.stix?.x_mitre_data_source_ref; // Emit event for newly referenced data source if (addedRef) { @@ -256,12 +276,15 @@ class DataComponentsService extends BaseService { { stixId: createdDocument.stix.id, dataSourceId: addedRef }, ); - await EventBus.emit('x-mitre-data-component::data-source-referenced', { - dataComponentId: createdDocument.stix.id, - dataComponent: createdDocument.toObject ? createdDocument.toObject() : createdDocument, - dataSourceId: addedRef, - options, - }); + await EventBus[options.requireReferences ? 'emitRequired' : 'emit']( + 'x-mitre-data-component::data-source-referenced', + { + dataComponentId: createdDocument.stix.id, + dataComponent: createdDocument.toObject ? createdDocument.toObject() : createdDocument, + dataSourceId: addedRef, + options, + }, + ); } // Emit event for removed data source (when creating a new version without the reference) @@ -271,33 +294,15 @@ class DataComponentsService extends BaseService { { stixId: createdDocument.stix.id, dataSourceId: removedRef }, ); - await EventBus.emit('x-mitre-data-component::data-source-removed', { - dataComponentId: createdDocument.stix.id, - dataSourceId: removedRef, - options, - }); - } - - // If no changes detected but there is a current reference, emit referenced event - // (this handles the case where this is the first version being created) - const currentDataSourceRef = createdDocument.stix?.x_mitre_data_source_ref; - if (!addedRef && !removedRef && currentDataSourceRef) { - logger.info( - `DataComponentsService: Emitting data-source-referenced event for data source ${currentDataSourceRef}`, - { stixId: createdDocument.stix.id, dataSourceId: currentDataSourceRef }, + await EventBus[options.requireReferences ? 'emitRequired' : 'emit']( + 'x-mitre-data-component::data-source-removed', + { + dataComponentId: createdDocument.stix.id, + dataSourceId: removedRef, + options, + }, ); - - await EventBus.emit('x-mitre-data-component::data-source-referenced', { - dataComponentId: createdDocument.stix.id, - dataComponent: createdDocument.toObject ? createdDocument.toObject() : createdDocument, - dataSourceId: currentDataSourceRef, - options, - }); } - - // Clean up instance variables - delete this._addedDataSourceRef; - delete this._removedDataSourceRef; } /** @@ -329,7 +334,7 @@ class DataComponentsService extends BaseService { // Preserve existing non-data-source embedded relationships (e.g., inbound from analytics) const existingNonDataSourceRels = (data.workspace.embedded_relationships || []).filter( - (rel) => !rel.stix_id?.startsWith('x-mitre-data-source--'), + (rel) => !(rel.direction === 'outbound' && rel.stix_id?.startsWith('x-mitre-data-source--')), ); // Build new outbound embedded relationship for data source @@ -357,22 +362,6 @@ class DataComponentsService extends BaseService { ...existingNonDataSourceRels, ...dataSourceEmbeddedRel, ]; - - // Detect changes in data source reference for event emission - const oldDataSourceRef = existingDocument.stix?.x_mitre_data_source_ref; - - // Determine what changed - if (oldDataSourceRef && !newDataSourceRef) { - // Data source removed (set to null/undefined) - this._removedDataSourceRef = oldDataSourceRef; - } else if (!oldDataSourceRef && newDataSourceRef) { - // Data source added - this._addedDataSourceRef = newDataSourceRef; - } else if (oldDataSourceRef && newDataSourceRef && oldDataSourceRef !== newDataSourceRef) { - // Data source changed - this._removedDataSourceRef = oldDataSourceRef; - this._addedDataSourceRef = newDataSourceRef; - } } /** @@ -380,13 +369,14 @@ class DataComponentsService extends BaseService { * Emits domain events for added/removed data source references * * @param {Object} updatedDocument - The updated data component document - * @param {Object} _previousDocument - The previous version of the data component (unused) + * @param {Object} previousDocument - The previous version of the data component * @returns {Promise} */ - // eslint-disable-next-line no-unused-vars - async afterUpdate(updatedDocument, _previousDocument) { - const addedRef = this._addedDataSourceRef; - const removedRef = this._removedDataSourceRef; + async afterUpdate(updatedDocument, previousDocument) { + const oldRef = previousDocument.stix?.x_mitre_data_source_ref; + const newRef = updatedDocument.stix?.x_mitre_data_source_ref; + const addedRef = newRef !== oldRef ? newRef : null; + const removedRef = oldRef !== newRef ? oldRef : null; // Emit event for newly referenced data source if (addedRef) { @@ -414,10 +404,6 @@ class DataComponentsService extends BaseService { dataSourceId: removedRef, }); } - - // Clean up instance variables - delete this._addedDataSourceRef; - delete this._removedDataSourceRef; } } diff --git a/app/services/stix/data-sources-service.js b/app/services/stix/data-sources-service.js index df8a8d5c..4a906da9 100644 --- a/app/services/stix/data-sources-service.js +++ b/app/services/stix/data-sources-service.js @@ -56,6 +56,10 @@ class DataSourcesService extends BaseService { const dataSource = await dataSourcesRepository.retrieveLatestByStixId(dataSourceId); if (!dataSource) { + if (payload.options?.requireReferences) { + const { NotFoundError } = require('../../exceptions'); + throw new NotFoundError({ stix_id: dataSourceId }); + } logger.warn( `DataSourcesService: Could not find data source ${dataSourceId} to add inbound relationship`, ); @@ -90,6 +94,7 @@ class DataSourcesService extends BaseService { await dataSourcesRepository.saveDocument(dataSource); } catch (error) { + if (payload.options?.requireReferences) throw error; logger.error( `DataSourcesService: Error handling data-source-referenced for ${dataSourceId}:`, error, @@ -117,6 +122,10 @@ class DataSourcesService extends BaseService { const dataSource = await dataSourcesRepository.retrieveLatestByStixId(dataSourceId); if (!dataSource) { + if (payload.options?.requireReferences) { + const { NotFoundError } = require('../../exceptions'); + throw new NotFoundError({ stix_id: dataSourceId }); + } logger.warn( `DataSourcesService: Could not find data source ${dataSourceId} to remove inbound relationship`, ); @@ -141,6 +150,7 @@ class DataSourcesService extends BaseService { await dataSourcesRepository.saveDocument(dataSource); } catch (error) { + if (payload.options?.requireReferences) throw error; logger.error( `DataSourcesService: Error handling data-source-removed for ${dataSourceId}:`, error, diff --git a/app/services/stix/detection-strategies-service.js b/app/services/stix/detection-strategies-service.js index d8ddb92f..27046c98 100644 --- a/app/services/stix/detection-strategies-service.js +++ b/app/services/stix/detection-strategies-service.js @@ -7,7 +7,6 @@ const { DetectionStrategy: DetectionStrategyType } = require('../../lib/types'); const logger = require('../../lib/logger'); const EventBus = require('../../lib/event-bus'); const { NotFoundError } = require('../../exceptions'); -const assertions = require('../../lib/assertions'); /** * Service for managing detection strategies @@ -23,29 +22,12 @@ const assertions = require('../../lib/assertions'); * - x-mitre-detection-strategy::analytics-removed */ class DetectionStrategiesService extends BaseService { - /** - * Assertion: Verify x_mitre_analytic_refs contains only unique values - * (This should never actually throw in practice. We have (or will have) validation middleware - * that checks the request body before the service layer runs. That middleware is powered by - * the `@mitre-attack/attack-data-model` library which checks for this condition implicitly. - * This assertion thus serves as a fail-safe in case the middleware is ever somehow bypassed. - * It will throw, causing a 500 exception, but it will block "bad data" from entering the - * database.) - */ - async _assertAnalyticRefsAreUnique(data) { - assertions.assertUnique(data.stix?.x_mitre_analytic_refs, 'x_mitre_analytic_refs', { - stixId: data.stix?.id || 'unknown', - }); - } - /** * Prepare detection strategy data before creation * Build outbound embedded_relationships for x_mitre_analytic_refs * Detects if this is a new version and tracks removed relationships */ - async beforeCreate(data) { - this._assertAnalyticRefsAreUnique(data); - + async beforeCreate(data, options) { // Initialize workspace if not present if (!data.workspace) { data.workspace = {}; @@ -66,14 +48,14 @@ class DetectionStrategiesService extends BaseService { // Build outbound embedded_relationships for x_mitre_analytic_refs // Cross-repository READS are allowed for denormalization (see CROSS_SERVICE_READS_PATTERN.md) // We emit events in afterCreate/afterUpdate for cross-service WRITES - const newAnalyticRefs = data.stix?.x_mitre_analytic_refs || []; + const newAnalyticRefs = [...new Set(data.stix?.x_mitre_analytic_refs || [])]; const oldAnalyticRefs = previousVersion?.stix?.x_mitre_analytic_refs || []; - // Detect changes for event emission - if (previousVersion) { - this._addedAnalyticRefs = newAnalyticRefs.filter((ref) => !oldAnalyticRefs.includes(ref)); - this._removedAnalyticRefs = oldAnalyticRefs.filter((ref) => !newAnalyticRefs.includes(ref)); - } + options._removedAnalyticRefs ??= new Map(); + options._removedAnalyticRefs.set( + `${data.stix.id}:${new Date(data.stix.modified).getTime()}`, + [...new Set(oldAnalyticRefs)].filter((ref) => !newAnalyticRefs.includes(ref)), + ); // Preserve non-analytic embedded_relationships from the previous version when POST is // creating a new version. Client payloads often omit server-managed workspace metadata, @@ -85,7 +67,7 @@ class DetectionStrategiesService extends BaseService { // Rebuild only the analytic outbound relationships for the new version. const existingNonAnalyticRels = baselineEmbeddedRelationships.filter( - (rel) => !rel.stix_id?.startsWith('x-mitre-analytic--'), + (rel) => !(rel.direction === 'outbound' && rel.stix_id?.startsWith('x-mitre-analytic--')), ); const analyticEmbeddedRels = []; @@ -122,8 +104,14 @@ class DetectionStrategiesService extends BaseService { * See app/lib/import-safety.js for the contract. */ async afterCreate(document, options) { - const addedRefs = this._addedAnalyticRefs || []; - const removedRefs = this._removedAnalyticRefs || []; + const key = `${document.stix.id}:${new Date(document.stix.modified).getTime()}`; + const removedRefs = options._removedAnalyticRefs?.get(key) || []; + options._removedAnalyticRefs?.delete(key); + const latest = await this.repository.retrieveLatestByStixId(document.stix.id); + if (new Date(latest.stix.modified).getTime() !== new Date(document.stix.modified).getTime()) { + return; + } + const addedRefs = [...new Set(document.stix?.x_mitre_analytic_refs || [])]; // Emit event for newly referenced analytics if (addedRefs.length > 0) { @@ -132,12 +120,15 @@ class DetectionStrategiesService extends BaseService { { stixId: document.stix.id, analyticIds: addedRefs }, ); - await EventBus.emit('x-mitre-detection-strategy::analytics-referenced', { - detectionStrategyId: document.stix.id, - detectionStrategy: document.toObject ? document.toObject() : document, - analyticIds: addedRefs, - options, - }); + await EventBus[options.requireReferences ? 'emitRequired' : 'emit']( + 'x-mitre-detection-strategy::analytics-referenced', + { + detectionStrategyId: document.stix.id, + detectionStrategy: document.toObject ? document.toObject() : document, + analyticIds: addedRefs, + options, + }, + ); } // Emit event for removed analytics (when creating a new version without the analytics) @@ -147,33 +138,15 @@ class DetectionStrategiesService extends BaseService { { stixId: document.stix.id, analyticIds: removedRefs }, ); - await EventBus.emit('x-mitre-detection-strategy::analytics-removed', { - detectionStrategyId: document.stix.id, - analyticIds: removedRefs, - options, - }); - } - - // If no changes detected but there are current analytics, emit referenced event - // (this handles the case where this is the first version being created) - const currentAnalyticRefs = document.stix?.x_mitre_analytic_refs || []; - if (!addedRefs.length && !removedRefs.length && currentAnalyticRefs.length > 0) { - logger.info( - `DetectionStrategiesService: Emitting analytics-referenced event for ${currentAnalyticRefs.length} analytic(s)`, - { stixId: document.stix.id, analyticIds: currentAnalyticRefs }, + await EventBus[options.requireReferences ? 'emitRequired' : 'emit']( + 'x-mitre-detection-strategy::analytics-removed', + { + detectionStrategyId: document.stix.id, + analyticIds: removedRefs, + options, + }, ); - - await EventBus.emit('x-mitre-detection-strategy::analytics-referenced', { - detectionStrategyId: document.stix.id, - detectionStrategy: document.toObject ? document.toObject() : document, - analyticIds: currentAnalyticRefs, - options, - }); } - - // Clean up instance variables - delete this._addedAnalyticRefs; - delete this._removedAnalyticRefs; } /** @@ -182,14 +155,7 @@ class DetectionStrategiesService extends BaseService { */ // eslint-disable-next-line no-unused-vars async beforeUpdate(stixId, stixModified, data, existingDocument, options) { - this._assertAnalyticRefsAreUnique(data); - - const oldAnalyticRefs = existingDocument.stix?.x_mitre_analytic_refs || []; - const newAnalyticRefs = data.stix?.x_mitre_analytic_refs || []; - - // Store change detection for afterUpdate - this._addedAnalyticRefs = newAnalyticRefs.filter((ref) => !oldAnalyticRefs.includes(ref)); - this._removedAnalyticRefs = oldAnalyticRefs.filter((ref) => !newAnalyticRefs.includes(ref)); + const newAnalyticRefs = [...new Set(data.stix?.x_mitre_analytic_refs || [])]; // Update embedded_relationships in the data being saved if (!data.workspace) { @@ -201,7 +167,7 @@ class DetectionStrategiesService extends BaseService { // Rebuild the analytic portion of embedded_relationships const existingNonAnalyticRels = (data.workspace.embedded_relationships || []).filter( - (rel) => !rel.stix_id?.startsWith('x-mitre-analytic--'), + (rel) => !(rel.direction === 'outbound' && rel.stix_id?.startsWith('x-mitre-analytic--')), ); const analyticEmbeddedRels = []; @@ -233,9 +199,11 @@ class DetectionStrategiesService extends BaseService { * Handle post-update logic * Emit domain events for analytics that were added or removed */ - async afterUpdate(updatedDocument) { - const addedRefs = this._addedAnalyticRefs || []; - const removedRefs = this._removedAnalyticRefs || []; + async afterUpdate(updatedDocument, previousDocument) { + const oldRefs = new Set(previousDocument.stix?.x_mitre_analytic_refs || []); + const newRefs = new Set(updatedDocument.stix?.x_mitre_analytic_refs || []); + const addedRefs = [...newRefs].filter((ref) => !oldRefs.has(ref)); + const removedRefs = [...oldRefs].filter((ref) => !newRefs.has(ref)); // Emit event for newly referenced analytics if (addedRefs.length > 0) { @@ -263,10 +231,6 @@ class DetectionStrategiesService extends BaseService { analyticIds: removedRefs, }); } - - // Clean up instance variables - delete this._addedAnalyticRefs; - delete this._removedAnalyticRefs; } } diff --git a/app/services/stix/lifecycle-service.js b/app/services/stix/lifecycle-service.js new file mode 100644 index 00000000..b0971ac4 --- /dev/null +++ b/app/services/stix/lifecycle-service.js @@ -0,0 +1,225 @@ +'use strict'; + +const _ = require('lodash'); +const objects = require('../../repository/attack-objects-repository'); +const relationships = require('../../repository/relationships-repository'); +const { + references, + addedReferences, + substitute, + mergeOutgoing, +} = require('../../lib/domain-references'); +const { + NotFoundError, + LifecycleConflictError, + DeprecationBlockedError, +} = require('../../exceptions'); + +const inactive = (document) => + document?.stix?.revoked === true || document?.stix?.x_mitre_deprecated === true; +const repositoryFor = (id) => (id?.startsWith('relationship--') ? relationships : objects); +const latest = (id) => repositoryFor(id).retrieveLatestByStixIdLean(id); + +async function deprecationCheck(stixId, proposed) { + const current = await latest(stixId); + if (!current && !proposed) throw new NotFoundError({ stix_id: stixId }); + const check = { stix_id: stixId, can_deprecate: true, blockers: { sros: [], embedded: [] } }; + if ((current || proposed).stix.type === 'relationship') return check; + + const [sros, graph] = await Promise.all([ + relationships.retrieveAll({ versions: 'latest', sourceOrTargetRef: stixId }), + objects.retrieveLatestGraphObjects(), + ]); + for (const { stix } of sros) { + // Preserve existing hierarchy and replacement links when retiring an SDO. + if (stix.relationship_type === 'subtechnique-of' || stix.relationship_type === 'revoked-by') + continue; + for (const [key, direction] of [ + ['source_ref', 'outbound'], + ['target_ref', 'inbound'], + ]) { + if (stix[key] === stixId) + check.blockers.sros.push({ + stix_id: stix.id, + modified: new Date(stix.modified).toISOString(), + relationship_type: stix.relationship_type, + direction, + }); + } + } + const seen = new Set(); + // A new revision may atomically remove its own outbound references. + const authoritative = proposed + ? [...graph.filter((document) => document.stix.id !== stixId), proposed] + : graph; + for (const document of authoritative) { + for (const ref of references(document.stix)) { + for (const [key, direction] of [ + ['source_ref', 'outbound'], + ['target_ref', 'inbound'], + ]) { + if (ref[key] !== stixId) continue; + const blocker = { ...ref, direction }; + const keyValue = JSON.stringify(blocker); + if (!seen.has(keyValue)) { + check.blockers.embedded.push(blocker); + seen.add(keyValue); + } + } + } + } + check.can_deprecate = !check.blockers.sros.length && !check.blockers.embedded.length; + return check; +} + +async function assertAuthoring(data, previous) { + const { stix } = data; + if (stix.type === 'relationship') { + // Retirement is legal even when the endpoints have already become inactive. + if (inactive(data) || stix.relationship_type === 'revoked-by') return; + const endpoints = await Promise.all([latest(stix.source_ref), latest(stix.target_ref)]); + const blocked = endpoints.filter(inactive).map((doc) => doc.stix.id); + if (blocked.length) + throw new LifecycleConflictError('Active relationships require active endpoints', { + code: 'inactive_reference', + references: blocked, + }); + return; + } + + const added = addedReferences(stix, previous?.stix); + const becomesLatest = !previous || new Date(stix.modified) > new Date(previous.stix.modified); + if ( + becomesLatest && + stix.x_mitre_deprecated === true && + (previous?.stix?.x_mitre_deprecated !== true || added.length > 0) + ) { + const check = await deprecationCheck(stix.id, data); + if (!check.can_deprecate) throw new DeprecationBlockedError(check); + } + const targets = new Map(); + for (const ref of added) { + if (!targets.has(ref.target_ref)) targets.set(ref.target_ref, await latest(ref.target_ref)); + const target = targets.get(ref.target_ref); + if (!target || inactive(target)) + throw new LifecycleConflictError( + 'New embedded references require an existing active target', + { + code: 'inactive_reference', + references: [ref], + }, + ); + } +} + +function revision(document) { + const data = _.cloneDeep(document.toObject ? document.toObject() : document); + delete data._id; + delete data.__v; + delete data.__t; + delete data.warnings; + if (data.workspace) { + delete data.workspace.release_tracks; + delete data.workspace.collections; + } + data.stix.modified = new Date( + Math.max(Date.now(), new Date(data.stix.modified).getTime() + 1), + ).toISOString(); + return data; +} + +async function planEmbeddedTransfer(objectA, objectB) { + objectA = objectA.toObject ? objectA.toObject() : objectA; + objectB = objectB.toObject ? objectB.toObject() : objectB; + const a = objectA.stix.id; + const b = objectB.stix.id; + const graph = await objects.retrieveLatestGraphObjects(); + const revisions = new Map(); + const bData = revision(objectB); + bData.stix = mergeOutgoing(bData.stix, objectA.stix, a, b); + for (const document of graph) { + if (document.stix.id === a) continue; + if (!references(document.stix).some((ref) => ref.target_ref === a)) continue; + if (inactive(document)) + throw new LifecycleConflictError( + 'An inactive referrer cannot receive a replacement revision', + { + code: 'inactive_referrer', + source_ref: document.stix.id, + target_ref: a, + }, + ); + const changed = document.stix.id === b ? bData : revision(document); + changed.stix = substitute(changed.stix, a, b); + revisions.set(document.stix.id, changed); + } + // modified alone does not justify creating a replacement revision. + const comparable = { ...bData.stix, modified: objectB.stix.modified }; + if (!_.isEqual(comparable, objectB.stix)) revisions.set(b, bData); + return [...revisions.values()]; +} + +async function createRevision(data, options) { + const EventBus = require('../../lib/event-bus'); + try { + const results = await EventBus.emitRequired( + `${data.stix.type}::lifecycle-create-requested`, + { data: _.cloneDeep(data), options }, + { minimumListeners: 1 }, + ); + return results[0]; + } catch (error) { + if (error.failures?.length === 1) throw error.failures[0]; + throw error; + } +} + +async function reconcileEmbeddedMetadata(changed) { + const EventBus = require('../../lib/event-bus'); + const graph = await objects.retrieveLatestGraphObjects(); + const byId = new Map(graph.map((document) => [document.stix.id, document])); + const affected = new Set(changed.map((document) => document.stix.id)); + for (const document of changed) { + for (const ref of references(document.stix)) affected.add(ref.target_ref); + } + const metadata = new Map([...affected].map((id) => [id, new Map()])); + for (const document of graph) { + for (const ref of references(document.stix)) { + if (metadata.has(ref.source_ref)) + metadata.get(ref.source_ref).set(`outbound:${ref.target_ref}`, { + stix_id: ref.target_ref, + direction: 'outbound', + attack_id: byId.get(ref.target_ref)?.workspace?.attack_id, + }); + if (metadata.has(ref.target_ref)) + metadata.get(ref.target_ref).set(`inbound:${ref.source_ref}`, { + stix_id: ref.source_ref, + direction: 'inbound', + attack_id: document.workspace?.attack_id, + }); + } + } + for (const [stixId, entries] of metadata) { + const document = byId.get(stixId); + if (!document) continue; + await EventBus.emitRequired( + `${document.stix.type}::lifecycle-cache-requested`, + { + stixId, + embeddedRelationships: [...entries.values()], + }, + { minimumListeners: 1 }, + ); + } +} + +module.exports = { + inactive, + latest, + deprecationCheck, + assertAuthoring, + revision, + planEmbeddedTransfer, + createRevision, + reconcileEmbeddedMetadata, +}; diff --git a/app/services/stix/relationships-service.js b/app/services/stix/relationships-service.js index 887f772a..e10df3cb 100644 --- a/app/services/stix/relationships-service.js +++ b/app/services/stix/relationships-service.js @@ -109,24 +109,6 @@ class RelationshipsService extends BaseService { * Called once on module load. */ static initializeEventListeners() { - const revokedEvents = [ - EventConstants.ATTACK_PATTERN_REVOKED, - EventConstants.TACTIC_REVOKED, - EventConstants.COURSE_OF_ACTION_REVOKED, - EventConstants.INTRUSION_SET_REVOKED, - EventConstants.MALWARE_REVOKED, - EventConstants.TOOL_REVOKED, - EventConstants.CAMPAIGN_REVOKED, - EventConstants.DATA_SOURCE_REVOKED, - EventConstants.DATA_COMPONENT_REVOKED, - EventConstants.MATRIX_REVOKED, - EventConstants.ASSET_REVOKED, - ]; - - for (const event of revokedEvents) { - EventBus.on(event, this.handleObjectRevoked.bind(this)); - } - EventBus.on( EventConstants.TECHNIQUE_CONVERTED_TO_SUBTECHNIQUE, this.handleTechniqueConvertedToSubtechnique.bind(this), @@ -320,75 +302,6 @@ class RelationshipsService extends BaseService { return { deprecated: deprecatedDocs, warnings }; } - /** - * Handle an object being revoked by deprecating all relationships that reference it. - * Creates a new version of each relationship with x_mitre_deprecated = true and bumped modified, - * preserving the original version in history. - * @param {object} payload - Event payload - * @param {string} payload.stixId - STIX ID of the revoked object - * @param {string[]} [payload.excludeRelationshipIds] - Relationship STIX IDs to skip (e.g. the revoked-by relationship) - */ - static async handleObjectRevoked(payload) { - const { stixId, excludeRelationshipIds = [] } = payload; - - logger.info(`RelationshipsService heard event: object revoked for ${stixId}`); - - const deprecatedDocs = []; - const warnings = []; - - try { - const relationships = await relationshipsRepository.retrieveAllBySourceOrTarget(stixId); - - const toDeprecate = relationships.filter( - (rel) => !excludeRelationshipIds.includes(rel.stix.id), - ); - - for (const rel of toDeprecate) { - try { - const relData = rel.toObject ? rel.toObject() : { ...rel }; - delete relData._id; - delete relData.__v; - delete relData.__t; - - relData.stix.x_mitre_deprecated = true; - relData.stix.modified = new Date().toISOString(); - // Backrefs are pinned to the exact revision a track references — - // never carried onto a new revision. - if (relData.workspace) { - delete relData.workspace.release_tracks; - } - - const saved = await relationshipsRepository.save(relData); - deprecatedDocs.push(saved); - - logger.info( - `Deprecated relationship ${rel.stix.id} (was referencing revoked object ${stixId})`, - ); - } catch (error) { - logger.error(`Failed to deprecate relationship ${rel.stix.id}: ${error.message}`); - warnings.push({ - message: 'Failed to deprecate relationship', - relationshipId: rel.stix.id, - error: error.message, - }); - } - } - - logger.info( - `RelationshipsService: deprecated ${deprecatedDocs.length}/${toDeprecate.length} relationships for revoked object ${stixId}`, - ); - } catch (error) { - logger.error(`RelationshipsService: Error handling object revoked for ${stixId}:`, error); - warnings.push({ - message: 'Failed to deprecate relationships for revoked object', - stixId, - error: error.message, - }); - } - - return { deprecated: deprecatedDocs, warnings }; - } - async retrieveAll(options) { let results = await this.repository.retrieveAll(options); diff --git a/app/services/stix/software-service.js b/app/services/stix/software-service.js index fb7651f8..55145940 100644 --- a/app/services/stix/software-service.js +++ b/app/services/stix/software-service.js @@ -8,6 +8,12 @@ const softwareRepository = require('../../repository/software-repository'); const { Malware: MalwareType, Tool: ToolType } = require('../../lib/types'); class SoftwareService extends BaseService { + constructor(type, repository) { + super(type, repository); + this.registerLifecycleType(MalwareType); + this.registerLifecycleType(ToolType); + } + /** * Ensure x_mitre_aliases[0] is always the object's own name. * @@ -81,6 +87,8 @@ class SoftwareService extends BaseService { * We temporarily set this.type to match the incoming data type so BaseService validation passes. */ async create(data, options) { + const graphWriteLock = require('../../lib/graph-write-lock'); + if (!graphWriteLock.isHeld()) return graphWriteLock.run(() => this.create(data, options)); // Validate that the type is either malware or tool if (data?.stix?.type !== MalwareType && data?.stix?.type !== ToolType) { const { InvalidTypeError } = require('../../exceptions'); diff --git a/app/services/stix/techniques-service.js b/app/services/stix/techniques-service.js index ccc8f9c2..75ffec58 100644 --- a/app/services/stix/techniques-service.js +++ b/app/services/stix/techniques-service.js @@ -53,6 +53,10 @@ class TechniquesService extends BaseService { * @param {string[]} payload.domains - The tactic's x_mitre_domains (e.g. ['enterprise-attack']) */ static async handleTacticShortnameChanged(payload) { + const graphWriteLock = require('../../lib/graph-write-lock'); + if (!graphWriteLock.isHeld()) { + return graphWriteLock.run(() => TechniquesService.handleTacticShortnameChanged(payload)); + } const { tacticId, oldShortname, newShortname, domains = [], createNewVersion } = payload; // Convert the tactic's domains to kill chain names so we only update @@ -259,6 +263,10 @@ class TechniquesService extends BaseService { * @returns {Object} The newly created subtechnique version */ async convertToSubtechnique(stixId, data, options = {}) { + const graphWriteLock = require('../../lib/graph-write-lock'); + if (!graphWriteLock.isHeld()) { + return graphWriteLock.run(() => this.convertToSubtechnique(stixId, data, options)); + } // Lazy-load to avoid circular dependency const relationshipsRepository = require('../../repository/relationships-repository'); @@ -298,6 +306,16 @@ class TechniquesService extends BaseService { details: `Parent technique with ATT&CK ID ${data.parentTechniqueAttackId} not found`, }); } + const lifecycle = require('./lifecycle-service'); + if (lifecycle.inactive(technique) || lifecycle.inactive(parentTechnique)) { + const { LifecycleConflictError } = require('../../exceptions'); + throw new LifecycleConflictError('Subtechnique conversion requires active endpoints', { + code: 'inactive_reference', + references: [technique, parentTechnique] + .filter(lifecycle.inactive) + .map((doc) => doc.stix.id), + }); + } // Check if this technique has child subtechniques (via subtechnique-of SROs). // Cross-service READ is permitted per architecture guidelines. @@ -391,6 +409,10 @@ class TechniquesService extends BaseService { * @returns {Object} The newly created technique version */ async convertToTechnique(stixId, options = {}) { + const graphWriteLock = require('../../lib/graph-write-lock'); + if (!graphWriteLock.isHeld()) { + return graphWriteLock.run(() => this.convertToTechnique(stixId, options)); + } if (!stixId) { throw new MissingParameterError('stixId'); } diff --git a/app/tests/api/attack-objects/attack-objects.lifecycle.spec.js b/app/tests/api/attack-objects/attack-objects.lifecycle.spec.js new file mode 100644 index 00000000..bfcf14b0 --- /dev/null +++ b/app/tests/api/attack-objects/attack-objects.lifecycle.spec.js @@ -0,0 +1,656 @@ +const request = require('supertest'); +const { expect } = require('expect'); +const { randomUUID } = require('crypto'); + +const database = require('../../../lib/database-in-memory'); +const databaseConfiguration = require('../../../lib/database-configuration'); +const config = require('../../../config/config'); +const login = require('../../shared/login'); +const AttackObject = require('../../../models/attack-object-model'); + +// Exercise the public API with ADM enabled. The sole model write below constructs +// legacy imported inactive objects that cannot be authored through guarded APIs. +describe('Attack object lifecycle graph integrity API', function () { + let app; + let cookie; + + before(async function () { + await database.initializeConnection(); + await databaseConfiguration.checkSystemConfiguration(); + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + app = await require('../../../index').initializeApp(); + const session = await login.loginAnonymous(app); + cookie = `${session.name}=${session.value}`; + }); + + after(async function () { + await database.closeConnection(); + }); + + function api(method, path, body) { + const call = request(app)[method](path).set('Accept', 'application/json').set('Cookie', cookie); + return body === undefined ? call : call.send(body); + } + + function payload(type, fields = {}) { + const timestamp = new Date().toISOString(); + return { + workspace: { workflow: { state: 'work-in-progress' } }, + stix: { + type, + spec_version: '2.1', + created: timestamp, + modified: timestamp, + ...(type === 'relationship' + ? {} + : { + name: `lifecycle-${randomUUID()}`, + x_mitre_domains: ['enterprise-attack'], + }), + ...fields, + }, + }; + } + + async function create(resource, type, fields = {}) { + const response = await api('post', `/api/${resource}`, payload(type, fields)).expect(201); + return response.body; + } + + function technique() { + return create('techniques', 'attack-pattern', { + x_mitre_is_subtechnique: false, + x_mitre_platforms: ['Windows'], + }); + } + + function component(fields = {}) { + return create('data-components', 'x-mitre-data-component', fields); + } + + function analytic(refs) { + return create('analytics', 'x-mitre-analytic', { + x_mitre_platforms: ['Windows'], + ...(refs ? { x_mitre_log_source_references: refs } : {}), + }); + } + + function logSource(target, name = 'Security', channel = 'Operational') { + return { x_mitre_data_component_ref: target.stix.id, name, channel }; + } + + function relationshipBody(source, target, type = 'subtechnique-of') { + return payload('relationship', { + relationship_type: type, + source_ref: source.stix.id, + target_ref: target.stix.id, + }); + } + + function revision(object, changes = {}) { + return { + workspace: { workflow: { state: 'work-in-progress' } }, + stix: { + ...object.stix, + modified: new Date( + Math.max(Date.now(), Date.parse(object.stix.modified) + 1), + ).toISOString(), + ...changes, + }, + }; + } + + async function revise(resource, object, changes) { + const response = await api('post', `/api/${resource}`, revision(object, changes)).expect(201); + return response.body; + } + + async function latest(resource, object) { + const response = await api('get', `/api/${resource}/${object.stix.id}?versions=latest`).expect( + 200, + ); + expect(response.body).toHaveLength(1); + return response.body[0]; + } + + async function versions(resource, object) { + const response = await api('get', `/api/${resource}/${object.stix.id}?versions=all`).expect( + 200, + ); + return response.body; + } + + async function check(object) { + const response = await api( + 'get', + `/api/attack-objects/${object.stix.id}/deprecation-check`, + ).expect(200); + return response.body; + } + + async function legacyInactive(object, field = 'x_mitre_deprecated') { + await AttackObject.collection.updateOne( + { 'stix.id': object.stix.id, 'stix.modified': new Date(object.stix.modified) }, + { $set: { [`stix.${field}`]: true } }, + ); + return { ...object, stix: { ...object.stix, [field]: true } }; + } + + function revoke(resource, original, replacement, query = '') { + return api( + 'post', + `/api/${resource}/${original.stix.id}/revoke?preserveRelationships=true${query}`, + { + revoking: { stixId: replacement.stix.id, modified: replacement.stix.modified }, + }, + ); + } + + it('returns 404 for a missing deprecation-check object', async function () { + await api( + 'get', + `/api/attack-objects/attack-pattern--${randomUUID()}/deprecation-check`, + ).expect(404); + }); + + for (const relationshipType of ['subtechnique-of', 'revoked-by']) { + for (const endpoint of ['source', 'target']) { + it(`preserves active ${relationshipType} when deprecating its ${endpoint}`, async function () { + const source = await technique(); + const target = await technique(); + const response = await api( + 'post', + '/api/relationships', + relationshipBody(source, target, relationshipType), + ).expect(201); + const relationship = response.body; + const object = endpoint === 'source' ? source : target; + expect(await check(object)).toEqual({ + stix_id: object.stix.id, + can_deprecate: true, + blockers: { sros: [], embedded: [] }, + }); + expect( + (await revise('techniques', object, { x_mitre_deprecated: true })).stix + .x_mitre_deprecated, + ).toBe(true); + expect((await latest('relationships', relationship)).stix).toEqual(relationship.stix); + expect(await versions('relationships', relationship)).toHaveLength(1); + + // The cascade exemption does not prevent explicitly retiring the SRO itself. + expect(await check(relationship)).toEqual({ + stix_id: relationship.stix.id, + can_deprecate: true, + blockers: { sros: [], embedded: [] }, + }); + const retired = await revise('relationships', relationship, { x_mitre_deprecated: true }); + expect(retired.stix.x_mitre_deprecated).toBe(true); + expect(await versions('relationships', relationship)).toHaveLength(2); + }); + } + } + + it('blocks ordinary SROs in both directions while preserving hierarchy and replacement links', async function () { + const source = await create('software', 'malware', { is_family: true }); + const target = await technique(); + const parent = await technique(); + const predecessor = await technique(); + const hierarchy = await api( + 'post', + '/api/relationships', + relationshipBody(target, parent, 'subtechnique-of'), + ).expect(201); + const replacement = await api( + 'post', + '/api/relationships', + relationshipBody(predecessor, target, 'revoked-by'), + ).expect(201); + const ordinary = await api( + 'post', + '/api/relationships', + relationshipBody(source, target, 'uses'), + ).expect(201); + for (const [object, resource, direction] of [ + [source, 'software', 'outbound'], + [target, 'techniques', 'inbound'], + ]) { + const eligibility = await check(object); + expect(eligibility).toEqual({ + stix_id: object.stix.id, + can_deprecate: false, + blockers: { + sros: [ + { + stix_id: ordinary.body.stix.id, + modified: ordinary.body.stix.modified, + relationship_type: 'uses', + direction, + }, + ], + embedded: [], + }, + }); + const rejected = await api( + 'post', + `/api/${resource}`, + revision(object, { x_mitre_deprecated: true }), + ).expect(409); + expect(rejected.body).toEqual({ + message: expect.any(String), + code: 'deprecation_blocked', + ...eligibility, + }); + expect(await versions(resource, object)).toHaveLength(1); + } + await revise('relationships', ordinary.body, { x_mitre_deprecated: true }); + for (const [object, resource] of [ + [source, 'software'], + [target, 'techniques'], + ]) { + expect((await check(object)).can_deprecate).toBe(true); + await revise(resource, object, { x_mitre_deprecated: true }); + } + for (const preserved of [hierarchy.body, replacement.body]) { + expect((await latest('relationships', preserved)).stix).toEqual(preserved.stix); + expect(await versions('relationships', preserved)).toHaveLength(1); + } + }); + + it('still blocks embedded references alongside a preserved replacement link', async function () { + const target = await component(); + const replacement = await component(); + const source = await analytic([logSource(target)]); + const relationship = await api( + 'post', + '/api/relationships', + relationshipBody(target, replacement, 'revoked-by'), + ).expect(201); + const eligibility = await check(target); + expect(eligibility.can_deprecate).toBe(false); + expect(eligibility.blockers.sros).toEqual([]); + expect(eligibility.blockers.embedded).toEqual([ + { + source_ref: source.stix.id, + target_ref: target.stix.id, + path: 'x_mitre_log_source_references[0].x_mitre_data_component_ref', + direction: 'inbound', + }, + ]); + const rejected = await api( + 'post', + '/api/data-components', + revision(target, { x_mitre_deprecated: true }), + ).expect(409); + expect(rejected.body.code).toBe('deprecation_blocked'); + expect(rejected.body.blockers).toEqual(eligibility.blockers); + expect(await versions('data-components', target)).toHaveLength(1); + expect((await latest('relationships', relationship.body)).stix).toEqual(relationship.body.stix); + expect(await versions('relationships', relationship.body)).toHaveLength(1); + }); + + it('counts latest inbound and outbound embedded refs, ignoring removed historical refs', async function () { + const first = await component(); + const second = await component(); + const source = await analytic([logSource(first)]); + const edge = { + source_ref: source.stix.id, + target_ref: first.stix.id, + path: 'x_mitre_log_source_references[0].x_mitre_data_component_ref', + }; + expect((await check(first)).blockers.embedded).toEqual([{ ...edge, direction: 'inbound' }]); + expect((await check(source)).blockers.embedded).toEqual([{ ...edge, direction: 'outbound' }]); + await api('post', '/api/analytics', revision(source, { x_mitre_deprecated: true })).expect(409); + const updated = await revise('analytics', source, { + x_mitre_log_source_references: [logSource(second)], + }); + expect((await check(first)).can_deprecate).toBe(true); + expect((await check(second)).blockers.embedded).toEqual([ + { + ...edge, + target_ref: second.stix.id, + direction: 'inbound', + }, + ]); + const removal = revision(updated); + delete removal.stix.x_mitre_log_source_references; + await api('post', '/api/analytics', removal).expect(201); + expect((await check(second)).can_deprecate).toBe(true); + expect((await check(source)).can_deprecate).toBe(true); + }); + + for (const field of ['revoked', 'x_mitre_deprecated']) { + it(`counts an imported ${field} inbound referrer as a blocker`, async function () { + const target = await component(); + const source = await analytic([logSource(target)]); + await legacyInactive(source, field); + const eligibility = await check(target); + expect(eligibility.can_deprecate).toBe(false); + expect(eligibility.blockers.embedded).toEqual([ + { + source_ref: source.stix.id, + target_ref: target.stix.id, + path: 'x_mitre_log_source_references[0].x_mitre_data_component_ref', + direction: 'inbound', + }, + ]); + const rejected = await api( + 'post', + '/api/data-components', + revision(target, { + x_mitre_deprecated: true, + }), + ).expect(409); + expect(rejected.body.code).toBe('deprecation_blocked'); + expect(rejected.body.blockers).toEqual(eligibility.blockers); + }); + + for (const endpoint of ['source', 'target']) { + it(`rejects ordinary SROs with a ${field} ${endpoint}, but permits revoked-by`, async function () { + let source = await technique(); + let target = await technique(); + if (endpoint === 'source') source = await legacyInactive(source, field); + else target = await legacyInactive(target, field); + const rejected = await api( + 'post', + '/api/relationships', + relationshipBody(source, target), + ).expect(409); + expect(rejected.body.code).toBe('inactive_reference'); + expect(rejected.body.references.length).toBeGreaterThan(0); + await api( + 'post', + '/api/relationships', + relationshipBody(source, target, 'revoked-by'), + ).expect(201); + }); + } + } + + it('allows retiring a legacy ordinary SRO whose endpoint is already inactive', async function () { + const source = await technique(); + const target = await technique(); + const response = await api( + 'post', + '/api/relationships', + relationshipBody(source, target), + ).expect(201); + await legacyInactive(target); + const retired = await revise('relationships', response.body, { x_mitre_deprecated: true }); + expect(retired.stix.x_mitre_deprecated).toBe(true); + expect((await check(source)).can_deprecate).toBe(true); + }); + + it('rejects new inactive domain refs but permits unchanged legacy refs on an unrelated revision', async function () { + const target = await component(); + const source = await analytic([logSource(target)]); + await legacyInactive(target); + const rejected = await api( + 'post', + '/api/analytics', + payload('x-mitre-analytic', { + x_mitre_platforms: ['Windows'], + x_mitre_log_source_references: [logSource(target)], + }), + ).expect(409); + expect(rejected.body.code).toBe('inactive_reference'); + expect(rejected.body.references.length).toBeGreaterThan(0); + const unlinked = await analytic(); + const rejectedRevision = await api( + 'post', + '/api/analytics', + revision(unlinked, { + x_mitre_log_source_references: [logSource(target)], + }), + ).expect(409); + expect(rejectedRevision.body.code).toBe('inactive_reference'); + expect(await versions('analytics', unlinked)).toHaveLength(1); + const retained = await revise('analytics', source, { + description: 'Unrelated legacy metadata edit', + }); + expect(retained.stix.x_mitre_log_source_references).toEqual( + source.stix.x_mitre_log_source_references, + ); + const removal = revision(retained, { x_mitre_deprecated: true }); + delete removal.stix.x_mitre_log_source_references; + const retired = await api('post', '/api/analytics', removal).expect(201); + expect(retired.body.stix.x_mitre_deprecated).toBe(true); + expect((await check(target)).blockers.embedded).toEqual([]); + }); + + it('migrates inbound log sources without losing name, channel, duplicate-target details or existing B refs', async function () { + const original = await component(); + const replacement = await component(); + const refs = [ + logSource(original, 'Original one', 'Channel one'), + logSource(original, 'Original two', 'Channel two'), + logSource(replacement, 'Retained B', 'Channel B'), + ]; + const source = await analytic(refs); + await revoke('data-components', original, replacement).expect(200); + const migrated = await latest('analytics', source); + expect(migrated.stix.x_mitre_log_source_references).toHaveLength(3); + expect(migrated.stix.x_mitre_log_source_references).toEqual( + expect.arrayContaining( + refs.map((ref) => ({ ...ref, x_mitre_data_component_ref: replacement.stix.id })), + ), + ); + expect((await latest('data-components', original)).stix.revoked).toBe(true); + expect((await check(original)).blockers.embedded).toEqual([]); + }); + + for (const alreadyReferenced of [false, true]) { + it(`preserves outgoing data-source refs and B metadata (B already references source: ${alreadyReferenced})`, async function () { + const dataSource = await create('data-sources', 'x-mitre-data-source'); + const original = await component({ x_mitre_data_source_ref: dataSource.stix.id }); + const replacement = await component({ + ...(alreadyReferenced ? { x_mitre_data_source_ref: dataSource.stix.id } : {}), + x_mitre_log_sources: [{ name: 'Retained B event', channel: 'Retained B channel' }], + }); + await revoke('data-components', original, replacement).expect(200); + const migrated = await latest('data-components', replacement); + expect(migrated.stix.name).toBe(replacement.stix.name); + expect(migrated.stix.x_mitre_data_source_ref).toBe(dataSource.stix.id); + expect(migrated.stix.x_mitre_log_sources).toEqual(replacement.stix.x_mitre_log_sources); + const revoked = await latest('data-components', original); + expect(revoked.stix.revoked).toBe(true); + expect(revoked.stix.x_mitre_data_source_ref).toBe(dataSource.stix.id); + expect((await check(dataSource)).blockers.embedded.map((edge) => edge.source_ref)).toEqual( + expect.arrayContaining([original.stix.id, replacement.stix.id]), + ); + }); + } + + it('rejects scalar-ref merge conflicts before revoking A or creating any revisions', async function () { + const first = await create('data-sources', 'x-mitre-data-source'); + const second = await create('data-sources', 'x-mitre-data-source'); + const original = await component({ x_mitre_data_source_ref: first.stix.id }); + const replacement = await component({ x_mitre_data_source_ref: second.stix.id }); + const beforeA = await versions('data-components', original); + const beforeB = await versions('data-components', replacement); + await revoke('data-components', original, replacement).expect(409); + expect(await versions('data-components', original)).toEqual(beforeA); + expect(await versions('data-components', replacement)).toEqual(beforeB); + expect((await latest('data-components', original)).stix.revoked).not.toBe(true); + expect((await check(original)).blockers.sros).toEqual([]); + }); + + it('rejects an inactive inbound referrer preflight without partial migration or revocation', async function () { + const original = await component(); + const replacement = await component(); + const active = await analytic([logSource(original)]); + const inactive = await analytic([logSource(original)]); + await legacyInactive(inactive); + const beforeA = await versions('data-components', original); + const beforeB = await versions('data-components', replacement); + const beforeActive = await versions('analytics', active); + const beforeInactive = await versions('analytics', inactive); + await revoke('data-components', original, replacement).expect(409); + expect(await versions('data-components', original)).toEqual(beforeA); + expect(await versions('data-components', replacement)).toEqual(beforeB); + expect(await versions('analytics', active)).toEqual(beforeActive); + expect(await versions('analytics', inactive)).toEqual(beforeInactive); + expect((await latest('data-components', original)).stix.revoked).not.toBe(true); + expect((await check(original)).blockers.sros).toEqual([]); + }); + + it('does not persist a dry-run reference revision or backlinks and does not leak options to later writes', async function () { + const first = await component(); + const second = await component(); + const source = await analytic([logSource(first)]); + const beforeSource = await versions('analytics', source); + const beforeFirst = await versions('data-components', first); + const beforeSecond = await versions('data-components', second); + const proposed = revision(source, { x_mitre_log_source_references: [logSource(second)] }); + const preview = await api('post', '/api/analytics?dryRun=true', proposed).expect(200); + expect(preview.body.stix.x_mitre_log_source_references).toEqual([logSource(second)]); + expect(await versions('analytics', source)).toEqual(beforeSource); + expect(await versions('data-components', first)).toEqual(beforeFirst); + expect(await versions('data-components', second)).toEqual(beforeSecond); + expect((await check(first)).can_deprecate).toBe(false); + expect((await check(second)).can_deprecate).toBe(true); + const unrelated = await analytic(); + expect((await latest('analytics', unrelated)).stix.id).toBe(unrelated.stix.id); + await api('post', '/api/analytics', proposed).expect(201); + expect((await check(first)).can_deprecate).toBe(true); + expect((await check(second)).can_deprecate).toBe(false); + }); + + it('requires an active latest replacement before any revocation side effects', async function () { + const original = await component(); + const replacement = await component(); + const newer = await revise('data-components', replacement, { description: 'new revision' }); + await revoke('data-components', original, replacement).expect(409); + await legacyInactive(newer); + await revoke('data-components', original, newer).expect(409); + expect((await latest('data-components', original)).stix.revoked).not.toBe(true); + expect(await versions('data-components', original)).toHaveLength(1); + expect((await check(original)).blockers.sros).toEqual([]); + }); + + it('rejects a newly added log-source occurrence to an inactive component while allowing reorder', async function () { + const target = await component(); + const first = logSource(target, 'Security', 'one'); + const second = logSource(target, 'Security', 'two'); + const source = await analytic([first, second]); + await legacyInactive(target); + const reordered = await revise('analytics', source, { + x_mitre_log_source_references: [second, first], + }); + await api( + 'post', + '/api/analytics', + revision(reordered, { + x_mitre_log_source_references: [second, first, logSource(target, 'Security', 'three')], + }), + ).expect(409); + }); + + it('fails closed while another Mongo graph-write owner holds the lock', async function () { + const lock = require('../../../repository/graph-write-lock-repository'); + const token = randomUUID(); + await lock.acquire(token); + try { + const response = await api( + 'post', + '/api/data-components', + payload('x-mitre-data-component'), + ).expect(409); + expect(response.body.code).toBe('graph_write_conflict'); + } finally { + await lock.release(token); + } + }); + + it('recursively extracts domain refs but excludes attribution, markings, and inventory', function () { + const { references } = require('../../../lib/domain-references'); + const source = `x-mitre-analytic--${randomUUID()}`; + const target = `x-mitre-data-component--${randomUUID()}`; + const refs = references({ + id: source, + type: 'x-mitre-analytic', + created_by_ref: target, + x_mitre_modified_by_ref: target, + object_marking_refs: [target], + granular_markings: [{ marking_ref: target }], + x_mitre_contents: [{ object_ref: target }], + x_mitre_log_source_references: [ + { x_mitre_data_component_ref: target, name: 'a', channel: 'b' }, + ], + nested: { object_refs: [target] }, + }); + expect(refs.map((ref) => ref.path)).toEqual([ + 'x_mitre_log_source_references[0].x_mitre_data_component_ref', + 'nested.object_refs[0]', + ]); + }); + + it('merges outgoing log-source records semantically without losing replacement data', function () { + const { mergeOutgoing, substitute } = require('../../../lib/domain-references'); + const a = `x-mitre-analytic--${randomUUID()}`; + const b = `x-mitre-analytic--${randomUUID()}`; + const target = { stix: { id: `x-mitre-data-component--${randomUUID()}` } }; + const common = logSource(target); + const addition = logSource(target, 'Sysmon', 'Operational'); + const original = { + type: 'x-mitre-analytic', + id: a, + x_mitre_log_source_references: [common, addition], + }; + const replacement = { + type: 'x-mitre-analytic', + id: b, + name: 'Replacement', + x_mitre_log_source_references: [common], + }; + const merged = mergeOutgoing(replacement, original, a, b); + expect(merged.name).toBe('Replacement'); + expect(merged.x_mitre_log_source_references).toEqual([common, addition]); + expect(replacement.x_mitre_log_source_references).toEqual([common]); + expect( + substitute({ type: 'x-mitre-detection-strategy', x_mitre_analytic_refs: [a, b] }, a, b) + .x_mitre_analytic_refs, + ).toEqual([b]); + }); + + for (const edgeKind of ['sro', 'embedded']) { + it(`serializes competing ${edgeKind} creation and deprecation without leaving an active edge to an inactive target`, async function () { + // Repeat with independent IDs, racing actual requests rather than mocking a lock. + for (let attempt = 0; attempt < 4; attempt += 1) { + const target = edgeKind === 'sro' ? await technique() : await component(); + const source = edgeKind === 'sro' ? await create('groups', 'intrusion-set') : undefined; + const resource = edgeKind === 'sro' ? 'techniques' : 'data-components'; + const body = + edgeKind === 'sro' + ? relationshipBody(source, target, 'uses') + : payload('x-mitre-analytic', { + x_mitre_platforms: ['Windows'], + x_mitre_log_source_references: [logSource(target)], + }); + const edgeRequest = () => + api('post', edgeKind === 'sro' ? '/api/relationships' : '/api/analytics', body); + const deprecationRequest = () => + api('post', `/api/${resource}`, revision(target, { x_mitre_deprecated: true })); + const calls = + attempt % 2 === 0 + ? [edgeRequest(), deprecationRequest()] + : [deprecationRequest(), edgeRequest()]; + const results = await Promise.all(calls); + expect(results.map((result) => result.status).sort()).toEqual([201, 409]); + expect(['inactive_reference', 'deprecation_blocked', 'graph_write_conflict']).toContain( + results.find((result) => result.status === 409).body.code, + ); + const stored = await latest(resource, target); + const eligibility = await check(target); + const edges = + edgeKind === 'sro' ? eligibility.blockers.sros : eligibility.blockers.embedded; + if (stored.stix.x_mitre_deprecated) { + expect(edges).toEqual([]); + } else { + expect(edges).toHaveLength(1); + expect(eligibility.can_deprecate).toBe(false); + } + } + }); + } +}); diff --git a/app/tests/api/data-sources/data-sources.spec.js b/app/tests/api/data-sources/data-sources.spec.js index 37437f4d..5dbad045 100644 --- a/app/tests/api/data-sources/data-sources.spec.js +++ b/app/tests/api/data-sources/data-sources.spec.js @@ -11,6 +11,7 @@ const config = require('../../../config/config'); const login = require('../../shared/login'); const dataComponentsService = require('../../../services/stix/data-components-service'); +const AttackObject = require('../../../models/attack-object-model'); const logger = require('../../../lib/logger'); logger.level = 'debug'; @@ -64,7 +65,7 @@ async function loadDataComponents(baseDataComponent) { let timestamp = new Date().toISOString(); data1.stix.created = timestamp; data1.stix.modified = timestamp; - const created1 = await dataComponentsService.create(data1); + await dataComponentsService.create(data1); const data2 = _.cloneDeep(baseDataComponent); timestamp = new Date().toISOString(); @@ -82,8 +83,12 @@ async function loadDataComponents(baseDataComponent) { timestamp = new Date().toISOString(); data4.stix.created = timestamp; data4.stix.modified = timestamp; - data4.stix.x_mitre_deprecated = true; - await dataComponentsService.create(data4); + const created4 = await dataComponentsService.create(data4); + // Legacy imported inactive components can retain their data source references. + await AttackObject.collection.updateOne( + { 'stix.id': created4.stix.id }, + { $set: { 'stix.x_mitre_deprecated': true } }, + ); const data5 = _.cloneDeep(baseDataComponent); timestamp = new Date().toISOString(); @@ -91,10 +96,10 @@ async function loadDataComponents(baseDataComponent) { data5.stix.modified = timestamp; const created5 = await dataComponentsService.create(data5); - // Revoke data component 5 using data component 1 as the revoking object - await dataComponentsService.revoke(created5.stix.id, { - revoking: { stixId: created1.stix.id, modified: created1.stix.modified }, - }); + await AttackObject.collection.updateOne( + { 'stix.id': created5.stix.id }, + { $set: { 'stix.revoked': true } }, + ); } describe('Data Sources API', function () { diff --git a/app/tests/api/matrices/matrices.spec.js b/app/tests/api/matrices/matrices.spec.js index 6985f6a8..408e7170 100644 --- a/app/tests/api/matrices/matrices.spec.js +++ b/app/tests/api/matrices/matrices.spec.js @@ -66,6 +66,27 @@ describe('Matrices API', function () { // Log into the app passportCookie = await login.loginAnonymous(app); + + const timestamp = new Date().toISOString(); + for (const id of initialObjectData.stix.tactic_refs) { + await request(app) + .post('/api/tactics') + .send({ + workspace: { workflow: { state: 'work-in-progress' } }, + stix: { + id, + type: 'x-mitre-tactic', + spec_version: '2.1', + name: `Matrix prerequisite ${id}`, + created: timestamp, + modified: timestamp, + x_mitre_domains: ['enterprise-attack'], + }, + }) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + } }); it('GET /api/matrices returns an empty array of matrices', async function () { diff --git a/app/tests/api/notes/notes.spec.js b/app/tests/api/notes/notes.spec.js index f2361deb..41ea3451 100644 --- a/app/tests/api/notes/notes.spec.js +++ b/app/tests/api/notes/notes.spec.js @@ -53,6 +53,27 @@ describe('Notes API', function () { // Log into the app passportCookie = await login.loginAnonymous(app); + + const timestamp = new Date().toISOString(); + const target = await request(app) + .post('/api/techniques') + .send({ + workspace: { workflow: { state: 'work-in-progress' } }, + stix: { + type: 'attack-pattern', + spec_version: '2.1', + name: 'Note reference target', + created: timestamp, + modified: timestamp, + x_mitre_domains: ['enterprise-attack'], + x_mitre_is_subtechnique: false, + x_mitre_platforms: ['Windows'], + }, + }) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201); + initialObjectData.stix.object_refs = [target.body.stix.id]; }); it('GET /api/notes should return an empty array of notes', async function () { diff --git a/app/tests/api/relationships/relationship-restoration.spec.js b/app/tests/api/relationships/relationship-restoration.spec.js new file mode 100644 index 00000000..92affbc5 --- /dev/null +++ b/app/tests/api/relationships/relationship-restoration.spec.js @@ -0,0 +1,164 @@ +const request = require('supertest'); +const { expect } = require('expect'); +const database = require('../../../lib/database-in-memory'); +const databaseConfiguration = require('../../../lib/database-configuration'); +const config = require('../../../config/config'); +const login = require('../../shared/login'); +const { cloneForCreate } = require('../../shared/clone-for-create'); + +describe('Relationship restoration after endpoint un-deprecation', function () { + let app; + let cookie; + + before(async function () { + await database.initializeConnection(); + await databaseConfiguration.checkSystemConfiguration(); + config.validateRequests.withAttackDataModel = true; + config.validateRequests.withOpenApi = true; + app = await require('../../../index').initializeApp(); + const session = await login.loginAnonymous(app); + cookie = `${session.name}=${session.value}`; + }); + + after(async function () { + await database.closeConnection(); + }); + + function api(method, path, data) { + const call = request(app)[method](`/api/${path}`).set('Cookie', cookie); + return data === undefined ? call : call.send(data); + } + + async function create(resource, type, name, fields = {}) { + const timestamp = new Date().toISOString(); + const response = await api('post', resource, { + workspace: { workflow: { state: 'work-in-progress' } }, + stix: { + type, + name, + spec_version: '2.1', + created: timestamp, + modified: timestamp, + ...(type === 'relationship' + ? {} + : { x_mitre_domains: ['mobile-attack'], x_mitre_platforms: ['Android'] }), + ...fields, + }, + }).expect(201); + return response.body; + } + + function revision(document, fields) { + const data = cloneForCreate(document); + data.workspace = { workflow: { state: 'work-in-progress' } }; + data.stix.modified = new Date( + Math.max(Date.now(), Date.parse(document.stix.modified) + 1), + ).toISOString(); + Object.assign(data.stix, fields); + return data; + } + + async function revise(resource, document, fields) { + return (await api('post', resource, revision(document, fields)).expect(201)).body; + } + + async function latest(resource, document) { + return (await api('get', `${resource}/${document.stix.id}?versions=latest`).expect(200)) + .body[0]; + } + + async function scenario() { + const source = await create('software', 'malware', 'Marcher', { is_family: true }); + const target = await create( + 'techniques', + 'attack-pattern', + 'Deliver Malicious Application via Other Means', + { + x_mitre_is_subtechnique: false, + }, + ); + const edge = await create('relationships', 'relationship', undefined, { + source_ref: source.stix.id, + target_ref: target.stix.id, + relationship_type: 'uses', + }); + const retired = await revise('relationships', edge, { x_mitre_deprecated: true }); + return { source, target, edge, retired }; + } + + for (const inactiveEndpoint of ['source', 'target']) { + for (const inactiveState of ['deprecated', 'revoked']) { + it(`keeps the existing SRO retired after restoring one endpoint while its ${inactiveEndpoint} is ${inactiveState}`, async function () { + const { source, target, edge, retired } = await scenario(); + const other = inactiveEndpoint === 'source' ? source : target; + const restored = inactiveEndpoint === 'source' ? target : source; + const otherResource = inactiveEndpoint === 'source' ? 'software' : 'techniques'; + const restoredResource = inactiveEndpoint === 'source' ? 'techniques' : 'software'; + const inactive = await revise(restoredResource, restored, { x_mitre_deprecated: true }); + if (inactiveState === 'deprecated') { + await revise(otherResource, other, { x_mitre_deprecated: true }); + } else { + const replacement = await create( + otherResource, + other.stix.type, + 'Active replacement', + other.stix.type === 'malware' + ? { is_family: true } + : { x_mitre_is_subtechnique: false }, + ); + await api('post', `${otherResource}/${other.stix.id}/revoke`, { + revoking: { stixId: replacement.stix.id, modified: replacement.stix.modified }, + }).expect(200); + } + + // Matches the reported sequence when source=Marcher and the technique is restored. + await revise(restoredResource, inactive, { x_mitre_deprecated: false }); + expect((await latest(restoredResource, restored)).stix.x_mitre_deprecated).toBe(false); + expect((await latest('relationships', edge)).stix).toEqual(retired.stix); + + // Even an explicit restore request must validate the opposite endpoint's latest state. + const rejected = await api( + 'post', + 'relationships', + revision(retired, { + x_mitre_deprecated: false, + }), + ).expect(409); + expect(rejected.body.code).toBe('inactive_reference'); + expect(rejected.body.references).toContain(other.stix.id); + expect((await latest('relationships', edge)).stix).toEqual(retired.stix); + const history = (await api('get', `relationships/${edge.stix.id}?versions=all`).expect(200)) + .body; + expect(history.map((document) => document.stix.modified).sort()).toEqual( + [edge.stix.modified, retired.stix.modified].sort(), + ); + const otherLatest = await latest(otherResource, other); + expect( + otherLatest.stix[inactiveState === 'deprecated' ? 'x_mitre_deprecated' : 'revoked'], + ).toBe(true); + }); + } + } + + it('allows explicit SRO restoration once both latest endpoints are active, without changing retired history', async function () { + const { source, target, edge, retired } = await scenario(); + const inactiveTarget = await revise('techniques', target, { x_mitre_deprecated: true }); + const inactiveSource = await revise('software', source, { x_mitre_deprecated: true }); + await revise('techniques', inactiveTarget, { x_mitre_deprecated: false }); + await revise('software', inactiveSource, { x_mitre_deprecated: false }); + expect((await latest('relationships', edge)).stix).toEqual(retired.stix); + + const restored = await revise('relationships', retired, { x_mitre_deprecated: false }); + expect(restored.stix.id).toBe(edge.stix.id); + expect(restored.stix.x_mitre_deprecated).toBe(false); + expect(restored.stix.source_ref).toBe(source.stix.id); + expect(restored.stix.target_ref).toBe(target.stix.id); + expect(Date.parse(restored.stix.modified)).toBeGreaterThan(Date.parse(retired.stix.modified)); + const old = ( + await api('get', `relationships/${edge.stix.id}/modified/${retired.stix.modified}`).expect( + 200, + ) + ).body; + expect(old.stix).toEqual(retired.stix); + }); +}); diff --git a/docs/README.md b/docs/README.md index 84f3b033..d79af9a2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,7 +10,8 @@ This directory contains supplementary technical documentation for the ATT&CK Wor Guides for consumers of the REST API — endpoints, workflows, and terminology. - [Build Information](user/build-information.md): Inspect the running REST API release and build provenance -- [Revoke Workflow](user/revoke-workflow.md): How to revoke ATT&CK objects via the API +- [Deprecation Workflow](user/deprecation-workflow.md): Blocking-reference retirement, frontend ordering, preserved hierarchy/replacement links, and explicit embedded-reference resolution +- [Revocation Workflow](user/revoke-workflow.md): Frontend/backend responsibilities, SRO and embedded-reference preservation, replacement constraints, and failure handling - [Data Quality Reports](user/data-quality-reports.md): Missing LinkById, parallel relationship, and domain consistency reports under `/api/reports` ### Release Tracks diff --git a/docs/admin/configuration.md b/docs/admin/configuration.md index f9dafe7c..4d9de28b 100644 --- a/docs/admin/configuration.md +++ b/docs/admin/configuration.md @@ -797,6 +797,36 @@ ATT&CK-specific configuration values. --- +## STIX graph-write exclusion + +Authoring lifecycle validation and persistence share a database-backed lock in +`graphWriteLocks` with `_id: "stix-graph"`. Writes queue within one API process; +a competing worker returns `409` with `code: "graph_write_conflict"`. Read-only +preflight checks are advisory; final writes revalidate while holding the lock. +Imports participate in write exclusion but retain their source-fidelity policy. + +The lock deliberately has **no automatic expiry**. On standalone MongoDB, taking +over an expired lock cannot fence a paused former owner's subsequent writes. +A process crash or failed lock release therefore fails closed: graph writes can +remain blocked until an operator recovers the lock. + +Recovery procedure: + +1. Stop **all API workers and other graph writers** using this database. Do not + remove a lock merely because an operation appears slow. +2. Inspect the failed operation's logs and persisted object/SRO revisions. + Revocation is preflighted but not a multi-document transaction; earlier + writes may have completed before an unexpected database failure. +3. In the correct Workbench database, inspect the ownership record: + `db.graphWriteLocks.findOne({_id: "stix-graph"})`. +4. Only after every writer is stopped, remove that record: + `db.graphWriteLocks.deleteOne({_id: "stix-graph"})`. +5. Restart the workers, refresh affected objects, and reconcile the operation's + persisted state before retrying. Never delete STIX revisions as lock cleanup. + +This recovery does not rebuild embedded-reference metadata or roll back partial +work. See [lifecycle workflow semantics](../user/revoke-workflow.md). + ## Additional Resources - [Authentication Documentation](./authentication/README.md) diff --git a/docs/developer/event-bus-architecture.md b/docs/developer/event-bus-architecture.md index 34c21b91..acf31c6c 100644 --- a/docs/developer/event-bus-architecture.md +++ b/docs/developer/event-bus-architecture.md @@ -244,16 +244,16 @@ Where `{type}` is the STIX type (e.g., `attack-pattern`, `x-mitre-analytic`, `x- 1. **DetectionStrategiesService.beforeCreate(data)** - Detect change: `oldRefs = []`, `newRefs = ['x-mitre-analytic--123']` - - Store: `this._addedAnalyticRefs = ['x-mitre-analytic--123']` + - Keep removal deltas in invocation options keyed by STIX ID and modified timestamp; never on the singleton service - Rebuild outbound embedded_relationships for new refs - Update `data.workspace.embedded_relationships` 2. **BaseService.create()** - Persist the new revision 3. **DetectionStrategiesService.afterCreate(createdDocument)** - - If `_addedAnalyticRefs` not empty: - - Emit `x-mitre-detection-strategy::analytics-referenced` - - Clean up: `delete this._addedAnalyticRefs` + - Confirm the saved revision is latest; a historical import must not change current backlinks + - Emit `x-mitre-detection-strategy::analytics-referenced` for current references + - Consume this revision's invocation-scoped removal delta 4. **BaseService.emitCreatedEvent()** - Emit `x-mitre-detection-strategy::created` @@ -269,13 +269,13 @@ Where `{type}` is the STIX type (e.g., `attack-pattern`, `x-mitre-analytic`, `x- 1. **DetectionStrategiesService.beforeCreate(...)** - Detect change: `removedRefs = ['x-mitre-analytic--123']` - - Store: `this._removedAnalyticRefs = ['x-mitre-analytic--123']` + - Store `removedRefs` in invocation options under this revision's ID/timestamp key - Rebuild outbound embedded_relationships (now empty) 2. **BaseService.create()** - Persist the new revision 3. **DetectionStrategiesService.afterCreate(...)** - - If `_removedAnalyticRefs` not empty: + - If the saved revision is latest and its invocation-scoped removal delta is nonempty: - Emit `x-mitre-detection-strategy::analytics-removed` ```javascript { @@ -283,7 +283,7 @@ Where `{type}` is the STIX type (e.g., `attack-pattern`, `x-mitre-analytic`, `x- analyticIds: ['x-mitre-analytic--123'] } ``` - - Clean up: `delete this._removedAnalyticRefs` + - Remove this revision's delta from the invocation context 4. **AnalyticsService** listener receives event - For each analyticId: diff --git a/docs/developer/stix-versioning-and-embedded-relationships.md b/docs/developer/stix-versioning-and-embedded-relationships.md index 82c9645d..28b683b7 100644 --- a/docs/developer/stix-versioning-and-embedded-relationships.md +++ b/docs/developer/stix-versioning-and-embedded-relationships.md @@ -400,19 +400,26 @@ if (data.stix?.id) { const oldDataSourceRef = previousVersion?.stix?.x_mitre_data_source_ref; const newDataSourceRef = data.stix?.x_mitre_data_source_ref; -// Detect changes -if (oldDataSourceRef && !newDataSourceRef) { - this._removedDataSourceRef = oldDataSourceRef; // Reference removed -} +// Keep per-revision deltas in the invocation's options, not on the singleton. +const key = `${data.stix.id}:${new Date(data.stix.modified).getTime()}`; +options._removedDataSourceRefs ??= new Map(); +options._removedDataSourceRefs.set( + key, + oldDataSourceRef && oldDataSourceRef !== newDataSourceRef ? oldDataSourceRef : null, +); ``` **In `DataComponentsService.afterCreate()`:** ```javascript -// Emit removed event for old reference -if (this._removedDataSourceRef) { +const key = `${createdDocument.stix.id}:${new Date(createdDocument.stix.modified).getTime()}`; +const removedRef = options._removedDataSourceRefs?.get(key); +options._removedDataSourceRefs?.delete(key); +// After confirming this revision is latest, emit the removed reference. +if (removedRef) { await EventBus.emit('x-mitre-data-component::data-source-removed', { dataComponentId: createdDocument.stix.id, - dataSourceId: this._removedDataSourceRef + dataSourceId: removedRef, + options, }); } ``` @@ -544,7 +551,7 @@ Only create DS1 snapshots when its `embedded_relationships` actually change. 2. **Detect version changes in `beforeCreate`** - Fetch previous latest version - Compare old vs new values - - Store change tracking in instance variables + - Keep change tracking in invocation-scoped context, never mutable singleton service fields 3. **Emit events for both added and removed relationships** - Don't assume POST only adds relationships @@ -578,6 +585,43 @@ If you find yourself frequently needing complete historical relationship graphs, --- +## Lifecycle integrity + +Authoring retirement checks are derived from latest STIX revisions, not the +denormalized workspace cache. Latest selection precedes lifecycle filtering: +an old active SRO does not block after its newest revision is deprecated. +Inbound domain references on inactive source objects still block SDO deprecation. +Attribution, marking, and release-inventory fields are outside this graph. + +SDO deprecation excludes existing active `subtechnique-of` and `revoked-by` SROs +from blockers in both directions and leaves their revisions unchanged. The +frontend likewise excludes them from its cascade and final blocker check, even +when an older API includes them. This does not exempt embedded references or +ordinary SROs. Explicit retirement of any SRO, including either preserved type, +remains permitted, even with inactive endpoints. The active-SRO authoring guard +is separate: only `revoked-by` may be authored with inactive endpoints; preserving +an existing `subtechnique-of` does not permit creating one. Dedicated revocation +planning and preservation semantics are unchanged. + +`domain-references.js` extracts nested `_ref` / `_refs` fields and compares +reference-bearing records, preserving analytic log-source name/channel context. +`lifecycle-service.js` uses the same extraction for preflight blockers, final +authoring validation, embedded revocation plans, and affected-cache reconciliation. +Required lifecycle events dispatch new revisions and cache writes to owning +services. Imports bypass authoring policy without altering source STIX. + +`preserveRelationships=true` creates source/replacement revisions rather than +editing historical STIX in place. Scalar conflicts and inactive referrers fail +preflight. Reconciliation retains the revoked original's declared outgoing refs; +it does not treat inactivity as disappearance. + +The graph write lock spans validation and persistence, including nested event +work, to prevent relationship creation racing endpoint retirement. It serializes +local writers and excludes other API workers through MongoDB. This lock is not +a multi-document transaction; unexpected persistence errors can leave completed +earlier writes. See [operator recovery](../admin/configuration.md#stix-graph-write-exclusion) +and the [public lifecycle contract](../user/revoke-workflow.md). + ## Related Documentation - [event-bus-architecture.md](event-bus-architecture.md) - Event-driven architecture patterns diff --git a/docs/user/deprecation-workflow.md b/docs/user/deprecation-workflow.md new file mode 100644 index 00000000..3f080529 --- /dev/null +++ b/docs/user/deprecation-workflow.md @@ -0,0 +1,234 @@ +# Deprecating objects + +Deprecation means **retire this object without naming a replacement**. Workbench +stores a new revision with the same STIX ID, a later `modified` timestamp, and +`x_mitre_deprecated: true`. It does not hard-delete the object or its history. +Deprecated objects are excluded from default lists but remain retrievable. + +For replacement rather than retirement, use the [revocation workflow](revoke-workflow.md). +The two operations intentionally have different relationship-handling rules. + +## Business rule: detach blocking references first + +An SDO being deprecated must have no blocking relationships in the **current domain graph**: + +- No incoming or outgoing **active formal relationships (SROs)** other than + `subtechnique-of` and `revoked-by`. These existing hierarchy and replacement + links are preserved, in either direction, without creating new SRO revisions. +- No incoming or outgoing **domain embedded references** on latest object revisions. +- An incoming embedded reference still counts if its source is already revoked + or deprecated. Retiring the source without removing its reference does not + detach it from the target. + +Here, active means neither `revoked: true` nor `x_mitre_deprecated: true`. +Latest means the most recent stored revision of each STIX ID. Workbench selects +that revision before considering its lifecycle state; an older active SRO does +not become a blocker again after its latest revision is deprecated. + +### What counts as a reference? + +| Reference | How it is represented | How to detach it | +|---|---|---| +| Formal SRO other than `subtechnique-of` / `revoked-by` | A separate `relationship` object with `source_ref` and `target_ref` | POST a new revision of the SRO with `x_mitre_deprecated: true` | +| Existing `subtechnique-of` / `revoked-by` SRO | Hierarchy or replacement link | Preserve unchanged; it does not block SDO deprecation | +| Strategy to analytic | `x_mitre_analytic_refs` on the strategy | Save a new strategy revision without that reference | +| Analytic to data component | `x_mitre_log_source_references[].x_mitre_data_component_ref` on the analytic | Remove or replace the relevant log-source records in a new analytic revision | +| Data component to data source | `x_mitre_data_source_ref` on the component | Remove or replace the source reference in a new component revision | +| Other domain embedded reference | Domain `_ref` / `_refs` fields, including nested fields | Edit the object that owns the STIX field, subject to its schema | + +`workspace.embedded_relationships` is derived navigation metadata, not the source +of truth. Editing or clearing that cache does not detach an authoritative STIX +reference and cannot bypass the guard. + +Historical revisions, already-retired SROs, attribution references, markings, +external citations, and release inventory do not block deprecation. In particular, +`created_by_ref`, `x_mitre_modified_by_ref`, `object_marking_refs`, and collection +`x_mitre_contents` are not domain relationships for this check. Existing published +snapshots and historical STIX are not rewritten. + +## What the frontend does + +An authenticated editor initiates deprecation from an object's deprecate control. +The shared frontend workflow then performs these steps: + +1. **Check the server's authoritative blockers.** The frontend calls + `GET /api/attack-objects/:stixId/deprecation-check`. +2. **Stop for any embedded blocker.** A dialog identifies each reference's source, + target, field path, and direction relative to the selected object. No SRO or + object writes are attempted. Resolve the references on their source objects, + save those changes, then try again. +3. **Ask for confirmation.** If only blocking SROs remain, confirmation explains + that those relationships will be retired before the object, while existing + `subtechnique-of` and `revoked-by` links are preserved. If no blockers remain, + it confirms object deprecation alone. +4. **Recheck after confirmation.** If embedded references appeared or the blocking + SRO set gained a new or changed revision, stop and ask the user to review again. +5. **Retire blocking SROs sequentially.** Fetch each current blocking SRO and POST + its new deprecated revision. Wait for completion before proceeding. Do not + fetch or retire preserved `subtechnique-of` or `revoked-by` SROs. +6. **Check again, then save the object.** Only after no blocking references remain + does the frontend POST the object's deprecated revision. The frontend applies + the same type exemptions at every check, even if an older API reports preserved + SROs as blockers. The server independently validates the final write; an older + server can still reject it, in which case the frontend reports the failure + without retiring the preserved links. +7. **Reload on success.** If any step fails, report the error rather than treating + the object as successfully deprecated. + +The shared retirement flow is used by the main object controls, applicable STIX +dialogs, and duplicate-relationship retirement. Retiring an SRO does not require +first removing its endpoint fields: those fields describe the retired edge and +remain in its history. Object-editor schema validation still applies to edits +used to resolve embedded blockers. + +### Preserving hierarchy and replacement links + +Deprecating an SDO preserves existing active `subtechnique-of` and `revoked-by` +SROs in both directions. Their STIX IDs, lifecycle flags, `modified` timestamps, +and revisions remain unchanged. This retains hierarchy and replacement navigation +without reparenting, reactivating, or retiring another endpoint object. + +This exemption is specific to the **SDO deprecation cascade**. An editor can still +explicitly retire either type of SRO itself, including duplicate relationships in +the data-quality bulk action. Such retirement creates a new deprecated SRO revision +and remains legal when an endpoint is inactive. + +Preserving an existing link is not permission to create a new active link to an +inactive endpoint. `revoked-by` remains the only relationship-type exemption from +that authoring guard; new active `subtechnique-of` links still require active +endpoints. The dedicated [revocation workflow](revoke-workflow.md) is unchanged. + +### Why data-source dependent objects are no longer automatically deprecated + +The old data-source UI flow gathered related data components and their SROs and +scheduled their deprecation alongside the data source. That retired additional +objects; it was not merely removal of relationships. + +The current policy requires explicit embedded-reference resolution instead. +For example: + +1. Component C contains `x_mitre_data_source_ref: S`. +2. Deprecating source S is blocked by C's incoming reference. +3. Merely deprecating C would not help: C's latest revision would still reference + S, and references from inactive sources also count. +4. Remove or replace C's source reference in a valid new C revision. Resolve all + other blockers in the same way, then retry deprecating S. + +If C must itself be deprecated, that is a separate decision. Its own incoming +analytic references and outgoing source reference must be resolved first. +Workbench does not assume that retiring S authorizes retiring every component or +analytic that depends on it. There is no force option that silently removes +references from inactive referrers; such blockers need explicit review consistent +with the source object's lifecycle and schema constraints. + +## What the REST API does + +The API provides the blocker query and protects the final write. It does **not** +interpret an object-deprecation POST as an instruction to cascade through other +objects or SROs. Scripts must perform the preparation that the frontend performs. + +### Query eligibility + +```http +GET /api/attack-objects/:stixId/deprecation-check +``` + +An authenticated reader can request this check. A missing object returns `404`. +A successful check returns `200`, even when deprecation is blocked. For example: + +```json +{ + "stix_id": "x-mitre-data-component--00290ac5-551e-44aa-bbd8-c4b913488a6c", + "can_deprecate": false, + "blockers": { + "sros": [], + "embedded": [{ + "source_ref": "x-mitre-analytic--00290ac5-551e-44aa-bbd8-c4b913488a6f", + "target_ref": "x-mitre-data-component--00290ac5-551e-44aa-bbd8-c4b913488a6c", + "path": "x_mitre_log_source_references[0].x_mitre_data_component_ref", + "direction": "inbound" + }] + } +} +``` + +SRO blockers contain `stix_id`, `modified`, `relationship_type`, and `direction`. +Embedded blockers contain `source_ref`, `target_ref`, `path`, and `direction`. +Paths are relative to the source object's `stix` object. + +### Perform the writes + +After resolving embedded references, POST new deprecated revisions of blocking SROs +to `/api/relationships`, leaving existing `subtechnique-of` and `revoked-by` SROs +unchanged. Then POST a new deprecated SDO revision to its ordinary resource endpoint, +such as `/api/techniques` or `/api/data-components`, using the usual +`{ "workspace": ..., "stix": ... }` request shape. Keep the object's STIX ID and +advance `stix.modified`; do not use DELETE or a STIX-changing PUT. +Retirement of an SRO remains legal if an endpoint is already inactive. + +The final authoring write checks authoritative relationships under graph-write +exclusion. If blockers remain, it returns `409`, `code: "deprecation_blocked"`, +and the same blocker structure. An eligibility response is advisory, not a token +that overrides later changes. A request may remove its own outgoing embedded +references in the same proposed deprecated revision; it still cannot remove +incoming references owned by other objects that way. + +## Restoring a deprecated object + +Un-deprecation creates a new revision of the selected object with the same STIX ID, +a later `modified` timestamp, and `x_mitre_deprecated: false`. It does not clear +`revoked`, and it is not an undo of every relationship change made when the +object was deprecated. + +The current object-restoration path saves the selected SDO; it does **not** +automatically restore ordinary SROs linked to that object. UI availability varies: +the main object toolbar disables its deprecate action after deprecation, while +applicable STIX dialogs can expose an un-deprecate action. API clients can POST +a valid new object revision directly. + +Restoring a retired SRO is a separate authoring request: POST a new revision of +that relationship with `x_mitre_deprecated: false`. The API checks **both endpoints' +latest revisions**, even when the relationship ID already exists: + +- If either endpoint is deprecated or revoked, an ordinary SRO restoration returns + `409 inactive_reference`. Its current retired revision and history remain unchanged. +- If both endpoints are active, an otherwise valid restoration is permitted. +- `revoked-by` retains its inactive-endpoint exception. Preserving an existing + `subtechnique-of` edge during SDO deprecation does not grant an exception for + explicitly restoring a retired `subtechnique-of` edge. + +For example, consider `Marcher (S0317) --uses--> Deliver Malicious Application via +Other Means (T1476)`: + +1. Retire the `uses` SRO, then deprecate T1476. +2. Deprecate S0317 as well. +3. Un-deprecate T1476. The `uses` SRO remains retired. +4. An explicit attempt to restore that SRO is rejected while S0317 is inactive. +5. Once S0317 is also active, the SRO can be explicitly restored as a new revision. + +The same rule applies with source and target reversed, or when the other endpoint +is revoked instead of deprecated. A bulk caller must not infer that restoring +one endpoint authorizes restoring all incident relationships. + +## Failure handling and boundaries + +- If the first check finds embedded blockers, the frontend makes **no writes**. +- If an SRO retirement fails or the final check/write is rejected, the frontend + stops. Earlier successful SRO retirements are not rolled back. Refresh the + object and relationships before retrying. +- A failed write can leave persisted work after an unexpected database or + post-persistence failure. Do not infer database state solely from the local UI + flag or blindly retry a multi-step operation. +- `409 graph_write_conflict` means another graph writer owns the lock. A crashed + writer requires the documented [operator recovery procedure](../admin/configuration.md#stix-graph-write-exclusion), + not deletion of STIX history. +- This policy protects **authoring operations**. Collection imports retain their + separate source-fidelity contract. Existing imported inactive objects are not + retroactively orphaned by this workflow. +- New embedded references to inactive targets and new active ordinary SROs with + inactive endpoints are rejected. `revoked-by` is the SRO creation exception. + Unchanged legacy embedded references may remain on unrelated revisions; + adding an occurrence or changing a reference-bearing record is checked. + +See [revocation](revoke-workflow.md) for replacement, preservation choices, and +why revocation does not require the original object to be orphaned first. diff --git a/docs/user/revoke-workflow.md b/docs/user/revoke-workflow.md index 2a506eff..539062b4 100644 --- a/docs/user/revoke-workflow.md +++ b/docs/user/revoke-workflow.md @@ -1,144 +1,259 @@ -# The 'Revoke Object' workflow +# Revoking objects -The Revoke Object workflow allows you to revoke an existing object in the system, which creates a new revoked version of the object with a new STIX ID and updated metadata. The original object remains in the system but is marked as revoked and is not returned in default queries. +Revocation means **replace this object with another existing object**. Call the +original object **A** and its replacement **B** throughout this guide. -Formerly, this workflow was orchestrated by the frontend, which made multiple API calls to achieve the desired result. Now, the backend has a dedicated endpoint that handles the entire revoke workflow in a single request, simplifying the process and reducing the potential for errors. +On success, Workbench stores a new revision of A with the **same STIX ID**, a +later `modified` timestamp, and `revoked: true`. It creates an active +`A --revoked-by--> B` SRO. It does not delete A, create B from scratch, or rewrite +historical revisions. Revoked objects are excluded from default lists but remain +retrievable. -## Usage +For retirement without a replacement, use [deprecation](deprecation-workflow.md). -To revoke an object, send a POST request to the following endpoint: +## Deprecation versus revocation -``` -POST /api/:type/:stixId/revoke -``` -Where `:stixId` is the STIX ID of the object you want to revoke and `:type` is the type of the object. - -e.g., `POST /api/attack-patterns/attack-pattern--00290ac5-551e-44aa-bbd8-c4b913488a6c/revoke` - -**Request Body**: - -Specify the `id` and `modified` timestamp for the revoked object in the request body. The `id` should be a new STIX ID that follows the standard format (e.g., `attack-pattern--xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`), and the `modified` timestamp should be in RFC3339 format. - -```json -{ - "revoking": { - "stixId": "attack-pattern--00290ac5-551e-44aa-bbd8-c4b913488a6f", - "modified": "2022-10-24T15:09:07.609Z" - } -} -``` - -### Query Parameters - -Optionally, you can set the following query parameter to preserve relationships: - -- `preserveRelationships` (boolean): If set to `true`, the workflow clones each relationship that references the revoked object so that it points to the revoking object instead, then deprecates the original. If not set or set to `false`, relationships referencing the revoked object are deprecated without being transferred. During transfer, each relationship on the revoked object (Object A) is rewritten so that Object A's STIX ID is replaced with Object B's STIX ID. If the revoking object (Object B) already participates in an equivalent relationship (i.e., one with the same source, target, and relationship type after substitution), the transfer is skipped and a warning is included in the response. Additionally, `subtechnique-of` relationships are never transferred — they are deprecated along with the other relationships but are excluded from the preservation process because transferring hierarchy relationships could create invalid parent/child states. A warning is emitted for each skipped `subtechnique-of` relationship. - -### Techniques and Subtechniques - -Both techniques (parents) and subtechniques are of STIX type `attack-pattern`, so the type check alone does not prevent cross-hierarchy revocations. The following rules apply: - -| Scenario | Allowed? | Notes | +| Question | Deprecation | Revocation | |---|---|---| -| Parent revokes parent | Yes | Standard flow, no hierarchy concerns | -| Sub revokes sub (same parent) | Yes | `subtechnique-of` relationships are skipped during preservation (shared parent) | -| Sub revokes sub (different parent) | Yes | `subtechnique-of` relationships are skipped during preservation; the revoking sub retains its existing parent | -| Parent revokes sub | Yes | `subtechnique-of` relationships are skipped during preservation | -| Sub revokes parent (parent has no children) | Yes | `subtechnique-of` relationships are skipped during preservation | -| Sub revokes parent (parent has children) | **No** | Would orphan the parent's subtechniques; convert the subtechnique to a parent first via the conversion endpoint | +| Intent | Retire without naming a replacement | Retire A in favor of existing B | +| Must the original be orphaned first? | Blocking references must be detached; existing `subtechnique-of` and `revoked-by` SROs are exempt | No; the backend handles the planned SRO changes and optional embedded transfer | +| Who orchestrates relationship writes? | Frontend or API caller, before the final SDO POST | Backend, within the dedicated revoke request | +| Existing active SROs | Caller retires ordinary SROs first; preserves `subtechnique-of` and `revoked-by` unchanged | Backend retires them; optionally creates equivalents involving B | +| Existing embedded references | Must be explicitly detached before deprecation | Transferred when preservation is enabled; left in place when it is disabled | +| Replacement link | None created | New active `revoked-by` SRO | +| History | Retained | Retained | + +Active means neither `revoked: true` nor `x_mitre_deprecated: true`. Relationship +planning uses each STIX ID's latest stored revision, not all historical versions. +Attribution, markings, external citations, and release inventory are not domain +embedded references for these workflows. + +## Before starting + +- A must exist and must not already be revoked. +- B must be a different existing object of the same STIX type. For example, + malware and tools share an API resource but are not the same STIX type. +- B must be active, and the supplied `modified` timestamp must identify its latest + revision. If it changed since selection, refresh and choose the current revision. +- The proposed writes must satisfy the installed ATT&CK Data Model and normal + authoring constraints. A revoke route alone does not make every type or payload + a valid `revoked-by` relationship. +- For embedded preservation, incoming referrers that need new revisions must be + active. An inactive referrer blocks preservation; it is not silently skipped + or rewritten. + +The current REST API exposes revoke routes for techniques, tactics, mitigations, +groups, software, campaigns, data sources, data components, matrices, and assets. +There is no dedicated analytic or detection-strategy revoke route. Schema +validation still limits which of the exposed workflows can succeed; this feature +does not add unsupported endpoint types or bypass ADM validation. + +## What the frontend does + +1. The user opens the revoke control on a supported object. +2. Workbench presents replacement candidates, excluding A and inactive objects. +3. The user selects B and chooses whether to preserve relationships. The checkbox + defaults to **off**. +4. The frontend sends **one mutation request** to A's revoke endpoint, containing + B's STIX ID, its selected revision timestamp, and the preservation option. +5. The backend performs the workflow. The frontend does not separately migrate + embedded references or issue an SRO-deprecation loop for revocation. +6. On successful completion, the frontend reloads the object. On error it displays + the failure and does not confirm the local object as successfully revoked. + +This differs from deprecation, where the frontend orchestrates separate ordinary +SRO retirements before the final object-deprecation request, preserving existing +`subtechnique-of` and `revoked-by` links. Those deprecation exemptions do not change +the dedicated revocation workflow described below. + +## What the backend does + +The dedicated revoke request coordinates the operation, not merely its validation: + +1. **Exclude competing graph writes.** Acquire the shared graph-write lock. +2. **Validate A, B, and hierarchy constraints.** Reject self-revocation, an already + revoked A, an invalid replacement, or an incompatible technique hierarchy. +3. **Plan the complete changes.** Find A's latest active incident SROs. Plan their + retirement, optional replacement SROs, optional embedded source/replacement + revisions, and the new `A --revoked-by--> B` link. +4. **Preflight the planned writes.** Validate the proposed revisions through their + owning services before persisting them. Expected schema errors and reference + conflicts stop here, without revoking A or saving the planned changes. +5. **Persist the plan.** Save embedded source/replacement revisions when requested, + deprecated revisions of the original SROs, replacement SROs when applicable, + and the new `revoked-by` SRO. Then save A's new revoked revision. +6. **Reconcile affected metadata.** With preservation enabled, rebuild affected + latest embedded relationship caches from authoritative STIX. Notify the normal + revocation lifecycle listeners, including release-track revision handling. +7. **Return the workflow result.** The response identifies A and the created, + revised, and deprecated objects. + +The graph lock prevents authoring changes from racing validation and persistence. +It is **not a multi-document database transaction**; see failure handling below. + +## Preservation choices + +### `preserveRelationships=false` — the default + +- Create deprecated revisions of A's latest active incident SROs. Keep their + original IDs and endpoints; older revisions remain stored. +- Create the new active `A --revoked-by--> B` SRO. +- Do not create replacement copies of the original SROs. +- Do not detach or migrate incoming or outgoing embedded references. + +For example, if active analytic X references component A, revoking A without +preservation leaves X referencing **revoked A**, not B. A still exists, so this +is a resolvable inactive reference rather than a hard-delete dangling reference. + +**This is an intentional boundary of the current implementation:** the prohibition +on *adding* references to inactive objects does not retroactively remove existing +references when preservation is disabled. Revocation is not the orphan-first +operation; deprecation is. + +### `preserveRelationships=true` + +#### Formal SROs + +For each eligible latest active SRO touching A, substitute B for A in its +endpoints and create a **new relationship ID**. Retire the original SRO through +a new deprecated revision. + +Example: `Group --uses--> A` becomes a new active `Group --uses--> B`, while the +old edge's latest revision becomes deprecated. + +An equivalent active SRO already involving B is not duplicated. Equality here +means the same source, relationship type, and target. A warning reports each +skipped duplicate. Already-retired SROs are not transferred. + +#### Incoming embedded references + +For each other latest object that references A, create a **new revision of that +source object**, replacing the domain reference to A with B. Keep the source's +STIX ID; do not change its historical revisions. + +Example: analytic X has a log-source record containing component A, a log-source +name, and a channel. The new X revision points to component B and retains that +name and channel. Its old revision still points to A. + +An inactive source object that would need this change causes `409 inactive_referrer`. +The workflow does not automatically reactivate it or write another revoked revision. + +#### Outgoing embedded references + +Merge A's outgoing domain references into B, creating a new B revision if needed: + +- Preserve B's existing references. +- Union reference-bearing arrays, deduplicating equal entries without discarding + distinct log-source name/channel records. +- Reject a conflicting single-reference field with `409 embedded_reference_conflict`. + Do not guess which value should win. -When a revocation is blocked due to these rules, the API returns a **400 Bad Request** with a message explaining the constraint violation. +For example, if component A references source S and B has no source reference, +a new B revision can acquire S. If B already references a different source T, +preservation is blocked until the conflict is resolved explicitly. + +A's new revoked revision retains A's original outgoing STIX references. Therefore, +a target can correctly show backlinks from both revoked A and active B after +outgoing preservation. Those backlinks describe stored references, not an +active-only projection. Relevant source and target workspace caches are reconciled; +historical STIX and sealed release contents are not rewritten. -## Response +### Special SROs: retirement is not transfer -### Success Response +`subtechnique-of` SROs are **retired but never copied onto B** during revocation, +even when preservation is enabled. Blindly substituting B could create an invalid +parent/child hierarchy. Each skipped hierarchy transfer produces a warning. -On success, the API returns a **200 OK** with a [workflow response envelope](../../docs/developer/workflow-response-pattern.md). The response includes the revoked object, the `revoked-by` relationship, any transferred relationships, and any deprecated relationships: +This does not restore the former *deprecation* exemption: deprecation also requires +those edges to be retired. The difference is that revocation additionally decides +which edges may be recreated for a replacement. -```json -{ - "workflow": "revoke", - "primary": { - "workspace": { - "workflow": { "state": "work-in-progress" }, - "attack_id": "T0006" - }, - "stix": { - "type": "attack-pattern", - "spec_version": "2.1", - "id": "attack-pattern--83efdc56-d35f-4508-9f10-152bbfffde79", - "revoked": true, - "modified": "2026-03-27T14:31:52.744Z", - "name": "technique-E" - } - }, - "sideEffects": { - "created": [ - { - "workspace": { "workflow": {} }, - "stix": { - "type": "relationship", - "relationship_type": "revoked-by", - "source_ref": "attack-pattern--83efdc56-d35f-4508-9f10-152bbfffde79", - "target_ref": "attack-pattern--ab992c5a-4a03-4374-ad15-440fac072760" - } - } - ], - "modified": [], - "deprecated": [ - { - "workspace": { "workflow": { "state": "reviewed" } }, - "stix": { - "type": "relationship", - "relationship_type": "uses", - "x_mitre_deprecated": true - } - } - ], - "deleted": { "count": 0, "stixIds": [] } - }, - "warnings": [] -} -``` +A pre-existing active `revoked-by` SRO touching A participates in the ordinary +retirement/transfer plan. The **new** `A --revoked-by--> B` SRO is created after that +plan is collected and remains active. It is the record of this replacement. -| Field | Description | -|-------|-------------| -| `workflow` | Always `"revoke"` | -| `primary` | The technique in its post-revocation state (`revoked: true`) | -| `sideEffects.created` | The `revoked-by` relationship, plus any transferred relationships (when `preserveRelationships=true`) | -| `sideEffects.deprecated` | Relationships that referenced the revoked object, deprecated with `x_mitre_deprecated: true` | -| `warnings` | Non-fatal issues (e.g., duplicate relationships skipped during transfer) | +For techniques, the backend specifically rejects replacing a parent technique +with a subtechnique while the parent still has active child `subtechnique-of` +relationships. Rehome the children or convert the proposed replacement into a +parent first. Other transfers still do not automatically reparent children. +See [technique conversion](technique-conversion-workflow.md). -> **Note:** When `preserveRelationships=true`, relationships are cloned to point to the revoking object (appearing in `sideEffects.created`) and the originals are deprecated (appearing in `sideEffects.deprecated`). If a duplicate relationship already exists on the revoking object, the transfer is skipped and a warning is emitted instead. +## REST API usage -### Error Responses +Mutation requires an authenticated editor-or-higher account. Use the plural API +resource name, not the STIX type name: -#### 409 Conflict - -If you attempt to revoke an object that has already been revoked, you will receive a 409 Conflict response with the following message: - -```json -{ - "message": "Object has already been revoked", - "details": "Object attack-pattern--00290ac5-551e-44aa-bbd8-c4b913488a6c is already revoked" -} +```http +POST /api/techniques/:stixId/revoke?preserveRelationships=true +Content-Type: application/json ``` -#### 404 Not Found - -If you attempt to revoke an object that does not exist, you will receive a 404 Not Found response with the following message: - ```json { - "message": "Document not found", - "details": "Object B with stixId attack-pattern--00290ac5-551e-44aa-bbd8-c4b913488a6f and modified 2022-10-24T15:09:07.609Z not found" + "revoking": { + "stixId": "attack-pattern--00290ac5-551e-44aa-bbd8-c4b913488a6f", + "modified": "2026-03-27T14:31:52.744Z" + } } ``` -#### 400 Self Revocation Error - -If you attempt to revoke an object by revoking with the same STIX ID and modified timestamp as the original object (i.e., self-revocation), you will receive a 400 Bad Request response with the following message: - -```html -"An object cannot revoke itself" -``` \ No newline at end of file +The path identifies **A**; `revoking` identifies **B**. Use an actual existing +replacement and its latest timestamp. Omitting `preserveRelationships` is the +same as setting it to `false`. The endpoint does not accept a new replacement +object body or require the caller to POST `revoked: true` separately. + +### Successful response + +Success returns `200` with the [workflow response envelope](../developer/workflow-response-pattern.md): + +| Field | Meaning | +|---|---| +| `workflow` | `revoke` | +| `primary` | A's new revoked revision | +| `sideEffects.created` | The new `revoked-by` SRO and any transferred SROs | +| `sideEffects.modified` | New revisions of embedded referrers and/or B; these are not in-place STIX edits | +| `sideEffects.deprecated` | New deprecated revisions of original SROs | +| `sideEffects.deleted` | No objects are hard-deleted by this workflow | +| `warnings` | Non-fatal skips, such as duplicate SROs or hierarchy transfers | + +API clients should inspect the envelope and warnings. A successful request does +not mean every original edge was copied: hierarchy and duplicate rules still apply. + +### Errors and recovery + +| Response | Meaning / next action | +|---|---| +| `400` | Missing/invalid input, self-revocation, hierarchy constraint, or proposed writes rejected by schema validation | +| `404` | A or an appropriate same-type B cannot be found | +| `409 invalid_replacement` | B is inactive or the supplied timestamp is not its latest revision; refresh B | +| `409 inactive_referrer` | Embedded preservation would require revising an inactive source; review that source explicitly | +| `409 embedded_reference_conflict` | B has an incompatible single-reference value; resolve the conflict rather than overwriting it | +| `409 inactive_reference` | A proposed new domain reference or active ordinary SRO would involve an inactive target/endpoint | +| `409 graph_write_conflict` | Another worker owns graph-write exclusion; refresh/retry after it completes, or ask an operator to investigate a crashed writer | +| `409` for an already-revoked original | A cannot be revoked again | +| Unexpected persistence/service error | Inspect A, B, and affected revisions before retrying; some planned writes may have persisted | + +Preflight protects against expected validation failures, but it cannot roll back +an unexpected database failure. Since the backend writes side effects before A's +revoked revision, a failed request may leave migrated references or retired SROs +while A is still active. A later failure can also occur after A was saved. Do not +assume an error means that nothing changed, and do not manually delete history as +recovery. A crashed writer's lock requires the documented +[operator recovery procedure](../admin/configuration.md#stix-graph-write-exclusion). + +## Ongoing reference restrictions and scope + +After retirement, authoring new domain embedded references requires an existing +active target. Creating an active ordinary SRO requires active endpoints. +`revoked-by` is exempt from that inactivity restriction because it records a +replacement involving a revoked object; schema and existence checks still apply. + +Unchanged legacy embedded references may remain on unrelated revisions. Adding a +new occurrence or changing a reference-bearing record is checked. Collection +imports use their separate source-fidelity contract: importing `revoked: true` +is not a call to this workflow, does not automatically migrate existing local +references, and is not silently filtered by these authoring rules. + +For the distinct orphan-first rule, its frontend ordering, and the reasons the +old deprecation exemptions/cascade were removed, see +[Deprecating objects](deprecation-workflow.md).