From f82e4b95f24ab8f0e43f54e22589e04c818aa0f2 Mon Sep 17 00:00:00 2001 From: Sean Sica <23294618+seansica@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:12:01 -0400 Subject: [PATCH 1/5] fix(relationships): reset related objects in place Saving a relationship resets its source and target objects to work-in-progress through PUT again instead of creating new revisions. Workflow state is workspace metadata, not STIX content, so the in-place update never touches a sealed revision, and the two extra object revisions (and the snapshot churn they triggered) per relationship save are gone. Co-Authored-By: Claude Fable 5.1 --- src/app/classes/stix/relationship.spec.ts | 29 ++++++++++++----------- src/app/classes/stix/relationship.ts | 7 +++--- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/app/classes/stix/relationship.spec.ts b/src/app/classes/stix/relationship.spec.ts index 9002419c..61804bf0 100644 --- a/src/app/classes/stix/relationship.spec.ts +++ b/src/app/classes/stix/relationship.spec.ts @@ -23,7 +23,7 @@ function rawObject(type: string, id: string, state: string) { } describe('Relationship revision workflow', () => { - it('creates WIP revisions of related SDOs after saving a relationship revision', async () => { + it('resets related SDOs to WIP in place after saving a relationship revision', async () => { const source = rawObject( 'intrusion-set', 'intrusion-set--00000000-0000-4000-8000-000000000001', @@ -55,16 +55,16 @@ describe('Relationship revision workflow', () => { calls.push('relationship:post'); return createAsyncObservable(value); }); - const postGroup = vi.fn((value: Group) => { - calls.push('source:post'); + const postGroup = vi.fn(); + const postMitigation = vi.fn(); + const putGroup = vi.fn((value: Group) => { + calls.push('source:put'); return createAsyncObservable(value); }); - const postMitigation = vi.fn((value: Mitigation) => { - calls.push('target:post'); + const putMitigation = vi.fn((value: Mitigation) => { + calls.push('target:put'); return createAsyncObservable(value); }); - const putGroup = vi.fn(); - const putMitigation = vi.fn(); const restApiService = { postRelationship, postGroup, @@ -75,15 +75,16 @@ describe('Relationship revision workflow', () => { await firstValueFrom(relationship.save(restApiService)); - expect(calls).toEqual(['relationship:post', 'source:post', 'target:post']); + expect(calls).toEqual(['relationship:post', 'source:put', 'target:put']); expect(postRelationship).toHaveBeenCalledWith(relationship); - expect(postGroup).toHaveBeenCalledOnce(); - expect(postMitigation).toHaveBeenCalledOnce(); - expect(postGroup.mock.calls[0][0].workflow?.state).toBe('work-in-progress'); - expect(postMitigation.mock.calls[0][0].workflow?.state).toBe( + expect(putGroup).toHaveBeenCalledOnce(); + expect(putMitigation).toHaveBeenCalledOnce(); + expect(putGroup.mock.calls[0][0].workflow?.state).toBe('work-in-progress'); + expect(putMitigation.mock.calls[0][0].workflow?.state).toBe( 'work-in-progress' ); - expect(putGroup).not.toHaveBeenCalled(); - expect(putMitigation).not.toHaveBeenCalled(); + // Workflow state is workspace metadata: no new SDO revisions are created. + expect(postGroup).not.toHaveBeenCalled(); + expect(postMitigation).not.toHaveBeenCalled(); }); }); diff --git a/src/app/classes/stix/relationship.ts b/src/app/classes/stix/relationship.ts index 91ec7b11..3c4d73e5 100644 --- a/src/app/classes/stix/relationship.ts +++ b/src/app/classes/stix/relationship.ts @@ -689,8 +689,9 @@ export class Relationship extends StixObject { } /** - * Creates a WIP revision of a related object. Existing revisions may be - * pinned by deterministic snapshot graphs and must remain immutable. + * Resets a related object's workflow state to WIP in place. Workflow state + * is workspace metadata, not STIX content, so a PUT never changes a sealed + * revision and never creates a new object revision. * @param restAPIService the rest api service * @param object the relationship source object */ @@ -704,6 +705,6 @@ export class Relationship extends StixObject { object.workflow = { state: WorkflowStatus.WorkInProgress }; } object.workflow.state = WorkflowStatus.WorkInProgress; - return object.save(restAPIService); + return object.update(restAPIService); } } From 013c38a5198ae29af8c22a9407bc33521f3078d3 Mon Sep 17 00:00:00 2001 From: Sean Sica <23294618+seansica@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:12:10 -0400 Subject: [PATCH 2/5] feat(release-tracks): surface sealed content and publication settings Align the release-track page with sealed content manifests: every snapshot now seals its content when members are written, so the bundle cache controls, cache status, and cache statistics are removed. Cards show content statistics; released snapshots show their stable bundle id and SHA-256 hashes; notes are editable on drafts only. Add a Publication section to track configuration: the publishing identity and collection markings inherit from organization settings unless overridden, with the resolved value and its source displayed, and collection id and created overrides that lock after the first release. Virtual tracks save publication settings after their composition. Rename the History tab to Releases, drop the per-snapshot Sealed chip, remove the dead Secondary Objects configuration section, and let administrators delete the most recent release from its card with a typed version confirmation. The release preview shows relationships sealed, added, dropped, and authored against other endpoint revisions. Fix Edit Config crashing on virtual tracks: the connector's identity and marking getters return functions that must be invoked as methods. Co-Authored-By: Claude Fable 5.1 --- docs/usage.md | 2 +- src/app/classes/release-tracks/api.ts | 41 +- src/app/classes/release-tracks/config.ts | 39 +- src/app/classes/release-tracks/snapshot.ts | 18 +- .../release-preview-dialog.component.html | 33 ++ .../release-preview-dialog.component.scss | 17 + .../release-preview-dialog.component.ts | 23 + .../rest-api/release-tracks.service.spec.ts | 40 +- .../rest-api/release-tracks.service.ts | 57 +- .../release-track-page.component.html | 350 +++++++----- .../release-track-page.component.scss | 27 + .../release-track-page.component.spec.ts | 316 ++++------- .../release-track-page.component.ts | 505 ++++++++++-------- 13 files changed, 793 insertions(+), 675 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 7d92911f..2a206e76 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -30,7 +30,7 @@ You can read more about the technical specifications for a collection, such as t The release preview offers minor and major relative tags as well as an exact `MAJOR.MINOR` version. Relative tags are calculated from the tagged snapshot immediately before the selected draft. When releasing an older draft, the exact version must also remain below the next tagged snapshot; the dialog shows these exclusive bounds. Optional release notes are stored on that snapshot and become the `x-mitre-collection` description in exported STIX bundles. -The release-track page can export the latest snapshot or a selected historical snapshot as a STIX 2.0 bundle, a STIX 2.1 bundle, or Workbench JSON. Historical snapshot exports can also copy a concise summary. Tagged snapshots with a bundle cache use that pinned member graph for deterministic member-only exports in either STIX version. +The release-track page can export the latest snapshot or a selected historical snapshot as a STIX 2.0 bundle, a STIX 2.1 bundle, or Workbench JSON. Historical snapshot exports can also copy a concise summary. Every snapshot seals its content when its members are written, so exports replay the exact members, relationships, and supporting objects in either STIX version; released snapshots also show their stable bundle identifier and SHA-256 hashes. Saving a relationship resets its source and target to work-in-progress in place without creating new revisions of those objects. Administrators can delete a track's most recent release from the Releases tab by confirming its version; its version becomes available again and later drafts are kept. Each cached snapshot card displays server-generated SHA-256 hashes for the exact UTF-8 JSON files produced by its STIX 2.0 and STIX 2.1 bundle downloads. The adjacent copy buttons copy a hash for external file-integrity verification. Snapshot notes are locked while the bundle is cached; delete the cache, edit the notes, and cache the bundle again to generate matching hashes. diff --git a/src/app/classes/release-tracks/api.ts b/src/app/classes/release-tracks/api.ts index af8470ae..d71b16be 100644 --- a/src/app/classes/release-tracks/api.ts +++ b/src/app/classes/release-tracks/api.ts @@ -14,8 +14,6 @@ export interface CreateReleaseTrackPayload { name: string; description?: string; snapshot_description?: string; - external_references?: any[]; - object_marking_refs?: string[]; type?: ReleaseTrackType; config?: ReleaseTrackConfig; composition?: Composition; @@ -31,8 +29,6 @@ export interface StixBundlePayload { export interface UpdateMetadataPayload { name?: string; description?: string; - external_references?: any[]; - object_marking_refs?: string[]; } export interface UpdateContentsPayload { @@ -65,7 +61,6 @@ export interface ReleaseTrackSnapshotOptions { include?: 'members' | 'staged' | 'candidates' | 'quarantine' | 'all'; state?: string | string[]; stixVersion?: '2.0' | '2.1'; - includeToc?: boolean; } export interface SnapshotHistoryOptions { @@ -74,7 +69,7 @@ export interface SnapshotHistoryOptions { offset?: number; } -export interface SnapshotGraphStatistics { +export interface SnapshotContentStatistics { primary_count: number; secondary_count: number; relationship_count: number; @@ -83,6 +78,33 @@ export interface SnapshotGraphStatistics { total_count: number; } +export interface SnapshotPublication { + collection_id: string; + created: string; + created_by_ref: string; + object_marking_refs: string[]; + attack_spec_version: string; +} + +export interface PreviewRelationshipChange { + object_ref: string; + object_modified: string; + relationship_type?: string; + source_ref?: string; + target_ref?: string; + stale_endpoints?: ('source' | 'target')[]; +} + +export interface PreviewRelationshipChanges { + selected_count: number; + added_count: number; + removed_count: number; + unchanged_count: number; + added: PreviewRelationshipChange[]; + removed: PreviewRelationshipChange[]; + stale_endpoints: PreviewRelationshipChange[]; +} + export type ReleasePreviewOptions = ReleasePayload & { format?: ReleasePreviewFormatType; }; @@ -115,6 +137,7 @@ export interface StandardReleasePreviewSummary extends ReleasePreviewSummaryBase changes: { promoted_count: number; }; + relationships?: PreviewRelationshipChanges; } export interface VirtualReleasePreviewSummary extends ReleasePreviewSummaryBase { @@ -152,9 +175,11 @@ export interface ReleaseTrackSnapshotHistoryItem { id?: string; modified?: string | Date; version?: string | null; - graph_manifest_id?: string; + content_manifest_id?: string; + publication?: SnapshotPublication; + bundle_id?: string; bundle_hashes?: SnapshotBundleHashes; - graph_statistics?: SnapshotGraphStatistics; + content_statistics?: SnapshotContentStatistics; snapshot_description?: string; type?: ReleaseTrackType; name?: string; diff --git a/src/app/classes/release-tracks/config.ts b/src/app/classes/release-tracks/config.ts index 4d8c6bbb..36c972b5 100644 --- a/src/app/classes/release-tracks/config.ts +++ b/src/app/classes/release-tracks/config.ts @@ -10,13 +10,42 @@ import type { MemberSyncStrategyType, } from './enums'; +export type InheritedIdentitySetting = + { inherit: true } | { inherit: false; value: string }; + +export type InheritedMarkingRefsSetting = + { inherit: true } | { inherit: false; value: string[] }; + +// Publication metadata for the emitted x-mitre-collection object. Each rule +// inherits the global system configuration unless overridden at the track +// scope. collection_id and created become immutable once the track has a +// tagged release. +export interface PublicationConfig { + collection_id?: string | null; + created?: string | null; + created_by_ref?: InheritedIdentitySetting; + object_marking_refs?: InheritedMarkingRefsSetting; +} + +export type PublicationSource = 'track' | 'global' | 'derived' | 'content'; + +export interface PublicationResolved { + collection_id: string; + created: string; + created_by_ref: string; + object_marking_refs: string[]; + attack_spec_version: string; + sources: { + collection_id: PublicationSource; + created: PublicationSource; + created_by_ref: PublicationSource; + object_marking_refs: PublicationSource; + }; +} + export interface ReleaseTrackConfig { candidacy_threshold?: WorkflowStatusType; auto_promote?: boolean; - include_secondary_objects?: { - enabled?: boolean; - status_threshold?: WorkflowStatusType; - }; promotion_conflicts?: { candidates_to_staged?: ConflictPolicyType; staged_to_members?: ConflictPolicyType; @@ -28,4 +57,6 @@ export interface ReleaseTrackConfig { status_policy?: MemberSyncPolicyType; }; }; + publication?: PublicationConfig; + publication_resolved?: PublicationResolved; } diff --git a/src/app/classes/release-tracks/snapshot.ts b/src/app/classes/release-tracks/snapshot.ts index 8af8a6bb..6bea1190 100644 --- a/src/app/classes/release-tracks/snapshot.ts +++ b/src/app/classes/release-tracks/snapshot.ts @@ -1,3 +1,4 @@ +import type { SnapshotBundleHashes, SnapshotPublication } from './api'; import { Composition, CompositionResolution } from './composition'; import { ReleaseTrackConfig } from './config'; import { ReleaseTrackType } from './enums'; @@ -21,7 +22,10 @@ export class ReleaseTrackSnapshot { public snapshot_description?: string; public created: Date = new Date(); public created_by_ref?: string; - public object_marking_refs?: string[]; + public content_manifest_id?: string; + public publication?: SnapshotPublication; + public bundle_id?: string; + public bundle_hashes?: SnapshotBundleHashes; public config: ReleaseTrackConfig = {} as ReleaseTrackConfig; public version_history: VersionHistoryEntry[] = []; @@ -96,8 +100,11 @@ export class ReleaseTrackSnapshot { this.snapshot_description = raw.snapshot_description; if ('created' in raw) this.created = new Date(raw.created); if ('created_by_ref' in raw) this.created_by_ref = raw.created_by_ref; - if ('object_marking_refs' in raw && Array.isArray(raw.object_marking_refs)) - this.object_marking_refs = raw.object_marking_refs.slice(); + if ('content_manifest_id' in raw) + this.content_manifest_id = raw.content_manifest_id; + if ('publication' in raw) this.publication = raw.publication; + if ('bundle_id' in raw) this.bundle_id = raw.bundle_id; + if ('bundle_hashes' in raw) this.bundle_hashes = raw.bundle_hashes; if ('config' in raw) this.config = raw.config; if ('summary' in raw) this.summary = raw.summary; @@ -196,7 +203,10 @@ export class ReleaseTrackSnapshot { snapshot_description: this.snapshot_description, created: this.created ? this.created.toISOString() : undefined, created_by_ref: this.created_by_ref, - object_marking_refs: this.object_marking_refs, + content_manifest_id: this.content_manifest_id, + publication: this.publication, + bundle_id: this.bundle_id, + bundle_hashes: this.bundle_hashes, config: this.config, summary: this.summary, version_history: this.version_history?.map(v => ({ diff --git a/src/app/components/release-preview-dialog/release-preview-dialog.component.html b/src/app/components/release-preview-dialog/release-preview-dialog.component.html index 0bff54c6..5f5d300f 100644 --- a/src/app/components/release-preview-dialog/release-preview-dialog.component.html +++ b/src/app/components/release-preview-dialog/release-preview-dialog.component.html @@ -73,6 +73,39 @@

+
+
+ {{ relationshipSelectedCount }} + Relationships sealed +
+
+ {{ relationshipAddedCount }} + Relationships added +
+
+ {{ relationshipRemovedCount }} + Relationships dropped +
+
+ {{ staleEndpointRelationships.length }} + Authored against other revisions +
+
+

+ + {{ staleEndpointRelationships.length }} relationship(s) will ship + against a newer revision of an endpoint than the one they were authored + on. Review them in the released bundle if their descriptions reference + revision-specific content. +

+ diff --git a/src/app/components/release-preview-dialog/release-preview-dialog.component.scss b/src/app/components/release-preview-dialog/release-preview-dialog.component.scss index fde05e2c..e9f532a5 100644 --- a/src/app/components/release-preview-dialog/release-preview-dialog.component.scss +++ b/src/app/components/release-preview-dialog/release-preview-dialog.component.scss @@ -439,3 +439,20 @@ } } } + +.release-summary--relationships { + margin-top: 0.75rem; +} + +.release-relationship-warning { + display: flex; + align-items: flex-start; + gap: 0.5rem; + margin: 0.75rem 0 0; + font-size: 0.9rem; + opacity: 0.85; + + mat-icon { + flex-shrink: 0; + } +} diff --git a/src/app/components/release-preview-dialog/release-preview-dialog.component.ts b/src/app/components/release-preview-dialog/release-preview-dialog.component.ts index 33f99038..59139383 100644 --- a/src/app/components/release-preview-dialog/release-preview-dialog.component.ts +++ b/src/app/components/release-preview-dialog/release-preview-dialog.component.ts @@ -170,6 +170,29 @@ export class ReleasePreviewDialogComponent { return this.data.previewSummary?.changes?.removed_count ?? 0; } + /** + * Relationship inventory the release commit would seal (standard tracks). + */ + public get relationshipChanges(): any | null { + return this.data.previewSummary?.relationships ?? null; + } + + public get relationshipSelectedCount(): number { + return this.relationshipChanges?.selected_count ?? 0; + } + + public get relationshipAddedCount(): number { + return this.relationshipChanges?.added_count ?? 0; + } + + public get relationshipRemovedCount(): number { + return this.relationshipChanges?.removed_count ?? 0; + } + + public get staleEndpointRelationships(): any[] { + return this.relationshipChanges?.stale_endpoints ?? []; + } + public get quarantinedObjectCount(): number { return this.data.previewSummary?.changes?.quarantined_count ?? 0; } diff --git a/src/app/services/connectors/rest-api/release-tracks.service.spec.ts b/src/app/services/connectors/rest-api/release-tracks.service.spec.ts index a3f4c924..d8a66451 100644 --- a/src/app/services/connectors/rest-api/release-tracks.service.spec.ts +++ b/src/app/services/connectors/rest-api/release-tracks.service.spec.ts @@ -129,42 +129,22 @@ describe('ReleaseTracksConnectorService', () => { expect(options.params.get('offset')).toBe('50'); }); - it('should create a deterministic graph for an exact snapshot', async () => { - const snapshot = { - modified: '2026-07-23T13:37:28.000Z', - version: '1.0', - graph_manifest_id: 'release-track-graph-manifest--123', - }; - http.post.mockReturnValue(of(snapshot)); - - const result = await firstValueFrom( - service.createSnapshotGraph( - 'release-track--standard', - '2026-07-23T13:37:28.000Z' - ) - ); - - expect(http.post).toHaveBeenCalledWith( - `${environment.integrations.rest_api.url}/release-tracks/release-track--standard/snapshots/2026-07-23T13%3A37%3A28.000Z/graph`, - {} - ); - expect(result).toEqual(snapshot); - }); - - it('should delete a deterministic graph for an exact snapshot', async () => { + it('should pass the release confirmation when deleting a release', () => { http.delete.mockReturnValue(of(undefined)); - const result = await firstValueFrom( - service.deleteSnapshotGraph( + service + .deleteSnapshotByModified( 'release-track--standard', - '2026-07-23T13:37:28.000Z' + '2026-07-23T13:37:28.000Z', + { confirmVersion: '1.1' } ) - ); + .subscribe(); - expect(http.delete).toHaveBeenCalledWith( - `${environment.integrations.rest_api.url}/release-tracks/release-track--standard/snapshots/2026-07-23T13%3A37%3A28.000Z/graph` + const [url, options] = http.delete.mock.calls[0]; + expect(url).toBe( + `${environment.integrations.rest_api.url}/release-tracks/release-track--standard/snapshots/2026-07-23T13%3A37%3A28.000Z` ); - expect(result).toBeUndefined(); + expect(options.params.get('confirm_version')).toBe('1.1'); }); it('should create virtual snapshots through the virtual namespace', () => { diff --git a/src/app/services/connectors/rest-api/release-tracks.service.ts b/src/app/services/connectors/rest-api/release-tracks.service.ts index 6a5189aa..0d6224fd 100644 --- a/src/app/services/connectors/rest-api/release-tracks.service.ts +++ b/src/app/services/connectors/rest-api/release-tracks.service.ts @@ -473,55 +473,6 @@ export class ReleaseTracksConnectorService extends ApiConnector { ); } - /** - * POST /api/release-tracks/:id/snapshots/:modified/graph - * Materialize the deterministic member graph for a tagged snapshot. - * @param id Release track id - * @param modified Snapshot modified timestamp - * @returns Observable snapshot containing its opaque graph manifest id - */ - public createSnapshotGraph( - id: string, - modified: string - ): Observable { - const url = `${this.apiUrl}/release-tracks/${id}/snapshots/${encodeURIComponent(modified)}/graph`; - return this.http.post(url, {}).pipe( - tap(result => - logger.log( - `created deterministic graph for snapshot ${modified}`, - result - ) - ), - catchError( - this.handleError_raise(false) - ), - share() - ); - } - - /** - * DELETE /api/release-tracks/:id/snapshots/:modified/graph - * Remove the deterministic member graph from a tagged snapshot. - * @param id Release track id - * @param modified Snapshot modified timestamp - * @returns Observable - */ - public deleteSnapshotGraph( - id: string, - modified: string - ): Observable { - const url = `${this.apiUrl}/release-tracks/${id}/snapshots/${encodeURIComponent(modified)}/graph`; - return this.http.delete(url).pipe( - tap(() => - logger.log( - `deleted deterministic graph for snapshot ${modified} from track ${id}` - ) - ), - catchError(this.handleError_raise()), - share() - ); - } - /** * POST /api/release-tracks/:id/snapshots/:modified/clone * Clone a new release track from a specific snapshot. @@ -552,10 +503,14 @@ export class ReleaseTracksConnectorService extends ApiConnector { */ public deleteSnapshotByModified( id: string, - modified: string + modified: string, + options?: { confirmVersion?: string } ): Observable { const url = `${this.apiUrl}/release-tracks/${id}/snapshots/${encodeURIComponent(modified)}`; - return this.http.delete(url).pipe( + const params = options?.confirmVersion + ? this.buildHttpParams({ confirm_version: options.confirmVersion }) + : undefined; + return this.http.delete(url, params ? { params } : {}).pipe( tap(() => logger.log(`deleted snapshot ${modified} from track ${id}`)), catchError(this.handleError_raise()), share() diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html index a5c56555..8d3893d5 100644 --- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html @@ -38,7 +38,7 @@

[showNotes]="false" [showMembership]="false" [customTabs]="[ - { label: 'History', template: historyTpl }, + { label: 'Releases', template: historyTpl }, { label: 'Config', template: configTpl }, ]"> @@ -660,19 +660,6 @@

- - - {{ - item.isBundleCached ? 'Bundle cached' : 'Not cached' - }} -

@@ -706,17 +693,10 @@

- - +
@@ -818,19 +773,19 @@

+ aria-label="Content statistics">
- Graph cache + Content
- {{ item.graphCacheTotal }} cached items + {{ item.contentTotal }} objects
Bundle SHA-256 + + {{ item.snapshot.bundle_id }} +
@@ -1491,84 +1452,193 @@

Conflict Resolution

+ -
-
-

Secondary Objects

+
+
+

Publication

+

+ Metadata for the x-mitre-collection object emitted in + STIX 2.1 bundles. Each value inherits the organization settings + unless overridden for this track. Values are frozen on every + released snapshot. +

-
-
-
Include Secondary Objects
-

- Includes supporting objects when they meet the configured - workflow state threshold. -

-
- - - - - - {{ - configForm.get('includeSecondaryObjects')?.value - ? 'ON' - : 'OFF' - }} - - +
+
+
Publishing Identity
+

+ Becomes created_by_ref on the collection object + and ships in the bundle as a supporting identity. +

+

+ {{ + formatPublicationSource( + publicationResolved.sources.created_by_ref + ) + }} +

+ +
+ + Inherit organization identity + + + Identity + + + {{ option.label }} + + + +
+
+ + {{ publicationIdentityLabel }} + +
- + -
-
-
Secondary Object Threshold
-

- Defines the minimum workflow state for automatically - included supporting objects. -

-
- - - Secondary Object Threshold - +
+
+
Collection Markings
+

+ Becomes object_marking_refs on the collection + object. When neither scope configures markings, the object + carries the markings referenced by its contents. +

+

+ {{ + formatPublicationSource( + publicationResolved.sources.object_marking_refs + ) + }} +

+
+ +
+ + Inherit default markings + + + Marking definitions + - {{ formatConfigOption(option) }} + *ngFor="let option of publicationMarkingOptions" + [value]="option.id"> + {{ option.label }} - - - +
+
+ + + + {{ publicationMarkingLabels.join(', ') }} + + Derived from contents + + +
+ + + +
+
+
Collection ID
+

+ Stable identifier of the collection object across every + snapshot of this track. Set the canonical ATT&CK value + before the first release when this track replaces a legacy + domain bundle; it cannot change afterwards. +

+

+ {{ + formatPublicationSource( + publicationResolved.sources.collection_id + ) + }} +

+
+ + + Collection ID + + + + + {{ + publicationResolved?.collection_id + }} + +
+ + + +
+
+
Collection Created
+

+ The collection object's created timestamp. + Defaults to when this track was created and cannot change + after the first release. +

+

+ {{ + formatPublicationSource(publicationResolved.sources.created) + }} +

+
+ + + Created (ISO 8601) + + + + + + {{ - formatConfigOption( - configForm.get('secondaryObjectThreshold')?.value - ) + publicationResolved?.created | date: 'MMM d, y, h:mm:ss a' }} - - -
+
+ +
-
- +
+
diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.scss b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.scss index 48a62283..1bbb004d 100644 --- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.scss +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.scss @@ -1828,3 +1828,30 @@ $released-members-disabled-dark: colors.on-color-deemphasis(dark); } } } + +// Publication configuration and sealed-content additions +.config-section-intro { + margin: 0 0 1rem; + color: var(--text-secondary, rgba(0, 0, 0, 0.6)); + font-size: 0.9rem; +} + +.config-source { + margin-top: 0.25rem; + font-size: 0.8rem; + opacity: 0.75; +} + +.config-inherit-editor { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.5rem; +} + +.snapshot-bundle-id { + margin-left: auto; + font-size: 0.8rem; + opacity: 0.8; + word-break: break-all; +} diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts index 0f958c2e..6824474e 100644 --- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts @@ -10,7 +10,7 @@ import { createPaginatedResponse, } from 'src/app/testing/mocks/rest-api-connector.mock'; import { ActivatedRoute, Router } from '@angular/router'; -import { of, Subject, throwError } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; import { BreadcrumbService } from 'src/app/services/helpers/breadcrumb.service'; @@ -19,7 +19,6 @@ import { MultipleChoiceDialogComponent } from 'src/app/components/multiple-choic import { AddDialogComponent } from 'src/app/components/add-dialog/add-dialog.component'; import { DeleteDialogComponent } from 'src/app/components/delete-dialog/delete-dialog.component'; import { ReleasePreviewDialogComponent } from 'src/app/components/release-preview-dialog/release-preview-dialog.component'; -import { ConfirmationDialogComponent } from 'src/app/components/confirmation-dialog/confirmation-dialog.component'; import { SnapshotDescriptionDialogComponent } from 'src/app/components/snapshot-description-dialog/snapshot-description-dialog.component'; import { AuthenticationService } from 'src/app/services/connectors/authentication/authentication.service'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; @@ -63,8 +62,6 @@ describe('ReleaseTrackPageComponent', () => { previewRelease: vi.fn(() => createAsyncObservable({})), releaseLatest: vi.fn(() => createAsyncObservable({})), releaseSnapshot: vi.fn(() => createAsyncObservable({})), - createSnapshotGraph: vi.fn(() => createAsyncObservable({})), - deleteSnapshotGraph: vi.fn(() => createAsyncObservable(undefined)), getConfig: vi.fn(() => createAsyncObservable(null)), updateConfig: vi.fn(() => createAsyncObservable({})), updateComposition: vi.fn(() => createAsyncObservable({})), @@ -72,6 +69,7 @@ describe('ReleaseTrackPageComponent', () => { updateMetadataByLatest: vi.fn(() => createAsyncObservable({})), addCandidates: vi.fn(() => createAsyncObservable({})), deleteReleaseTrack: vi.fn(() => createAsyncObservable({})), + deleteSnapshotByModified: vi.fn(() => createAsyncObservable(undefined)), }); mockDialog = { open: vi.fn(), @@ -94,6 +92,7 @@ describe('ReleaseTrackPageComponent', () => { }; mockAuthenticationService = { canEdit: vi.fn(() => true), + canDelete: vi.fn(() => true), }; await TestBed.configureTestingModule({ @@ -371,7 +370,6 @@ describe('ReleaseTrackPageComponent', () => { modified: '2024-04-15T06:00:00.000Z', taggedAt: new Date('2024-04-15T06:30:00.000Z'), isTagged: true, - isBundleCached: true, stats: [], addedCount: 0, modifiedCount: 0, @@ -498,8 +496,9 @@ describe('ReleaseTrackPageComponent', () => { members_count: 120, staged_count: 4, candidates_count: 9, - graph_manifest_id: 'release-track-graph-manifest--cached', - graph_statistics: { + content_manifest_id: 'release-track-graph-manifest--sealed', + bundle_id: 'bundle--release', + content_statistics: { primary_count: 120, secondary_count: 18, relationship_count: 42, @@ -514,10 +513,9 @@ describe('ReleaseTrackPageComponent', () => { taggedAt: new Date('2024-04-15T06:30:00.000Z'), isTagged: true, isLatest: false, - isBundleCached: true, stats: [], - graphCacheStats: [], - graphCacheTotal: 185, + contentStats: [], + contentTotal: 185, addedCount: 0, modifiedCount: 0, totalObjects: 120, @@ -554,9 +552,8 @@ describe('ReleaseTrackPageComponent', () => { staged: 4, candidates: 9, }, - graph_cache: { - cached: true, - manifest_id: 'release-track-graph-manifest--cached', + content: { + manifest_id: 'release-track-graph-manifest--sealed', statistics: { primary_count: 120, secondary_count: 18, @@ -566,6 +563,7 @@ describe('ReleaseTrackPageComponent', () => { total_count: 185, }, }, + bundle_id: 'bundle--release', }); expect(mockSnackbar.open).toHaveBeenCalledWith( 'Snapshot summary copied to the clipboard.', @@ -593,7 +591,6 @@ describe('ReleaseTrackPageComponent', () => { modified: '2024-05-21T07:00:00.000Z', isTagged: false, isLatest: true, - isBundleCached: false, } as any); expect(mockSnackbar.open).toHaveBeenCalledWith( @@ -837,14 +834,14 @@ describe('ReleaseTrackPageComponent', () => { { id: 'release-track--456', version: '1.3', - graph_manifest_id: 'release-track-graph-manifest--cached', + content_manifest_id: 'release-track-graph-manifest--sealed', tagged_at: '2024-04-15T06:30:00.000Z', modified: '2024-04-15T06:00:00.000Z', type: ReleaseTrackType.Virtual, name: 'Combined', members_count: 5, quarantine_count: 1, - graph_statistics: { + content_statistics: { primary_count: 5, secondary_count: 8, relationship_count: 12, @@ -871,8 +868,8 @@ describe('ReleaseTrackPageComponent', () => { modifiedCount: 1, totalObjects: 27, isTagged: false, - isBundleCached: false, - canCacheBundle: false, + contentStats: [], + contentTotal: 0, stats: [ expect.objectContaining({ label: 'Added', value: '+2' }), expect.objectContaining({ label: 'Modified', value: 1 }), @@ -890,14 +887,12 @@ describe('ReleaseTrackPageComponent', () => { totalObjects: 6, taggedAt: new Date('2024-04-15T06:30:00.000Z'), isTagged: true, - isBundleCached: true, - canCacheBundle: false, - graphCacheTotal: 28, - graphCacheStats: [ - expect.objectContaining({ label: 'Primary', value: 5 }), - expect.objectContaining({ label: 'Secondary', value: 8 }), + contentTotal: 28, + contentStats: [ + expect.objectContaining({ label: 'Members', value: 5 }), expect.objectContaining({ label: 'Relationships', value: 12 }), expect.objectContaining({ label: 'Dependencies', value: 3 }), + expect.objectContaining({ label: 'Legacy secondary', value: 8 }), ], stats: [ expect.objectContaining({ label: 'Members', value: 5 }), @@ -909,118 +904,83 @@ describe('ReleaseTrackPageComponent', () => { expect(component.hasCurrentDraftSnapshot).toBe(true); }); - it('should explain cached, uncached, and draft bundle states', () => { - const cached = { - isTagged: true, - isBundleCached: true, - } as any; - const uncached = { - isTagged: true, - isBundleCached: false, - } as any; - const draft = { - isTagged: false, - isBundleCached: false, - } as any; - - expect(component.getBundleCacheTooltip(cached)).toContain( - 'repeated exports are deterministic' - ); - expect(component.getBundleCacheTooltip(uncached)).toContain( - 'not guaranteed to be deterministic' - ); - expect(component.getBundleCacheTooltip(draft)).toContain( - 'Tag this snapshot before caching it' - ); - }); - - it('should cache a tagged snapshot and update its history state', () => { + it('should delete the most recent release after typed confirmation', () => { const item = { - snapshot: {}, - title: 'v1.0', + snapshot: { + version: '1.1', + content_manifest_id: 'release-track-content-manifest--x', + }, + title: 'v1.1', modified: '2026-07-23T13:37:28.000Z', isTagged: true, - isBundleCached: false, - canCacheBundle: true, - stats: [], } as any; - mockReleaseTrackApiConnector.createSnapshotGraph.mockReturnValue( - of({ - modified: item.modified, - version: '1.0', - graph_manifest_id: 'release-track-graph-manifest--cached', - bundle_hashes: { - manifest_id: 'release-track-graph-manifest--cached', - stix_2_0: 'a'.repeat(64), - stix_2_1: 'b'.repeat(64), - }, - }) + mockDialog.open.mockReturnValue({ afterClosed: () => of(true) }); + mockReleaseTrackApiConnector.deleteSnapshotByModified.mockReturnValue( + of(undefined) ); + const trackSpy = vi + .spyOn(component, 'getReleaseTrack') + .mockImplementation(() => undefined); + const historySpy = vi + .spyOn(component, 'getSnapshotHistory') + .mockImplementation(() => undefined); component.id = 'release-track--123'; - component.onCacheSnapshotBundle(item); + component.onDeleteRelease(item); - expect( - mockReleaseTrackApiConnector.createSnapshotGraph - ).toHaveBeenCalledWith('release-track--123', item.modified); - expect(item.snapshot.graph_manifest_id).toBe( - 'release-track-graph-manifest--cached' - ); - expect(component.getSnapshotBundleHash(item, '2.0')).toBe('a'.repeat(64)); - expect(component.getSnapshotBundleHash(item, '2.1')).toBe('b'.repeat(64)); - expect(item.isBundleCached).toBe(true); - expect(item.canCacheBundle).toBe(false); - expect(mockReleaseTrackApiConnector.listSnapshots).toHaveBeenCalledWith( - 'release-track--123' + expect(mockDialog.open).toHaveBeenCalledWith( + DeleteDialogComponent, + expect.objectContaining({ + data: expect.objectContaining({ + title: 'Delete release 1.1?', + stixId: '1.1', + }), + }) ); + expect( + mockReleaseTrackApiConnector.deleteSnapshotByModified + ).toHaveBeenCalledWith('release-track--123', item.modified, { + confirmVersion: '1.1', + }); + expect(trackSpy).toHaveBeenCalled(); + expect(historySpy).toHaveBeenCalled(); expect(mockSnackbar.open).toHaveBeenCalledWith( - 'Bundle cached. Member-only bundle exports are now deterministic.', + 'Release 1.1 deleted.', null, - expect.objectContaining({ duration: 5000 }) + { duration: 5000 } ); - expect(component.isCachingSnapshot(item)).toBe(false); }); - it('should expose cache materialization as in progress until it completes', () => { - const graphResult = new Subject(); - const item = { - snapshot: {}, - title: 'v1.0', + it('should not offer release deletion to non-administrators or for drafts', () => { + mockAuthenticationService.canDelete.mockReturnValue(false); + component.id = 'release-track--123'; + component.onDeleteRelease({ + snapshot: { version: '1.0' }, modified: '2026-07-23T13:37:28.000Z', isTagged: true, - isBundleCached: false, - canCacheBundle: true, - stats: [], - } as any; - mockReleaseTrackApiConnector.createSnapshotGraph.mockReturnValue( - graphResult - ); - component.id = 'release-track--123'; - - component.onCacheSnapshotBundle(item); - - expect(component.isCachingSnapshot(item)).toBe(true); - - graphResult.next({ - modified: item.modified, - version: '1.0', - graph_manifest_id: 'release-track-graph-manifest--cached', - }); - graphResult.complete(); + } as any); + mockAuthenticationService.canDelete.mockReturnValue(true); + component.onDeleteRelease({ + snapshot: {}, + modified: '2026-07-23T13:37:28.000Z', + isTagged: false, + } as any); - expect(component.isCachingSnapshot(item)).toBe(false); + expect(mockDialog.open).not.toHaveBeenCalled(); + expect( + mockReleaseTrackApiConnector.deleteSnapshotByModified + ).not.toHaveBeenCalled(); }); - it('should edit notes on a snapshot without a bundle cache', () => { + it('should edit notes on a draft snapshot', () => { const modified = '2026-07-23T13:37:28.000Z'; const item = { snapshot: { snapshot_description: 'Original context', }, - title: 'v1.0', + title: 'Draft Snapshot', modified, - isTagged: true, - isBundleCached: false, + isTagged: false, } as any; mockDialog.open.mockReturnValue({ afterClosed: () => of('Updated analyst context'), @@ -1066,16 +1026,15 @@ describe('ReleaseTrackPageComponent', () => { ); }); - it('should not open the notes editor for a cached snapshot', () => { + it('should not open the notes editor for a released snapshot', () => { const item = { snapshot: { - graph_manifest_id: 'release-track-graph-manifest--cached', + content_manifest_id: 'release-track-graph-manifest--sealed', snapshot_description: 'Frozen release notes', }, title: 'v1.0', modified: '2026-07-23T13:37:28.000Z', isTagged: true, - isBundleCached: true, } as any; component.id = 'release-track--123'; @@ -1091,9 +1050,9 @@ describe('ReleaseTrackPageComponent', () => { const modified = '2026-07-23T13:37:28.000Z'; const item = { snapshot: { - graph_manifest_id: 'release-track-graph-manifest--cached', + content_manifest_id: 'release-track-graph-manifest--sealed', bundle_hashes: { - manifest_id: 'release-track-graph-manifest--cached', + manifest_id: 'release-track-graph-manifest--sealed', stix_2_0: 'a'.repeat(64), stix_2_1: 'b'.repeat(64), }, @@ -1113,66 +1072,6 @@ describe('ReleaseTrackPageComponent', () => { ); }); - it('should delete a cached snapshot graph after confirmation', () => { - const item = { - snapshot: { - graph_manifest_id: 'release-track-graph-manifest--cached', - graph_statistics: { total_count: 28 }, - bundle_hashes: { - manifest_id: 'release-track-graph-manifest--cached', - stix_2_0: 'a'.repeat(64), - stix_2_1: 'b'.repeat(64), - }, - }, - title: 'v1.0', - modified: '2026-07-23T13:37:28.000Z', - isTagged: true, - isBundleCached: true, - canCacheBundle: false, - graphCacheStats: [{ label: 'Primary', value: 5 }], - graphCacheTotal: 28, - stats: [], - } as any; - mockDialog.open.mockReturnValue({ - afterClosed: () => of(true), - }); - mockReleaseTrackApiConnector.deleteSnapshotGraph.mockReturnValue( - of(undefined) - ); - component.id = 'release-track--123'; - - component.onDeleteSnapshotCache(item); - - expect(mockDialog.open).toHaveBeenCalledWith( - ConfirmationDialogComponent, - expect.objectContaining({ - data: expect.objectContaining({ - title: 'Delete bundle cache?', - confirm_color: 'warn', - }), - }) - ); - expect( - mockReleaseTrackApiConnector.deleteSnapshotGraph - ).toHaveBeenCalledWith('release-track--123', item.modified); - expect(item.snapshot.graph_manifest_id).toBeUndefined(); - expect(item.snapshot.graph_statistics).toBeUndefined(); - expect(item.snapshot.bundle_hashes).toBeUndefined(); - expect(item.isBundleCached).toBe(false); - expect(item.canCacheBundle).toBe(true); - expect(item.graphCacheStats).toEqual([]); - expect(item.graphCacheTotal).toBe(0); - expect(mockReleaseTrackApiConnector.listSnapshots).toHaveBeenCalledWith( - 'release-track--123' - ); - expect(mockSnackbar.open).toHaveBeenCalledWith( - 'Bundle cache deleted. Member-only bundle exports are no longer guaranteed to be deterministic.', - null, - expect.objectContaining({ duration: 5000 }) - ); - expect(component.isDeletingSnapshotCache(item)).toBe(false); - }); - it('should use latest snapshot summary counts for the latest history row', () => { component.releaseTrack = { modified: new Date('2024-05-21T07:00:00.000Z'), @@ -1867,10 +1766,6 @@ describe('ReleaseTrackPageComponent', () => { status_policy: 'reset', }, }, - include_secondary_objects: { - enabled: true, - status_threshold: 'work-in-progress', - }, }) ); component.id = 'release-track--123'; @@ -1889,45 +1784,11 @@ describe('ReleaseTrackPageComponent', () => { memberSyncSupplantStatusPolicy: 'reset', candidatesToStagedConflict: 'always_reject', stagedToMembersConflict: 'abort', - includeSecondaryObjects: true, - secondaryObjectThreshold: 'work-in-progress', }) ); expect(component.configForm.get('candidacyThreshold')?.disabled).toBe(true); }); - it('should disable the secondary object threshold when secondary objects are not included', () => { - mockReleaseTrackApiConnector.getConfig.mockReturnValue( - of({ - include_secondary_objects: { - enabled: false, - status_threshold: 'awaiting-review', - }, - }) - ); - component.id = 'release-track--123'; - - component.getConfig(); - - expect(component.configForm.getRawValue()).toEqual( - expect.objectContaining({ - includeSecondaryObjects: false, - secondaryObjectThreshold: 'awaiting-review', - }) - ); - expect(component.configForm.get('secondaryObjectThreshold')?.disabled).toBe( - true - ); - - component.configForm.patchValue({ - includeSecondaryObjects: true, - }); - - expect(component.configForm.get('secondaryObjectThreshold')?.enabled).toBe( - true - ); - }); - it('should save release track config and refresh state', () => { const refreshSpy = vi .spyOn(component, 'getReleaseTrack') @@ -1946,8 +1807,6 @@ describe('ReleaseTrackPageComponent', () => { memberSyncSupplantStatusPolicy: MemberSyncPolicy.Preserve, candidatesToStagedConflict: ConflictPolicy.PreferLatest, stagedToMembersConflict: ConflictPolicy.Abort, - includeSecondaryObjects: false, - secondaryObjectThreshold: 'reviewed', }); component.isEditingConfig = true; @@ -1958,14 +1817,16 @@ describe('ReleaseTrackPageComponent', () => { { auto_promote: true, candidacy_threshold: 'reviewed', - include_secondary_objects: { - enabled: false, - status_threshold: 'reviewed', - }, promotion_conflicts: { candidates_to_staged: 'prefer_latest', staged_to_members: 'abort', }, + publication: { + collection_id: null, + created: null, + created_by_ref: { inherit: true }, + object_marking_refs: { inherit: true }, + }, member_sync: { strategy: 'manual', supplant: { @@ -2060,6 +1921,7 @@ describe('ReleaseTrackPageComponent', () => { .spyOn(component, 'getSnapshotHistory') .mockImplementation(() => undefined); mockReleaseTrackApiConnector.updateComposition.mockReturnValue(of({})); + mockReleaseTrackApiConnector.updateConfig.mockReturnValue(of({})); component.id = 'release-track--virtual'; component.releaseTrack = { type: ReleaseTrackType.Virtual, @@ -2106,7 +1968,19 @@ describe('ReleaseTrackPageComponent', () => { }, } ); - expect(mockReleaseTrackApiConnector.updateConfig).not.toHaveBeenCalled(); + // Publication settings apply to virtual tracks too and are saved after + // the composition. + expect(mockReleaseTrackApiConnector.updateConfig).toHaveBeenCalledWith( + 'release-track--virtual', + { + publication: { + collection_id: null, + created: null, + created_by_ref: { inherit: true }, + object_marking_refs: { inherit: true }, + }, + } + ); expect(component.isEditingConfig).toBe(false); expect(refreshSpy).toHaveBeenCalled(); expect(historySpy).toHaveBeenCalled(); diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts index d94af7ea..b658a12c 100644 --- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts @@ -6,7 +6,7 @@ import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; import { ActivatedRoute, Router } from '@angular/router'; import { forkJoin, Observable, of } from 'rxjs'; -import { finalize, map, take } from 'rxjs/operators'; +import { finalize, map, switchMap, take } from 'rxjs/operators'; import { ConflictPolicy, ConflictPolicyType, @@ -22,6 +22,9 @@ import { MemberSyncStrategyType, ReleasePayload, ReleasePreviewFormat, + PublicationConfig, + PublicationResolved, + PublicationSource, ReleaseTrackConfig, ReleaseTrackSnapshot, ReleaseTrackSnapshotHistoryItem, @@ -36,7 +39,6 @@ import { } from 'src/app/classes/release-tracks'; import { StixObject } from 'src/app/classes/stix'; import { AddDialogComponent } from 'src/app/components/add-dialog/add-dialog.component'; -import { ConfirmationDialogComponent } from 'src/app/components/confirmation-dialog/confirmation-dialog.component'; import { DeleteDialogComponent } from 'src/app/components/delete-dialog/delete-dialog.component'; import { MultipleChoiceDialogComponent } from 'src/app/components/multiple-choice-dialog/multiple-choice-dialog.component'; import { @@ -100,11 +102,9 @@ interface SnapshotHistoryViewModel { isTagged: boolean; isLatest: boolean; isCurrentDraft: boolean; - isBundleCached: boolean; - canCacheBundle: boolean; stats: SnapshotHistoryStat[]; - graphCacheStats: SnapshotHistoryStat[]; - graphCacheTotal: number; + contentStats: SnapshotHistoryStat[]; + contentTotal: number; addedCount: number; modifiedCount: number; totalObjects: number; @@ -125,8 +125,17 @@ interface ReleaseTrackConfigFormValue { memberSyncSupplantStatusPolicy: MemberSyncPolicyType; candidatesToStagedConflict: ConflictPolicyType; stagedToMembersConflict: ConflictPolicyType; - includeSecondaryObjects: boolean; - secondaryObjectThreshold: WorkflowStatusType; + publicationCollectionId: string; + publicationCreated: string; + publicationIdentityInherit: boolean; + publicationIdentityValue: string; + publicationMarkingsInherit: boolean; + publicationMarkingsValue: string[]; +} + +interface PublicationOption { + id: string; + label: string; } interface VirtualReleaseTrackConfigFormValue { @@ -215,9 +224,11 @@ export class ReleaseTrackPageComponent implements OnInit { public virtualComponentTrackOptions: VirtualComponentTrackOption[] = []; public virtualConfigComponentTracks: any[] = []; private createdDraftSnapshot: ReleaseTrackSnapshotHistoryItem | null = null; - private cachingSnapshotModified = new Set(); - private deletingSnapshotCacheModified = new Set(); private updatingSnapshotDescriptionModified = new Set(); + private deletingReleaseModified = new Set(); + public publicationResolved: PublicationResolved | null = null; + public publicationIdentityOptions: PublicationOption[] = []; + public publicationMarkingOptions: PublicationOption[] = []; public candidacyOptions = Object.values(WorkflowStatus); public memberSyncStrategyOptions = Object.values(MemberSyncStrategy); @@ -258,8 +269,12 @@ export class ReleaseTrackPageComponent implements OnInit { memberSyncSupplantStatusPolicy: [MemberSyncPolicy.Preserve], candidatesToStagedConflict: [ConflictPolicy.PreferLatest], stagedToMembersConflict: [ConflictPolicy.Abort], - includeSecondaryObjects: [false], - secondaryObjectThreshold: [WorkflowStatus.Reviewed], + publicationCollectionId: [''], + publicationCreated: [''], + publicationIdentityInherit: [true], + publicationIdentityValue: [''], + publicationMarkingsInherit: [true], + publicationMarkingsValue: [[] as string[]], virtualDeduplicationStrategy: [ DeduplicationStrategy.PrioritizeLatestObject, ], @@ -272,12 +287,6 @@ export class ReleaseTrackPageComponent implements OnInit { this.configForm.get('autoPromote')?.valueChanges.subscribe(autoPromote => { this.syncCandidacyThresholdControl(!!autoPromote); }); - this.configForm - .get('includeSecondaryObjects') - ?.valueChanges.subscribe(includeSecondaryObjects => { - this.syncSecondaryObjectThresholdControl(!!includeSecondaryObjects); - }); - this.syncSecondaryObjectThresholdControl(false); } ngOnInit(): void { @@ -551,6 +560,10 @@ export class ReleaseTrackPageComponent implements OnInit { return this.authenticationService.canEdit(); } + public get canDeleteRelease(): boolean { + return this.authenticationService.canDelete(); + } + public getReleaseTrack(): void { this.connector .getLatestSnapshot(this.id, { @@ -715,6 +728,74 @@ export class ReleaseTrackPageComponent implements OnInit { }); } + public isDeletingRelease(item: SnapshotHistoryViewModel): boolean { + return !!item.modified && this.deletingReleaseModified.has(item.modified); + } + + /** + * Delete the track's most recent release. Only administrators may do this, + * and they confirm by typing the release version. + */ + public onDeleteRelease(item: SnapshotHistoryViewModel): void { + if ( + !this.id || + !item.modified || + !item.isTagged || + !this.canDeleteRelease || + this.isDeletingRelease(item) + ) { + return; + } + const version = item.snapshot.version || ''; + const modified = item.modified; + const prompt = this.dialog.open(DeleteDialogComponent, { + maxWidth: '35em', + disableClose: true, + autoFocus: false, + data: { + title: `Delete release ${version}?`, + warning: `Release ${version} of ${this.releaseTrackName || 'this track'} will be permanently deleted. Its version becomes available again and later drafts are kept.`, + stixId: version, + }, + }); + + prompt + .afterClosed() + .pipe(take(1)) + .subscribe(confirm => { + if (!confirm) return; + + this.deletingReleaseModified.add(modified); + this.connector + .deleteSnapshotByModified(this.id, modified, { + confirmVersion: version, + }) + .pipe( + take(1), + finalize(() => { + this.deletingReleaseModified.delete(modified); + }) + ) + .subscribe({ + next: () => { + this.snackbar.open(`Release ${version} deleted.`, null, { + duration: 5000, + }); + this.getReleaseTrack(); + this.getSnapshotHistory(); + }, + error: err => { + console.error('Failed to delete release', err); + this.snackbar.open( + 'Unable to delete this release. Please try again.', + null, + { duration: 5000, panelClass: 'error' } + ); + }, + }); + }); + } + public getSnapshotHistory(): void { if (!this.id) return; @@ -757,6 +838,9 @@ export class ReleaseTrackPageComponent implements OnInit { if (!this.isEditingConfig) { if (this.isVirtualReleaseTrack) { this.setVirtualConfig(); + this.setConfig( + this.getConfigFromResponse(config, this.releaseTrack?.config) + ); } else { this.setConfig( this.getConfigFromResponse(config, this.releaseTrack?.config) @@ -1522,7 +1606,7 @@ export class ReleaseTrackPageComponent implements OnInit { label: 'Summary', value: 'copy-summary', description: - 'Copy lightweight snapshot metadata, object counts, and graph-cache statistics to the clipboard.', + 'Copy lightweight snapshot metadata, object counts, and content statistics to the clipboard.', }); } @@ -1718,6 +1802,7 @@ export class ReleaseTrackPageComponent implements OnInit { public onEditConfig(): void { if (this.isVirtualReleaseTrack) this.setVirtualConfig(); + this.loadPublicationOptions(); this.isEditingConfig = true; } @@ -1754,6 +1839,7 @@ export class ReleaseTrackPageComponent implements OnInit { if (this.releaseTrack) this.releaseTrack.config = this.releaseTrackConfig; this.refreshReleaseTrackState(); + this.getConfig(); }, error: err => { console.error('Failed to update release track config', err); @@ -1920,26 +2006,14 @@ export class ReleaseTrackPageComponent implements OnInit { tagged: item.isTagged, latest: item.isLatest, counts, - graph_cache: item.isBundleCached - ? { - cached: true, - manifest_id: snapshot.graph_manifest_id, - statistics: snapshot.graph_statistics, - } - : { cached: false }, + content: { + manifest_id: snapshot.content_manifest_id ?? null, + statistics: snapshot.content_statistics ?? null, + }, + bundle_id: snapshot.bundle_id ?? null, }; } - public isCachingSnapshot(item: SnapshotHistoryViewModel): boolean { - return !!item.modified && this.cachingSnapshotModified.has(item.modified); - } - - public isDeletingSnapshotCache(item: SnapshotHistoryViewModel): boolean { - return ( - !!item.modified && this.deletingSnapshotCacheModified.has(item.modified) - ); - } - public isUpdatingSnapshotDescription( item: SnapshotHistoryViewModel ): boolean { @@ -1954,8 +2028,7 @@ export class ReleaseTrackPageComponent implements OnInit { !this.id || !item.modified || !this.canEditReleaseTrack || - item.isBundleCached || - !!item.snapshot.graph_manifest_id || + item.isTagged || this.isUpdatingSnapshotDescription(item) ) { return; @@ -1970,7 +2043,7 @@ export class ReleaseTrackPageComponent implements OnInit { : 'Add snapshot notes', description: item.snapshot.snapshot_description || '', message: - 'These notes are visible on this snapshot in history and in its STIX bundles. Notes cannot be changed while the bundle is cached.', + 'These notes are visible on this snapshot in history and become the collection description in its STIX bundles. Notes are fixed once the snapshot is released.', confirmLabel: 'Save notes', }, }); @@ -2028,131 +2101,6 @@ export class ReleaseTrackPageComponent implements OnInit { }); } - public getBundleCacheTooltip(item: SnapshotHistoryViewModel): string { - if (item.isBundleCached) { - return 'Member-only bundle exports use exact object and relationship revisions, so repeated exports are deterministic. Candidate and staged content remains live.'; - } - if (!item.isTagged) { - return 'Draft snapshots cannot be cached. Tag this snapshot before caching it for deterministic member-only bundle exports.'; - } - return 'Member-only bundle exports are not guaranteed to be deterministic until this snapshot is cached.'; - } - - public onCacheSnapshotBundle(item: SnapshotHistoryViewModel): void { - if ( - !this.id || - !item.modified || - !item.isTagged || - item.isBundleCached || - !this.canEditReleaseTrack || - this.isCachingSnapshot(item) - ) { - return; - } - - const modified = item.modified; - this.cachingSnapshotModified.add(modified); - this.connector - .createSnapshotGraph(this.id, modified) - .pipe( - take(1), - finalize(() => { - this.cachingSnapshotModified.delete(modified); - }) - ) - .subscribe({ - next: snapshot => { - item.snapshot = { ...item.snapshot, ...snapshot }; - item.isBundleCached = !!snapshot.graph_manifest_id; - item.canCacheBundle = item.isTagged && !item.isBundleCached; - this.getSnapshotHistory(); - this.snackbar.open( - 'Bundle cached. Member-only bundle exports are now deterministic.', - null, - { duration: 5000 } - ); - }, - error: err => { - console.error('Failed to cache snapshot bundle graph', err); - this.snackbar.open( - 'Unable to cache this snapshot. Please try again.', - null, - { duration: 5000, panelClass: 'error' } - ); - }, - }); - } - - public onDeleteSnapshotCache(item: SnapshotHistoryViewModel): void { - if ( - !this.id || - !item.modified || - !item.isBundleCached || - !this.canEditReleaseTrack || - this.isCachingSnapshot(item) || - this.isDeletingSnapshotCache(item) - ) { - return; - } - - const modified = item.modified; - const dialogRef = this.dialog.open(ConfirmationDialogComponent, { - width: '30em', - autoFocus: false, - data: { - title: 'Delete bundle cache?', - message: `Delete the bundle cache for ${item.title}? Member-only bundle exports will no longer be guaranteed to be deterministic until the cache is rebuilt.`, - no_label: 'Cancel', - yes_label: 'Delete Cache', - confirm_color: 'warn', - confirm_appearance: 'raised', - layout: 'simple', - }, - }); - - dialogRef - .afterClosed() - .pipe(take(1)) - .subscribe(confirmed => { - if (!confirmed || !this.id) return; - - this.deletingSnapshotCacheModified.add(modified); - this.connector - .deleteSnapshotGraph(this.id, modified) - .pipe( - take(1), - finalize(() => { - this.deletingSnapshotCacheModified.delete(modified); - }) - ) - .subscribe({ - next: () => { - delete item.snapshot.graph_manifest_id; - delete item.snapshot.bundle_hashes; - delete item.snapshot.graph_statistics; - item.isBundleCached = false; - item.canCacheBundle = item.isTagged; - item.graphCacheStats = []; - item.graphCacheTotal = 0; - this.getSnapshotHistory(); - this.snackbar.open( - 'Bundle cache deleted. Member-only bundle exports are no longer guaranteed to be deterministic.', - null, - { duration: 5000 } - ); - }, - error: err => { - console.error('Failed to delete snapshot bundle graph', err); - this.snackbar.open( - 'Unable to delete this bundle cache. Please try again.', - null, - { duration: 5000, panelClass: 'error' } - ); - }, - }); - }); - } - private downloadSnapshot( item: SnapshotHistoryViewModel, modified: string, @@ -2184,7 +2132,7 @@ export class ReleaseTrackPageComponent implements OnInit { stixVersion: StixVersion ): string | null { const hashes = item.snapshot.bundle_hashes; - if (!hashes || hashes.manifest_id !== item.snapshot.graph_manifest_id) { + if (!hashes || hashes.manifest_id !== item.snapshot.content_manifest_id) { return null; } return stixVersion === '2.0' ? hashes.stix_2_0 : hashes.stix_2_1; @@ -2501,13 +2449,13 @@ export class ReleaseTrackPageComponent implements OnInit { private setConfig(config: any): void { const normalizedConfig = this.normalizeConfig(config); this.releaseTrackConfig = normalizedConfig; + if (normalizedConfig.publication_resolved) { + this.publicationResolved = normalizedConfig.publication_resolved; + } this.configForm.patchValue(this.getConfigFormValue(normalizedConfig), { emitEvent: false, }); this.syncCandidacyThresholdControl(!!normalizedConfig.auto_promote); - this.syncSecondaryObjectThresholdControl( - !!normalizedConfig.include_secondary_objects?.enabled - ); } private setVirtualConfig(): void { @@ -2537,17 +2485,25 @@ export class ReleaseTrackPageComponent implements OnInit { private saveVirtualConfig(): void { const payload = this.getVirtualCompositionPayload(); + const publication = this.getPublicationPayload( + this.configForm.getRawValue() as ReleaseTrackConfigFormValue + ); this.isSavingConfig = true; this.connector .updateComposition(this.id, payload) .pipe( take(1), + switchMap(result => + this.connector + .updateConfig(this.id, { publication }) + .pipe(map(configResult => ({ result, configResult }))) + ), finalize(() => { this.isSavingConfig = false; }) ) .subscribe({ - next: result => { + next: ({ result, configResult }) => { this.isEditingConfig = false; if (this.releaseTrack) { this.releaseTrack.composition = this.getCompositionFromResponse( @@ -2555,7 +2511,11 @@ export class ReleaseTrackPageComponent implements OnInit { payload ); } + this.setConfig( + this.getConfigFromResponse(configResult, { publication }) + ); this.refreshReleaseTrackState(); + this.getConfig(); }, error: err => { console.error('Failed to update virtual release track config', err); @@ -2572,15 +2532,6 @@ export class ReleaseTrackPageComponent implements OnInit { this.syncWorkflowStatusControl('candidacyThreshold', autoPromote); } - private syncSecondaryObjectThresholdControl( - includeSecondaryObjects: boolean - ): void { - this.syncWorkflowStatusControl( - 'secondaryObjectThreshold', - includeSecondaryObjects - ); - } - private syncWorkflowStatusControl( controlName: string, isEnabled: boolean @@ -2616,9 +2567,9 @@ export class ReleaseTrackPageComponent implements OnInit { const configKeys = [ 'auto_promote', 'candidacy_threshold', - 'include_secondary_objects', 'promotion_conflicts', 'member_sync', + 'publication', ]; return configKeys.some(key => key in response) ? response : fallback || {}; } @@ -2634,20 +2585,10 @@ export class ReleaseTrackPageComponent implements OnInit { source.promotion_conflicts?.candidates_to_staged === ConflictPolicy.Abort ? ConflictPolicy.PreferLatest : source.promotion_conflicts?.candidates_to_staged; - const includeSecondaryObjects = - typeof source.include_secondary_objects === 'boolean' - ? { enabled: source.include_secondary_objects } - : source.include_secondary_objects; - return { auto_promote: source.auto_promote ?? true, candidacy_threshold: source.candidacy_threshold ?? WorkflowStatus.Reviewed, - include_secondary_objects: { - enabled: includeSecondaryObjects?.enabled ?? false, - status_threshold: - includeSecondaryObjects?.status_threshold ?? WorkflowStatus.Reviewed, - }, promotion_conflicts: { candidates_to_staged: candidatesToStagedConflict ?? ConflictPolicy.PreferLatest, @@ -2661,6 +2602,15 @@ export class ReleaseTrackPageComponent implements OnInit { status_policy: supplant?.status_policy ?? MemberSyncPolicy.Preserve, }, }, + publication: { + collection_id: source.publication?.collection_id ?? null, + created: source.publication?.created ?? null, + created_by_ref: source.publication?.created_by_ref ?? { inherit: true }, + object_marking_refs: source.publication?.object_marking_refs ?? { + inherit: true, + }, + }, + publication_resolved: source.publication_resolved, }; } @@ -2683,22 +2633,142 @@ export class ReleaseTrackPageComponent implements OnInit { ConflictPolicy.PreferLatest, stagedToMembersConflict: config.promotion_conflicts?.staged_to_members ?? ConflictPolicy.Abort, - includeSecondaryObjects: - config.include_secondary_objects?.enabled ?? false, - secondaryObjectThreshold: - config.include_secondary_objects?.status_threshold ?? - WorkflowStatus.Reviewed, + publicationCollectionId: config.publication?.collection_id ?? '', + publicationCreated: config.publication?.created ?? '', + publicationIdentityInherit: + config.publication?.created_by_ref?.inherit !== false, + publicationIdentityValue: + config.publication?.created_by_ref?.inherit === false + ? config.publication.created_by_ref.value + : '', + publicationMarkingsInherit: + config.publication?.object_marking_refs?.inherit !== false, + publicationMarkingsValue: + config.publication?.object_marking_refs?.inherit === false + ? [...config.publication.object_marking_refs.value] + : [], + }; + } + + private getPublicationPayload( + value: ReleaseTrackConfigFormValue + ): PublicationConfig { + const identityValue = value.publicationIdentityValue?.trim(); + const markingValues = (value.publicationMarkingsValue || []).filter( + Boolean + ); + const payload: PublicationConfig = { + created_by_ref: + value.publicationIdentityInherit || !identityValue + ? { inherit: true } + : { inherit: false, value: identityValue }, + object_marking_refs: + value.publicationMarkingsInherit || markingValues.length === 0 + ? { inherit: true } + : { inherit: false, value: markingValues }, }; + // Collection identity is immutable once released; only send it while it + // can still change so an unchanged form never triggers a conflict. + if (!this.hasTaggedRelease) { + payload.collection_id = value.publicationCollectionId?.trim() || null; + payload.created = value.publicationCreated?.trim() || null; + } + return payload; + } + + public get hasTaggedRelease(): boolean { + return (this.releaseTrack?.version_history?.length ?? 0) > 0; + } + + public get publicationIdentityLabel(): string { + const resolved = this.publicationResolved; + if (!resolved) return ''; + const option = this.publicationIdentityOptions.find( + candidate => candidate.id === resolved.created_by_ref + ); + return option?.label || resolved.created_by_ref; + } + + public get publicationMarkingLabels(): string[] { + const resolved = this.publicationResolved; + if (!resolved) return []; + return resolved.object_marking_refs.map( + ref => + this.publicationMarkingOptions.find(candidate => candidate.id === ref) + ?.label || ref + ); + } + + public formatPublicationSource(source?: PublicationSource): string { + switch (source) { + case 'track': + return 'Track override'; + case 'global': + return 'Inherited from organization settings'; + case 'content': + return 'Derived from bundle contents'; + case 'derived': + return 'Derived from this track'; + default: + return ''; + } + } + + private loadPublicationOptions(): void { + // The connector exposes these as getters returning bound-by-call functions, + // so they must be invoked as methods on the service. + const service = this.restApiConnectorService as any; + if (typeof service?.getAllIdentities === 'function') { + service + .getAllIdentities() + .pipe(take(1)) + .subscribe({ + next: (result: any) => { + this.publicationIdentityOptions = (result?.data ?? []).map( + (identity: any) => ({ + id: identity.stixID ?? identity.stix?.id, + label: + identity.name ?? + identity.stix?.name ?? + identity.stixID ?? + identity.stix?.id, + }) + ); + }, + error: err => + console.error('Failed to load identities for publication', err), + }); + } + if (typeof service?.getAllMarkingDefinitions === 'function') { + service + .getAllMarkingDefinitions() + .pipe(take(1)) + .subscribe({ + next: (result: any) => { + this.publicationMarkingOptions = (result?.data ?? []).map( + (marking: any) => ({ + id: marking.stixID ?? marking.stix?.id, + label: + marking.definition_string ?? + marking.stix?.definition?.tlp ?? + marking.stixID ?? + marking.stix?.id, + }) + ); + }, + error: err => + console.error( + 'Failed to load marking definitions for publication', + err + ), + }); + } } private getConfigPayload(): ReleaseTrackConfig { const value = this.configForm.getRawValue() as ReleaseTrackConfigFormValue; const payload: ReleaseTrackConfig = { auto_promote: value.autoPromote, - include_secondary_objects: { - enabled: value.includeSecondaryObjects, - status_threshold: value.secondaryObjectThreshold, - }, promotion_conflicts: { candidates_to_staged: value.candidatesToStagedConflict, staged_to_members: value.stagedToMembersConflict, @@ -2715,6 +2785,7 @@ export class ReleaseTrackPageComponent implements OnInit { if (value.autoPromote && value.candidacyThreshold) { payload.candidacy_threshold = value.candidacyThreshold; } + payload.publication = this.getPublicationPayload(value); return payload; } @@ -2890,7 +2961,6 @@ export class ReleaseTrackPageComponent implements OnInit { return sorted.map((snapshot, index) => { const previousSnapshot = sorted[index + 1]; const isTagged = this.isTaggedSnapshot(snapshot); - const isBundleCached = !!snapshot.graph_manifest_id; const isLatest = snapshot === latestSnapshot; const currentMembers = this.getSnapshotMembers(snapshot); const previousMembers = previousSnapshot @@ -2920,11 +2990,9 @@ export class ReleaseTrackPageComponent implements OnInit { isTagged, isLatest, isCurrentDraft: !isTagged && isLatest, - isBundleCached, - canCacheBundle: isTagged && !isBundleCached, stats: this.getSnapshotStats(snapshot, addedCount, modifiedCount), - graphCacheStats: this.getGraphCacheStats(snapshot), - graphCacheTotal: snapshot.graph_statistics?.total_count ?? 0, + contentStats: this.getContentStats(snapshot), + contentTotal: snapshot.content_statistics?.total_count ?? 0, addedCount, modifiedCount, totalObjects, @@ -3051,35 +3119,40 @@ export class ReleaseTrackPageComponent implements OnInit { ]; } - private getGraphCacheStats( + private getContentStats( snapshot: ReleaseTrackSnapshotHistoryItem ): SnapshotHistoryStat[] { - const statistics = snapshot.graph_statistics; - if (!snapshot.graph_manifest_id || !statistics) return []; + const statistics = snapshot.content_statistics; + if (!snapshot.content_manifest_id || !statistics) return []; - return [ + const stats: SnapshotHistoryStat[] = [ { - label: 'Primary', + label: 'Members', value: statistics.primary_count, - tooltip: 'Objects deliberately included as snapshot members.', - }, - { - label: 'Secondary', - value: statistics.secondary_count, - tooltip: 'Related objects pulled in while resolving the member graph.', + tooltip: 'Exact object revisions selected as snapshot members.', }, { label: 'Relationships', value: statistics.relationship_count, - tooltip: 'Connections pinned between cached graph objects.', + tooltip: + 'Relationships whose source and target are both members, pinned to those member revisions.', }, { label: 'Dependencies', value: statistics.supporting_count + statistics.link_target_count, tooltip: - 'Supporting identities, markings, and LinkById targets used by the cache.', + 'Supporting identities, markings, and LinkById targets sealed with the content.', }, ]; + if (statistics.secondary_count > 0) { + stats.push({ + label: 'Legacy secondary', + value: statistics.secondary_count, + tooltip: + 'Historical non-member objects carried by a source-attested manifest.', + }); + } + return stats; } private getSnapshotType( From ff2f8dbc3cb182d36b774f009c812bd9539efc0a Mon Sep 17 00:00:00 2001 From: Sean Sica <23294618+seansica@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:54:30 -0400 Subject: [PATCH 3/5] feat(data-quality): show domain consistency report Release-track bundles only ship a relationship when both endpoints are members of the same track, so a relationship whose objects share no domain can never be published, and nothing in the UI pointed editors at such content. The Data Quality page now lists cross-domain relationships (source, target, their domains, and links to each object) and domain-bearing objects that declare no domain, backed by GET /api/reports/domain-consistency. Co-Authored-By: Claude Fable 5.1 --- .../rest-api/rest-api-connector.service.ts | 20 +++++ .../data-quality/data-quality.component.html | 66 ++++++++++++++ .../data-quality/data-quality.component.scss | 33 +++++++ .../data-quality.component.spec.ts | 71 +++++++++++++++ .../data-quality/data-quality.component.ts | 88 +++++++++++++++++++ 5 files changed, 278 insertions(+) diff --git a/src/app/services/connectors/rest-api/rest-api-connector.service.ts b/src/app/services/connectors/rest-api/rest-api-connector.service.ts index c7545f96..d96ffb1b 100644 --- a/src/app/services/connectors/rest-api/rest-api-connector.service.ts +++ b/src/app/services/connectors/rest-api/rest-api-connector.service.ts @@ -3141,6 +3141,26 @@ export class RestApiConnectorService extends ApiConnector { share() // multicast so that multiple subscribers don't trigger the call twice. THIS MUST BE THE LAST LINE OF THE PIPE ); } + /** + * Retrieve relationships whose endpoints share no domain and objects that + * declare no domain + */ + public getDomainConsistencyReport(): Observable { + const url = `${this.apiUrl}/reports/domain-consistency`; + return this.http.get(url).pipe( + tap(results => + logger.log('retrieved domain consistency report', results) + ), + catchError( + this.handleError_continue({ + cross_domain_relationships: [], + objects_without_domains: [], + }) + ), + share() // multicast so that multiple subscribers don't trigger the call twice. THIS MUST BE THE LAST LINE OF THE PIPE + ); + } + /** * Retrieve groups of parallel relationships between the same source/target/type */ diff --git a/src/app/views/dashboard-page/data-quality/data-quality.component.html b/src/app/views/dashboard-page/data-quality/data-quality.component.html index d5ef084c..de669388 100644 --- a/src/app/views/dashboard-page/data-quality/data-quality.component.html +++ b/src/app/views/dashboard-page/data-quality/data-quality.component.html @@ -54,6 +54,72 @@

Duplicate Relationships

No parallel relationships found. + +

Cross-Domain Relationships

+

+ Release-track bundles only include a relationship when both of its objects + are members of the same track, so a relationship whose objects share no + domain can never be published. Add the missing domain to the object or + deprecate the relationship. +

+ +
+ {{ domainConsistencyError }} +
+
+ + + + + + + + + + + + + + + + + + + +
SourceSource domainsRelationshipTargetTarget domains
+ {{ row.sourceId }} + {{ row.sourceName }} + {{ row.sourceDomains.join(', ') }}{{ row.relationshipType }} + {{ row.targetId }} + {{ row.targetName }} + {{ row.targetDomains.join(', ') }}
+
+
+ No cross-domain relationships found. +
+ +

Objects Without Domains

+ + + + + + Objects without a domain ({{ objectsWithoutDomains.length }}) + + + + + +
+ No objects without domains found. +
+

Missing LinkByIds Status

diff --git a/src/app/views/dashboard-page/data-quality/data-quality.component.scss b/src/app/views/dashboard-page/data-quality/data-quality.component.scss index 8915dce8..43ac9cb3 100644 --- a/src/app/views/dashboard-page/data-quality/data-quality.component.scss +++ b/src/app/views/dashboard-page/data-quality/data-quality.component.scss @@ -30,6 +30,39 @@ } } +.data-quality-note { + margin: 0 0 16px; + opacity: 0.85; +} + +.dq-domain-table-wrapper { + overflow-x: auto; + margin-bottom: 24px; +} + +.dq-domain-table { + width: 100%; + border-collapse: collapse; + font-size: 0.95rem; + + th, + td { + text-align: left; + padding: 8px 12px; + border-bottom: 1px solid rgba(128, 128, 128, 0.3); + vertical-align: top; + } + + th { + font-weight: 600; + } + + .dq-object-name { + display: block; + opacity: 0.8; + } +} + .missing-list { .missing-id { font-family: Roboto, Arial, sans-serif; diff --git a/src/app/views/dashboard-page/data-quality/data-quality.component.spec.ts b/src/app/views/dashboard-page/data-quality/data-quality.component.spec.ts index 3bf74184..1d1ffffb 100644 --- a/src/app/views/dashboard-page/data-quality/data-quality.component.spec.ts +++ b/src/app/views/dashboard-page/data-quality/data-quality.component.spec.ts @@ -10,6 +10,51 @@ describe('DataQualityComponent', () => { const mockReportService = { getMissingLinkById: () => of([]), getParallelRelationships: () => of({}), + getDomainConsistencyReport: () => + of({ + cross_domain_relationships: [ + { + stix: { + id: 'relationship--1', + relationship_type: 'uses', + source_ref: 'intrusion-set--1', + target_ref: 'attack-pattern--1', + }, + source_object: { + workspace: { attack_id: 'G0001' }, + stix: { + id: 'intrusion-set--1', + type: 'intrusion-set', + name: 'Group One', + }, + }, + target_object: { + workspace: { attack_id: 'T0001' }, + stix: { + id: 'attack-pattern--1', + type: 'attack-pattern', + name: 'Technique One', + }, + }, + source_domains: ['enterprise-attack'], + target_domains: ['mobile-attack'], + }, + ], + objects_without_domains: [ + { + workspace: { attack_id: 'T0002' }, + stix: { + id: 'attack-pattern--2', + type: 'attack-pattern', + name: 'Domainless', + }, + }, + ], + summary: { + cross_domain_relationship_count: 1, + objects_without_domains_count: 1, + }, + }), }; beforeEach(async () => { @@ -28,4 +73,30 @@ describe('DataQualityComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should render the domain consistency report rows', () => { + expect(component.crossDomainRelationships).toEqual([ + expect.objectContaining({ + stixId: 'relationship--1', + relationshipType: 'uses', + sourceId: 'G0001', + targetId: 'T0001', + sourceName: 'Group One', + targetName: 'Technique One', + sourceDomains: ['enterprise-attack'], + targetDomains: ['mobile-attack'], + }), + ]); + expect( + component.objectLink(component.crossDomainRelationships[0].source) + ).toEqual(['/', 'group', 'intrusion-set--1']); + const config = component.stixConfigForObjectsWithoutDomains(); + expect(config.stixObjects).toEqual([ + expect.objectContaining({ + stixID: 'attack-pattern--2', + attackID: 'T0002', + name: 'Domainless', + }), + ]); + }); }); diff --git a/src/app/views/dashboard-page/data-quality/data-quality.component.ts b/src/app/views/dashboard-page/data-quality/data-quality.component.ts index 4d983add..68c2f4d2 100644 --- a/src/app/views/dashboard-page/data-quality/data-quality.component.ts +++ b/src/app/views/dashboard-page/data-quality/data-quality.component.ts @@ -10,6 +10,19 @@ import { forkJoin } from 'rxjs'; import { Relationship } from 'src/app/classes/stix/relationship'; import { ConfirmationDialogComponent } from 'src/app/components/confirmation-dialog/confirmation-dialog.component'; +export interface CrossDomainRelationshipRow { + stixId: string; + relationshipType: string; + source: any; + target: any; + sourceId: string; + targetId: string; + sourceName: string; + targetName: string; + sourceDomains: string[]; + targetDomains: string[]; +} + interface ParallelRelationshipGroup { key: string; sourceRef: string; @@ -45,6 +58,11 @@ export class DataQualityComponent implements OnInit { loadingParallel = false; parallelError?: string; + crossDomainRelationships: CrossDomainRelationshipRow[] = []; + objectsWithoutDomains: any[] = []; + loadingDomainConsistency = false; + domainConsistencyError?: string; + stixRelationshipConfig: StixListConfig = { type: 'relationship', stixObjects: [], @@ -59,6 +77,76 @@ export class DataQualityComponent implements OnInit { ngOnInit(): void { this.loadParallelRelationships(); this.loadMissingLinks(); + this.loadDomainConsistency(); + } + + /** + * Release-track bundles only ship a relationship when both endpoints are + * members of the same track, so endpoints that share no domain can never be + * published together. + */ + loadDomainConsistency(): void { + this.loadingDomainConsistency = true; + this.reportService.getDomainConsistencyReport().subscribe({ + next: report => { + this.crossDomainRelationships = ( + report?.cross_domain_relationships ?? [] + ).map((entry: any) => this.mapCrossDomainRelationship(entry)); + this.objectsWithoutDomains = report?.objects_without_domains ?? []; + this.loadingDomainConsistency = false; + this.domainConsistencyError = undefined; + }, + error: err => { + this.domainConsistencyError = + 'Failed to load domain consistency report'; + this.loadingDomainConsistency = false; + console.error(err); + }, + }); + } + + private mapCrossDomainRelationship(entry: any): CrossDomainRelationshipRow { + const source = entry?.source_object; + const target = entry?.target_object; + const attackId = (object: any) => + object?.workspace?.attack_id || + object?.stix?.external_references?.[0]?.external_id || + object?.stix?.id || + ''; + return { + stixId: entry?.stix?.id, + relationshipType: entry?.stix?.relationship_type, + source, + target, + sourceId: attackId(source), + targetId: attackId(target), + sourceName: source?.stix?.name || '', + targetName: target?.stix?.name || '', + sourceDomains: entry?.source_domains ?? [], + targetDomains: entry?.target_domains ?? [], + }; + } + + // Use stix-list for objects without domains with id and name columns + stixConfigForObjectsWithoutDomains(): StixListConfig { + return { + type: 'relationship', // use relationship so stix-list table styling matches + stixObjects: (this.objectsWithoutDomains || []).map(item => { + const s = item?.stix || item; + return { + stixID: s.id, + attackType: StixTypeToAttackType[s.type], + type: s.type, + attackID: item?.workspace?.attack_id || '', + name: s.name || s.id, + } as any; + }), + columnsPreset: 'id-name', + showControls: false, + showFilters: false, + showDeprecatedFilter: false, + clickBehavior: 'linkToObjectPage', + }; } private transformParallelRelationships( From 4bc6f47ee242a651c4027ff268bd483b52c79fdc Mon Sep 17 00:00:00 2001 From: Sean Sica <23294618+seansica@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:54:37 -0400 Subject: [PATCH 4/5] feat(release-tracks): adopt the draft-then-tag flow with aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release-track page had three overlapping ways to act on a draft: the header's Preview & Release (tag the latest draft), each draft card's Preview & Tag, and an Export Latest that duplicated the card export and was sending `include=all` to the bundle endpoint, which now rejects it. Track deletion sat beside them in the header. The page now follows one linear flow. The Board tab (formerly Details) manages what the next draft contains; the header keeps only Create Draft for virtual tracks; a draft is previewed and tagged from its card on the Releases tab; and deleting the track moves to a danger zone at the bottom of Config. Bundle exports send only the STIX version. Preview & Tag no longer downloads the whole object catalogue to label tier entries — the workbench snapshot carries each entry's type and version — and the buttons show a "Preparing preview" state while the preview loads. Only the most recent release offers Delete release, since any other card would always be refused. Tracks can carry an alias: the Config tab's Address card edits it (validated slug with a URL preview, saved through the metadata endpoint before the config write), track cards navigate by alias, and the page adopts the canonical id from the loaded snapshot so confirmations never see the alias. Co-Authored-By: Claude Fable 5.1 --- docs/usage.md | 297 ++++++------- src/app/classes/release-tracks/api.ts | 4 +- .../classes/release-tracks/release-track.ts | 2 + src/app/classes/release-tracks/snapshot.ts | 3 + src/app/classes/release-tracks/tiers.ts | 6 +- .../release-track-card.component.ts | 4 +- .../stix-page-tabs.component.html | 2 +- .../stix-page-tabs.component.ts | 2 + .../release-track-page.component.html | 120 ++++-- .../release-track-page.component.scss | 23 + .../release-track-page.component.spec.ts | 401 ++++++++---------- .../release-track-page.component.ts | 307 ++++++-------- 12 files changed, 597 insertions(+), 574 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 2a206e76..1ca94858 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,6 +1,6 @@ # ATT&CK Workbench Usage Documentation -The ATT&CK Workbench is a tool intended to allow the ATT&CK community to *explore*, *create*, *annotate* and *share* extensions of ATT&CK. +The ATT&CK Workbench is a tool intended to allow the ATT&CK community to _explore_, _create_, _annotate_ and _share_ extensions of ATT&CK. ## Build Information @@ -16,7 +16,7 @@ When first instantiated, the ATT&CK Workbench will not include any data. You can ### Managing Collections -Accessing and sharing ATT&CK knowledge is realized through _collections_. A collection is a set of related ATT&CK objects; collections may be used represent specific releases of a dataset such as "Enterprise ATT&CK v7.2", or any other set of objects one may want to share with someone else. +Accessing and sharing ATT&CK knowledge is realized through _collections_. A collection is a set of related ATT&CK objects; collections may be used represent specific releases of a dataset such as "Enterprise ATT&CK v7.2", or any other set of objects one may want to share with someone else. Collections can be created by anyone, not just MITRE. The ATT&CK Workbench application includes workflows for both importing and creating new collections. Collections can be shared as STIX bundles, uploaded to the internet, or sent through email. @@ -30,7 +30,7 @@ You can read more about the technical specifications for a collection, such as t The release preview offers minor and major relative tags as well as an exact `MAJOR.MINOR` version. Relative tags are calculated from the tagged snapshot immediately before the selected draft. When releasing an older draft, the exact version must also remain below the next tagged snapshot; the dialog shows these exclusive bounds. Optional release notes are stored on that snapshot and become the `x-mitre-collection` description in exported STIX bundles. -The release-track page can export the latest snapshot or a selected historical snapshot as a STIX 2.0 bundle, a STIX 2.1 bundle, or Workbench JSON. Historical snapshot exports can also copy a concise summary. Every snapshot seals its content when its members are written, so exports replay the exact members, relationships, and supporting objects in either STIX version; released snapshots also show their stable bundle identifier and SHA-256 hashes. Saving a relationship resets its source and target to work-in-progress in place without creating new revisions of those objects. Administrators can delete a track's most recent release from the Releases tab by confirming its version; its version becomes available again and later drafts are kept. +The release-track page follows a draft-then-tag flow: the Board tab manages what the next draft contains (candidates, staged objects, and for virtual tracks the Create Draft action), and the Releases tab previews and tags a draft from its card. Any snapshot can be exported from its card as a STIX 2.0 bundle, a STIX 2.1 bundle, or Workbench JSON. Historical snapshot exports can also copy a concise summary. Every snapshot seals its content when its members are written, so exports replay the exact members, relationships, and supporting objects in either STIX version; released snapshots also show their stable bundle identifier and SHA-256 hashes. Saving a relationship resets its source and target to work-in-progress in place without creating new revisions of those objects. Administrators can delete a track's most recent release from the Releases tab by confirming its version; its version becomes available again and later drafts are kept. A track can carry an alias (a short lowercase slug set in the Config tab) that works in place of its ID in page URLs and API paths; the track list opens aliased tracks by their alias. The dashboard's Data Quality page adds a domain consistency report: relationships whose objects share no domain (and objects with no domain) can never ship in the same bundle, so fix them at the source rather than expecting the bundle to pull in related objects. Only the most recent release offers a delete button, the Preview & Tag action shows its progress while the preview is prepared, and deleting an entire track lives in the danger zone at the bottom of the Config tab. Each cached snapshot card displays server-generated SHA-256 hashes for the exact UTF-8 JSON files produced by its STIX 2.0 and STIX 2.1 bundle downloads. The adjacent copy buttons copy a hash for external file-integrity verification. Snapshot notes are locked while the bundle is cached; delete the cache, edit the notes, and cache the bundle again to generate matching hashes. @@ -40,10 +40,10 @@ Collection indexes can be added from the collections page. To add a collection i Once saved, the Workbench will periodically check for updates to the collection index at the original URL it was loaded from. Thus can data providers update subscribers by updating their collection index with new collections. - #### Subscribing to a Collection Once a collection index has been added, you can subscribe to a collection listed within the index. Once subscribed, two things will happen: + 1. The most recent version of that collection will be downloaded automatically (this may take a few moments depending on the size of the collection) 2. When the collection index updates, any new versions of subscribed collections will be downloaded, helping you stay up-to-date with releases of subscribed collections as the data provider updates their index. @@ -56,42 +56,46 @@ There are multiple means through which a collection can be imported. The "import ##### 1. Indicate the Collection Users can import the collection in several different ways: -- *Import from URL*: In cases where the collection has been hosted on the internet, the user may specify the URL of a collection STIX bundle for the application to download. -- *Upload from file*: Users can upload a STIX bundle representing the collection. You can upload a collection in JSON, CSV, or XLSX format. -- *Import from collection index*: The user can choose to import collections listed by attached _collection indexes_, which are essentially lists of collections on the internet. + +- _Import from URL_: In cases where the collection has been hosted on the internet, the user may specify the URL of a collection STIX bundle for the application to download. +- _Upload from file_: Users can upload a STIX bundle representing the collection. You can upload a collection in JSON, CSV, or XLSX format. +- _Import from collection index_: The user can choose to import collections listed by attached _collection indexes_, which are essentially lists of collections on the internet. ##### 2. Review Contents -In this step, the user should review the contents of the collection being imported. The review step is provided to ensure that users have control over the contents of their local knowledge base. Users can choose to only import specific objects from the collection if they so choose, or likewise exclude certain objects from the import. +In this step, the user should review the contents of the collection being imported. The review step is provided to ensure that users have control over the contents of their local knowledge base. Users can choose to only import specific objects from the collection if they so choose, or likewise exclude certain objects from the import. While previewing an import the list of contents will be organized by object type, and then by change type. The change types are as follows: -- *Additions*: Additions; objects which were not previously in the knowledge base. -- *Changes*: Updated objects; Objects with major updates such as changes to scope, new reporting, and so forth. -- *Minor changes*: Objects with minor updates such as typo corrections. -- *Revocations*: Objects that have been replaced by other objects. -- *Deprecations*: Objects that have been removed from the dataset. -- *Unchanged*: Objects that already exist in the Workbench and have no changes. -- *Out of date*: Objects which are outdated by more recent edits in your knowledge base. These objects already exist in your workbench, and the version in the collection is older. The version imported in the collection will appear in the version history of the object. + +- _Additions_: Additions; objects which were not previously in the knowledge base. +- _Changes_: Updated objects; Objects with major updates such as changes to scope, new reporting, and so forth. +- _Minor changes_: Objects with minor updates such as typo corrections. +- _Revocations_: Objects that have been replaced by other objects. +- _Deprecations_: Objects that have been removed from the dataset. +- _Unchanged_: Objects that already exist in the Workbench and have no changes. +- _Out of date_: Objects which are outdated by more recent edits in your knowledge base. These objects already exist in your workbench, and the version in the collection is older. The version imported in the collection will appear in the version history of the object. The following error change types may appear when there are conflicts importing a collection: -- *Import conflicts*: Object supersedes local edits, and the user should merge their changes with the new object content. -- *Other Errors*: The Workbench encountered errors when determining whether the following objects exist within the Workbench already. + +- _Import conflicts_: Object supersedes local edits, and the user should merge their changes with the new object content. +- _Other Errors_: The Workbench encountered errors when determining whether the following objects exist within the Workbench already. After importing a collection, users can review the results of the import from the collection page. The collection review UI reflects the changes that occurred at the time of the import. ##### 3. Incorporate into Knowledge Base -After selecting the objects to import, the application will automatically integrate them into the knowledge base. +After selecting the objects to import, the application will automatically integrate them into the knowledge base. + +In cases where objects being imported already exist in the knowledge base, the imported object will appear as a new _version_ of that object. -In cases where objects being imported already exist in the knowledge base, the imported object will appear as a new _version_ of that object. -- If it was edited more recently than the copy already in the knowledge base, it will appear as the most recent version (supersede the version already in the knowledge base). +- If it was edited more recently than the copy already in the knowledge base, it will appear as the most recent version (supersede the version already in the knowledge base). - If it was edited less recently than the copy already in the knowledge base, it will appear as a _previous version_ of the object (superseded by the version already in the knowledge base). -In both cases, the user may need to manually merge the two versions to prevent the incoming knowledge, or knowledge created by the user, from being lost. +In both cases, the user may need to manually merge the two versions to prevent the incoming knowledge, or knowledge created by the user, from being lost. ### Browsing the Knowledge Base -Once you have imported or created data, you can browse the knowledge base using the simple interface provided. For each object type (excepting relationships) a master list of all objects is provided to find the data you want to explore. Clicking on an entry in that list will preview the description of the object, and provide a link to view the full definition. +Once you have imported or created data, you can browse the knowledge base using the simple interface provided. For each object type (excepting relationships) a master list of all objects is provided to find the data you want to explore. Clicking on an entry in that list will preview the description of the object, and provide a link to view the full definition. On the view page for a specific object you can also see relationships the object has with other objects in the knowledge base. Clicking on a relationship in the table will open a dialog window with more information about the relationship, such as the modified date and external references. @@ -100,6 +104,7 @@ On the view page for a specific object you can also see relationships the object Most object lists include pagination, which improves performance of the application by only loading a few objects at a time. The controls in the bottom of the list provides controls for changing the page size and moving between pages. Most object lists also support searching and filtering. The search input above such lists will match text within object IDs, names, and descriptions. The options dropdown menu allows you to filter the data. Available filters include: + - Workflow status: quality control workflow status as discussed in the quality control workflows section below. - State: by default, revoked and deprecated objects are not shown in lists as they are considered removed from the knowledge base. Enabling them in this menu will allow them to appear in the list. - Domain: available in lists of objects which support the domains field. @@ -112,15 +117,15 @@ Object history can be found in the resources drawer, accessible through the icon The history timeline browser allows users to see the revision history of an object itself as well as that of any relationships with the object. Clicking an event within the timeline will show what the corresponding object looked like at that moment in time. - Events within the timeline are color-coded by type: - - Purple events correspond to object changes - - Blue events correspond to relationship changes - - Gray events correspond to collection events + - Purple events correspond to object changes + - Blue events correspond to relationship changes + - Gray events correspond to collection events - Events within the timeline are also differentiated by type, denoted by tooltip and icon: - - A plus symbol denotes additions, such as the creation of the object itself or the addition of relationships with the object. - - A pencil symbol denotes modifications. Modifications to the object that change the version number have additional markings. - - A download icon denotes the first available version of the object, but that earlier versions exist outside of what the user has in their workbench. This occurs when an object has been imported from a collection. - - A minus symbol denotes removals, such as if the object was removed from a collection. - - A verified icon denotes releases, such as if the object was included in a collection marked for release. + - A plus symbol denotes additions, such as the creation of the object itself or the addition of relationships with the object. + - A pencil symbol denotes modifications. Modifications to the object that change the version number have additional markings. + - A download icon denotes the first available version of the object, but that earlier versions exist outside of what the user has in their workbench. This occurs when an object has been imported from a collection. + - A minus symbol denotes removals, such as if the object was removed from a collection. + - A verified icon denotes releases, such as if the object was included in a collection marked for release. ## Creating Extensions of ATT&CK @@ -130,14 +135,15 @@ Objects imported from collections can be modified, or new objects created. The p The Workbench will attribute edits to you when you edit existing objects or create new objects. Attribution is shown next to created and modified dates and in the object history timeline. Attribution is represented by an automatically generated icon to easily distinguish different editing/creating organizations or individuals; hovering over the icon will display the full organization name or user display name. Your user display name can be edited or removed through the user profile page. If removed, your username will be shown in its place for attribution. -Edits you make in the knowledge base are attributed to your _organization identity_, which is unique to your Workbench instance. The organization identity can be edited from the admin page accessible from the application homepage; when you first open the application you will be prompted to edit the organization identity to ensure the placeholder identity is not used. Changes to your organization identity will automatically update objects in the knowledge base, but attribution within exported collections will not be automatically affected. +Edits you make in the knowledge base are attributed to your _organization identity_, which is unique to your Workbench instance. The organization identity can be edited from the admin page accessible from the application homepage; when you first open the application you will be prompted to edit the organization identity to ensure the placeholder identity is not used. Changes to your organization identity will automatically update objects in the knowledge base, but attribution within exported collections will not be automatically affected. ### Quality Control Workflows The ATT&CK Workbench provides optional quality control workflows to assist in the creation of ATT&CK data. Objects are marked with a "workflow status," reflecting their place in the quality control pipeline: -- *work in progress*: this object is being actively developed. Work in progress objects are marked in the UI using a red document icon. -- *awaiting review*: this object is awaiting the review within your organization. Awaiting review objects are marked in the UI using an orange person icon. -- *reviewed*: this object has passed the quality control checks of a reviewer. Reviewed objects are marked using a green checkmark icon. + +- _work in progress_: this object is being actively developed. Work in progress objects are marked in the UI using a red document icon. +- _awaiting review_: this object is awaiting the review within your organization. Awaiting review objects are marked in the UI using an orange person icon. +- _reviewed_: this object has passed the quality control checks of a reviewer. Reviewed objects are marked using a green checkmark icon. Object lists can be filtered to show only objects within a specific state to enable reviewers to find the objects awaiting their review, or editors to find objects still in development. You can set the workflow state of an object by clicking on the gear icon in the toolbar while on an object page. @@ -146,18 +152,21 @@ The quality control workflow is intended to be generic in order to support the q ### Validating Changes When saving an object, the application will validate the data to ensure the new data has no issues. There are four types of messages that can be shown in the validation window: -- *Successes*, which let you know that things are as they should be. This will tell you that your object has a unique name and ATT&CK ID, and other important messages. -- *Warnings*, which tell you that you might want to make a correction, but don't prevent you from saving altogether. Name conflicts for instance are a warning: nothing will break if there's a conflict, but it should still be avoided if possible. -- *Errors*, which tell you that you can't save until you've fixed the mistake. ATT&CK ID conflicts, malformed version numbers and duplicate relationships are examples of validation errors. -- *Info*, which convey other information about your changes. Letting you know that you've already incremented the version number (and shouldn't/can't use the automatic version-increment buttons) is an example of an info message. + +- _Successes_, which let you know that things are as they should be. This will tell you that your object has a unique name and ATT&CK ID, and other important messages. +- _Warnings_, which tell you that you might want to make a correction, but don't prevent you from saving altogether. Name conflicts for instance are a warning: nothing will break if there's a conflict, but it should still be avoided if possible. +- _Errors_, which tell you that you can't save until you've fixed the mistake. ATT&CK ID conflicts, malformed version numbers and duplicate relationships are examples of validation errors. +- _Info_, which convey other information about your changes. Letting you know that you've already incremented the version number (and shouldn't/can't use the automatic version-increment buttons) is an example of an info message. Once you have reviewed the validation feedback you can proceed to save the object unless errors are present which must be corrected first. If you want to make changes as the result of validation warnings or errors, you can simply click cancel to continue editing. ### Version Numbers All objects (Excepting relationships) support version numbers. Version numbers can have any level of granularity (e.g `2.1`, `2.1.1`, `2.1.0.5`), but it is recommended to use at least 2 levels of granularity, formatted as `major.minor`: - - *major* updates include revisions to object scope. - - *minor* updates include additions to reporting and content that do not change the overall scope of the object. + +- _major_ updates include revisions to object scope. +- _minor_ updates include additions to reporting and content that do not change the overall scope of the object. + When saving an object, the validation window will prompt you to increment the version number you haven't already edited the version field. We recommend data providers be careful when incrementing versions so as to avoid double-increments between their releases. @@ -172,7 +181,7 @@ Fields which show a "preview" tab when editing (all descriptions and the detecti #### LinkByIds -LinkByIds are supported in description and detection fields and are used to reference an ATT&CK object. When viewing an object, LinkByIDs are visualized as a full hyperlink within the field and link to the corresponding object's page in the Workbench. +LinkByIds are supported in description and detection fields and are used to reference an ATT&CK object. When viewing an object, LinkByIDs are visualized as a full hyperlink within the field and link to the corresponding object's page in the Workbench. LinkByIds are formatted in text as `(LinkById: ATT&CK ID)`, which corresponds to the linked object's ATT&CK ID. When exporting collection and STIX bundles, LinkByIds will be replaced with the equivalent markdown formatted hyperlink to the object's page on the [ATT&CK Website](https://attack.mitre.org/). @@ -182,7 +191,7 @@ Description, detection, and alias-description* fields allow for in-text citation Citations are formatted within the text as `(Citation: source name)`, which corresponds to the source name of a reference. This will get compiled to a citation marker with hyperlink when rendered, and the relevant reference added to the references section of the object when it is saved. -The **Reference Manager Tool** can be used to add, view, edit, and find references. The tool can be found under "Reference Manager" in the header, and contains a list of all references on all objects, as well as references you've created to add to objects later. +The **Reference Manager Tool** can be used to add, view, edit, and find references. The tool can be found under "Reference Manager" in the header, and contains a list of all references on all objects, as well as references you've created to add to objects later. - When a collection is imported, all references will be added to the master list in the sidebar. - Once a reference has been created, you cannot change the source name. @@ -197,20 +206,20 @@ _\* Unlike other descriptions, alias descriptions do not support markdown and ty ATT&CK IDs must follow a prescribed format: -| Object Type | ID Format | -|:------------|:----------| -| Matrix | (domain identifier)* | -| Tactic | `TAxxxx` | -| Technique | `Txxxx` | -| Sub-Technique | `Txxxx.yyy` | -| Mitigation | `Mxxxx` | -| Campaign | `Cxxxx` | -| Group | `Gxxxx` | -| Software | `Sxxxx` | -| Data Source (deprecated) | `DSxxxx` | -| Detection Strategy | `DETxxxx` | -| Log Source | `LSxxxx` | -| Analytic | `ANxxxx` | +| Object Type | ID Format | +| :----------------------- | :------------------- | +| Matrix | (domain identifier)* | +| Tactic | `TAxxxx` | +| Technique | `Txxxx` | +| Sub-Technique | `Txxxx.yyy` | +| Mitigation | `Mxxxx` | +| Campaign | `Cxxxx` | +| Group | `Gxxxx` | +| Software | `Sxxxx` | +| Data Source (deprecated) | `DSxxxx` | +| Detection Strategy | `DETxxxx` | +| Log Source | `LSxxxx` | +| Analytic | `ANxxxx` | _\* Domain identifiers for Matrices are described in the section for editing matrices._ @@ -248,11 +257,11 @@ Domain-bearing ATT&CK objects expose a Domain field that reads and writes the ST Matrices share the typical fields on objects, including a description supporting markdown, LinkByIds, and citations. Unlike other object types, their IDs serve as identifier for their domain: -| Domain | ID | -|:------------|:----------| -| Enterprise | enterprise-attack | -| Mobile | mobile-attack | -| ICS | ics-attack | +| Domain | ID | +| :--------- | :---------------- | +| Enterprise | enterprise-attack | +| Mobile | mobile-attack | +| ICS | ics-attack | Multiple matrices _can_ exist for a domain (e.x _Device Access_ and _Network-Based Effects_ in the Mobile domain), and matrices of the same domain share IDs. @@ -278,51 +287,52 @@ Sub-techniques are a more specific description of the adversarial behavior used The set of fields available to edit on a technique differs according to the domains and tactics of the technique. Domain specific fields are displayed in the same row as the domain field, and tactic specific fields are displayed in the same row as the tactics field. -| Field | Domains | Tactics? | Description | -|:------|:--------|:---------|:-------------| -| Data Sources | ICS | (All Tactics) | Sources of information that may be used to identify the action or result of the action being performed. | -| sub-technique? | Enterprise | (All Tactics) | Is this object a sub-technique? This cannot be changed for sub-techniques with assigned parents, or for parent-techniques with assigned sub-techniques. | -| System Requirements | Enterprise | (All Tactics) | Additional information on requirements the adversary needs to meet or about the state of the system (software, patch level, etc.) that may be required for the technique to work. | -| Permissions Required | Enterprise | Privilege Escalation | The lowest level of permissions the adversary is required to be operating within to perform the technique on a system. | -| Effective Permissions | Enterprise | Privilege Escalation | The level of permissions the adversary will attain by performing the technique. | -| Defenses Bypassed | Enterprise | Defense Evasion | List of defensive tools, methodologies, or processes the technique can bypass. | -| Remote Support | Enterprise | Execution | Can the technique can be used to execute something on a remote system? | -| Impact Type | Enterprise | Impact | Denotes if the technique can be used for integrity or availability attacks. | -| CAPEC IDs | Enterprise | (All Tactics) | [CAPEC](https://capec.mitre.org/) IDs associated with the technique. Must follow the format `CAPEC-###`. | -| MTC IDs | Mobile | (All Tactics) | NIST [Mobile Threat Catalogue](https://pages.nist.gov/mobile-threat-catalogue/) IDs associated with the technique. Must follow the format `[Threat Category]-###`. | -| Tactic Type | Mobile | (All Tactics) | "Post-Adversary Device Access", "Pre-Adversary Device Access", or "Without Adversary Device Access". | - +| Field | Domains | Tactics? | Description | +| :-------------------- | :--------- | :------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Data Sources | ICS | (All Tactics) | Sources of information that may be used to identify the action or result of the action being performed. | +| sub-technique? | Enterprise | (All Tactics) | Is this object a sub-technique? This cannot be changed for sub-techniques with assigned parents, or for parent-techniques with assigned sub-techniques. | +| System Requirements | Enterprise | (All Tactics) | Additional information on requirements the adversary needs to meet or about the state of the system (software, patch level, etc.) that may be required for the technique to work. | +| Permissions Required | Enterprise | Privilege Escalation | The lowest level of permissions the adversary is required to be operating within to perform the technique on a system. | +| Effective Permissions | Enterprise | Privilege Escalation | The level of permissions the adversary will attain by performing the technique. | +| Defenses Bypassed | Enterprise | Defense Evasion | List of defensive tools, methodologies, or processes the technique can bypass. | +| Remote Support | Enterprise | Execution | Can the technique can be used to execute something on a remote system? | +| Impact Type | Enterprise | Impact | Denotes if the technique can be used for integrity or availability attacks. | +| CAPEC IDs | Enterprise | (All Tactics) | [CAPEC](https://capec.mitre.org/) IDs associated with the technique. Must follow the format `CAPEC-###`. | +| MTC IDs | Mobile | (All Tactics) | NIST [Mobile Threat Catalogue](https://pages.nist.gov/mobile-threat-catalogue/) IDs associated with the technique. Must follow the format `[Threat Category]-###`. | +| Tactic Type | Mobile | (All Tactics) | "Post-Adversary Device Access", "Pre-Adversary Device Access", or "Without Adversary Device Access". | ##### Technique Relationships -| Relationship Section | Description | -|:-----|:----| -| Sub-techniques / Other Sub-techniques | Sub-techniques of the technique if it is a parent technique, or other sub-techniques of the parent | is a sub-technique. -| Campaigns | Campaigns that use this technique | -| Mitigations | Mitigations that apply to this technique | -| Procedure Examples | Groups and software that use this technique | -| Data Sources (deprecated) | Data components that detect this technique | -| Detection Strategies | Strategies that detect this technique | +| Relationship Section | Description | +| :------------------------------------ | :------------------------------------------------------------------------------------------------- | +| Sub-techniques / Other Sub-techniques | Sub-techniques of the technique if it is a parent technique, or other sub-techniques of the parent | is a sub-technique. | +| Campaigns | Campaigns that use this technique | +| Mitigations | Mitigations that apply to this technique | +| Procedure Examples | Groups and software that use this technique | +| Data Sources (deprecated) | Data components that detect this technique | +| Detection Strategies | Strategies that detect this technique | #### Editing Tactics -Tactics represent the "why" of an ATT&CK technique or sub-technique. It is the adversary's tactical goal: the reason for performing an action. For example, an adversary may want to achieve credential access. +Tactics represent the "why" of an ATT&CK technique or sub-technique. It is the adversary's tactical goal: the reason for performing an action. For example, an adversary may want to achieve credential access. Tactics support the standard set of fields, including a description supporting citations, LinkByIds, and markdown formatting. Tactics must be assigned to a domain before techniques can be assigned to them. The assignment of techniques to tactics can only be done on the techniques page. ##### Tactic Relationships Tactics do not have any associated relationships. + #### Editing Mitigations -Mitigations represent security concepts and classes of technologies that can be used to prevent a technique or sub-technique from being successfully executed. They support the standard set of fields and must be assigned to a domain. +Mitigations represent security concepts and classes of technologies that can be used to prevent a technique or sub-technique from being successfully executed. They support the standard set of fields and must be assigned to a domain. A special mitigation published within the Enterprise domain, "Do Not Mitigate," should be used to mark any techniques which should not be mitigated. + ##### Mitigation Relationships -| Relationship Section | Description | -|:-----|:----| -| Techniques Addressed by Mitigation | Techniques the mitigation addresses / mitigates. | +| Relationship Section | Description | +| :--------------------------------- | :----------------------------------------------- | +| Techniques Addressed by Mitigation | Techniques the mitigation addresses / mitigates. | #### Editing Campaigns @@ -332,11 +342,11 @@ Campaigns support the standard set of fields, including a description supporting ##### Campaign Relationships -| Relationship Section | Description | -|:-----|:----| -| Groups | Groups involved in carrying out the campaign | -| Techniques Used | Techniques used as part of the campaign | -| Software Used | Software used as part of the campaign | +| Relationship Section | Description | +| :------------------- | :------------------------------------------- | +| Groups | Groups involved in carrying out the campaign | +| Techniques Used | Techniques used as part of the campaign | +| Software Used | Software used as part of the campaign | #### Editing Groups @@ -346,11 +356,11 @@ Groups support the standard set of fields as well as the "Associated Groups" fie ##### Group Relationships -| Relationship Section | Description | -|:-----|:----| -| Campaigns | Campaigns attributed to the group | +| Relationship Section | Description | +| :------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Campaigns | Campaigns attributed to the group | | Techniques Used | Techniques used by the group. Note that this should not include indirect usages through software, which should be expressed by mapping to the software itself. | -| Software Used | Software used by the group | +| Software Used | Software used by the group | #### Editing Software @@ -358,21 +368,22 @@ Software is a generic term for custom or commercial code, operating system utili Software support the standard set of fields as well as the "Associated Software" field. Each associated software is tracked using a name and description. The alias description is typically used to hold a set of citations, though plain-text can also be entered alongside citations if additional context is necessary. Alias names cannot be changed after they are added, but the description can be changed by clicking on the entry in the associated software list. -##### Types of Software +##### Types of Software Two types of software exist, _malware_ and _tool_: -- *malware*: commercial, custom closed source, or open source software intended to be used for malicious purposes by adversaries. -- *tool*: commercial, open-source, built-in, or publicly available software that could be used by a defender, pen tester, red teamer, or an adversary. + +- _malware_: commercial, custom closed source, or open source software intended to be used for malicious purposes by adversaries. +- _tool_: commercial, open-source, built-in, or publicly available software that could be used by a defender, pen tester, red teamer, or an adversary. The software type must be selected when creating it and due to limitations of the data model cannot be changed after the software is created. If the type must be changed, create a new object of the other type and _revoke_ the old object with the replacing object. ##### Software Relationships -| Relationship Section | Description | -|:-----|:----| -| Campaigns | Campaigns that use this software | -| Techniques Used | Techniques used by the software | -| Associated Groups | Groups that use this software | +| Relationship Section | Description | +| :------------------- | :------------------------------- | +| Campaigns | Campaigns that use this software | +| Techniques Used | Techniques used by the software | +| Associated Groups | Groups that use this software | #### Editing Data Sources (deprecated) @@ -386,15 +397,15 @@ data component dialog window. ##### Data Source Relationships -| Relationship Section | Description | -|:-----|:----| +| Relationship Section | Description | +| :------------------- | :------------------------------------------ | | Data Components | Data components related to this data source | ##### Data Component Relationships (deprecated) -| Relationship Section | Description | -|:-----|:----| -| Techniques Detected | Techniques detected by the data component | +| Relationship Section | Description | +| :------------------- | :---------------------------------------- | +| Techniques Detected | Techniques detected by the data component | #### Editing Data Components @@ -410,9 +421,9 @@ Detection strategies define high-level approaches for detecting specific adversa ##### Detection Strategy Relationships -| Relationship Section | Description | -|:-----|:----| -| Technqiues | Techniques detected by the detection strategy. | +| Relationship Section | Description | +| :------------------- | :--------------------------------------------- | +| Technqiues | Techniques detected by the detection strategy. | #### Editing Analytics @@ -426,14 +437,13 @@ While Analytics do not have direct relationships with other objects, they are li Relationships map objects to other objects. Relationships have types, sources, and targets. The source and targets define the objects connected by the relationship, and the type is a verb describing the nature of their relationship. - -| Relationship Type | Valid Source Types | Valid Target Types | -|:-----|:----|:---| -| uses | Campaign, Group, Software* | Software*, Technique | -| mitigates | Mitigation | Technique | -| subtechnique-of | Technique | Technique | -| detects | Detection Strategy, Data Component (deprecated) | Technique | -| attributed-to | Campaign | Group | +| Relationship Type | Valid Source Types | Valid Target Types | +| :---------------- | :---------------------------------------------- | :------------------- | +| uses | Campaign, Group, Software* | Software*, Technique | +| mitigates | Mitigation | Technique | +| subtechnique-of | Technique | Technique | +| detects | Detection Strategy, Data Component (deprecated) | Technique | +| attributed-to | Campaign | Group | _\* Relationships cannot be created between two software._ @@ -443,7 +453,7 @@ The source and target objects can be changed after the relationship has been cre Relationships also have a description to provide additional context or to hold citations of relevant reporting. Like all descriptions, those on relationships support citations, LinkByIds, and markdown formatting. Relationships between sub-techniques and techniques however are purely structural and do not support descriptions. -Saving a relationship creates a new relationship revision and returns its connected source and target objects to the *work in progress* workflow state through new SDO revisions. Previously published or snapshot-pinned SDO revisions remain unchanged. +Saving a relationship creates a new relationship revision and returns its connected source and target objects to the _work in progress_ workflow state through new SDO revisions. Previously published or snapshot-pinned SDO revisions remain unchanged. ### Revoking and Deprecating Objects @@ -452,7 +462,7 @@ All objects within the knowledge base can be _revoked_ or _deprecated_. These fu - _Revoked_ objects are objects that are replaced by others within the knowledge base. Revoke an object by clicking the gear icon in the toolbar while on an object page and then clicking "revoke." You will then be prompted to select the revoking (replacing) object. Relationships cannot be revoked, only deprecated. - _Deprecated_ objects are objects that you want to remove without indicating a replacement. Deprecate an object by clicking the gear icon in the toolbar while on an object page and then clicking "deprecate." We also recommend prepending a paragraph to the object description explaining the reason for the deprecation, although this is optional. -When an object is revoked or deprecated, all relationships attached to the object in question will themselves be deprecated. +When an object is revoked or deprecated, all relationships attached to the object in question will themselves be deprecated. ### Deleting Objects @@ -462,9 +472,10 @@ Deletion is limited to Technique, Mitigation, Group, Software, Data Source, Data ## Annotating ATT&CK Data -Annotations allow users to add additional information about an object in the dataset without extending it directly. This is useful for a number of reasons, most notably that incoming updates from a data provider won't overwrite notes but _can_ conflict with local changes to the object itself. +Annotations allow users to add additional information about an object in the dataset without extending it directly. This is useful for a number of reasons, most notably that incoming updates from a data provider won't overwrite notes but _can_ conflict with local changes to the object itself. Uses of notes include but are not limited to: + - Sharing informal knowledge within an organization (e.g "This mitigation might be useful to protect us from _X_") - Recording potential knowledge (e.g "TODO: verify whether the mention in threat report _X_ is actually this technique") - Enabling collaboration in development workflows (e.g "Marcie, make sure to update the platforms once you finish determining the technique scope") @@ -479,7 +490,7 @@ Notes have titles and descriptions, both of which must be filled in order to sav ### Searching notes -Notes are searchable through the "More" option in the dropdown menu within the notes option. You can search notes by content, title, and which user created the note. Clicking on a note within the list will open up the object the note is associated with, as well as the selected note. +Notes are searchable through the "More" option in the dropdown menu within the notes option. You can search notes by content, title, and which user created the note. Clicking on a note within the list will open up the object the note is associated with, as well as the selected note. ## Sharing Your Extensions @@ -488,13 +499,15 @@ Objects you create can be published in collections. Please see the [collections You can create new collections and manage releases from the "my collections" tab of the collections page. This tab will track all published releases of your collections as well as any work in progress releases. Previously published releases cannot be edited, but you can always draft a new release from the most recent version of the collection. ### Staging Changes + When editing a collection, you can stage changes for each object type. Changes are shown as compared to the previous release of the collection, so if you had previously released "example collection v0.1" your staged and potential changes will be shown against that version. Changes are grouped by type: -- *Additions*: Objects added in this release. -- *Changes*: Objects changed by this release where the version number has been incremented. -- *Minor changes*: Objects changed by this release where the version number has _not_ been incremented. -- *Revocations*: Objects that have been revoked by this release. -- *Deprecations*: Objects that have been deprecated by this release. -- *Unchanged*: Objects that have not changed with this release. + +- _Additions_: Objects added in this release. +- _Changes_: Objects changed by this release where the version number has been incremented. +- _Minor changes_: Objects changed by this release where the version number has _not_ been incremented. +- _Revocations_: Objects that have been revoked by this release. +- _Deprecations_: Objects that have been deprecated by this release. +- _Unchanged_: Objects that have not changed with this release. Within each change type, two lists are shown. On the left are _potential changes_, the contents of your knowledge base that you can add to your collection. On the right are the staged changes for the given change section. For instance, for "additions", the left list shows objects not present in the collection at all, and the right list shows objects which have been added in this release. For "changes", the left list shows objects with newer versions available in the knowledge base, and the right list shows staged changes. @@ -503,18 +516,19 @@ You can move objects from the left ("potential") list to the right ("staged") li Clicking on an object within the list will open a preview dialog to show the contents of the object. #### Importing a group into a collection + When editing a collection, there is an option to import a group and all of it's related objects into the collection being edited. ### Handling Relationships Unlike other object types, relationships are handled automatically by the system. When a collection is saved, the relationships included are determined automatically according to the other contents of the collection. - - All relationships between objects in the collection are included at their most recent version. - - New relationships between objects already in the collection are included even if their attached objects did not change or the changes to said objects were not staged. - - Existing relationships are updated if newer versions are available even if the objects they are attached to did not change or the changes to said objects were not staged. - - New and updated relationships are added at the version they existed at when the collection is saved; further updates to relationships after saving will not be included. - - Relationships are only included if both of their attached objects are in the collection. - - Relationships conveying revocations will be included only if the revoked version of the object they are attached to is included (staged) in the collection. Objects which have revoked versions not included in the collection won't trigger the inclusion of revoking relationships. +- All relationships between objects in the collection are included at their most recent version. + - New relationships between objects already in the collection are included even if their attached objects did not change or the changes to said objects were not staged. + - Existing relationships are updated if newer versions are available even if the objects they are attached to did not change or the changes to said objects were not staged. + - New and updated relationships are added at the version they existed at when the collection is saved; further updates to relationships after saving will not be included. +- Relationships are only included if both of their attached objects are in the collection. +- Relationships conveying revocations will be included only if the revoked version of the object they are attached to is included (staged) in the collection. Objects which have revoked versions not included in the collection won't trigger the inclusion of revoking relationships. A summary of the relationships included is provided when saving the relationship. @@ -523,7 +537,7 @@ A summary of the relationships included is provided when saving the relationship After drafting multiple iterations of a new release, it comes time to mark one as the actual version to be released. This can be done prior to saving the collection by checking the "is release version?" checkbox. This has several effects: - Versions marked as releases will be considered when determining the changes between collection releases. The next version you create after the release will be compared to this prior release when staging changes. -- Collection releases will show up independently within the collections list. +- Collection releases will show up independently within the collections list. These effects will occur even if the collection you marked as release was never published, and you cannot un-mark a collection version as a release. Therefore it is very important to be sure that the version you mark as a release is actually the one you intend to publish. @@ -531,19 +545,18 @@ These effects will occur even if the collection you marked as release was never The data from a collection can be accessed as a raw STIX bundle from the collection view page. A hyperlink is provided for use in scripts or tools which are built to pull collections over HTTP, and the download button can also be used to download the collection data as a JSON file. These resources provide the means to publish your collections for other users of the ATT&CK Workbench, whether it be by uploading the JSON to GitHub, mailing a floppy disk, or some other means of data transmission. - ## Teams Workbench now supports the ability to create teams in order to allow admins to keep track of users and the changes they make within workbench. ### View a team -Admins have an option within the "More" dropdown option to select teams. This brings up a list of every team present in your instance of workbench. You can search teams by their name or description. +Admins have an option within the "More" dropdown option to select teams. This brings up a list of every team present in your instance of workbench. You can search teams by their name or description. ### Creating a team -Within the page showing the list of teams, there is an option to create a new team. Teams require a name and have an optional description field. Users can be added or removed from a team at any time. +Within the page showing the list of teams, there is an option to create a new team. Teams require a name and have an optional description field. Users can be added or removed from a team at any time. ### Editing a team -When viewing a team, click the edit icon in the toolbar to edit it. You can edit the name, description, or user list of a team at any time. +When viewing a team, click the edit icon in the toolbar to edit it. You can edit the name, description, or user list of a team at any time. diff --git a/src/app/classes/release-tracks/api.ts b/src/app/classes/release-tracks/api.ts index d71b16be..6437c61c 100644 --- a/src/app/classes/release-tracks/api.ts +++ b/src/app/classes/release-tracks/api.ts @@ -29,6 +29,8 @@ export interface StixBundlePayload { export interface UpdateMetadataPayload { name?: string; description?: string; + /** URL-safe slug accepted wherever the track ID is; null clears it. */ + alias?: string | null; } export interface UpdateContentsPayload { @@ -58,8 +60,8 @@ export interface PromoteQuarantinePayload { export interface ReleaseTrackSnapshotOptions { format?: ExportFormatType; + /** Workbench responses only; bundles reject it (they replay the sealed manifest). */ include?: 'members' | 'staged' | 'candidates' | 'quarantine' | 'all'; - state?: string | string[]; stixVersion?: '2.0' | '2.1'; } diff --git a/src/app/classes/release-tracks/release-track.ts b/src/app/classes/release-tracks/release-track.ts index 0ee7b53a..2a1c7e77 100644 --- a/src/app/classes/release-tracks/release-track.ts +++ b/src/app/classes/release-tracks/release-track.ts @@ -4,6 +4,8 @@ export interface ReleaseTrack { track_id: string; type: ReleaseTrackType; name: string; + /** Optional URL-safe slug accepted wherever the track ID is. */ + alias?: string | null; description?: string; created_at: Date; updated_at: Date; diff --git a/src/app/classes/release-tracks/snapshot.ts b/src/app/classes/release-tracks/snapshot.ts index 6bea1190..7ce8f75e 100644 --- a/src/app/classes/release-tracks/snapshot.ts +++ b/src/app/classes/release-tracks/snapshot.ts @@ -18,6 +18,8 @@ export class ReleaseTrackSnapshot { public modified: Date = new Date(); public version?: string | null; public name = ''; + /** Registry alias for the track, attached to workbench responses. */ + public alias?: string | null; public description?: string; public snapshot_description?: string; public created: Date = new Date(); @@ -95,6 +97,7 @@ export class ReleaseTrackSnapshot { if ('modified' in raw) this.modified = new Date(raw.modified); if ('version' in raw) this.version = raw.version; if ('name' in raw) this.name = raw.name; + if ('alias' in raw) this.alias = raw.alias; if ('description' in raw) this.description = raw.description; if ('snapshot_description' in raw) this.snapshot_description = raw.snapshot_description; diff --git a/src/app/classes/release-tracks/tiers.ts b/src/app/classes/release-tracks/tiers.ts index bae5f0ad..7bba8f20 100644 --- a/src/app/classes/release-tracks/tiers.ts +++ b/src/app/classes/release-tracks/tiers.ts @@ -16,11 +16,15 @@ export interface TierEntryModifiedByUser { export interface TierEntryDisplayFields { attack_id?: string; name?: string; + /** STIX object type of the selected revision */ + type?: string; + /** ATT&CK version of the selected revision */ + x_mitre_version?: string; description?: string; modified_by_user?: TierEntryModifiedByUser; } -export interface MemberEntry { +export interface MemberEntry extends TierEntryDisplayFields { object_ref: string; object_modified: Date; } diff --git a/src/app/components/release-track-card/release-track-card.component.ts b/src/app/components/release-track-card/release-track-card.component.ts index 2f64b8a1..318526c3 100644 --- a/src/app/components/release-track-card/release-track-card.component.ts +++ b/src/app/components/release-track-card/release-track-card.component.ts @@ -28,7 +28,9 @@ export class ReleaseTrackCardComponent { } public onViewTrack(): void { - const id = this.track?.id || this.track?.track_id || null; + // Prefer the alias so the page URL reads as the track's slug. + const id = + this.track?.alias || this.track?.id || this.track?.track_id || null; if (id) this.viewTrack.emit(id); } } diff --git a/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.html b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.html index 5b74be55..9a967f45 100644 --- a/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.html +++ b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.html @@ -1,7 +1,7 @@ - DETAILS + {{ detailsLabel | uppercase }}
diff --git a/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.ts b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.ts index fc4f67df..d3fb775c 100644 --- a/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.ts +++ b/src/app/components/stix/stix-page-tabs/stix-page-tabs.component.ts @@ -17,6 +17,8 @@ interface CustomTab { export class StixPageTabsComponent { @Input() config!: StixViewConfig; @Input() detailsTemplate!: TemplateRef; + /** Label of the details tab; pages whose first tab is not a plain details view override it. */ + @Input() detailsLabel = 'Details'; @Input() customTabs: CustomTab[] = []; @Input() showHistory = true; @Input() showNotes = true; diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html index 8d3893d5..86238c8a 100644 --- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html @@ -3,37 +3,32 @@

{{ releaseTrackName }}

-
- - +
-
+

+ + Preparing the release preview. Large tracks can take a while; stay on this + page until the preview opens. +

mat-stroked-button [disabled]="isReleasing || !item.modified" (click)="onTagSnapshot(item)"> - sell - Preview & Tag + + sell + {{ + isPreviewingSnapshot(item) + ? 'Preparing preview' + : 'Preview & Tag' + }}
+
+
+

Address

+ +
+
+
Alias
+

+ Optional short name accepted wherever the track ID is, + including this page's URL and API paths. Lowercase letters, + digits, and hyphens; unique across tracks. +

+
+ + + Alias + + {{ aliasUrlPreview }} + + Use 2–64 lowercase letters, digits, or hyphens, starting and + ending with a letter or digit. + + + + + {{ releaseTrack.alias }} + + None + + +
+
+
+
@@ -1640,6 +1685,33 @@

Publication

+ +
+
+

Danger zone

+
+
+
Delete this release track
+

+ Permanently deletes the track with every snapshot and release in + its history. You will be asked to type the track ID to confirm. +

+
+ +
+
+
diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.scss b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.scss index 1bbb004d..b229981b 100644 --- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.scss +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.scss @@ -47,6 +47,21 @@ $released-members-disabled-dark: colors.on-color-deemphasis(dark); gap: 8px; } + .release-preview-status { + display: flex; + align-items: center; + gap: 8px; + margin: -6px 0 18px; + font-size: 14px; + opacity: 0.85; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + } + .review-tpl-wrapper { margin-top: 20px; } @@ -72,6 +87,14 @@ $released-members-disabled-dark: colors.on-color-deemphasis(dark); gap: 16px; } + .config-card.danger-zone { + border-color: colors.color(error); + + h3 { + color: colors.color(error); + } + } + .config-card > .content { display: block; padding: 22px 24px; diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts index 6824474e..21a9e868 100644 --- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.spec.ts @@ -15,7 +15,6 @@ import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; import { BreadcrumbService } from 'src/app/services/helpers/breadcrumb.service'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; -import { MultipleChoiceDialogComponent } from 'src/app/components/multiple-choice-dialog/multiple-choice-dialog.component'; import { AddDialogComponent } from 'src/app/components/add-dialog/add-dialog.component'; import { DeleteDialogComponent } from 'src/app/components/delete-dialog/delete-dialog.component'; import { ReleasePreviewDialogComponent } from 'src/app/components/release-preview-dialog/release-preview-dialog.component'; @@ -234,68 +233,7 @@ describe('ReleaseTrackPageComponent', () => { expect(mockDialog.open).not.toHaveBeenCalled(); }); - it('should download the release track in the selected export format', () => { - const exportPayload = { type: 'bundle', objects: [] }; - mockDialog.open.mockReturnValue({ - afterClosed: () => of('bundle-stix-2.1'), - }); - mockReleaseTrackApiConnector.exportLatestSnapshot.mockReturnValue( - of(exportPayload) - ); - component.id = 'release-track--123'; - component.releaseTrack = { name: 'Enterprise Release' } as any; - - component.onExport(); - - const choices = mockDialog.open.mock.calls[0][1].data.choices; - expect(mockDialog.open).toHaveBeenCalledWith( - MultipleChoiceDialogComponent, - expect.anything() - ); - expect(choices.map((choice: any) => choice.value)).toEqual([ - 'bundle-stix-2.0', - 'bundle-stix-2.1', - 'workbench', - ]); - expect(choices.map((choice: any) => choice.label)).toEqual([ - 'Bundle (STIX 2.0)', - 'Bundle (STIX 2.1)', - 'Workbench', - ]); - expect( - mockReleaseTrackApiConnector.exportLatestSnapshot - ).toHaveBeenCalledWith('release-track--123', 'bundle', { - include: 'all', - stixVersion: '2.1', - }); - expect(mockRestApiConnector.triggerBrowserDownload).toHaveBeenCalledWith( - exportPayload, - 'enterprise-release-latest-bundle-stix-2.1.json' - ); - }); - - it('should not export when the dialog is dismissed', () => { - mockDialog.open.mockReturnValue({ - afterClosed: () => of(null), - }); - component.id = 'release-track--123'; - - component.onExport(); - - expect( - mockReleaseTrackApiConnector.exportLatestSnapshot - ).not.toHaveBeenCalled(); - }); - - it('should not open the export dialog without a release track id', () => { - component.id = ''; - - component.onExport(); - - expect(mockDialog.open).not.toHaveBeenCalled(); - }); - - it('should export a standard draft snapshot with staged content', () => { + it('should export a standard draft snapshot as a sealed bundle', () => { const exportPayload = { type: 'bundle', objects: [] }; mockDialog.open.mockReturnValue({ afterClosed: () => of('bundle-stix-2.0'), @@ -332,7 +270,7 @@ describe('ReleaseTrackPageComponent', () => { 'release-track--123', '2024-05-21T07:00:00.000Z', 'bundle', - { include: 'staged', stixVersion: '2.0' } + { stixVersion: '2.0' } ); expect(mockRestApiConnector.triggerBrowserDownload).toHaveBeenCalledWith( exportPayload, @@ -615,6 +553,106 @@ describe('ReleaseTrackPageComponent', () => { expect(component.releaseTrack?.name).toBe('Enterprise Release'); }); + it('should adopt the canonical id when the route carries an alias', () => { + mockReleaseTrackApiConnector.getLatestSnapshot.mockReturnValue( + of({ id: 'release-track--123', alias: 'core-team', name: 'Core Team' }) + ); + component.id = 'core-team'; + + component.getReleaseTrack(); + + expect(mockReleaseTrackApiConnector.getLatestSnapshot).toHaveBeenCalledWith( + 'core-team', + { format: 'workbench', include: 'all' } + ); + expect(component.id).toBe('release-track--123'); + expect(component.configForm.get('alias')?.value).toBe('core-team'); + expect(component.aliasUrlPreview).toBe( + '/dashboard/release-management/core-team' + ); + }); + + it('should save an alias change through metadata before the config', () => { + vi.spyOn(component, 'getReleaseTrack').mockImplementation(() => undefined); + vi.spyOn(component, 'getSnapshotHistory').mockImplementation( + () => undefined + ); + mockReleaseTrackApiConnector.updateMetadataByLatest.mockReturnValue(of({})); + mockReleaseTrackApiConnector.updateConfig.mockReturnValue(of({})); + component.id = 'release-track--123'; + component.releaseTrack = { + id: 'release-track--123', + alias: null, + config: {}, + } as any; + component.configForm.patchValue({ alias: 'core-team' }); + component.isEditingConfig = true; + + component.onSaveConfig(); + + expect( + mockReleaseTrackApiConnector.updateMetadataByLatest + ).toHaveBeenCalledWith('release-track--123', { alias: 'core-team' }); + expect(mockReleaseTrackApiConnector.updateConfig).toHaveBeenCalled(); + expect(component.getReleaseTrack).toHaveBeenCalled(); + }); + + it('should clear an alias by saving an empty value', () => { + vi.spyOn(component, 'getReleaseTrack').mockImplementation(() => undefined); + mockReleaseTrackApiConnector.updateMetadataByLatest.mockReturnValue(of({})); + mockReleaseTrackApiConnector.updateConfig.mockReturnValue(of({})); + component.id = 'release-track--123'; + component.releaseTrack = { + id: 'release-track--123', + alias: 'core-team', + config: {}, + } as any; + component.configForm.patchValue({ alias: '' }); + component.isEditingConfig = true; + + component.onSaveConfig(); + + expect( + mockReleaseTrackApiConnector.updateMetadataByLatest + ).toHaveBeenCalledWith('release-track--123', { alias: null }); + }); + + it('should not save the config while the alias is invalid', () => { + mockReleaseTrackApiConnector.updateConfig.mockReturnValue(of({})); + component.id = 'release-track--123'; + component.releaseTrack = { id: 'release-track--123', config: {} } as any; + component.configForm.patchValue({ alias: 'Bad Alias' }); + component.isEditingConfig = true; + + component.onSaveConfig(); + + expect( + mockReleaseTrackApiConnector.updateMetadataByLatest + ).not.toHaveBeenCalled(); + expect(mockReleaseTrackApiConnector.updateConfig).not.toHaveBeenCalled(); + expect(component.configForm.get('alias')?.touched).toBe(true); + }); + + it('should leave metadata alone when the alias is unchanged', () => { + vi.spyOn(component, 'getReleaseTrack').mockImplementation(() => undefined); + mockReleaseTrackApiConnector.updateConfig.mockReturnValue(of({})); + component.id = 'release-track--123'; + component.releaseTrack = { + id: 'release-track--123', + alias: 'core-team', + config: {}, + } as any; + component.configForm.patchValue({ alias: 'core-team' }); + component.isEditingConfig = true; + + component.onSaveConfig(); + + expect( + mockReleaseTrackApiConnector.updateMetadataByLatest + ).not.toHaveBeenCalled(); + expect(mockReleaseTrackApiConnector.updateConfig).toHaveBeenCalled(); + }); + it('should load release track summary when latest snapshot is unavailable', () => { mockReleaseTrackApiConnector.getLatestSnapshot.mockReturnValue(of(null)); mockReleaseTrackApiConnector.listReleaseTracks.mockReturnValue( @@ -1335,7 +1373,7 @@ describe('ReleaseTrackPageComponent', () => { ); }); - it('should preview and tag the latest draft release', () => { + it('should preview and tag the current draft from its card', () => { const refreshSpy = vi .spyOn(component, 'getReleaseTrack') .mockImplementation(() => undefined); @@ -1362,40 +1400,43 @@ describe('ReleaseTrackPageComponent', () => { conflicts: [], }) ); - mockReleaseTrackApiConnector.releaseLatest.mockReturnValue(of({})); - component.releaseTrack = { - id: 'release-track--123', - name: 'Core Objects', - version: null, - members: [], - staged: [], - candidates: [ - { - object_ref: 'attack-pattern--candidate', - name: 'Canonical Candidate', - attack_type: 'technique', - x_mitre_version: '2.1', - object_status: 'awaiting-review', - }, - ], - } as any; + mockReleaseTrackApiConnector.retrieveSnapshotByModified.mockReturnValue( + of({ + id: 'release-track--123', + name: 'Core Objects', + version: null, + members: [], + staged: [], + candidates: [ + { + object_ref: 'attack-pattern--candidate', + name: 'Canonical Candidate', + type: 'attack-pattern', + x_mitre_version: '2.1', + object_status: 'awaiting-review', + }, + ], + }) + ); + mockReleaseTrackApiConnector.releaseSnapshot.mockReturnValue(of({})); mockDialog.open.mockReturnValue({ afterClosed: () => of({ increment: 'minor', description: 'First release context' }), }); component.id = 'release-track--123'; - component.onPreviewRelease(); + component.onTagSnapshot({ + modified: '2026-07-30T14:00:00.000Z', + isTagged: false, + snapshot: {}, + } as any); expect(mockReleaseTrackApiConnector.previewRelease).toHaveBeenCalledWith( 'release-track--123', - { format: 'summary', increment: 'minor' } + { format: 'summary', increment: 'minor' }, + '2026-07-30T14:00:00.000Z' ); - expect(mockRestApiConnector.getAllObjects).toHaveBeenCalledWith({ - revoked: true, - deprecated: true, - versions: 'all', - }); + expect(mockRestApiConnector.getAllObjects).not.toHaveBeenCalled(); expect(mockDialog.open).toHaveBeenCalledWith( ReleasePreviewDialogComponent, expect.objectContaining({ @@ -1414,10 +1455,12 @@ describe('ReleaseTrackPageComponent', () => { }), }) ); - expect(mockReleaseTrackApiConnector.releaseLatest).toHaveBeenCalledWith( + expect(mockReleaseTrackApiConnector.releaseSnapshot).toHaveBeenCalledWith( 'release-track--123', + '2026-07-30T14:00:00.000Z', { increment: 'minor', description: 'First release context' } ); + expect(mockReleaseTrackApiConnector.releaseLatest).not.toHaveBeenCalled(); expect(refreshSpy).toHaveBeenCalled(); expect(historySpy).toHaveBeenCalled(); expect(component.isReleasing).toBe(false); @@ -1458,43 +1501,25 @@ describe('ReleaseTrackPageComponent', () => { { object_ref: 'malware--replacement', object_modified: '2026-07-01T12:00:00.000Z', + attack_id: 'S0001', + name: 'Replacement Example', + type: 'malware', + x_mitre_version: '1.0', }, ], staged: [ { object_ref: 'malware--replacement', object_modified: '2026-07-23T12:00:00.000Z', + attack_id: 'S0001', + name: 'Replacement Example', + type: 'malware', + x_mitre_version: '1.1', }, ], candidates: [], }) ); - mockRestApiConnector.getAllObjects.mockReturnValue( - of( - createPaginatedResponse([ - { - workspace: { attack_id: 'S0001' }, - stix: { - id: 'malware--replacement', - modified: '2026-07-01T12:00:00.000Z', - name: 'Replacement Example', - type: 'malware', - x_mitre_version: '1.0', - }, - }, - { - workspace: { attack_id: 'S0001' }, - stix: { - id: 'malware--replacement', - modified: '2026-07-23T12:00:00.000Z', - name: 'Replacement Example', - type: 'malware', - x_mitre_version: '1.1', - }, - }, - ]) - ) - ); mockDialog.open.mockReturnValue({ afterClosed: () => of({ version: '1.5', description: 'Exact release context' }), @@ -1518,11 +1543,7 @@ describe('ReleaseTrackPageComponent', () => { format: 'workbench', include: 'all', }); - expect(mockRestApiConnector.getAllObjects).toHaveBeenCalledWith({ - revoked: true, - deprecated: true, - versions: 'all', - }); + expect(mockRestApiConnector.getAllObjects).not.toHaveBeenCalled(); expect(mockDialog.open).toHaveBeenCalledWith( ReleasePreviewDialogComponent, expect.objectContaining({ @@ -1531,11 +1552,13 @@ describe('ReleaseTrackPageComponent', () => { members: [ expect.objectContaining({ x_mitre_version: '1.0', + attack_type: 'software', }), ], staged: [ expect.objectContaining({ x_mitre_version: '1.1', + attack_type: 'software', }), ], }), @@ -1553,53 +1576,6 @@ describe('ReleaseTrackPageComponent', () => { expect(component.isReleasing).toBe(false); }); - it('should preview the newest draft when multiple drafts exist', () => { - mockReleaseTrackApiConnector.previewRelease.mockReturnValue( - of({ version: '1.1', conflicts: [] }) - ); - mockReleaseTrackApiConnector.retrieveSnapshotByModified.mockReturnValue( - of({ - id: 'release-track--123', - modified: '2026-07-30T14:00:00.000Z', - members: [], - staged: [], - candidates: [], - }) - ); - mockDialog.open.mockReturnValue({ - afterClosed: () => of(undefined), - }); - component.id = 'release-track--123'; - component.releaseTrack = { - id: 'release-track--123', - version: null, - } as any; - component.snapshotHistory = [ - { - modified: '2026-07-30T14:00:00.000Z', - isTagged: false, - }, - { - modified: '2026-07-29T14:00:00.000Z', - isTagged: false, - }, - ] as any; - - component.onPreviewRelease(); - - expect(mockReleaseTrackApiConnector.previewRelease).toHaveBeenCalledWith( - 'release-track--123', - { format: 'summary', increment: 'minor' }, - '2026-07-30T14:00:00.000Z' - ); - expect( - mockReleaseTrackApiConnector.retrieveSnapshotByModified - ).toHaveBeenCalledWith('release-track--123', '2026-07-30T14:00:00.000Z', { - format: 'workbench', - include: 'all', - }); - }); - it('should not tag a release when preview returns conflicts', () => { mockReleaseTrackApiConnector.previewRelease.mockReturnValue( of({ @@ -1614,18 +1590,19 @@ describe('ReleaseTrackPageComponent', () => { ], }) ); - component.releaseTrack = { - id: 'release-track--123', - members: [], - staged: [], - candidates: [], - } as any; + mockReleaseTrackApiConnector.retrieveSnapshotByModified.mockReturnValue( + of({ id: 'release-track--123', members: [], staged: [], candidates: [] }) + ); mockDialog.open.mockReturnValue({ afterClosed: () => of(undefined), }); component.id = 'release-track--123'; - component.onPreviewRelease(); + component.onTagSnapshot({ + modified: '2026-07-30T14:00:00.000Z', + isTagged: false, + snapshot: {}, + } as any); expect(mockDialog.open).toHaveBeenCalledWith( ReleasePreviewDialogComponent, @@ -1639,7 +1616,7 @@ describe('ReleaseTrackPageComponent', () => { }), }) ); - expect(mockReleaseTrackApiConnector.releaseLatest).not.toHaveBeenCalled(); + expect(mockReleaseTrackApiConnector.releaseSnapshot).not.toHaveBeenCalled(); }); it('should not create a snapshot when the preview is cancelled', () => { @@ -1649,18 +1626,19 @@ describe('ReleaseTrackPageComponent', () => { conflicts: [], }) ); - component.releaseTrack = { - id: 'release-track--123', - members: [], - staged: [], - candidates: [], - } as any; + mockReleaseTrackApiConnector.retrieveSnapshotByModified.mockReturnValue( + of({ id: 'release-track--123', members: [], staged: [], candidates: [] }) + ); mockDialog.open.mockReturnValue({ afterClosed: () => of(undefined), }); component.id = 'release-track--123'; - component.onPreviewRelease(); + component.onTagSnapshot({ + modified: '2026-07-30T14:00:00.000Z', + isTagged: false, + snapshot: {}, + } as any); expect(mockDialog.open).toHaveBeenCalledWith( ReleasePreviewDialogComponent, @@ -1672,7 +1650,7 @@ describe('ReleaseTrackPageComponent', () => { }), }) ); - expect(mockReleaseTrackApiConnector.releaseLatest).not.toHaveBeenCalled(); + expect(mockReleaseTrackApiConnector.releaseSnapshot).not.toHaveBeenCalled(); }); it('should stop releasing when the preview request fails', () => { @@ -1683,14 +1661,15 @@ describe('ReleaseTrackPageComponent', () => { throwError(() => new Error('preview failed')) ); component.id = 'release-track--123'; - component.releaseTrack = { - id: 'release-track--123', - version: null, - } as any; - component.onPreviewRelease(); + component.onTagSnapshot({ + modified: '2026-07-30T14:00:00.000Z', + isTagged: false, + snapshot: {}, + } as any); expect(component.isReleasing).toBe(false); + expect(component.previewingSnapshotModified).toBeNull(); expect(consoleSpy).toHaveBeenCalledWith( 'Failed to load objects for release preview', expect.any(Error) @@ -1700,13 +1679,16 @@ describe('ReleaseTrackPageComponent', () => { it('should notify the user when the preview response is empty', () => { mockReleaseTrackApiConnector.previewRelease.mockReturnValue(of(null)); + mockReleaseTrackApiConnector.retrieveSnapshotByModified.mockReturnValue( + of({ id: 'release-track--123' }) + ); component.id = 'release-track--123'; - component.releaseTrack = { - id: 'release-track--123', - version: null, - } as any; - component.onPreviewRelease(); + component.onTagSnapshot({ + modified: '2026-07-30T14:00:00.000Z', + isTagged: false, + snapshot: {}, + } as any); expect(mockSnackbar.open).toHaveBeenCalledWith( 'Unable to load the release preview. Please try again.', @@ -1720,34 +1702,17 @@ describe('ReleaseTrackPageComponent', () => { expect(component.isReleasing).toBe(false); }); - it('should keep Preview & Release enabled for a tagged snapshot', () => { + it('should ignore tagging requests for released snapshots', () => { component.id = 'release-track--123'; - component.releaseTrack = { - id: 'release-track--123', - version: '1.0', - } as any; - fixture.detectChanges(); - - const previewButton = Array.from( - fixture.nativeElement.querySelectorAll('button') - ).find((button: Element) => - button.textContent?.includes('Preview & Release') - ) as HTMLButtonElement; - - expect(previewButton).toBeTruthy(); - expect(previewButton.disabled).toBe(false); - component.onPreviewRelease(); + component.onTagSnapshot({ + modified: '2026-07-30T14:00:00.000Z', + isTagged: true, + snapshot: {}, + } as any); expect(mockReleaseTrackApiConnector.previewRelease).not.toHaveBeenCalled(); - expect(mockDialog.open).toHaveBeenCalledWith( - MultipleChoiceDialogComponent, - expect.objectContaining({ - data: expect.objectContaining({ - title: 'No draft snapshot available', - }), - }) - ); + expect(mockDialog.open).not.toHaveBeenCalled(); }); it('should load release track config into the config form', () => { diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts index b658a12c..7e91e111 100644 --- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts @@ -1,7 +1,7 @@ import { Clipboard } from '@angular/cdk/clipboard'; import { SelectionModel } from '@angular/cdk/collections'; import { Component, OnInit } from '@angular/core'; -import { FormBuilder, FormGroup } from '@angular/forms'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; import { ActivatedRoute, Router } from '@angular/router'; @@ -36,6 +36,7 @@ import { SnapshotTier, SnapshotTierType, StixObjectRef, + UpdateMetadataPayload, } from 'src/app/classes/release-tracks'; import { StixObject } from 'src/app/classes/stix'; import { AddDialogComponent } from 'src/app/components/add-dialog/add-dialog.component'; @@ -93,6 +94,9 @@ interface SnapshotMemberRef { object_modified?: string; } +/** Mirrors the API's alias rule: 2-64 lowercase letters, digits, and hyphens. */ +export const TRACK_ALIAS_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])$/; + interface SnapshotHistoryViewModel { snapshot: ReleaseTrackSnapshotHistoryItem; title: string; @@ -101,6 +105,8 @@ interface SnapshotHistoryViewModel { taggedAt: Date | null; isTagged: boolean; isLatest: boolean; + /** The track's most recent release: the only one that can be deleted. */ + isLatestRelease: boolean; isCurrentDraft: boolean; stats: SnapshotHistoryStat[]; contentStats: SnapshotHistoryStat[]; @@ -211,6 +217,8 @@ export class ReleaseTrackPageComponent implements OnInit { public isDeleting = false; public isLoadingSnapshotHistory = false; public isReleasing = false; + /** Snapshot whose release preview is being prepared ('latest' for the header action). */ + public previewingSnapshotModified: string | null = null; public isLoadingConfig = false; public isEditingConfig = false; public isSavingConfig = false; @@ -262,6 +270,10 @@ export class ReleaseTrackPageComponent implements OnInit { private fb: FormBuilder ) { this.configForm = this.fb.group({ + alias: [ + '', + [Validators.maxLength(64), Validators.pattern(TRACK_ALIAS_PATTERN)], + ], autoPromote: [true], candidacyThreshold: [WorkflowStatus.Reviewed], memberSyncStrategy: [MemberSyncStrategy.Manual], @@ -536,12 +548,6 @@ export class ReleaseTrackPageComponent implements OnInit { ); } - private get latestDraftSnapshot(): SnapshotHistoryViewModel | undefined { - return this.snapshotHistory.find((snapshot, index) => - this.isCurrentDraftHistoryItem(snapshot, index) - ); - } - private isCurrentDraftHistoryItem( item: SnapshotHistoryViewModel, index: number @@ -601,6 +607,12 @@ export class ReleaseTrackPageComponent implements OnInit { private setReleaseTrack(track: any): void { this.releaseTrack = track; if (!this.releaseTrack) return; + // The route may carry the track's alias; work with the canonical id from + // here on so confirmations and comparisons never see the alias. + if (this.releaseTrack.id && this.releaseTrack.id !== this.id) { + this.id = this.releaseTrack.id; + } + this.syncAliasControl(); this.hydrateDynamicEntryDates(); this.breadcrumbService.changeBreadcrumb( @@ -1560,18 +1572,6 @@ export class ReleaseTrackPageComponent implements OnInit { return `${typeLabel.replace(/\b\w/g, char => char.toUpperCase())} ${id.slice(0, 8)}`; } - public onExport(): void { - if (!this.id) return; - - this.openExportFormatDialog('Export latest release snapshot') - .pipe(take(1)) - .subscribe(choice => { - const selection = this.getSnapshotExportSelection(choice); - if (!selection) return; - this.downloadLatestReleaseTrack(selection); - }); - } - private openExportFormatDialog( title: string, includeSummary = false @@ -1651,38 +1651,6 @@ export class ReleaseTrackPageComponent implements OnInit { return null; } - private downloadLatestReleaseTrack(selection: SnapshotExportSelection): void { - const options: Omit = { - include: 'all', - ...(selection.stixVersion ? { stixVersion: selection.stixVersion } : {}), - }; - this.connector - .exportLatestSnapshot(this.id, selection.format, options) - .pipe(take(1)) - .subscribe({ - next: result => { - this.restApiConnectorService.triggerBrowserDownload( - result, - this.getExportFilename(selection) - ); - }, - error: err => { - console.error('Failed to export release track', err); - }, - }); - } - - private getExportFilename(selection: SnapshotExportSelection): string { - const name = this.releaseTrackName || this.id || 'release-track'; - const safeName = - name - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') || 'release-track'; - return `${safeName}-latest-${this.getExportFilenameSuffix(selection)}.json`; - } - public onDraft(): void { if (!this.canCreateDraft) return; @@ -1803,6 +1771,7 @@ export class ReleaseTrackPageComponent implements OnInit { public onEditConfig(): void { if (this.isVirtualReleaseTrack) this.setVirtualConfig(); this.loadPublicationOptions(); + this.syncAliasControl(); this.isEditingConfig = true; } @@ -1812,20 +1781,57 @@ export class ReleaseTrackPageComponent implements OnInit { } else { this.setConfig(this.releaseTrackConfig); } + this.syncAliasControl(); this.isEditingConfig = false; } + public get aliasUrlPreview(): string { + const alias = (this.configForm.get('alias')?.value ?? '').trim(); + return `/dashboard/release-management/${alias || ''}`; + } + + private syncAliasControl(): void { + this.configForm + .get('alias') + ?.setValue(this.releaseTrack?.alias ?? '', { emitEvent: false }); + } + + /** The metadata update that brings the registry alias in line with the form, if any. */ + private getAliasUpdate(): UpdateMetadataPayload | null { + const draft = (this.configForm.get('alias')?.value ?? '').trim(); + const current = this.releaseTrack?.alias ?? ''; + if (draft === current) return null; + return { alias: draft || null }; + } + + /** + * The alias is registry metadata saved through the metadata endpoint, so a + * config save first applies any alias change, then runs the config write. + */ + private withAliasUpdate(next: () => Observable): Observable { + const update = this.getAliasUpdate(); + if (!update) return next(); + return this.connector + .updateMetadataByLatest(this.id, update) + .pipe(switchMap(() => next())); + } + public onSaveConfig(): void { if (!this.id || this.isSavingConfig) return; + const aliasControl = this.configForm.get('alias'); + if (aliasControl?.invalid) { + aliasControl.markAsTouched(); + return; + } if (this.isVirtualReleaseTrack) { this.saveVirtualConfig(); return; } const payload = this.getConfigPayload(); + const aliasChanged = !!this.getAliasUpdate(); this.isSavingConfig = true; - this.connector - .updateConfig(this.id, payload) + this.withAliasUpdate(() => this.connector.updateConfig(this.id, payload)) .pipe( take(1), finalize(() => { @@ -1840,6 +1846,7 @@ export class ReleaseTrackPageComponent implements OnInit { this.releaseTrack.config = this.releaseTrackConfig; this.refreshReleaseTrackState(); this.getConfig(); + if (aliasChanged) this.getReleaseTrack(); }, error: err => { console.error('Failed to update release track config', err); @@ -1847,65 +1854,33 @@ export class ReleaseTrackPageComponent implements OnInit { }); } - public onPreviewRelease(): void { - if (!this.latestDraftSnapshot && this.releaseTrack?.version != null) { - this.openNoDraftSnapshotDialog(); - return; - } - - this.previewRelease(this.latestDraftSnapshot); - } - - private openNoDraftSnapshotDialog(): void { - this.dialog.open(MultipleChoiceDialogComponent, { - width: '30em', - autoFocus: false, - restoreFocus: true, - data: { - title: 'No draft snapshot available', - description: - 'The latest snapshot has already been released. Modify the release track to create a new draft before previewing another release.', - choices: [ - { - label: 'Close', - value: 'close', - }, - ], - }, - }); - } - + /** + * Tagging is the second step of the draft-then-tag flow: a draft snapshot + * (implicit for standard tracks, materialized for virtual ones) is + * previewed and then tagged from its card on the Releases tab. + */ public onTagSnapshot(item: SnapshotHistoryViewModel): void { if (!item || item.isTagged || !item.modified) return; this.previewRelease(item); } - private previewRelease(item?: SnapshotHistoryViewModel): void { - if (!this.id || this.isReleasing) return; + private previewRelease(item: SnapshotHistoryViewModel): void { + if (!this.id || !item.modified || this.isReleasing) return; this.isReleasing = true; + this.previewingSnapshotModified = item.modified; const selection: ReleasePayload = { increment: 'minor' }; + // The workbench snapshot already carries each entry's name, ATT&CK ID, + // type, and version, so no catalogue download is needed for the dialog. const preview = forkJoin({ - preview: item?.modified - ? this.connector.previewRelease( - this.id, - { format: ReleasePreviewFormat.Summary, ...selection }, - item.modified - ) - : this.connector.previewRelease(this.id, { - format: ReleasePreviewFormat.Summary, - ...selection, - }), - track: item?.modified - ? this.connector.retrieveSnapshotByModified(this.id, item.modified, { - format: ExportFormat.Workbench, - include: 'all', - }) - : of(this.releaseTrack), - objects: this.restApiConnectorService.getAllObjects({ - revoked: true, - deprecated: true, - versions: 'all', + preview: this.connector.previewRelease( + this.id, + { format: ReleasePreviewFormat.Summary, ...selection }, + item.modified + ), + track: this.connector.retrieveSnapshotByModified(this.id, item.modified, { + format: ExportFormat.Workbench, + include: 'all', }), }); @@ -1914,6 +1889,7 @@ export class ReleaseTrackPageComponent implements OnInit { take(1), finalize(() => { this.isReleasing = false; + this.previewingSnapshotModified = null; }) ) .subscribe({ @@ -1932,7 +1908,7 @@ export class ReleaseTrackPageComponent implements OnInit { this.openReleasePreviewDialog( result.preview, - this.enrichReleasePreviewTrack(result.track, result.objects), + this.enrichReleasePreviewTrack(result.track), item ); }, @@ -2111,7 +2087,7 @@ export class ReleaseTrackPageComponent implements OnInit { this.id, modified, selection.format, - this.getSnapshotExportOptions(item, selection) + this.getSnapshotExportOptions(selection) ) .pipe(take(1)) .subscribe({ @@ -2155,27 +2131,29 @@ export class ReleaseTrackPageComponent implements OnInit { ); } + /** + * Workbench exports select every tier; bundle exports replay the sealed + * content manifest and accept only the STIX version. + */ private getSnapshotExportOptions( - item: SnapshotHistoryViewModel, selection: SnapshotExportSelection ): Omit | undefined { if (selection.format === ExportFormat.Workbench) { return { include: 'all' }; } - - const stixVersionOptions = selection.stixVersion + return selection.stixVersion ? { stixVersion: selection.stixVersion } - : {}; - const trackType = - this.getSnapshotType(item.snapshot) || this.releaseTrack?.type; - if (trackType === ReleaseTrackType.Standard && !item.isTagged) { - return { include: 'staged', ...stixVersionOptions }; - } - return Object.keys(stixVersionOptions).length - ? stixVersionOptions : undefined; } + public isPreviewingSnapshot(item: SnapshotHistoryViewModel): boolean { + return ( + this.isReleasing && + !!item.modified && + this.previewingSnapshotModified === item.modified + ); + } + private reviewCandidateStatus( from: WorkflowStatusType, to: WorkflowStatusType, @@ -2488,9 +2466,11 @@ export class ReleaseTrackPageComponent implements OnInit { const publication = this.getPublicationPayload( this.configForm.getRawValue() as ReleaseTrackConfigFormValue ); + const aliasChanged = !!this.getAliasUpdate(); this.isSavingConfig = true; - this.connector - .updateComposition(this.id, payload) + this.withAliasUpdate(() => + this.connector.updateComposition(this.id, payload) + ) .pipe( take(1), switchMap(result => @@ -2516,6 +2496,7 @@ export class ReleaseTrackPageComponent implements OnInit { ); this.refreshReleaseTrackState(); this.getConfig(); + if (aliasChanged) this.getReleaseTrack(); }, error: err => { console.error('Failed to update virtual release track config', err); @@ -2817,7 +2798,7 @@ export class ReleaseTrackPageComponent implements OnInit { private openReleasePreviewDialog( preview: any, track: ReleaseTrackSnapshot, - item?: SnapshotHistoryViewModel + item: SnapshotHistoryViewModel ): void { const releaseRef = this.dialog.open(ReleasePreviewDialogComponent, { maxWidth: 'none', @@ -2843,60 +2824,21 @@ export class ReleaseTrackPageComponent implements OnInit { }); } + /** + * Tier entries arrive with name, ATT&CK ID, STIX type, and version from the + * API; only the Workbench attack type has to be derived for the dialog. + */ private enrichReleasePreviewTrack( - track: ReleaseTrackSnapshot, - response: any - ) { - const objects = Array.isArray(response) - ? response - : Array.isArray(response?.data) - ? response.data - : []; - const objectsByRevision = new Map(); - - objects.forEach((object: any) => { - const objectRef = object?.stix?.id ?? object?.stixID ?? object?.id; - const modified = - object?.stix?.modified ?? object?.modified ?? object?.object_modified; - if (objectRef && modified) { - objectsByRevision.set( - this.getReleasePreviewRevisionKey(objectRef, modified), - object - ); - } + track: ReleaseTrackSnapshot + ): ReleaseTrackSnapshot { + const enrich = (entry: any) => ({ + ...entry, + attack_type: + entry?.attack_type ?? + StixTypeToAttackType[entry?.type as StixType] ?? + entry?.attack_type, }); - const enrich = (entry: any) => { - const object = objectsByRevision.get( - this.getReleasePreviewRevisionKey( - entry?.object_ref, - entry?.object_modified - ) - ); - if (!object) return entry; - - const stix = object?.stix ?? object; - return { - ...entry, - name: object?.name ?? stix?.name ?? entry?.name, - attack_id: - object?.attackID ?? - object?.attack_id ?? - object?.workspace?.attack_id ?? - entry?.attack_id, - attack_type: - object?.attackType ?? - StixTypeToAttackType[stix?.type as StixType] ?? - entry?.attack_type, - type: stix?.type ?? entry?.type, - x_mitre_version: - object?.version?.toString?.() ?? - object?.version ?? - stix?.x_mitre_version ?? - entry?.x_mitre_version, - }; - }; - return { ...track, members: (track.members ?? []).map(enrich), @@ -2905,26 +2847,15 @@ export class ReleaseTrackPageComponent implements OnInit { } as ReleaseTrackSnapshot; } - private getReleasePreviewRevisionKey( - objectRef: unknown, - modified: unknown - ): string { - const timestamp = new Date(modified as any).getTime(); - return `${String(objectRef ?? '')}::${timestamp}`; - } - private releaseSnapshot( selection: ReleasePayload, - item?: SnapshotHistoryViewModel + item: SnapshotHistoryViewModel ): void { - if (!this.id) return; + if (!this.id || !item.modified) return; this.isReleasing = true; - const release = item?.modified - ? this.connector.releaseSnapshot(this.id, item.modified, selection) - : this.connector.releaseLatest(this.id, selection); - - release + this.connector + .releaseSnapshot(this.id, item.modified, selection) .pipe( take(1), finalize(() => { @@ -2933,7 +2864,7 @@ export class ReleaseTrackPageComponent implements OnInit { ) .subscribe({ next: () => { - if (item?.modified === this.createdDraftSnapshot?.modified) { + if (item.modified === this.createdDraftSnapshot?.modified) { this.createdDraftSnapshot = null; } this.refreshReleaseTrackState(); @@ -2957,6 +2888,9 @@ export class ReleaseTrackPageComponent implements OnInit { const latestSnapshot = sorted.find(snapshot => this.isLatestHistorySnapshot(snapshot)) ?? sorted[0]; + const latestRelease = sorted.find(snapshot => + this.isTaggedSnapshot(snapshot) + ); return sorted.map((snapshot, index) => { const previousSnapshot = sorted[index + 1]; @@ -2989,6 +2923,7 @@ export class ReleaseTrackPageComponent implements OnInit { taggedAt: this.getSnapshotTaggedAt(snapshot), isTagged, isLatest, + isLatestRelease: isTagged && snapshot === latestRelease, isCurrentDraft: !isTagged && isLatest, stats: this.getSnapshotStats(snapshot, addedCount, modifiedCount), contentStats: this.getContentStats(snapshot), From 7e3f162634b6f613ac207552ec340d2e3293fdfb Mon Sep 17 00:00:00 2001 From: Sean Sica <23294618+seansica@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:41:41 -0400 Subject: [PATCH 5/5] feat(release-tracks): show page activity while long operations run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disabling a button for the duration of a multi-second request (creating a virtual draft, preparing or committing a release, deleting a release or the track, saving the configuration) read as a frozen page rather than work in progress, and the release commit had no indicator at all. A page-level activity bar — an indeterminate progress bar with a message naming what the server is doing — now appears under the header for every such operation, and the triggering button shows a spinner with a "Creating draft" / "Preparing preview" label. One getter derives the message from the existing busy flags so new operations have a single place to plug in. Co-Authored-By: Claude Fable 5.1 --- docs/usage.md | 2 +- .../release-track-page.component.html | 26 ++++++++---- .../release-track-page.component.scss | 18 ++++---- .../release-track-page.component.spec.ts | 41 +++++++++++++++++++ .../release-track-page.component.ts | 26 ++++++++++++ 5 files changed, 93 insertions(+), 20 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 1ca94858..ddac028a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -30,7 +30,7 @@ You can read more about the technical specifications for a collection, such as t The release preview offers minor and major relative tags as well as an exact `MAJOR.MINOR` version. Relative tags are calculated from the tagged snapshot immediately before the selected draft. When releasing an older draft, the exact version must also remain below the next tagged snapshot; the dialog shows these exclusive bounds. Optional release notes are stored on that snapshot and become the `x-mitre-collection` description in exported STIX bundles. -The release-track page follows a draft-then-tag flow: the Board tab manages what the next draft contains (candidates, staged objects, and for virtual tracks the Create Draft action), and the Releases tab previews and tags a draft from its card. Any snapshot can be exported from its card as a STIX 2.0 bundle, a STIX 2.1 bundle, or Workbench JSON. Historical snapshot exports can also copy a concise summary. Every snapshot seals its content when its members are written, so exports replay the exact members, relationships, and supporting objects in either STIX version; released snapshots also show their stable bundle identifier and SHA-256 hashes. Saving a relationship resets its source and target to work-in-progress in place without creating new revisions of those objects. Administrators can delete a track's most recent release from the Releases tab by confirming its version; its version becomes available again and later drafts are kept. A track can carry an alias (a short lowercase slug set in the Config tab) that works in place of its ID in page URLs and API paths; the track list opens aliased tracks by their alias. The dashboard's Data Quality page adds a domain consistency report: relationships whose objects share no domain (and objects with no domain) can never ship in the same bundle, so fix them at the source rather than expecting the bundle to pull in related objects. Only the most recent release offers a delete button, the Preview & Tag action shows its progress while the preview is prepared, and deleting an entire track lives in the danger zone at the bottom of the Config tab. +The release-track page follows a draft-then-tag flow: the Board tab manages what the next draft contains (candidates, staged objects, and for virtual tracks the Create Draft action), and the Releases tab previews and tags a draft from its card. Any snapshot can be exported from its card as a STIX 2.0 bundle, a STIX 2.1 bundle, or Workbench JSON. Historical snapshot exports can also copy a concise summary. Every snapshot seals its content when its members are written, so exports replay the exact members, relationships, and supporting objects in either STIX version; released snapshots also show their stable bundle identifier and SHA-256 hashes. Saving a relationship resets its source and target to work-in-progress in place without creating new revisions of those objects. Administrators can delete a track's most recent release from the Releases tab by confirming its version; its version becomes available again and later drafts are kept. A track can carry an alias (a short lowercase slug set in the Config tab) that works in place of its ID in page URLs and API paths; the track list opens aliased tracks by their alias. The dashboard's Data Quality page adds a domain consistency report: relationships whose objects share no domain (and objects with no domain) can never ship in the same bundle, so fix them at the source rather than expecting the bundle to pull in related objects. Only the most recent release offers a delete button, a progress bar with a status message appears under the page header while a long operation runs (creating a draft, preparing or committing a release, deleting a release or the track, saving the configuration), and deleting an entire track lives in the danger zone at the bottom of the Config tab. Each cached snapshot card displays server-generated SHA-256 hashes for the exact UTF-8 JSON files produced by its STIX 2.0 and STIX 2.1 bundle downloads. The adjacent copy buttons copy a hash for external file-integrity verification. Snapshot notes are locked while the bundle is cached; delete the cache, edit the notes, and cache the bundle again to generate matching hashes. diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html index 86238c8a..0afd5b6e 100644 --- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.html @@ -11,20 +11,28 @@

color="primary" [disabled]="!canCreateDraft" (click)="onDraft()"> - Create Draft - local_see + + {{ isCreatingDraft ? 'Creating draft' : 'Create Draft' }} + local_see -

- - Preparing the release preview. Large tracks can take a while; stay on this - page until the preview opens. -

+ +

+ + {{ message }} Stay on this page until it finishes. +

+ { expect(component.isReleasing).toBe(false); }); + it('should describe in-flight work in the activity bar', () => { + expect(component.activityMessage).toBeNull(); + + component.isCreatingDraft = true; + expect(component.activityMessage).toContain('Creating the draft snapshot'); + component.isCreatingDraft = false; + + component.isReleasing = true; + component.previewingSnapshotModified = '2026-07-30T14:00:00.000Z'; + expect(component.activityMessage).toContain( + 'Preparing the release preview' + ); + component.previewingSnapshotModified = null; + expect(component.activityMessage).toContain('Tagging the release'); + component.isReleasing = false; + + component.isDeleting = true; + expect(component.activityMessage).toContain('Deleting the release track'); + component.isDeleting = false; + + component.isSavingConfig = true; + expect(component.activityMessage).toContain( + 'Saving the track configuration' + ); + component.isSavingConfig = false; + + expect(component.activityMessage).toBeNull(); + }); + + it('should show the activity bar while a draft is being created', () => { + component.id = 'release-track--123'; + component.releaseTrack = { id: 'release-track--123' } as any; + component.isCreatingDraft = true; + fixture.detectChanges(); + + const activity = fixture.nativeElement.querySelector('.page-activity'); + expect(activity).toBeTruthy(); + expect(activity.querySelector('mat-progress-bar')).toBeTruthy(); + expect(activity.textContent).toContain('Creating the draft snapshot'); + }); + it('should ignore tagging requests for released snapshots', () => { component.id = 'release-track--123'; diff --git a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts index 7e91e111..cb517e4c 100644 --- a/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts +++ b/src/app/views/dashboard-page/release-management/release-track-page/release-track-page.component.ts @@ -1785,6 +1785,32 @@ export class ReleaseTrackPageComponent implements OnInit { this.isEditingConfig = false; } + /** + * Message for the page-level activity bar while a long-running operation is + * in flight, or null. Each operation names what the server is doing so a + * multi-second wait reads as work, not as a frozen page. + */ + public get activityMessage(): string | null { + if (this.isCreatingDraft) { + return 'Creating the draft snapshot. Materializing a virtual track resolves every component track and can take a while.'; + } + if (this.isReleasing) { + return this.previewingSnapshotModified + ? 'Preparing the release preview. Large tracks can take a while.' + : 'Tagging the release and sealing its content.'; + } + if (this.deletingReleaseModified.size > 0) { + return 'Deleting the release and reconciling the track.'; + } + if (this.isDeleting) { + return 'Deleting the release track and its history.'; + } + if (this.isSavingConfig) { + return 'Saving the track configuration.'; + } + return null; + } + public get aliasUrlPreview(): string { const alias = (this.configForm.get('alias')?.value ?? '').trim(); return `/dashboard/release-management/${alias || ''}`;