[SIEM] Create template timeline (#63136) (#64750)

* init routes for template timeline

* create template timeline

* add create/update timelines route

* update api entry point

* fix types

* add template type

* fix types

* add types and template timeline id

* fix types

* update import timeline to handle template timeline

* unit test

* sudo code

* remove class for savedobject

* add template timeline version

* clean up arguments

* fix types for framework request

* show filter in find

* fix create template timeline

* update mock data

* handle missing timeline when exporting

* update the order for timeline routes

* update schemas

* move type to common folder so we can re-use them on UI and server side

* fix types + integrate persist with epic timeline

* update all timeline when persit timeline

* add timeline api readme

* fix validation error

* fix unit test

* display error if unexpected format is given

* fix issue with reftech all timeline query

* fix flashing timeline while refetch

* fix types

* fix types

* fix dependency

* fix timeline deletion

* remove redundant dependency

* add i18n message

* fix unit test

Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>

Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Angela Chuang 2020-04-29 14:41:42 +01:00 committed by GitHub
parent 7985bda96f
commit f84e8b577f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
56 changed files with 3054 additions and 1114 deletions

View file

@ -7,14 +7,11 @@
/* eslint-disable @typescript-eslint/no-empty-interface */
import * as runtimeTypes from 'io-ts';
import { SavedObjectsClient } from 'kibana/server';
import { unionWithNullType } from '../framework';
import { NoteSavedObjectToReturnRuntimeType, NoteSavedObject } from '../note/types';
import {
PinnedEventToReturnSavedObjectRuntimeType,
PinnedEventSavedObject,
} from '../pinned_event/types';
import { SavedObjectsClient } from '../../../../../../src/core/server';
import { unionWithNullType } from '../../utility_types';
import { NoteSavedObject, NoteSavedObjectToReturnRuntimeType } from './note';
import { PinnedEventToReturnSavedObjectRuntimeType, PinnedEventSavedObject } from './pinned_event';
/*
* ColumnHeader Types
@ -136,6 +133,17 @@ const SavedSortRuntimeType = runtimeTypes.partial({
/*
* Timeline Types
*/
export enum TimelineType {
default = 'default',
template = 'template',
}
export const TimelineTypeLiteralRt = runtimeTypes.union([
runtimeTypes.literal(TimelineType.template),
runtimeTypes.literal(TimelineType.default),
]);
export const SavedTimelineRuntimeType = runtimeTypes.partial({
columns: unionWithNullType(runtimeTypes.array(SavedColumnHeaderRuntimeType)),
dataProviders: unionWithNullType(runtimeTypes.array(SavedDataProviderRuntimeType)),
@ -146,6 +154,9 @@ export const SavedTimelineRuntimeType = runtimeTypes.partial({
kqlMode: unionWithNullType(runtimeTypes.string),
kqlQuery: unionWithNullType(SavedFilterQueryQueryRuntimeType),
title: unionWithNullType(runtimeTypes.string),
templateTimelineId: unionWithNullType(runtimeTypes.string),
templateTimelineVersion: unionWithNullType(runtimeTypes.number),
timelineType: unionWithNullType(TimelineTypeLiteralRt),
dateRange: unionWithNullType(SavedDateRangePickerRuntimeType),
savedQueryId: unionWithNullType(runtimeTypes.string),
sort: unionWithNullType(SavedSortRuntimeType),
@ -192,6 +203,25 @@ export const TimelineSavedToReturnObjectRuntimeType = runtimeTypes.intersection(
export interface TimelineSavedObject
extends runtimeTypes.TypeOf<typeof TimelineSavedToReturnObjectRuntimeType> {}
/**
* All Timeline Saved object type with metadata
*/
export const TimelineResponseType = runtimeTypes.type({
data: runtimeTypes.type({
persistTimeline: runtimeTypes.intersection([
runtimeTypes.partial({
code: unionWithNullType(runtimeTypes.number),
message: unionWithNullType(runtimeTypes.string),
}),
runtimeTypes.type({
timeline: TimelineSavedToReturnObjectRuntimeType,
}),
]),
}),
});
export interface TimelineResponse extends runtimeTypes.TypeOf<typeof TimelineResponseType> {}
/**
* All Timeline Saved object type with metadata
*/
@ -234,6 +264,11 @@ export type ExportedTimelines = TimelineSavedObject &
pinnedEventIds: string[];
};
export interface ExportTimelineNotFoundError {
statusCode: number;
message: string;
}
export interface BulkGetInput {
type: string;
id: string;

View file

@ -8,7 +8,7 @@
import * as runtimeTypes from 'io-ts';
import { unionWithNullType } from '../framework';
import { unionWithNullType } from '../../../utility_types';
/*
* Note Types
@ -56,11 +56,7 @@ export const NoteSavedObjectToReturnRuntimeType = runtimeTypes.intersection([
version: runtimeTypes.string,
}),
runtimeTypes.partial({
timelineVersion: runtimeTypes.union([
runtimeTypes.string,
runtimeTypes.null,
runtimeTypes.undefined,
]),
timelineVersion: unionWithNullType(runtimeTypes.string),
}),
]);

View file

@ -8,7 +8,7 @@
import * as runtimeTypes from 'io-ts';
import { unionWithNullType } from '../framework';
import { unionWithNullType } from '../../../utility_types';
/*
* Note Types
@ -40,11 +40,7 @@ export const PinnedEventSavedObjectRuntimeType = runtimeTypes.intersection([
}),
runtimeTypes.partial({
pinnedEventId: unionWithNullType(runtimeTypes.string),
timelineVersion: runtimeTypes.union([
runtimeTypes.string,
runtimeTypes.null,
runtimeTypes.undefined,
]),
timelineVersion: unionWithNullType(runtimeTypes.string),
}),
]);
@ -55,11 +51,7 @@ export const PinnedEventToReturnSavedObjectRuntimeType = runtimeTypes.intersecti
}),
SavedPinnedEventRuntimeType,
runtimeTypes.partial({
timelineVersion: runtimeTypes.union([
runtimeTypes.string,
runtimeTypes.null,
runtimeTypes.undefined,
]),
timelineVersion: unionWithNullType(runtimeTypes.string),
}),
]);

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import * as runtimeTypes from 'io-ts';
import { ReactNode } from 'react';
// This type is for typing EuiDescriptionList
@ -11,3 +12,6 @@ export interface DescriptionList {
title: NonNullable<ReactNode>;
description: NonNullable<ReactNode>;
}
export const unionWithNullType = <T extends runtimeTypes.Mixed>(type: T) =>
runtimeTypes.union([type, runtimeTypes.null]);

View file

@ -1,5 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Matrix Histogram Component not initial load it renders no MatrixLoader 1`] = `"<div class=\\"sc-AykKF jbBKkl\\"><div class=\\"euiPanel euiPanel--paddingMedium sc-AykKC sc-AykKH iNPult\\" data-test-subj=\\"mockIdPanel\\" height=\\"300\\"><div class=\\"headerSection\\"></div><div class=\\"barchart\\"></div></div></div>"`;
exports[`Matrix Histogram Component not initial load it renders no MatrixLoader 1`] = `"<div class=\\"sc-AykKI gltyKM\\"><div class=\\"euiPanel euiPanel--paddingMedium sc-AykKC sc-AykKK guzfus\\" data-test-subj=\\"mockIdPanel\\" height=\\"300\\"><div class=\\"headerSection\\"></div><div class=\\"barchart\\"></div></div></div>"`;
exports[`Matrix Histogram Component on initial load it renders MatrixLoader 1`] = `"<div class=\\"sc-AykKF hneqJM\\"><div class=\\"euiPanel euiPanel--paddingMedium sc-AykKC sc-AykKH iNPult\\" data-test-subj=\\"mockIdPanel\\" height=\\"300\\"><div class=\\"headerSection\\"></div><div class=\\"matrixLoader\\"></div></div></div><div class=\\"euiSpacer euiSpacer--l\\" data-test-subj=\\"spacer\\"></div>"`;
exports[`Matrix Histogram Component on initial load it renders MatrixLoader 1`] = `"<div class=\\"sc-AykKI RNnzH\\"><div class=\\"euiPanel euiPanel--paddingMedium sc-AykKC sc-AykKK guzfus\\" data-test-subj=\\"mockIdPanel\\" height=\\"300\\"><div class=\\"headerSection\\"></div><div class=\\"matrixLoader\\"></div></div></div><div class=\\"euiSpacer euiSpacer--l\\" data-test-subj=\\"spacer\\"></div>"`;

View file

@ -9,7 +9,7 @@ import { DeleteTimelines } from '../types';
import { TimelineDownloader } from './export_timeline';
import { DeleteTimelineModalOverlay } from '../delete_timeline_modal';
import { exportSelectedTimeline } from '../../../containers/timeline/all/api';
import { exportSelectedTimeline } from '../../../containers/timeline/api';
export interface ExportTimeline {
disableExportTimelineDownloader: () => void;

View file

@ -15,15 +15,34 @@ import { TestProviderWithoutDragAndDrop, apolloClient } from '../../mock/test_pr
import { mockOpenTimelineQueryResults } from '../../mock/timeline_results';
import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../pages/timelines/timelines_page';
import { StatefulOpenTimeline } from '.';
import { NotePreviews } from './note_previews';
import { OPEN_TIMELINE_CLASS_NAME } from './helpers';
import { StatefulOpenTimeline } from '.';
import { useGetAllTimeline, getAllTimeline } from '../../containers/timeline/all';
jest.mock('../../lib/kibana');
jest.mock('../../containers/timeline/all', () => {
const originalModule = jest.requireActual('../../containers/timeline/all');
return {
useGetAllTimeline: jest.fn(),
getAllTimeline: originalModule.getAllTimeline,
};
});
describe('StatefulOpenTimeline', () => {
const theme = () => ({ eui: euiDarkVars, darkMode: true });
const title = 'All Timelines / Open Timelines';
beforeEach(() => {
((useGetAllTimeline as unknown) as jest.Mock).mockReturnValue({
fetchAllTimeline: jest.fn(),
timelines: getAllTimeline(
'',
mockOpenTimelineQueryResults[0].result.data?.getAllTimeline?.timeline ?? []
),
loading: false,
totalCount: mockOpenTimelineQueryResults[0].result.data.getAllTimeline.totalCount,
refetch: jest.fn(),
});
});
test('it has the expected initial state', () => {
const wrapper = mount(
@ -459,6 +478,8 @@ describe('StatefulOpenTimeline', () => {
.find('[data-test-subj="expand-notes"]')
.first()
.simulate('click');
expect(wrapper.find('[data-test-subj="note-previews-container"]').exists()).toEqual(true);
expect(wrapper.find('[data-test-subj="updated-by"]').exists()).toEqual(true);
expect(
wrapper
@ -532,7 +553,7 @@ describe('StatefulOpenTimeline', () => {
test('it renders the expected count of matching timelines when no query has been entered', async () => {
const wrapper = mount(
<ThemeProvider theme={theme}>
<MockedProvider mocks={mockOpenTimelineQueryResults} addTypename={false}>
<MockedProvider addTypename={false}>
<TestProviderWithoutDragAndDrop>
<StatefulOpenTimeline
data-test-subj="stateful-timeline"

View file

@ -11,8 +11,7 @@ import { connect, ConnectedProps } from 'react-redux';
import { Dispatch } from 'redux';
import { defaultHeaders } from '../../components/timeline/body/column_headers/default_headers';
import { deleteTimelineMutation } from '../../containers/timeline/delete/persist.gql_query';
import { AllTimelinesVariables, AllTimelinesQuery } from '../../containers/timeline/all';
import { allTimelinesQuery } from '../../containers/timeline/all/index.gql_query';
import { useGetAllTimeline } from '../../containers/timeline/all';
import { DeleteTimelineMutation, SortFieldTimeline, Direction } from '../../graphql/types';
import { State, timelineSelectors } from '../../store';
import { ColumnHeaderOptions, TimelineModel } from '../../store/timeline/model';
@ -104,6 +103,8 @@ export const StatefulOpenTimelineComponent = React.memo<OpenTimelineOwnProps>(
/** The requested field to sort on */
const [sortField, setSortField] = useState(DEFAULT_SORT_FIELD);
const { fetchAllTimeline, timelines, loading, totalCount, refetch } = useGetAllTimeline();
/** Invoked when the user presses enters to submit the text in the search input */
const onQueryChange: OnQueryChange = useCallback((query: EuiSearchBarQuery) => {
setSearch(query.queryText.trim());
@ -133,45 +134,41 @@ export const StatefulOpenTimelineComponent = React.memo<OpenTimelineOwnProps>(
// }
// };
const onDeleteOneTimeline: OnDeleteOneTimeline = useCallback(
(timelineIds: string[]) => {
deleteTimelines(timelineIds, {
search,
pageInfo: {
pageIndex: pageIndex + 1,
pageSize,
},
sort: {
sortField: sortField as SortFieldTimeline,
sortOrder: sortDirection as Direction,
},
onlyUserFavorite: onlyFavorites,
const deleteTimelines: DeleteTimelines = useCallback(
async (timelineIds: string[]) => {
if (timelineIds.includes(timeline.savedObjectId || '')) {
createNewTimeline({ id: 'timeline-1', columns: defaultHeaders, show: false });
}
await apolloClient.mutate<
DeleteTimelineMutation.Mutation,
DeleteTimelineMutation.Variables
>({
mutation: deleteTimelineMutation,
fetchPolicy: 'no-cache',
variables: { id: timelineIds },
});
refetch();
},
[search, pageIndex, pageSize, sortField, sortDirection, onlyFavorites]
[apolloClient, createNewTimeline, refetch, timeline]
);
const onDeleteOneTimeline: OnDeleteOneTimeline = useCallback(
async (timelineIds: string[]) => {
await deleteTimelines(timelineIds);
},
[deleteTimelines]
);
/** Invoked when the user clicks the action to delete the selected timelines */
const onDeleteSelected: OnDeleteSelected = useCallback(() => {
deleteTimelines(getSelectedTimelineIds(selectedItems), {
search,
pageInfo: {
pageIndex: pageIndex + 1,
pageSize,
},
sort: {
sortField: sortField as SortFieldTimeline,
sortOrder: sortDirection as Direction,
},
onlyUserFavorite: onlyFavorites,
});
const onDeleteSelected: OnDeleteSelected = useCallback(async () => {
await deleteTimelines(getSelectedTimelineIds(selectedItems));
// NOTE: we clear the selection state below, but if the server fails to
// delete a timeline, it will remain selected in the table:
resetSelectionState();
// TODO: the query must re-execute to show the results of the deletion
}, [selectedItems, search, pageIndex, pageSize, sortField, sortDirection, onlyFavorites]);
}, [selectedItems, deleteTimelines]);
/** Invoked when the user selects (or de-selects) timelines */
const onSelectionChange: OnSelectionChange = useCallback(
@ -227,99 +224,88 @@ export const StatefulOpenTimelineComponent = React.memo<OpenTimelineOwnProps>(
[apolloClient, updateIsLoading, updateTimeline]
);
const deleteTimelines: DeleteTimelines = useCallback(
(timelineIds: string[], variables?: AllTimelinesVariables) => {
if (timelineIds.includes(timeline.savedObjectId || '')) {
createNewTimeline({ id: 'timeline-1', columns: defaultHeaders, show: false });
}
apolloClient.mutate<DeleteTimelineMutation.Mutation, DeleteTimelineMutation.Variables>({
mutation: deleteTimelineMutation,
fetchPolicy: 'no-cache',
variables: { id: timelineIds },
refetchQueries: [
{
query: allTimelinesQuery,
variables,
},
],
});
},
[apolloClient, createNewTimeline, timeline]
);
useEffect(() => {
focusInput();
}, []);
return (
<AllTimelinesQuery
pageInfo={{
useEffect(() => {
fetchAllTimeline({
pageInfo: {
pageIndex: pageIndex + 1,
pageSize,
}}
search={search}
sort={{ sortField: sortField as SortFieldTimeline, sortOrder: sortDirection as Direction }}
onlyUserFavorite={onlyFavorites}
>
{({ timelines, loading, totalCount, refetch }) => {
return !isModal ? (
<OpenTimeline
data-test-subj={'open-timeline'}
deleteTimelines={onDeleteOneTimeline}
defaultPageSize={defaultPageSize}
isLoading={loading}
itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap}
importDataModalToggle={importDataModalToggle}
onAddTimelinesToFavorites={undefined}
onDeleteSelected={onDeleteSelected}
onlyFavorites={onlyFavorites}
onOpenTimeline={openTimeline}
onQueryChange={onQueryChange}
onSelectionChange={onSelectionChange}
onTableChange={onTableChange}
onToggleOnlyFavorites={onToggleOnlyFavorites}
onToggleShowNotes={onToggleShowNotes}
pageIndex={pageIndex}
pageSize={pageSize}
query={search}
refetch={refetch}
searchResults={timelines}
setImportDataModalToggle={setImportDataModalToggle}
selectedItems={selectedItems}
sortDirection={sortDirection}
sortField={sortField}
title={title}
totalSearchResultsCount={totalCount}
/>
) : (
<OpenTimelineModalBody
data-test-subj={'open-timeline-modal'}
deleteTimelines={onDeleteOneTimeline}
defaultPageSize={defaultPageSize}
hideActions={hideActions}
isLoading={loading}
itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap}
onAddTimelinesToFavorites={undefined}
onlyFavorites={onlyFavorites}
onOpenTimeline={openTimeline}
onQueryChange={onQueryChange}
onSelectionChange={onSelectionChange}
onTableChange={onTableChange}
onToggleOnlyFavorites={onToggleOnlyFavorites}
onToggleShowNotes={onToggleShowNotes}
pageIndex={pageIndex}
pageSize={pageSize}
query={search}
searchResults={timelines}
selectedItems={selectedItems}
sortDirection={sortDirection}
sortField={sortField}
title={title}
totalSearchResultsCount={totalCount}
/>
);
}}
</AllTimelinesQuery>
},
search,
sort: { sortField: sortField as SortFieldTimeline, sortOrder: sortDirection as Direction },
onlyUserFavorite: onlyFavorites,
timelines,
totalCount,
});
}, [
pageIndex,
pageSize,
search,
sortField,
sortDirection,
timelines,
totalCount,
onlyFavorites,
]);
return !isModal ? (
<OpenTimeline
data-test-subj={'open-timeline'}
deleteTimelines={onDeleteOneTimeline}
defaultPageSize={defaultPageSize}
isLoading={loading}
itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap}
importDataModalToggle={importDataModalToggle}
onAddTimelinesToFavorites={undefined}
onDeleteSelected={onDeleteSelected}
onlyFavorites={onlyFavorites}
onOpenTimeline={openTimeline}
onQueryChange={onQueryChange}
onSelectionChange={onSelectionChange}
onTableChange={onTableChange}
onToggleOnlyFavorites={onToggleOnlyFavorites}
onToggleShowNotes={onToggleShowNotes}
pageIndex={pageIndex}
pageSize={pageSize}
query={search}
refetch={refetch}
searchResults={timelines}
setImportDataModalToggle={setImportDataModalToggle}
selectedItems={selectedItems}
sortDirection={sortDirection}
sortField={sortField}
title={title}
totalSearchResultsCount={totalCount}
/>
) : (
<OpenTimelineModalBody
data-test-subj={'open-timeline-modal'}
deleteTimelines={onDeleteOneTimeline}
defaultPageSize={defaultPageSize}
hideActions={hideActions}
isLoading={loading}
itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap}
onAddTimelinesToFavorites={undefined}
onlyFavorites={onlyFavorites}
onOpenTimeline={openTimeline}
onQueryChange={onQueryChange}
onSelectionChange={onSelectionChange}
onTableChange={onTableChange}
onToggleOnlyFavorites={onToggleOnlyFavorites}
onToggleShowNotes={onToggleShowNotes}
pageIndex={pageIndex}
pageSize={pageSize}
query={search}
searchResults={timelines}
selectedItems={selectedItems}
sortDirection={sortDirection}
sortField={sortField}
title={title}
totalSearchResultsCount={totalCount}
/>
);
}
);
@ -328,7 +314,6 @@ const makeMapStateToProps = () => {
const getTimeline = timelineSelectors.getTimelineByIdSelector();
const mapStateToProps = (state: State) => {
const timeline = getTimeline(state, 'timeline-1') ?? timelineDefaults;
return {
timeline,
};

View file

@ -14,7 +14,7 @@ import { TimelinesTable } from './timelines_table';
import { TitleRow } from './title_row';
import { ImportDataModal } from '../import_data_modal';
import * as i18n from './translations';
import { importTimelines } from '../../containers/timeline/all/api';
import { importTimelines } from '../../containers/timeline/api';
import {
UtilityBarGroup,

View file

@ -13,6 +13,7 @@ import { ThemeProvider } from 'styled-components';
import { wait } from '../../../lib/helpers';
import { TestProviderWithoutDragAndDrop } from '../../../mock/test_providers';
import { mockOpenTimelineQueryResults } from '../../../mock/timeline_results';
import { useGetAllTimeline, getAllTimeline } from '../../../containers/timeline/all';
import { OpenTimelineModal } from '.';
@ -20,9 +21,28 @@ jest.mock('../../../lib/kibana');
jest.mock('../../../utils/apollo_context', () => ({
useApolloClient: () => ({}),
}));
jest.mock('../../../containers/timeline/all', () => {
const originalModule = jest.requireActual('../../../containers/timeline/all');
return {
useGetAllTimeline: jest.fn(),
getAllTimeline: originalModule.getAllTimeline,
};
});
describe('OpenTimelineModal', () => {
const theme = () => ({ eui: euiDarkVars, darkMode: true });
beforeEach(() => {
((useGetAllTimeline as unknown) as jest.Mock).mockReturnValue({
fetchAllTimeline: jest.fn(),
timelines: getAllTimeline(
'',
mockOpenTimelineQueryResults[0].result.data?.getAllTimeline?.timeline ?? []
),
loading: false,
totalCount: mockOpenTimelineQueryResults[0].result.data.getAllTimeline.totalCount,
refetch: jest.fn(),
});
});
test('it renders the expected modal', async () => {
const wrapper = mount(

View file

@ -9,6 +9,7 @@ import { AllTimelinesVariables } from '../../containers/timeline/all';
import { TimelineModel } from '../../store/timeline/model';
import { NoteResult } from '../../graphql/types';
import { Refetch } from '../../store/inputs/model';
import { TimelineType } from '../../../common/types/timeline';
/** The users who added a timeline to favorites */
export interface FavoriteTimelineResult {
@ -47,6 +48,8 @@ export interface OpenTimelineResult {
pinnedEventIds?: Readonly<Record<string, boolean>> | null;
savedObjectId?: string | null;
title?: string | null;
templateTimelineId?: string | null;
type?: TimelineType.template | TimelineType.default;
updated?: number | null;
updatedBy?: string | null;
}

View file

@ -6,11 +6,11 @@
import ApolloClient from 'apollo-client';
import { EuiHorizontalRule, EuiLink, EuiText } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import React, { useCallback, useMemo, useEffect } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { Dispatch } from 'redux';
import { AllTimelinesQuery } from '../../containers/timeline/all';
import { useGetAllTimeline } from '../../containers/timeline/all';
import { SortFieldTimeline, Direction } from '../../graphql/types';
import { queryTimelineById, dispatchUpdateTimeline } from '../open_timeline/helpers';
import { OnOpenTimeline } from '../open_timeline/types';
@ -62,35 +62,39 @@ const StatefulRecentTimelinesComponent = React.memo<Props>(
[filterBy]
);
return (
<AllTimelinesQuery
pageInfo={{
const { fetchAllTimeline, timelines, totalCount, loading } = useGetAllTimeline();
useEffect(() => {
fetchAllTimeline({
pageInfo: {
pageIndex: 1,
pageSize: PAGE_SIZE,
}}
search={''}
sort={{
},
search: '',
sort: {
sortField: SortFieldTimeline.updated,
sortOrder: Direction.desc,
}}
onlyUserFavorite={filterBy === 'favorites'}
>
{({ timelines, loading }) => (
<>
{loading ? (
loadingPlaceholders
) : (
<RecentTimelines
noTimelinesMessage={noTimelinesMessage}
onOpenTimeline={onOpenTimeline}
timelines={timelines}
/>
)}
<EuiHorizontalRule margin="s" />
<EuiText size="xs">{linkAllTimelines}</EuiText>
</>
},
onlyUserFavorite: filterBy === 'favorites',
timelines,
totalCount,
});
}, [filterBy, timelines, totalCount]);
return (
<>
{loading ? (
loadingPlaceholders
) : (
<RecentTimelines
noTimelinesMessage={noTimelinesMessage}
onOpenTimeline={onOpenTimeline}
timelines={timelines}
/>
)}
</AllTimelinesQuery>
<EuiHorizontalRule margin="s" />
<EuiText size="xs">{linkAllTimelines}</EuiText>
</>
);
}
);

View file

@ -17,11 +17,11 @@ import {
EuiFilterButton,
} from '@elastic/eui';
import { isEmpty } from 'lodash/fp';
import React, { memo, useCallback, useMemo, useState } from 'react';
import React, { memo, useCallback, useMemo, useState, useEffect } from 'react';
import { ListProps } from 'react-virtualized';
import styled from 'styled-components';
import { AllTimelinesQuery } from '../../../containers/timeline/all';
import { useGetAllTimeline } from '../../../containers/timeline/all';
import { SortFieldTimeline, Direction } from '../../../graphql/types';
import { isUntitled } from '../../open_timeline/helpers';
import * as i18nTimeline from '../../open_timeline/translations';
@ -96,6 +96,7 @@ const SelectableTimelineComponent: React.FC<SelectableTimelineProps> = ({
const [searchTimelineValue, setSearchTimelineValue] = useState('');
const [onlyFavorites, setOnlyFavorites] = useState(false);
const [searchRef, setSearchRef] = useState<HTMLElement | null>(null);
const { fetchAllTimeline, timelines, loading, totalCount: timelineCount } = useGetAllTimeline();
const onSearchTimeline = useCallback(val => {
setSearchTimelineValue(val);
@ -215,61 +216,64 @@ const SelectableTimelineComponent: React.FC<SelectableTimelineProps> = ({
[searchRef, onlyFavorites, handleOnToggleOnlyFavorites]
);
useEffect(() => {
fetchAllTimeline({
pageInfo: {
pageIndex: 1,
pageSize,
},
search: searchTimelineValue,
sort: {
sortField: SortFieldTimeline.updated,
sortOrder: Direction.desc,
},
onlyUserFavorite: onlyFavorites,
timelines,
totalCount: timelineCount,
});
}, [onlyFavorites, pageSize, searchTimelineValue, timelines, timelineCount]);
return (
<>
<AllTimelinesQuery
pageInfo={{
pageIndex: 1,
pageSize,
<EuiSelectableContainer isLoading={loading}>
<EuiSelectable
height={POPOVER_HEIGHT}
isLoading={loading && timelines.length === 0}
listProps={{
rowHeight: TIMELINE_ITEM_HEIGHT,
showIcons: false,
virtualizedProps: ({
onScroll: handleOnScroll.bind(
null,
timelines.filter(t => !hideUntitled || t.title !== '').length,
timelineCount
),
} as unknown) as ListProps,
}}
search={searchTimelineValue}
sort={{ sortField: SortFieldTimeline.updated, sortOrder: Direction.desc }}
onlyUserFavorite={onlyFavorites}
renderOption={renderTimelineOption}
onChange={handleTimelineChange}
searchable
searchProps={{
'data-test-subj': 'timeline-super-select-search-box',
isLoading: loading,
placeholder: i18n.SEARCH_BOX_TIMELINE_PLACEHOLDER,
onSearch: onSearchTimeline,
incremental: false,
inputRef: (ref: HTMLElement) => {
setSearchRef(ref);
},
}}
singleSelection={true}
options={getSelectableOptions({ timelines, onlyFavorites, searchTimelineValue })}
>
{({ timelines, loading, totalCount }) => (
<EuiSelectableContainer isLoading={loading}>
<EuiSelectable
height={POPOVER_HEIGHT}
isLoading={loading && timelines.length === 0}
listProps={{
rowHeight: TIMELINE_ITEM_HEIGHT,
showIcons: false,
virtualizedProps: ({
onScroll: handleOnScroll.bind(
null,
timelines.filter(t => !hideUntitled || t.title !== '').length,
totalCount
),
} as unknown) as ListProps,
}}
renderOption={renderTimelineOption}
onChange={handleTimelineChange}
searchable
searchProps={{
'data-test-subj': 'timeline-super-select-search-box',
isLoading: loading,
placeholder: i18n.SEARCH_BOX_TIMELINE_PLACEHOLDER,
onSearch: onSearchTimeline,
incremental: false,
inputRef: (ref: HTMLElement) => {
setSearchRef(ref);
},
}}
singleSelection={true}
options={getSelectableOptions({ timelines, onlyFavorites, searchTimelineValue })}
>
{(list, search) => (
<>
{search}
{favoritePortal}
{list}
</>
)}
</EuiSelectable>
</EuiSelectableContainer>
{(list, search) => (
<>
{search}
{favoritePortal}
{list}
</>
)}
</AllTimelinesQuery>
</>
</EuiSelectable>
</EuiSelectableContainer>
);
};

View file

@ -1,48 +0,0 @@
/*
* 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 { TIMELINE_IMPORT_URL, TIMELINE_EXPORT_URL } from '../../../../common/constants';
import { ImportDataProps, ImportDataResponse } from '../../detection_engine/rules';
import { KibanaServices } from '../../../lib/kibana';
import { ExportSelectedData } from '../../../components/generic_downloader';
export const importTimelines = async ({
fileToImport,
overwrite = false,
signal,
}: ImportDataProps): Promise<ImportDataResponse> => {
const formData = new FormData();
formData.append('file', fileToImport);
return KibanaServices.get().http.fetch<ImportDataResponse>(`${TIMELINE_IMPORT_URL}`, {
method: 'POST',
headers: { 'Content-Type': undefined },
query: { overwrite },
body: formData,
signal,
});
};
export const exportSelectedTimeline: ExportSelectedData = async ({
excludeExportDetails = false,
filename = `timelines_export.ndjson`,
ids = [],
signal,
}): Promise<Blob> => {
const body = ids.length > 0 ? JSON.stringify({ ids }) : undefined;
const response = await KibanaServices.get().http.fetch<Blob>(`${TIMELINE_EXPORT_URL}`, {
method: 'POST',
body,
query: {
exclude_export_details: excludeExportDetails,
file_name: filename,
},
signal,
asResponse: true,
});
return response.body!;
};

View file

@ -55,6 +55,9 @@ export const allTimelinesQuery = gql`
noteIds
pinnedEventIds
title
timelineType
templateTimelineId
templateTimelineVersion
created
createdBy
updated

View file

@ -3,23 +3,28 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useCallback } from 'react';
import { getOr } from 'lodash/fp';
import { getOr, noop } from 'lodash/fp';
import memoizeOne from 'memoize-one';
import { useCallback, useState, useRef, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { Query } from 'react-apollo';
import { ApolloQueryResult } from 'apollo-client';
import { OpenTimelineResult } from '../../../components/open_timeline/types';
import { errorToToaster, useStateToaster } from '../../../components/toasters';
import {
GetAllTimeline,
PageInfoTimeline,
SortTimeline,
TimelineResult,
} from '../../../graphql/types';
import { inputsModel, inputsActions } from '../../../store/inputs';
import { useApolloClient } from '../../../utils/apollo_context';
import { allTimelinesQuery } from './index.gql_query';
import * as i18n from '../../../pages/timelines/translations';
export interface AllTimelinesArgs {
fetchAllTimeline: ({ onlyUserFavorite, pageInfo, search, sort }: AllTimelinesVariables) => void;
timelines: OpenTimelineResult[];
loading: boolean;
totalCount: number;
@ -31,17 +36,13 @@ export interface AllTimelinesVariables {
pageInfo: PageInfoTimeline;
search: string;
sort: SortTimeline;
timelines: OpenTimelineResult[];
totalCount: number;
}
interface OwnProps extends AllTimelinesVariables {
children?: (args: AllTimelinesArgs) => React.ReactNode;
}
export const ALL_TIMELINE_QUERY_ID = 'FETCH_ALL_TIMELINES';
type Refetch = (
variables: GetAllTimeline.Variables | undefined
) => Promise<ApolloQueryResult<GetAllTimeline.Query>>;
const getAllTimeline = memoizeOne(
export const getAllTimeline = memoizeOne(
(variables: string, timelines: TimelineResult[]): OpenTimelineResult[] =>
timelines.map(timeline => ({
created: timeline.created,
@ -76,41 +77,117 @@ const getAllTimeline = memoizeOne(
}))
);
const AllTimelinesQueryComponent: React.FC<OwnProps> = ({
children,
onlyUserFavorite,
pageInfo,
search,
sort,
}) => {
const variables: GetAllTimeline.Variables = {
onlyUserFavorite,
pageInfo,
search,
sort,
};
const handleRefetch = useCallback((refetch: Refetch) => refetch(variables), [variables]);
export const useGetAllTimeline = (): AllTimelinesArgs => {
const dispatch = useDispatch();
const apolloClient = useApolloClient();
const refetch = useRef<inputsModel.Refetch>();
const [, dispatchToaster] = useStateToaster();
const [allTimelines, setAllTimelines] = useState<AllTimelinesArgs>({
fetchAllTimeline: noop,
loading: false,
refetch: refetch.current ?? noop,
totalCount: 0,
timelines: [],
});
return (
<Query<GetAllTimeline.Query, GetAllTimeline.Variables>
query={allTimelinesQuery}
fetchPolicy="network-only"
notifyOnNetworkStatusChange
variables={variables}
>
{({ data, loading, refetch }) =>
children!({
loading,
refetch: handleRefetch.bind(null, refetch),
totalCount: getOr(0, 'getAllTimeline.totalCount', data),
timelines: getAllTimeline(
JSON.stringify(variables),
getOr([], 'getAllTimeline.timeline', data)
),
})
}
</Query>
const fetchAllTimeline = useCallback(
async ({
onlyUserFavorite,
pageInfo,
search,
sort,
timelines,
totalCount,
}: AllTimelinesVariables) => {
let didCancel = false;
const abortCtrl = new AbortController();
const fetchData = async () => {
try {
if (apolloClient != null) {
setAllTimelines({
...allTimelines,
timelines: timelines ?? allTimelines.timelines,
totalCount: totalCount ?? allTimelines.totalCount,
loading: true,
});
const variables: GetAllTimeline.Variables = {
onlyUserFavorite,
pageInfo,
search,
sort,
};
const response = await apolloClient.query<
GetAllTimeline.Query,
GetAllTimeline.Variables
>({
query: allTimelinesQuery,
fetchPolicy: 'network-only',
variables,
context: {
fetchOptions: {
abortSignal: abortCtrl.signal,
},
},
});
if (!didCancel) {
dispatch(
inputsActions.setQuery({
inputId: 'global',
id: ALL_TIMELINE_QUERY_ID,
loading: false,
refetch: refetch.current ?? noop,
inspect: null,
})
);
setAllTimelines({
fetchAllTimeline,
loading: false,
refetch: refetch.current ?? noop,
totalCount: getOr(0, 'getAllTimeline.totalCount', response.data),
timelines: getAllTimeline(
JSON.stringify(variables),
getOr([], 'getAllTimeline.timeline', response.data)
),
});
}
}
} catch (error) {
if (!didCancel) {
errorToToaster({
title: i18n.ERROR_FETCHING_TIMELINES_TITLE,
error: error.body && error.body.message ? new Error(error.body.message) : error,
dispatchToaster,
});
setAllTimelines({
fetchAllTimeline,
loading: false,
refetch: noop,
totalCount: 0,
timelines: [],
});
}
}
};
refetch.current = fetchData;
fetchData();
return () => {
didCancel = true;
abortCtrl.abort();
};
},
[apolloClient, allTimelines]
);
};
export const AllTimelinesQuery = React.memo(AllTimelinesQueryComponent);
useEffect(() => {
return () => {
dispatch(inputsActions.deleteOneQuery({ inputId: 'global', id: ALL_TIMELINE_QUERY_ID }));
};
}, [dispatch]);
return {
...allTimelines,
fetchAllTimeline,
refetch: refetch.current ?? noop,
};
};

View file

@ -0,0 +1,115 @@
/*
* 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 { fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';
import { pipe } from 'fp-ts/lib/pipeable';
import { throwErrors } from '../../../../case/common/api';
import {
SavedTimeline,
TimelineResponse,
TimelineResponseType,
} from '../../../common/types/timeline';
import { TIMELINE_URL, TIMELINE_IMPORT_URL, TIMELINE_EXPORT_URL } from '../../../common/constants';
import { KibanaServices } from '../../lib/kibana';
import { ExportSelectedData } from '../../components/generic_downloader';
import { createToasterPlainError } from '../case/utils';
import { ImportDataProps, ImportDataResponse } from '../detection_engine/rules';
interface RequestPostTimeline {
timeline: SavedTimeline;
signal?: AbortSignal;
}
interface RequestPatchTimeline<T = string> extends RequestPostTimeline {
timelineId: T;
version: T;
}
type RequestPersistTimeline = RequestPostTimeline & Partial<RequestPatchTimeline<null | string>>;
const decodeTimelineResponse = (respTimeline?: TimelineResponse) =>
pipe(
TimelineResponseType.decode(respTimeline),
fold(throwErrors(createToasterPlainError), identity)
);
const postTimeline = async ({ timeline }: RequestPostTimeline): Promise<TimelineResponse> => {
const response = await KibanaServices.get().http.post<TimelineResponse>(TIMELINE_URL, {
method: 'POST',
body: JSON.stringify({ timeline }),
});
return decodeTimelineResponse(response);
};
const patchTimeline = async ({
timelineId,
timeline,
version,
}: RequestPatchTimeline): Promise<TimelineResponse> => {
const response = await KibanaServices.get().http.patch<TimelineResponse>(TIMELINE_URL, {
method: 'PATCH',
body: JSON.stringify({ timeline, timelineId, version }),
});
return decodeTimelineResponse(response);
};
export const persistTimeline = async ({
timelineId,
timeline,
version,
}: RequestPersistTimeline): Promise<TimelineResponse> => {
if (timelineId == null) {
return postTimeline({ timeline });
}
return patchTimeline({
timelineId,
timeline,
version: version ?? '',
});
};
export const importTimelines = async ({
fileToImport,
overwrite = false,
signal,
}: ImportDataProps): Promise<ImportDataResponse> => {
const formData = new FormData();
formData.append('file', fileToImport);
return KibanaServices.get().http.fetch<ImportDataResponse>(`${TIMELINE_IMPORT_URL}`, {
method: 'POST',
headers: { 'Content-Type': undefined },
query: { overwrite },
body: formData,
signal,
});
};
export const exportSelectedTimeline: ExportSelectedData = async ({
excludeExportDetails = false,
filename = `timelines_export.ndjson`,
ids = [],
signal,
}): Promise<Blob> => {
const body = ids.length > 0 ? JSON.stringify({ ids }) : undefined;
const response = await KibanaServices.get().http.fetch<Blob>(`${TIMELINE_EXPORT_URL}`, {
method: 'POST',
body,
query: {
exclude_export_details: excludeExportDetails,
file_name: filename,
},
signal,
asResponse: true,
});
return response.body!;
};

View file

@ -9728,6 +9728,30 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "templateTimelineId",
"description": "",
"args": [],
"type": { "kind": "SCALAR", "name": "String", "ofType": null },
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "templateTimelineVersion",
"description": "",
"args": [],
"type": { "kind": "SCALAR", "name": "Int", "ofType": null },
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "timelineType",
"description": "",
"args": [],
"type": { "kind": "ENUM", "name": "TimelineType", "ofType": null },
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "updated",
"description": "",
@ -10323,6 +10347,39 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "SCALAR",
"name": "Int",
"description": "The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1. ",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "TimelineType",
"description": "",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "default",
"description": "",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "template",
"description": "",
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "PageInfoTimeline",
@ -10863,6 +10920,24 @@
"type": { "kind": "SCALAR", "name": "String", "ofType": null },
"defaultValue": null
},
{
"name": "templateTimelineId",
"description": "",
"type": { "kind": "SCALAR", "name": "String", "ofType": null },
"defaultValue": null
},
{
"name": "templateTimelineVersion",
"description": "",
"type": { "kind": "SCALAR", "name": "Int", "ofType": null },
"defaultValue": null
},
{
"name": "timelineType",
"description": "",
"type": { "kind": "ENUM", "name": "TimelineType", "ofType": null },
"defaultValue": null
},
{
"name": "dateRange",
"description": "",

View file

@ -132,6 +132,12 @@ export interface TimelineInput {
title?: Maybe<string>;
templateTimelineId?: Maybe<string>;
templateTimelineVersion?: Maybe<number>;
timelineType?: Maybe<TimelineType>;
dateRange?: Maybe<DateRangePickerInput>;
savedQueryId?: Maybe<string>;
@ -334,6 +340,11 @@ export enum TlsFields {
_id = '_id',
}
export enum TimelineType {
default = 'default',
template = 'template',
}
export enum SortFieldTimeline {
title = 'title',
description = 'description',
@ -1944,6 +1955,12 @@ export interface TimelineResult {
title?: Maybe<string>;
templateTimelineId?: Maybe<string>;
templateTimelineVersion?: Maybe<number>;
timelineType?: Maybe<TimelineType>;
updated?: Maybe<number>;
updatedBy?: Maybe<string>;
@ -4030,6 +4047,12 @@ export namespace GetAllTimeline {
title: Maybe<string>;
timelineType: Maybe<TimelineType>;
templateTimelineId: Maybe<string>;
templateTimelineVersion: Maybe<number>;
created: Maybe<number>;
createdBy: Maybe<string>;

View file

@ -168,6 +168,9 @@ export const mockOpenTimelineQueryResults: MockedProvidedQuery[] = [
'ZF0W12oB9v5HJNSHwY6L',
],
title: 'test 1',
timelineType: null,
templateTimelineId: null,
templateTimelineVersion: null,
created: 1558386787614,
createdBy: 'elastic',
updated: 1558390951234,
@ -294,6 +297,9 @@ export const mockOpenTimelineQueryResults: MockedProvidedQuery[] = [
'ZF0W12oB9v5HJNSHwY6L',
],
title: 'test 2',
timelineType: null,
templateTimelineId: null,
templateTimelineVersion: null,
created: 1558386787614,
createdBy: 'elastic',
updated: 1558390951234,
@ -420,6 +426,9 @@ export const mockOpenTimelineQueryResults: MockedProvidedQuery[] = [
'ZF0W12oB9v5HJNSHwY6L',
],
title: 'test 2',
timelineType: null,
templateTimelineId: null,
templateTimelineVersion: null,
created: 1558386787614,
createdBy: 'elastic',
updated: 1558390951234,
@ -546,6 +555,9 @@ export const mockOpenTimelineQueryResults: MockedProvidedQuery[] = [
'ZF0W12oB9v5HJNSHwY6L',
],
title: 'test 3',
timelineType: null,
templateTimelineId: null,
templateTimelineVersion: null,
created: 1558386787614,
createdBy: 'elastic',
updated: 1558390951234,
@ -672,6 +684,9 @@ export const mockOpenTimelineQueryResults: MockedProvidedQuery[] = [
'ZF0W12oB9v5HJNSHwY6L',
],
title: 'test 4',
timelineType: null,
templateTimelineId: null,
templateTimelineVersion: null,
created: 1558386787614,
createdBy: 'elastic',
updated: 1558390951234,
@ -798,6 +813,9 @@ export const mockOpenTimelineQueryResults: MockedProvidedQuery[] = [
'ZF0W12oB9v5HJNSHwY6L',
],
title: 'test 5',
timelineType: null,
templateTimelineId: null,
templateTimelineVersion: null,
created: 1558386787614,
createdBy: 'elastic',
updated: 1558390951234,
@ -924,6 +942,9 @@ export const mockOpenTimelineQueryResults: MockedProvidedQuery[] = [
'ZF0W12oB9v5HJNSHwY6L',
],
title: 'test 6',
timelineType: null,
templateTimelineId: null,
templateTimelineVersion: null,
created: 1558386787614,
createdBy: 'elastic',
updated: 1558390951234,
@ -1050,6 +1071,9 @@ export const mockOpenTimelineQueryResults: MockedProvidedQuery[] = [
'ZF0W12oB9v5HJNSHwY6L',
],
title: 'test 7',
timelineType: null,
templateTimelineId: null,
templateTimelineVersion: null,
created: 1558386787614,
createdBy: 'elastic',
updated: 1558390951234,
@ -1176,6 +1200,9 @@ export const mockOpenTimelineQueryResults: MockedProvidedQuery[] = [
'ZF0W12oB9v5HJNSHwY6L',
],
title: 'test 7',
timelineType: null,
templateTimelineId: null,
templateTimelineVersion: null,
created: 1558386787614,
createdBy: 'elastic',
updated: 1558390951234,
@ -1302,6 +1329,9 @@ export const mockOpenTimelineQueryResults: MockedProvidedQuery[] = [
'ZF0W12oB9v5HJNSHwY6L',
],
title: 'test 7',
timelineType: null,
templateTimelineId: null,
templateTimelineVersion: null,
created: 1558386787614,
createdBy: 'elastic',
updated: 1558390951234,
@ -1428,6 +1458,9 @@ export const mockOpenTimelineQueryResults: MockedProvidedQuery[] = [
'ZF0W12oB9v5HJNSHwY6L',
],
title: 'test 7',
timelineType: null,
templateTimelineId: null,
templateTimelineVersion: null,
created: 1558386787614,
createdBy: 'elastic',
updated: 1558390951234,
@ -1554,6 +1587,9 @@ export const mockOpenTimelineQueryResults: MockedProvidedQuery[] = [
'ZF0W12oB9v5HJNSHwY6L',
],
title: 'test 7',
timelineType: null,
templateTimelineId: null,
templateTimelineVersion: null,
created: 1558386787614,
createdBy: 'elastic',
updated: 1558390951234,
@ -1680,6 +1716,9 @@ export const mockOpenTimelineQueryResults: MockedProvidedQuery[] = [
'ZF0W12oB9v5HJNSHwY6L',
],
title: 'test 7',
timelineType: null,
templateTimelineId: null,
templateTimelineVersion: null,
created: 1558386787614,
createdBy: 'elastic',
updated: 1558390951234,

View file

@ -23,3 +23,10 @@ export const ALL_TIMELINES_IMPORT_TIMELINE_TITLE = i18n.translate(
defaultMessage: 'Import Timeline',
}
);
export const ERROR_FETCHING_TIMELINES_TITLE = i18n.translate(
'xpack.siem.timelines.allTimelines.errorFetchingTimelinesTitle',
{
defaultMessage: 'Failed to query all timelines data',
}
);

View file

@ -32,6 +32,7 @@ export const createStore = (
const middlewareDependencies = {
apolloClient$: apolloClient,
selectAllTimelineQuery: inputsSelectors.globalQueryByIdSelector,
selectNotesByIdSelector: appSelectors.selectNotesByIdSelector,
timelineByIdSelector: timelineSelectors.timelineByIdSelector,
timelineTimeRangeSelector: inputsSelectors.timelineTimeRangeSelector,

View file

@ -29,17 +29,11 @@ import {
} from 'rxjs/operators';
import { esFilters, Filter, MatchAllFilter } from '../../../../../../src/plugins/data/public';
import { persistTimelineMutation } from '../../containers/timeline/persist.gql_query';
import {
PersistTimelineMutation,
TimelineInput,
ResponseTimeline,
TimelineResult,
} from '../../graphql/types';
import { TimelineInput, ResponseTimeline, TimelineResult } from '../../graphql/types';
import { AppApolloClient } from '../../lib/lib';
import { addError } from '../app/actions';
import { NotesById } from '../app/model';
import { TimeRange } from '../inputs/model';
import { inputsModel } from '../inputs';
import {
applyKqlFilterQuery,
@ -75,13 +69,15 @@ import { epicPersistPinnedEvent, timelinePinnedEventActionsType } from './epic_p
import { epicPersistTimelineFavorite, timelineFavoriteActionsType } from './epic_favorite';
import { isNotNull } from './helpers';
import { dispatcherTimelinePersistQueue } from './epic_dispatcher_timeline_persistence_queue';
import { refetchQueries } from './refetch_queries';
import { myEpicTimelineId } from './my_epic_timeline_id';
import { ActionTimeline, TimelineById } from './types';
import { persistTimeline } from '../../containers/timeline/api';
import { ALL_TIMELINE_QUERY_ID } from '../../containers/timeline/all';
interface TimelineEpicDependencies<State> {
timelineByIdSelector: (state: State) => TimelineById;
timelineTimeRangeSelector: (state: State) => TimeRange;
timelineTimeRangeSelector: (state: State) => inputsModel.TimeRange;
selectAllTimelineQuery: () => (state: State, id: string) => inputsModel.GlobalQuery;
selectNotesByIdSelector: (state: State) => NotesById;
apolloClient$: Observable<AppApolloClient>;
}
@ -119,10 +115,24 @@ export const createTimelineEpic = <State>(): Epic<
> => (
action$,
state$,
{ selectNotesByIdSelector, timelineByIdSelector, timelineTimeRangeSelector, apolloClient$ }
{
selectAllTimelineQuery,
selectNotesByIdSelector,
timelineByIdSelector,
timelineTimeRangeSelector,
apolloClient$,
}
) => {
const timeline$ = state$.pipe(map(timelineByIdSelector), filter(isNotNull));
const allTimelineQuery$ = state$.pipe(
map(state => {
const getQuery = selectAllTimelineQuery();
return getQuery(state, ALL_TIMELINE_QUERY_ID);
}),
filter(isNotNull)
);
const notes$ = state$.pipe(map(selectNotesByIdSelector), filter(isNotNull));
const timelineTimeRange$ = state$.pipe(map(timelineTimeRangeSelector), filter(isNotNull));
@ -168,33 +178,52 @@ export const createTimelineEpic = <State>(): Epic<
const version = myEpicTimelineId.getTimelineVersion();
if (timelineNoteActionsType.includes(action.type)) {
return epicPersistNote(apolloClient, action, timeline, notes, action$, timeline$, notes$);
return epicPersistNote(
apolloClient,
action,
timeline,
notes,
action$,
timeline$,
notes$,
allTimelineQuery$
);
} else if (timelinePinnedEventActionsType.includes(action.type)) {
return epicPersistPinnedEvent(apolloClient, action, timeline, action$, timeline$);
return epicPersistPinnedEvent(
apolloClient,
action,
timeline,
action$,
timeline$,
allTimelineQuery$
);
} else if (timelineFavoriteActionsType.includes(action.type)) {
return epicPersistTimelineFavorite(apolloClient, action, timeline, action$, timeline$);
return epicPersistTimelineFavorite(
apolloClient,
action,
timeline,
action$,
timeline$,
allTimelineQuery$
);
} else if (timelineActionsType.includes(action.type)) {
return from(
apolloClient.mutate<
PersistTimelineMutation.Mutation,
PersistTimelineMutation.Variables
>({
mutation: persistTimelineMutation,
fetchPolicy: 'no-cache',
variables: {
timelineId,
version,
timeline: convertTimelineAsInput(timeline[action.payload.id], timelineTimeRange),
},
refetchQueries,
persistTimeline({
timelineId,
version,
timeline: convertTimelineAsInput(timeline[action.payload.id], timelineTimeRange),
})
).pipe(
withLatestFrom(timeline$),
mergeMap(([result, recentTimeline]) => {
withLatestFrom(timeline$, allTimelineQuery$),
mergeMap(([result, recentTimeline, allTimelineQuery]) => {
const savedTimeline = recentTimeline[action.payload.id];
const response: ResponseTimeline = get('data.persistTimeline', result);
const callOutMsg = response.code === 403 ? [showCallOutUnauthorizedMsg()] : [];
if (allTimelineQuery.refetch != null) {
(allTimelineQuery.refetch as inputsModel.Refetch)();
}
return [
response.code === 409
? updateAutoSaveMsg({
@ -261,7 +290,7 @@ const timelineInput: TimelineInput = {
export const convertTimelineAsInput = (
timeline: TimelineModel,
timelineTimeRange: TimeRange
timelineTimeRange: inputsModel.TimeRange
): TimelineInput =>
Object.keys(timelineInput).reduce<TimelineInput>((acc, key) => {
if (has(key, timeline)) {

View file

@ -26,6 +26,7 @@ import { dispatcherTimelinePersistQueue } from './epic_dispatcher_timeline_persi
import { refetchQueries } from './refetch_queries';
import { myEpicTimelineId } from './my_epic_timeline_id';
import { ActionTimeline, TimelineById } from './types';
import { inputsModel } from '../inputs';
export const timelineFavoriteActionsType = [updateIsFavorite.type];
@ -34,7 +35,8 @@ export const epicPersistTimelineFavorite = (
action: ActionTimeline,
timeline: TimelineById,
action$: Observable<Action>,
timeline$: Observable<TimelineById>
timeline$: Observable<TimelineById>,
allTimelineQuery$: Observable<inputsModel.GlobalQuery>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): Observable<any> =>
from(
@ -50,12 +52,16 @@ export const epicPersistTimelineFavorite = (
refetchQueries,
})
).pipe(
withLatestFrom(timeline$),
mergeMap(([result, recentTimelines]) => {
withLatestFrom(timeline$, allTimelineQuery$),
mergeMap(([result, recentTimelines, allTimelineQuery]) => {
const savedTimeline = recentTimelines[action.payload.id];
const response: ResponseFavoriteTimeline = get('data.persistFavorite', result);
const callOutMsg = response.code === 403 ? [showCallOutUnauthorizedMsg()] : [];
if (allTimelineQuery.refetch != null) {
(allTimelineQuery.refetch as inputsModel.Refetch)();
}
return [
...callOutMsg,
updateTimeline({

View file

@ -16,6 +16,7 @@ import { persistTimelineNoteMutation } from '../../containers/timeline/notes/per
import { PersistTimelineNoteMutation, ResponseNote } from '../../graphql/types';
import { updateNote, addError } from '../app/actions';
import { NotesById } from '../app/model';
import { inputsModel } from '../inputs';
import {
addNote,
@ -39,7 +40,8 @@ export const epicPersistNote = (
notes: NotesById,
action$: Observable<Action>,
timeline$: Observable<TimelineById>,
notes$: Observable<NotesById>
notes$: Observable<NotesById>,
allTimelineQuery$: Observable<inputsModel.GlobalQuery>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): Observable<any> =>
from(
@ -61,12 +63,16 @@ export const epicPersistNote = (
refetchQueries,
})
).pipe(
withLatestFrom(timeline$, notes$),
mergeMap(([result, recentTimeline, recentNotes]) => {
withLatestFrom(timeline$, notes$, allTimelineQuery$),
mergeMap(([result, recentTimeline, recentNotes, allTimelineQuery]) => {
const noteIdRedux = action.payload.noteId;
const response: ResponseNote = get('data.persistNote', result);
const callOutMsg = response.code === 403 ? [showCallOutUnauthorizedMsg()] : [];
if (allTimelineQuery.refetch != null) {
(allTimelineQuery.refetch as inputsModel.Refetch)();
}
return [
...callOutMsg,
recentTimeline[action.payload.id].savedObjectId == null

View file

@ -15,6 +15,8 @@ import { filter, mergeMap, startWith, withLatestFrom, takeUntil } from 'rxjs/ope
import { persistTimelinePinnedEventMutation } from '../../containers/timeline/pinned_event/persist.gql_query';
import { PersistTimelinePinnedEventMutation, PinnedEvent } from '../../graphql/types';
import { addError } from '../app/actions';
import { inputsModel } from '../inputs';
import {
pinEvent,
endTimelineSaving,
@ -35,7 +37,8 @@ export const epicPersistPinnedEvent = (
action: ActionTimeline,
timeline: TimelineById,
action$: Observable<Action>,
timeline$: Observable<TimelineById>
timeline$: Observable<TimelineById>,
allTimelineQuery$: Observable<inputsModel.GlobalQuery>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): Observable<any> =>
from(
@ -57,12 +60,16 @@ export const epicPersistPinnedEvent = (
refetchQueries,
})
).pipe(
withLatestFrom(timeline$),
mergeMap(([result, recentTimeline]) => {
withLatestFrom(timeline$, allTimelineQuery$),
mergeMap(([result, recentTimeline, allTimelineQuery]) => {
const savedTimeline = recentTimeline[action.payload.id];
const response: PinnedEvent = get('data.persistPinnedEventOnTimeline', result);
const callOutMsg = response && response.code === 403 ? [showCallOutUnauthorizedMsg()] : [];
if (allTimelineQuery.refetch != null) {
(allTimelineQuery.refetch as inputsModel.Refetch)();
}
return [
response != null
? updateTimeline({

View file

@ -125,6 +125,11 @@ export const timelineSchema = gql`
script: String
}
enum TimelineType {
default
template
}
input TimelineInput {
columns: [ColumnHeaderInput!]
dataProviders: [DataProviderInput!]
@ -134,6 +139,9 @@ export const timelineSchema = gql`
kqlMode: String
kqlQuery: SerializedFilterQueryInput
title: String
templateTimelineId: String
templateTimelineVersion: Int
timelineType: TimelineType
dateRange: DateRangePickerInput
savedQueryId: String
sort: SortTimelineInput
@ -237,6 +245,9 @@ export const timelineSchema = gql`
savedObjectId: String!
sort: SortTimelineResult
title: String
templateTimelineId: String
templateTimelineVersion: Int
timelineType: TimelineType
updated: Float
updatedBy: String
version: String!

View file

@ -134,6 +134,12 @@ export interface TimelineInput {
title?: Maybe<string>;
templateTimelineId?: Maybe<string>;
templateTimelineVersion?: Maybe<number>;
timelineType?: Maybe<TimelineType>;
dateRange?: Maybe<DateRangePickerInput>;
savedQueryId?: Maybe<string>;
@ -336,6 +342,11 @@ export enum TlsFields {
_id = '_id',
}
export enum TimelineType {
default = 'default',
template = 'template',
}
export enum SortFieldTimeline {
title = 'title',
description = 'description',
@ -1946,6 +1957,12 @@ export interface TimelineResult {
title?: Maybe<string>;
templateTimelineId?: Maybe<string>;
templateTimelineVersion?: Maybe<number>;
timelineType?: Maybe<TimelineType>;
updated?: Maybe<number>;
updatedBy?: Maybe<string>;
@ -8023,6 +8040,12 @@ export namespace TimelineResultResolvers {
title?: TitleResolver<Maybe<string>, TypeParent, TContext>;
templateTimelineId?: TemplateTimelineIdResolver<Maybe<string>, TypeParent, TContext>;
templateTimelineVersion?: TemplateTimelineVersionResolver<Maybe<number>, TypeParent, TContext>;
timelineType?: TimelineTypeResolver<Maybe<TimelineType>, TypeParent, TContext>;
updated?: UpdatedResolver<Maybe<number>, TypeParent, TContext>;
updatedBy?: UpdatedByResolver<Maybe<string>, TypeParent, TContext>;
@ -8130,6 +8153,21 @@ export namespace TimelineResultResolvers {
Parent = TimelineResult,
TContext = SiemContext
> = Resolver<R, Parent, TContext>;
export type TemplateTimelineIdResolver<
R = Maybe<string>,
Parent = TimelineResult,
TContext = SiemContext
> = Resolver<R, Parent, TContext>;
export type TemplateTimelineVersionResolver<
R = Maybe<number>,
Parent = TimelineResult,
TContext = SiemContext
> = Resolver<R, Parent, TContext>;
export type TimelineTypeResolver<
R = Maybe<TimelineType>,
Parent = TimelineResult,
TContext = SiemContext
> = Resolver<R, Parent, TContext>;
export type UpdatedResolver<
R = Maybe<number>,
Parent = TimelineResult,

View file

@ -27,9 +27,9 @@ import { ElasticsearchSourceStatusAdapter, SourceStatus } from '../source_status
import { ConfigurationSourcesAdapter, Sources } from '../sources';
import { AppBackendLibs, AppDomainLibs } from '../types';
import { ElasticsearchUncommonProcessesAdapter, UncommonProcesses } from '../uncommon_processes';
import { Note } from '../note/saved_object';
import { PinnedEvent } from '../pinned_event/saved_object';
import { Timeline } from '../timeline/saved_object';
import * as note from '../note/saved_object';
import * as pinnedEvent from '../pinned_event/saved_object';
import * as timeline from '../timeline/saved_object';
import { ElasticsearchMatrixHistogramAdapter, MatrixHistogram } from '../matrix_histogram';
export function compose(
@ -41,10 +41,6 @@ export function compose(
const sources = new Sources(new ConfigurationSourcesAdapter());
const sourceStatus = new SourceStatus(new ElasticsearchSourceStatusAdapter(framework));
const timeline = new Timeline();
const note = new Note();
const pinnedEvent = new PinnedEvent();
const domainLibs: AppDomainLibs = {
authentications: new Authentications(new ElasticsearchAuthenticationAdapter(framework)),
events: new Events(new ElasticsearchEventsAdapter(framework)),

View file

@ -15,6 +15,11 @@ import { identity } from 'fp-ts/lib/function';
import { SavedObjectsFindOptions } from '../../../../../../src/core/server';
import { AuthenticatedUser } from '../../../../security/common/model';
import { UNAUTHENTICATED_USER } from '../../../common/constants';
import {
SavedNote,
NoteSavedObjectRuntimeType,
NoteSavedObject,
} from '../../../common/types/timeline/note';
import {
PageInfoNote,
ResponseNote,
@ -23,178 +28,198 @@ import {
NoteResult,
} from '../../graphql/types';
import { FrameworkRequest } from '../framework';
import { SavedNote, NoteSavedObjectRuntimeType, NoteSavedObject } from './types';
import { noteSavedObjectType } from './saved_object_mappings';
import { pickSavedTimeline } from '../timeline/pick_saved_timeline';
import { convertSavedObjectToSavedTimeline } from '../timeline/convert_saved_object_to_savedtimeline';
import { timelineSavedObjectType } from '../timeline/saved_object_mappings';
export class Note {
public async deleteNote(request: FrameworkRequest, noteIds: string[]) {
const savedObjectsClient = request.context.core.savedObjects.client;
await Promise.all(
noteIds.map(noteId => savedObjectsClient.delete(noteSavedObjectType, noteId))
);
}
public async deleteNoteByTimelineId(request: FrameworkRequest, timelineId: string) {
const options: SavedObjectsFindOptions = {
type: noteSavedObjectType,
search: timelineId,
searchFields: ['timelineId'],
};
const notesToBeDeleted = await this.getAllSavedNote(request, options);
const savedObjectsClient = request.context.core.savedObjects.client;
await Promise.all(
notesToBeDeleted.notes.map(note =>
savedObjectsClient.delete(noteSavedObjectType, note.noteId)
)
);
}
public async getNote(request: FrameworkRequest, noteId: string): Promise<NoteSavedObject> {
return this.getSavedNote(request, noteId);
}
public async getNotesByEventId(
request: FrameworkRequest,
eventId: string
): Promise<NoteSavedObject[]> {
const options: SavedObjectsFindOptions = {
type: noteSavedObjectType,
search: eventId,
searchFields: ['eventId'],
};
const notesByEventId = await this.getAllSavedNote(request, options);
return notesByEventId.notes;
}
public async getNotesByTimelineId(
request: FrameworkRequest,
timelineId: string
): Promise<NoteSavedObject[]> {
const options: SavedObjectsFindOptions = {
type: noteSavedObjectType,
search: timelineId,
searchFields: ['timelineId'],
};
const notesByTimelineId = await this.getAllSavedNote(request, options);
return notesByTimelineId.notes;
}
public async getAllNotes(
export interface Note {
deleteNote: (request: FrameworkRequest, noteIds: string[]) => Promise<void>;
deleteNoteByTimelineId: (request: FrameworkRequest, noteIds: string) => Promise<void>;
getNote: (request: FrameworkRequest, noteId: string) => Promise<NoteSavedObject>;
getNotesByEventId: (request: FrameworkRequest, noteId: string) => Promise<NoteSavedObject[]>;
getNotesByTimelineId: (request: FrameworkRequest, noteId: string) => Promise<NoteSavedObject[]>;
getAllNotes: (
request: FrameworkRequest,
pageInfo: PageInfoNote | null,
search: string | null,
sort: SortNote | null
): Promise<ResponseNotes> {
const options: SavedObjectsFindOptions = {
type: noteSavedObjectType,
perPage: pageInfo != null ? pageInfo.pageSize : undefined,
page: pageInfo != null ? pageInfo.pageIndex : undefined,
search: search != null ? search : undefined,
searchFields: ['note'],
sortField: sort != null ? sort.sortField : undefined,
sortOrder: sort != null ? sort.sortOrder : undefined,
};
return this.getAllSavedNote(request, options);
}
public async persistNote(
) => Promise<ResponseNotes>;
persistNote: (
request: FrameworkRequest,
noteId: string | null,
version: string | null,
note: SavedNote
): Promise<ResponseNote> {
try {
const savedObjectsClient = request.context.core.savedObjects.client;
) => Promise<ResponseNote>;
convertSavedObjectToSavedNote: (
savedObject: unknown,
timelineVersion?: string | undefined | null
) => NoteSavedObject;
}
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;
export const deleteNote = async (request: FrameworkRequest, noteIds: string[]) => {
const savedObjectsClient = request.context.core.savedObjects.client;
// Create new note
return {
code: 200,
message: 'success',
note: convertSavedObjectToSavedNote(
await savedObjectsClient.create(
noteSavedObjectType,
pickSavedNote(noteId, note, request.user)
),
timelineVersionSavedObject != null ? timelineVersionSavedObject : undefined
),
};
}
await Promise.all(noteIds.map(noteId => savedObjectsClient.delete(noteSavedObjectType, noteId)));
};
// Update new note
export const deleteNoteByTimelineId = async (request: FrameworkRequest, timelineId: string) => {
const options: SavedObjectsFindOptions = {
type: noteSavedObjectType,
search: timelineId,
searchFields: ['timelineId'],
};
const notesToBeDeleted = await getAllSavedNote(request, options);
const savedObjectsClient = request.context.core.savedObjects.client;
const existingNote = await this.getSavedNote(request, noteId);
await Promise.all(
notesToBeDeleted.notes.map(note => savedObjectsClient.delete(noteSavedObjectType, note.noteId))
);
};
export const getNote = async (
request: FrameworkRequest,
noteId: string
): Promise<NoteSavedObject> => {
return getSavedNote(request, noteId);
};
export const getNotesByEventId = async (
request: FrameworkRequest,
eventId: string
): Promise<NoteSavedObject[]> => {
const options: SavedObjectsFindOptions = {
type: noteSavedObjectType,
search: eventId,
searchFields: ['eventId'],
};
const notesByEventId = await getAllSavedNote(request, options);
return notesByEventId.notes;
};
export const getNotesByTimelineId = async (
request: FrameworkRequest,
timelineId: string
): Promise<NoteSavedObject[]> => {
const options: SavedObjectsFindOptions = {
type: noteSavedObjectType,
search: timelineId,
searchFields: ['timelineId'],
};
const notesByTimelineId = await getAllSavedNote(request, options);
return notesByTimelineId.notes;
};
export const getAllNotes = async (
request: FrameworkRequest,
pageInfo: PageInfoNote | null,
search: string | null,
sort: SortNote | null
): Promise<ResponseNotes> => {
const options: SavedObjectsFindOptions = {
type: noteSavedObjectType,
perPage: pageInfo != null ? pageInfo.pageSize : undefined,
page: pageInfo != null ? pageInfo.pageIndex : undefined,
search: search != null ? search : undefined,
searchFields: ['note'],
sortField: sort != null ? sort.sortField : undefined,
sortOrder: sort != null ? sort.sortOrder : undefined,
};
return getAllSavedNote(request, options);
};
export const persistNote = async (
request: FrameworkRequest,
noteId: string | null,
version: string | null,
note: SavedNote
): Promise<ResponseNote> => {
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.update(
await savedObjectsClient.create(
noteSavedObjectType,
noteId,
pickSavedNote(noteId, note, request.user),
{
version: existingNote.version || undefined,
}
)
pickSavedNote(noteId, note, request.user)
),
timelineVersionSavedObject != null ? timelineVersionSavedObject : undefined
),
};
} catch (err) {
if (getOr(null, 'output.statusCode', err) === 403) {
const noteToReturn: NoteResult = {
...note,
noteId: uuid.v1(),
version: '',
timelineId: '',
timelineVersion: '',
};
return {
code: 403,
message: err.message,
note: noteToReturn,
};
}
throw err;
}
}
private async getSavedNote(request: FrameworkRequest, NoteId: string) {
const savedObjectsClient = request.context.core.savedObjects.client;
const savedObject = await savedObjectsClient.get(noteSavedObjectType, NoteId);
return convertSavedObjectToSavedNote(savedObject);
}
private async getAllSavedNote(request: FrameworkRequest, options: SavedObjectsFindOptions) {
const savedObjectsClient = request.context.core.savedObjects.client;
const savedObjects = await savedObjectsClient.find(options);
// Update new note
const existingNote = await getSavedNote(request, noteId);
return {
totalCount: savedObjects.total,
notes: savedObjects.saved_objects.map(savedObject =>
convertSavedObjectToSavedNote(savedObject)
code: 200,
message: 'success',
note: convertSavedObjectToSavedNote(
await savedObjectsClient.update(
noteSavedObjectType,
noteId,
pickSavedNote(noteId, note, request.user),
{
version: existingNote.version || undefined,
}
)
),
};
} catch (err) {
if (getOr(null, 'output.statusCode', err) === 403) {
const noteToReturn: NoteResult = {
...note,
noteId: uuid.v1(),
version: '',
timelineId: '',
timelineVersion: '',
};
return {
code: 403,
message: err.message,
note: noteToReturn,
};
}
throw err;
}
}
};
const getSavedNote = async (request: FrameworkRequest, NoteId: string) => {
const savedObjectsClient = request.context.core.savedObjects.client;
const savedObject = await savedObjectsClient.get(noteSavedObjectType, NoteId);
return convertSavedObjectToSavedNote(savedObject);
};
const getAllSavedNote = async (request: FrameworkRequest, options: SavedObjectsFindOptions) => {
const savedObjectsClient = request.context.core.savedObjects.client;
const savedObjects = await savedObjectsClient.find(options);
return {
totalCount: savedObjects.total,
notes: savedObjects.saved_objects.map(savedObject =>
convertSavedObjectToSavedNote(savedObject)
),
};
};
export const convertSavedObjectToSavedNote = (
savedObject: unknown,

View file

@ -13,175 +13,225 @@ import { identity } from 'fp-ts/lib/function';
import { SavedObjectsFindOptions } from '../../../../../../src/core/server';
import { AuthenticatedUser } from '../../../../security/common/model';
import { UNAUTHENTICATED_USER } from '../../../common/constants';
import { FrameworkRequest } from '../framework';
import {
PinnedEventSavedObject,
PinnedEventSavedObjectRuntimeType,
SavedPinnedEvent,
} from './types';
} from '../../../common/types/timeline/pinned_event';
import { FrameworkRequest } from '../framework';
import { PageInfoNote, SortNote, PinnedEvent as PinnedEventResponse } from '../../graphql/types';
import { pickSavedTimeline } from '../timeline/pick_saved_timeline';
import { convertSavedObjectToSavedTimeline } from '../timeline/convert_saved_object_to_savedtimeline';
import { pinnedEventSavedObjectType } from './saved_object_mappings';
import { timelineSavedObjectType } from '../timeline/saved_object_mappings';
export class PinnedEvent {
public async deletePinnedEventOnTimeline(request: FrameworkRequest, pinnedEventIds: string[]) {
const savedObjectsClient = request.context.core.savedObjects.client;
export interface PinnedEvent {
deletePinnedEventOnTimeline: (
request: FrameworkRequest,
pinnedEventIds: string[]
) => Promise<void>;
await Promise.all(
pinnedEventIds.map(pinnedEventId =>
savedObjectsClient.delete(pinnedEventSavedObjectType, pinnedEventId)
)
);
}
deleteAllPinnedEventsOnTimeline: (request: FrameworkRequest, timelineId: string) => Promise<void>;
public async deleteAllPinnedEventsOnTimeline(request: FrameworkRequest, timelineId: string) {
const savedObjectsClient = request.context.core.savedObjects.client;
const options: SavedObjectsFindOptions = {
type: pinnedEventSavedObjectType,
search: timelineId,
searchFields: ['timelineId'],
};
const pinnedEventToBeDeleted = await this.getAllSavedPinnedEvents(request, options);
await Promise.all(
pinnedEventToBeDeleted.map(pinnedEvent =>
savedObjectsClient.delete(pinnedEventSavedObjectType, pinnedEvent.pinnedEventId)
)
);
}
public async getPinnedEvent(
getPinnedEvent: (
request: FrameworkRequest,
pinnedEventId: string
): Promise<PinnedEventSavedObject> {
return this.getSavedPinnedEvent(request, pinnedEventId);
}
) => Promise<PinnedEventSavedObject>;
public async getAllPinnedEventsByTimelineId(
getAllPinnedEventsByTimelineId: (
request: FrameworkRequest,
timelineId: string
): Promise<PinnedEventSavedObject[]> {
const options: SavedObjectsFindOptions = {
type: pinnedEventSavedObjectType,
search: timelineId,
searchFields: ['timelineId'],
};
return this.getAllSavedPinnedEvents(request, options);
}
) => Promise<PinnedEventSavedObject[]>;
public async getAllPinnedEvents(
getAllPinnedEvents: (
request: FrameworkRequest,
pageInfo: PageInfoNote | null,
search: string | null,
sort: SortNote | null
): Promise<PinnedEventSavedObject[]> {
const options: SavedObjectsFindOptions = {
type: pinnedEventSavedObjectType,
perPage: pageInfo != null ? pageInfo.pageSize : undefined,
page: pageInfo != null ? pageInfo.pageIndex : undefined,
search: search != null ? search : undefined,
searchFields: ['timelineId', 'eventId'],
sortField: sort != null ? sort.sortField : undefined,
sortOrder: sort != null ? sort.sortOrder : undefined,
};
return this.getAllSavedPinnedEvents(request, options);
}
) => Promise<PinnedEventSavedObject[]>;
public async persistPinnedEventOnTimeline(
persistPinnedEventOnTimeline: (
request: FrameworkRequest,
pinnedEventId: string | null, // pinned event saved object id
eventId: string,
timelineId: string | null
): Promise<PinnedEventResponse | null> {
const savedObjectsClient = request.context.core.savedObjects.client;
) => Promise<PinnedEventResponse | null>;
try {
if (pinnedEventId == null) {
const timelineVersionSavedObject =
timelineId == null
? await (async () => {
const timelineResult = convertSavedObjectToSavedTimeline(
await savedObjectsClient.create(
timelineSavedObjectType,
pickSavedTimeline(null, {}, request.user || null)
)
);
timelineId = timelineResult.savedObjectId; // eslint-disable-line no-param-reassign
return timelineResult.version;
})()
: null;
convertSavedObjectToSavedPinnedEvent: (
savedObject: unknown,
timelineVersion?: string | undefined | null
) => PinnedEventSavedObject;
if (timelineId != null) {
const allPinnedEventId = await this.getAllPinnedEventsByTimelineId(request, timelineId);
const isPinnedAlreadyExisting = allPinnedEventId.filter(
pinnedEvent => pinnedEvent.eventId === eventId
);
if (isPinnedAlreadyExisting.length === 0) {
const savedPinnedEvent: SavedPinnedEvent = {
eventId,
timelineId,
};
// create Pinned Event on Timeline
return convertSavedObjectToSavedPinnedEvent(
await savedObjectsClient.create(
pinnedEventSavedObjectType,
pickSavedPinnedEvent(pinnedEventId, savedPinnedEvent, request.user || null)
),
timelineVersionSavedObject != null ? timelineVersionSavedObject : undefined
);
}
return isPinnedAlreadyExisting[0];
}
throw new Error('You can NOT pinned event without a timelineID');
}
// Delete Pinned Event on Timeline
await this.deletePinnedEventOnTimeline(request, [pinnedEventId]);
return null;
} catch (err) {
if (getOr(null, 'output.statusCode', err) === 404) {
/*
* Why we are doing that, because if it is not found for sure that it will be unpinned
* There is no need to bring back this error since we can assume that it is unpinned
*/
return null;
}
if (getOr(null, 'output.statusCode', err) === 403) {
return pinnedEventId != null
? {
code: 403,
message: err.message,
pinnedEventId: eventId,
timelineId: '',
timelineVersion: '',
}
: null;
}
throw err;
}
}
private async getSavedPinnedEvent(request: FrameworkRequest, pinnedEventId: string) {
const savedObjectsClient = request.context.core.savedObjects.client;
const savedObject = await savedObjectsClient.get(pinnedEventSavedObjectType, pinnedEventId);
return convertSavedObjectToSavedPinnedEvent(savedObject);
}
private async getAllSavedPinnedEvents(
request: FrameworkRequest,
options: SavedObjectsFindOptions
) {
const savedObjectsClient = request.context.core.savedObjects.client;
const savedObjects = await savedObjectsClient.find(options);
return savedObjects.saved_objects.map(savedObject =>
convertSavedObjectToSavedPinnedEvent(savedObject)
);
}
pickSavedPinnedEvent: (
pinnedEventId: string | null,
savedPinnedEvent: SavedPinnedEvent,
userInfo: AuthenticatedUser | null
) => // eslint-disable-next-line @typescript-eslint/no-explicit-any
any;
}
export const deletePinnedEventOnTimeline = async (
request: FrameworkRequest,
pinnedEventIds: string[]
) => {
const savedObjectsClient = request.context.core.savedObjects.client;
await Promise.all(
pinnedEventIds.map(pinnedEventId =>
savedObjectsClient.delete(pinnedEventSavedObjectType, pinnedEventId)
)
);
};
export const deleteAllPinnedEventsOnTimeline = async (
request: FrameworkRequest,
timelineId: string
) => {
const savedObjectsClient = request.context.core.savedObjects.client;
const options: SavedObjectsFindOptions = {
type: pinnedEventSavedObjectType,
search: timelineId,
searchFields: ['timelineId'],
};
const pinnedEventToBeDeleted = await getAllSavedPinnedEvents(request, options);
await Promise.all(
pinnedEventToBeDeleted.map(pinnedEvent =>
savedObjectsClient.delete(pinnedEventSavedObjectType, pinnedEvent.pinnedEventId)
)
);
};
export const getPinnedEvent = async (
request: FrameworkRequest,
pinnedEventId: string
): Promise<PinnedEventSavedObject> => {
return getSavedPinnedEvent(request, pinnedEventId);
};
export const getAllPinnedEventsByTimelineId = async (
request: FrameworkRequest,
timelineId: string
): Promise<PinnedEventSavedObject[]> => {
const options: SavedObjectsFindOptions = {
type: pinnedEventSavedObjectType,
search: timelineId,
searchFields: ['timelineId'],
};
return getAllSavedPinnedEvents(request, options);
};
export const getAllPinnedEvents = async (
request: FrameworkRequest,
pageInfo: PageInfoNote | null,
search: string | null,
sort: SortNote | null
): Promise<PinnedEventSavedObject[]> => {
const options: SavedObjectsFindOptions = {
type: pinnedEventSavedObjectType,
perPage: pageInfo != null ? pageInfo.pageSize : undefined,
page: pageInfo != null ? pageInfo.pageIndex : undefined,
search: search != null ? search : undefined,
searchFields: ['timelineId', 'eventId'],
sortField: sort != null ? sort.sortField : undefined,
sortOrder: sort != null ? sort.sortOrder : undefined,
};
return getAllSavedPinnedEvents(request, options);
};
export const persistPinnedEventOnTimeline = async (
request: FrameworkRequest,
pinnedEventId: string | null, // pinned event saved object id
eventId: string,
timelineId: string | null
): Promise<PinnedEventResponse | null> => {
const savedObjectsClient = request.context.core.savedObjects.client;
try {
if (pinnedEventId == null) {
const timelineVersionSavedObject =
timelineId == null
? await (async () => {
const timelineResult = convertSavedObjectToSavedTimeline(
await savedObjectsClient.create(
timelineSavedObjectType,
pickSavedTimeline(null, {}, request.user || null)
)
);
timelineId = timelineResult.savedObjectId; // eslint-disable-line no-param-reassign
return timelineResult.version;
})()
: null;
if (timelineId != null) {
const allPinnedEventId = await getAllPinnedEventsByTimelineId(request, timelineId);
const isPinnedAlreadyExisting = allPinnedEventId.filter(
pinnedEvent => pinnedEvent.eventId === eventId
);
if (isPinnedAlreadyExisting.length === 0) {
const savedPinnedEvent: SavedPinnedEvent = {
eventId,
timelineId,
};
// create Pinned Event on Timeline
return convertSavedObjectToSavedPinnedEvent(
await savedObjectsClient.create(
pinnedEventSavedObjectType,
pickSavedPinnedEvent(pinnedEventId, savedPinnedEvent, request.user || null)
),
timelineVersionSavedObject != null ? timelineVersionSavedObject : undefined
);
}
return isPinnedAlreadyExisting[0];
}
throw new Error('You can NOT pinned event without a timelineID');
}
// Delete Pinned Event on Timeline
await deletePinnedEventOnTimeline(request, [pinnedEventId]);
return null;
} catch (err) {
if (getOr(null, 'output.statusCode', err) === 404) {
/*
* Why we are doing that, because if it is not found for sure that it will be unpinned
* There is no need to bring back this error since we can assume that it is unpinned
*/
return null;
}
if (getOr(null, 'output.statusCode', err) === 403) {
return pinnedEventId != null
? {
code: 403,
message: err.message,
pinnedEventId: eventId,
timelineId: '',
timelineVersion: '',
}
: null;
}
throw err;
}
};
const getSavedPinnedEvent = async (request: FrameworkRequest, pinnedEventId: string) => {
const savedObjectsClient = request.context.core.savedObjects.client;
const savedObject = await savedObjectsClient.get(pinnedEventSavedObjectType, pinnedEventId);
return convertSavedObjectToSavedPinnedEvent(savedObject);
};
const getAllSavedPinnedEvents = async (
request: FrameworkRequest,
options: SavedObjectsFindOptions
) => {
const savedObjectsClient = request.context.core.savedObjects.client;
const savedObjects = await savedObjectsClient.find(options);
return savedObjects.saved_objects.map(savedObject =>
convertSavedObjectToSavedPinnedEvent(savedObject)
);
};
export const convertSavedObjectToSavedPinnedEvent = (
savedObject: unknown,
timelineVersion?: string | undefined | null

View file

@ -8,16 +8,21 @@ import { failure } from 'io-ts/lib/PathReporter';
import { pipe } from 'fp-ts/lib/pipeable';
import { map, fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';
import { TimelineSavedObjectRuntimeType, TimelineSavedObject } from './types';
import {
TimelineSavedObjectRuntimeType,
TimelineSavedObject,
} from '../../../common/types/timeline';
export const convertSavedObjectToSavedTimeline = (savedObject: unknown): TimelineSavedObject => {
const timeline = pipe(
TimelineSavedObjectRuntimeType.decode(savedObject),
map(savedTimeline => ({
savedObjectId: savedTimeline.id,
version: savedTimeline.version,
...savedTimeline.attributes,
})),
map(savedTimeline => {
return {
savedObjectId: savedTimeline.id,
version: savedTimeline.version,
...savedTimeline.attributes,
};
}),
fold(errors => {
throw new Error(failure(errors).join('\n'));
}, identity)

View file

@ -22,6 +22,7 @@ import {
import { ImportTimelineResponse } from './routes/utils/import_timelines';
import { ImportTimelinesSchemaRt } from './routes/schemas/import_timelines_schema';
import { BadRequestError } from '../detection_engine/errors/bad_request_error';
type ErrorFactory = (message: string) => Error;
@ -38,8 +39,11 @@ export const decodeOrThrow = <A, O, I>(
pipe(runtimeType.decode(inputValue), fold(throwErrors(createError), identity));
export const validateTimelines = (): Transform =>
createMapStream((obj: ImportTimelineResponse) => decodeOrThrow(ImportTimelinesSchemaRt)(obj));
createMapStream((obj: ImportTimelineResponse) =>
obj instanceof Error
? new BadRequestError(obj.message)
: decodeOrThrow(ImportTimelinesSchemaRt)(obj)
);
export const createTimelinesStreamFromNdJson = (ruleLimit: number) => {
return [
createSplitStream('\n'),

View file

@ -4,9 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
import uuid from 'uuid';
import { AuthenticatedUser } from '../../../../security/common/model';
import { UNAUTHENTICATED_USER } from '../../../common/constants';
import { SavedTimeline } from './types';
import { SavedTimeline, TimelineType } from '../../../common/types/timeline';
export const pickSavedTimeline = (
timelineId: string | null,
@ -24,5 +25,21 @@ export const pickSavedTimeline = (
savedTimeline.updated = dateNow;
savedTimeline.updatedBy = userInfo?.username ?? UNAUTHENTICATED_USER;
}
if (savedTimeline.timelineType === TimelineType.template) {
savedTimeline.timelineType = TimelineType.template;
if (savedTimeline.templateTimelineId == null) {
savedTimeline.templateTimelineId = uuid.v4();
}
if (savedTimeline.templateTimelineVersion == null) {
savedTimeline.templateTimelineVersion = 1;
}
} else {
savedTimeline.timelineType = TimelineType.default;
savedTimeline.templateTimelineId = null;
savedTimeline.templateTimelineVersion = null;
}
return savedTimeline;
};

View file

@ -0,0 +1,326 @@
**Timeline apis**
1. Create timeline api
2. Update timeline api
3. Create template timeline api
4. Update template timeline api
## Create timeline api
#### POST /api/timeline
##### Authorization
Type: Basic Auth
username: Your Kibana username
password: Your Kibana password
##### Request header
```
Content-Type: application/json
kbn-version: 8.0.0
```
##### Request body
```json
{
"timeline": {
"columns": [
{
"columnHeaderType": "not-filtered",
"id": "@timestamp"
},
{
"columnHeaderType": "not-filtered",
"id": "message"
},
{
"columnHeaderType": "not-filtered",
"id": "event.category"
},
{
"columnHeaderType": "not-filtered",
"id": "event.action"
},
{
"columnHeaderType": "not-filtered",
"id": "host.name"
},
{
"columnHeaderType": "not-filtered",
"id": "source.ip"
},
{
"columnHeaderType": "not-filtered",
"id": "destination.ip"
},
{
"columnHeaderType": "not-filtered",
"id": "user.name"
}
],
"dataProviders": [],
"description": "",
"eventType": "all",
"filters": [],
"kqlMode": "filter",
"kqlQuery": {
"filterQuery": null
},
"title": "abd",
"dateRange": {
"start": 1587370079200,
"end": 1587456479201
},
"savedQueryId": null,
"sort": {
"columnId": "@timestamp",
"sortDirection": "desc"
}
},
"timelineId":null, // Leave this as null
"version":null // Leave this as null
}
```
## Update timeline api
#### PATCH /api/timeline
##### Authorization
Type: Basic Auth
username: Your Kibana username
password: Your Kibana password
##### Request header
```
Content-Type: application/json
kbn-version: 8.0.0
```
##### Request body
```json
{
"timeline": {
"columns": [
{
"columnHeaderType": "not-filtered",
"id": "@timestamp"
},
{
"columnHeaderType": "not-filtered",
"id": "message"
},
{
"columnHeaderType": "not-filtered",
"id": "event.category"
},
{
"columnHeaderType": "not-filtered",
"id": "event.action"
},
{
"columnHeaderType": "not-filtered",
"id": "host.name"
},
{
"columnHeaderType": "not-filtered",
"id": "source.ip"
},
{
"columnHeaderType": "not-filtered",
"id": "destination.ip"
},
{
"columnHeaderType": "not-filtered",
"id": "user.name"
}
],
"dataProviders": [],
"description": "",
"eventType": "all",
"filters": [],
"kqlMode": "filter",
"kqlQuery": {
"filterQuery": null
},
"title": "abd",
"dateRange": {
"start": 1587370079200,
"end": 1587456479201
},
"savedQueryId": null,
"sort": {
"columnId": "@timestamp",
"sortDirection": "desc"
},
"created": 1587468588922,
"createdBy": "casetester",
"updated": 1587468588922,
"updatedBy": "casetester",
"timelineType": "default"
},
"timelineId":"68ea5330-83c3-11ea-bff9-ab01dd7cb6cc", // Have to match the existing timeline savedObject id
"version":"WzYwLDFd" // Have to match the existing timeline version
}
```
## Create template timeline api
#### POST /api/timeline
##### Authorization
Type: Basic Auth
username: Your Kibana username
password: Your Kibana password
##### Request header
```
Content-Type: application/json
kbn-version: 8.0.0
```
##### Request body
```json
{
"timeline": {
"columns": [
{
"columnHeaderType": "not-filtered",
"id": "@timestamp"
},
{
"columnHeaderType": "not-filtered",
"id": "message"
},
{
"columnHeaderType": "not-filtered",
"id": "event.category"
},
{
"columnHeaderType": "not-filtered",
"id": "event.action"
},
{
"columnHeaderType": "not-filtered",
"id": "host.name"
},
{
"columnHeaderType": "not-filtered",
"id": "source.ip"
},
{
"columnHeaderType": "not-filtered",
"id": "destination.ip"
},
{
"columnHeaderType": "not-filtered",
"id": "user.name"
}
],
"dataProviders": [
],
"description": "",
"eventType": "all",
"filters": [
],
"kqlMode": "filter",
"kqlQuery": {
"filterQuery": null
},
"title": "abd",
"dateRange": {
"start": 1587370079200,
"end": 1587456479201
},
"savedQueryId": null,
"sort": {
"columnId": "@timestamp",
"sortDirection": "desc"
},
"timelineType": "template" // This is the difference between create timeline
},
"timelineId":null, // Leave this as null
"version":null // Leave this as null
}
```
## Update template timeline api
#### PATCH /api/timeline
##### Authorization
Type: Basic Auth
username: Your Kibana username
password: Your Kibana password
##### Request header
```
Content-Type: application/json
kbn-version: 8.0.0
```
##### Request body
```json
{
"timeline": {
"columns": [
{
"columnHeaderType": "not-filtered",
"id": "@timestamp"
},
{
"columnHeaderType": "not-filtered",
"id": "message"
},
{
"columnHeaderType": "not-filtered",
"id": "event.category"
},
{
"columnHeaderType": "not-filtered",
"id": "event.action"
},
{
"columnHeaderType": "not-filtered",
"id": "host.name"
},
{
"columnHeaderType": "not-filtered",
"id": "source.ip"
},
{
"columnHeaderType": "not-filtered",
"id": "destination.ip"
},
{
"columnHeaderType": "not-filtered",
"id": "user.name"
}
],
"dataProviders": [],
"description": "",
"eventType": "all",
"filters": [],
"kqlMode": "filter",
"kqlQuery": {
"filterQuery": null
},
"title": "abd",
"dateRange": {
"start": 1587370079200,
"end": 1587456479201
},
"savedQueryId": null,
"sort": {
"columnId": "@timestamp",
"sortDirection": "desc"
},
"timelineType": "template",
"created": 1587473119992,
"createdBy": "casetester",
"updated": 1587473119992,
"updatedBy": "casetester",
"templateTimelineId": "745d0316-6af7-43bf-afd6-9747119754fb", // Please provide the existing template timeline version
"templateTimelineVersion": 2 // Please provide a template timeline version grater than existing one
},
"timelineId":"f5a4bd10-83cd-11ea-bf78-0547a65f1281", // This is a must as well
"version":"Wzg2LDFd" // Please provide the existing timeline version
}
```

View file

@ -5,6 +5,7 @@
*/
import { omit } from 'lodash/fp';
import { TimelineType } from '../../../../../common/types/timeline';
export const mockDuplicateIdErrors = [];
@ -148,6 +149,13 @@ export const mockGetTimelineValue = {
pinnedEventIds: ['k-gi8nABm-sIqJ_scOoS'],
};
export const mockGetTemplateTimelineValue = {
...mockGetTimelineValue,
timelineType: TimelineType.template,
templateTimelineId: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189',
templateTimelineVersion: 1,
};
export const mockParsedTimelineObject = omit(
[
'globalNotes',

View file

@ -3,10 +3,18 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { TIMELINE_EXPORT_URL, TIMELINE_IMPORT_URL } from '../../../../../common/constants';
import { requestMock } from '../../../detection_engine/routes/__mocks__';
import * as rt from 'io-ts';
import {
TIMELINE_EXPORT_URL,
TIMELINE_IMPORT_URL,
TIMELINE_URL,
} from '../../../../../common/constants';
import stream from 'stream';
import { requestMock } from '../../../detection_engine/routes/__mocks__';
import { SavedTimeline, TimelineType } from '../../../../../common/types/timeline';
import { updateTimelineSchema } from '../schemas/update_timelines_schema';
import { createTimelineSchema } from '../schemas/create_timelines_schema';
const readable = new stream.Readable();
export const getExportTimelinesRequest = () =>
requestMock.create({
@ -31,6 +39,96 @@ export const getImportTimelinesRequest = (filename?: string) =>
},
});
export const inputTimeline: SavedTimeline = {
columns: [
{ columnHeaderType: 'not-filtered', id: '@timestamp' },
{ columnHeaderType: 'not-filtered', id: 'message' },
{ columnHeaderType: 'not-filtered', id: 'event.category' },
{ columnHeaderType: 'not-filtered', id: 'event.action' },
{ columnHeaderType: 'not-filtered', id: 'host.name' },
{ columnHeaderType: 'not-filtered', id: 'source.ip' },
{ columnHeaderType: 'not-filtered', id: 'destination.ip' },
{ columnHeaderType: 'not-filtered', id: 'user.name' },
],
dataProviders: [],
description: '',
eventType: 'all',
filters: [],
kqlMode: 'filter',
kqlQuery: { filterQuery: null },
title: 't',
timelineType: TimelineType.default,
templateTimelineId: null,
templateTimelineVersion: null,
dateRange: { start: 1585227005527, end: 1585313405527 },
savedQueryId: null,
sort: { columnId: '@timestamp', sortDirection: 'desc' },
};
export const inputTemplateTimeline = {
...inputTimeline,
timelineType: TimelineType.template,
templateTimelineId: null,
templateTimelineVersion: null,
};
export const createTimelineWithoutTimelineId = {
templateTimelineId: null,
timeline: inputTimeline,
timelineId: null,
version: null,
timelineType: TimelineType.default,
};
export const createTemplateTimelineWithoutTimelineId = {
templateTimelineId: null,
timeline: inputTemplateTimeline,
timelineId: null,
version: null,
timelineType: TimelineType.template,
};
export const createTimelineWithTimelineId = {
...createTimelineWithoutTimelineId,
timelineId: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189',
};
export const createTemplateTimelineWithTimelineId = {
...createTemplateTimelineWithoutTimelineId,
timelineId: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189',
templateTimelineId: 'existing template timeline id',
};
export const updateTimelineWithTimelineId = {
timeline: inputTimeline,
timelineId: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189',
version: 'WzEyMjUsMV0=',
};
export const updateTemplateTimelineWithTimelineId = {
timeline: {
...inputTemplateTimeline,
templateTimelineId: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189',
templateTimelineVersion: 2,
},
timelineId: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189',
version: 'WzEyMjUsMV0=',
};
export const getCreateTimelinesRequest = (mockBody: rt.TypeOf<typeof createTimelineSchema>) =>
requestMock.create({
method: 'post',
path: TIMELINE_URL,
body: mockBody,
});
export const getUpdateTimelinesRequest = (mockBody: rt.TypeOf<typeof updateTimelineSchema>) =>
requestMock.create({
method: 'patch',
path: TIMELINE_URL,
body: mockBody,
});
export const getImportTimelinesRequestEnableOverwrite = (filename?: string) =>
requestMock.create({
method: 'post',

View file

@ -0,0 +1,272 @@
/*
* 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 { SecurityPluginSetup } from '../../../../../../plugins/security/server';
import {
serverMock,
requestContextMock,
createMockConfig,
} from '../../detection_engine/routes/__mocks__';
import {
mockGetCurrentUser,
mockGetTimelineValue,
mockGetTemplateTimelineValue,
} from './__mocks__/import_timelines';
import {
getCreateTimelinesRequest,
inputTimeline,
createTimelineWithoutTimelineId,
createTimelineWithTimelineId,
createTemplateTimelineWithoutTimelineId,
createTemplateTimelineWithTimelineId,
} from './__mocks__/request_responses';
import {
CREATE_TEMPLATE_TIMELINE_ERROR_MESSAGE,
CREATE_TIMELINE_ERROR_MESSAGE,
} from './utils/create_timelines';
describe('create timelines', () => {
let server: ReturnType<typeof serverMock.create>;
let securitySetup: SecurityPluginSetup;
let { context } = requestContextMock.createTools();
let mockGetTimeline: jest.Mock;
let mockPersistTimeline: jest.Mock;
let mockPersistPinnedEventOnTimeline: jest.Mock;
let mockPersistNote: jest.Mock;
beforeEach(() => {
jest.resetModules();
jest.resetAllMocks();
jest.restoreAllMocks();
jest.clearAllMocks();
server = serverMock.create();
context = requestContextMock.createTools().context;
securitySetup = ({
authc: {
getCurrentUser: jest.fn().mockReturnValue(mockGetCurrentUser),
},
authz: {},
} as unknown) as SecurityPluginSetup;
mockGetTimeline = jest.fn();
mockPersistTimeline = jest.fn();
mockPersistPinnedEventOnTimeline = jest.fn();
mockPersistNote = jest.fn();
});
describe('Manipulate timeline', () => {
describe('Create a new timeline', () => {
beforeEach(async () => {
jest.doMock('../saved_object', () => {
return {
getTimeline: mockGetTimeline.mockReturnValue(null),
persistTimeline: mockPersistTimeline.mockReturnValue({
timeline: createTimelineWithTimelineId,
}),
};
});
jest.doMock('../../pinned_event/saved_object', () => {
return {
persistPinnedEventOnTimeline: mockPersistPinnedEventOnTimeline,
};
});
jest.doMock('../../note/saved_object', () => {
return {
persistNote: mockPersistNote,
};
});
const createTimelinesRoute = jest.requireActual('./create_timelines_route')
.createTimelinesRoute;
createTimelinesRoute(server.router, createMockConfig(), securitySetup);
const mockRequest = getCreateTimelinesRequest(createTimelineWithoutTimelineId);
await server.inject(mockRequest, context);
});
test('should Create a new timeline savedObject', async () => {
expect(mockPersistTimeline).toHaveBeenCalled();
});
test('should Create a new timeline savedObject without timelineId', async () => {
expect(mockPersistTimeline.mock.calls[0][1]).toBeNull();
});
test('should Create a new timeline savedObject without timeline version', async () => {
expect(mockPersistTimeline.mock.calls[0][2]).toBeNull();
});
test('should Create a new timeline savedObject witn given timeline', async () => {
expect(mockPersistTimeline.mock.calls[0][3]).toEqual(inputTimeline);
});
test('should NOT Create new pinned events', async () => {
expect(mockPersistPinnedEventOnTimeline).not.toBeCalled();
});
test('should NOT Create notes', async () => {
expect(mockPersistNote).not.toBeCalled();
});
test('returns 200 when create timeline successfully', async () => {
const response = await server.inject(
getCreateTimelinesRequest(createTimelineWithoutTimelineId),
context
);
expect(response.status).toEqual(200);
});
});
describe('Import a timeline already exist', () => {
beforeEach(() => {
jest.doMock('../saved_object', () => {
return {
getTimeline: mockGetTimeline.mockReturnValue(mockGetTimelineValue),
persistTimeline: mockPersistTimeline,
};
});
jest.doMock('../../pinned_event/saved_object', () => {
return {
persistPinnedEventOnTimeline: mockPersistPinnedEventOnTimeline,
};
});
jest.doMock('../../note/saved_object', () => {
return {
persistNote: mockPersistNote,
};
});
const createTimelinesRoute = jest.requireActual('./create_timelines_route')
.createTimelinesRoute;
createTimelinesRoute(server.router, createMockConfig(), securitySetup);
});
test('returns error message', async () => {
const response = await server.inject(
getCreateTimelinesRequest(createTimelineWithTimelineId),
context
);
expect(response.body).toEqual({
message: CREATE_TIMELINE_ERROR_MESSAGE,
status_code: 405,
});
});
});
});
describe('Manipulate template timeline', () => {
describe('Create a new template timeline', () => {
beforeEach(async () => {
jest.doMock('../saved_object', () => {
return {
getTimeline: mockGetTimeline.mockReturnValue(null),
persistTimeline: mockPersistTimeline.mockReturnValue({
timeline: createTemplateTimelineWithTimelineId,
}),
};
});
jest.doMock('../../pinned_event/saved_object', () => {
return {
persistPinnedEventOnTimeline: mockPersistPinnedEventOnTimeline,
};
});
jest.doMock('../../note/saved_object', () => {
return {
persistNote: mockPersistNote,
};
});
const createTimelinesRoute = jest.requireActual('./create_timelines_route')
.createTimelinesRoute;
createTimelinesRoute(server.router, createMockConfig(), securitySetup);
const mockRequest = getCreateTimelinesRequest(createTemplateTimelineWithoutTimelineId);
await server.inject(mockRequest, context);
});
test('should Create a new template timeline savedObject', async () => {
expect(mockPersistTimeline).toHaveBeenCalled();
});
test('should Create a new template timeline savedObject without timelineId', async () => {
expect(mockPersistTimeline.mock.calls[0][1]).toBeNull();
});
test('should Create a new template timeline savedObject without template timeline version', async () => {
expect(mockPersistTimeline.mock.calls[0][2]).toBeNull();
});
test('should Create a new template timeline savedObject witn given template timeline', async () => {
expect(mockPersistTimeline.mock.calls[0][3]).toEqual(
createTemplateTimelineWithTimelineId.timeline
);
});
test('should NOT Create new pinned events', async () => {
expect(mockPersistPinnedEventOnTimeline).not.toBeCalled();
});
test('should NOT Create notes', async () => {
expect(mockPersistNote).not.toBeCalled();
});
test('returns 200 when create timeline successfully', async () => {
const response = await server.inject(
getCreateTimelinesRequest(createTimelineWithoutTimelineId),
context
);
expect(response.status).toEqual(200);
});
});
describe('Import a template timeline already exist', () => {
beforeEach(() => {
jest.doMock('../saved_object', () => {
return {
getTimeline: mockGetTimeline.mockReturnValue(mockGetTemplateTimelineValue),
persistTimeline: mockPersistTimeline,
};
});
jest.doMock('../../pinned_event/saved_object', () => {
return {
persistPinnedEventOnTimeline: mockPersistPinnedEventOnTimeline,
};
});
jest.doMock('../../note/saved_object', () => {
return {
persistNote: mockPersistNote,
};
});
const createTimelinesRoute = jest.requireActual('./create_timelines_route')
.createTimelinesRoute;
createTimelinesRoute(server.router, createMockConfig(), securitySetup);
});
test('returns error message', async () => {
const response = await server.inject(
getCreateTimelinesRequest(createTemplateTimelineWithTimelineId),
context
);
expect(response.body).toEqual({
message: CREATE_TEMPLATE_TIMELINE_ERROR_MESSAGE,
status_code: 405,
});
});
});
});
});

View file

@ -0,0 +1,90 @@
/*
* 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 { IRouter } from '../../../../../../../src/core/server';
import { TIMELINE_URL } from '../../../../common/constants';
import { TimelineType } from '../../../../common/types/timeline';
import { ConfigType } from '../../..';
import { SetupPlugins } from '../../../plugin';
import { buildRouteValidation } from '../../../utils/build_validation/route_validation';
import { transformError, buildSiemResponse } from '../../detection_engine/routes/utils';
import { createTimelineSchema } from './schemas/create_timelines_schema';
import { buildFrameworkRequest } from './utils/common';
import {
createTimelines,
getTimeline,
getTemplateTimeline,
CREATE_TEMPLATE_TIMELINE_ERROR_MESSAGE,
CREATE_TIMELINE_ERROR_MESSAGE,
} from './utils/create_timelines';
export const createTimelinesRoute = (
router: IRouter,
config: ConfigType,
security: SetupPlugins['security']
) => {
router.post(
{
path: TIMELINE_URL,
validate: {
body: buildRouteValidation(createTimelineSchema),
},
options: {
tags: ['access:siem'],
},
},
async (context, request, response) => {
const siemResponse = buildSiemResponse(response);
try {
const frameworkRequest = await buildFrameworkRequest(context, security, request);
const { timelineId, timeline, version } = request.body;
const { templateTimelineId, timelineType } = timeline;
const isHandlingTemplateTimeline = timelineType === TimelineType.template;
const existTimeline =
timelineId != null ? await getTimeline(frameworkRequest, timelineId) : null;
const existTemplateTimeline =
templateTimelineId != null
? await getTemplateTimeline(frameworkRequest, templateTimelineId)
: null;
if (
(!isHandlingTemplateTimeline && existTimeline != null) ||
(isHandlingTemplateTimeline && (existTemplateTimeline != null || existTimeline != null))
) {
return siemResponse.error({
body: isHandlingTemplateTimeline
? CREATE_TEMPLATE_TIMELINE_ERROR_MESSAGE
: CREATE_TIMELINE_ERROR_MESSAGE,
statusCode: 405,
});
}
// Create timeline
const newTimeline = await createTimelines(frameworkRequest, timeline, null, version);
return response.ok({
body: {
data: {
persistTimeline: newTimeline,
},
},
});
} catch (err) {
const error = transformError(err);
return siemResponse.error({
body: error.message,
statusCode: error.statusCode,
});
}
}
);
};

View file

@ -6,13 +6,13 @@
import { getImportTimelinesRequest } from './__mocks__/request_responses';
import {
createMockConfig,
serverMock,
requestContextMock,
requestMock,
createMockConfig,
} from '../../detection_engine/routes/__mocks__';
import { TIMELINE_EXPORT_URL } from '../../../../common/constants';
import { SecurityPluginSetup } from '../../../../../security/server';
import { SecurityPluginSetup } from '../../../../../../plugins/security/server';
import {
mockUniqueParsedObjects,
@ -24,7 +24,6 @@ import {
} from './__mocks__/import_timelines';
describe('import timelines', () => {
let config: ReturnType<typeof createMockConfig>;
let server: ReturnType<typeof serverMock.create>;
let request: ReturnType<typeof requestMock.create>;
let securitySetup: SecurityPluginSetup;
@ -43,7 +42,6 @@ describe('import timelines', () => {
server = serverMock.create();
context = requestContextMock.createTools().context;
config = createMockConfig();
securitySetup = ({
authc: {
@ -84,40 +82,28 @@ describe('import timelines', () => {
beforeEach(() => {
jest.doMock('../saved_object', () => {
return {
Timeline: jest.fn().mockImplementation(() => {
return {
getTimeline: mockGetTimeline.mockReturnValue(null),
persistTimeline: mockPersistTimeline.mockReturnValue({
timeline: { savedObjectId: newTimelineSavedObjectId, version: newTimelineVersion },
}),
};
getTimeline: mockGetTimeline.mockReturnValue(null),
persistTimeline: mockPersistTimeline.mockReturnValue({
timeline: { savedObjectId: newTimelineSavedObjectId, version: newTimelineVersion },
}),
};
});
jest.doMock('../../pinned_event/saved_object', () => {
return {
PinnedEvent: jest.fn().mockImplementation(() => {
return {
persistPinnedEventOnTimeline: mockPersistPinnedEventOnTimeline,
};
}),
persistPinnedEventOnTimeline: mockPersistPinnedEventOnTimeline,
};
});
jest.doMock('../../note/saved_object', () => {
return {
Note: jest.fn().mockImplementation(() => {
return {
persistNote: mockPersistNote,
};
}),
persistNote: mockPersistNote,
};
});
const importTimelinesRoute = jest.requireActual('./import_timelines_route')
.importTimelinesRoute;
importTimelinesRoute(server.router, config, securitySetup);
importTimelinesRoute(server.router, createMockConfig(), securitySetup);
});
test('should use given timelineId to check if the timeline savedObject already exist', async () => {
@ -230,38 +216,26 @@ describe('import timelines', () => {
beforeEach(() => {
jest.doMock('../saved_object', () => {
return {
Timeline: jest.fn().mockImplementation(() => {
return {
getTimeline: mockGetTimeline.mockReturnValue(mockGetTimelineValue),
persistTimeline: mockPersistTimeline,
};
}),
getTimeline: mockGetTimeline.mockReturnValue(mockGetTimelineValue),
persistTimeline: mockPersistTimeline,
};
});
jest.doMock('../../pinned_event/saved_object', () => {
return {
PinnedEvent: jest.fn().mockImplementation(() => {
return {
persistPinnedEventOnTimeline: mockPersistPinnedEventOnTimeline,
};
}),
persistPinnedEventOnTimeline: mockPersistPinnedEventOnTimeline,
};
});
jest.doMock('../../note/saved_object', () => {
return {
Note: jest.fn().mockImplementation(() => {
return {
persistNote: mockPersistNote,
};
}),
persistNote: mockPersistNote,
};
});
const importTimelinesRoute = jest.requireActual('./import_timelines_route')
.importTimelinesRoute;
importTimelinesRoute(server.router, config, securitySetup);
importTimelinesRoute(server.router, createMockConfig(), securitySetup);
});
test('returns error message', async () => {
@ -286,36 +260,24 @@ describe('import timelines', () => {
beforeEach(() => {
jest.doMock('../saved_object', () => {
return {
Timeline: jest.fn().mockImplementation(() => {
return {
getTimeline: mockGetTimeline.mockReturnValue(null),
persistTimeline: mockPersistTimeline.mockReturnValue({
timeline: { savedObjectId: '79deb4c0-6bc1-11ea-9999-f5341fb7a189' },
}),
};
getTimeline: mockGetTimeline.mockReturnValue(null),
persistTimeline: mockPersistTimeline.mockReturnValue({
timeline: { savedObjectId: '79deb4c0-6bc1-11ea-9999-f5341fb7a189' },
}),
};
});
jest.doMock('../../pinned_event/saved_object', () => {
return {
PinnedEvent: jest.fn().mockImplementation(() => {
return {
persistPinnedEventOnTimeline: mockPersistPinnedEventOnTimeline.mockReturnValue(
new Error('Test error')
),
};
}),
persistPinnedEventOnTimeline: mockPersistPinnedEventOnTimeline.mockReturnValue(
new Error('Test error')
),
};
});
jest.doMock('../../note/saved_object', () => {
return {
Note: jest.fn().mockImplementation(() => {
return {
persistNote: mockPersistNote,
};
}),
persistNote: mockPersistNote,
};
});
});
@ -328,11 +290,14 @@ describe('import timelines', () => {
const importTimelinesRoute = jest.requireActual('./import_timelines_route')
.importTimelinesRoute;
importTimelinesRoute(server.router, config, securitySetup);
importTimelinesRoute(server.router, createMockConfig(), securitySetup);
const result = server.validate(request);
expect(result.badRequest).toHaveBeenCalledWith(
'Invalid value "undefined" supplied to "file",Invalid value "undefined" supplied to "file"'
[
'Invalid value "undefined" supplied to "file"',
'Invalid value "undefined" supplied to "file"',
].join(',')
);
});
});

View file

@ -5,9 +5,19 @@
*/
import { extname } from 'path';
import { chunk, omit, set } from 'lodash/fp';
import { chunk, omit } from 'lodash/fp';
import { createPromiseFromStreams } from '../../../../../../../src/legacy/utils';
import { IRouter } from '../../../../../../../src/core/server';
import { TIMELINE_IMPORT_URL } from '../../../../common/constants';
import { SetupPlugins } from '../../../plugin';
import { ConfigType } from '../../../config';
import { buildRouteValidation } from '../../../utils/build_validation/route_validation';
import { importRulesSchema } from '../../detection_engine/routes/schemas/response/import_rules_schema';
import { validate } from '../../detection_engine/routes/rules/validate';
import {
buildSiemResponse,
createBulkErrorObject,
@ -16,32 +26,22 @@ import {
} from '../../detection_engine/routes/utils';
import { createTimelinesStreamFromNdJson } from '../create_timelines_stream_from_ndjson';
import { createPromiseFromStreams } from '../../../../../../../src/legacy/utils';
import { ImportTimelinesPayloadSchemaRt } from './schemas/import_timelines_schema';
import { buildFrameworkRequest } from './utils/common';
import {
createTimelines,
getTupleDuplicateErrorsAndUniqueTimeline,
isBulkError,
isImportRegular,
ImportTimelineResponse,
ImportTimelinesSchema,
PromiseFromStreams,
timelineSavedObjectOmittedFields,
} from './utils/import_timelines';
import { createTimelines, getTimeline } from './utils/create_timelines';
import { IRouter } from '../../../../../../../src/core/server';
import { SetupPlugins } from '../../../plugin';
import { ImportTimelinesPayloadSchemaRt } from './schemas/import_timelines_schema';
import { importRulesSchema } from '../../detection_engine/routes/schemas/response/import_rules_schema';
import { ConfigType } from '../../../config';
import { Timeline } from '../saved_object';
import { validate } from '../../detection_engine/routes/rules/validate';
import { FrameworkRequest } from '../../framework';
import { buildRouteValidation } from '../../../utils/build_validation/route_validation';
const CHUNK_PARSED_OBJECT_SIZE = 10;
const timelineLib = new Timeline();
export const importTimelinesRoute = (
router: IRouter,
config: ConfigType,
@ -95,9 +95,7 @@ export const importTimelinesRoute = (
const chunkParseObjects = chunk(CHUNK_PARSED_OBJECT_SIZE, uniqueParsedObjects);
let importTimelineResponse: ImportTimelineResponse[] = [];
const user = await security?.authc.getCurrentUser(request);
let frameworkRequest = set('context.core.savedObjects.client', savedObjectsClient, request);
frameworkRequest = set('user', user, frameworkRequest);
const frameworkRequest = await buildFrameworkRequest(context, security, request);
while (chunkParseObjects.length) {
const batchParseObjects = chunkParseObjects.shift() ?? [];
@ -125,32 +123,16 @@ export const importTimelinesRoute = (
eventNotes,
} = parsedTimeline;
const parsedTimelineObject = omit(
[
'globalNotes',
'eventNotes',
'pinnedEventIds',
'version',
'savedObjectId',
'created',
'createdBy',
'updated',
'updatedBy',
],
timelineSavedObjectOmittedFields,
parsedTimeline
);
let newTimeline = null;
try {
let timeline = null;
try {
timeline = await timelineLib.getTimeline(
(frameworkRequest as unknown) as FrameworkRequest,
savedObjectId
);
// eslint-disable-next-line no-empty
} catch (e) {}
const timeline = await getTimeline(frameworkRequest, savedObjectId);
if (timeline == null) {
const newSavedObjectId = await createTimelines(
(frameworkRequest as unknown) as FrameworkRequest,
newTimeline = await createTimelines(
frameworkRequest,
parsedTimelineObject,
null, // timelineSavedObjectId
null, // timelineVersion
@ -159,7 +141,10 @@ export const importTimelinesRoute = (
[] // existing note ids
);
resolve({ timeline_id: newSavedObjectId, status_code: 200 });
resolve({
timeline_id: newTimeline.timeline.savedObjectId,
status_code: 200,
});
} else {
resolve(
createBulkErrorObject({

View file

@ -0,0 +1,19 @@
/*
* 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 * as rt from 'io-ts';
import { SavedTimelineRuntimeType } from '../../../../../common/types/timeline';
import { unionWithNullType } from '../../../../../common/utility_types';
export const createTimelineSchema = rt.intersection([
rt.type({
timeline: SavedTimelineRuntimeType,
}),
rt.partial({
timelineId: unionWithNullType(rt.string),
version: unionWithNullType(rt.string),
}),
]);

View file

@ -7,14 +7,17 @@ import * as rt from 'io-ts';
import { Readable } from 'stream';
import { either } from 'fp-ts/lib/Either';
import { SavedTimelineRuntimeType } from '../../../../../common/types/timeline';
import { eventNotes, globalNotes, pinnedEventIds } from './schemas';
import { SavedTimelineRuntimeType } from '../../types';
import { unionWithNullType } from '../../../../../common/utility_types';
export const ImportTimelinesSchemaRt = rt.intersection([
SavedTimelineRuntimeType,
rt.type({
savedObjectId: rt.string,
version: rt.string,
savedObjectId: unionWithNullType(rt.string),
version: unionWithNullType(rt.string),
}),
rt.type({
globalNotes,

View file

@ -4,9 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import * as runtimeTypes from 'io-ts';
import { unionWithNullType } from '../../../framework';
import { SavedNoteRuntimeType } from '../../../note/types';
import { unionWithNullType } from '../../../../../common/utility_types';
import { SavedNoteRuntimeType } from '../../../../../common/types/timeline/note';
export const eventNotes = runtimeTypes.array(unionWithNullType(SavedNoteRuntimeType));
export const globalNotes = runtimeTypes.array(unionWithNullType(SavedNoteRuntimeType));
export const pinnedEventIds = runtimeTypes.array(unionWithNullType(runtimeTypes.string));
export const eventNotes = unionWithNullType(runtimeTypes.array(SavedNoteRuntimeType));
export const globalNotes = unionWithNullType(runtimeTypes.array(SavedNoteRuntimeType));
export const pinnedEventIds = unionWithNullType(runtimeTypes.array(runtimeTypes.string));

View file

@ -0,0 +1,16 @@
/*
* 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 * as rt from 'io-ts';
import { SavedTimelineRuntimeType } from '../../../../../common/types/timeline';
import { unionWithNullType } from '../../../../../common/utility_types';
export const updateTimelineSchema = rt.type({
timeline: SavedTimelineRuntimeType,
timelineId: unionWithNullType(rt.string),
version: unionWithNullType(rt.string),
});

View file

@ -0,0 +1,288 @@
/*
* 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 { SecurityPluginSetup } from '../../../../../../plugins/security/server';
import {
serverMock,
requestContextMock,
createMockConfig,
} from '../../detection_engine/routes/__mocks__';
import {
getUpdateTimelinesRequest,
inputTimeline,
updateTimelineWithTimelineId,
updateTemplateTimelineWithTimelineId,
} from './__mocks__/request_responses';
import {
mockGetCurrentUser,
mockGetTimelineValue,
mockGetTemplateTimelineValue,
} from './__mocks__/import_timelines';
import {
UPDATE_TIMELINE_ERROR_MESSAGE,
UPDATE_TEMPLATE_TIMELINE_ERROR_MESSAGE,
} from './utils/update_timelines';
describe('update timelines', () => {
let server: ReturnType<typeof serverMock.create>;
let securitySetup: SecurityPluginSetup;
let { context } = requestContextMock.createTools();
let mockGetTimeline: jest.Mock;
let mockGetTemplateTimeline: jest.Mock;
let mockPersistTimeline: jest.Mock;
let mockPersistPinnedEventOnTimeline: jest.Mock;
let mockPersistNote: jest.Mock;
beforeEach(() => {
jest.resetModules();
jest.resetAllMocks();
jest.restoreAllMocks();
jest.clearAllMocks();
server = serverMock.create();
context = requestContextMock.createTools().context;
securitySetup = ({
authc: {
getCurrentUser: jest.fn().mockReturnValue(mockGetCurrentUser),
},
authz: {},
} as unknown) as SecurityPluginSetup;
mockGetTimeline = jest.fn();
mockGetTemplateTimeline = jest.fn();
mockPersistTimeline = jest.fn();
mockPersistPinnedEventOnTimeline = jest.fn();
mockPersistNote = jest.fn();
});
describe('Manipulate timeline', () => {
describe('Update an existing timeline', () => {
beforeEach(async () => {
jest.doMock('../saved_object', () => {
return {
getTimeline: mockGetTimeline.mockReturnValue(mockGetTimelineValue),
persistTimeline: mockPersistTimeline.mockReturnValue({
timeline: updateTimelineWithTimelineId.timeline,
}),
};
});
jest.doMock('../../pinned_event/saved_object', () => {
return {
persistPinnedEventOnTimeline: mockPersistPinnedEventOnTimeline,
};
});
jest.doMock('../../note/saved_object', () => {
return {
persistNote: mockPersistNote,
};
});
const updateTimelinesRoute = jest.requireActual('./update_timelines_route')
.updateTimelinesRoute;
updateTimelinesRoute(server.router, createMockConfig(), securitySetup);
const mockRequest = getUpdateTimelinesRequest(updateTimelineWithTimelineId);
await server.inject(mockRequest, context);
});
test('should Check a if given timeline id exist', async () => {
expect(mockGetTimeline.mock.calls[0][1]).toEqual(updateTimelineWithTimelineId.timelineId);
});
test('should Update existing timeline savedObject with timelineId', async () => {
expect(mockPersistTimeline.mock.calls[0][1]).toEqual(
updateTimelineWithTimelineId.timelineId
);
});
test('should Update existing timeline savedObject with timeline version', async () => {
expect(mockPersistTimeline.mock.calls[0][2]).toEqual(updateTimelineWithTimelineId.version);
});
test('should Update existing timeline savedObject witn given timeline', async () => {
expect(mockPersistTimeline.mock.calls[0][3]).toEqual(inputTimeline);
});
test('should NOT Update new pinned events', async () => {
expect(mockPersistPinnedEventOnTimeline).not.toBeCalled();
});
test('should NOT Update notes', async () => {
expect(mockPersistNote).not.toBeCalled();
});
test('returns 200 when create timeline successfully', async () => {
const response = await server.inject(
getUpdateTimelinesRequest(updateTimelineWithTimelineId),
context
);
expect(response.status).toEqual(200);
});
});
describe("Update a timeline that doesn't exist", () => {
beforeEach(() => {
jest.doMock('../saved_object', () => {
return {
getTimeline: mockGetTimeline.mockReturnValue(null),
getTimelineByTemplateTimelineId: mockGetTemplateTimeline.mockReturnValue(null),
persistTimeline: mockPersistTimeline,
};
});
jest.doMock('../../pinned_event/saved_object', () => {
return {
persistPinnedEventOnTimeline: mockPersistPinnedEventOnTimeline,
};
});
jest.doMock('../../note/saved_object', () => {
return {
persistNote: mockPersistNote,
};
});
const updateTimelinesRoute = jest.requireActual('./update_timelines_route')
.updateTimelinesRoute;
updateTimelinesRoute(server.router, createMockConfig(), securitySetup);
});
test('returns error message', async () => {
const response = await server.inject(
getUpdateTimelinesRequest(updateTimelineWithTimelineId),
context
);
expect(response.body).toEqual({
message: UPDATE_TIMELINE_ERROR_MESSAGE,
status_code: 405,
});
});
});
});
describe('Manipulate template timeline', () => {
describe('Update an existing template timeline', () => {
beforeEach(async () => {
jest.doMock('../saved_object', () => {
return {
getTimeline: mockGetTimeline.mockReturnValue(mockGetTemplateTimelineValue),
getTimelineByTemplateTimelineId: mockGetTemplateTimeline.mockReturnValue({
timeline: [mockGetTemplateTimelineValue],
}),
persistTimeline: mockPersistTimeline.mockReturnValue({
timeline: updateTimelineWithTimelineId.timeline,
}),
};
});
jest.doMock('../../pinned_event/saved_object', () => {
return {
persistPinnedEventOnTimeline: mockPersistPinnedEventOnTimeline,
};
});
jest.doMock('../../note/saved_object', () => {
return {
persistNote: mockPersistNote,
};
});
const updateTimelinesRoute = jest.requireActual('./update_timelines_route')
.updateTimelinesRoute;
updateTimelinesRoute(server.router, createMockConfig(), securitySetup);
const mockRequest = getUpdateTimelinesRequest(updateTemplateTimelineWithTimelineId);
await server.inject(mockRequest, context);
});
test('should Check if given timeline id exist', async () => {
expect(mockGetTimeline.mock.calls[0][1]).toEqual(
updateTemplateTimelineWithTimelineId.timelineId
);
});
test('should Update existing template timeline with template timelineId', async () => {
expect(mockGetTemplateTimeline.mock.calls[0][1]).toEqual(
updateTemplateTimelineWithTimelineId.timelineId
);
});
test('should Update existing template timeline with timeline version', async () => {
expect(mockPersistTimeline.mock.calls[0][2]).toEqual(
updateTemplateTimelineWithTimelineId.version
);
});
test('should Update existing template timeline witn given timeline', async () => {
expect(mockPersistTimeline.mock.calls[0][3]).toEqual(
updateTemplateTimelineWithTimelineId.timeline
);
});
test('should NOT Update new pinned events', async () => {
expect(mockPersistPinnedEventOnTimeline).not.toBeCalled();
});
test('should NOT Update notes', async () => {
expect(mockPersistNote).not.toBeCalled();
});
test('returns 200 when create template timeline successfully', async () => {
const response = await server.inject(
getUpdateTimelinesRequest(updateTemplateTimelineWithTimelineId),
context
);
expect(response.status).toEqual(200);
});
});
describe("Update a template timeline that doesn't exist", () => {
beforeEach(() => {
jest.doMock('../saved_object', () => {
return {
getTimeline: mockGetTimeline.mockReturnValue(null),
getTimelineByTemplateTimelineId: mockGetTemplateTimeline.mockReturnValue({
timeline: [],
}),
persistTimeline: mockPersistTimeline,
};
});
jest.doMock('../../pinned_event/saved_object', () => {
return {
persistPinnedEventOnTimeline: mockPersistPinnedEventOnTimeline,
};
});
jest.doMock('../../note/saved_object', () => {
return {
persistNote: mockPersistNote,
};
});
const updateTimelinesRoute = jest.requireActual('./update_timelines_route')
.updateTimelinesRoute;
updateTimelinesRoute(server.router, createMockConfig(), securitySetup);
});
test('returns error message', async () => {
const response = await server.inject(
getUpdateTimelinesRequest(updateTemplateTimelineWithTimelineId),
context
);
expect(response.body).toEqual({
message: UPDATE_TEMPLATE_TIMELINE_ERROR_MESSAGE,
status_code: 405,
});
});
});
});
});

View file

@ -0,0 +1,87 @@
/*
* 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 { IRouter } from '../../../../../../../src/core/server';
import { TIMELINE_URL } from '../../../../common/constants';
import { TimelineType } from '../../../../common/types/timeline';
import { SetupPlugins } from '../../../plugin';
import { buildRouteValidation } from '../../../utils/build_validation/route_validation';
import { ConfigType } from '../../..';
import { transformError, buildSiemResponse } from '../../detection_engine/routes/utils';
import { FrameworkRequest } from '../../framework';
import { updateTimelineSchema } from './schemas/update_timelines_schema';
import { buildFrameworkRequest } from './utils/common';
import { createTimelines, getTimeline, getTemplateTimeline } from './utils/create_timelines';
import { checkIsFailureCases } from './utils/update_timelines';
export const updateTimelinesRoute = (
router: IRouter,
config: ConfigType,
security: SetupPlugins['security']
) => {
router.patch(
{
path: TIMELINE_URL,
validate: {
body: buildRouteValidation(updateTimelineSchema),
},
options: {
tags: ['access:siem'],
},
},
// eslint-disable-next-line complexity
async (context, request, response) => {
const siemResponse = buildSiemResponse(response);
try {
const frameworkRequest = await buildFrameworkRequest(context, security, request);
const { timelineId, timeline, version } = request.body;
const { templateTimelineId, templateTimelineVersion, timelineType } = timeline;
const isHandlingTemplateTimeline = timelineType === TimelineType.template;
const existTimeline =
timelineId != null ? await getTimeline(frameworkRequest, timelineId) : null;
const existTemplateTimeline =
templateTimelineId != null
? await getTemplateTimeline(frameworkRequest, templateTimelineId)
: null;
const errorObj = checkIsFailureCases(
isHandlingTemplateTimeline,
version,
templateTimelineVersion ?? null,
existTimeline,
existTemplateTimeline
);
if (errorObj != null) {
return siemResponse.error(errorObj);
}
const updatedTimeline = await createTimelines(
(frameworkRequest as unknown) as FrameworkRequest,
timeline,
timelineId,
version
);
return response.ok({
body: {
data: {
persistTimeline: updatedTimeline,
},
},
});
} catch (err) {
const error = transformError(err);
return siemResponse.error({
body: error.message,
statusCode: error.statusCode,
});
}
}
);
};

View file

@ -0,0 +1,30 @@
/*
* 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 { set } from 'lodash/fp';
import { SetupPlugins } from '../../../../plugin';
import { KibanaRequest } from '../../../../../../../../src/core/server';
import { RequestHandlerContext } from '../../../../../../../../target/types/core/server';
import { FrameworkRequest } from '../../../framework';
export const buildFrameworkRequest = async (
context: RequestHandlerContext,
security: SetupPlugins['security'],
request: KibanaRequest
): Promise<FrameworkRequest> => {
const savedObjectsClient = context.core.savedObjects.client;
const user = await security?.authc.getCurrentUser(request);
return set<FrameworkRequest>(
'user',
user,
set<KibanaRequest & { context: RequestHandlerContext }>(
'context.core.savedObjects.client',
savedObjectsClient,
request
)
);
};

View file

@ -0,0 +1,150 @@
/*
* 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 { isEmpty } from 'lodash/fp';
import * as timelineLib from '../../saved_object';
import * as pinnedEventLib from '../../../pinned_event/saved_object';
import * as noteLib from '../../../note/saved_object';
import { FrameworkRequest } from '../../../framework';
import { SavedTimeline, TimelineSavedObject } from '../../../../../common/types/timeline';
import { SavedNote } from '../../../../../common/types/timeline/note';
import { NoteResult, ResponseTimeline } from '../../../../graphql/types';
export const CREATE_TIMELINE_ERROR_MESSAGE =
'UPDATE timeline with POST is not allowed, please use PATCH instead';
export const CREATE_TEMPLATE_TIMELINE_ERROR_MESSAGE =
'UPDATE template timeline with POST is not allowed, please use PATCH instead';
export const saveTimelines = (
frameworkRequest: FrameworkRequest,
timeline: SavedTimeline,
timelineSavedObjectId?: string | null,
timelineVersion?: string | null
): Promise<ResponseTimeline> => {
return timelineLib.persistTimeline(
frameworkRequest,
timelineSavedObjectId ?? null,
timelineVersion ?? null,
timeline
);
};
export const savePinnedEvents = (
frameworkRequest: FrameworkRequest,
timelineSavedObjectId: string,
pinnedEventIds: string[]
) =>
Promise.all(
pinnedEventIds.map(eventId =>
pinnedEventLib.persistPinnedEventOnTimeline(
frameworkRequest,
null, // pinnedEventSavedObjectId
eventId,
timelineSavedObjectId
)
)
);
export const saveNotes = (
frameworkRequest: FrameworkRequest,
timelineSavedObjectId: string,
timelineVersion?: string | null,
existingNoteIds?: string[],
newNotes?: NoteResult[]
) => {
return Promise.all(
newNotes?.map(note => {
const newNote: SavedNote = {
eventId: note.eventId,
note: note.note,
timelineId: timelineSavedObjectId,
};
return noteLib.persistNote(
frameworkRequest,
existingNoteIds?.find(nId => nId === note.noteId) ?? null,
timelineVersion ?? null,
newNote
);
}) ?? []
);
};
export const createTimelines = async (
frameworkRequest: FrameworkRequest,
timeline: SavedTimeline,
timelineSavedObjectId?: string | null,
timelineVersion?: string | null,
pinnedEventIds?: string[] | null,
notes?: NoteResult[],
existingNoteIds?: string[]
): Promise<ResponseTimeline> => {
const responseTimeline = await saveTimelines(
frameworkRequest,
timeline,
timelineSavedObjectId,
timelineVersion
);
const newTimelineSavedObjectId = responseTimeline.timeline.savedObjectId;
const newTimelineVersion = responseTimeline.timeline.version;
let myPromises: unknown[] = [];
if (pinnedEventIds != null && !isEmpty(pinnedEventIds)) {
myPromises = [
...myPromises,
savePinnedEvents(
frameworkRequest,
timelineSavedObjectId ?? newTimelineSavedObjectId,
pinnedEventIds
),
];
}
if (!isEmpty(notes)) {
myPromises = [
...myPromises,
saveNotes(
frameworkRequest,
timelineSavedObjectId ?? newTimelineSavedObjectId,
newTimelineVersion,
existingNoteIds,
notes
),
];
}
if (myPromises.length > 0) {
await Promise.all(myPromises);
}
return responseTimeline;
};
export const getTimeline = async (
frameworkRequest: FrameworkRequest,
savedObjectId: string
): Promise<TimelineSavedObject | null> => {
let timeline = null;
try {
timeline = await timelineLib.getTimeline(frameworkRequest, savedObjectId);
// eslint-disable-next-line no-empty
} catch (e) {}
return timeline;
};
export const getTemplateTimeline = async (
frameworkRequest: FrameworkRequest,
templateTimelineId: string
): Promise<TimelineSavedObject | null> => {
let templateTimeline = null;
try {
templateTimeline = await timelineLib.getTimelineByTemplateTimelineId(
frameworkRequest,
templateTimelineId
);
} catch (e) {
return null;
}
return templateTimeline.timeline[0];
};

View file

@ -4,13 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { NoteSavedObject } from '../../../note/types';
import { PinnedEventSavedObject } from '../../../pinned_event/types';
import { convertSavedObjectToSavedTimeline } from '../../convert_saved_object_to_savedtimeline';
import { convertSavedObjectToSavedPinnedEvent } from '../../../pinned_event/saved_object';
import { convertSavedObjectToSavedNote } from '../../../note/saved_object';
import {
SavedObjectsClient,
SavedObjectsFindOptions,
@ -22,11 +15,20 @@ import {
ExportTimelineSavedObjectsClient,
ExportedNotes,
TimelineSavedObject,
} from '../../types';
ExportTimelineNotFoundError,
} from '../../../../../common/types/timeline';
import { NoteSavedObject } from '../../../../../common/types/timeline/note';
import { PinnedEventSavedObject } from '../../../../../common/types/timeline/pinned_event';
import { transformDataToNdjson } from '../../../../utils/read_stream/create_stream_from_ndjson';
import { convertSavedObjectToSavedPinnedEvent } from '../../../pinned_event/saved_object';
import { convertSavedObjectToSavedNote } from '../../../note/saved_object';
import { pinnedEventSavedObjectType } from '../../../pinned_event/saved_object_mappings';
import { noteSavedObjectType } from '../../../note/saved_object_mappings';
import { timelineSavedObjectType } from '../../saved_object_mappings';
import { convertSavedObjectToSavedTimeline } from '../../convert_saved_object_to_savedtimeline';
export type TimelineSavedObjectsClient = Pick<
SavedObjectsClient,
@ -126,12 +128,23 @@ const getTimelines = async (
)
);
const timelineObjects: TimelineSavedObject[] | undefined =
savedObjects != null
? savedObjects.saved_objects.map((savedObject: unknown) => {
return convertSavedObjectToSavedTimeline(savedObject);
})
: [];
const timelineObjects: {
timelines: TimelineSavedObject[];
errors: ExportTimelineNotFoundError[];
} = savedObjects.saved_objects.reduce(
(acc, savedObject) => {
return savedObject.error == null
? {
errors: acc.errors,
timelines: [...acc.timelines, convertSavedObjectToSavedTimeline(savedObject)],
}
: { errors: [...acc.errors, savedObject.error], timelines: acc.timelines };
},
{
timelines: [] as TimelineSavedObject[],
errors: [] as ExportTimelineNotFoundError[],
}
);
return timelineObjects;
};
@ -139,12 +152,8 @@ const getTimelines = async (
const getTimelinesFromObjects = async (
savedObjectsClient: ExportTimelineSavedObjectsClient,
ids: string[]
): Promise<ExportedTimelines[]> => {
const timelines: TimelineSavedObject[] = await getTimelines(savedObjectsClient, ids);
// To Do for feature freeze
// if (timelines.length !== request.body.ids.length) {
// //figure out which is missing to tell user
// }
): Promise<Array<ExportedTimelines | ExportTimelineNotFoundError>> => {
const { timelines, errors } = await getTimelines(savedObjectsClient, ids);
const [notes, pinnedEventIds] = await Promise.all([
Promise.all(ids.map(timelineId => getNotesByTimelineId(savedObjectsClient, timelineId))),
@ -178,7 +187,7 @@ const getTimelinesFromObjects = async (
return acc;
}, []);
return myResponse ?? [];
return [...myResponse, ...errors] ?? [];
};
export const getExportTimelineByObjectIds = async ({

View file

@ -7,20 +7,10 @@
import uuid from 'uuid';
import { has } from 'lodash/fp';
import { createBulkErrorObject, BulkError } from '../../../detection_engine/routes/utils';
import { PinnedEvent } from '../../../pinned_event/saved_object';
import { Note } from '../../../note/saved_object';
import { Timeline } from '../../saved_object';
import { SavedTimeline } from '../../types';
import { FrameworkRequest } from '../../../framework';
import { SavedNote } from '../../../note/types';
import { SavedTimeline } from '../../../../../common/types/timeline';
import { NoteResult } from '../../../../graphql/types';
import { HapiReadableStream } from '../../../detection_engine/rules/types';
const pinnedEventLib = new PinnedEvent();
const timelineLib = new Timeline();
const noteLib = new Note();
export interface ImportTimelinesSchema {
success: boolean;
success_count: number;
@ -84,100 +74,6 @@ export const getTupleDuplicateErrorsAndUniqueTimeline = (
return [Array.from(errors.values()), Array.from(timelinesAcc.values())];
};
export const saveTimelines = async (
frameworkRequest: FrameworkRequest,
timeline: SavedTimeline,
timelineSavedObjectId?: string | null,
timelineVersion?: string | null
) => {
const newTimelineRes = await timelineLib.persistTimeline(
frameworkRequest,
timelineSavedObjectId ?? null,
timelineVersion ?? null,
timeline
);
return {
newTimelineSavedObjectId: newTimelineRes?.timeline?.savedObjectId ?? null,
newTimelineVersion: newTimelineRes?.timeline?.version ?? null,
};
};
export const savePinnedEvents = (
frameworkRequest: FrameworkRequest,
timelineSavedObjectId: string,
pinnedEventIds?: string[] | null
) => {
return (
pinnedEventIds?.map(eventId => {
return pinnedEventLib.persistPinnedEventOnTimeline(
frameworkRequest,
null, // pinnedEventSavedObjectId
eventId,
timelineSavedObjectId
);
}) ?? []
);
};
export const saveNotes = (
frameworkRequest: FrameworkRequest,
timelineSavedObjectId: string,
timelineVersion?: string | null,
existingNoteIds?: string[],
newNotes?: NoteResult[]
) => {
return Promise.all(
newNotes?.map(note => {
const newNote: SavedNote = {
eventId: note.eventId,
note: note.note,
timelineId: timelineSavedObjectId,
};
return noteLib.persistNote(
frameworkRequest,
existingNoteIds?.find(nId => nId === note.noteId) ?? null,
timelineVersion ?? null,
newNote
);
}) ?? []
);
};
export const createTimelines = async (
frameworkRequest: FrameworkRequest,
timeline: SavedTimeline,
timelineSavedObjectId?: string | null,
timelineVersion?: string | null,
pinnedEventIds?: string[] | null,
notes?: NoteResult[],
existingNoteIds?: string[]
) => {
const { newTimelineSavedObjectId, newTimelineVersion } = await saveTimelines(
frameworkRequest,
timeline,
timelineSavedObjectId,
timelineVersion
);
await Promise.all([
savePinnedEvents(
frameworkRequest,
timelineSavedObjectId ?? newTimelineSavedObjectId,
pinnedEventIds
),
saveNotes(
frameworkRequest,
timelineSavedObjectId ?? newTimelineSavedObjectId,
newTimelineVersion,
existingNoteIds,
notes
),
]);
return newTimelineSavedObjectId;
};
export const isImportRegular = (
importTimelineResponse: ImportTimelineResponse
): importTimelineResponse is ImportRegular => {
@ -189,3 +85,15 @@ export const isBulkError = (
): importRuleResponse is BulkError => {
return has('error', importRuleResponse);
};
export const timelineSavedObjectOmittedFields = [
'globalNotes',
'eventNotes',
'pinnedEventIds',
'version',
'savedObjectId',
'created',
'createdBy',
'updated',
'updatedBy',
];

View file

@ -0,0 +1,81 @@
/*
* 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 { TimelineSavedObject } from '../../../../../common/types/timeline';
export const UPDATE_TIMELINE_ERROR_MESSAGE =
'CREATE timeline with PATCH is not allowed, please use POST instead';
export const UPDATE_TEMPLATE_TIMELINE_ERROR_MESSAGE =
'CREATE template timeline with PATCH is not allowed, please use POST instead';
export const NO_MATCH_VERSION_ERROR_MESSAGE =
'TimelineVersion conflict: The given version doesn not match with existing timeline';
export const NO_MATCH_ID_ERROR_MESSAGE =
"Timeline id doesn't match with existing template timeline";
export const OLDER_VERSION_ERROR_MESSAGE =
'Template timelineVersion conflict: The given version is older then existing version';
export const checkIsFailureCases = (
isHandlingTemplateTimeline: boolean,
version: string | null,
templateTimelineVersion: number | null,
existTimeline: TimelineSavedObject | null,
existTemplateTimeline: TimelineSavedObject | null
) => {
if (!isHandlingTemplateTimeline && existTimeline == null) {
return {
body: UPDATE_TIMELINE_ERROR_MESSAGE,
statusCode: 405,
};
} else if (isHandlingTemplateTimeline && existTemplateTimeline == null) {
// Throw error to create template timeline in patch
return {
body: UPDATE_TEMPLATE_TIMELINE_ERROR_MESSAGE,
statusCode: 405,
};
} else if (
isHandlingTemplateTimeline &&
existTimeline != null &&
existTemplateTimeline != null &&
existTimeline.savedObjectId !== existTemplateTimeline.savedObjectId
) {
// Throw error you can not have a no matching between your timeline and your template timeline during an update
return {
body: NO_MATCH_ID_ERROR_MESSAGE,
statusCode: 409,
};
} else if (!isHandlingTemplateTimeline && existTimeline?.version !== version) {
// throw error 409 conflict timeline
return {
body: NO_MATCH_VERSION_ERROR_MESSAGE,
statusCode: 409,
};
} else if (
isHandlingTemplateTimeline &&
existTemplateTimeline != null &&
existTemplateTimeline.templateTimelineVersion == null &&
existTemplateTimeline.version !== version
) {
// throw error 409 conflict timeline
return {
body: NO_MATCH_VERSION_ERROR_MESSAGE,
statusCode: 409,
};
} else if (
isHandlingTemplateTimeline &&
templateTimelineVersion != null &&
existTemplateTimeline != null &&
existTemplateTimeline.templateTimelineVersion != null &&
existTemplateTimeline.templateTimelineVersion >= templateTimelineVersion
) {
// Throw error you can not update a template timeline version with an old version
return {
body: OLDER_VERSION_ERROR_MESSAGE,
statusCode: 409,
};
} else {
return null;
}
};

View file

@ -8,260 +8,308 @@ import { getOr } from 'lodash/fp';
import { SavedObjectsFindOptions } from '../../../../../../src/core/server';
import { UNAUTHENTICATED_USER } from '../../../common/constants';
import { NoteSavedObject } from '../../../common/types/timeline/note';
import { PinnedEventSavedObject } from '../../../common/types/timeline/pinned_event';
import { SavedTimeline, TimelineSavedObject, TimelineType } from '../../../common/types/timeline';
import {
ResponseTimeline,
PageInfoTimeline,
SortTimeline,
ResponseFavoriteTimeline,
TimelineResult,
Maybe,
} from '../../graphql/types';
import { FrameworkRequest } from '../framework';
import { Note } from '../note/saved_object';
import { NoteSavedObject } from '../note/types';
import { PinnedEventSavedObject } from '../pinned_event/types';
import { PinnedEvent } from '../pinned_event/saved_object';
import * as note from '../note/saved_object';
import * as pinnedEvent from '../pinned_event/saved_object';
import { convertSavedObjectToSavedTimeline } from './convert_saved_object_to_savedtimeline';
import { pickSavedTimeline } from './pick_saved_timeline';
import { timelineSavedObjectType } from './saved_object_mappings';
import { SavedTimeline, TimelineSavedObject } from './types';
interface ResponseTimelines {
timeline: TimelineSavedObject[];
totalCount: number;
}
export class Timeline {
private readonly note = new Note();
private readonly pinnedEvent = new PinnedEvent();
export interface ResponseTemplateTimeline {
code?: Maybe<number>;
public async getTimeline(
request: FrameworkRequest,
timelineId: string
): Promise<TimelineSavedObject> {
return this.getSavedTimeline(request, timelineId);
}
message?: Maybe<string>;
public async getAllTimeline(
templateTimeline: TimelineResult;
}
export interface Timeline {
getTimeline: (request: FrameworkRequest, timelineId: string) => Promise<TimelineSavedObject>;
getAllTimeline: (
request: FrameworkRequest,
onlyUserFavorite: boolean | null,
pageInfo: PageInfoTimeline | null,
search: string | null,
sort: SortTimeline | null
): Promise<ResponseTimelines> {
const options: SavedObjectsFindOptions = {
type: timelineSavedObjectType,
perPage: pageInfo != null ? pageInfo.pageSize : undefined,
page: pageInfo != null ? pageInfo.pageIndex : undefined,
search: search != null ? search : undefined,
searchFields: onlyUserFavorite
? ['title', 'description', 'favorite.keySearch']
: ['title', 'description'],
sortField: sort != null ? sort.sortField : undefined,
sortOrder: sort != null ? sort.sortOrder : undefined,
};
) => Promise<ResponseTimelines>;
return this.getAllSavedTimeline(request, options);
}
public async persistFavorite(
persistFavorite: (
request: FrameworkRequest,
timelineId: string | null
): Promise<ResponseFavoriteTimeline> {
const userName = request.user?.username ?? UNAUTHENTICATED_USER;
const fullName = request.user?.full_name ?? '';
try {
let timeline: SavedTimeline = {};
if (timelineId != null) {
const {
eventIdToNoteIds,
notes,
noteIds,
pinnedEventIds,
pinnedEventsSaveObject,
savedObjectId,
version,
...savedTimeline
} = await this.getBasicSavedTimeline(request, timelineId);
timelineId = savedObjectId; // eslint-disable-line no-param-reassign
timeline = savedTimeline;
}
) => Promise<ResponseFavoriteTimeline>;
const userFavoriteTimeline = {
keySearch: userName != null ? convertStringToBase64(userName) : null,
favoriteDate: new Date().valueOf(),
fullName,
userName,
};
if (timeline.favorite != null) {
const alreadyExistsTimelineFavoriteByUser = timeline.favorite.findIndex(
user => user.userName === userName
);
timeline.favorite =
alreadyExistsTimelineFavoriteByUser > -1
? [
...timeline.favorite.slice(0, alreadyExistsTimelineFavoriteByUser),
...timeline.favorite.slice(alreadyExistsTimelineFavoriteByUser + 1),
]
: [...timeline.favorite, userFavoriteTimeline];
} else if (timeline.favorite == null) {
timeline.favorite = [userFavoriteTimeline];
}
const persistResponse = await this.persistTimeline(request, timelineId, null, timeline);
return {
savedObjectId: persistResponse.timeline.savedObjectId,
version: persistResponse.timeline.version,
favorite:
persistResponse.timeline.favorite != null
? persistResponse.timeline.favorite.filter(fav => fav.userName === userName)
: [],
};
} catch (err) {
if (getOr(null, 'output.statusCode', err) === 403) {
return {
savedObjectId: '',
version: '',
favorite: [],
code: 403,
message: err.message,
};
}
throw err;
}
}
public async persistTimeline(
persistTimeline: (
request: FrameworkRequest,
timelineId: string | null,
version: string | null,
timeline: SavedTimeline
): Promise<ResponseTimeline> {
const savedObjectsClient = request.context.core.savedObjects.client;
try {
if (timelineId == null) {
// Create new timeline
const newTimeline = convertSavedObjectToSavedTimeline(
await savedObjectsClient.create(
timelineSavedObjectType,
pickSavedTimeline(timelineId, timeline, request.user)
)
);
return {
code: 200,
message: 'success',
timeline: newTimeline,
};
}
// Update Timeline
await savedObjectsClient.update(
timelineSavedObjectType,
timelineId,
pickSavedTimeline(timelineId, timeline, request.user),
{
version: version || undefined,
}
timeline: SavedTimeline,
timelineType?: TimelineType | null
) => Promise<ResponseTimeline>;
deleteTimeline: (request: FrameworkRequest, timelineIds: string[]) => Promise<void>;
convertStringToBase64: (text: string) => string;
timelineWithReduxProperties: (
notes: NoteSavedObject[],
pinnedEvents: PinnedEventSavedObject[],
timeline: TimelineSavedObject,
userName: string
) => TimelineSavedObject;
}
export const getTimeline = async (
request: FrameworkRequest,
timelineId: string
): Promise<TimelineSavedObject> => {
return getSavedTimeline(request, timelineId);
};
export const getTimelineByTemplateTimelineId = async (
request: FrameworkRequest,
templateTimelineId: string
): Promise<{
totalCount: number;
timeline: TimelineSavedObject[];
}> => {
const options: SavedObjectsFindOptions = {
type: timelineSavedObjectType,
filter: `siem-ui-timeline.attributes.templateTimelineId: ${templateTimelineId}`,
};
return getAllSavedTimeline(request, options);
};
export const getAllTimeline = async (
request: FrameworkRequest,
onlyUserFavorite: boolean | null,
pageInfo: PageInfoTimeline | null,
search: string | null,
sort: SortTimeline | null
): Promise<ResponseTimelines> => {
const options: SavedObjectsFindOptions = {
type: timelineSavedObjectType,
perPage: pageInfo != null ? pageInfo.pageSize : undefined,
page: pageInfo != null ? pageInfo.pageIndex : undefined,
search: search != null ? search : undefined,
searchFields: onlyUserFavorite
? ['title', 'description', 'favorite.keySearch']
: ['title', 'description'],
sortField: sort != null ? sort.sortField : undefined,
sortOrder: sort != null ? sort.sortOrder : undefined,
};
return getAllSavedTimeline(request, options);
};
export const persistFavorite = async (
request: FrameworkRequest,
timelineId: string | null
): Promise<ResponseFavoriteTimeline> => {
const userName = request.user?.username ?? UNAUTHENTICATED_USER;
const fullName = request.user?.full_name ?? '';
try {
let timeline: SavedTimeline = {};
if (timelineId != null) {
const {
eventIdToNoteIds,
notes,
noteIds,
pinnedEventIds,
pinnedEventsSaveObject,
savedObjectId,
version,
...savedTimeline
} = await getBasicSavedTimeline(request, timelineId);
timelineId = savedObjectId; // eslint-disable-line no-param-reassign
timeline = savedTimeline;
}
const userFavoriteTimeline = {
keySearch: userName != null ? convertStringToBase64(userName) : null,
favoriteDate: new Date().valueOf(),
fullName,
userName,
};
if (timeline.favorite != null) {
const alreadyExistsTimelineFavoriteByUser = timeline.favorite.findIndex(
user => user.userName === userName
);
timeline.favorite =
alreadyExistsTimelineFavoriteByUser > -1
? [
...timeline.favorite.slice(0, alreadyExistsTimelineFavoriteByUser),
...timeline.favorite.slice(alreadyExistsTimelineFavoriteByUser + 1),
]
: [...timeline.favorite, userFavoriteTimeline];
} else if (timeline.favorite == null) {
timeline.favorite = [userFavoriteTimeline];
}
const persistResponse = await persistTimeline(request, timelineId, null, timeline);
return {
savedObjectId: persistResponse.timeline.savedObjectId,
version: persistResponse.timeline.version,
favorite:
persistResponse.timeline.favorite != null
? persistResponse.timeline.favorite.filter(fav => fav.userName === userName)
: [],
};
} catch (err) {
if (getOr(null, 'output.statusCode', err) === 403) {
return {
savedObjectId: '',
version: '',
favorite: [],
code: 403,
message: err.message,
};
}
throw err;
}
};
export const persistTimeline = async (
request: FrameworkRequest,
timelineId: string | null,
version: string | null,
timeline: SavedTimeline
): Promise<ResponseTimeline> => {
const savedObjectsClient = request.context.core.savedObjects.client;
try {
if (timelineId == null) {
// Create new timeline
const newTimeline = convertSavedObjectToSavedTimeline(
await savedObjectsClient.create(
timelineSavedObjectType,
pickSavedTimeline(timelineId, timeline, request.user)
)
);
return {
code: 200,
message: 'success',
timeline: await this.getSavedTimeline(request, timelineId),
timeline: newTimeline,
};
} catch (err) {
if (timelineId != null && savedObjectsClient.errors.isConflictError(err)) {
return {
code: 409,
message: err.message,
timeline: await this.getSavedTimeline(request, timelineId),
};
} else if (getOr(null, 'output.statusCode', err) === 403) {
const timelineToReturn: TimelineResult = {
...timeline,
savedObjectId: '',
version: '',
};
return {
code: 403,
message: err.message,
timeline: timelineToReturn,
};
}
// Update Timeline
await savedObjectsClient.update(
timelineSavedObjectType,
timelineId,
pickSavedTimeline(timelineId, timeline, request.user),
{
version: version || undefined,
}
throw err;
}
}
public async deleteTimeline(request: FrameworkRequest, timelineIds: string[]) {
const savedObjectsClient = request.context.core.savedObjects.client;
await Promise.all(
timelineIds.map(timelineId =>
Promise.all([
savedObjectsClient.delete(timelineSavedObjectType, timelineId),
this.note.deleteNoteByTimelineId(request, timelineId),
this.pinnedEvent.deleteAllPinnedEventsOnTimeline(request, timelineId),
])
)
);
}
private async getBasicSavedTimeline(request: FrameworkRequest, timelineId: string) {
const savedObjectsClient = request.context.core.savedObjects.client;
const savedObject = await savedObjectsClient.get(timelineSavedObjectType, timelineId);
return convertSavedObjectToSavedTimeline(savedObject);
}
private async getSavedTimeline(request: FrameworkRequest, timelineId: string) {
const userName = request.user?.username ?? UNAUTHENTICATED_USER;
const savedObjectsClient = request.context.core.savedObjects.client;
const savedObject = await savedObjectsClient.get(timelineSavedObjectType, timelineId);
const timelineSaveObject = convertSavedObjectToSavedTimeline(savedObject);
const timelineWithNotesAndPinnedEvents = await Promise.all([
this.note.getNotesByTimelineId(request, timelineSaveObject.savedObjectId),
this.pinnedEvent.getAllPinnedEventsByTimelineId(request, timelineSaveObject.savedObjectId),
Promise.resolve(timelineSaveObject),
]);
const [notes, pinnedEvents, timeline] = timelineWithNotesAndPinnedEvents;
return timelineWithReduxProperties(notes, pinnedEvents, timeline, userName);
}
private async getAllSavedTimeline(request: FrameworkRequest, options: SavedObjectsFindOptions) {
const userName = request.user?.username ?? UNAUTHENTICATED_USER;
const savedObjectsClient = request.context.core.savedObjects.client;
if (options.searchFields != null && options.searchFields.includes('favorite.keySearch')) {
options.search = `${options.search != null ? options.search : ''} ${
userName != null ? convertStringToBase64(userName) : null
}`;
}
const savedObjects = await savedObjectsClient.find(options);
const timelinesWithNotesAndPinnedEvents = await Promise.all(
savedObjects.saved_objects.map(async savedObject => {
const timelineSaveObject = convertSavedObjectToSavedTimeline(savedObject);
return Promise.all([
this.note.getNotesByTimelineId(request, timelineSaveObject.savedObjectId),
this.pinnedEvent.getAllPinnedEventsByTimelineId(
request,
timelineSaveObject.savedObjectId
),
Promise.resolve(timelineSaveObject),
]);
})
);
return {
totalCount: savedObjects.total,
timeline: timelinesWithNotesAndPinnedEvents.map(([notes, pinnedEvents, timeline]) =>
timelineWithReduxProperties(notes, pinnedEvents, timeline, userName)
),
code: 200,
message: 'success',
timeline: await getSavedTimeline(request, timelineId),
};
} catch (err) {
if (timelineId != null && savedObjectsClient.errors.isConflictError(err)) {
return {
code: 409,
message: err.message,
timeline: await getSavedTimeline(request, timelineId),
};
} else if (getOr(null, 'output.statusCode', err) === 403) {
const timelineToReturn: TimelineResult = {
...timeline,
savedObjectId: '',
version: '',
};
return {
code: 403,
message: err.message,
timeline: timelineToReturn,
};
}
throw err;
}
}
};
export const deleteTimeline = async (request: FrameworkRequest, timelineIds: string[]) => {
const savedObjectsClient = request.context.core.savedObjects.client;
await Promise.all(
timelineIds.map(timelineId =>
Promise.all([
savedObjectsClient.delete(timelineSavedObjectType, timelineId),
note.deleteNoteByTimelineId(request, timelineId),
pinnedEvent.deleteAllPinnedEventsOnTimeline(request, timelineId),
])
)
);
};
const getBasicSavedTimeline = async (request: FrameworkRequest, timelineId: string) => {
const savedObjectsClient = request.context.core.savedObjects.client;
const savedObject = await savedObjectsClient.get(timelineSavedObjectType, timelineId);
return convertSavedObjectToSavedTimeline(savedObject);
};
const getSavedTimeline = async (request: FrameworkRequest, timelineId: string) => {
const userName = request.user?.username ?? UNAUTHENTICATED_USER;
const savedObjectsClient = request.context.core.savedObjects.client;
const savedObject = await savedObjectsClient.get(timelineSavedObjectType, timelineId);
const timelineSaveObject = convertSavedObjectToSavedTimeline(savedObject);
const timelineWithNotesAndPinnedEvents = await Promise.all([
note.getNotesByTimelineId(request, timelineSaveObject.savedObjectId),
pinnedEvent.getAllPinnedEventsByTimelineId(request, timelineSaveObject.savedObjectId),
Promise.resolve(timelineSaveObject),
]);
const [notes, pinnedEvents, timeline] = timelineWithNotesAndPinnedEvents;
return timelineWithReduxProperties(notes, pinnedEvents, timeline, userName);
};
const getAllSavedTimeline = async (request: FrameworkRequest, options: SavedObjectsFindOptions) => {
const userName = request.user?.username ?? UNAUTHENTICATED_USER;
const savedObjectsClient = request.context.core.savedObjects.client;
if (options.searchFields != null && options.searchFields.includes('favorite.keySearch')) {
options.search = `${options.search != null ? options.search : ''} ${
userName != null ? convertStringToBase64(userName) : null
}`;
}
const savedObjects = await savedObjectsClient.find(options);
const timelinesWithNotesAndPinnedEvents = await Promise.all(
savedObjects.saved_objects.map(async savedObject => {
const timelineSaveObject = convertSavedObjectToSavedTimeline(savedObject);
return Promise.all([
note.getNotesByTimelineId(request, timelineSaveObject.savedObjectId),
pinnedEvent.getAllPinnedEventsByTimelineId(request, timelineSaveObject.savedObjectId),
Promise.resolve(timelineSaveObject),
]);
})
);
return {
totalCount: savedObjects.total,
timeline: timelinesWithNotesAndPinnedEvents.map(([notes, pinnedEvents, timeline]) =>
timelineWithReduxProperties(notes, pinnedEvents, timeline, userName)
),
};
};
export const convertStringToBase64 = (text: string): string => Buffer.from(text).toString('base64');
@ -283,11 +331,9 @@ export const timelineWithReduxProperties = (
timeline.favorite != null && userName != null
? timeline.favorite.filter(fav => fav.userName === userName)
: [],
eventIdToNoteIds: notes.filter(note => note.eventId != null),
noteIds: notes
.filter(note => note.eventId == null && note.noteId != null)
.map(note => note.noteId),
eventIdToNoteIds: notes.filter(n => n.eventId != null),
noteIds: notes.filter(n => n.eventId == null && n.noteId != null).map(n => n.noteId),
notes,
pinnedEventIds: pinnedEvents.map(pinnedEvent => pinnedEvent.eventId),
pinnedEventIds: pinnedEvents.map(e => e.eventId),
pinnedEventsSaveObject: pinnedEvents,
});

View file

@ -231,6 +231,15 @@ export const timelineSavedObjectMappings = {
title: {
type: 'text',
},
templateTimelineId: {
type: 'text',
},
templateTimelineVersion: {
type: 'integer',
},
timelineType: {
type: 'keyword',
},
dateRange: {
properties: {
start: {

View file

@ -30,6 +30,8 @@ import { findRulesStatusesRoute } from '../lib/detection_engine/routes/rules/fin
import { getPrepackagedRulesStatusRoute } from '../lib/detection_engine/routes/rules/get_prepackaged_rules_status_route';
import { importTimelinesRoute } from '../lib/timeline/routes/import_timelines_route';
import { exportTimelinesRoute } from '../lib/timeline/routes/export_timelines_route';
import { createTimelinesRoute } from '../lib/timeline/routes/create_timelines_route';
import { updateTimelinesRoute } from '../lib/timeline/routes/update_timelines_route';
import { SetupPlugins } from '../plugin';
import { ConfigType } from '../config';
@ -55,6 +57,8 @@ export const initRoutes = (
patchRulesBulkRoute(router);
deleteRulesBulkRoute(router);
createTimelinesRoute(router, config, security);
updateTimelinesRoute(router, config, security);
importRulesRoute(router, config);
exportRulesRoute(router, config);

View file

@ -4,7 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
import * as runtimeTypes from 'io-ts';
import { GraphQLResolveInfo } from 'graphql';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -106,6 +105,3 @@ export type ChildResolverOf<Resolver_, ParentResolver> = ResolverWithParent<
Resolver_,
ResultOf<ParentResolver>
>;
export const unionWithNullType = <T extends runtimeTypes.Mixed>(type: T) =>
runtimeTypes.union([type, runtimeTypes.null]);