Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions app/api/definitions/components/release-tracks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions app/repository/release-tracks/release-track-dynamic.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
104 changes: 103 additions & 1 deletion app/services/release-tracks/release-tracks-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,115 @@ 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';

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)
// -----------------------------------------------------------------------------
Expand Down Expand Up @@ -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) {
Expand Down
24 changes: 23 additions & 1 deletion app/services/release-tracks/snapshot-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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,
};
};

/**
Expand Down
Loading
Loading