diff --git a/x-pack/plugins/security_solution/common/types/timeline/note/index.ts b/x-pack/plugins/security_solution/common/types/timeline/note/index.ts index 074e4132efdf..4bda81d75d92 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/note/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/note/index.ts @@ -31,6 +31,12 @@ export const SavedNoteRuntimeType = runtimeTypes.intersection([ export interface SavedNote extends runtimeTypes.TypeOf {} +/** + * This type represents a note type stored in a saved object that does not include any fields that reference + * other saved objects. + */ +export type NoteWithoutExternalRefs = Omit; + /** * Note Saved object type with metadata */ diff --git a/x-pack/plugins/security_solution/server/lib/timeline/constants.ts b/x-pack/plugins/security_solution/server/lib/timeline/constants.ts index 9e761a1f5c31..e38096bc2e82 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/constants.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/constants.ts @@ -15,3 +15,8 @@ export const SAVED_QUERY_ID_REF_NAME = 'savedQueryId'; * https://github.com/elastic/kibana/blob/master/src/plugins/data/public/query/saved_query/saved_query_service.ts#L54 */ export const SAVED_QUERY_TYPE = 'query'; + +/** + * The reference name for the timeline ID field within the notes and pinned events saved object definition + */ +export const TIMELINE_ID_REF_NAME = 'timelineId'; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/persist_note.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/persist_note.ts index 32fd87f39620..ad94f06f2d34 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/persist_note.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/persist_note.ts @@ -42,18 +42,16 @@ export const persistNoteRoute = ( const frameworkRequest = await buildFrameworkRequest(context, security, request); const { note } = request.body; const noteId = request.body?.noteId ?? null; - const version = request.body?.version ?? null; - const res = await persistNote( - frameworkRequest, + const res = await persistNote({ + request: frameworkRequest, noteId, - version, - { + note: { ...note, timelineId: note.timelineId || null, }, - true - ); + overrideOwner: true, + }); return response.ok({ body: { data: { persistNote: res } }, diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/create_timelines/helpers.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/create_timelines/helpers.test.ts index f5e5b7dfb8ae..c76b0858a6e4 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/create_timelines/helpers.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/create_timelines/helpers.test.ts @@ -119,7 +119,7 @@ describe('createTimelines', () => { }); test('persistNotes', () => { - expect((persistNotes as jest.Mock).mock.calls[0][4]).toEqual([ + expect((persistNotes as jest.Mock).mock.calls[0][3]).toEqual([ { created: 1603885051655, createdBy: 'elastic', diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/create_timelines/helpers.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/create_timelines/helpers.ts index e202230bf5cc..b393c753853f 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/create_timelines/helpers.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/create_timelines/helpers.ts @@ -54,7 +54,6 @@ export const createTimelines = async ({ isImmutable ); const newTimelineSavedObjectId = responseTimeline.timeline.savedObjectId; - const newTimelineVersion = responseTimeline.timeline.version; let myPromises: unknown[] = []; if (pinnedEventIds != null && !isEmpty(pinnedEventIds)) { @@ -73,7 +72,6 @@ export const createTimelines = async ({ persistNotes( frameworkRequest, timelineSavedObjectId ?? newTimelineSavedObjectId, - newTimelineVersion, existingNoteIds, notes, overrideNotesOwner diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/import_timelines/index.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/import_timelines/index.test.ts index 2f51b23d7367..e0962e1fdce2 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/import_timelines/index.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/timelines/import_timelines/index.test.ts @@ -257,25 +257,13 @@ describe('import timelines', () => { test('should provide no noteSavedObjectId when Creating notes for a timeline', async () => { const mockRequest = await getImportTimelinesRequest(); await server.inject(mockRequest, context); - expect(mockPersistNote.mock.calls[0][1]).toBeNull(); - }); - - test('should provide new timeline version when Creating notes for a timeline', async () => { - const mockRequest = await getImportTimelinesRequest(); - await server.inject(mockRequest, context); - expect(mockPersistNote.mock.calls[0][1]).toBeNull(); - }); - - test('should provide note content when Creating notes for a timeline', async () => { - const mockRequest = await getImportTimelinesRequest(); - await server.inject(mockRequest, context); - expect(mockPersistNote.mock.calls[0][2]).toEqual(mockCreatedTimeline.version); + expect(mockPersistNote.mock.calls[0][0].noteId).toBeNull(); }); test('should provide new notes with original author info when Creating notes for a timeline', async () => { const mockRequest = await getImportTimelinesRequest(); await server.inject(mockRequest, context); - expect(mockPersistNote.mock.calls[0][3]).toEqual({ + expect(mockPersistNote.mock.calls[0][0].note).toEqual({ eventId: undefined, note: 'original note', created: '1584830796960', @@ -284,7 +272,7 @@ describe('import timelines', () => { updatedBy: 'original author A', timelineId: mockCreatedTimeline.savedObjectId, }); - expect(mockPersistNote.mock.calls[1][3]).toEqual({ + expect(mockPersistNote.mock.calls[1][0].note).toEqual({ eventId: mockUniqueParsedObjects[0].eventNotes[0].eventId, note: 'original event note', created: '1584830796960', @@ -293,7 +281,7 @@ describe('import timelines', () => { updatedBy: 'original author B', timelineId: mockCreatedTimeline.savedObjectId, }); - expect(mockPersistNote.mock.calls[2][3]).toEqual({ + expect(mockPersistNote.mock.calls[2][0].note).toEqual({ eventId: mockUniqueParsedObjects[0].eventNotes[1].eventId, note: 'event note2', created: '1584830796960', @@ -310,7 +298,7 @@ describe('import timelines', () => { const mockRequest = await getImportTimelinesRequest(); await server.inject(mockRequest, context); - expect(mockPersistNote.mock.calls[0][3]).toEqual({ + expect(mockPersistNote.mock.calls[0][0].note).toEqual({ created: mockUniqueParsedObjects[0].globalNotes[0].created, createdBy: mockUniqueParsedObjects[0].globalNotes[0].createdBy, updated: mockUniqueParsedObjects[0].globalNotes[0].updated, @@ -319,7 +307,7 @@ describe('import timelines', () => { note: mockUniqueParsedObjects[0].globalNotes[0].note, timelineId: mockCreatedTimeline.savedObjectId, }); - expect(mockPersistNote.mock.calls[1][3]).toEqual({ + expect(mockPersistNote.mock.calls[1][0].note).toEqual({ created: mockUniqueParsedObjects[0].eventNotes[0].created, createdBy: mockUniqueParsedObjects[0].eventNotes[0].createdBy, updated: mockUniqueParsedObjects[0].eventNotes[0].updated, @@ -328,7 +316,7 @@ describe('import timelines', () => { note: mockUniqueParsedObjects[0].eventNotes[0].note, timelineId: mockCreatedTimeline.savedObjectId, }); - expect(mockPersistNote.mock.calls[2][3]).toEqual({ + expect(mockPersistNote.mock.calls[2][0].note).toEqual({ created: mockUniqueParsedObjects[0].eventNotes[1].created, createdBy: mockUniqueParsedObjects[0].eventNotes[1].createdBy, updated: mockUniqueParsedObjects[0].eventNotes[1].updated, @@ -640,19 +628,13 @@ describe('import timeline templates', () => { test('should provide no noteSavedObjectId when Creating notes for a timeline', async () => { const mockRequest = await getImportTimelinesRequest(); await server.inject(mockRequest, context); - expect(mockPersistNote.mock.calls[0][1]).toBeNull(); - }); - - test('should provide new timeline version when Creating notes for a timeline', async () => { - const mockRequest = await getImportTimelinesRequest(); - await server.inject(mockRequest, context); - expect(mockPersistNote.mock.calls[0][2]).toEqual(mockCreatedTemplateTimeline.version); + expect(mockPersistNote.mock.calls[0][0].noteId).toBeNull(); }); test('should exclude event notes when creating notes', async () => { const mockRequest = await getImportTimelinesRequest(); await server.inject(mockRequest, context); - expect(mockPersistNote.mock.calls[0][3]).toEqual({ + expect(mockPersistNote.mock.calls[0][0].note).toEqual({ eventId: undefined, note: mockUniqueParsedTemplateTimelineObjects[0].globalNotes[0].note, created: mockUniqueParsedTemplateTimelineObjects[0].globalNotes[0].created, @@ -792,19 +774,13 @@ describe('import timeline templates', () => { test('should provide noteSavedObjectId when Creating notes for a timeline', async () => { const mockRequest = await getImportTimelinesRequest(); await server.inject(mockRequest, context); - expect(mockPersistNote.mock.calls[0][1]).toBeNull(); - }); - - test('should provide new timeline version when Creating notes for a timeline', async () => { - const mockRequest = await getImportTimelinesRequest(); - await server.inject(mockRequest, context); - expect(mockPersistNote.mock.calls[0][2]).toEqual(mockCreatedTemplateTimeline.version); + expect(mockPersistNote.mock.calls[0][0].noteId).toBeNull(); }); test('should exclude event notes when creating notes', async () => { const mockRequest = await getImportTimelinesRequest(); await server.inject(mockRequest, context); - expect(mockPersistNote.mock.calls[0][3]).toEqual({ + expect(mockPersistNote.mock.calls[0][0].note).toEqual({ eventId: undefined, note: mockUniqueParsedTemplateTimelineObjects[0].globalNotes[0].note, created: mockUniqueParsedTemplateTimelineObjects[0].globalNotes[0].created, diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/field_migrator.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/field_migrator.ts new file mode 100644 index 000000000000..608c104440e7 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/field_migrator.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TIMELINE_ID_REF_NAME } from '../../constants'; +import { timelineSavedObjectType } from '../../saved_object_mappings'; +import { FieldMigrator } from '../../utils/migrator'; + +/** + * A migrator to handle moving specific fields that reference the timeline saved object to the references field within a note saved + * object. + */ +export const noteFieldsMigrator = new FieldMigrator([ + { path: 'timelineId', type: timelineSavedObjectType, name: TIMELINE_ID_REF_NAME }, +]); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/index.ts index 34914517da68..81941853c57a 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/index.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/index.ts @@ -28,13 +28,17 @@ export interface Notes { search: string | null, sort: SortNote | null ) => Promise; - persistNote: ( - request: FrameworkRequest, - noteId: string | null, - version: string | null, - note: SavedNote, - overrideOwner: boolean - ) => Promise; + persistNote: ({ + request, + noteId, + note, + overrideOwner, + }: { + request: FrameworkRequest; + noteId: string | null; + note: SavedNote; + overrideOwner: boolean; + }) => Promise; convertSavedObjectToSavedNote: ( savedObject: unknown, timelineVersion?: string | undefined | null diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/persist_notes.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/persist_notes.ts index 58b4e33444d9..612c9083cb34 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/persist_notes.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/persist_notes.ts @@ -13,7 +13,6 @@ import { NoteResult } from '../../../../../common/types/timeline/note'; export const persistNotes = async ( frameworkRequest: FrameworkRequest, timelineSavedObjectId: string, - timelineVersion?: string | null, existingNoteIds?: string[], newNotes?: NoteResult[], overrideOwner: boolean = true @@ -26,13 +25,12 @@ export const persistNotes = async ( timelineSavedObjectId, overrideOwner ); - return persistNote( - frameworkRequest, - overrideOwner ? existingNoteIds?.find((nId) => nId === note.noteId) ?? null : null, - timelineVersion ?? null, - newNote, - overrideOwner - ); + return persistNote({ + request: frameworkRequest, + noteId: overrideOwner ? existingNoteIds?.find((nId) => nId === note.noteId) ?? null : null, + note: newNote, + overrideOwner, + }); }) ?? [] ); }; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.ts index 91caaa8cc8a8..29a2aa809b80 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.ts @@ -25,11 +25,13 @@ import { NoteResult, ResponseNotes, ResponseNote, + NoteWithoutExternalRefs, } from '../../../../../common/types/timeline/note'; import { FrameworkRequest } from '../../../framework'; import { noteSavedObjectType } from '../../saved_object_mappings/notes'; -import { convertSavedObjectToSavedTimeline, pickSavedTimeline } from '../timelines'; +import { createTimeline } from '../timelines'; import { timelineSavedObjectType } from '../../saved_object_mappings'; +import { noteFieldsMigrator } from './field_migrator'; export const deleteNote = async (request: FrameworkRequest, noteIds: string[]) => { const savedObjectsClient = request.context.core.savedObjects.client; @@ -42,8 +44,7 @@ export const deleteNote = async (request: FrameworkRequest, noteIds: string[]) = export const deleteNoteByTimelineId = async (request: FrameworkRequest, timelineId: string) => { const options: SavedObjectsFindOptions = { type: noteSavedObjectType, - search: timelineId, - searchFields: ['timelineId'], + hasReference: { type: timelineSavedObjectType, id: timelineId }, }; const notesToBeDeleted = await getAllSavedNote(request, options); const savedObjectsClient = request.context.core.savedObjects.client; @@ -81,8 +82,7 @@ export const getNotesByTimelineId = async ( ): Promise => { const options: SavedObjectsFindOptions = { type: noteSavedObjectType, - search: timelineId, - searchFields: ['timelineId'], + hasReference: { type: timelineSavedObjectType, id: timelineId }, }; const notesByTimelineId = await getAllSavedNote(request, options); return notesByTimelineId.notes; @@ -106,62 +106,29 @@ export const getAllNotes = async ( return getAllSavedNote(request, options); }; -export const persistNote = async ( - request: FrameworkRequest, - noteId: string | null, - version: string | null, - note: SavedNote, - overrideOwner: boolean = true -): Promise => { +export const persistNote = async ({ + request, + noteId, + note, + overrideOwner = true, +}: { + request: FrameworkRequest; + noteId: string | null; + note: SavedNote; + overrideOwner?: boolean; +}): Promise => { try { - const savedObjectsClient = request.context.core.savedObjects.client; - if (noteId == null) { - const timelineVersionSavedObject = - note.timelineId == null - ? await (async () => { - const timelineResult = convertSavedObjectToSavedTimeline( - await savedObjectsClient.create( - timelineSavedObjectType, - pickSavedTimeline(null, {}, request.user) - ) - ); - note.timelineId = timelineResult.savedObjectId; - return timelineResult.version; - })() - : null; - - // Create new note - return { - code: 200, - message: 'success', - note: convertSavedObjectToSavedNote( - await savedObjectsClient.create( - noteSavedObjectType, - overrideOwner ? pickSavedNote(noteId, note, request.user) : note - ), - timelineVersionSavedObject != null ? timelineVersionSavedObject : undefined - ), - }; + return await createNote({ + request, + noteId, + note, + overrideOwner, + }); } // Update existing note - - const existingNote = await getSavedNote(request, noteId); - return { - code: 200, - message: 'success', - note: convertSavedObjectToSavedNote( - await savedObjectsClient.update( - noteSavedObjectType, - noteId, - overrideOwner ? pickSavedNote(noteId, note, request.user) : note, - { - version: existingNote.version || undefined, - } - ) - ), - }; + return await updateNote({ request, noteId, note, overrideOwner }); } catch (err) { if (getOr(null, 'output.statusCode', err) === 403) { const noteToReturn: NoteResult = { @@ -181,22 +148,142 @@ export const persistNote = async ( } }; +const createNote = async ({ + request, + noteId, + note, + overrideOwner = true, +}: { + request: FrameworkRequest; + noteId: string | null; + note: SavedNote; + overrideOwner?: boolean; +}) => { + const savedObjectsClient = request.context.core.savedObjects.client; + const userInfo = request.user; + + const shallowCopyOfNote = { ...note }; + let timelineVersion: string | undefined; + + if (note.timelineId == null) { + const { timeline: timelineResult } = await createTimeline({ + timelineId: null, + timeline: {}, + savedObjectsClient, + userInfo, + }); + + shallowCopyOfNote.timelineId = timelineResult.savedObjectId; + timelineVersion = timelineResult.version; + } + + const noteWithCreator = overrideOwner + ? pickSavedNote(noteId, shallowCopyOfNote, userInfo) + : shallowCopyOfNote; + + const { + transformedFields: migratedAttributes, + references, + } = noteFieldsMigrator.extractFieldsToReferences({ + data: noteWithCreator, + }); + + const createdNote = await savedObjectsClient.create( + noteSavedObjectType, + migratedAttributes, + { + references, + } + ); + + const repopulatedSavedObject = noteFieldsMigrator.populateFieldsFromReferences(createdNote); + + const convertedNote = convertSavedObjectToSavedNote(repopulatedSavedObject, timelineVersion); + + // Create new note + return { + code: 200, + message: 'success', + note: convertedNote, + }; +}; + +const updateNote = async ({ + request, + noteId, + note, + overrideOwner = true, +}: { + request: FrameworkRequest; + noteId: string; + note: SavedNote; + overrideOwner?: boolean; +}) => { + const savedObjectsClient = request.context.core.savedObjects.client; + const userInfo = request.user; + + const existingNote = await savedObjectsClient.get( + noteSavedObjectType, + noteId + ); + + const noteWithCreator = overrideOwner ? pickSavedNote(noteId, note, userInfo) : note; + + const { + transformedFields: migratedPatchAttributes, + references, + } = noteFieldsMigrator.extractFieldsToReferences({ + data: noteWithCreator, + existingReferences: existingNote.references, + }); + + const updatedNote = await savedObjectsClient.update( + noteSavedObjectType, + noteId, + migratedPatchAttributes, + { + version: existingNote.version || undefined, + references, + } + ); + + const populatedNote = noteFieldsMigrator.populateFieldsFromReferencesForPatch({ + dataBeforeRequest: note, + dataReturnedFromRequest: updatedNote, + }); + + const convertedNote = convertSavedObjectToSavedNote(populatedNote); + + return { + code: 200, + message: 'success', + note: convertedNote, + }; +}; + const getSavedNote = async (request: FrameworkRequest, NoteId: string) => { const savedObjectsClient = request.context.core.savedObjects.client; - const savedObject = await savedObjectsClient.get(noteSavedObjectType, NoteId); + const savedObject = await savedObjectsClient.get( + noteSavedObjectType, + NoteId + ); - return convertSavedObjectToSavedNote(savedObject); + const populatedNote = noteFieldsMigrator.populateFieldsFromReferences(savedObject); + + return convertSavedObjectToSavedNote(populatedNote); }; const getAllSavedNote = async (request: FrameworkRequest, options: SavedObjectsFindOptions) => { const savedObjectsClient = request.context.core.savedObjects.client; - const savedObjects = await savedObjectsClient.find(options); + const savedObjects = await savedObjectsClient.find(options); return { totalCount: savedObjects.total, - notes: savedObjects.saved_objects.map((savedObject) => - convertSavedObjectToSavedNote(savedObject) - ), + notes: savedObjects.saved_objects.map((savedObject) => { + const populatedNote = noteFieldsMigrator.populateFieldsFromReferences(savedObject); + + return convertSavedObjectToSavedNote(populatedNote); + }), }; }; @@ -233,11 +320,9 @@ const pickSavedNote = ( if (noteId == null) { savedNote.created = new Date().valueOf(); savedNote.createdBy = userInfo?.username ?? UNAUTHENTICATED_USER; - savedNote.updated = new Date().valueOf(); - savedNote.updatedBy = userInfo?.username ?? UNAUTHENTICATED_USER; - } else if (noteId != null) { - savedNote.updated = new Date().valueOf(); - savedNote.updatedBy = userInfo?.username ?? UNAUTHENTICATED_USER; } + + savedNote.updated = new Date().valueOf(); + savedNote.updatedBy = userInfo?.username ?? UNAUTHENTICATED_USER; return savedNote; }; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.ts index d35a023e8df5..d25d2ece7d24 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.ts @@ -426,7 +426,7 @@ export const persistTimeline = async ( } }; -const createTimeline = async ({ +export const createTimeline = async ({ timelineId, timeline, savedObjectsClient, diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/index.ts index 0b8c698bc3ea..e4c8858321e1 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/index.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/index.ts @@ -6,3 +6,4 @@ */ export { timelinesMigrations } from './timelines'; +export { notesMigrations } from './notes'; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/notes.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/notes.test.ts new file mode 100644 index 000000000000..0aa847cac34c --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/notes.test.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TIMELINE_ID_REF_NAME } from '../../constants'; +import { migrateNoteTimelineIdToReferences, TimelineId } from './notes'; + +describe('notes migrations', () => { + describe('7.16.0 timelineId', () => { + it('removes the timelineId from the migrated document', () => { + const migratedDoc = migrateNoteTimelineIdToReferences({ + id: '1', + type: 'awesome', + attributes: { timelineId: '123' }, + }); + + expect(migratedDoc.attributes).toEqual({}); + expect(migratedDoc.references).toEqual([ + // importing the timeline saved object type from the timeline saved object causes a circular import and causes the jest tests to fail + { id: '123', name: TIMELINE_ID_REF_NAME, type: 'siem-ui-timeline' }, + ]); + }); + + it('preserves additional fields when migrating timeline id', () => { + const migratedDoc = migrateNoteTimelineIdToReferences({ + id: '1', + type: 'awesome', + attributes: ({ awesome: 'yes', timelineId: '123' } as unknown) as TimelineId, + }); + + expect(migratedDoc.attributes).toEqual({ awesome: 'yes' }); + expect(migratedDoc.references).toEqual([ + { id: '123', name: TIMELINE_ID_REF_NAME, type: 'siem-ui-timeline' }, + ]); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/notes.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/notes.ts new file mode 100644 index 000000000000..a8d753e916af --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/notes.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + SavedObjectMigrationMap, + SavedObjectSanitizedDoc, + SavedObjectUnsanitizedDoc, +} from 'kibana/server'; +import { timelineSavedObjectType } from '..'; +import { TIMELINE_ID_REF_NAME } from '../../constants'; +import { createMigratedDoc, createReference } from './utils'; + +export interface TimelineId { + timelineId?: string | null; +} + +export const migrateNoteTimelineIdToReferences = ( + doc: SavedObjectUnsanitizedDoc +): SavedObjectSanitizedDoc => { + const { timelineId, ...restAttributes } = doc.attributes; + + const { references: docReferences = [] } = doc; + const timelineIdReferences = createReference( + timelineId, + TIMELINE_ID_REF_NAME, + timelineSavedObjectType + ); + + return createMigratedDoc({ + doc, + attributes: restAttributes, + docReferences, + migratedReferences: timelineIdReferences, + }); +}; + +export const notesMigrations: SavedObjectMigrationMap = { + '7.16.0': migrateNoteTimelineIdToReferences, +}; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/timelines.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/timelines.ts index 7c26df4a475e..45733d7737b6 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/timelines.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/timelines.ts @@ -11,7 +11,7 @@ import { SavedObjectUnsanitizedDoc, } from 'kibana/server'; import { SAVED_QUERY_ID_REF_NAME, SAVED_QUERY_TYPE } from '../../constants'; -import { createReference } from './utils'; +import { createMigratedDoc, createReference } from './utils'; export interface SavedQueryId { savedQueryId?: string | null; @@ -29,13 +29,12 @@ export const migrateSavedQueryIdToReferences = ( SAVED_QUERY_TYPE ); - return { - ...doc, - attributes: { - ...restAttributes, - }, - references: [...docReferences, ...savedQueryIdReferences], - }; + return createMigratedDoc({ + doc, + attributes: restAttributes, + docReferences, + migratedReferences: savedQueryIdReferences, + }); }; export const timelinesMigrations: SavedObjectMigrationMap = { diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/utils.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/utils.test.ts index cdf4124dc9c4..02e3fca996d5 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/utils.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { createReference } from './utils'; +import { createMigratedDoc, createReference } from './utils'; describe('migration utils', () => { describe('createReference', () => { @@ -23,4 +23,52 @@ describe('migration utils', () => { expect(createReference(null, 'name', 'type')).toHaveLength(0); }); }); + + describe('createMigratedDoc', () => { + it('overwrites the attributes of the original doc', () => { + const doc = { + id: '1', + attributes: { + hello: '1', + }, + type: 'yes', + }; + + expect( + createMigratedDoc({ doc, attributes: {}, docReferences: [], migratedReferences: [] }) + ).toEqual({ + id: '1', + attributes: {}, + type: 'yes', + references: [], + }); + }); + + it('combines the references', () => { + const doc = { + id: '1', + attributes: { + hello: '1', + }, + type: 'yes', + }; + + expect( + createMigratedDoc({ + doc, + attributes: {}, + docReferences: [{ id: '1', name: 'name', type: 'type' }], + migratedReferences: [{ id: '5', name: 'name5', type: 'type5' }], + }) + ).toEqual({ + id: '1', + attributes: {}, + type: 'yes', + references: [ + { id: '1', name: 'name', type: 'type' }, + { id: '5', name: 'name5', type: 'type5' }, + ], + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/utils.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/utils.ts index 8efa6bb0ec93..ff9b56e6ae2c 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/migrations/utils.ts @@ -5,7 +5,11 @@ * 2.0. */ -import { SavedObjectReference } from 'kibana/server'; +import { + SavedObjectReference, + SavedObjectSanitizedDoc, + SavedObjectUnsanitizedDoc, +} from 'kibana/server'; export function createReference( id: string | null | undefined, @@ -14,3 +18,21 @@ export function createReference( ): SavedObjectReference[] { return id != null ? [{ id, name, type }] : []; } + +export const createMigratedDoc = ({ + doc, + attributes, + docReferences, + migratedReferences, +}: { + doc: SavedObjectUnsanitizedDoc; + attributes: object; + docReferences: SavedObjectReference[]; + migratedReferences: SavedObjectReference[]; +}): SavedObjectSanitizedDoc => ({ + ...doc, + attributes: { + ...attributes, + }, + references: [...docReferences, ...migratedReferences], +}); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/notes.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/notes.ts index 5815747d3e72..387f78e5059f 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/notes.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/notes.ts @@ -6,14 +6,12 @@ */ import { SavedObjectsType } from '../../../../../../../src/core/server'; +import { notesMigrations } from './migrations'; export const noteSavedObjectType = 'siem-ui-timeline-note'; export const noteSavedObjectMappings: SavedObjectsType['mappings'] = { properties: { - timelineId: { - type: 'keyword', - }, eventId: { type: 'keyword', }, @@ -40,4 +38,5 @@ export const noteType: SavedObjectsType = { hidden: false, namespaceType: 'single', mappings: noteSavedObjectMappings, + migrations: notesMigrations, }; diff --git a/x-pack/test/api_integration/apis/security_solution/timeline_migrations.ts b/x-pack/test/api_integration/apis/security_solution/timeline_migrations.ts index 820e0945f151..9863ebb7ba64 100644 --- a/x-pack/test/api_integration/apis/security_solution/timeline_migrations.ts +++ b/x-pack/test/api_integration/apis/security_solution/timeline_migrations.ts @@ -7,6 +7,7 @@ import expect from '@kbn/expect'; import { SavedTimeline } from '../../../../plugins/security_solution/common/types/timeline'; +import { SavedNote } from '../../../../plugins/security_solution/common/types/timeline/note'; import { FtrProviderContext } from '../../ftr_provider_context'; import { getSavedObjectFromES } from './utils'; @@ -15,6 +16,10 @@ interface TimelineWithoutSavedQueryId { 'siem-ui-timeline': Omit; } +interface NoteWithoutTimelineId { + 'siem-ui-timeline-note': Omit; +} + export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -23,48 +28,106 @@ export default function ({ getService }: FtrProviderContext) { const es = getService('es'); describe('7.16.0', () => { - before(async () => { - await esArchiver.load( - 'x-pack/test/functional/es_archives/security_solution/timelines/7.15.0' - ); + describe('notes timelineId', () => { + before(async () => { + await esArchiver.load( + 'x-pack/test/functional/es_archives/security_solution/timelines/7.15.0' + ); + }); + + after(async () => { + await esArchiver.unload( + 'x-pack/test/functional/es_archives/security_solution/timelines/7.15.0' + ); + }); + + it('removes the timelineId in the saved object', async () => { + const timelines = await getSavedObjectFromES( + es, + 'siem-ui-timeline-note', + { + ids: { + values: [ + 'siem-ui-timeline-note:989002c0-126e-11ec-83d2-db1096c73738', + 'siem-ui-timeline-note:f09b5980-1271-11ec-83d2-db1096c73738', + ], + }, + } + ); + + expect( + timelines.body.hits.hits[0]._source?.['siem-ui-timeline-note'] + ).to.not.have.property('timelineId'); + + expect( + timelines.body.hits.hits[1]._source?.['siem-ui-timeline-note'] + ).to.not.have.property('timelineId'); + }); + + it('preserves the eventId in the saved object after migration', async () => { + const resp = await supertest + .get('/api/timeline') + .query({ id: '6484cc90-126e-11ec-83d2-db1096c73738' }); + + expect(resp.body.data.getOneTimeline.notes[0].eventId).to.be('Edo00XsBEVtyvU-8LGNe'); + }); + + it('returns the timelineId in the response', async () => { + const resp = await supertest + .get('/api/timeline') + .query({ id: '6484cc90-126e-11ec-83d2-db1096c73738' }); + + expect(resp.body.data.getOneTimeline.notes[0].timelineId).to.be( + '6484cc90-126e-11ec-83d2-db1096c73738' + ); + expect(resp.body.data.getOneTimeline.notes[1].timelineId).to.be( + '6484cc90-126e-11ec-83d2-db1096c73738' + ); + }); }); - after(async () => { - await esArchiver.unload( - 'x-pack/test/functional/es_archives/security_solution/timelines/7.15.0' - ); - }); + describe('savedQueryId', () => { + before(async () => { + await esArchiver.load( + 'x-pack/test/functional/es_archives/security_solution/timelines/7.15.0' + ); + }); - it('removes the savedQueryId', async () => { - const timelines = await getSavedObjectFromES( - es, - 'siem-ui-timeline', - { - ids: { values: ['siem-ui-timeline:8dc70950-1012-11ec-9ad3-2d7c6600c0f7'] }, - } - ); + after(async () => { + await esArchiver.unload( + 'x-pack/test/functional/es_archives/security_solution/timelines/7.15.0' + ); + }); - expect(timelines.body.hits.hits[0]._source?.['siem-ui-timeline']).to.not.have.property( - 'savedQueryId' - ); - }); + it('removes the savedQueryId', async () => { + const timelines = await getSavedObjectFromES( + es, + 'siem-ui-timeline', + { + ids: { values: ['siem-ui-timeline:8dc70950-1012-11ec-9ad3-2d7c6600c0f7'] }, + } + ); - it('preserves the title in the saved object after migration', async () => { - const resp = await supertest - .get('/api/timeline') - .query({ id: '8dc70950-1012-11ec-9ad3-2d7c6600c0f7' }) - .set('kbn-xsrf', 'true'); + expect(timelines.body.hits.hits[0]._source?.['siem-ui-timeline']).to.not.have.property( + 'savedQueryId' + ); + }); - expect(resp.body.data.getOneTimeline.title).to.be('Awesome Timeline'); - }); + it('preserves the title in the saved object after migration', async () => { + const resp = await supertest + .get('/api/timeline') + .query({ id: '8dc70950-1012-11ec-9ad3-2d7c6600c0f7' }); - it('returns the savedQueryId in the response', async () => { - const resp = await supertest - .get('/api/timeline') - .query({ id: '8dc70950-1012-11ec-9ad3-2d7c6600c0f7' }) - .set('kbn-xsrf', 'true'); + expect(resp.body.data.getOneTimeline.title).to.be('Awesome Timeline'); + }); - expect(resp.body.data.getOneTimeline.savedQueryId).to.be("It's me"); + it('returns the savedQueryId in the response', async () => { + const resp = await supertest + .get('/api/timeline') + .query({ id: '8dc70950-1012-11ec-9ad3-2d7c6600c0f7' }); + + expect(resp.body.data.getOneTimeline.savedQueryId).to.be("It's me"); + }); }); }); }); diff --git a/x-pack/test/functional/es_archives/security_solution/timelines/7.15.0/data.json.gz b/x-pack/test/functional/es_archives/security_solution/timelines/7.15.0/data.json.gz index 9f45c0303ff7..e942ef732b22 100644 Binary files a/x-pack/test/functional/es_archives/security_solution/timelines/7.15.0/data.json.gz and b/x-pack/test/functional/es_archives/security_solution/timelines/7.15.0/data.json.gz differ diff --git a/x-pack/test/functional/es_archives/security_solution/timelines/7.15.0/mappings.json b/x-pack/test/functional/es_archives/security_solution/timelines/7.15.0/mappings.json index da616b9d19f6..7561dbb8dc6d 100644 --- a/x-pack/test/functional/es_archives/security_solution/timelines/7.15.0/mappings.json +++ b/x-pack/test/functional/es_archives/security_solution/timelines/7.15.0/mappings.json @@ -3,6 +3,8 @@ "value": { "aliases": { ".kibana": { + }, + ".kibana_7.15.0": { } }, "index": ".kibana_1",