diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx index fb3fc23f6985e..1aff9bf14a44b 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx @@ -418,7 +418,10 @@ export class StatefulOpenTimelineComponent extends React.PureComponent< ? {} : timelineModel.pinnedEventsSaveObject != null ? timelineModel.pinnedEventsSaveObject.reduce( - (acc, pinnedEvent) => ({ ...acc, [pinnedEvent.pinnedEventId]: pinnedEvent }), + (acc, pinnedEvent) => ({ + ...acc, + ...(pinnedEvent.eventId != null ? { [pinnedEvent.eventId]: pinnedEvent } : {}), + }), {} ) : {}, diff --git a/x-pack/legacy/plugins/siem/public/store/timeline/epic.ts b/x-pack/legacy/plugins/siem/public/store/timeline/epic.ts index 2b31ac9505d18..b921c7530a496 100644 --- a/x-pack/legacy/plugins/siem/public/store/timeline/epic.ts +++ b/x-pack/legacy/plugins/siem/public/store/timeline/epic.ts @@ -75,8 +75,7 @@ interface TimelineEpicDependencies { apolloClient$: Observable; } -export interface ActionTimeline { - type: string; +export interface ActionTimeline extends Action { payload: { id: string; eventId: string; @@ -158,7 +157,7 @@ export const createTimelineEpic = (): Epic< delay(500), withLatestFrom(timeline$, apolloClient$, notes$, timelineTimeRange$), concatMap(([objAction, timeline, apolloClient, notes, timelineTimeRange]) => { - const action: Action = get('action', objAction); + const action: ActionTimeline = get('action', objAction); const timelineId = myEpicTimelineId.getTimelineId(); const version = myEpicTimelineId.getTimelineVersion(); diff --git a/x-pack/legacy/plugins/siem/public/store/timeline/epic_pinned_event.ts b/x-pack/legacy/plugins/siem/public/store/timeline/epic_pinned_event.ts index 8202c2f0dc49b..e44dedef86da8 100644 --- a/x-pack/legacy/plugins/siem/public/store/timeline/epic_pinned_event.ts +++ b/x-pack/legacy/plugins/siem/public/store/timeline/epic_pinned_event.ts @@ -27,12 +27,13 @@ import { myEpicTimelineId } from './my_epic_timeline_id'; import { refetchQueries } from './refetch_queries'; import { dispatcherTimelinePersistQueue } from './epic_dispatcher_timeline_persistence_queue'; import { TimelineById } from './types'; +import { ActionTimeline } from './epic'; export const timelinePinnedEventActionsType = [pinEvent.type, unPinEvent.type]; export const epicPersistPinnedEvent = ( apolloClient: ApolloClient, - action: Action, + action: ActionTimeline, timeline: TimelineById, action$: Observable, timeline$: Observable @@ -47,14 +48,11 @@ export const epicPersistPinnedEvent = ( fetchPolicy: 'no-cache', variables: { pinnedEventId: - timeline[get('payload.id', action)].pinnedEventsSaveObject[ - get('payload.eventId', action) - ] != null - ? timeline[get('payload.id', action)].pinnedEventsSaveObject[ - get('payload.eventId', action) - ].pinnedEventId + timeline[action.payload.id].pinnedEventsSaveObject[action.payload.eventId] != null + ? timeline[action.payload.id].pinnedEventsSaveObject[action.payload.eventId] + .pinnedEventId : null, - eventId: get('payload.eventId', action), + eventId: action.payload.eventId, timelineId: myEpicTimelineId.getTimelineId(), }, refetchQueries, @@ -62,14 +60,13 @@ export const epicPersistPinnedEvent = ( ).pipe( withLatestFrom(timeline$), mergeMap(([result, recentTimeline]) => { - const savedTimeline = recentTimeline[get('payload.id', action)]; + const savedTimeline = recentTimeline[action.payload.id]; const response: PinnedEvent = get('data.persistPinnedEventOnTimeline', result); const callOutMsg = response && response.code === 403 ? [showCallOutUnauthorizedMsg()] : []; - return [ response != null ? updateTimeline({ - id: get('payload.id', action), + id: action.payload.id, timeline: { ...savedTimeline, savedObjectId: @@ -80,29 +77,34 @@ export const epicPersistPinnedEvent = ( savedTimeline.version == null && response.timelineVersion != null ? response.timelineVersion : savedTimeline.version, + pinnedEventIds: { + ...savedTimeline.pinnedEventIds, + [action.payload.eventId]: true, + }, pinnedEventsSaveObject: { ...savedTimeline.pinnedEventsSaveObject, - [get('payload.eventId', action)]: response, + [action.payload.eventId]: response, }, }, }) : updateTimeline({ - id: get('payload.id', action), + id: action.payload.id, timeline: { ...savedTimeline, + pinnedEventIds: omit(action.payload.eventId, savedTimeline.pinnedEventIds), pinnedEventsSaveObject: omit( - get('payload.eventId', action), + action.payload.eventId, savedTimeline.pinnedEventsSaveObject ), }, }), ...callOutMsg, endTimelineSaving({ - id: get('payload.id', action), + id: action.payload.id, }), ]; }), - startWith(startTimelineSaving({ id: get('payload.id', action) })), + startWith(startTimelineSaving({ id: action.payload.id })), takeUntil( action$.pipe( withLatestFrom(timeline$), diff --git a/x-pack/legacy/plugins/siem/public/store/timeline/helpers.test.ts b/x-pack/legacy/plugins/siem/public/store/timeline/helpers.test.ts new file mode 100644 index 0000000000000..875f7ee8b6a4b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/store/timeline/helpers.test.ts @@ -0,0 +1,270 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { addTimelineToStore } from './helpers'; +import { TimelineResult } from '../../graphql/types'; +import { timelineDefaults } from './model'; + +describe('helpers', () => { + describe('#addTimelineToStore', () => { + test('if title is null, we should get the default title', () => { + const timeline: TimelineResult = { + savedObjectId: 'savedObject-1', + title: null, + version: '1', + }; + const newTimeline = addTimelineToStore({ id: 'timeline-1', timeline }); + expect(newTimeline).toEqual({ + 'timeline-1': { + columns: [ + { + columnHeaderType: 'not-filtered', + id: '@timestamp', + width: 240, + }, + { + columnHeaderType: 'not-filtered', + id: 'message', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'event.category', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'event.action', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'host.name', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'source.ip', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'destination.ip', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'user.name', + width: 180, + }, + ], + dataProviders: [], + dateRange: { + end: 0, + start: 0, + }, + description: '', + eventIdToNoteIds: {}, + highlightedDropAndProviderId: '', + historyIds: [], + id: 'savedObject-1', + isFavorite: false, + isLive: false, + isLoading: false, + isSaving: false, + itemsPerPage: 25, + itemsPerPageOptions: [10, 25, 50, 100], + kqlMode: 'filter', + kqlQuery: { + filterQuery: null, + filterQueryDraft: null, + }, + noteIds: [], + pinnedEventIds: {}, + pinnedEventsSaveObject: {}, + savedObjectId: 'savedObject-1', + show: true, + sort: { + columnId: '@timestamp', + sortDirection: 'desc', + }, + title: '', + version: '1', + width: 1100, + }, + }); + }); + test('if columns are null, we should get the default columns', () => { + const timeline: TimelineResult = { + savedObjectId: 'savedObject-1', + columns: null, + version: '1', + }; + const newTimeline = addTimelineToStore({ id: 'timeline-1', timeline }); + expect(newTimeline).toEqual({ + 'timeline-1': { + columns: [ + { + columnHeaderType: 'not-filtered', + id: '@timestamp', + width: 240, + }, + { + columnHeaderType: 'not-filtered', + id: 'message', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'event.category', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'event.action', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'host.name', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'source.ip', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'destination.ip', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'user.name', + width: 180, + }, + ], + dataProviders: [], + dateRange: { + end: 0, + start: 0, + }, + description: '', + eventIdToNoteIds: {}, + highlightedDropAndProviderId: '', + historyIds: [], + id: 'savedObject-1', + isFavorite: false, + isLive: false, + isLoading: false, + isSaving: false, + itemsPerPage: 25, + itemsPerPageOptions: [10, 25, 50, 100], + kqlMode: 'filter', + kqlQuery: { + filterQuery: null, + filterQueryDraft: null, + }, + noteIds: [], + pinnedEventIds: {}, + pinnedEventsSaveObject: {}, + savedObjectId: 'savedObject-1', + show: true, + sort: { + columnId: '@timestamp', + sortDirection: 'desc', + }, + title: '', + version: '1', + width: 1100, + }, + }); + }); + test('should merge columns when event.action is deleted without two extra column names of user.name', () => { + const timeline: TimelineResult = { + savedObjectId: 'savedObject-1', + columns: timelineDefaults.columns.filter(column => column.id !== 'event.action'), + version: '1', + }; + const newTimeline = addTimelineToStore({ id: 'timeline-1', timeline }); + expect(newTimeline).toEqual({ + 'timeline-1': { + savedObjectId: 'savedObject-1', + columns: [ + { + columnHeaderType: 'not-filtered', + id: '@timestamp', + width: 240, + }, + { + columnHeaderType: 'not-filtered', + id: 'message', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'event.category', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'host.name', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'source.ip', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'destination.ip', + width: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'user.name', + width: 180, + }, + ], + version: '1', + dataProviders: [], + description: '', + eventIdToNoteIds: {}, + highlightedDropAndProviderId: '', + historyIds: [], + isFavorite: false, + isLive: false, + isLoading: false, + isSaving: false, + itemsPerPage: 25, + itemsPerPageOptions: [10, 25, 50, 100], + kqlMode: 'filter', + kqlQuery: { + filterQuery: null, + filterQueryDraft: null, + }, + title: '', + noteIds: [], + pinnedEventIds: {}, + pinnedEventsSaveObject: {}, + dateRange: { + start: 0, + end: 0, + }, + show: true, + sort: { + columnId: '@timestamp', + sortDirection: 'desc', + }, + width: 1100, + id: 'savedObject-1', + }, + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/store/timeline/helpers.ts b/x-pack/legacy/plugins/siem/public/store/timeline/helpers.ts index 34759730d2563..541c0f24331ca 100644 --- a/x-pack/legacy/plugins/siem/public/store/timeline/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/store/timeline/helpers.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { getOr, omit, uniq, isEmpty, isEqualWith, defaultsDeep, pickBy, isNil } from 'lodash/fp'; +import { getOr, omit, uniq, isEmpty, isEqualWith, set } from 'lodash/fp'; import { ColumnHeader } from '../../components/timeline/body/column_headers/column_header'; import { getColumnWidthFromType } from '../../components/timeline/body/helpers'; @@ -109,6 +109,18 @@ interface AddTimelineParams { timeline: TimelineResult; } +const mergeTimeline = (timeline: TimelineResult): TimelineModel => { + return Object.entries(timeline).reduce( + (acc: TimelineModel, [key, value]) => { + if (value != null) { + acc = set(key, value, acc); + } + return acc; + }, + { ...timelineDefaults, id: '' } + ); +}; + /** * Add a saved object timeline to the store * and default the value to what need to be if values are null @@ -116,7 +128,7 @@ interface AddTimelineParams { export const addTimelineToStore = ({ id, timeline }: AddTimelineParams): TimelineById => ({ // TODO: revisit this when we support multiple timelines [id]: { - ...defaultsDeep(timelineDefaults, pickBy(v => !isNil(v), timeline)), + ...mergeTimeline(timeline), id: timeline.savedObjectId || '', show: true, }, diff --git a/x-pack/legacy/plugins/siem/server/lib/pinned_event/saved_object.ts b/x-pack/legacy/plugins/siem/server/lib/pinned_event/saved_object.ts index 605acfdfbaae5..f6b154683e45c 100644 --- a/x-pack/legacy/plugins/siem/server/lib/pinned_event/saved_object.ts +++ b/x-pack/legacy/plugins/siem/server/lib/pinned_event/saved_object.ts @@ -151,6 +151,9 @@ export class PinnedEvent { await this.deletePinnedEventOnTimeline(request, [pinnedEventId]); return null; } catch (err) { + if (getOr(null, 'output.statusCode', err) === 404) { + return null; + } if (getOr(null, 'output.statusCode', err) === 403) { return pinnedEventId != null ? {