[8.x] [Security Solution][Notes] - allow filtering by note association (#195501) (#196508)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Security Solution][Notes] - allow filtering by note association
(#195501)](https://github.com/elastic/kibana/pull/195501)

<!--- Backport version: 9.4.3 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Philippe
Oberti","email":"philippe.oberti@elastic.co"},"sourceCommit":{"committedDate":"2024-10-16T09:32:51Z","message":"[Security
Solution][Notes] - allow filtering by note association
(#195501)","sha":"66708b26c5dd2918692d77da81edcd1d3836cec5","branchLabelMapping":{"^v9.0.0$":"main","^v8.16.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["backport","release_note:skip","v9.0.0","Team:Threat
Hunting:Investigations","v8.16.0"],"title":"[Security Solution][Notes] -
allow filtering by note
association","number":195501,"url":"https://github.com/elastic/kibana/pull/195501","mergeCommit":{"message":"[Security
Solution][Notes] - allow filtering by note association
(#195501)","sha":"66708b26c5dd2918692d77da81edcd1d3836cec5"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/195501","number":195501,"mergeCommit":{"message":"[Security
Solution][Notes] - allow filtering by note association
(#195501)","sha":"66708b26c5dd2918692d77da81edcd1d3836cec5"}},{"branch":"8.x","label":"v8.16.0","branchLabelMappingKey":"^v8.16.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->

Co-authored-by: Philippe Oberti <philippe.oberti@elastic.co>
This commit is contained in:
Kibana Machine 2024-10-17 01:24:28 +11:00 committed by GitHub
parent 173b5259df
commit 74b1ca6df2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 409 additions and 40 deletions

View file

@ -15016,6 +15016,10 @@ paths:
schema:
nullable: true
type: string
- in: query
name: associatedFilter
schema:
$ref: '#/components/schemas/Security_Timeline_API_AssociatedFilterType'
responses:
'200':
content:
@ -31680,6 +31684,14 @@ components:
Security_Osquery_API_VersionOrUndefined:
$ref: '#/components/schemas/Security_Osquery_API_Version'
nullable: true
Security_Timeline_API_AssociatedFilterType:
description: Filter notes based on their association with a document or saved object.
enum:
- document_only
- saved_object_only
- document_and_saved_object
- orphan
type: string
Security_Timeline_API_BareNote:
type: object
properties:

View file

@ -15016,6 +15016,10 @@ paths:
schema:
nullable: true
type: string
- in: query
name: associatedFilter
schema:
$ref: '#/components/schemas/Security_Timeline_API_AssociatedFilterType'
responses:
'200':
content:
@ -31680,6 +31684,14 @@ components:
Security_Osquery_API_VersionOrUndefined:
$ref: '#/components/schemas/Security_Osquery_API_Version'
nullable: true
Security_Timeline_API_AssociatedFilterType:
description: Filter notes based on their association with a document or saved object.
enum:
- document_only
- saved_object_only
- document_and_saved_object
- orphan
type: string
Security_Timeline_API_BareNote:
type: object
properties:

View file

@ -18446,6 +18446,10 @@ paths:
schema:
nullable: true
type: string
- in: query
name: associatedFilter
schema:
$ref: '#/components/schemas/Security_Timeline_API_AssociatedFilterType'
responses:
'200':
content:
@ -40445,6 +40449,14 @@ components:
Security_Osquery_API_VersionOrUndefined:
$ref: '#/components/schemas/Security_Osquery_API_Version'
nullable: true
Security_Timeline_API_AssociatedFilterType:
description: Filter notes based on their association with a document or saved object.
enum:
- document_only
- saved_object_only
- document_and_saved_object
- orphan
type: string
Security_Timeline_API_BareNote:
type: object
properties:

View file

@ -18446,6 +18446,10 @@ paths:
schema:
nullable: true
type: string
- in: query
name: associatedFilter
schema:
$ref: '#/components/schemas/Security_Timeline_API_AssociatedFilterType'
responses:
'200':
content:
@ -40445,6 +40449,14 @@ components:
Security_Osquery_API_VersionOrUndefined:
$ref: '#/components/schemas/Security_Osquery_API_Version'
nullable: true
Security_Timeline_API_AssociatedFilterType:
description: Filter notes based on their association with a document or saved object.
enum:
- document_only
- saved_object_only
- document_and_saved_object
- orphan
type: string
Security_Timeline_API_BareNote:
type: object
properties:

View file

@ -18,6 +18,19 @@ import { z } from '@kbn/zod';
import { Note } from '../model/components.gen';
/**
* Filter notes based on their association with a document or saved object.
*/
export type AssociatedFilterType = z.infer<typeof AssociatedFilterType>;
export const AssociatedFilterType = z.enum([
'document_only',
'saved_object_only',
'document_and_saved_object',
'orphan',
]);
export type AssociatedFilterTypeEnum = typeof AssociatedFilterType.enum;
export const AssociatedFilterTypeEnum = AssociatedFilterType.enum;
export type DocumentIds = z.infer<typeof DocumentIds>;
export const DocumentIds = z.union([z.array(z.string()), z.string()]);
@ -41,6 +54,7 @@ export const GetNotesRequestQuery = z.object({
sortOrder: z.string().nullable().optional(),
filter: z.string().nullable().optional(),
userFilter: z.string().nullable().optional(),
associatedFilter: AssociatedFilterType.optional(),
});
export type GetNotesRequestQueryInput = z.input<typeof GetNotesRequestQuery>;

View file

@ -56,6 +56,10 @@ paths:
schema:
nullable: true
type: string
- name: associatedFilter
in: query
schema:
$ref: '#/components/schemas/AssociatedFilterType'
responses:
'200':
description: Indicates the requested notes were returned.
@ -68,6 +72,14 @@ paths:
components:
schemas:
AssociatedFilterType:
type: string
enum:
- document_only
- saved_object_only
- document_and_saved_object
- orphan
description: Filter notes based on their association with a document or saved object.
DocumentIds:
oneOf:
- type: array

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export enum AssociatedFilter {
all = 'all',
documentOnly = 'document_only',
savedObjectOnly = 'saved_object_only',
documentAndSavedObject = 'document_and_saved_object',
orphan = 'orphan',
}

View file

@ -101,6 +101,10 @@ paths:
schema:
nullable: true
type: string
- in: query
name: associatedFilter
schema:
$ref: '#/components/schemas/AssociatedFilterType'
responses:
'200':
content:
@ -908,6 +912,14 @@ paths:
- 'access:securitySolution'
components:
schemas:
AssociatedFilterType:
description: Filter notes based on their association with a document or saved object.
enum:
- document_only
- saved_object_only
- document_and_saved_object
- orphan
type: string
BareNote:
type: object
properties:

View file

@ -101,6 +101,10 @@ paths:
schema:
nullable: true
type: string
- in: query
name: associatedFilter
schema:
$ref: '#/components/schemas/AssociatedFilterType'
responses:
'200':
content:
@ -908,6 +912,14 @@ paths:
- 'access:securitySolution'
components:
schemas:
AssociatedFilterType:
description: Filter notes based on their association with a document or saved object.
enum:
- document_only
- saved_object_only
- document_and_saved_object
- orphan
type: string
BareNote:
type: object
properties:

View file

@ -7,6 +7,7 @@
import { TableId } from '@kbn/securitysolution-data-table';
import type { DataViewSpec } from '@kbn/data-views-plugin/public';
import { AssociatedFilter } from '../../../common/notes/constants';
import { ReqStatus } from '../../notes/store/notes.slice';
import { HostsFields } from '../../../common/api/search_strategy/hosts/model/sort';
import { InputsModelId } from '../store/inputs/constants';
@ -550,6 +551,7 @@ export const mockGlobalState: State = {
},
filter: '',
userFilter: '',
associatedFilter: AssociatedFilter.all,
search: '',
selectedIds: [],
pendingDeleteIds: [],

View file

@ -11,6 +11,7 @@ import type {
GetNotesResponse,
PersistNoteRouteResponse,
} from '../../../common/api/timeline';
import type { AssociatedFilter } from '../../../common/notes/constants';
import { KibanaServices } from '../../common/lib/kibana';
import { NOTE_URL } from '../../../common/constants';
@ -43,6 +44,7 @@ export const fetchNotes = async ({
sortOrder,
filter,
userFilter,
associatedFilter,
search,
}: {
page: number;
@ -51,6 +53,7 @@ export const fetchNotes = async ({
sortOrder: string;
filter: string;
userFilter: string;
associatedFilter: AssociatedFilter;
search: string;
}) => {
const response = await KibanaServices.get().http.get<GetNotesResponse>(NOTE_URL, {
@ -61,6 +64,7 @@ export const fetchNotes = async ({
sortOrder,
filter,
userFilter,
associatedFilter,
search,
},
version: '2023-10-31',

View file

@ -9,7 +9,8 @@ import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { SearchRow } from './search_row';
import { SEARCH_BAR_TEST_ID, USER_SELECT_TEST_ID } from './test_ids';
import { ASSOCIATED_NOT_SELECT_TEST_ID, SEARCH_BAR_TEST_ID, USER_SELECT_TEST_ID } from './test_ids';
import { AssociatedFilter } from '../../../common/notes/constants';
import { useSuggestUsers } from '../../common/components/user_profiles/use_suggest_users';
jest.mock('../../common/components/user_profiles/use_suggest_users');
@ -38,6 +39,7 @@ describe('SearchRow', () => {
expect(getByTestId(SEARCH_BAR_TEST_ID)).toBeInTheDocument();
expect(getByTestId(USER_SELECT_TEST_ID)).toBeInTheDocument();
expect(getByTestId(ASSOCIATED_NOT_SELECT_TEST_ID)).toBeInTheDocument();
});
it('should call the correct action when entering a value in the search bar', async () => {
@ -62,4 +64,13 @@ describe('SearchRow', () => {
expect(mockDispatch).toHaveBeenCalled();
});
it('should call the correct action when select a value in the associated note dropdown', async () => {
const { getByTestId } = render(<SearchRow />);
const associatedNoteSelect = getByTestId(ASSOCIATED_NOT_SELECT_TEST_ID);
await userEvent.selectOptions(associatedNoteSelect, [AssociatedFilter.documentOnly]);
expect(mockDispatch).toHaveBeenCalled();
});
});

View file

@ -5,30 +5,48 @@
* 2.0.
*/
import { EuiComboBox, EuiFlexGroup, EuiFlexItem, EuiSearchBar } from '@elastic/eui';
import React, { useMemo, useCallback, useState } from 'react';
import type { EuiSelectOption } from '@elastic/eui';
import {
EuiComboBox,
EuiFlexGroup,
EuiFlexItem,
EuiSearchBar,
EuiSelect,
useGeneratedHtmlId,
} from '@elastic/eui';
import { useDispatch } from 'react-redux';
import type { UserProfileWithAvatar } from '@kbn/user-profile-components';
import { i18n } from '@kbn/i18n';
import type { EuiComboBoxOptionOption } from '@elastic/eui/src/components/combo_box/types';
import { SEARCH_BAR_TEST_ID, USER_SELECT_TEST_ID } from './test_ids';
import { ASSOCIATED_NOT_SELECT_TEST_ID, SEARCH_BAR_TEST_ID, USER_SELECT_TEST_ID } from './test_ids';
import { useSuggestUsers } from '../../common/components/user_profiles/use_suggest_users';
import { userFilterUsers, userSearchedNotes } from '..';
import { userFilterAssociatedNotes, userFilterUsers, userSearchedNotes } from '..';
import { AssociatedFilter } from '../../../common/notes/constants';
export const USERS_DROPDOWN = i18n.translate('xpack.securitySolution.notes.usersDropdownLabel', {
defaultMessage: 'Users',
});
const FILTER_SELECT = i18n.translate('xpack.securitySolution.notes.management.filterSelect', {
defaultMessage: 'Select filter',
});
const searchBox = {
placeholder: 'Search note contents',
incremental: false,
'data-test-subj': SEARCH_BAR_TEST_ID,
};
const associatedNoteSelectOptions: EuiSelectOption[] = [
{ value: AssociatedFilter.all, text: 'All' },
{ value: AssociatedFilter.documentOnly, text: 'Attached to document only' },
{ value: AssociatedFilter.savedObjectOnly, text: 'Attached to timeline only' },
{ value: AssociatedFilter.documentAndSavedObject, text: 'Attached to document and timeline' },
{ value: AssociatedFilter.orphan, text: 'Orphan' },
];
export const SearchRow = React.memo(() => {
const dispatch = useDispatch();
const searchBox = useMemo(
() => ({
placeholder: 'Search note contents',
incremental: false,
'data-test-subj': SEARCH_BAR_TEST_ID,
}),
[]
);
const associatedSelectId = useGeneratedHtmlId({ prefix: 'associatedSelectId' });
const onQueryChange = useCallback(
({ queryText }: { queryText: string }) => {
@ -57,6 +75,13 @@ export const SearchRow = React.memo(() => {
[dispatch]
);
const onAssociatedNoteSelectChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
dispatch(userFilterAssociatedNotes(e.target.value as AssociatedFilter));
},
[dispatch]
);
return (
<EuiFlexGroup gutterSize="m">
<EuiFlexItem>
@ -73,6 +98,16 @@ export const SearchRow = React.memo(() => {
data-test-subj={USER_SELECT_TEST_ID}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSelect
id={associatedSelectId}
options={associatedNoteSelectOptions}
onChange={onAssociatedNoteSelectChange}
prepend={FILTER_SELECT}
aria-label={FILTER_SELECT}
data-test-subj={ASSOCIATED_NOT_SELECT_TEST_ID}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
});

View file

@ -21,3 +21,4 @@ export const NOTE_CONTENT_BUTTON_TEST_ID = `${PREFIX}NoteContentButton` as const
export const NOTE_CONTENT_POPOVER_TEST_ID = `${PREFIX}NoteContentPopover` as const;
export const SEARCH_BAR_TEST_ID = `${PREFIX}SearchBar` as const;
export const USER_SELECT_TEST_ID = `${PREFIX}UserSelect` as const;
export const ASSOCIATED_NOT_SELECT_TEST_ID = `${PREFIX}AssociatedNoteSelect` as const;

View file

@ -24,6 +24,7 @@ import {
selectNotesTableSearch,
userSelectedBulkDelete,
selectNotesTableUserFilters,
selectNotesTableAssociatedFilter,
} from '..';
export const BATCH_ACTIONS = i18n.translate(
@ -53,6 +54,7 @@ export const NotesUtilityBar = React.memo(() => {
const sort = useSelector(selectNotesTableSort);
const selectedItems = useSelector(selectNotesTableSelectedIds);
const notesUserFilters = useSelector(selectNotesTableUserFilters);
const notesAssociatedFilters = useSelector(selectNotesTableAssociatedFilter);
const resultsCount = useMemo(() => {
const { perPage, page, total } = pagination;
const startOfCurrentPage = perPage * (page - 1) + 1;
@ -86,6 +88,7 @@ export const NotesUtilityBar = React.memo(() => {
sortOrder: sort.direction,
filter: '',
userFilter: notesUserFilters,
associatedFilter: notesAssociatedFilters,
search: notesSearch,
})
);
@ -96,6 +99,7 @@ export const NotesUtilityBar = React.memo(() => {
sort.field,
sort.direction,
notesUserFilters,
notesAssociatedFilters,
notesSearch,
]);
return (

View file

@ -37,6 +37,7 @@ import {
selectFetchNotesError,
ReqStatus,
selectNotesTableUserFilters,
selectNotesTableAssociatedFilter,
} from '..';
import type { NotesState } from '..';
import { SearchRow } from '../components/search_row';
@ -121,6 +122,7 @@ export const NoteManagementPage = () => {
const sort = useSelector(selectNotesTableSort);
const notesSearch = useSelector(selectNotesTableSearch);
const notesUserFilters = useSelector(selectNotesTableUserFilters);
const notesAssociatedFilters = useSelector(selectNotesTableAssociatedFilter);
const pendingDeleteIds = useSelector(selectNotesTablePendingDeleteIds);
const isDeleteModalVisible = pendingDeleteIds.length > 0;
const fetchNotesStatus = useSelector(selectFetchNotesStatus);
@ -137,6 +139,7 @@ export const NoteManagementPage = () => {
sortOrder: sort.direction,
filter: '',
userFilter: notesUserFilters,
associatedFilter: notesAssociatedFilters,
search: notesSearch,
})
);
@ -147,6 +150,7 @@ export const NoteManagementPage = () => {
sort.field,
sort.direction,
notesUserFilters,
notesAssociatedFilters,
notesSearch,
]);

View file

@ -5,13 +5,15 @@
* 2.0.
*/
import * as uuid from 'uuid';
import { miniSerializeError } from '@reduxjs/toolkit';
import type { SerializedError } from '@reduxjs/toolkit';
import { miniSerializeError } from '@reduxjs/toolkit';
import type { NotesState } from './notes.slice';
import {
createNote,
deleteNotes,
fetchNotesByDocumentIds,
fetchNotes,
fetchNotesByDocumentIds,
fetchNotesBySavedObjectIds,
initialNotesState,
notesReducer,
ReqStatus,
@ -20,6 +22,7 @@ import {
selectCreateNoteStatus,
selectDeleteNotesError,
selectDeleteNotesStatus,
selectDocumentNotesBySavedObjectId,
selectFetchNotesByDocumentIdsError,
selectFetchNotesByDocumentIdsStatus,
selectFetchNotesError,
@ -27,12 +30,16 @@ import {
selectNoteById,
selectNoteIds,
selectNotesByDocumentId,
selectDocumentNotesBySavedObjectId,
selectNotesBySavedObjectId,
selectNotesPagination,
selectNotesTablePendingDeleteIds,
selectNotesTableSearch,
selectNotesTableSelectedIds,
selectNotesTableSort,
selectSortedNotesByDocumentId,
selectSortedNotesBySavedObjectId,
selectNotesTableUserFilters,
selectNotesTableAssociatedFilter,
userClosedDeleteModal,
userFilteredNotes,
userSearchedNotes,
@ -42,17 +49,13 @@ import {
userSelectedRow,
userSelectedNotesForDeletion,
userSortedNotes,
selectSortedNotesByDocumentId,
fetchNotesBySavedObjectIds,
selectNotesBySavedObjectId,
selectSortedNotesBySavedObjectId,
userFilterUsers,
selectNotesTableUserFilters,
userClosedCreateErrorToast,
userFilterAssociatedNotes,
} from './notes.slice';
import type { NotesState } from './notes.slice';
import { mockGlobalState } from '../../common/mock';
import type { Note } from '../../../common/api/timeline';
import { AssociatedFilter } from '../../../common/notes/constants';
const initalEmptyState = initialNotesState;
@ -102,6 +105,7 @@ const initialNonEmptyState: NotesState = {
},
filter: '',
userFilter: '',
associatedFilter: AssociatedFilter.all,
search: '',
selectedIds: [],
pendingDeleteIds: [],
@ -515,6 +519,17 @@ describe('notesSlice', () => {
});
});
describe('userFilterAssociatedNotes', () => {
it('should set correct value to filter associated notes', () => {
const action = { type: userFilterAssociatedNotes.type, payload: 'abc' };
expect(notesReducer(initalEmptyState, action)).toEqual({
...initalEmptyState,
associatedFilter: 'abc',
});
});
});
describe('userSearchedNotes', () => {
it('should set correct value to search notes', () => {
const action = { type: userSearchedNotes.type, payload: 'abc' };
@ -851,7 +866,7 @@ describe('notesSlice', () => {
expect(selectNotesTableSearch(state)).toBe('test search');
});
it('should select associated filter', () => {
it('should select user filter', () => {
const state = {
...mockGlobalState,
notes: { ...initialNotesState, userFilter: 'abc' },
@ -859,6 +874,14 @@ describe('notesSlice', () => {
expect(selectNotesTableUserFilters(state)).toBe('abc');
});
it('should select associated filter', () => {
const state = {
...mockGlobalState,
notes: { ...initialNotesState, associatedFilter: AssociatedFilter.all },
};
expect(selectNotesTableAssociatedFilter(state)).toBe(AssociatedFilter.all);
});
it('should select notes table pending delete ids', () => {
const state = {
...mockGlobalState,

View file

@ -8,6 +8,7 @@
import type { EntityState, SerializedError } from '@reduxjs/toolkit';
import { createAsyncThunk, createEntityAdapter, createSlice } from '@reduxjs/toolkit';
import { createSelector } from 'reselect';
import { AssociatedFilter } from '../../../common/notes/constants';
import type { State } from '../../common/store';
import {
createNote as createNoteApi,
@ -59,6 +60,7 @@ export interface NotesState extends EntityState<Note> {
filter: string;
userFilter: string;
search: string;
associatedFilter: AssociatedFilter;
selectedIds: string[];
pendingDeleteIds: string[];
}
@ -93,6 +95,7 @@ export const initialNotesState: NotesState = notesAdapter.getInitialState({
},
filter: '',
userFilter: '',
associatedFilter: AssociatedFilter.all,
search: '',
selectedIds: [],
pendingDeleteIds: [],
@ -127,11 +130,13 @@ export const fetchNotes = createAsyncThunk<
sortOrder: string;
filter: string;
userFilter: string;
associatedFilter: AssociatedFilter;
search: string;
},
{}
>('notes/fetchNotes', async (args) => {
const { page, perPage, sortField, sortOrder, filter, userFilter, search } = args;
const { page, perPage, sortField, sortOrder, filter, userFilter, associatedFilter, search } =
args;
const res = await fetchNotesApi({
page,
perPage,
@ -139,6 +144,7 @@ export const fetchNotes = createAsyncThunk<
sortOrder,
filter,
userFilter,
associatedFilter,
search,
});
return {
@ -163,7 +169,7 @@ export const deleteNotes = createAsyncThunk<string[], { ids: string[]; refetch?:
await deleteNotesApi(ids);
if (refetch) {
const state = getState() as State;
const { search, pagination, userFilter, sort } = state.notes;
const { search, pagination, userFilter, associatedFilter, sort } = state.notes;
dispatch(
fetchNotes({
page: pagination.page,
@ -172,6 +178,7 @@ export const deleteNotes = createAsyncThunk<string[], { ids: string[]; refetch?:
sortOrder: sort.direction,
filter: '',
userFilter,
associatedFilter,
search,
})
);
@ -202,6 +209,9 @@ const notesSlice = createSlice({
userFilterUsers: (state: NotesState, action: { payload: string }) => {
state.userFilter = action.payload;
},
userFilterAssociatedNotes: (state: NotesState, action: { payload: AssociatedFilter }) => {
state.associatedFilter = action.payload;
},
userSearchedNotes: (state: NotesState, action: { payload: string }) => {
state.search = action.payload;
},
@ -324,6 +334,8 @@ export const selectNotesTableSearch = (state: State) => state.notes.search;
export const selectNotesTableUserFilters = (state: State) => state.notes.userFilter;
export const selectNotesTableAssociatedFilter = (state: State) => state.notes.associatedFilter;
export const selectNotesTablePendingDeleteIds = (state: State) => state.notes.pendingDeleteIds;
export const selectFetchNotesError = (state: State) => state.notes.error.fetchNotes;
@ -412,6 +424,7 @@ export const {
userSortedNotes,
userFilteredNotes,
userFilterUsers,
userFilterAssociatedNotes,
userSearchedNotes,
userSelectedRow,
userClosedDeleteModal,

View file

@ -9,9 +9,13 @@ import type { IKibanaResponse } from '@kbn/core-http-server';
import { transformError } from '@kbn/securitysolution-es-utils';
import type { SortOrder } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { buildRouteValidationWithZod } from '@kbn/zod-helpers';
import type { SavedObjectsFindOptions } from '@kbn/core-saved-objects-api-server';
import { nodeBuilder } from '@kbn/es-query';
import type {
SavedObjectsFindOptions,
SavedObjectsFindOptionsReference,
} from '@kbn/core-saved-objects-api-server';
import type { KueryNode } from '@kbn/es-query';
import { nodeBuilder, nodeTypes } from '@kbn/es-query';
import { AssociatedFilter } from '../../../../../common/notes/constants';
import { timelineSavedObjectType } from '../../saved_object_mappings';
import type { SecuritySolutionPluginRouter } from '../../../../types';
import { MAX_UNASSOCIATED_NOTES, NOTE_URL } from '../../../../../common/constants';
@ -22,6 +26,7 @@ import { getAllSavedNote } from '../../saved_object/notes';
import { noteSavedObjectType } from '../../saved_object_mappings/notes';
import { GetNotesRequestQuery, type GetNotesResponse } from '../../../../../common/api/timeline';
/* eslint-disable complexity */
export const getNotesRoute = (router: SecuritySolutionPluginRouter) => {
router.versioned
.get({
@ -128,21 +133,70 @@ export const getNotesRoute = (router: SecuritySolutionPluginRouter) => {
filter,
};
// we need to combine the associatedFilter with the filter query
// we have to type case here because the filter is a string (from the schema) and that cannot be changed as it would be a breaking change
const filterAsKueryNode: KueryNode = (filter || '') as unknown as KueryNode;
const filterKueryNodeArray = [filterAsKueryNode];
// retrieve all the notes created by a specific user
const userFilter = queryParams?.userFilter;
if (userFilter) {
// we need to combine the associatedFilter with the filter query
// we have to type case here because the filter is a string (from the schema) and that cannot be changed as it would be a breaking change
const filterAsKueryNode: KueryNode = (filter || '') as unknown as KueryNode;
options.filter = nodeBuilder.and([
nodeBuilder.is(`${noteSavedObjectType}.attributes.createdBy`, userFilter),
filterAsKueryNode,
]);
} else {
options.filter = filter;
filterKueryNodeArray.push(
nodeBuilder.is(`${noteSavedObjectType}.attributes.createdBy`, userFilter)
);
}
const associatedFilter = queryParams?.associatedFilter;
if (associatedFilter) {
// select documents that have or don't have a reference to an empty value
// used in combination with hasReference (not associated with a timeline) or hasNoReference (associated with a timeline)
const referenceToATimeline: SavedObjectsFindOptionsReference = {
type: timelineSavedObjectType,
id: '',
};
// select documents that don't have a value in the eventId field (not associated with a document)
const emptyDocumentIdFilter: KueryNode = nodeBuilder.is(
`${noteSavedObjectType}.attributes.eventId`,
''
);
switch (associatedFilter) {
case AssociatedFilter.documentOnly:
// select documents that have a reference to an empty saved object id (not associated with a timeline)
// and have a value in the eventId field (associated with a document)
options.hasReference = referenceToATimeline;
filterKueryNodeArray.push(
nodeTypes.function.buildNode('not', emptyDocumentIdFilter)
);
break;
case AssociatedFilter.savedObjectOnly:
// select documents that don't have a reference to an empty saved object id (associated with a timeline)
// and don't have a value in the eventId field (not associated with a document)
options.hasNoReference = referenceToATimeline;
filterKueryNodeArray.push(emptyDocumentIdFilter);
break;
case AssociatedFilter.documentAndSavedObject:
// select documents that don't have a reference to an empty saved object id (associated with a timeline)
// and have a value in the eventId field (associated with a document)
options.hasNoReference = referenceToATimeline;
filterKueryNodeArray.push(
nodeTypes.function.buildNode('not', emptyDocumentIdFilter)
);
break;
case AssociatedFilter.orphan:
// select documents that have a reference to an empty saved object id (not associated with a timeline)
// and don't have a value in the eventId field (not associated with a document)
options.hasReference = referenceToATimeline;
// TODO we might want to also check for the existence of the eventId field, on top of getting eventId having empty values
filterKueryNodeArray.push(emptyDocumentIdFilter);
break;
}
}
// combine all filters
options.filter = nodeBuilder.and(filterKueryNodeArray);
const res = await getAllSavedNote(frameworkRequest, options);
const body: GetNotesResponse = res ?? {};
return response.ok({ body });

View file

@ -408,11 +408,7 @@ export default function ({ getService }: FtrProviderContext) {
expect(notes[2].eventId).to.be('1');
});
// TODO should add more tests for the filter query parameter (I don't know how it's supposed to work)
// TODO should add more tests for the MAX_UNASSOCIATED_NOTES advanced settings values
// TODO figure out why this test is failing on CI but not locally
// we can't really test for other users because the persistNote endpoint forces overrideOwner to be true then all the notes created here are owned by the elastic user
it.skip('should retrieve all notes that have been created by a specific user', async () => {
await Promise.all([
createNote(supertest, { text: 'first note' }),
@ -444,6 +440,116 @@ export default function ({ getService }: FtrProviderContext) {
expect(totalCount).to.be(0);
});
it('should retrieve all notes that have an association with a document only', async () => {
await Promise.all([
createNote(supertest, { documentId: eventId1, text: 'associated with event-1 only' }),
createNote(supertest, {
savedObjectId: timelineId1,
text: 'associated with timeline-1 only',
}),
createNote(supertest, {
documentId: eventId1,
savedObjectId: timelineId1,
text: 'associated with event-1 and timeline-1',
}),
createNote(supertest, { text: 'associated with nothing' }),
]);
const response = await supertest
.get('/api/note?associatedFilter=document_only')
.set('kbn-xsrf', 'true')
.set('elastic-api-version', '2023-10-31');
const { totalCount, notes } = response.body;
expect(totalCount).to.be(1);
expect(notes[0].eventId).to.be(eventId1);
});
it('should retrieve all notes that have an association with a saved object only', async () => {
await Promise.all([
createNote(supertest, { documentId: eventId1, text: 'associated with event-1 only' }),
createNote(supertest, {
savedObjectId: timelineId1,
text: 'associated with timeline-1 only',
}),
createNote(supertest, {
documentId: eventId1,
savedObjectId: timelineId1,
text: 'associated with event-1 and timeline-1',
}),
createNote(supertest, { text: 'associated with nothing' }),
]);
const response = await supertest
.get('/api/note?associatedFilter=saved_object_only')
.set('kbn-xsrf', 'true')
.set('elastic-api-version', '2023-10-31');
const { totalCount, notes } = response.body;
expect(totalCount).to.be(1);
expect(notes[0].timelineId).to.be(timelineId1);
});
it('should retrieve all notes that have an association with a document AND a saved object', async () => {
await Promise.all([
createNote(supertest, { documentId: eventId1, text: 'associated with event-1 only' }),
createNote(supertest, {
savedObjectId: timelineId1,
text: 'associated with timeline-1 only',
}),
createNote(supertest, {
documentId: eventId1,
savedObjectId: timelineId1,
text: 'associated with event-1 and timeline-1',
}),
createNote(supertest, { text: 'associated with nothing' }),
]);
const response = await supertest
.get('/api/note?associatedFilter=document_and_saved_object')
.set('kbn-xsrf', 'true')
.set('elastic-api-version', '2023-10-31');
const { totalCount, notes } = response.body;
expect(totalCount).to.be(1);
expect(notes[0].eventId).to.be(eventId1);
expect(notes[0].timelineId).to.be(timelineId1);
});
it('should retrieve all notes that have an association with no document AND no saved object', async () => {
await Promise.all([
createNote(supertest, { documentId: eventId1, text: 'associated with event-1 only' }),
createNote(supertest, {
savedObjectId: timelineId1,
text: 'associated with timeline-1 only',
}),
createNote(supertest, {
documentId: eventId1,
savedObjectId: timelineId1,
text: 'associated with event-1 and timeline-1',
}),
createNote(supertest, { text: 'associated with nothing' }),
]);
const response = await supertest
.get('/api/note?associatedFilter=orphan')
.set('kbn-xsrf', 'true')
.set('elastic-api-version', '2023-10-31');
const { totalCount, notes } = response.body;
expect(totalCount).to.be(1);
expect(notes[0].eventId).to.be('');
expect(notes[0].timelineId).to.be('');
});
// TODO should add more tests for the filter query parameter (I don't know how it's supposed to work)
// TODO should add more tests for the MAX_UNASSOCIATED_NOTES advanced settings values
// TODO add more tests to check the combination of filters (user, association and filter)
});
});
}