diff --git a/docs/usage.md b/docs/usage.md index 558716524..498afbdba 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -698,12 +698,20 @@ Saving a relationship creates a new relationship revision and returns its connec ### Revoking and Deprecating Objects -All objects within the knowledge base can be _revoked_ or _deprecated_. These functionalities allow you to remove irrelevant or outdated objects without deleting them outright since deletions cannot be propagated to data consumers subscribed to your collections. Revoked and deprecated objects are hidden from UIs unless the user explicitly chooses to display them. +Objects can be retired without deleting their history. Deprecation marks an object as no longer used; supported object types can instead be revoked in favor of an existing replacement. Retired objects are hidden from default lists unless explicitly included. - _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. +Before deprecation, Workbench checks the latest authoritative inbound and outbound domain references. If embedded references remain, a dialog lists their source, target, field, and direction. Remove or replace those references on their source objects first. No relationships or dependent objects are automatically changed by a blocked attempt. + +If only blocking formal relationships remain, confirmation retires those SROs first, then checks again and deprecates the object. Existing active `subtechnique-of` and `revoked-by` relationships are **preserved unchanged in both directions**: they neither block SDO deprecation nor receive new revisions. Ordinary relationship retirement creates a new revision with `x_mitre_deprecated: true`; it does not delete history. A failure stops the final object save, although earlier relationship retirements may already have completed. Data-source deprecation no longer automatically deprecates its components. + +The type exemptions apply only when deprecating an SDO, not when explicitly retiring a relationship itself. The relationship controls and data-quality duplicate-relationship bulk action can still retire `subtechnique-of` or `revoked-by` SROs, including when an endpoint is inactive. The frontend consistently excludes these types from cascade confirmation, retirement, and final blocker checks even against older API responses; an older server may still reject the final SDO save, which is reported without retiring the preserved links. These exemptions do not change revocation. + +When revoking with relationship preservation enabled, supported revocations also transfer embedded references through new source/replacement revisions. Analytic log-source names and channels are preserved. Conflicting single-reference fields and inactive referring objects block the operation rather than silently losing references. Without preservation, existing embedded references are not detached. + +Authoring new embedded references or active SROs to revoked/deprecated objects is rejected, except for `revoked-by` SROs. Preserving an existing `subtechnique-of` link does not permit authoring a new one with inactive endpoints. Unchanged legacy embedded references and source-faithful collection imports have separate retention semantics. Historical revisions, retired SROs, attribution, markings, and release inventory do not block deprecation checks. ### Deleting Objects diff --git a/src/app/components/markdown-view-dialog/markdown-view-dialog.component.scss b/src/app/components/markdown-view-dialog/markdown-view-dialog.component.scss index 38c003752..11d2c6f8a 100644 --- a/src/app/components/markdown-view-dialog/markdown-view-dialog.component.scss +++ b/src/app/components/markdown-view-dialog/markdown-view-dialog.component.scss @@ -6,6 +6,7 @@ overflow-y: scroll; max-height: 50vh; max-width: 33em; + overflow-wrap: anywhere; } .buttons { margin-top: 24px; diff --git a/src/app/components/object-status/object-status.component.spec.ts b/src/app/components/object-status/object-status.component.spec.ts index 38d934ca1..1624c07b7 100644 --- a/src/app/components/object-status/object-status.component.spec.ts +++ b/src/app/components/object-status/object-status.component.spec.ts @@ -12,7 +12,10 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { ActivatedRoute, Router } from '@angular/router'; import { MtxPopoverModule } from '@ng-matero/extensions/popover'; -import { of } from 'rxjs'; +import { of, Subject } from 'rxjs'; +import { vi } from 'vitest'; +import { Technique } from 'src/app/classes/stix/technique'; +import { DeprecationService } from 'src/app/services/helpers/deprecation.service'; import { ObjectStatusComponent } from './object-status.component'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; @@ -75,7 +78,22 @@ describe('ObjectStatusComponent', () => { fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('uses authoritative deprecation without changing local status early', () => { + const pending = new Subject(); + const lifecycle = TestBed.inject(DeprecationService); + vi.spyOn(lifecycle, 'deprecate').mockReturnValue(pending); + component.object = new Technique(); + component.objects = [component.object]; + component.loaded = true; + component.editorService.editing = false; + + component.toggleDeprecated(); + expect(component.object.deprecated).toBe(false); + expect(component.deprecateDisabled).toBe(true); + + pending.next(false); + pending.complete(); + expect(component.deprecated).toBe(false); + expect(component.lifecyclePending).toBe(false); }); }); diff --git a/src/app/components/object-status/object-status.component.ts b/src/app/components/object-status/object-status.component.ts index eb5e03889..364c2631d 100644 --- a/src/app/components/object-status/object-status.component.ts +++ b/src/app/components/object-status/object-status.component.ts @@ -6,8 +6,9 @@ import { StixObject } from 'src/app/classes/stix/stix-object'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; import { EditorService } from 'src/app/services/editor/editor.service'; import { AddDialogComponent } from '../add-dialog/add-dialog.component'; -import { ConfirmationDialogComponent } from '../confirmation-dialog/confirmation-dialog.component'; -import { forkJoin } from 'rxjs'; +import { of } from 'rxjs'; +import { finalize, map, switchMap } from 'rxjs/operators'; +import { DeprecationService } from 'src/app/services/helpers/deprecation.service'; import { WorkflowStatusMap } from 'src/app/utils/types'; @Component({ @@ -25,10 +26,13 @@ export class ObjectStatusComponent implements OnInit { public relationships = []; public revoked = false; public deprecated = false; + public lifecyclePending = false; public get disabled(): boolean { return ( - this.editorService.editing || this.editorService.type == 'collection' + this.lifecyclePending || + this.editorService.editing || + this.editorService.type == 'collection' ); } @@ -53,7 +57,8 @@ export class ObjectStatusComponent implements OnInit { constructor( public editorService: EditorService, private restAPIService: RestApiConnectorService, - private dialog: MatDialog + private dialog: MatDialog, + private deprecationService: DeprecationService ) {} ngOnInit(): void { @@ -115,21 +120,6 @@ export class ObjectStatusComponent implements OnInit { }, }); - if (this.editorService.type == 'data-source') { - // retrieve related data components & their relationships - data$ = this.restAPIService.getAllRelatedToDataSource( - this.editorService.stixId - ); - const dataSubscription = data$.subscribe({ - next: results => { - this.relationships = this.relationships.concat(results); - }, - complete: () => { - dataSubscription.unsubscribe(); - }, - }); - } - // retrieve relationships with the object data$ = this.restAPIService.getRelatedTo({ sourceOrTargetRef: this.editorService.stixId, @@ -147,15 +137,6 @@ export class ObjectStatusComponent implements OnInit { } } - private save() { - const saveSubscription = this.object.save(this.restAPIService).subscribe({ - complete: () => { - this.editorService.onReload.emit(); - saveSubscription.unsubscribe(); - }, - }); - } - public revoke() { if (!this.loaded || !this.object || !this.objects) return; if (this.revokeDisabled) return; @@ -176,7 +157,11 @@ export class ObjectStatusComponent implements OnInit { this.select = new SelectionModel(); const revokeDialogData = { selectableObjects: this.objects.filter(object => { - return object.stixID !== this.editorService.stixId; + return ( + object.stixID !== this.editorService.stixId && + !object.revoked && + !object.deprecated + ); }), type: this.editorService.type, select: this.select, @@ -207,29 +192,63 @@ export class ObjectStatusComponent implements OnInit { }); } else { // unrevoke object, deprecate the 'revoked-by' relationship - // this is the only case in which a 'revoked-by' relationship is deprecated const revokedRelationship = this.relationships.find( r => r.relationship_type == 'revoked-by' && r.source_ref == this.object.stixID ); - if (revokedRelationship) { - revokedRelationship.deprecated = true; - revokedRelationship.save(this.restAPIService); - } - this.revoked = false; - this.object.revoked = false; - this.save(); + this.lifecyclePending = true; + const retire = revokedRelationship + ? this.deprecationService.deprecate(revokedRelationship) + : of(true); + retire + .pipe( + switchMap(retired => { + if (!retired) return of(false); + this.object.revoked = false; + return this.object.save(this.restAPIService).pipe(map(() => true)); + }), + finalize(() => (this.lifecyclePending = false)) + ) + .subscribe({ + next: saved => { + this.revoked = !saved; + if (saved) this.editorService.onReload.emit(); + }, + error: error => { + this.revoked = true; + this.object.revoked = true; + this.deprecationService.showError(error); + }, + }); } } private setDeprecated(deprecated: boolean) { - this.deprecated = deprecated; + this.lifecyclePending = true; if (deprecated) { - this.deprecateObjects(false); + this.deprecationService + .deprecate(this.object) + .pipe(finalize(() => (this.lifecyclePending = false))) + .subscribe(saved => { + this.deprecated = this.object.deprecated; + if (saved) this.editorService.onReload.emit(); + }); } else { this.object.deprecated = false; - this.save(); + this.object + .save(this.restAPIService) + .pipe(finalize(() => (this.lifecyclePending = false))) + .subscribe({ + complete: () => { + this.deprecated = false; + this.editorService.onReload.emit(); + }, + error: error => { + this.object.deprecated = true; + this.deprecationService.showError(error); + }, + }); } } @@ -266,77 +285,10 @@ export class ObjectStatusComponent implements OnInit { this.editorService.onReload.emit(); revokeSubscription.unsubscribe(); }, - error: () => { + error: error => { + this.deprecationService.showError(error); this.revoked = false; }, }); } - - /** - * Deprecates or revokes the object and deprecates all relationships with this object, - * with the exception of 'subtechnique-of' relationships - */ - private deprecateObjects(revoked: boolean, revoked_by_id?: string) { - const saves = []; - - // inform users of relationship changes - const confirmationPrompt = this.dialog.open(ConfirmationDialogComponent, { - maxWidth: '35em', - data: { - message: - 'All relationships with this object will be deprecated. Do you want to continue?', - }, - autoFocus: false, // prevents auto focus on toolbar buttons - }); - - const confirmationSub = confirmationPrompt.afterClosed().subscribe({ - next: result => { - if (!result) { - // user cancelled - if (revoked) this.revoked = false; - else this.deprecated = false; - return; - } - - // deprecate or revoke object - if (revoked) this.object.revoked = true; - else this.object.deprecated = true; - saves.push(this.object.save(this.restAPIService)); - - // update relationships with the object - for (const relationship of this.relationships) { - // do not deprecate 'subtechnique-of' or 'revoked-by' relationships - if ( - !relationship.deprecated && - !['subtechnique-of', 'revoked-by'].includes( - relationship.relationship_type - ) - ) { - relationship.deprecated = true; - saves.push(relationship.save(this.restAPIService)); - } - } - - if (revoked_by_id) { - // create a new 'revoked-by' relationship - const revokedRelationship = new Relationship(); - revokedRelationship.relationship_type = 'revoked-by'; - revokedRelationship.source_ref = this.object.stixID; - revokedRelationship.target_ref = revoked_by_id; - saves.push(revokedRelationship.save(this.restAPIService)); - } - - // complete save calls - const saveSubscription = forkJoin(saves).subscribe({ - complete: () => { - this.editorService.onReload.emit(); - saveSubscription.unsubscribe(); - }, - }); - }, - complete: () => { - confirmationSub.unsubscribe(); - }, - }); - } } diff --git a/src/app/services/connectors/rest-api/rest-api-connector.service.spec.ts b/src/app/services/connectors/rest-api/rest-api-connector.service.spec.ts deleted file mode 100644 index 2ce69e90f..000000000 --- a/src/app/services/connectors/rest-api/rest-api-connector.service.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; -import { provideHttpClient } from '@angular/common/http'; - -import { RestApiConnectorService } from './rest-api-connector.service'; - -describe('RestApiConnectorService', () => { - let service: RestApiConnectorService; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [provideHttpClient()], - schemas: [NO_ERRORS_SCHEMA], - }); - service = TestBed.inject(RestApiConnectorService); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); -}); 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 e3b2da01a..56a604d1c 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 @@ -65,6 +65,27 @@ export interface Paginated { }; } +export interface DeprecationBlockers { + sros: { + stix_id: string; + modified: string; + relationship_type: string; + direction: 'inbound' | 'outbound'; + }[]; + embedded: { + source_ref: string; + target_ref: string; + path: string; + direction: 'inbound' | 'outbound'; + }[]; +} + +export interface DeprecationCheck { + stix_id: string; + can_deprecate: boolean; + blockers: DeprecationBlockers; +} + export interface Namespace { prefix: string; range_start: string; @@ -143,6 +164,13 @@ export class RestApiConnectorService extends ApiConnector { super(snackbar); } + /** Check authoritative latest references; never substitute cached workspace data. */ + public getDeprecationCheck(stixId: string): Observable { + return this.http.get( + `${this.apiUrl}/attack-objects/${encodeURIComponent(stixId)}/deprecation-check` + ); + } + /** * Get the name of a given STIX object */ diff --git a/src/app/services/helpers/deprecation.service.spec.ts b/src/app/services/helpers/deprecation.service.spec.ts new file mode 100644 index 000000000..03df477e3 --- /dev/null +++ b/src/app/services/helpers/deprecation.service.spec.ts @@ -0,0 +1,337 @@ +import { TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { of, Subject, throwError } from 'rxjs'; +import { vi } from 'vitest'; +import { Technique } from 'src/app/classes/stix/technique'; +import { Relationship } from 'src/app/classes/stix/relationship'; +import { MarkdownViewDialogComponent } from 'src/app/components/markdown-view-dialog/markdown-view-dialog.component'; +import { + DeprecationCheck, + RestApiConnectorService, +} from '../connectors/rest-api/rest-api-connector.service'; +import { DeprecationService } from './deprecation.service'; + +describe('DeprecationService', () => { + let service: DeprecationService; + let object: Technique; + const api = { + getDeprecationCheck: vi.fn(), + getRelationship: vi.fn(), + postRelationship: vi.fn(), + }; + const dialog = { open: vi.fn() }; + + function check(): DeprecationCheck { + return { + stix_id: object.stixID, + can_deprecate: true, + blockers: { sros: [], embedded: [] }, + }; + } + + beforeEach(() => { + vi.resetAllMocks(); + TestBed.configureTestingModule({ + providers: [ + DeprecationService, + { provide: RestApiConnectorService, useValue: api }, + { provide: MatDialog, useValue: dialog }, + ], + }); + service = TestBed.inject(DeprecationService); + object = new Technique(); + vi.spyOn(object, 'save').mockReturnValue(of(object)); + api.getDeprecationCheck.mockImplementation(() => of(check())); + dialog.open.mockReturnValue({ afterClosed: () => of(true) }); + }); + + it('blocks embedded references before confirmation or any write', () => { + const blocked = check(); + blocked.can_deprecate = false; + blocked.blockers.embedded.push({ + source_ref: 'x-mitre-analytic--source', + target_ref: object.stixID, + path: 'x_mitre_log_source_references[0].x_mitre_data_component_ref', + direction: 'inbound', + }); + blocked.blockers.sros = ['subtechnique-of', 'revoked-by'].map(type => ({ + stix_id: `relationship--${type}`, + modified: '2026-01-01T00:00:00.000Z', + relationship_type: type, + direction: 'outbound', + })); + api.getDeprecationCheck.mockReturnValue(of(blocked)); + const results: boolean[] = []; + service.deprecate(object).subscribe(result => results.push(result)); + expect(results).toEqual([false]); + expect(object.save).not.toHaveBeenCalled(); + expect(api.getRelationship).not.toHaveBeenCalled(); + expect(dialog.open).toHaveBeenCalledWith( + MarkdownViewDialogComponent, + expect.objectContaining({ + data: expect.objectContaining({ + markdown: expect.stringContaining('x_mitre_log_source_references[0]'), + }), + }) + ); + expect(object.deprecated).toBe(false); + }); + + it('waits for ordinary SRO retirements in order and preserves hierarchy and replacement SROs', () => { + const first = new Relationship(); + first.modified = new Date('2026-01-01T00:00:00.000Z'); + first.relationship_type = 'uses'; + const second = new Relationship(); + second.modified = first.modified; + second.relationship_type = 'mitigates'; + const pending = new Subject(); + api.postRelationship.mockImplementation((relationship: Relationship) => + relationship === first ? pending : of(second) + ); + const initial = check(); + initial.can_deprecate = false; + initial.blockers.sros = [first, second].map(rel => ({ + stix_id: rel.stixID, + modified: rel.modified.toISOString(), + relationship_type: rel.relationship_type, + direction: 'inbound', + })); + const preserved = check(); + // Older APIs incorrectly report these as blockers at every check. + preserved.can_deprecate = false; + preserved.blockers.sros = ['subtechnique-of', 'revoked-by'].map(type => ({ + stix_id: `relationship--${type}`, + modified: first.modified.toISOString(), + relationship_type: type, + direction: 'outbound', + })); + initial.blockers.sros.push(...preserved.blockers.sros); + // Duplicate inbound/outbound entries must still retire an SRO only once. + initial.blockers.sros.push(initial.blockers.sros[0]); + api.getDeprecationCheck + .mockReturnValueOnce(of(initial)) + .mockReturnValueOnce(of(initial)) + .mockReturnValueOnce(of(preserved)); + api.getRelationship.mockImplementation((id: string) => + of([id === first.stixID ? first : second]) + ); + const results: boolean[] = []; + service.deprecate(object).subscribe(result => results.push(result)); + expect(dialog.open).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + data: expect.objectContaining({ + message: expect.stringContaining('Retire 2 active relationship(s)'), + }), + }) + ); + pending.next(first); + expect(object.save).not.toHaveBeenCalled(); + expect(api.postRelationship).toHaveBeenCalledTimes(1); + pending.complete(); + expect(api.postRelationship).toHaveBeenCalledTimes(2); + expect(api.postRelationship).toHaveBeenNthCalledWith(1, first); + expect(api.postRelationship).toHaveBeenNthCalledWith(2, second); + expect(first.deprecated).toBe(true); + expect(second.deprecated).toBe(true); + expect(api.getRelationship).toHaveBeenCalledTimes(2); + expect(object.save).toHaveBeenCalledTimes(1); + expect(results).toEqual([true]); + }); + + for (const relationshipType of ['subtechnique-of', 'revoked-by']) { + for (const direction of ['inbound', 'outbound'] as const) { + it(`preserves ${direction} ${relationshipType} reported by an older API`, () => { + const initial = check(); + initial.can_deprecate = false; + initial.blockers.sros.push({ + stix_id: 'relationship--preserved', + modified: '2026-01-01T00:00:00.000Z', + relationship_type: relationshipType, + direction, + }); + api.getDeprecationCheck.mockReturnValue(of(initial)); + const results: boolean[] = []; + service.deprecate(object).subscribe(result => results.push(result)); + expect(results).toEqual([true]); + expect(api.getRelationship).not.toHaveBeenCalled(); + expect(api.postRelationship).not.toHaveBeenCalled(); + expect(object.save).toHaveBeenCalledTimes(1); + expect(api.getDeprecationCheck).toHaveBeenCalledTimes(3); + expect(dialog.open).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + data: expect.objectContaining({ + message: 'Deprecate this object?', + }), + }) + ); + }); + } + } + + it('does not save the object after an SRO retirement failure', () => { + const relationship = new Relationship(); + relationship.modified = new Date('2026-01-01T00:00:00.000Z'); + api.postRelationship.mockReturnValue( + throwError(() => new Error('Retirement failed')) + ); + const initial = check(); + initial.can_deprecate = false; + initial.blockers.sros.push({ + stix_id: relationship.stixID, + modified: relationship.modified.toISOString(), + relationship_type: 'uses', + direction: 'outbound', + }); + api.getDeprecationCheck.mockReturnValue(of(initial)); + api.getRelationship.mockReturnValue(of([relationship])); + service.deprecate(object).subscribe(); + expect(object.save).not.toHaveBeenCalled(); + expect(object.deprecated).toBe(false); + expect(dialog.open).toHaveBeenLastCalledWith( + MarkdownViewDialogComponent, + expect.objectContaining({ + data: expect.objectContaining({ + markdown: expect.stringContaining('Retirement failed'), + }), + }) + ); + }); + + it('stops when embedded references appear during confirmation', () => { + const changed = check(); + changed.can_deprecate = false; + changed.blockers.embedded.push({ + source_ref: object.stixID, + target_ref: 'x-mitre-analytic--target', + path: 'x_mitre_analytic_refs[0]', + direction: 'outbound', + }); + api.getDeprecationCheck + .mockReturnValueOnce(of(check())) + .mockReturnValueOnce(of(changed)); + service.deprecate(object).subscribe(); + expect(object.save).not.toHaveBeenCalled(); + expect(api.getRelationship).not.toHaveBeenCalled(); + }); + + it('stops when the final authoritative check finds a new SRO', () => { + const changed = check(); + changed.can_deprecate = false; + changed.blockers.sros.push({ + stix_id: 'relationship--new', + modified: '2026-01-01T00:00:00.000Z', + relationship_type: 'uses', + direction: 'outbound', + }); + changed.blockers.sros.push({ + stix_id: 'relationship--preserved', + modified: '2026-01-01T00:00:00.000Z', + relationship_type: 'subtechnique-of', + direction: 'inbound', + }); + api.getDeprecationCheck + .mockReturnValueOnce(of(check())) + .mockReturnValueOnce(of(check())) + .mockReturnValueOnce(of(changed)); + service.deprecate(object).subscribe(); + expect(object.save).not.toHaveBeenCalled(); + expect(object.deprecated).toBe(false); + }); + + it('still blocks new embedded references at the final check alongside preserved SROs', () => { + const initial = check(); + initial.can_deprecate = false; + initial.blockers.sros.push({ + stix_id: 'relationship--preserved', + modified: '2026-01-01T00:00:00.000Z', + relationship_type: 'revoked-by', + direction: 'inbound', + }); + const changed: DeprecationCheck = { + ...initial, + blockers: { + sros: initial.blockers.sros, + embedded: [ + { + source_ref: 'x-mitre-matrix--source', + target_ref: object.stixID, + path: 'tactic_refs[0]', + direction: 'inbound', + }, + ], + }, + }; + api.getDeprecationCheck + .mockReturnValueOnce(of(initial)) + .mockReturnValueOnce(of(initial)) + .mockReturnValueOnce(of(changed)); + const results: boolean[] = []; + service.deprecate(object).subscribe(result => results.push(result)); + expect(results).toEqual([false]); + expect(object.save).not.toHaveBeenCalled(); + expect(api.getRelationship).not.toHaveBeenCalled(); + expect(api.postRelationship).not.toHaveBeenCalled(); + expect(object.deprecated).toBe(false); + }); + + it('surfaces final-write 409 blockers and restores the local status', () => { + vi.mocked(object.save).mockReturnValue( + throwError(() => ({ + status: 409, + error: { + message: 'New references block deprecation', + code: 'deprecation_blocked', + blockers: { + sros: [], + embedded: [ + { + source_ref: 'x-mitre-matrix--source', + target_ref: object.stixID, + path: 'tactic_refs[0]', + direction: 'inbound', + }, + ], + }, + }, + })) + ); + const results: boolean[] = []; + service.deprecate(object).subscribe(result => results.push(result)); + expect(results).toEqual([false]); + expect(object.deprecated).toBe(false); + expect(dialog.open).toHaveBeenLastCalledWith( + MarkdownViewDialogComponent, + expect.objectContaining({ + data: expect.objectContaining({ + markdown: expect.stringContaining('tactic_refs[0]'), + }), + }) + ); + }); + + it('does not write when confirmation is cancelled', () => { + dialog.open.mockReturnValue({ afterClosed: () => of(false) }); + service.deprecate(object).subscribe(); + expect(object.save).not.toHaveBeenCalled(); + expect(api.getDeprecationCheck).toHaveBeenCalledTimes(1); + }); + + for (const relationshipType of ['uses', 'subtechnique-of', 'revoked-by']) { + it(`explicitly retires a ${relationshipType} SRO without frontend endpoint metadata edits`, () => { + const relationship = new Relationship(); + relationship.relationship_type = relationshipType; + relationship.source_ref = 'attack-pattern--inactive-source'; + relationship.target_ref = 'attack-pattern--inactive-target'; + const modelSave = vi.spyOn(relationship, 'save'); + api.postRelationship.mockReturnValue(of(relationship)); + const results: boolean[] = []; + service.deprecate(relationship).subscribe(result => results.push(result)); + expect(api.postRelationship).toHaveBeenCalledWith(relationship); + expect(modelSave).not.toHaveBeenCalled(); + expect(relationship.deprecated).toBe(true); + expect(results).toEqual([true]); + }); + } +}); diff --git a/src/app/services/helpers/deprecation.service.ts b/src/app/services/helpers/deprecation.service.ts new file mode 100644 index 000000000..3fc157f80 --- /dev/null +++ b/src/app/services/helpers/deprecation.service.ts @@ -0,0 +1,230 @@ +import { Injectable } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { defer, from, Observable, of } from 'rxjs'; +import { + catchError, + concatMap, + last, + map, + switchMap, + toArray, +} from 'rxjs/operators'; +import { StixObject } from 'src/app/classes/stix/stix-object'; +import { Relationship } from 'src/app/classes/stix/relationship'; +import { ConfirmationDialogComponent } from 'src/app/components/confirmation-dialog/confirmation-dialog.component'; +import { MarkdownViewDialogComponent } from 'src/app/components/markdown-view-dialog/markdown-view-dialog.component'; +import { + DeprecationBlockers, + DeprecationCheck, + RestApiConnectorService, +} from '../connectors/rest-api/rest-api-connector.service'; + +interface LifecycleFailure { + message?: string; + blockers?: DeprecationBlockers; + details?: { blockers?: DeprecationBlockers }; +} + +@Injectable({ providedIn: 'root' }) +export class DeprecationService { + constructor( + private restApi: RestApiConnectorService, + private dialog: MatDialog + ) {} + + /** All entry points share the same orphan-first write ordering. */ + public deprecate( + object: StixObject, + alreadyConfirmed = false + ): Observable { + return defer(() => { + const wasDeprecated = object.deprecated; + let retirementStarted = false; + return this.restApi.getDeprecationCheck(object.stixID).pipe( + switchMap(check => { + this.requireNoEmbedded(check); + const relationships = this.uniqueRelationships(check); + const confirmation = alreadyConfirmed + ? of(true) + : this.dialog + .open(ConfirmationDialogComponent, { + maxWidth: '35em', + autoFocus: false, + data: { + title: 'Deprecate object', + message: relationships.length + ? `Retire ${relationships.length} active relationship(s) before deprecating this object? Existing subtechnique-of and revoked-by relationships are preserved. Embedded references must be removed separately.` + : 'Deprecate this object?', + yes_label: 'Deprecate', + no_label: 'Cancel', + }, + }) + .afterClosed(); + return confirmation.pipe( + switchMap(confirmed => { + if (!confirmed) return of(false); + // References can change while the confirmation dialog is open. + return this.restApi.getDeprecationCheck(object.stixID).pipe( + switchMap(fresh => { + this.requireNoEmbedded(fresh); + const current = this.uniqueRelationships(fresh); + if ( + current.some( + rel => + !relationships.some( + approved => + approved.stix_id === rel.stix_id && + approved.modified === rel.modified + ) + ) + ) { + throw this.blocked( + fresh, + 'Relationships changed. Review the current blockers and try again.' + ); + } + return from(current).pipe( + concatMap(blocker => + this.restApi.getRelationship(blocker.stix_id).pipe( + switchMap(versions => { + const relationship = versions[0]; + if (!relationship) + throw new Error( + `Could not load relationship ${blocker.stix_id}.` + ); + if (relationship.deprecated || relationship.revoked) + return of(null); + if ( + relationship.modified?.toISOString() !== + blocker.modified + ) { + throw new Error( + `Relationship ${blocker.stix_id} changed. Refresh and try again.` + ); + } + retirementStarted = true; + relationship.deprecated = true; + // Retirement metadata and endpoint pins are owned by the API. + return this.restApi.postRelationship(relationship); + }) + ) + ), + toArray(), + switchMap(() => + this.restApi.getDeprecationCheck(object.stixID) + ), + switchMap(finalCheck => { + // Older APIs may count preserved SROs in can_deprecate. + // Apply the same blocker policy used for confirmation and retirement. + if ( + finalCheck.blockers.embedded.length || + this.uniqueRelationships(finalCheck).length + ) { + throw this.blocked( + finalCheck, + 'References changed before the final save.' + ); + } + object.deprecated = true; + // Wait for every part of the save, not just its first emission. + const save: Observable = + object instanceof Relationship + ? this.restApi.postRelationship(object) + : object.save(this.restApi); + return save.pipe( + last(), + map(saved => { + object.modified = saved.modified; + return true; + }) + ); + }) + ); + }) + ); + }) + ); + }), + catchError(error => { + object.deprecated = wasDeprecated; + this.showError(error, retirementStarted); + return of(false); + }) + ); + }); + } + + public showError( + error: LifecycleFailure & { error?: LifecycleFailure }, + retirementStarted = false + ): void { + const body = error?.error || error; + const blockers: DeprecationBlockers = + body?.blockers || body?.details?.blockers; + const lines = [ + body?.message || + 'The lifecycle change could not be completed. Refresh and try again.', + ]; + if (blockers?.embedded?.length) { + lines.push( + 'Remove or replace these embedded references on their **source objects**, save those changes, then try again. No dependent objects are automatically edited.' + ); + for (const ref of blockers.embedded) { + lines.push( + `- **${ref.direction}**: \`${ref.source_ref}\` → \`${ref.target_ref}\`, field \`${ref.path}\`` + ); + } + } + const relationships = (blockers?.sros || []).filter( + rel => + rel.relationship_type !== 'subtechnique-of' && + rel.relationship_type !== 'revoked-by' + ); + if (relationships.length) { + lines.push('Active relationships still block deprecation:'); + for (const rel of relationships) { + lines.push( + `- **${rel.direction} ${rel.relationship_type}**: \`${rel.stix_id}\`` + ); + } + } + if (retirementStarted) { + lines.push( + 'Some relationships may already have been retired. Refresh before trying again; the object deprecation was not confirmed.' + ); + } + this.dialog.open(MarkdownViewDialogComponent, { + maxWidth: '60em', + maxHeight: '80vh', + autoFocus: false, + data: { title: 'Lifecycle change blocked', markdown: lines.join('\n\n') }, + }); + } + + private uniqueRelationships(check: DeprecationCheck) { + return [ + ...new Map( + check.blockers.sros + .filter( + rel => + rel.relationship_type !== 'subtechnique-of' && + rel.relationship_type !== 'revoked-by' + ) + .map(rel => [rel.stix_id, rel]) + ).values(), + ]; + } + + private requireNoEmbedded(check: DeprecationCheck): void { + if (check.blockers.embedded.length) { + throw this.blocked( + check, + 'Embedded references must be removed before deprecation. No relationships were retired by this attempt.' + ); + } + } + + private blocked(check: DeprecationCheck, message: string) { + return { ...check, message }; + } +} 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 1d1ffffbc..a7d035b68 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 @@ -2,12 +2,17 @@ import { TestBed, ComponentFixture } from '@angular/core/testing'; import { of } from 'rxjs'; import { DataQualityComponent } from './data-quality.component'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; +import { MatDialog } from '@angular/material/dialog'; +import { vi } from 'vitest'; +import { Relationship } from 'src/app/classes/stix/relationship'; +import { DeprecationService } from 'src/app/services/helpers/deprecation.service'; describe('DataQualityComponent', () => { let component: DataQualityComponent; let fixture: ComponentFixture; const mockReportService = { + getRelationship: vi.fn(), getMissingLinkById: () => of([]), getParallelRelationships: () => of({}), getDomainConsistencyReport: () => @@ -56,12 +61,17 @@ describe('DataQualityComponent', () => { }, }), }; + const lifecycle = { deprecate: vi.fn(), showError: vi.fn() }; + const dialog = { open: () => ({ afterClosed: () => of(true) }) }; beforeEach(async () => { + vi.clearAllMocks(); await TestBed.configureTestingModule({ declarations: [DataQualityComponent], providers: [ { provide: RestApiConnectorService, useValue: mockReportService }, + { provide: DeprecationService, useValue: lifecycle }, + { provide: MatDialog, useValue: dialog }, ], }).compileComponents(); @@ -70,10 +80,6 @@ describe('DataQualityComponent', () => { fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); - }); - it('should render the domain consistency report rows', () => { expect(component.crossDomainRelationships).toEqual([ expect.objectContaining({ @@ -99,4 +105,28 @@ describe('DataQualityComponent', () => { }), ]); }); + + for (const relationshipType of ['subtechnique-of', 'revoked-by']) { + it(`explicitly retires duplicate ${relationshipType} SROs and stops on failure`, () => { + const relationship = new Relationship(); + relationship.relationship_type = relationshipType; + mockReportService.getRelationship.mockReturnValue(of([relationship])); + lifecycle.deprecate.mockReturnValue(of(false)); + const group = { + key: 'duplicates', + sourceRef: 'attack-pattern--source', + targetRef: 'attack-pattern--target', + relationshipType, + count: 3, + relationships: [], + selectedRelationship: 'relationship--keep', + toDeprecate: [relationship.stixID, 'relationship--second'], + }; + component.deprecateOthers(group); + expect(lifecycle.deprecate).toHaveBeenCalledWith(relationship, true); + expect(mockReportService.getRelationship).toHaveBeenCalledTimes(1); + expect(group.toDeprecate).toHaveLength(2); + expect(component.loadingParallel).toBe(false); + }); + } }); 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 68c2f4d2d..74ab74992 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 @@ -6,8 +6,9 @@ import { StixListConfig } from 'src/app/components/stix/stix-list/stix-list.comp import { StixTypeToAttackType } from 'src/app/utils/type-mappings'; import { StixListComponent } from 'src/app/components/stix/stix-list/stix-list.component'; import { SelectionModel } from '@angular/cdk/collections'; -import { forkJoin } from 'rxjs'; -import { Relationship } from 'src/app/classes/stix/relationship'; +import { from, of } from 'rxjs'; +import { concatMap, finalize, takeWhile, toArray } from 'rxjs/operators'; +import { DeprecationService } from 'src/app/services/helpers/deprecation.service'; import { ConfirmationDialogComponent } from 'src/app/components/confirmation-dialog/confirmation-dialog.component'; export interface CrossDomainRelationshipRow { @@ -46,7 +47,8 @@ interface ParallelRelationshipGroup { export class DataQualityComponent implements OnInit { constructor( private reportService: RestApiConnectorService, - private dialog: MatDialog + private dialog: MatDialog, + private deprecationService: DeprecationService ) {} missingLinks: any[] = []; @@ -322,7 +324,7 @@ export class DataQualityComponent implements OnInit { // Deprecate non-selected relationships for a group deprecateOthers(group: ParallelRelationshipGroup): void { - if (!group || !group.toDeprecate?.length) return; + if (this.loadingParallel || !group || !group.toDeprecate?.length) return; const confirmationPrompt = this.dialog.open(ConfirmationDialogComponent, { maxWidth: '35em', @@ -333,41 +335,48 @@ export class DataQualityComponent implements OnInit { autoFocus: false, // prevents auto focus on toolbar buttons }); - const confirmationSub = confirmationPrompt.afterClosed().subscribe({ + confirmationPrompt.afterClosed().subscribe({ next: result => { if (!result) return; // user cancelled - const tasks = group.relationships - .filter( - r => - r.stix.id !== group.selectedRelationship && - !r?.x_mitre_deprecated && - !['subtechnique-of', 'revoked-by'].includes( - r.stix?.relationship_type + const ids = [...group.toDeprecate]; + this.loadingParallel = true; + from(ids) + .pipe( + concatMap(id => + this.reportService.getRelationship(id).pipe( + concatMap(versions => { + const relationship = versions[0]; + if (!relationship) + throw new Error(`Could not load relationship ${id}.`); + if (relationship.deprecated) return of(true); + return this.deprecationService.deprecate(relationship, true); + }) ) + ), + takeWhile(saved => saved, true), + toArray(), + finalize(() => (this.loadingParallel = false)) ) - .map(r => { - const rel = new Relationship(r); - rel.deprecated = true; - return this.reportService.putRelationship(rel); + .subscribe({ + next: results => { + if ( + results.length !== ids.length || + results.some(saved => !saved) + ) + return; + group.relationships = group.relationships.filter( + r => r.stix?.id === group.selectedRelationship + ); + group.toDeprecate = []; + group.stixObjects = this.buildStixObjectsForGroup(group); + window.location.reload(); + }, + error: err => { + this.deprecationService.showError(err, true); + }, }); - - const sub = forkJoin(tasks).subscribe({ - next: () => { - group.relationships = group.relationships.filter( - r => r.stix?.id === group.selectedRelationship - ); - group.toDeprecate = []; - group.stixObjects = this.buildStixObjectsForGroup(group); - window.location.reload(); - }, - error: err => { - console.error(err); - }, - complete: () => sub.unsubscribe(), - }); }, - complete: () => confirmationSub.unsubscribe(), }); } } diff --git a/src/app/views/stix/stix-dialog/stix-dialog.component.spec.ts b/src/app/views/stix/stix-dialog/stix-dialog.component.spec.ts index df1ce2919..0497a5884 100644 --- a/src/app/views/stix/stix-dialog/stix-dialog.component.spec.ts +++ b/src/app/views/stix/stix-dialog/stix-dialog.component.spec.ts @@ -5,6 +5,10 @@ import { ActivatedRoute } from '@angular/router'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { of } from 'rxjs'; +import { vi } from 'vitest'; +import { DataComponent } from 'src/app/classes/stix/data-component'; +import { Relationship } from 'src/app/classes/stix/relationship'; +import { DeprecationService } from 'src/app/services/helpers/deprecation.service'; import { StixDialogComponent } from './stix-dialog.component'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; @@ -56,10 +60,6 @@ describe('StixDialogComponent', () => { component = fixture.componentInstance; }); - it('should create', () => { - expect(component).toBeTruthy(); - }); - it('keeps diff dialogs read-only even when marked editable', () => { component._config = { mode: 'diff', @@ -69,4 +69,16 @@ describe('StixDialogComponent', () => { expect(component.config.editable).toBe(false); }); + + it('uses the shared lifecycle check for data components and relationships', () => { + const lifecycle = TestBed.inject(DeprecationService); + vi.spyOn(lifecycle, 'deprecate').mockReturnValue(of(false)); + for (const object of [new DataComponent(), new Relationship()]) { + component._config = { mode: 'view', object, editable: true }; + component.deprecateChanged(); + expect(object.deprecated).toBe(false); + expect(component.loading).toBe(false); + expect(component.dirty).toBe(false); + } + }); }); diff --git a/src/app/views/stix/stix-dialog/stix-dialog.component.ts b/src/app/views/stix/stix-dialog/stix-dialog.component.ts index 4e627aa24..26888de65 100644 --- a/src/app/views/stix/stix-dialog/stix-dialog.component.ts +++ b/src/app/views/stix/stix-dialog/stix-dialog.component.ts @@ -4,8 +4,8 @@ import { MatDialogRef, MAT_DIALOG_DATA, } from '@angular/material/dialog'; -import { forkJoin, Observable, of } from 'rxjs'; -import { filter, map, switchMap } from 'rxjs/operators'; +import { defer, forkJoin, Observable, of } from 'rxjs'; +import { catchError, finalize, map } from 'rxjs/operators'; import { ValidationData } from 'src/app/classes/serializable'; import { DataComponent, @@ -13,7 +13,7 @@ import { Software, StixObject, } from 'src/app/classes/stix'; -import { ConfirmationDialogComponent } from 'src/app/components/confirmation-dialog/confirmation-dialog.component'; +import { DeprecationService } from 'src/app/services/helpers/deprecation.service'; import { DeleteDialogComponent } from 'src/app/components/delete-dialog/delete-dialog.component'; import { AuthenticationService } from 'src/app/services/connectors/authentication/authentication.service'; import { RestApiConnectorService } from 'src/app/services/connectors/rest-api/rest-api-connector.service'; @@ -37,7 +37,8 @@ export class StixDialogComponent implements OnInit { private releaseTracksService: ReleaseTracksConnectorService, public editorService: EditorService, private authenticationService: AuthenticationService, - private dialog: MatDialog + private dialog: MatDialog, + private deprecationService: DeprecationService ) { if ( this._config.mode && @@ -310,73 +311,32 @@ export class StixDialogComponent implements OnInit { public loading = false; public deprecateChanged() { + if (this.loading || this.editing || !this.config.editable) return; const object = Array.isArray(this.config.object) ? this.config.object[0] : this.config.object; - - if (!object.deprecated && this.stixType == 'x-mitre-data-component') { - // inform users of relationship changes - const confirmationPrompt = this.dialog.open(ConfirmationDialogComponent, { - maxWidth: '35em', - data: { - message: - 'All relationships with this object will be deprecated. Do you want to continue?', - }, - autoFocus: false, // prevents auto focus on buttons - }); - - const confirmationSub = confirmationPrompt - .afterClosed() - .pipe( - filter(result => result), // user continued - switchMap(_ => { - this.loading = true; - return this.restApiService.getRelatedTo({ - sourceRef: object.stixID, - includeDeprecated: false, - }); - }) - ) - .subscribe({ - next: result => { - const saves = []; - const relationships = result?.data as Relationship[]; - - // deprecate or revoke object - object.deprecated = !object.deprecated; - this.dirty = true; // triggers refresh of object list - saves.push(object.save(this.restApiService)); - - // update relationships with the object - for (const relationship of relationships) { - relationship.deprecated = true; - saves.push(relationship.save(this.restApiService)); - } - - // complete save calls - const saveSubscription = forkJoin(saves).subscribe({ - complete: () => { - this.editorService.onReload.emit(); - saveSubscription.unsubscribe(); - }, - }); - }, - complete: () => { - this.loading = false; - confirmationSub.unsubscribe(); - }, - }); - } else { - object.deprecated = !object.deprecated; - this.dirty = true; // triggers refresh of object list - - // save object - const subscription = object.save(this.restApiService).subscribe({ - complete: () => { - subscription.unsubscribe(); - }, - }); - } + this.loading = true; + const wasDeprecated = object.deprecated; + const change = wasDeprecated + ? defer(() => { + object.deprecated = false; + return object.save(this.restApiService).pipe( + map(() => true), + catchError(error => { + object.deprecated = true; + this.deprecationService.showError(error); + return of(false); + }) + ); + }) + : this.deprecationService.deprecate(object); + change.pipe(finalize(() => (this.loading = false))).subscribe(saved => { + if (saved) { + this.dirty = true; + this.editorService.onReload.emit(); + this.reload(); + } + }); } public sidebarOpened = false;