From cb81e1247661bad1b6386537e02d60d8ad6528a0 Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:35:57 -0400 Subject: [PATCH 1/3] fix(release-tracks): include tier counts in release track list --- .../definitions/components/release-tracks.yml | 13 ++++++++++ .../release-track-dynamic.repository.js | 23 ++++++++++++++++++ .../release-tracks/snapshot-service.js | 24 ++++++++++++++++++- docs/user/release-tracks/api-reference.md | 16 ++++++++++--- 4 files changed, 72 insertions(+), 4 deletions(-) diff --git a/app/api/definitions/components/release-tracks.yml b/app/api/definitions/components/release-tracks.yml index 214d28ef..3b8b2ced 100644 --- a/app/api/definitions/components/release-tracks.yml +++ b/app/api/definitions/components/release-tracks.yml @@ -302,6 +302,19 @@ components: tagged_release_count: type: number description: 'Number of tagged releases' + summary: + type: object + description: 'Counts of objects in each release track tier for the latest snapshot' + properties: + members_count: + type: number + description: 'Number of objects in the members tier' + staged_count: + type: number + description: 'Number of objects in the staged tier' + candidates_count: + type: number + description: 'Number of objects in the candidates tier' created_at: type: string format: date-time diff --git a/app/repository/release-tracks/release-track-dynamic.repository.js b/app/repository/release-tracks/release-track-dynamic.repository.js index e34be760..9a2475d1 100644 --- a/app/repository/release-tracks/release-track-dynamic.repository.js +++ b/app/repository/release-tracks/release-track-dynamic.repository.js @@ -31,6 +31,29 @@ class ReleaseTrackDynamicRepository { } } + async getLatestSnapshotTierSummary(trackId) { + try { + const Model = this._getModel(trackId); + const [summary] = await Model.aggregate([ + { $match: { id: trackId } }, + { $sort: { modified: -1 } }, + { $limit: 1 }, + { + $project: { + _id: 0, + members_count: { $size: { $ifNull: ['$members', []] } }, + staged_count: { $size: { $ifNull: ['$staged', []] } }, + candidates_count: { $size: { $ifNull: ['$candidates', []] } }, + }, + }, + ]).exec(); + + return summary || null; + } catch (err) { + throw new DatabaseError(err); + } + } + async getSnapshotByModified(trackId, modified) { try { const Model = this._getModel(trackId); diff --git a/app/services/release-tracks/snapshot-service.js b/app/services/release-tracks/snapshot-service.js index faa0ab4d..674b1674 100644 --- a/app/services/release-tracks/snapshot-service.js +++ b/app/services/release-tracks/snapshot-service.js @@ -36,6 +36,14 @@ function deepClone(snapshot) { return clone; } +function normalizeTierSummary(summary) { + return { + members_count: summary?.members_count ?? 0, + staged_count: summary?.staged_count ?? 0, + candidates_count: summary?.candidates_count ?? 0, + }; +} + /** * Recompute and persist denormalized registry counters from actual snapshot data. * @@ -76,7 +84,21 @@ async function syncRegistryCounters(trackId) { * @returns {Promise<{ data: Object[], pagination: Object }>} */ exports.listTracks = async function listTracks(options) { - return registryRepo.findAll(options); + const result = await registryRepo.findAll(options); + const data = await Promise.all( + result.data.map(async (track) => { + const summary = await dynamicRepo.getLatestSnapshotTierSummary(track.track_id); + return { + ...track, + summary: normalizeTierSummary(summary), + }; + }), + ); + + return { + ...result, + data, + }; }; /** diff --git a/docs/user/release-tracks/api-reference.md b/docs/user/release-tracks/api-reference.md index 08601f2a..90ab6ccb 100644 --- a/docs/user/release-tracks/api-reference.md +++ b/docs/user/release-tracks/api-reference.md @@ -153,7 +153,12 @@ GET /api/release-tracks "latest_version": "14.1", "latest_modified": "2024-01-15T16:20:00Z", "snapshot_count": 47, - "tagged_release_count": 12 + "tagged_release_count": 12, + "summary": { + "members_count": 3247, + "staged_count": 18, + "candidates_count": 42 + } }, { "id": "release-track--456", @@ -163,7 +168,12 @@ GET /api/release-tracks "latest_version": null, "latest_modified": "2024-01-10T10:00:00Z", "snapshot_count": 3, - "tagged_release_count": 2 + "tagged_release_count": 2, + "summary": { + "members_count": 870, + "staged_count": 0, + "candidates_count": 0 + } } ], "total": 2, @@ -967,4 +977,4 @@ The `include` query parameter is **NOT supported** on bump preview or dry-run en - `GET /api/release-tracks/:id/bump/preview` — only `format` is supported - `POST /api/release-tracks/:id/bump` with `dry_run: true` — only `format` is supported (via request body) -These endpoints are designed to show exactly what *will* happen during a release bump. Allowing ad-hoc tier filters would be misleading because they do not affect the actual release outcome. \ No newline at end of file +These endpoints are designed to show exactly what *will* happen during a release bump. Allowing ad-hoc tier filters would be misleading because they do not affect the actual release outcome. From fef7442658264234744aa07a73de843d606e7497 Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Fri, 26 Jun 2026 09:36:04 -0400 Subject: [PATCH 2/3] feat(release-tracks): add object details to release track tiers --- .../definitions/components/release-tracks.yml | 26 +++ .../release-tracks/release-tracks-service.js | 68 ++++++- .../api/release-tracks/release-tracks.spec.js | 178 ++++++++++++++++++ docs/user/release-tracks/api-reference.md | 7 + 4 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 app/tests/api/release-tracks/release-tracks.spec.js diff --git a/app/api/definitions/components/release-tracks.yml b/app/api/definitions/components/release-tracks.yml index 3b8b2ced..d5459d1c 100644 --- a/app/api/definitions/components/release-tracks.yml +++ b/app/api/definitions/components/release-tracks.yml @@ -90,6 +90,32 @@ components: type: string format: date-time description: 'Version pin: the modified timestamp of this object version' + attack_id: + type: string + description: 'ATT&CK ID, if found' + example: 'T1234' + name: + type: string + description: 'Object name, if found' + description: + type: string + description: 'Object description, if found' + modified_by_user: + type: object + description: 'User who last modified this object version, if found' + properties: + id: + type: string + description: 'User ID' + username: + type: string + description: 'Username' + displayName: + type: string + description: 'Display name' + name: + type: string + description: 'Display name, or username if display name is missing' candidate-entry: allOf: diff --git a/app/services/release-tracks/release-tracks-service.js b/app/services/release-tracks/release-tracks-service.js index 51c9ff05..c5c1b3b4 100644 --- a/app/services/release-tracks/release-tracks-service.js +++ b/app/services/release-tracks/release-tracks-service.js @@ -23,6 +23,7 @@ const exportService = require('./export-service'); const ephemeralService = require('./ephemeral-service'); const bundleImportService = require('./bundle-import-service'); const memberSyncService = require('./member-sync-service'); +const attackObjectsService = require('../stix/attack-objects-service'); const MODULE = 'release-tracks-service'; @@ -30,6 +31,71 @@ function notImplemented(methodName) { throw new NotImplementedError(MODULE, methodName); } +function versionKey(objectRef, objectModified) { + return `${objectRef}:${new Date(objectModified).toISOString()}`; +} + +function addObjectInfo(entry, objectsByVersion) { + const object = objectsByVersion.get(versionKey(entry.object_ref, entry.object_modified)); + if (!object) return entry; + + const user = object.created_by_user_account; + const entryWithObjectInfo = { + ...entry, + attack_id: object.workspace?.attack_id, + name: object.stix?.name, + }; + + if (object.stix?.description !== undefined) { + entryWithObjectInfo.description = object.stix.description; + } + + if (user) { + entryWithObjectInfo.modified_by_user = { + id: user.id, + username: user.username, + displayName: user.displayName, + name: user.displayName || user.username, + }; + } + + return entryWithObjectInfo; +} + +async function addObjectInfoToSnapshot(snapshot) { + const candidates = snapshot.candidates || []; + const staged = snapshot.staged || []; + const tierEntries = [...candidates, ...staged]; + + if (tierEntries.length === 0) { + return snapshot; + } + + const uniqueEntriesByVersion = new Map(); + for (const entry of tierEntries) { + uniqueEntriesByVersion.set(versionKey(entry.object_ref, entry.object_modified), entry); + } + + const objects = await attackObjectsService.getBulkByIdAndModified([ + ...uniqueEntriesByVersion.values(), + ]); + const objectsByVersion = new Map( + objects.map((object) => [versionKey(object.stix.id, object.stix.modified), object]), + ); + + const snapshotWithObjectInfo = { ...snapshot }; + if (snapshot.candidates) { + snapshotWithObjectInfo.candidates = candidates.map((entry) => + addObjectInfo(entry, objectsByVersion), + ); + } + if (snapshot.staged) { + snapshotWithObjectInfo.staged = staged.map((entry) => addObjectInfo(entry, objectsByVersion)); + } + + return snapshotWithObjectInfo; +} + // ----------------------------------------------------------------------------- // Track management (Phase 1 → snapshot-service) // ----------------------------------------------------------------------------- @@ -62,7 +128,7 @@ exports.getLatestSnapshot = async function getLatestSnapshot(trackId, options) { if (format && format !== 'snapshot') { return exportService.exportSnapshot(snapshot, format, options); } - return snapshot; + return addObjectInfoToSnapshot(snapshot); }; exports.getSnapshotByModified = async function getSnapshotByModified(trackId, modified, options) { diff --git a/app/tests/api/release-tracks/release-tracks.spec.js b/app/tests/api/release-tracks/release-tracks.spec.js new file mode 100644 index 00000000..79b630ad --- /dev/null +++ b/app/tests/api/release-tracks/release-tracks.spec.js @@ -0,0 +1,178 @@ +const request = require('supertest'); +const { expect } = require('expect'); + +const config = require('../../../config/config'); +const database = require('../../../lib/database-in-memory'); +const databaseConfiguration = require('../../../lib/database-configuration'); +const login = require('../../shared/login'); + +const logger = require('../../../lib/logger'); +logger.level = 'debug'; + +function buildTechnique(name, description) { + const timestamp = new Date().toISOString(); + return { + workspace: { + workflow: { + state: 'work-in-progress', + }, + }, + stix: { + created: timestamp, + modified: timestamp, + name, + description, + spec_version: '2.1', + type: 'attack-pattern', + object_marking_refs: ['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], + created_by_ref: 'identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', + kill_chain_phases: [{ kill_chain_name: 'kill-chain-name-1', phase_name: 'phase-1' }], + x_mitre_is_subtechnique: false, + x_mitre_platforms: ['platform-1'], + }, + }; +} + +describe('Release Tracks API', function () { + let app; + let passportCookie; + + before(async function () { + await database.initializeConnection(); + await databaseConfiguration.checkSystemConfiguration(); + + config.validateRequests.withAttackDataModel = false; + config.validateRequests.withOpenApi = true; + + app = await require('../../../index').initializeApp(); + passportCookie = await login.loginAnonymous(app); + }); + + async function createTechnique(name, description) { + const res = await request(app) + .post('/api/techniques') + .send(buildTechnique(name, description)) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201) + .expect('Content-Type', /json/); + + return res.body; + } + + it('GET /api/release-tracks includes latest tier count summaries', async function () { + const memberObject = await createTechnique('Member Technique', 'Member description'); + const candidateObject = await createTechnique('Candidate Technique', 'Candidate description'); + const stagedObject = await createTechnique('Staged Technique', 'Staged description'); + + const createRes = await request(app) + .post('/api/release-tracks/new') + .send({ + name: 'Enterprise Test', + description: 'Release track summary test', + type: 'standard', + }) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(201) + .expect('Content-Type', /json/); + + const trackId = createRes.body.id; + + await request(app) + .post(`/api/release-tracks/${trackId}/contents`) + .send({ + x_mitre_contents: [ + { + obj_ref: memberObject.stix.id, + obj_modified: memberObject.stix.modified, + }, + ], + }) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200) + .expect('Content-Type', /json/); + + await request(app) + .post(`/api/release-tracks/${trackId}/candidates`) + .send({ + object_refs: [ + { + id: candidateObject.stix.id, + modified: candidateObject.stix.modified, + }, + { + id: stagedObject.stix.id, + modified: stagedObject.stix.modified, + }, + ], + }) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200) + .expect('Content-Type', /json/); + + await request(app) + .post(`/api/release-tracks/${trackId}/candidates/promote`) + .send({ + object_refs: [stagedObject.stix.id], + }) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200) + .expect('Content-Type', /json/); + + const listRes = await request(app) + .get('/api/release-tracks') + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200) + .expect('Content-Type', /json/); + + const track = listRes.body.data.find((entry) => entry.track_id === trackId); + expect(track).toBeDefined(); + expect(track.summary).toEqual({ + members_count: 1, + staged_count: 1, + candidates_count: 1, + }); + + const latestRes = await request(app) + .get(`/api/release-tracks/${trackId}`) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200) + .expect('Content-Type', /json/); + + const candidate = latestRes.body.candidates.find( + (entry) => entry.object_ref === candidateObject.stix.id, + ); + expect(candidate).toMatchObject({ + attack_id: candidateObject.workspace.attack_id, + name: candidateObject.stix.name, + description: candidateObject.stix.description, + modified_by_user: { + username: 'anonymous', + displayName: 'Anonymous User', + name: 'Anonymous User', + }, + }); + + const staged = latestRes.body.staged.find((entry) => entry.object_ref === stagedObject.stix.id); + expect(staged).toMatchObject({ + attack_id: stagedObject.workspace.attack_id, + name: stagedObject.stix.name, + description: stagedObject.stix.description, + modified_by_user: { + username: 'anonymous', + displayName: 'Anonymous User', + name: 'Anonymous User', + }, + }); + }); + + after(async function () { + await database.closeConnection(); + }); +}); diff --git a/docs/user/release-tracks/api-reference.md b/docs/user/release-tracks/api-reference.md index 90ab6ccb..a4a49e3c 100644 --- a/docs/user/release-tracks/api-reference.md +++ b/docs/user/release-tracks/api-reference.md @@ -262,6 +262,13 @@ Retrieves the most recent snapshot from the release track (by `modified` timesta GET /api/release-tracks/:id ``` +For raw snapshot responses, entries in the `candidates` and `staged` tiers include extra object details: + +- `attack_id` +- `name` +- `description` (when available) +- `modified_by_user.name` (display name, or username if display name is missing) + **Query Parameters:** | Parameter | Values | Description | From 924834f3d3eeec0a7b5dd3ae2d9f9f712189a581 Mon Sep 17 00:00:00 2001 From: Charissa Miller <48832936+clemiller@users.noreply.github.com> Date: Fri, 26 Jun 2026 09:48:58 -0400 Subject: [PATCH 3/3] fix(release-tracks): fall back to tier user for modified user details --- .../release-tracks/release-tracks-service.js | 66 ++++++++++++++----- .../api/release-tracks/release-tracks.spec.js | 10 +++ 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/app/services/release-tracks/release-tracks-service.js b/app/services/release-tracks/release-tracks-service.js index c5c1b3b4..b48365a9 100644 --- a/app/services/release-tracks/release-tracks-service.js +++ b/app/services/release-tracks/release-tracks-service.js @@ -24,6 +24,7 @@ const ephemeralService = require('./ephemeral-service'); const bundleImportService = require('./bundle-import-service'); const memberSyncService = require('./member-sync-service'); const attackObjectsService = require('../stix/attack-objects-service'); +const userAccountsService = require('../system/user-accounts-service'); const MODULE = 'release-tracks-service'; @@ -35,28 +36,59 @@ function versionKey(objectRef, objectModified) { return `${objectRef}:${new Date(objectModified).toISOString()}`; } -function addObjectInfo(entry, objectsByVersion) { - const object = objectsByVersion.get(versionKey(entry.object_ref, entry.object_modified)); - if (!object) return entry; +function getTierUserId(entry) { + return entry.object_added_by || entry.object_staged_by; +} + +function formatUser(user) { + if (!user) return undefined; + + return { + id: user.id, + username: user.username, + displayName: user.displayName, + name: user.displayName || user.username, + }; +} + +async function getUsersById(userIds) { + const usersById = new Map(); + + await Promise.all( + userIds.map(async (userId) => { + if (userId === 'system') { + usersById.set(userId, { id: userId, username: userId }); + return; + } + + const user = await userAccountsService.getLatest(userId); + if (user) { + usersById.set(userId, user); + } + }), + ); + + return usersById; +} - const user = object.created_by_user_account; +function addObjectInfo(entry, objectsByVersion, usersById) { + const object = objectsByVersion.get(versionKey(entry.object_ref, entry.object_modified)); const entryWithObjectInfo = { ...entry, - attack_id: object.workspace?.attack_id, - name: object.stix?.name, }; - if (object.stix?.description !== undefined) { + if (object) { + entryWithObjectInfo.attack_id = object.workspace?.attack_id; + entryWithObjectInfo.name = object.stix?.name; + } + + if (object?.stix?.description !== undefined) { entryWithObjectInfo.description = object.stix.description; } + const user = object?.created_by_user_account || usersById.get(getTierUserId(entry)); if (user) { - entryWithObjectInfo.modified_by_user = { - id: user.id, - username: user.username, - displayName: user.displayName, - name: user.displayName || user.username, - }; + entryWithObjectInfo.modified_by_user = formatUser(user); } return entryWithObjectInfo; @@ -82,15 +114,19 @@ async function addObjectInfoToSnapshot(snapshot) { const objectsByVersion = new Map( objects.map((object) => [versionKey(object.stix.id, object.stix.modified), object]), ); + const userIds = [...new Set(tierEntries.map(getTierUserId).filter(Boolean))]; + const usersById = await getUsersById(userIds); const snapshotWithObjectInfo = { ...snapshot }; if (snapshot.candidates) { snapshotWithObjectInfo.candidates = candidates.map((entry) => - addObjectInfo(entry, objectsByVersion), + addObjectInfo(entry, objectsByVersion, usersById), ); } if (snapshot.staged) { - snapshotWithObjectInfo.staged = staged.map((entry) => addObjectInfo(entry, objectsByVersion)); + snapshotWithObjectInfo.staged = staged.map((entry) => + addObjectInfo(entry, objectsByVersion, usersById), + ); } return snapshotWithObjectInfo; diff --git a/app/tests/api/release-tracks/release-tracks.spec.js b/app/tests/api/release-tracks/release-tracks.spec.js index 79b630ad..a59d9451 100644 --- a/app/tests/api/release-tracks/release-tracks.spec.js +++ b/app/tests/api/release-tracks/release-tracks.spec.js @@ -5,6 +5,7 @@ const config = require('../../../config/config'); const database = require('../../../lib/database-in-memory'); const databaseConfiguration = require('../../../lib/database-configuration'); const login = require('../../shared/login'); +const AttackObject = require('../../../models/attack-object-model'); const logger = require('../../../lib/logger'); logger.level = 'debug'; @@ -60,10 +61,19 @@ describe('Release Tracks API', function () { return res.body; } + async function removeObjectUser(object) { + await AttackObject.updateOne( + { 'stix.id': object.stix.id, 'stix.modified': object.stix.modified }, + { $unset: { 'workspace.workflow.created_by_user_account': '' } }, + ); + } + it('GET /api/release-tracks includes latest tier count summaries', async function () { const memberObject = await createTechnique('Member Technique', 'Member description'); const candidateObject = await createTechnique('Candidate Technique', 'Candidate description'); const stagedObject = await createTechnique('Staged Technique', 'Staged description'); + await removeObjectUser(candidateObject); + await removeObjectUser(stagedObject); const createRes = await request(app) .post('/api/release-tracks/new')