From 7aca0df9fc86f0c057cbf19b54157e841e006fe1 Mon Sep 17 00:00:00 2001 From: Sean Sica <23294618+seansica@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:25:32 -0400 Subject: [PATCH] feat(release-tracks): preserve drafts for safe release rollback and retagging Protect virtual dependencies, publish retag hashes atomically, recover interrupted updates, and validate destructive confirmation under the release lock. --- .../definitions/components/release-tracks.yml | 12 + .../paths/release-tracks-paths.yml | 119 ++++- app/controllers/release-tracks-controller.js | 28 ++ .../release-tracks/release-track-schemas.js | 4 + .../release-track-audit-event-model.js | 2 +- .../release-track-snapshot-schema.js | 21 + .../release-track-dynamic.repository.js | 118 ++++- app/routes/release-tracks-routes.js | 5 + .../release-tracks/release-tracks-service.js | 78 +++- .../release-tracks/snapshot-service.js | 71 +++ .../release-tracks/versioning-service.js | 102 ++++- .../release-tracks/virtual-track-service.js | 84 ++-- .../release-tracks/content-manifests.spec.js | 7 +- .../destructive-authorization.spec.js | 426 +++++++++++++++++- .../deterministic-graph-migration.spec.js | 23 +- .../release-tracks-release.spec.js | 6 +- .../snapshot-descriptions.spec.js | 5 +- docs/admin/release-track-audit.md | 10 +- docs/developer/TODO.md | 39 ++ .../developer/release-tracks/authorization.md | 13 +- docs/developer/release-tracks/entities.md | 16 +- .../release-tracks/implementation-notes.md | 16 +- .../release-tracks/releases-by-object.md | 22 +- .../sealed-content-manifests.md | 33 +- docs/user/release-tracks/api-reference.md | 59 ++- docs/user/release-tracks/release-workflow.md | 2 + docs/user/release-tracks/summary.md | 9 +- docs/user/release-tracks/terminology.md | 16 +- docs/user/release-tracks/versioning.md | 79 ++-- docs/user/release-tracks/workflow-examples.md | 25 +- 30 files changed, 1254 insertions(+), 196 deletions(-) diff --git a/app/api/definitions/components/release-tracks.yml b/app/api/definitions/components/release-tracks.yml index ceb2bc22..642d92d6 100644 --- a/app/api/definitions/components/release-tracks.yml +++ b/app/api/definitions/components/release-tracks.yml @@ -27,6 +27,13 @@ components: nullable: true description: 'Semantic version (e.g., "1.0", "2.1") if tagged, null for draft snapshots' example: '1.0' + release_source_modified: + type: string + format: date-time + readOnly: true + description: | + Standard releases only: the exact preserved draft snapshot from + which this release snapshot was created. content_manifest_id: type: string readOnly: true @@ -206,6 +213,11 @@ components: type: string nullable: true description: 'Tagged version, or null for an untagged draft' + release_source_modified: + type: string + format: date-time + readOnly: true + description: 'Standard release source draft timestamp' content_manifest_id: type: string readOnly: true diff --git a/app/api/definitions/paths/release-tracks-paths.yml b/app/api/definitions/paths/release-tracks-paths.yml index 6c127661..99d9011a 100644 --- a/app/api/definitions/paths/release-tracks-paths.yml +++ b/app/api/definitions/paths/release-tracks-paths.yml @@ -389,18 +389,19 @@ paths: summary: 'Release the latest snapshot' operationId: 'release-tracks-release-latest' description: | - Immutably tag the latest snapshot with a version. Standard tracks - promote staged entries to members. Any staged `object_modified: + Publish the latest snapshot with a version. Standard tracks retain the + exact draft and create a new tagged snapshot; virtual tracks tag the + materialized draft in place. Standard tracks promote staged entries + to members. Any staged `object_modified: "latest"` selector is resolved to the object's actual latest `stix.modified` timestamp during release planning; tagged members always contain exact revision timestamps. Supply either `increment` (`major` or `minor`) or an explicit `version` in `MAJOR.MINOR` form, but never both. Omitting both defaults to a minor increment. An optional `description` is stored as snapshot-local release notes. - Relative increments use the nearest earlier tagged snapshot. The - selected version must be strictly between the nearest earlier and - later tagged snapshots; the later bound is relevant to retroactive - releases. + Relative increments use the latest tagged release. A historical + standard draft is published as a new release at the current time and + must follow the current version lineage. tags: - 'Release Tracks' parameters: @@ -878,6 +879,9 @@ paths: optional strict scheduled_materialization object to the resulting virtual draft. `description` becomes the new snapshot's local notes; it does not replace the release track description. + Component release locks are held from resolution through persistence. + A concurrent release, rollback, retag, or materialization sharing a + component may return 409; retry after the competing operation finishes. tags: - 'Release Tracks' parameters: @@ -905,11 +909,19 @@ paths: '400': description: 'Track is not virtual or cannot resolve its composition' '409': - description: 'A resolved component snapshot references missing primary revisions' + description: 'A component release lock is busy or a resolved snapshot references missing primary revisions' content: application/json: schema: - $ref: '../components/release-tracks.yml#/components/schemas/object-revision-error' + anyOf: + - $ref: '../components/release-tracks.yml#/components/schemas/object-revision-error' + - type: object + required: [message, track_id] + properties: + message: + type: string + track_id: + type: string /api/release-tracks/{id}/virtual/quarantine/promote: post: @@ -966,6 +978,8 @@ paths: content_manifest_id together with content_statistics counts for primary, relationship, supporting, and LinkById entries. Tagged summaries also expose bundle_id and bundle_hashes. + Standard releases with a preserved source draft also expose + release_source_modified, allowing clients to hide that retained draft. tags: - 'Release Tracks' parameters: @@ -1175,13 +1189,16 @@ paths: (editor or higher); the track reverts to its immediately preceding snapshot. Historical drafts have already been pruned. - An administrator may also delete the track's most recent release by - supplying `confirm_version` equal to that snapshot's version. The - release's ledger entry is retracted from every remaining snapshot, its - content manifest is discarded when nothing else references it, the - registry catalogue is reconciled, and a `delete_release` audit event - is recorded. A release that is followed by a later release cannot be - deleted until the later one is removed. + An administrator may also roll back the track's most recent standard + release by supplying `confirm_version` equal to that snapshot's + version. The tagged clone is deleted and its exact preserved source + draft becomes available again. The ledger and registry catalogue are + reconciled and a `delete_release` audit event is recorded. Rollback is + blocked if any persisted virtual snapshot resolved the exact release, + if it is followed by a later release, or if it predates preserved + source drafts. Virtual releases retain the existing irreversible + newest-release deletion behavior because virtual tagging remains + in-place. tags: - 'Release Tracks' parameters: @@ -1210,7 +1227,7 @@ paths: '403': description: 'Deleting a release requires an administrator' '409': - description: 'The release is not the most recent one, or the draft is not the latest snapshot' + description: 'The release cannot be rolled back, a virtual snapshot depends on it, or the draft is not latest' '404': description: 'Snapshot not found' @@ -1349,17 +1366,17 @@ paths: summary: 'Release a specific snapshot' operationId: 'release-tracks-release-by-modified' description: | - Immutably tag the snapshot selected by the modified timestamp using - the same version-selection contract as the latest release operation: + Publish the snapshot selected by the modified timestamp using the same + version-selection contract as the latest release operation: supply `increment` or `version`, never both; omit both for a minor increment. Virtual drafts must have composition_resolution from a successful materialization. For standard tracks, dynamic staged references are resolved to exact object revisions when this release - request is handled, including when the selected snapshot is historical. - An optional `description` is stored as snapshot-local release notes. - Relative increments use the nearest earlier tagged snapshot, and the - selected version must be strictly below the nearest later tagged - snapshot when one exists. + request is handled. Standard tracks retain the selected source draft + and create a tagged clone at the current time, including when the + selected draft is historical. An optional `description` is stored as + snapshot-local release notes. Relative increments use the latest + tagged release. tags: - 'Release Tracks' parameters: @@ -1398,6 +1415,62 @@ paths: application/json: schema: $ref: '../components/release-tracks.yml#/components/schemas/release-track-reconciliation-error' + put: + summary: 'Change a release version' + operationId: 'release-tracks-release-retag' + description: | + Administratively replace the semantic version assigned to a tagged + snapshot without changing its identity or contents. The new version + must remain strictly between the chronologically adjacent releases. + Stored bundle hashes and the release catalogue are regenerated. + The STIX 2.1 digest changes; STIX 2.0 omits the collection object and + its digest is unchanged by a version-only correction. Hashes are + prepared before writing and published atomically with the version. + Retry the same version after a failure to finish history/catalogue + reconciliation; a same-version request repairs derived state. + Existing virtual snapshot provenance remains an immutable record of + the version that was resolved at materialization time. + tags: + - 'Release Tracks' + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: modified + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - version + additionalProperties: false + properties: + version: + type: string + pattern: '^\d+\.\d+$' + responses: + '200': + description: 'Release version changed successfully' + content: + application/json: + schema: + $ref: '../components/release-tracks.yml#/components/schemas/release-track-snapshot' + '400': + description: 'Invalid version body or release-lineage violation' + '403': + description: 'Changing a release version requires an administrator' + '404': + description: 'Snapshot not found' + '409': + description: 'Snapshot is a draft or another release operation is in progress' /api/release-tracks/{id}/snapshots/{modified}/release/preview: get: diff --git a/app/controllers/release-tracks-controller.js b/app/controllers/release-tracks-controller.js index 62842693..bbdfcb4b 100644 --- a/app/controllers/release-tracks-controller.js +++ b/app/controllers/release-tracks-controller.js @@ -41,6 +41,7 @@ const { updateMetadataBodySchema, updateSnapshotDescriptionBodySchema, releaseBodySchema, + retagReleaseBodySchema, releaseVersionSelectionSchema, cloneBodySchema, addCandidatesBodySchema, @@ -630,6 +631,33 @@ exports.releaseByModified = async function releaseByModified(req, res, next) { } }; +/** PUT /api/release-tracks/:id/snapshots/:modified/release */ +exports.retagRelease = async function retagRelease(req, res, next) { + try { + const bodyResult = retagReleaseBodySchema.safeParse(req.body || {}); + if (!bodyResult.success) { + return next( + new BadRequestError({ + message: 'Invalid release version update', + details: bodyResult.error.errors, + }), + ); + } + + const result = await releaseTracksService.retagRelease( + req.params.id, + req.params.modified, + bodyResult.data.version, + destructiveActor(req), + ); + logger.debug(`Success: Changed release version for snapshot ${req.params.modified}`); + return res.status(200).send(result); + } catch (err) { + logger.error('Failed to change release version: ' + err); + return next(err); + } +}; + /** POST /api/release-tracks/:id/snapshots/:modified/clone */ exports.cloneByModified = async function cloneByModified(req, res, next) { try { diff --git a/app/lib/release-tracks/release-track-schemas.js b/app/lib/release-tracks/release-track-schemas.js index ef897365..bd777abf 100644 --- a/app/lib/release-tracks/release-track-schemas.js +++ b/app/lib/release-tracks/release-track-schemas.js @@ -495,6 +495,9 @@ const releaseBodySchema = z message: 'increment and version are mutually exclusive', }); +/** PUT /release-tracks/:id/snapshots/:modified/release */ +const retagReleaseBodySchema = z.object({ version: xMitreVersionSchema }).strict(); + /** POST /release-tracks/:id/clone */ const cloneBodySchema = z .object({ @@ -676,6 +679,7 @@ module.exports = { updateMetadataBodySchema, updateSnapshotDescriptionBodySchema, releaseBodySchema, + retagReleaseBodySchema, publicationConfigSchema, cloneBodySchema, addCandidatesBodySchema, diff --git a/app/models/release-tracks/release-track-audit-event-model.js b/app/models/release-tracks/release-track-audit-event-model.js index 85b1fea0..48853cea 100644 --- a/app/models/release-tracks/release-track-audit-event-model.js +++ b/app/models/release-tracks/release-track-audit-event-model.js @@ -9,7 +9,7 @@ const releaseTrackAuditEventSchema = new mongoose.Schema( action: { type: String, required: true, - enum: ['delete_track', 'delete_release'], + enum: ['delete_track', 'delete_release', 'retag_release'], }, track_id: { type: String, required: true, validate: validateTrackId }, status: { diff --git a/app/models/release-tracks/release-track-snapshot-schema.js b/app/models/release-tracks/release-track-snapshot-schema.js index 5f3acebd..1fa7f312 100644 --- a/app/models/release-tracks/release-track-snapshot-schema.js +++ b/app/models/release-tracks/release-track-snapshot-schema.js @@ -415,6 +415,10 @@ const releaseTrackSnapshotDefinition = { default: null, validate: validateVersion, }, + // Standard releases are new snapshots. This pointer keeps the exact draft + // that was released reachable so deleting the release rolls back to that + // preserved state instead of attempting to reconstruct it. + release_source_modified: { type: Date, default: undefined }, // Every snapshot references the sealed content manifest that describes its // exact member graph. Member-changing writes seal a new manifest; other // clones inherit their predecessor's manifest by reference. @@ -488,6 +492,23 @@ releaseTrackSnapshotSchema.index( }, ); +releaseTrackSnapshotSchema.index( + { id: 1, release_source_modified: 1 }, + { + name: 'unique_standard_release_source', + unique: true, + partialFilterExpression: { + version: { $type: 'string' }, + release_source_modified: { $type: 'date' }, + }, + }, +); + +releaseTrackSnapshotSchema.index({ + 'composition_resolution.component_snapshots.track_id': 1, + 'composition_resolution.component_snapshots.resolved_snapshot_id': 1, +}); + // A scheduled occurrence may materialize at most one snapshot, including // after restart recovery or duplicate delivery by multiple scheduler nodes. releaseTrackSnapshotSchema.index( diff --git a/app/repository/release-tracks/release-track-dynamic.repository.js b/app/repository/release-tracks/release-track-dynamic.repository.js index 7d750d19..8a209433 100644 --- a/app/repository/release-tracks/release-track-dynamic.repository.js +++ b/app/repository/release-tracks/release-track-dynamic.repository.js @@ -126,6 +126,21 @@ class ReleaseTrackDynamicRepository { } } + async getReleaseBySourceModified(trackId, sourceModified) { + try { + const Model = this._getModel(trackId); + return await Model.findOne({ + id: trackId, + version: { $type: 'string' }, + release_source_modified: sourceModified, + }) + .lean() + .exec(); + } catch (err) { + throw new DatabaseError(err); + } + } + async getSnapshotByScheduledMaterialization(trackId, scheduledFor) { try { const Model = this._getModel(trackId); @@ -291,6 +306,7 @@ class ReleaseTrackDynamicRepository { content_manifest_id: 1, bundle_id: 1, bundle_hashes: 1, + release_source_modified: 1, snapshot_description: 1, name: 1, description: 1, @@ -329,7 +345,9 @@ class ReleaseTrackDynamicRepository { throw new DuplicateReleaseVersionError(trackId, snapshotData.version, { cause: err }); } throw new DuplicateIdError({ - details: `Snapshot with modified '${snapshotData.modified}' already exists for track '${trackId}'.`, + details: + `Snapshot uniqueness conflict for track '${trackId}' at modified ` + + `'${new Date(snapshotData.modified).toISOString()}': ${JSON.stringify(err.keyValue)}`, cause: err, }); } @@ -379,6 +397,70 @@ class ReleaseTrackDynamicRepository { } } + async retagSnapshotInPlace(trackId, modified, currentVersion, nextVersion, artifacts) { + try { + const Model = this._getModel(trackId); + return await Model.findOneAndUpdate( + { + id: trackId, + modified, + version: currentVersion, + content_manifest_id: artifacts.bundle_hashes.manifest_id, + }, + { + $set: { + version: nextVersion, + 'version_history.$[entry].version': nextVersion, + ...artifacts, + }, + }, + { + arrayFilters: [ + { + 'entry.version': currentVersion, + 'entry.snapshot_id': new Date(modified), + }, + ], + new: true, + runValidators: true, + lean: true, + }, + ).exec(); + } catch (err) { + if (err.name === 'MongoServerError' && err.code === 11000) { + throw new DuplicateReleaseVersionError(trackId, nextVersion, { cause: err }); + } + throw new DatabaseError(err); + } + } + + async replaceVersionHistoryVersion(trackId, snapshotModified, nextVersion) { + try { + const Model = this._getModel(trackId); + const result = await Model.updateMany( + { + id: trackId, + modified: { $ne: snapshotModified }, + version_history: { + $elemMatch: { snapshot_id: snapshotModified }, + }, + }, + { $set: { 'version_history.$[entry].version': nextVersion } }, + { + arrayFilters: [ + { + 'entry.snapshot_id': new Date(snapshotModified), + }, + ], + runValidators: true, + }, + ).exec(); + return result.modifiedCount; + } catch (err) { + throw new DatabaseError(err); + } + } + async updateSnapshot(trackId, modified, updateOps) { try { const Model = this._getModel(trackId); @@ -478,7 +560,16 @@ class ReleaseTrackDynamicRepository { async deleteOlderDrafts(trackId, modified) { try { const Model = this._getModel(trackId); - const query = { id: trackId, version: null, modified: { $lt: modified } }; + const retainedDrafts = await Model.distinct('release_source_modified', { + id: trackId, + version: { $type: 'string' }, + release_source_modified: { $type: 'date' }, + }).exec(); + const query = { + id: trackId, + version: null, + modified: { $lt: modified, ...(retainedDrafts.length ? { $nin: retainedDrafts } : {}) }, + }; const snapshots = await Model.find(query) .select('modified content_manifest_id') .lean() @@ -501,6 +592,29 @@ class ReleaseTrackDynamicRepository { } } + async findSnapshotsResolvingComponent(trackId, componentTrackId, componentSnapshotModified) { + try { + const Model = this._getModel(trackId); + return await Model.find( + { + id: trackId, + 'composition_resolution.component_snapshots': { + $elemMatch: { + track_id: componentTrackId, + resolved_snapshot_id: new Date(componentSnapshotModified), + }, + }, + }, + { id: 1, name: 1, modified: 1, version: 1 }, + ) + .sort({ modified: 1 }) + .lean() + .exec(); + } catch (err) { + throw new DatabaseError(err); + } + } + async deleteAllSnapshots(trackId) { try { const Model = this._getModel(trackId); diff --git a/app/routes/release-tracks-routes.js b/app/routes/release-tracks-routes.js index 832bcd41..4e9ecd76 100644 --- a/app/routes/release-tracks-routes.js +++ b/app/routes/release-tracks-routes.js @@ -266,6 +266,11 @@ router authn.authenticate, authz.requireRole(authz.editorOrHigher), releaseTracksController.releaseByModified, + ) + .put( + authn.authenticate, + authz.requireRole(authz.editorOrHigher), + releaseTracksController.retagRelease, ); router diff --git a/app/services/release-tracks/release-tracks-service.js b/app/services/release-tracks/release-tracks-service.js index 6b8e531d..3af08de2 100644 --- a/app/services/release-tracks/release-tracks-service.js +++ b/app/services/release-tracks/release-tracks-service.js @@ -377,31 +377,34 @@ exports.deleteSnapshot = async function deleteSnapshot(trackId, modified, option return snapshotService.deleteSnapshot(trackId, modified); } - if (options.actor?.role !== authz.userRoles.admin) { - throw new InsufficientRoleError('administrator', { - details: 'Deleting a release requires an administrator.', - track_id: trackId, - version: snapshot.version, - }); - } - if (options.confirmation !== snapshot.version) { - throw new BadRequestError({ - message: 'Destructive release confirmation is required', - details: `Set confirm_version to the exact release version '${snapshot.version}'.`, - parameter_name: 'confirm_version', - expected_version: snapshot.version, - }); - } + return versioningService.withReleaseLock(trackId, async () => { + const snapshot = await snapshotService.getSnapshotByModified(trackId, modified); + if (options.actor?.role !== authz.userRoles.admin) { + throw new InsufficientRoleError('administrator', { + details: 'Deleting a release requires an administrator.', + track_id: trackId, + version: snapshot.version, + }); + } + if (options.confirmation !== snapshot.version) { + throw new BadRequestError({ + message: 'Destructive release confirmation is required', + details: `Set confirm_version to the exact release version '${snapshot.version}'.`, + parameter_name: 'confirm_version', + expected_version: snapshot.version, + }); + } - return destructiveAuditService.execute( - { - action: 'delete_release', - trackId, - ...destructiveIdentity(trackId, options.actor, options.confirmation), - request: { snapshot_modified: new Date(snapshot.modified).toISOString() }, - }, - () => snapshotService.deleteRelease(trackId, modified), - ); + return destructiveAuditService.execute( + { + action: 'delete_release', + trackId, + ...destructiveIdentity(trackId, options.actor, options.confirmation), + request: { snapshot_modified: new Date(snapshot.modified).toISOString() }, + }, + () => snapshotService.deleteRelease(trackId, modified), + ); + }); }; exports.reconstructSnapshotManifest = function reconstructSnapshotManifest( @@ -473,6 +476,33 @@ exports.releaseByModified = function releaseByModified(trackId, modified, option return versioningService.releaseByModified(trackId, modified, options); }; +exports.retagRelease = async function retagRelease(trackId, modified, nextVersion, actor) { + return versioningService.withReleaseLock(trackId, async () => { + const snapshot = await snapshotService.getSnapshotByModified(trackId, modified); + if (actor?.role !== authz.userRoles.admin) { + throw new InsufficientRoleError('administrator', { + details: 'Changing a release version requires an administrator.', + track_id: trackId, + version: snapshot.version, + }); + } + + return destructiveAuditService.execute( + { + action: 'retag_release', + trackId, + ...destructiveIdentity(trackId, actor, snapshot.version), + request: { + snapshot_modified: new Date(snapshot.modified).toISOString(), + previous_version: snapshot.version, + next_version: nextVersion, + }, + }, + () => versioningService.retagReleaseLocked(trackId, modified, nextVersion), + ); + }); +}; + async function renderReleasePlan(plan, options) { const format = options.format || 'summary'; rejectFilesystemStoreFormat(format, 'previewRelease'); diff --git a/app/services/release-tracks/snapshot-service.js b/app/services/release-tracks/snapshot-service.js index 102abdda..f6f8ddf4 100644 --- a/app/services/release-tracks/snapshot-service.js +++ b/app/services/release-tracks/snapshot-service.js @@ -62,6 +62,21 @@ function normalizeTierSummary(summary) { }; } +async function mapWithConcurrency(items, concurrency, mapper) { + const results = new Array(items.length); + let nextIndex = 0; + + async function worker() { + while (nextIndex < items.length) { + const index = nextIndex++; + results[index] = await mapper(items[index], index); + } + } + + await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker())); + return results; +} + /** * Recompute and persist denormalized registry counters from actual snapshot data. * @@ -113,6 +128,24 @@ async function emitContentsChanged(trackId, snapshot) { } exports.emitContentsChanged = emitContentsChanged; +async function findVirtualSnapshotDependents(componentTrackId, componentSnapshotModified) { + const virtualTracks = (await registryRepo.findAll({ type: 'virtual' })).data; + const matches = await mapWithConcurrency(virtualTracks, 12, async (track) => + dynamicRepo.findSnapshotsResolvingComponent( + track.track_id, + componentTrackId, + componentSnapshotModified, + ), + ); + return matches.flat().map((snapshot) => ({ + track_id: snapshot.id, + track_name: snapshot.name, + snapshot_modified: snapshot.modified, + version: snapshot.version ?? null, + })); +} +exports.findVirtualSnapshotDependents = findVirtualSnapshotDependents; + /** * Seal a manifest for a snapshot that is about to be saved, then persist the * snapshot referencing it. The manifest is discarded if the save fails, so a @@ -289,6 +322,7 @@ exports.listSnapshots = async function listSnapshots(trackId, options) { content_manifest_id: snapshot.content_manifest_id, bundle_id: snapshot.bundle_id, bundle_hashes: snapshot.bundle_hashes, + release_source_modified: snapshot.release_source_modified, snapshot_description: snapshot.snapshot_description, content_statistics: snapshot.content_manifest_id ? statisticsByManifestId.get(snapshot.content_manifest_id) @@ -602,6 +636,18 @@ exports.updateSnapshotDescription = async function updateSnapshotDescription( version: snapshot.version, }); } + const sourceRelease = await dynamicRepo.getReleaseBySourceModified(trackId, snapshot.modified); + if (sourceRelease) { + throw new ReleaseConflictError( + 'Snapshot notes cannot change while the draft is retained as a release rollback point.', + { + track_id: trackId, + snapshot_modified: new Date(snapshot.modified).toISOString(), + release_version: sourceRelease.version, + release_snapshot_modified: new Date(sourceRelease.modified).toISOString(), + }, + ); + } const update = description ? { $set: { snapshot_description: description } } @@ -831,6 +877,31 @@ exports.deleteRelease = async function deleteRelease(trackId, modified) { ); } + if (snapshot.type === 'standard') { + if (!snapshot.release_source_modified) { + throw new ReleaseConflictError( + 'This release predates preserved source drafts and cannot be rolled back.', + { + track_id: trackId, + snapshot_modified: new Date(snapshot.modified).toISOString(), + version: snapshot.version, + }, + ); + } + const dependents = await findVirtualSnapshotDependents(trackId, snapshot.modified); + if (dependents.length > 0) { + throw new ReleaseConflictError( + 'This release cannot be deleted because virtual track snapshots depend on it.', + { + track_id: trackId, + snapshot_modified: new Date(snapshot.modified).toISOString(), + version: snapshot.version, + dependent_snapshots: dependents, + }, + ); + } + } + await dynamicRepo.deleteSnapshot(trackId, snapshot.modified); await dynamicRepo.pullVersionHistory(trackId, snapshot.version); await contentManifestService.discardUnreferenced(trackId, [snapshot.content_manifest_id]); diff --git a/app/services/release-tracks/versioning-service.js b/app/services/release-tracks/versioning-service.js index 1f014fb8..94ecd22f 100644 --- a/app/services/release-tracks/versioning-service.js +++ b/app/services/release-tracks/versioning-service.js @@ -143,14 +143,18 @@ function planRelease( const normalized = tierRevisionInvariant.normalizeSnapshot(sourceSnapshot); const snapshot = normalized.snapshot; + const releaseModified = + sourceSnapshot.type === 'standard' + ? new Date(Math.max(now.getTime(), new Date(sourceSnapshot.modified).getTime() + 1)) + : sourceSnapshot.modified; const version = versionUtils.calculateNextVersion( versionHistory, options.increment, options.version, - sourceSnapshot.modified, + releaseModified, ); - versionUtils.validateVersionProgression(version, versionHistory, sourceSnapshot.modified); - const versionBounds = versionUtils.findVersionBounds(versionHistory, sourceSnapshot.modified); + versionUtils.validateVersionProgression(version, versionHistory, releaseModified); + const versionBounds = versionUtils.findVersionBounds(versionHistory, releaseModified); const isVirtual = snapshot.type === 'virtual'; const before = isVirtual @@ -197,7 +201,11 @@ function planRelease( const afterSnapshot = { ...snapshot, + modified: releaseModified, version, + ...(sourceSnapshot.type === 'standard' + ? { release_source_modified: sourceSnapshot.modified } + : {}), members: mergedMembers, ...(updatesSnapshotDescription && options.description ? { snapshot_description: options.description } @@ -217,7 +225,7 @@ function planRelease( version, tagged_at: now, tagged_by: options.userAccountId || 'system', - snapshot_id: sourceSnapshot.modified, + snapshot_id: releaseModified, summary: { ...after, promoted_count: blockingError ? 0 : staged.length, @@ -245,6 +253,7 @@ function planRelease( track_id: trackId, type: snapshot.type, source_snapshot_modified: iso(sourceSnapshot.modified), + release_snapshot_modified: iso(releaseModified), version, version_bounds: { lower: versionBounds.lower @@ -280,6 +289,15 @@ function planRelease( } async function planLoadedSnapshot(trackId, snapshot, options) { + if (snapshot.type === 'standard') { + const existingRelease = await dynamicRepo.getReleaseBySourceModified( + trackId, + snapshot.modified, + ); + if (existingRelease) { + throw new AlreadyReleasedError(existingRelease.version); + } + } const [versionHistory, previousTaggedSnapshot, resolvedStaged] = await Promise.all([ releaseHistoryService.getTrackWideVersionHistory(trackId), snapshot.type === 'virtual' @@ -381,12 +399,20 @@ async function commitPlan(plan) { let tagged; try { - tagged = await dynamicRepo.tagSnapshotInPlace(plan.trackId, source.modified, { - version: plan.version, - versionHistoryEntry: plan.versionHistoryEntry, - additionalOps: setOps, - unsetOps: Object.keys(unsetOps).length ? unsetOps : undefined, - }); + if (source.type === 'standard') { + const releaseSnapshot = { ...plan.plannedSnapshot, ...setOps }; + delete releaseSnapshot._id; + delete releaseSnapshot.__v; + if (plan.clearSnapshotDescription) delete releaseSnapshot.snapshot_description; + tagged = await dynamicRepo.saveSnapshot(plan.trackId, releaseSnapshot); + } else { + tagged = await dynamicRepo.tagSnapshotInPlace(plan.trackId, source.modified, { + version: plan.version, + versionHistoryEntry: plan.versionHistoryEntry, + additionalOps: setOps, + unsetOps: Object.keys(unsetOps).length ? unsetOps : undefined, + }); + } } catch (err) { await contentManifestService.discard(sealedManifestId); throw err; @@ -406,6 +432,9 @@ async function commitPlan(plan) { const withArtifacts = await refreshReleaseArtifacts(tagged); await releaseHistoryService.reconcileTaggedReleases(plan.trackId); + if (source.type === 'standard') { + await snapshotService.syncRegistryCounters(plan.trackId); + } const latest = await dynamicRepo.getLatestSnapshot(plan.trackId); await snapshotService.emitContentsChanged(plan.trackId, latest); @@ -451,6 +480,7 @@ async function withReleaseLock(trackId, operation) { } exports.planRelease = planRelease; +exports.withReleaseLock = withReleaseLock; exports._private = { memberRevisions, sameRevisions, @@ -483,3 +513,55 @@ exports.releaseByModified = async function releaseByModified(trackId, modified, commitPlan(await exports.planReleaseByModified(trackId, modified, options)), ); }; + +// The facade holds the release lock across validation, audit capture, and this operation. +exports.retagReleaseLocked = async function retagReleaseLocked(trackId, modified, nextVersion) { + const snapshot = await snapshotService.getSnapshotByModified(trackId, modified); + if (snapshot.version == null) { + throw new ReleaseConflictError('The selected snapshot is not a release', { + track_id: trackId, + snapshot_modified: iso(snapshot.modified), + }); + } + const currentVersion = snapshot.version; + const versionHistory = (await releaseHistoryService.getTrackWideVersionHistory(trackId)).filter( + (entry) => iso(entry.modified) !== iso(snapshot.modified), + ); + versionUtils.validateVersionProgression(nextVersion, versionHistory, snapshot.modified); + + // Hash the proposed serialization before publishing any change. A failed + // export leaves the old release intact; version and artifacts change in one + // document update. Same-version retries deliberately replay all side effects. + const publication = + snapshot.publication || (await publicationService.freezePublication(snapshot)); + const bundleId = snapshot.bundle_id || `bundle--${uuid.v4()}`; + const bundleHashes = await bundleHashService.generateBundleHashes({ + ...snapshot, + version: nextVersion, + publication, + bundle_id: bundleId, + }); + const retagged = await dynamicRepo.retagSnapshotInPlace( + trackId, + snapshot.modified, + currentVersion, + nextVersion, + { publication, bundle_id: bundleId, bundle_hashes: bundleHashes }, + ); + if (!retagged) { + throw new ReleaseConflictError('The release changed while its version was being updated', { + track_id: trackId, + snapshot_modified: iso(snapshot.modified), + expected_version: currentVersion, + }); + } + + await dynamicRepo.replaceVersionHistoryVersion(trackId, snapshot.modified, nextVersion); + await releaseHistoryService.reconcileTaggedReleases(trackId); + await snapshotService.syncRegistryCounters(trackId); + + logger.verbose( + `VersioningService: Changed release ${currentVersion} to ${nextVersion} on track "${trackId}"`, + ); + return retagged; +}; diff --git a/app/services/release-tracks/virtual-track-service.js b/app/services/release-tracks/virtual-track-service.js index d7487215..251299a5 100644 --- a/app/services/release-tracks/virtual-track-service.js +++ b/app/services/release-tracks/virtual-track-service.js @@ -488,41 +488,59 @@ exports.createVirtualSnapshot = async function createVirtualSnapshot(trackId, op }); } - // Validate component tracks - const registryMap = await validateComponentTracks(composition.component_tracks); - - // Resolve composition - const { members, quarantined, compositionResolution } = await resolveComposition( - source, - registryMap, - ); - await primaryRevisionService.assertStoredEntries([...members, ...quarantined]); - - // Build overrides for the new snapshot - const overrides = { - members, - quarantine: quarantined, - composition_resolution: compositionResolution, - scheduled_materialization: options.scheduledMaterialization, - snapshot_description: options.description, - }; - - let snapshot; - try { - snapshot = await snapshotService.cloneSnapshot(trackId, source, overrides); - } catch (err) { - if (!scheduledFor || !(err instanceof DuplicateIdError)) throw err; - - const existing = await dynamicRepo.getSnapshotByScheduledMaterialization(trackId, scheduledFor); - if (!existing) throw err; - snapshot = existing; + // Hold component release locks from resolution through persistence. Rollback + // cannot pass its dependency scan while a new dependent is being created. + // Sorted acquisition and fail-fast conflicts also release partial lock sets. + const componentIds = [ + ...new Set(composition.component_tracks.map((entry) => entry.track_id)), + ].sort(); + const { withReleaseLock } = require('./versioning-service'); + async function withComponentLocks(index) { + if (index === componentIds.length) return materialize(); + return withReleaseLock(componentIds[index], () => withComponentLocks(index + 1)); } + return withComponentLocks(0); + + async function materialize() { + // Validate component tracks + const registryMap = await validateComponentTracks(composition.component_tracks); + + // Resolve composition + const { members, quarantined, compositionResolution } = await resolveComposition( + source, + registryMap, + ); + await primaryRevisionService.assertStoredEntries([...members, ...quarantined]); + + // Build overrides for the new snapshot + const overrides = { + members, + quarantine: quarantined, + composition_resolution: compositionResolution, + scheduled_materialization: options.scheduledMaterialization, + snapshot_description: options.description, + }; + + let snapshot; + try { + snapshot = await snapshotService.cloneSnapshot(trackId, source, overrides); + } catch (err) { + if (!scheduledFor || !(err instanceof DuplicateIdError)) throw err; + + const existing = await dynamicRepo.getSnapshotByScheduledMaterialization( + trackId, + scheduledFor, + ); + if (!existing) throw err; + snapshot = existing; + } - logger.verbose( - `VirtualTrackService: Created virtual snapshot for track "${trackId}" ` + - `(${members.length} members, ${quarantined.length} quarantined)`, - ); - return snapshot; + logger.verbose( + `VirtualTrackService: Created virtual snapshot for track "${trackId}" ` + + `(${members.length} members, ${quarantined.length} quarantined)`, + ); + return snapshot; + } }; /** diff --git a/app/tests/api/release-tracks/content-manifests.spec.js b/app/tests/api/release-tracks/content-manifests.spec.js index 9edc8db0..b012f65e 100644 --- a/app/tests/api/release-tracks/content-manifests.spec.js +++ b/app/tests/api/release-tracks/content-manifests.spec.js @@ -158,8 +158,8 @@ describe('Sealed release-track content manifests', function () { stix_2_0: expect.stringMatching(/^[a-f0-9]{64}$/), stix_2_1: expect.stringMatching(/^[a-f0-9]{64}$/), }); - // The initial manifest is no longer referenced by any snapshot. - expect(await ReleaseTrackContentManifest.countDocuments({ track_id: track.id })).toBe(1); + // The preserved source draft still references its inherited manifest. + expect(await ReleaseTrackContentManifest.countDocuments({ track_id: track.id })).toBe(2); const sealed = await ReleaseTrackContentManifest.findOne({ manifest_id: released.content_manifest_id, @@ -485,7 +485,8 @@ describe('Sealed release-track content manifests', function () { expect(reconstructed.bundle_id).toBe(released.bundle_id); expect(reconstructed.bundle_hashes.manifest_id).toBe(reconstructed.content_manifest_id); expect(reconstructed.bundle_hashes.stix_2_1).not.toBe(released.bundle_hashes.stix_2_1); - expect(await ReleaseTrackContentManifest.countDocuments({ track_id: track.id })).toBe(1); + // Reconstruction replaces only the release manifest; the source draft remains intact. + expect(await ReleaseTrackContentManifest.countDocuments({ track_id: track.id })).toBe(2); const manifest = await ReleaseTrackContentManifest.findOne({ manifest_id: reconstructed.content_manifest_id, }) diff --git a/app/tests/api/release-tracks/destructive-authorization.spec.js b/app/tests/api/release-tracks/destructive-authorization.spec.js index 09d58959..8590d920 100644 --- a/app/tests/api/release-tracks/destructive-authorization.spec.js +++ b/app/tests/api/release-tracks/destructive-authorization.spec.js @@ -3,6 +3,13 @@ const request = require('supertest'); const { expect } = require('expect'); const sinon = require('sinon'); +const crypto = require('node:crypto'); +const dynamicRepo = require('../../../repository/release-tracks/release-track-dynamic.repository'); +const registryRepo = require('../../../repository/release-tracks/release-track-registry.repository'); +const snapshotService = require('../../../services/release-tracks/snapshot-service'); +const versioningService = require('../../../services/release-tracks/versioning-service'); +const bundleHashService = require('../../../services/release-tracks/bundle-hash-service'); +const releaseHistoryService = require('../../../services/release-tracks/release-history-service'); const config = require('../../../config/config'); const database = require('../../../lib/database-in-memory'); @@ -135,7 +142,21 @@ describe('Release-track destructive authorization and audit', function () { const first = await releaseExactMembers(app, passportCookie, track.id, [technique], { version: '1.0', }); - await post(`/api/release-tracks/${track.id}/meta`, { description: 'next' }, 200); + const firstSource = await api( + 'get', + `/api/release-tracks/${track.id}/snapshots/${encodeURIComponent(first.release_source_modified)}`, + undefined, + 200, + ); + expect(firstSource.body).toMatchObject({ + version: null, + staged: [expect.objectContaining({ object_ref: technique.stix.id })], + }); + const secondDraft = await post( + `/api/release-tracks/${track.id}/meta`, + { description: 'next' }, + 200, + ); const second = await post( `/api/release-tracks/${track.id}/snapshots/latest/release`, { version: '1.1' }, @@ -172,7 +193,9 @@ describe('Release-track destructive authorization and audit', function () { undefined, 200, ); - expect(remaining.body.version).toBe('1.0'); + expect(remaining.body.modified).toBe(secondDraft.modified); + expect(remaining.body.version).toBeNull(); + expect(remaining.body.description).toBe('next'); expect(remaining.body.version_history.map((entry) => entry.version)).toEqual(['1.0']); expect( await ReleaseTrackContentManifest.countDocuments({ @@ -208,6 +231,405 @@ describe('Release-track destructive authorization and audit', function () { expect(again.version).toBe('1.1'); }); + it('blocks deletion when implicit or explicit virtual snapshots resolved the release', async function () { + await setRole('admin'); + const component = await post( + '/api/release-tracks/new', + { name: 'Protected component release', type: 'standard' }, + 201, + ); + const released = await post( + `/api/release-tracks/${component.id}/snapshots/latest/release`, + { version: '1.0' }, + 200, + ); + + const virtualSnapshots = []; + for (const [name, rule] of [ + ['Implicit dependent', { resolution_strategy: 'latest_tagged' }], + ['Explicit dependent', { resolution_strategy: 'specific_version', version: '1.0' }], + ]) { + const virtual = await post( + '/api/release-tracks/new', + { + name, + type: 'virtual', + composition: { + component_tracks: [{ track_id: component.id, priority: 1, ...rule }], + }, + }, + 201, + ); + virtualSnapshots.push({ + trackId: virtual.id, + snapshot: await post(`/api/release-tracks/${virtual.id}/virtual/snapshots/create`, {}, 201), + }); + } + + const response = await api( + 'delete', + `/api/release-tracks/${component.id}/snapshots/${encodeURIComponent(released.modified)}`, + undefined, + 409, + { confirm_version: '1.0' }, + ); + expect(response.body.dependent_snapshots).toHaveLength(2); + expect(response.body.dependent_snapshots.map((item) => item.track_name).sort()).toEqual([ + 'Explicit dependent', + 'Implicit dependent', + ]); + await api( + 'get', + `/api/release-tracks/${component.id}/snapshots/${encodeURIComponent(released.modified)}`, + undefined, + 200, + ); + + const retagged = await api( + 'put', + `/api/release-tracks/${component.id}/snapshots/${encodeURIComponent(released.modified)}/release`, + { version: '1.1' }, + 200, + ); + expect(retagged.body.version).toBe('1.1'); + for (const virtual of virtualSnapshots) { + const persisted = await api( + 'get', + `/api/release-tracks/${virtual.trackId}/snapshots/${encodeURIComponent(virtual.snapshot.modified)}`, + undefined, + 200, + ); + expect(persisted.body.composition_resolution.component_snapshots[0]).toMatchObject({ + resolved_version: '1.0', + resolved_snapshot_id: released.modified, + }); + } + }); + + it('lets administrators retag a release within its semantic-version bounds', async function () { + await setRole('admin'); + const track = await post( + '/api/release-tracks/new', + { name: 'Retag release track', type: 'standard' }, + 201, + ); + const first = await post( + `/api/release-tracks/${track.id}/snapshots/latest/release`, + { version: '1.0' }, + 200, + ); + await post(`/api/release-tracks/${track.id}/meta`, { description: 'second' }, 200); + const second = await post( + `/api/release-tracks/${track.id}/snapshots/latest/release`, + { version: '2.0' }, + 200, + ); + + await setRole('editor'); + await api( + 'put', + `/api/release-tracks/${track.id}/snapshots/${encodeURIComponent(second.modified)}/release`, + { version: '1.1' }, + 403, + ); + + await setRole('admin'); + const retagged = await api( + 'put', + `/api/release-tracks/${track.id}/snapshots/${encodeURIComponent(second.modified)}/release`, + { version: '1.1' }, + 200, + ); + expect(retagged.body).toMatchObject({ + modified: second.modified, + version: '1.1', + bundle_id: second.bundle_id, + }); + expect(retagged.body.bundle_hashes.stix_2_0).toBe(second.bundle_hashes.stix_2_0); + expect(retagged.body.bundle_hashes.stix_2_1).not.toBe(second.bundle_hashes.stix_2_1); + expect(retagged.body.version_history.map((entry) => entry.version)).toEqual(['1.0', '1.1']); + + const history = await api('get', `/api/release-tracks/${track.id}/snapshots`, undefined, 200); + const summary = history.body.data.find((entry) => entry.modified === second.modified); + expect(summary.release_source_modified).toBe(second.release_source_modified); + expect(summary.bundle_hashes).toEqual(retagged.body.bundle_hashes); + for (const stixVersion of ['2.0', '2.1']) { + const download = await api( + 'get', + `/api/release-tracks/${track.id}/snapshots/${encodeURIComponent(second.modified)}`, + undefined, + 200, + { format: 'bundle', stixVersion }, + ); + const digest = crypto + .createHash('sha256') + .update(JSON.stringify(download.body, null, 4)) + .digest('hex'); + expect(digest).toBe(summary.bundle_hashes[stixVersion === '2.0' ? 'stix_2_0' : 'stix_2_1']); + } + + await api( + 'put', + `/api/release-tracks/${track.id}/snapshots/${encodeURIComponent(first.modified)}/release`, + { version: '1.2' }, + 400, + ); + + const event = await ReleaseTrackAuditEvent.findOne({ + action: 'retag_release', + status: 'completed', + }) + .lean() + .exec(); + expect(event).toMatchObject({ + track_id: track.id, + confirmation: '2.0', + request: { previous_version: '2.0', next_version: '1.1' }, + }); + }); + + for (const [label, target, method, versionPublished] of [ + ['hash generation', bundleHashService, 'generateBundleHashes', false], + ['copied history', dynamicRepo, 'replaceVersionHistoryVersion', true], + ['release catalogue', releaseHistoryService, 'reconcileTaggedReleases', true], + ['registry counters', snapshotService, 'syncRegistryCounters', true], + ]) { + it(`recovers a retag interrupted during ${label}`, async function () { + await setRole('admin'); + const track = await post('/api/release-tracks/new', { name: label, type: 'standard' }, 201); + const base = `/api/release-tracks/${track.id}`; + const released = await post(`${base}/snapshots/latest/release`, { version: '1.0' }); + const draft = await post(`${base}/meta`, { description: 'Copied release history' }); + const path = `${base}/snapshots/${encodeURIComponent(released.modified)}`; + const failure = sinon.stub(target, method).rejects(new Error(`Injected ${label} failure`)); + await api('put', `${path}/release`, { version: '1.1' }, 500); + failure.restore(); + const persisted = await dynamicRepo.getSnapshotByModified(track.id, released.modified); + expect(persisted.version).toBe(versionPublished ? '1.1' : '1.0'); + expect(persisted.bundle_hashes.stix_2_0).toBe(released.bundle_hashes.stix_2_0); + if (!versionPublished) expect(persisted.bundle_hashes).toEqual(released.bundle_hashes); + const retried = await api('put', `${path}/release`, { version: '1.1' }, 200); + expect(retried.body.bundle_hashes.stix_2_1).not.toBe(released.bundle_hashes.stix_2_1); + const copied = await dynamicRepo.getSnapshotByModified(track.id, draft.modified); + expect(copied.version_history.map((entry) => entry.version)).toEqual(['1.1']); + const registry = await registryRepo.findByTrackId(track.id); + expect(registry.tagged_releases.map((entry) => entry.version)).toEqual(['1.1']); + const events = await ReleaseTrackAuditEvent.find({ + track_id: track.id, + action: 'retag_release', + }).lean(); + expect(events.map((event) => event.status).sort()).toEqual(['completed', 'failed']); + }); + } + + for (const strategy of ['latest_tagged', 'specific_version']) { + it(`holds component locks until ${strategy} materialization is persisted`, async function () { + await setRole('admin'); + const component = await post( + '/api/release-tracks/new', + { name: strategy.replaceAll('_', ' '), type: 'standard' }, + 201, + ); + const base = `/api/release-tracks/${component.id}`; + const released = await post(`${base}/snapshots/latest/release`, { version: '1.0' }); + const virtual = await post( + '/api/release-tracks/new', + { + name: 'Concurrent dependent', + type: 'virtual', + composition: { + component_tracks: [ + { + track_id: component.id, + priority: 1, + resolution_strategy: strategy, + ...(strategy === 'specific_version' ? { version: '1.0' } : {}), + }, + ], + }, + }, + 201, + ); + const path = `${base}/snapshots/${encodeURIComponent(released.modified)}`; + const clone = snapshotService.cloneSnapshot; + const stub = sinon.stub(snapshotService, 'cloneSnapshot').callsFake(async (...args) => { + await api('delete', path, undefined, 409, { confirm_version: '1.0' }); + return clone(...args); + }); + await post(`/api/release-tracks/${virtual.id}/virtual/snapshots/create`, {}, 201); + expect(stub.calledOnce).toBe(true); + stub.restore(); + const blocked = await api('delete', path, undefined, 409, { confirm_version: '1.0' }); + expect(blocked.body.dependent_snapshots).toHaveLength(1); + expect((await registryRepo.findByTrackId(component.id)).release_lock).toBeUndefined(); + }); + } + + it('repairs missing hashes on a same-version retry and holds the lock during audit capture', async function () { + await setRole('admin'); + const track = await post( + '/api/release-tracks/new', + { name: 'Retag repair', type: 'standard' }, + 201, + ); + const base = `/api/release-tracks/${track.id}`; + const released = await post(`${base}/snapshots/latest/release`, { version: '1.0' }); + const path = `${base}/snapshots/${encodeURIComponent(released.modified)}`; + await dynamicRepo.updateSnapshot(track.id, released.modified, { + $unset: { bundle_hashes: '' }, + }); + const create = auditRepository.create; + const stub = sinon.stub(auditRepository, 'create').callsFake(async (...args) => { + await api('put', `${path}/release`, { version: '1.1' }, 409); + return create.apply(auditRepository, args); + }); + const repaired = await api('put', `${path}/release`, { version: '1.0' }, 200); + expect(stub.calledOnce).toBe(true); + expect(repaired.body.version).toBe('1.0'); + expect(repaired.body.bundle_hashes).toEqual(released.bundle_hashes); + const event = await ReleaseTrackAuditEvent.findOne({ + track_id: track.id, + action: 'retag_release', + }).lean(); + expect(event.request.previous_version).toBe('1.0'); + expect(event.request.next_version).toBe('1.0'); + }); + + it('does not publish retag hashes if the content manifest changed during export', async function () { + await setRole('admin'); + const track = await post( + '/api/release-tracks/new', + { name: 'Manifest race', type: 'standard' }, + 201, + ); + const base = `/api/release-tracks/${track.id}`; + const released = await post(`${base}/snapshots/latest/release`, { version: '1.0' }); + const generate = bundleHashService.generateBundleHashes; + sinon.stub(bundleHashService, 'generateBundleHashes').callsFake(async (snapshot) => { + const hashes = await generate(snapshot); + // Simulate administrative manifest replacement after export was read. + await dynamicRepo.replaceContentManifest( + track.id, + released.modified, + released.content_manifest_id, + track.content_manifest_id, + ); + return hashes; + }); + await api( + 'put', + `${base}/snapshots/${encodeURIComponent(released.modified)}/release`, + { version: '1.1' }, + 409, + ); + const current = await dynamicRepo.getSnapshotByModified(track.id, released.modified); + expect(current.version).toBe('1.0'); + expect(current.content_manifest_id).toBe(track.content_manifest_id); + expect(current.bundle_hashes).toBeUndefined(); + }); + + it('blocks materialization while rollback holds the component lock', async function () { + await setRole('admin'); + const component = await post( + '/api/release-tracks/new', + { name: 'Rollback first', type: 'standard' }, + 201, + ); + const base = `/api/release-tracks/${component.id}`; + const released = await post(`${base}/snapshots/latest/release`, { version: '1.0' }); + const virtual = await post( + '/api/release-tracks/new', + { + name: 'Dependent', + type: 'virtual', + composition: { + component_tracks: [ + { track_id: component.id, priority: 1, resolution_strategy: 'latest_tagged' }, + ], + }, + }, + 201, + ); + const find = dynamicRepo.findSnapshotsResolvingComponent; + sinon.stub(dynamicRepo, 'findSnapshotsResolvingComponent').callsFake(async (...args) => { + await api('post', `/api/release-tracks/${virtual.id}/virtual/snapshots/create`, {}, 409); + return find.apply(dynamicRepo, args); + }); + await api( + 'delete', + `${base}/snapshots/${encodeURIComponent(released.modified)}`, + undefined, + 204, + { confirm_version: '1.0' }, + ); + expect((await registryRepo.findByTrackId(component.id)).release_lock).toBeUndefined(); + }); + + it('rechecks confirmation after a retag wins the release lock', async function () { + await setRole('admin'); + const track = await post( + '/api/release-tracks/new', + { name: 'Confirmation race', type: 'standard' }, + 201, + ); + const base = `/api/release-tracks/${track.id}`; + const released = await post(`${base}/snapshots/latest/release`, { version: '1.0' }); + const path = `${base}/snapshots/${encodeURIComponent(released.modified)}`; + const acquire = registryRepo.acquireReleaseLock; + const stub = sinon.stub(registryRepo, 'acquireReleaseLock').callsFake(async (...args) => { + stub.restore(); + await api('put', `${path}/release`, { version: '1.1' }, 200); + return acquire.apply(registryRepo, args); + }); + const rejected = await api('delete', path, undefined, 400, { confirm_version: '1.0' }); + expect(rejected.body.expected_version).toBe('1.1'); + expect((await dynamicRepo.getSnapshotByModified(track.id, released.modified)).version).toBe( + '1.1', + ); + const event = await ReleaseTrackAuditEvent.findOne({ + track_id: track.id, + action: 'retag_release', + }).lean(); + expect(event.request.previous_version).toBe('1.0'); + expect(event.request.next_version).toBe('1.1'); + }); + + it('releases partially acquired component locks after a conflict', async function () { + const components = []; + for (let i = 0; i < 2; i++) { + components.push( + await post('/api/release-tracks/new', { name: `Lock ${i}`, type: 'standard' }, 201), + ); + } + components.sort((a, b) => a.id.localeCompare(b.id)); + const virtual = await post( + '/api/release-tracks/new', + { + name: 'Multiple locks', + type: 'virtual', + composition: { + component_tracks: components.map((component, priority) => ({ + track_id: component.id, + priority, + resolution_strategy: 'latest_tagged', + })), + }, + }, + 201, + ); + await versioningService.withReleaseLock(components[1].id, () => + api('post', `/api/release-tracks/${virtual.id}/virtual/snapshots/create`, {}, 409), + ); + for (const component of components) { + expect((await registryRepo.findByTrackId(component.id)).release_lock).toBeUndefined(); + } + // Resolution failure must also unwind the complete lock set. + await api('post', `/api/release-tracks/${virtual.id}/virtual/snapshots/create`, {}, 400); + for (const component of components) { + expect((await registryRepo.findByTrackId(component.id)).release_lock).toBeUndefined(); + } + }); + it('reports an audit-finalization failure without hiding the persisted mutation', async function () { await setRole('admin'); const track = await post( diff --git a/app/tests/api/release-tracks/deterministic-graph-migration.spec.js b/app/tests/api/release-tracks/deterministic-graph-migration.spec.js index 47678a77..b5c075e7 100644 --- a/app/tests/api/release-tracks/deterministic-graph-migration.spec.js +++ b/app/tests/api/release-tracks/deterministic-graph-migration.spec.js @@ -73,6 +73,17 @@ describe('Release-track manifest migrations', function () { return mongoose.connection.db.collection(trackId); } + async function removeModernReleaseSource(trackId, release) { + await trackCollection(trackId).deleteOne({ + modified: new Date(release.release_source_modified), + version: null, + }); + await trackCollection(trackId).updateOne( + { modified: new Date(release.modified) }, + { $unset: { release_source_modified: '' } }, + ); + } + before('create and then downgrade representative legacy data', async function () { const timestamp = new Date().toISOString(); technique = await post('/api/techniques', { @@ -125,8 +136,12 @@ describe('Release-track manifest migrations', function () { 201, ); legacyTrackId = legacyTrack.id; - await releaseExactMembers(app, passportCookie, legacyTrackId, [technique, group]); + const legacyRelease = await releaseExactMembers(app, passportCookie, legacyTrackId, [ + technique, + group, + ]); await post(`/api/release-tracks/${legacyTrackId}/meta`, { description: 'draft' }, 200); + await removeModernReleaseSource(legacyTrackId, legacyRelease); await trackCollection(legacyTrackId).updateMany( {}, { @@ -150,6 +165,7 @@ describe('Release-track manifest migrations', function () { const sealedRelease = await releaseExactMembers(app, passportCookie, sealedTrackId, [ technique, ]); + await removeModernReleaseSource(sealedTrackId, sealedRelease); sealedManifestId = sealedRelease.content_manifest_id.replace( 'release-track-content-manifest--', 'release-track-graph-manifest--', @@ -219,7 +235,10 @@ describe('Release-track manifest migrations', function () { 201, ); orphanTrackId = orphanTrack.id; - await releaseExactMembers(app, passportCookie, orphanTrackId, [technique]); + const orphanRelease = await releaseExactMembers(app, passportCookie, orphanTrackId, [ + technique, + ]); + await removeModernReleaseSource(orphanTrackId, orphanRelease); await mongoose.connection.db .collection('releaseTrackRegistry') .deleteOne({ track_id: orphanTrackId }); diff --git a/app/tests/api/release-tracks/release-tracks-release.spec.js b/app/tests/api/release-tracks/release-tracks-release.spec.js index e912e079..b2e9411b 100644 --- a/app/tests/api/release-tracks/release-tracks-release.spec.js +++ b/app/tests/api/release-tracks/release-tracks-release.spec.js @@ -497,7 +497,8 @@ describe('Release-track release planning and commit API', function () { }); const released = await post(`/api/release-tracks/${track.id}/snapshots/latest/release`, {}); - expect(released.body.modified).toBe(updated.body.modified); + expect(released.body.modified).not.toBe(updated.body.modified); + expect(released.body.release_source_modified).toBe(updated.body.modified); expect(released.body.modified).not.toBe(preview.body.source_snapshot_modified); expect(released.body.version).toBe('1.0'); }); @@ -518,7 +519,8 @@ describe('Release-track release planning and commit API', function () { const released = await post(`/api/release-tracks/${track.id}/snapshots/latest/release`, { version: '3.0', }); - expect(released.body.modified).toBe(replacement.body.modified); + expect(released.body.modified).not.toBe(replacement.body.modified); + expect(released.body.release_source_modified).toBe(replacement.body.modified); expect(released.body.version).toBe('3.0'); }); diff --git a/app/tests/api/release-tracks/snapshot-descriptions.spec.js b/app/tests/api/release-tracks/snapshot-descriptions.spec.js index 50d54466..51d4dc3a 100644 --- a/app/tests/api/release-tracks/snapshot-descriptions.spec.js +++ b/app/tests/api/release-tracks/snapshot-descriptions.spec.js @@ -99,11 +99,14 @@ describe('Release-track snapshot descriptions', function () { }); expect(released).toMatchObject({ - modified: track.modified, version: '1.0', description: 'Stable track description', snapshot_description: 'What changed in the first publication.', }); + expect(released.modified).not.toBe(track.modified); + expect(new Date(released.release_source_modified).toISOString()).toBe( + new Date(track.modified).toISOString(), + ); const originalHashes = released.bundle_hashes; const conflict = await put( diff --git a/docs/admin/release-track-audit.md b/docs/admin/release-track-audit.md index b36f071b..e6427d8f 100644 --- a/docs/admin/release-track-audit.md +++ b/docs/admin/release-track-audit.md @@ -1,15 +1,17 @@ # Release-Track Destructive Audit Events Workbench stores administrator-initiated destructive attempts in -`releaseTrackAuditEvents`: full-track deletion (`delete_track`) and deletion -of a track's most recent release (`delete_release`). The collection is empty -until an administrator performs one of those actions. +`releaseTrackAuditEvents`: full-track deletion (`delete_track`), rollback of a +track's most recent release (`delete_release`), and release-version correction +(`retag_release`). The collection is empty until an administrator performs one +of those actions. Each record contains: - `event_id`, `action`, and `track_id` - the authenticated `actor` -- the exact `confirmation` supplied by the caller +- the exact destructive `confirmation` supplied by the caller (or the prior + version for `retag_release`) - a bounded request/result summary - `pending`, `completed`, or `failed` status - start/finish timestamps and failure detail diff --git a/docs/developer/TODO.md b/docs/developer/TODO.md index e6ff635f..e52b12d1 100644 --- a/docs/developer/TODO.md +++ b/docs/developer/TODO.md @@ -1,5 +1,44 @@ # Release Track TODOs +## Preserve pre-release drafts and protect virtual dependencies + +- [x] Change standard-track release commit from in-place tagging to creation + of a new tagged snapshot while retaining the exact source draft. +- [x] Prevent rolling-draft cleanup from pruning drafts retained as the source + of a tagged standard release. +- [x] Block release deletion when any persisted virtual snapshot resolved the + exact standard release snapshot, for implicit or explicit composition. +- [x] Add a post-hoc release-version update that preserves the snapshot and + validates the replacement against adjacent release versions. +- [x] Reconcile release catalogues, copied version ledgers, bundle hashes, + audit records, and current-snapshot backrefs for both operations. +- [x] Update OpenAPI, user/developer/operator docs, and Bruno requests. +- [x] Add ADM-valid API regressions and run focused specs, then full `npm test`. +- [x] Update the frontend release controls, wording, connector, and tests. +- [x] Propose conventional commit messages without committing. + +## Rollback / retag review follow-up + +- [x] Coordinate component release locks with virtual materialization. +- [x] Publish retag hashes atomically with the version and repair derived state on retry. +- [x] Expose preserved source pointers in snapshot history. +- [x] Validate deletion confirmation and capture audit identity under the release lock. +- [x] Add concurrency, failure-recovery, history, and exact-download hash regressions. +- [x] Update OpenAPI, user/developer docs, and Bruno smoke requests. +- [x] Run focused specs, full npm test, and lint; propose a commit without committing. + +Verification: focused backend group 32 passing, final destructive/retag spec +16 passing; full `npm test` passes (OpenAPI 2, config 22, API 1033, +middleware 29, scheduler 10). Backend lint and frontend page/connector tests +(90) pass. An initial unrelated technique-conversion 404 passed in isolation +(24) and on the final full run; no unrelated source changes were made. + +Proposed commit: `fix(release-tracks): make rollback and retag concurrency-safe` + +Coordinate materialization with component release locks, publish retag hashes +atomically, repair derived state on retry, expose preserved draft pointers, +and validate destructive confirmation under the audit lock. + ## Sealed snapshot content manifests (Problem 1) Design: [release-tracks/sealed-content-manifests.md](release-tracks/sealed-content-manifests.md). diff --git a/docs/developer/release-tracks/authorization.md b/docs/developer/release-tracks/authorization.md index 70f6df53..3254aaef 100644 --- a/docs/developer/release-tracks/authorization.md +++ b/docs/developer/release-tracks/authorization.md @@ -15,6 +15,7 @@ history requires an administrator. | Tag a standard or virtual snapshot | No | Yes | Yes | | Delete the latest untagged draft snapshot | No | Yes | Yes | | Delete the track's most recent release | No | No | Yes | +| Change a tagged release's semantic version | No | No | Yes | | Delete an entire track and all snapshot history | No | No | Yes | Full-track deletion also requires `confirm_track_id` to equal the `:id` path @@ -24,9 +25,19 @@ deletion shares the snapshot deletion route, so the service checks the administrator role itself and answers `403` otherwise. Confirmation runs before persistence in both cases. +Release-version correction uses `PUT /snapshots/:modified/release`, is also +checked in the service, and does not require destructive confirmation because +it preserves the snapshot. It is serialized with release and rollback and is +recorded as `retag_release`. + +Release deletion re-reads the snapshot and checks `confirm_version` under the +release lock. Both deletion and retag capture audit identity under that same +lock, so a competing version correction cannot invalidate confirmation or +change the version between audit capture and mutation. + ## Audited destructive actions -The `delete_track` and `delete_release` actions create a +The `delete_track`, `delete_release`, and `retag_release` actions create a `releaseTrackAuditEvents` record before the business operation begins. Each event records the authenticated actor, confirmation value, target track, diff --git a/docs/developer/release-tracks/entities.md b/docs/developer/release-tracks/entities.md index 53105871..4da5ed91 100644 --- a/docs/developer/release-tracks/entities.md +++ b/docs/developer/release-tracks/entities.md @@ -7,11 +7,11 @@ This document tracks new database schemas, interfaces, etc.; as well as changes | Collection | Purpose | Written by | Growth and retention | | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- | | `releaseTrackRegistry` | One document per track: name, type, denormalized counters, the tagged-release catalogue (`tagged_releases`), the release lock, and virtual schedules. The index that maps a track to its own snapshot collection. | Track create/delete, every snapshot write (counters), release commit and release deletion (catalogue). | One document per track. | -| `release-track--` | The track's snapshots: at most one rolling draft plus every tagged release for a standard track; every materialized draft plus releases for a virtual track. | Snapshot service and release commit. | Bounded by releases plus one draft (standard) or by materializations (virtual). | +| `release-track--` | The track's snapshots: one active rolling draft, a preserved source draft per tagged standard release, and every tagged release; every materialized draft plus releases for a virtual track. | Snapshot service and release commit. | Standard tracks grow by two snapshots per release plus one active draft; virtual tracks by materializations. | | `releaseTrackContentManifests` | The sealed bill of materials each snapshot references (`content_manifest_id`). Several snapshots share one manifest when their member sets are identical. | Sealed whenever members are written; discarded when no snapshot references it. | Bounded by member-changing writes, not by snapshot count. | | `releaseTrackContentManifestEntries` | One exact-revision pointer per object a manifest emits or depends on. The `(object_ref, object_modified)` index is what protects referenced revisions from deletion. | With its manifest. | Roughly members + relationships + a few supporting objects per manifest. | | `releaseTrackReconciliations` | Outstanding backref reconciliation work only: a record is created before the `workspace.release_tracks` listeners run and deleted when they succeed, so anything present is pending or failed and needs repair. | Every snapshot write. | Normally empty. | -| `releaseTrackAuditEvents` | Audit trail for administrator-only destructive operations: `delete_track` and `delete_release`, with actor, confirmation, and outcome. | Those two operations. | Empty until an administrator deletes a track or release. | +| `releaseTrackAuditEvents` | Audit trail for administrator-only track deletion, release rollback, and release retagging (`delete_track`, `delete_release`, `retag_release`). | Those operations. | Empty until an administrator performs one of those operations. | | `virtualTrackScheduleOccurrences` | Durable claims for scheduled virtual materialization (cron or dated schedules) so restarts and duplicate delivery execute each occurrence once. | The scheduler. | One record per scheduled occurrence; empty when no virtual track has a schedule. | Removed by the sealed-manifest work: the former `releaseTrackGraphManifests` @@ -262,6 +262,12 @@ does not change `modified`, tier contents, or the content manifest; once the snapshot is released it is immutable. Rolling edits to the same draft preserve its description; the first draft of a new release cycle starts blank. +For a tagged standard snapshot, `release_source_modified` identifies the exact +untagged draft from which it was created. The pair is unique within the track. +Draft pruning excludes these source snapshots, and the UI suppresses them +while the release exists. Removing the newest release therefore exposes the +unchanged source draft without reconstructing state from a ledger or manifest. + ### Version History The `version_history` array tracks all tagged releases in reverse chronological order (newest first): @@ -288,6 +294,12 @@ This provides: - Attribution for each tagged release - Chronological release history +Correcting a release version updates the matching entry identified by +`snapshot_id`, including copies carried forward into later snapshots. Exact +snapshot identity, publication metadata, content, and bundle ID do not change; +the bundle hashes are regenerated because the projected collection version +does change. + ### Object (SDO/SRO/SMO) Document Schema Objects maintain a simple reverse reference to the release tracks that diff --git a/docs/developer/release-tracks/implementation-notes.md b/docs/developer/release-tracks/implementation-notes.md index 62e21bac..2d153600 100644 --- a/docs/developer/release-tracks/implementation-notes.md +++ b/docs/developer/release-tracks/implementation-notes.md @@ -152,8 +152,9 @@ objects; no partial release track points at them. `app/lib/release-tracks/tier-revision-invariant.js` owns selector identity (`object_ref` + normalized `object_modified`) and normalization. Every clone-based mutation passes through `snapshot-service.cloneSnapshot`; -track cloning uses the same normalizer. Tagging is the one in-place mutation, -so `versioning-service` normalizes before the atomic tag update. This covers +track cloning uses the same normalizer. Standard tagging also creates a clone, +while virtual tagging remains an in-place mutation of its materialized draft. +`versioning-service` normalizes before either commit. This covers candidate adds, manual/automatic promotion, demotion, status transitions, candidate pin changes, member sync, direct content replacement, bundle import, standard/virtual snapshot creation, and release commits without @@ -346,6 +347,17 @@ property. Mongoose validates every map value with the shared release-version validator and requires every persisted component resolution to identify its tagged `resolved_version`. +Standard release commit assigns a fresh timestamp, stores +`release_source_modified`, and inserts the tagged clone while retaining the +source. Release, retag, and rollback share the registry release lock. Rollback +queries exact virtual provenance (`track_id` + `resolved_snapshot_id`) across +all virtual snapshot collections and fails closed when any dependent exists; +this catches both implicit `latest_tagged` and explicit resolution rules. + +Retagging preserves `resolved_snapshot_id`. Existing virtual provenance keeps +the `resolved_version` label observed when it materialized; future explicit +rules that name an obsolete label must be updated by the caller. + ### Snapshot history reads Snapshot history is exposed as a nested collection at diff --git a/docs/developer/release-tracks/releases-by-object.md b/docs/developer/release-tracks/releases-by-object.md index 14a58f5e..5bb2d959 100644 --- a/docs/developer/release-tracks/releases-by-object.md +++ b/docs/developer/release-tracks/releases-by-object.md @@ -43,21 +43,19 @@ length. The dynamic snapshot remains authoritative for its contents. ### Reconciliation -Tagging is already a two-document workflow: it mutates the snapshot in its -dynamic collection, then updates the registry. After a successful tag, the +Tagging is already a two-document workflow: it inserts or updates the release +in its dynamic collection, then updates the registry. After a successful tag, the service reads the track's tagged snapshot metadata and replaces the registry -projection. Reconciliation rather than `$push` makes the operation idempotent, -repairs missing entries, and handles retroactive tags. +projection. Reconciliation rather than `$push` makes the operation idempotent +and repairs missing entries. Existing deployments receive the same projection through an idempotent -database migration. Tagged snapshots are immutable and cannot be deleted; -deleting a whole track removes both its dynamic collection and registry -document. Draft-snapshot squashing is orthogonal because it only deletes -snapshots with `version == null`. - -Version calculation and monotonicity validation must use track-wide tagged -release metadata. An older draft's embedded `version_history` can predate -newer tags and is not a safe global ledger for retroactive tagging. +database migration. Tagged snapshot content is immutable. The newest standard +release can be rolled back only when its preserved source draft exists and no +virtual snapshot resolved it. Draft squashing excludes preserved sources. + +Version calculation and monotonicity validation use track-wide tagged release +metadata rather than a draft's copied ledger. ## Query algorithm diff --git a/docs/developer/release-tracks/sealed-content-manifests.md b/docs/developer/release-tracks/sealed-content-manifests.md index 510d4d9b..dc59e079 100644 --- a/docs/developer/release-tracks/sealed-content-manifests.md +++ b/docs/developer/release-tracks/sealed-content-manifests.md @@ -88,10 +88,13 @@ endpoint. is editable on drafts only. The graph create and delete endpoints are removed. The admin-only source-attested reconstruction endpoint remains and can replace an existing manifest when the caller names the manifest it - expects to replace. The correction path for a mistaken release is - deletion: an administrator may delete the track's most recent release with - a typed version confirmation, which retracts its ledger entry, discards its - manifest when unreferenced, and is audited as `delete_release`. + expects to replace. Standard release commit creates a tagged clone and + retains its exact source draft. The correction path for a mistaken latest + release is rollback: an administrator supplies typed version confirmation, + the clone is removed, and the preserved draft becomes active again. + Rollback is blocked while any virtual snapshot resolves the release. + Version-only corrections preserve content and snapshot identity but + regenerate export hashes. 8. **Storage is named for what it holds.** Manifests live in `releaseTrackContentManifests` and `releaseTrackContentManifestEntries` with `release-track-content-manifest--` ids. A manifest header carries @@ -111,6 +114,28 @@ endpoint. ## Consequences +### Rollback and retag concurrency / recovery + +Virtual materialization acquires the existing database-backed release locks +for all component tracks in sorted order, before resolving any release, and +holds them through snapshot persistence. Partial acquisition and failed +materialization unwind the locks. Contention fails fast with 409. The rollback +dependency scan therefore cannot miss an in-flight materialization: either +rollback owns the lock first, or it sees the persisted virtual dependency +after materialization releases the lock. + +Retag prepares both bundle serializations before writing, then atomically +publishes the version, publication metadata, bundle ID, and hashes on the +snapshot document. Export failure leaves the old release unchanged. Copied +version histories are repaired by snapshot identity, not by the previous +version string; this and catalogue/counter reconciliation run even on +same-version retries. This makes an interrupted multi-document update +recoverable without MongoDB transactions. STIX 2.0 bytes do not include the +release tag, so only the STIX 2.1 digest changes on a version-only correction. + +History's repository projection and service summary both expose +`release_source_modified` so clients can identify retained source drafts. + - Determinism is unconditional: exporting a tagged snapshot replays pointers and never queries relationships, and a draft replays its inherited members graph. diff --git a/docs/user/release-tracks/api-reference.md b/docs/user/release-tracks/api-reference.md index bc4ad84e..d1669ee8 100644 --- a/docs/user/release-tracks/api-reference.md +++ b/docs/user/release-tracks/api-reference.md @@ -575,7 +575,10 @@ set. ### Release Latest Snapshot -Converts the latest draft snapshot to a tagged release. Tags the snapshot in-place (does not create new snapshot). Dynamically sets `x_mitre_version` based on the request body options. +Creates a tagged snapshot from the latest standard-track draft and retains the +exact source draft as its rollback point. The tagged snapshot has a new +`modified` timestamp and records the source in `release_source_modified`. +Virtual-track releases continue to tag their materialized draft in place. The request may also include an optional `description` (up to 4000 characters) to set the tagged snapshot's notes in the same operation: @@ -593,9 +596,9 @@ to set the tagged snapshot's notes in the same operation: `400 Bad Request` rather than choosing one - If both are omitted, defaults to a minor release - If this is the first release, the version will be `1.0` -- Relative increments use the nearest chronologically earlier tagged snapshot. - The result, or an explicit version, must also be lower than the nearest later - tagged snapshot when retroactively releasing a historical draft. +- Relative increments use the latest tagged release. Releasing a historical + standard draft still creates a new release at the current time, so its + version must follow the current release lineage. ``` POST /api/release-tracks/:id/snapshots/latest/release @@ -702,7 +705,9 @@ GET /api/release-tracks/:id/snapshots/2024-01-15T16:20:00.000Z?format=bundle ### Release/Tag Specific Snapshot -Converts a specific draft snapshot to a tagged release. Tags snapshot in-place (does not create new snapshot). +Releases a specific draft snapshot. Standard tracks create a new tagged +snapshot and preserve the selected draft; virtual tracks tag the selected +materialized draft in place. ``` POST /api/release-tracks/:id/snapshots/:modified/release @@ -710,6 +715,20 @@ POST /api/release-tracks/:id/snapshots/:modified/release **Request Body:** Same as [Release Latest Snapshot](#release-latest-snapshot). +### Change a Release Version + +``` +PUT /api/release-tracks/:id/snapshots/:modified/release +``` + +Administrators may correct the `MAJOR.MINOR` version of a tagged snapshot +without changing its `modified` identity, bundle ID, publication metadata, or +content. The replacement must remain strictly between the preceding and +following release versions. Copied version ledgers, the release catalogue, +bundle hashes, and the audit trail are updated. Virtual snapshots that already +resolved this release retain their exact `resolved_snapshot_id`; their stored +`resolved_version` remains the historical label observed at materialization. + ### Clone Specific Snapshot Bootstraps a new release track from the specified snapshot. @@ -809,14 +828,16 @@ DELETE /api/release-tracks/:id/snapshots/:modified?confirm_version=1.1 ``` Editors may delete the latest untagged draft; the track reverts to the -preceding snapshot. Administrators may also delete the track's most recent -release by confirming its version. The release's ledger entry is retracted -from every remaining snapshot so the version becomes available again, its -content manifest is discarded when nothing else references it, the registry -catalogue is reconciled, later drafts are kept, and a `delete_release` audit -event is recorded. Deleting an older release, or a release followed by a later -one, returns `409 Conflict`; a missing or wrong confirmation returns `400`; -a non-administrator receives `403`. +preceding snapshot. Administrators may roll back the most recent standard +release by confirming its version. The tagged clone is removed, revealing its +exact preserved source draft; the release ledger and catalogue are reconciled +and a `delete_release` audit event is recorded. Rollback returns `409 Conflict` +if any persisted virtual snapshot resolved the exact release (whether through +`latest_tagged` or an explicit rule), or if the release predates preserved +source drafts. Deleting an older release also returns `409`; a missing or wrong +confirmation returns `400`; a non-administrator receives `403`. +The newest virtual release retains the existing irreversible deletion +behavior because virtual materializations are still tagged in place. --- @@ -1095,6 +1116,7 @@ GET /api/release-tracks/:id/snapshots/latest/release/preview "track_id": "release-track--123", "type": "standard", "source_snapshot_modified": "2024-01-15T16:20:00.000Z", + "release_snapshot_modified": "2024-02-01T10:00:00.000Z", "version": "1.2", "version_bounds": { "lower": { "version": "1.1", "modified": "2024-01-01T12:00:00.000Z" }, @@ -1108,9 +1130,12 @@ GET /api/release-tracks/:id/snapshots/latest/release/preview } ``` +`release_snapshot_modified` is the new identity a standard release would +receive; for a virtual release it equals `source_snapshot_modified`. `version_bounds` reports the exclusive adjacent tagged releases used by both -relative and explicit selection. A historical draft can have both a `lower` -and an `upper` bound. +relative and explicit selection. A standard release is created at the current +time, so it ordinarily has no upper bound; a historical virtual draft can have +both bounds. `format=workbench` returns the complete would-be persisted snapshot. `format=bundle` returns its publication-ready STIX bundle. Thus “dry run” is @@ -1277,7 +1302,9 @@ Invalid version format or not greater than previous versions. **Status:** 409 Conflict -Tagged snapshots are immutable and cannot be deleted. +Tagged contents are immutable. Ordinary draft deletion cannot delete a tagged +snapshot; administrators use the guarded newest-release rollback described +above. ### NotFoundError diff --git a/docs/user/release-tracks/release-workflow.md b/docs/user/release-tracks/release-workflow.md index 66fbe35a..2b0967cc 100644 --- a/docs/user/release-tracks/release-workflow.md +++ b/docs/user/release-tracks/release-workflow.md @@ -564,6 +564,7 @@ GET /api/release-tracks/:id/snapshots/latest/release/preview "track_id": "release-track--123", "type": "standard", "source_snapshot_modified": "2024-01-15T16:20:00.000Z", + "release_snapshot_modified": "2024-02-01T10:00:00.000Z", "version": "1.2", "releasable": true, "before": { "members_count": 2, "staged_count": 3, "candidates_count": 1 }, @@ -579,6 +580,7 @@ GET /api/release-tracks/:id/snapshots/latest/release/preview "track_id": "release-track--123", "type": "standard", "source_snapshot_modified": "2024-01-15T16:20:00.000Z", + "release_snapshot_modified": "2024-02-01T10:00:00.000Z", "version": "1.2", "releasable": false, "before": { "members_count": 2, "staged_count": 3, "candidates_count": 1 }, diff --git a/docs/user/release-tracks/summary.md b/docs/user/release-tracks/summary.md index b78499d6..04517060 100644 --- a/docs/user/release-tracks/summary.md +++ b/docs/user/release-tracks/summary.md @@ -100,16 +100,19 @@ We borrow heavily concepts from git. Snapshots are sort of like commits and tagg - Every supported modification creates a replacement draft snapshot - Identified by `stix.modified` timestamp - Immutable once created -- Standard tracks retain one rolling untagged draft; tagged releases remain historical +- Standard tracks retain one active rolling draft plus the hidden source draft + for each tagged release; tagged releases remain historical - May be a **draft release** (untagged) or **tagged release** (has version number) **Tagged Releases** (like Git tags) - Snapshots are tagged with `version`, which when exported/retrieved as a STIX bundle, will be expressed as `x_mitre_version`. Draft snapshots are denoted by the fact that their `version` key is set to `null`. - Uses MAJOR.MINOR versioning (not MAJOR.MINOR.PATCH), as specified by the [`x_mitre_version` ADM schema](https://github.com/mitre-attack/attack-data-model/blob/f249442b3588de9cca84b819d480306b106d2c1f/src/schemas/common/property-schemas/attack-versioning.ts#L21:L26) -- Snapshots are tagged in-place (no duplicate data) +- Standard releases are tagged clones with exact rollback drafts; virtual + releases are tagged in place - When a snapshot is tagged/released, an event is captured in its `version_history` array -- Once a snapshot is tagged, it cannot be re-tagged. Tagged snapshots are **immutable**. +- Tagged content is **immutable**. Administrators may correct a release label + within semantic-version lineage constraints. ### 3. Three-Tier Workflow Integration with Version Pinning diff --git a/docs/user/release-tracks/terminology.md b/docs/user/release-tracks/terminology.md index 9b90dfb3..7d5d81e2 100644 --- a/docs/user/release-tracks/terminology.md +++ b/docs/user/release-tracks/terminology.md @@ -100,7 +100,7 @@ A **draft release** (or **draft snapshot**) is an untagged snapshot - still in d **Characteristics:** - No version number assigned - Not considered production-ready -- Can transition from draft to tagged state via tagging (in-place) operation +- Standard drafts are preserved when a tagged release clone is created - May contain candidate, staged, and member objects in various states **Examples:** @@ -117,13 +117,15 @@ A **tagged release** (or **tagged snapshot**) is a snapshot that has been marked **Technical Definition:** - A snapshot where `version !== null` - The version follows MAJOR.MINOR format (e.g., "1.0", "2.3", "15.1") -- Created by performing a tagging operation on a draft release -- The `stix.modified` timestamp does not change during tagging (in-place operation) +- Created from a draft release +- Standard releases receive a new `modified` timestamp and retain a link to + their exact source draft; virtual releases are tagged in place **Characteristics:** - Has an explicit version number - Considered production-ready and published -- **Immutable** - cannot be re-tagged or untagged +- Content is immutable; administrators may correct the semantic version or + roll back the newest standard release when no virtual snapshot depends on it - Recorded in `version_history` for audit trail - Analogous to a Git tag @@ -137,11 +139,11 @@ A **tagged release** (or **tagged snapshot**) is a snapshot that has been marked ### Tagging Operation -The **tagging operation** marks an existing snapshot as a tagged release by assigning it a version number. +The **tagging operation** publishes a draft by assigning a version number. **Technical Definition:** -- Sets `version` on an existing snapshot (in-place update) -- Does NOT create a new snapshot (does NOT change `modified`) +- For standard tracks, creates a tagged snapshot and preserves the source draft +- For virtual tracks, sets `version` on the materialized snapshot in place - Adds an entry to `version_history` for audit trail - Can be performed on the latest snapshot or a specific historical snapshot diff --git a/docs/user/release-tracks/versioning.md b/docs/user/release-tracks/versioning.md index ad8f3b2d..750adbf9 100644 --- a/docs/user/release-tracks/versioning.md +++ b/docs/user/release-tracks/versioning.md @@ -101,23 +101,41 @@ publication metadata, assigns a stable bundle identifier, and records SHA-256 hashes of both bundle serializations. A released snapshot is immutable, including its notes. -### In-Place Tagging Strategy +### Preserved Standard-Track Release Strategy When you release a snapshot: -1. The **existing** snapshot is updated in-place -2. `version` is set to the new version -3. An entry is added to `version_history` for audit trail -4. The `modified` timestamp **does not change** -5. For standard tracks, staged objects are promoted into `members` and a - content manifest is sealed over the result in the same atomic update - -**Why in-place?** - -- Avoids duplicate data (no need to copy the entire release track) -- Clear semantics: tagging is metadata, not a content change -- Snapshots remain immutable except for the version tag -- Matches Git's model where tags point to existing commits +1. The selected standard draft remains unchanged as the rollback point. +2. A new tagged snapshot is created with a new `modified` timestamp. +3. `release_source_modified` points to the exact source draft. +4. `version` and a matching `version_history` entry are added to the release. +5. Staged objects are promoted into `members` and a fresh content manifest is + sealed over the release. + +Virtual tracks still tag their already-materialized draft in place. Standard +tracks use a clone because rollback must restore notes, workflow tiers, +dynamic selectors, and manifest identity exactly as they existed immediately +before release. + +The preserved source draft is hidden from the normal Releases timeline while +its tagged clone exists. Rolling-draft cleanup does not prune it. + +Administrators can correct a tagged snapshot's version with `PUT +/snapshots/:modified/release`. This preserves snapshot identity and content, +while enforcing the adjacent semantic-version bounds. + +For a version-only correction, the STIX 2.1 SHA-256 changes because the +collection object contains `x_mitre_version`. The STIX 2.0 SHA-256 stays the +same: that format omits the collection object. The bundle ID is unchanged. +Hashes are generated before the version is changed and stored together with +the new version. If later history or catalogue updates fail, retry the same +PUT with the same version to finish them; a same-version request repairs +derived state rather than being a no-op. + +Virtual materialization holds the component release locks until its snapshot +is persisted. Concurrent release, rollback, retag, or materialization on a +shared component may return `409`; retry after the other operation finishes. +Once the virtual snapshot exists, rollback is blocked by its dependency. ### Tagging Endpoints @@ -193,18 +211,16 @@ POST /api/release-tracks/release--123/snapshots/latest/release POST /api/release-tracks/:id/snapshots/:modified/release ``` -Tags a specific snapshot as a tagged release. Can tag retroactively, (i.e., a non-latest snapshot can be tagged), granted no [versioning rules](#versioning-rules) are violated. +Publishes a specific draft. For a standard track, the server preserves that +draft and creates a tagged clone at the current time. **Use Cases:** -- You want to tag snapshot 3, then later also tag snapshot 5 -- You forgot to tag a snapshot and want to mark it retroactively -- You want to create multiple tagged releases from different development branches +- You want to release the content of an earlier retained draft +- You want to pin the operation to a snapshot rather than use `latest` -**Constraint:** The version must be greater than the nearest earlier tagged -snapshot and less than the nearest later tagged snapshot. Both bounds are -exclusive. This allows a forgotten historical draft to be tagged without -breaking the version order of the timeline. +**Constraint:** A standard release created from an earlier draft is not +backdated. Its version must be greater than the current latest release. ## Versioning Rules @@ -219,22 +235,21 @@ Collections use a **two-part versioning scheme** (MAJOR.MINOR), inspired by sema ### Version Constraints -1. **Chronologically increasing** - Tagged versions increase with snapshot - `modified` time. A retroactive tag is exclusively lower- and upper-bounded - by its adjacent tagged snapshots. -2. **Immutable once set** - Once a snapshot has `version` assigned, it cannot be changed -3. **Cannot re-tag** - A snapshot can only be tagged once (throws `AlreadyReleasedError` if attempted) +1. **Chronologically increasing** - Tagged versions increase with release + snapshot `modified` time. +2. **Immutable content** - Release contents and identity cannot be changed. + Administrators may correct the version within its adjacent bounds. +3. **Cannot release twice** - A draft already linked to a tagged standard + release cannot be released again. 4. **Valid version format** - Must match `/^\d+\.\d+$/` (MAJOR.MINOR only, no patch component) 5. **Unique within the track** - Exactly one snapshot may hold a given tagged version. If concurrent release requests race for the same version, one succeeds and the other receives `409 Conflict` with the conflicting `track_id` and `version`. -Relative `minor` and `major` increments are calculated from the nearest -earlier tagged snapshot, not from the numerically highest tag elsewhere in the -track. For example, a draft after explicit v19.1 previews as v19.2 for `minor` -and v20.0 for `major`. A historical draft between v1.0 and v3.0 previews as -v1.1 or v2.0 and may use any explicit version strictly inside that interval. +Relative `minor` and `major` increments are calculated from the latest tagged +release. For example, a track after explicit v19.1 previews as v19.2 for +`minor` and v20.0 for `major`, even when the selected source draft is older. ### First Tagged Release diff --git a/docs/user/release-tracks/workflow-examples.md b/docs/user/release-tracks/workflow-examples.md index d1d796f4..0d5d209f 100644 --- a/docs/user/release-tracks/workflow-examples.md +++ b/docs/user/release-tracks/workflow-examples.md @@ -26,19 +26,19 @@ POST /api/release-tracks/release--123/meta # 5. Ready for first release - staged objects become members POST /api/release-tracks/release--123/snapshots/latest/release { "increment": "major" } -# Updates: snapshot 4, version: "1.0" (in place) +# Preserves snapshot 4 and creates snapshot 5, version: "1.0" # 6. Continue development through the same candidate workflow POST /api/release-tracks/release--123/candidates { "object_refs": [{ "id": "malware--...", "modified": "latest" }] } POST /api/release-tracks/release--123/candidates/promote { "object_refs": ["malware--..."] } -# Creates snapshots 5 and 6 +# Creates snapshots 6 and 7 # 7. Minor release POST /api/release-tracks/release--123/snapshots/latest/release { "increment": "minor" } -# Updates: snapshot 6, version: "1.1" (in place) +# Preserves snapshot 7 and creates snapshot 8, version: "1.1" ``` **Resulting Timeline:** @@ -46,9 +46,11 @@ POST /api/release-tracks/release--123/snapshots/latest/release snapshot 1: initial empty draft snapshot 2: candidate added snapshot 3: candidate staged -snapshot 4: version "1.0" ← RELEASE -snapshot 5: next candidate added -snapshot 6: version "1.1" ← RELEASE +snapshot 4: preserved pre-1.0 draft +snapshot 5: version "1.0" ← RELEASE +snapshot 6: next candidate added +snapshot 7: preserved pre-1.1 draft +snapshot 8: version "1.1" ← RELEASE ``` ### Example 2: Selective Release Tagging @@ -60,7 +62,7 @@ POST /api/release-tracks/release--456/meta # draft 3 POST /api/release-tracks/release--456/meta # draft 4 POST /api/release-tracks/release--456/meta # draft 5 -# Tag draft 2 retroactively and then tag the latest draft +# Release draft 2 now and then release the latest remaining draft POST /api/release-tracks/release--456/snapshots//release { "version": "1.0" } @@ -71,13 +73,16 @@ POST /api/release-tracks/release--456/snapshots/latest/release **Resulting Timeline:** ``` snapshot 1: version: null (skipped) -snapshot 2: version: "1.0" ← RELEASE +snapshot 2: preserved pre-1.0 draft snapshot 3: version: null (skipped) snapshot 4: version: null (skipped) -snapshot 5: version: "1.1" ← RELEASE +snapshot 5: preserved pre-1.1 draft +snapshot 6: version: "1.0" ← RELEASE (created now from snapshot 2) +snapshot 7: version: "1.1" ← RELEASE (created now from snapshot 5) ``` -This mirrors Git's ability to tag any commit, not just the latest. +Selecting a historical draft does not backdate a release: its tagged clone is +created at the current time and must follow the current version lineage. ### Example 3: Handling Already-Released Snapshots