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/api/definitions/paths/release-tracks-paths.yml b/app/api/definitions/paths/release-tracks-paths.yml index dd67ea65..88974eb7 100644 --- a/app/api/definitions/paths/release-tracks-paths.yml +++ b/app/api/definitions/paths/release-tracks-paths.yml @@ -21,19 +21,19 @@ paths: example: 'enterprise-attack' - name: format in: query - description: 'Output format (bundle, workbench, or filesystem-store)' + description: 'Output format. filesystemstore is not yet implemented.' schema: type: string enum: - bundle - workbench - - filesystem-store + - filesystemstore default: bundle responses: '200': description: 'Ephemeral bundle generated successfully' '501': - description: 'Not yet implemented' + description: 'Requested format is not yet implemented' # ============================================================================= # Track management @@ -154,7 +154,8 @@ paths: operationId: 'release-tracks-get-latest' description: | Retrieve the most recent snapshot for a release track. - By default returns only members; use include query param for other tiers. + By default returns the Workbench snapshot shape with all tiers present. + Use the include query parameter to narrow tier arrays when desired. tags: - 'Release Tracks' parameters: @@ -174,19 +175,19 @@ paths: - members - staged - candidates + - quarantine - all - default: members + default: all - name: format in: query - description: 'Output format: snapshot (raw), bundle (STIX 2.1), workbench (with metadata), filesystemstore (not implemented)' + description: 'Output format. filesystemstore is not yet implemented.' schema: type: string enum: - - snapshot - bundle - workbench - filesystemstore - default: snapshot + default: workbench responses: '200': description: 'Latest snapshot retrieved successfully' @@ -196,6 +197,8 @@ paths: $ref: '../components/release-tracks.yml#/components/schemas/release-track-snapshot' '404': description: 'Release track not found' + '501': + description: 'Requested format is not yet implemented' delete: summary: 'Delete a release track' @@ -326,17 +329,19 @@ paths: type: string - name: format in: query + description: 'Output format. filesystemstore is not yet implemented.' schema: type: string enum: - - summary - - detailed - default: summary + - bundle + - workbench + - filesystemstore + default: workbench responses: '200': description: 'Release preview generated' '501': - description: 'Not yet implemented' + description: 'Requested format is not yet implemented' # ============================================================================= # Candidate management @@ -728,22 +733,26 @@ paths: - members - staged - candidates + - quarantine - all - default: members + default: all - name: format in: query + description: 'Output format. filesystemstore is not yet implemented.' schema: type: string enum: - - snapshot - bundle + - filesystemstore - workbench - default: snapshot + default: workbench responses: '200': description: 'Snapshot retrieved successfully' '404': description: 'Snapshot not found' + '501': + description: 'Requested format is not yet implemented' delete: summary: 'Delete a specific snapshot' diff --git a/app/controllers/release-tracks-controller.js b/app/controllers/release-tracks-controller.js index 239a9c8d..9f172ca7 100644 --- a/app/controllers/release-tracks-controller.js +++ b/app/controllers/release-tracks-controller.js @@ -56,12 +56,31 @@ function parseOptionalQuery(value, schema, defaultValue) { return result.success ? result.data : defaultValue; } +function parseOptionalQueryStrict(value, schema, defaultValue, parameterName) { + if (value === undefined || value === null) return defaultValue; + const result = schema.safeParse(value); + if (result.success) return result.data; + + throw new InvalidQueryStringParameterError({ + parameterName, + message: `Invalid ${parameterName} parameter`, + }); +} + +function rejectFilesystemStoreFormat(format, methodName) { + if (format !== 'filesystemstore') return null; + + return new NotImplementedError('release-tracks-controller', methodName, { + message: 'The filesystemstore format is not yet implemented', + }); +} + /** * Parse common query parameters shared across GET snapshot endpoints. */ function parseSnapshotQueryParams(query) { return { - format: parseOptionalQuery(query.format, formatQuerySchema, 'snapshot'), + format: parseOptionalQueryStrict(query.format, formatQuerySchema, 'workbench', 'format'), include: parseOptionalQuery(query.include, includeQuerySchema, undefined), releases: query.releases === 'only' ? 'only' : undefined, version: parseOptionalQuery(query.version, xMitreVersionSchema, undefined), @@ -88,7 +107,17 @@ exports.retrieveEphemeralByDomain = async function retrieveEphemeralByDomain(req ); } - const format = parseOptionalQuery(req.query.format, formatQuerySchema, 'bundle'); + const format = parseOptionalQueryStrict( + req.query.format, + formatQuerySchema, + 'bundle', + 'format', + ); + const formatError = rejectFilesystemStoreFormat(format, 'retrieveEphemeralByDomain'); + if (formatError) { + return next(formatError); + } + const result = await releaseTracksService.getEphemeralBundle(domainResult.data, format); logger.debug(`Success: Retrieved ephemeral ${domainResult.data} bundle`); return res.status(200).send(result); @@ -182,14 +211,9 @@ exports.importReleaseTrack = async function importReleaseTrack(_req, _res, next) exports.retrieveLatestSnapshot = async function retrieveLatestSnapshot(req, res, next) { try { const queryOptions = parseSnapshotQueryParams(req.query); - - // filesystemstore format is not yet implemented - if (queryOptions.format === 'filesystemstore') { - return next( - new NotImplementedError('release-tracks-controller', 'retrieveLatestSnapshot', { - message: 'The filesystemstore format is not yet implemented', - }), - ); + const formatError = rejectFilesystemStoreFormat(queryOptions.format, 'retrieveLatestSnapshot'); + if (formatError) { + return next(formatError); } const result = await releaseTracksService.getLatestSnapshot(req.params.id, queryOptions); @@ -323,6 +347,13 @@ exports.deleteReleaseTrack = async function deleteReleaseTrack(req, res, next) { exports.retrieveSnapshotByModified = async function retrieveSnapshotByModified(req, res, next) { try { const queryOptions = parseSnapshotQueryParams(req.query); + const formatError = rejectFilesystemStoreFormat( + queryOptions.format, + 'retrieveSnapshotByModified', + ); + if (formatError) { + return next(formatError); + } const result = await releaseTracksService.getSnapshotByModified( req.params.id, @@ -686,7 +717,16 @@ exports.updateConfig = async function updateConfig(req, res, next) { /** GET /api/release-tracks/:id/bump/preview */ exports.previewBump = async function previewBump(req, res, next) { try { - const format = parseOptionalQuery(req.query.format, formatQuerySchema, 'workbench'); + const format = parseOptionalQueryStrict( + req.query.format, + formatQuerySchema, + 'workbench', + 'format', + ); + const formatError = rejectFilesystemStoreFormat(format, 'previewBump'); + if (formatError) { + return next(formatError); + } const result = await releaseTracksService.previewBump(req.params.id, format); logger.debug(`Success: Generated bump preview for track ${req.params.id}`); diff --git a/app/lib/release-tracks/release-track-schemas.js b/app/lib/release-tracks/release-track-schemas.js index 99e53857..c4c52f3d 100644 --- a/app/lib/release-tracks/release-track-schemas.js +++ b/app/lib/release-tracks/release-track-schemas.js @@ -150,9 +150,9 @@ const cronSchema = z const domainParamSchema = z.enum(['enterprise', 'ics', 'mobile']); -const formatQuerySchema = z.enum(['snapshot', 'bundle', 'filesystemstore', 'workbench']); +const formatQuerySchema = z.enum(['bundle', 'filesystemstore', 'workbench']); -const includeQuerySchema = z.enum(['staged', 'candidates', 'all']); +const includeQuerySchema = z.enum(['members', 'staged', 'candidates', 'quarantine', 'all']); const trackTypeQuerySchema = z.enum(['standard', 'virtual']); 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/export-service.js b/app/services/release-tracks/export-service.js index 72a2dec7..3547ce51 100644 --- a/app/services/release-tracks/export-service.js +++ b/app/services/release-tracks/export-service.js @@ -151,17 +151,17 @@ exports.formatAsFilesystemStore = function formatAsFilesystemStore(snapshot, hyd // ============================================================================= /** - * Export a snapshot in the specified format. + * Export a snapshot in the specified STIX-oriented format. * - * This is the primary entry point called by the facade when a `format` - * query parameter is provided on snapshot retrieval endpoints. + * Workbench snapshot retrieval is handled by release-tracks-service because it + * returns the release-track snapshot shape with UI-friendly tier entry details. * * @param {Object} snapshot - The raw snapshot document from the dynamic repo - * @param {string} format - One of: 'bundle', 'workbench', 'filesystemstore' + * @param {string} format - One of: 'bundle', 'filesystemstore' * @param {Object} [options] - Additional options - * @param {string} [options.include] - For workbench format: 'staged', 'candidates', or 'all' * @returns {Promise} The formatted export */ +// eslint-disable-next-line no-unused-vars exports.exportSnapshot = async function exportSnapshot(snapshot, format, options = {}) { const members = snapshot.members || []; @@ -170,26 +170,12 @@ exports.exportSnapshot = async function exportSnapshot(snapshot, format, options return exports.formatAsBundle(snapshot, hydratedMembers); } - if (format === 'workbench') { - // Workbench format optionally includes staged and/or candidate objects - const allRefs = [...members]; - if (options.include === 'staged' || options.include === 'all') { - allRefs.push(...(snapshot.staged || [])); - } - if (options.include === 'candidates' || options.include === 'all') { - allRefs.push(...(snapshot.candidates || [])); - } - - const hydratedAll = await exports.hydrateMembers(allRefs); - return exports.formatAsWorkbench(snapshot, hydratedAll); - } - if (format === 'filesystemstore') { const hydratedMembers = await exports.hydrateMembers(members); return exports.formatAsFilesystemStore(snapshot, hydratedMembers); } - // Unknown format — return raw snapshot unchanged - logger.warn(`ExportService: Unknown format "${format}", returning raw snapshot`); + // Unknown format -- return the snapshot unchanged. + logger.warn(`ExportService: Unknown format "${format}", returning snapshot unchanged`); return snapshot; }; diff --git a/app/services/release-tracks/release-tracks-service.js b/app/services/release-tracks/release-tracks-service.js index 51c9ff05..d8b9ab74 100644 --- a/app/services/release-tracks/release-tracks-service.js +++ b/app/services/release-tracks/release-tracks-service.js @@ -23,13 +23,139 @@ 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'; +const TIER_NAMES = ['members', 'staged', 'candidates', 'quarantine']; function notImplemented(methodName) { throw new NotImplementedError(MODULE, methodName); } +function rejectFilesystemStoreFormat(format, methodName) { + if (format !== 'filesystemstore') return; + + throw new NotImplementedError(MODULE, methodName, { + message: 'The filesystemstore format is not yet implemented', + }); +} + +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 tierEntries = TIER_NAMES.flatMap((tierName) => snapshot[tierName] || []); + + 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 }; + for (const tierName of TIER_NAMES) { + if (snapshot[tierName]) { + snapshotWithObjectInfo[tierName] = snapshot[tierName].map((entry) => + addObjectInfo(entry, objectsByVersion, usersById), + ); + } + } + + return snapshotWithObjectInfo; +} + +function filterSnapshotTiers(snapshot, include) { + if (!include || include === 'all') return snapshot; + + const includedTiers = new Set(['members', include]); + const filtered = { ...snapshot }; + + for (const tierName of TIER_NAMES) { + if (!includedTiers.has(tierName)) { + delete filtered[tierName]; + } + } + + return filtered; +} + +async function formatWorkbenchSnapshot(snapshot, options) { + const enriched = await addObjectInfoToSnapshot(snapshot); + return filterSnapshotTiers(enriched, options?.include); +} + // ----------------------------------------------------------------------------- // Track management (Phase 1 → snapshot-service) // ----------------------------------------------------------------------------- @@ -53,25 +179,29 @@ exports.importTrack = async function importTrack(_data) { }; // Phase 6: Format-aware snapshot retrieval -// - 'snapshot' format (or no format): returns raw snapshot as stored -// - 'bundle'/'workbench' formats: hydrates and transforms via export-service -// - 'filesystemstore': blocked at controller level (NotImplementedError) +// - 'workbench' format (or no format): returns enriched release-track snapshot +// - 'bundle' format: hydrates members and transforms via export-service +// - 'filesystemstore': blocked before delegation (NotImplementedError) exports.getLatestSnapshot = async function getLatestSnapshot(trackId, options) { const snapshot = await snapshotService.getLatestSnapshot(trackId, options); const format = options?.format; - if (format && format !== 'snapshot') { + rejectFilesystemStoreFormat(format, 'getLatestSnapshot'); + + if (format === 'bundle') { return exportService.exportSnapshot(snapshot, format, options); } - return snapshot; + return formatWorkbenchSnapshot(snapshot, options); }; exports.getSnapshotByModified = async function getSnapshotByModified(trackId, modified, options) { const snapshot = await snapshotService.getSnapshotByModified(trackId, modified, options); const format = options?.format; - if (format && format !== 'snapshot') { + rejectFilesystemStoreFormat(format, 'getSnapshotByModified'); + + if (format === 'bundle') { return exportService.exportSnapshot(snapshot, format, options); } - return snapshot; + return formatWorkbenchSnapshot(snapshot, options); }; exports.updateMetadata = function updateMetadata(trackId, updates, userId) { @@ -121,6 +251,7 @@ exports.deleteSnapshot = function deleteSnapshot(trackId, modified) { // ----------------------------------------------------------------------------- exports.getEphemeralBundle = function getEphemeralBundle(domain, format) { + rejectFilesystemStoreFormat(format, 'getEphemeralBundle'); return ephemeralService.getEphemeralBundle(domain, format); }; @@ -177,6 +308,7 @@ exports.bumpByModified = function bumpByModified(trackId, modified, options) { }; exports.previewBump = function previewBump(trackId, format) { + rejectFilesystemStoreFormat(format, 'previewBump'); return versioningService.previewBump(trackId, format); }; 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..7cced1a4 --- /dev/null +++ b/app/tests/api/release-tracks/release-tracks.spec.js @@ -0,0 +1,253 @@ +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 snapshotService = require('../../../services/release-tracks/snapshot-service'); + +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': '' } }, + ); + } + + function expectObjectInfo(entry, object) { + expect(entry).toMatchObject({ + attack_id: object.workspace.attack_id, + name: object.stix.name, + description: object.stix.description, + modified_by_user: { + username: 'anonymous', + displayName: 'Anonymous User', + name: 'Anonymous User', + }, + }); + } + + it('GET /api/release-tracks includes summaries and workbench object details', 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 quarantinedObject = await createTechnique( + 'Quarantined Technique', + 'Quarantined 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/); + + const promoteRes = 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 promotedSnapshot = await snapshotService.getLatestSnapshot(trackId); + await snapshotService.cloneSnapshot(trackId, promotedSnapshot, { + quarantine: [ + { + object_ref: quarantinedObject.stix.id, + object_modified: quarantinedObject.stix.modified, + source_track_id: trackId, + source_track_name: 'Enterprise Test', + conflict_reason: 'test conflict', + }, + ], + }); + + 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 member = latestRes.body.members.find( + (entry) => entry.object_ref === memberObject.stix.id, + ); + expectObjectInfo(member, memberObject); + + const candidate = latestRes.body.candidates.find( + (entry) => entry.object_ref === candidateObject.stix.id, + ); + expectObjectInfo(candidate, candidateObject); + + const staged = latestRes.body.staged.find((entry) => entry.object_ref === stagedObject.stix.id); + expectObjectInfo(staged, stagedObject); + + const quarantined = latestRes.body.quarantine.find( + (entry) => entry.object_ref === quarantinedObject.stix.id, + ); + expectObjectInfo(quarantined, quarantinedObject); + + const historicalRes = await request(app) + .get(`/api/release-tracks/${trackId}/snapshots/${promoteRes.body.modified}`) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(200) + .expect('Content-Type', /json/); + + const historicalMember = historicalRes.body.members.find( + (entry) => entry.object_ref === memberObject.stix.id, + ); + expectObjectInfo(historicalMember, memberObject); + + const historicalCandidate = historicalRes.body.candidates.find( + (entry) => entry.object_ref === candidateObject.stix.id, + ); + expectObjectInfo(historicalCandidate, candidateObject); + + const historicalStaged = historicalRes.body.staged.find( + (entry) => entry.object_ref === stagedObject.stix.id, + ); + expectObjectInfo(historicalStaged, stagedObject); + + await request(app) + .get(`/api/release-tracks/${trackId}?format=snapshot`) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(400); + + await request(app) + .get(`/api/release-tracks/${trackId}?format=filesystemstore`) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(501); + + await request(app) + .get( + `/api/release-tracks/${trackId}/snapshots/${promoteRes.body.modified}?format=filesystemstore`, + ) + .set('Accept', 'application/json') + .set('Cookie', `${passportCookie.name}=${passportCookie.value}`) + .expect(501); + }); + + 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..9e330d26 100644 --- a/docs/user/release-tracks/api-reference.md +++ b/docs/user/release-tracks/api-reference.md @@ -121,7 +121,7 @@ GET /api/release-tracks/ephemeral/:domain - `:domain` - `enterprise` | `ics` | `mobile` **Query Parameters:** -- `format` - `bundle` | `filesystemstore` | `workbench` (default: `bundle`) +- `format` - `bundle` | `filesystemstore` | `workbench` (default: `bundle`; `filesystemstore` is not yet implemented) --- @@ -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,12 +262,20 @@ Retrieves the most recent snapshot from the release track (by `modified` timesta GET /api/release-tracks/:id ``` +Workbench responses return the release-track snapshot shape. Entries in the `members`, +`staged`, `candidates`, and `quarantine` tiers include UI-friendly 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 | |-----------|--------|-------------| -| `format` | `bundle` \| `filesystemstore` \| `workbench` | Output format (default: `bundle`) | -| `include` | `staged` \| `candidates` \| `all` | Which tiers to include (default: members only) | +| `format` | `workbench` \| `bundle` \| `filesystemstore` | Output format (default: `workbench`; `filesystemstore` is not yet implemented) | +| `include` | `members` \| `staged` \| `candidates` \| `quarantine` \| `all` | Which tier arrays to include in `workbench` responses (default: all tiers) | | `releases` | `only` | Return only the latest tagged release instead of latest snapshot | | `version` | `X.Y` | Return specific version (e.g., `14.1`) | | `versions` | `all` | List all snapshots with metadata | @@ -265,11 +283,14 @@ GET /api/release-tracks/:id **Examples:** ```bash -# Get latest snapshot as STIX bundle (members only) +# Get latest snapshot for the Workbench UI GET /api/release-tracks/:id -# Get latest snapshot with all tiers in workbench format -GET /api/release-tracks/:id?include=all&format=workbench +# Get latest snapshot as STIX bundle (members only) +GET /api/release-tracks/:id?format=bundle + +# Get latest snapshot with members and quarantine only +GET /api/release-tracks/:id?include=quarantine # Get latest tagged release (not draft) GET /api/release-tracks/:id?releases=only @@ -380,16 +401,16 @@ GET /api/release-tracks/:id/snapshots/:modified - `:modified` - ISO 8601 timestamp (e.g., `2024-01-15T16:20:00.000Z`) **Query Parameters:** -- `format` - `bundle` | `filesystemstore` | `workbench` (default: `bundle`) -- `include` - `staged` | `candidates` | `all` (default: members only) +- `format` - `workbench` | `bundle` | `filesystemstore` (default: `workbench`; `filesystemstore` is not yet implemented) +- `include` - `members` | `staged` | `candidates` | `quarantine` | `all` (default: all tiers) **Example:** ```bash -# Get snapshot from January 15, 2024 as STIX bundle +# Get snapshot from January 15, 2024 for the Workbench UI GET /api/release-tracks/:id/snapshots/2024-01-15T16:20:00.000Z -# Get with all tiers in workbench format -GET /api/release-tracks/:id/snapshots/2024-01-15T16:20:00.000Z?include=all&format=workbench +# Get snapshot from January 15, 2024 as STIX bundle +GET /api/release-tracks/:id/snapshots/2024-01-15T16:20:00.000Z?format=bundle ``` ### Update Metadata (Specific Snapshot) @@ -644,7 +665,7 @@ GET /api/release-tracks/:id/bump/preview ``` **Query Parameters:** -- `format` - `bundle` | `filesystemstore` | `workbench` (default: `workbench`) +- `format` - `bundle` | `filesystemstore` | `workbench` (default: `workbench`; `filesystemstore` is not yet implemented) **Response Example:** ```json @@ -748,17 +769,20 @@ GET /api/release-tracks/:id/objects/:objectRef/versions ## Output Formats -### `bundle` (Default) +### `workbench` (Default) -Standard STIX 2.1 bundle. +Release-track snapshot shape optimized for the Workbench frontend. Tier entries +include UI-friendly object details such as `attack_id`, `name`, `description`, +and `modified_by_user`. -### `filesystemstore` +### `bundle` -STIX FileSystemStore directory structure. +Standard STIX 2.1 bundle. -### `workbench` +### `filesystemstore` (Not Implemented) -Custom format with workflow metadata for UI. +Planned STIX FileSystemStore directory structure. Requests with +`format=filesystemstore` currently return HTTP 501. --- @@ -934,25 +958,30 @@ GET /api/release-tracks/:id/snapshots/preview ### Snapshot Retrieval Endpoints -The following retrieval endpoints support `include` and `format` query parameters: +The following release-track snapshot retrieval endpoints support `include` and +`format` query parameters: - `GET /api/release-tracks/:id` (get latest snapshot) - `GET /api/release-tracks/:id/snapshots/:modified` (get specific snapshot) -- `GET /api/release-tracks/ephemeral/:domain` (get ephemeral bundle) + +The ephemeral bundle endpoint supports `format`, but not tier `include`, because +it does not read from a persisted release-track snapshot. **Include Parameter** (controls which tiers are returned): ``` -GET /api/release-tracks/:id # Default: members only +GET /api/release-tracks/:id # Default: all tiers +GET /api/release-tracks/:id?include=members # Members tier only GET /api/release-tracks/:id?include=staged # Members and staged tiers GET /api/release-tracks/:id?include=candidates # Members and candidates tiers -GET /api/release-tracks/:id?include=all # All tiers (members, staged, candidates) +GET /api/release-tracks/:id?include=quarantine # Members and quarantine tiers +GET /api/release-tracks/:id?include=all # All tiers ``` **Format Parameter** (controls output format): ``` -GET /api/release-tracks/:id?format=bundle # Standard STIX 2.1 bundle (default) -GET /api/release-tracks/:id?format=filesystemstore # STIX FileSystemStore structure -GET /api/release-tracks/:id?format=workbench # Workbench format with metadata +GET /api/release-tracks/:id?format=workbench # Workbench snapshot with metadata (default) +GET /api/release-tracks/:id?format=bundle # Standard STIX 2.1 bundle +GET /api/release-tracks/:id?format=filesystemstore # Not implemented; returns 501 ``` **Combined Example:** @@ -967,4 +996,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. diff --git a/docs/user/release-tracks/output-formats.md b/docs/user/release-tracks/output-formats.md index 71fb2a38..8a9f9a5a 100644 --- a/docs/user/release-tracks/output-formats.md +++ b/docs/user/release-tracks/output-formats.md @@ -6,7 +6,56 @@ Release tracks (or rather, each snapshot) can serialize/export to multiple forma GET /api/release-tracks/:id?format= ``` -### Format: `bundle` (Default) +### Format: `workbench` (Default) + +Workbench-optimized release-track snapshot format. This is the default response +shape for snapshot retrieval endpoints and is intended for the Workbench frontend. + +```json +{ + "id": "release-track--123", + "type": "standard", + "version": null, + "name": "ATT&CK Enterprise", + "modified": "2024-01-15T16:20:00Z", + "members": [ + { + "object_ref": "attack-pattern--aaa", + "object_modified": "2024-01-10T10:00:00Z", + "attack_id": "T1234", + "name": "Technique A", + "description": "Technique description", + "modified_by_user": { + "id": "identity--...", + "username": "alice", + "displayName": "Alice Example", + "name": "Alice Example" + } + } + ], + "staged": [], + "candidates": [], + "quarantine": [] +} +``` + +**Characteristics:** +- Preserves the release-track snapshot structure +- Includes `members`, `staged`, `candidates`, and `quarantine` tier arrays when present +- Adds UI-friendly object details to tier entries +- Suitable for Workbench UI rendering and release-track management workflows + +Use `include` to narrow tier arrays in `workbench` responses: + +```bash +GET /api/release-tracks/:id?include=members +GET /api/release-tracks/:id?include=staged +GET /api/release-tracks/:id?include=candidates +GET /api/release-tracks/:id?include=quarantine +GET /api/release-tracks/:id?include=all +``` + +### Format: `bundle` Standard STIX 2.1 bundle format: @@ -38,7 +87,10 @@ Standard STIX 2.1 bundle format: - No workflow states, no workspace data - Suitable for external publication -### Format: `filesystemstore` +### Format: `filesystemstore` (Not Implemented) + +STIX FileSystemStore export is planned, but is not implemented yet. Requests +with `format=filesystemstore` currently return HTTP 501. STIX FileSystemStore structure (directory tree): @@ -53,7 +105,7 @@ collection-123/ malware--xxx.json ``` -**Response:** +**Example Response:** ```json { "format": "filesystemstore", @@ -76,63 +128,20 @@ collection-123/ > **NOTE**: The `filesystemstore` is still a *concept* that will need additional refinement before it can be implemented. We will need to figure out an optimal way to return JSON files to the user. Optionally, we can attempt to generate an archive and serialize it over the wire, though this may be slow and error prone. Additionally, we can allow users to specify an output path via S3, FTP, etc. -### Format: `workbench` (Custom) - -Workbench-optimized format with full metadata: - -```json -{ - "collection": { - "id": "x-mitre-collection--123", - "version": "1.1", - "name": "ATT&CK Enterprise", - "modified": "2024-01-15T16:20:00Z" - }, - "objects": [ - { - "stix": { /* Full STIX object */ }, - "workspace": { - "workflow": { - "status": "reviewed", - "reviewed_by": "admin@example.com", - "reviewed_at": "2024-01-14T10:00:00Z" - } - }, - "metadata": { - "collection_tier": "released", // "released" | "staged" | "candidate" - "object_type": "attack-pattern", - "object_name": "Technique A" - } - } - ], - "summary": { - "released_count": 2, - "staged_count": 1, - "candidate_count": 1 - } -} -``` - -**Characteristics:** -- Includes workflow states -- Includes workspace metadata -- Optimized for Workbench UI consumption -- Shows which tier each object belongs to - -> **NOTE**: The response `workbench` object above is just an example. This is not a prescriptive, final draft. The concept is desribed here to illustrate that we can serve information to the frontend in formats more suitable for UI rendering; we are not beholden to exclusively serving content in STIX-compatible formats. ### Format Usage ```bash +# Workbench UI response +GET /api/release-tracks/:id +GET /api/release-tracks/:id?format=workbench + # Standard STIX bundle for publication GET /api/release-tracks/:id?format=bundle -# FileSystemStore export -GET /api/release-tracks/:id?format=filesystemstore - -# Workbench UI with workflow metadata -GET /api/release-tracks/:id?format=workbench +# FileSystemStore export is not implemented yet +GET /api/release-tracks/:id?format=filesystemstore # Returns HTTP 501 # Dry run with detailed preview GET /api/release-tracks/:id/bump/preview?format=workbench -``` \ No newline at end of file +``` diff --git a/docs/user/release-tracks/release-workflow.md b/docs/user/release-tracks/release-workflow.md index c39faa04..7f8783bb 100644 --- a/docs/user/release-tracks/release-workflow.md +++ b/docs/user/release-tracks/release-workflow.md @@ -452,7 +452,9 @@ PUT /api/release-tracks/:id/config ### 5. Viewing Latest Snapshot with All Tiers -Set the `include` query parameter to `members`, `staged`, `candidates` or `all` to view different subsets of a given snapshot. +Workbench snapshot responses include all tier arrays by default. Set the +`include` query parameter to `members`, `staged`, `candidates`, `quarantine`, +or `all` to view a narrower subset of a given snapshot. ``` GET /api/release-tracks/:id?include=all ``` @@ -1107,4 +1109,3 @@ July 16 (manual): - All snapshots start as **drafts** (must explicitly tag) - Component tracks must have at least one tagged release - Circular dependencies not allowed - diff --git a/docs/user/release-tracks/summary.md b/docs/user/release-tracks/summary.md index dd2c4717..7f27c60e 100644 --- a/docs/user/release-tracks/summary.md +++ b/docs/user/release-tracks/summary.md @@ -145,17 +145,18 @@ workspace.config.candidacy_threshold = "work-in-progress" // Very permissive ### Multiple Output Formats +- **workbench** - Release-track snapshot with UI-friendly metadata (default for UI) - **bundle** - Standard STIX 2.1 bundle (for publication) -- **filesystemstore** - STIX FileSystemStore directory structure -- **workbench** - Custom format with workflow metadata (for UI) +- **filesystemstore** - Planned STIX FileSystemStore directory structure; not implemented yet and returns HTTP 501 ### Dry Run + Preview "Preview" will provide a verbose/detailed diff of what will change in the next release ``` GET /api/release-tracks/:id/bump/preview - ?format = bundle | filesystemstore | workbench + ?format = bundle | workbench ``` +`format=filesystemstore` is reserved for future FileSystemStore export support and currently returns HTTP 501. "Dry-run" will output the literal/exact contents of the would-be tagged release ``` @@ -230,4 +231,4 @@ Tag Again │ (TAGGED RELEASE) │ │ │ └──────────────────────────────┘ -``` \ No newline at end of file +``` diff --git a/docs/user/release-tracks/virtual-tracks.md b/docs/user/release-tracks/virtual-tracks.md index e00bb67f..023fac9b 100644 --- a/docs/user/release-tracks/virtual-tracks.md +++ b/docs/user/release-tracks/virtual-tracks.md @@ -858,7 +858,7 @@ GET /api/release-tracks/:id?format=workbench&include=all ``` **Query params:** -- `format`: `bundle` | `workbench` | `filesystemstore` +- `format`: `bundle` | `workbench` | `filesystemstore` (`filesystemstore` is not yet implemented and returns HTTP 501) - `include`: `members` | `quarantine` | `all` - `resolve`: `true` (default) | `false` - Whether to resolve composition