diff --git a/app/api/definitions/components/release-tracks.yml b/app/api/definitions/components/release-tracks.yml index 214d28ef..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: @@ -302,6 +328,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/release-tracks-service.js b/app/services/release-tracks/release-tracks-service.js index 51c9ff05..b48365a9 100644 --- a/app/services/release-tracks/release-tracks-service.js +++ b/app/services/release-tracks/release-tracks-service.js @@ -23,6 +23,8 @@ 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 userAccountsService = require('../system/user-accounts-service'); const MODULE = 'release-tracks-service'; @@ -30,6 +32,106 @@ function notImplemented(methodName) { throw new NotImplementedError(MODULE, methodName); } +function versionKey(objectRef, objectModified) { + return `${objectRef}:${new Date(objectModified).toISOString()}`; +} + +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; +} + +function addObjectInfo(entry, objectsByVersion, usersById) { + const object = objectsByVersion.get(versionKey(entry.object_ref, entry.object_modified)); + const entryWithObjectInfo = { + ...entry, + }; + + 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 = formatUser(user); + } + + 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 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, usersById), + ); + } + if (snapshot.staged) { + snapshotWithObjectInfo.staged = staged.map((entry) => + addObjectInfo(entry, objectsByVersion, usersById), + ); + } + + return snapshotWithObjectInfo; +} + // ----------------------------------------------------------------------------- // Track management (Phase 1 → snapshot-service) // ----------------------------------------------------------------------------- @@ -62,7 +164,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/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/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..a59d9451 --- /dev/null +++ b/app/tests/api/release-tracks/release-tracks.spec.js @@ -0,0 +1,188 @@ +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 AttackObject = require('../../../models/attack-object-model'); + +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; + } + + 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') + .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 08601f2a..a4a49e3c 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, @@ -252,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 | @@ -967,4 +984,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.