diff --git a/oas_docs/output/kibana.serverless.staging.yaml b/oas_docs/output/kibana.serverless.staging.yaml index d7b1b6d02323a..1ee5e2e149a1f 100644 --- a/oas_docs/output/kibana.serverless.staging.yaml +++ b/oas_docs/output/kibana.serverless.staging.yaml @@ -35266,6 +35266,10 @@ paths: schema: nullable: true type: string + - in: query + name: associatedFilter + schema: + $ref: '#/components/schemas/Security_Timeline_API_AssociatedFilterType' responses: '200': content: @@ -49419,6 +49423,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: diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml index d7b1b6d02323a..1ee5e2e149a1f 100644 --- a/oas_docs/output/kibana.serverless.yaml +++ b/oas_docs/output/kibana.serverless.yaml @@ -35266,6 +35266,10 @@ paths: schema: nullable: true type: string + - in: query + name: associatedFilter + schema: + $ref: '#/components/schemas/Security_Timeline_API_AssociatedFilterType' responses: '200': content: @@ -49419,6 +49423,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: diff --git a/oas_docs/output/kibana.staging.yaml b/oas_docs/output/kibana.staging.yaml index 24b0462ae93ef..8323fc524ebce 100644 --- a/oas_docs/output/kibana.staging.yaml +++ b/oas_docs/output/kibana.staging.yaml @@ -38697,6 +38697,10 @@ paths: schema: nullable: true type: string + - in: query + name: associatedFilter + schema: + $ref: '#/components/schemas/Security_Timeline_API_AssociatedFilterType' responses: '200': content: @@ -58185,6 +58189,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: diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml index 24b0462ae93ef..8323fc524ebce 100644 --- a/oas_docs/output/kibana.yaml +++ b/oas_docs/output/kibana.yaml @@ -38697,6 +38697,10 @@ paths: schema: nullable: true type: string + - in: query + name: associatedFilter + schema: + $ref: '#/components/schemas/Security_Timeline_API_AssociatedFilterType' responses: '200': content: @@ -58185,6 +58189,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: diff --git a/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.gen.ts b/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.gen.ts index a4659d8d98d5a..41615f24d011c 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.gen.ts +++ b/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.gen.ts @@ -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; +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; 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; diff --git a/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.schema.yaml b/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.schema.yaml index cc8681c6f8f64..734a9580dcd23 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.schema.yaml +++ b/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.schema.yaml @@ -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 diff --git a/x-pack/plugins/security_solution/common/notes/constants.ts b/x-pack/plugins/security_solution/common/notes/constants.ts new file mode 100644 index 0000000000000..c296e377d1c4f --- /dev/null +++ b/x-pack/plugins/security_solution/common/notes/constants.ts @@ -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', +} diff --git a/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_timeline_api_2023_10_31.bundled.schema.yaml b/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_timeline_api_2023_10_31.bundled.schema.yaml index 8de192ce26826..e48dafbdc0e05 100644 --- a/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_timeline_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_timeline_api_2023_10_31.bundled.schema.yaml @@ -102,6 +102,10 @@ paths: schema: nullable: true type: string + - in: query + name: associatedFilter + schema: + $ref: '#/components/schemas/AssociatedFilterType' responses: '200': content: @@ -921,6 +925,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: diff --git a/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_timeline_api_2023_10_31.bundled.schema.yaml b/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_timeline_api_2023_10_31.bundled.schema.yaml index 66127d5b8cd52..fab5e022c6b06 100644 --- a/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_timeline_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_timeline_api_2023_10_31.bundled.schema.yaml @@ -102,6 +102,10 @@ paths: schema: nullable: true type: string + - in: query + name: associatedFilter + schema: + $ref: '#/components/schemas/AssociatedFilterType' responses: '200': content: @@ -921,6 +925,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: diff --git a/x-pack/plugins/security_solution/public/common/mock/global_state.ts b/x-pack/plugins/security_solution/public/common/mock/global_state.ts index 01eec48ed7718..5874062f05523 100644 --- a/x-pack/plugins/security_solution/public/common/mock/global_state.ts +++ b/x-pack/plugins/security_solution/public/common/mock/global_state.ts @@ -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: [], diff --git a/x-pack/plugins/security_solution/public/notes/api/api.ts b/x-pack/plugins/security_solution/public/notes/api/api.ts index 3bac1a0a2d7df..917974a154884 100644 --- a/x-pack/plugins/security_solution/public/notes/api/api.ts +++ b/x-pack/plugins/security_solution/public/notes/api/api.ts @@ -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(NOTE_URL, { @@ -61,6 +64,7 @@ export const fetchNotes = async ({ sortOrder, filter, userFilter, + associatedFilter, search, }, version: '2023-10-31', diff --git a/x-pack/plugins/security_solution/public/notes/components/search_row.test.tsx b/x-pack/plugins/security_solution/public/notes/components/search_row.test.tsx index 71693edb81724..be9546c77525b 100644 --- a/x-pack/plugins/security_solution/public/notes/components/search_row.test.tsx +++ b/x-pack/plugins/security_solution/public/notes/components/search_row.test.tsx @@ -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(); + + const associatedNoteSelect = getByTestId(ASSOCIATED_NOT_SELECT_TEST_ID); + await userEvent.selectOptions(associatedNoteSelect, [AssociatedFilter.documentOnly]); + + expect(mockDispatch).toHaveBeenCalled(); + }); }); diff --git a/x-pack/plugins/security_solution/public/notes/components/search_row.tsx b/x-pack/plugins/security_solution/public/notes/components/search_row.tsx index 9a33c84cbec58..d540a586814d8 100644 --- a/x-pack/plugins/security_solution/public/notes/components/search_row.tsx +++ b/x-pack/plugins/security_solution/public/notes/components/search_row.tsx @@ -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) => { + dispatch(userFilterAssociatedNotes(e.target.value as AssociatedFilter)); + }, + [dispatch] + ); + return ( @@ -73,6 +98,16 @@ export const SearchRow = React.memo(() => { data-test-subj={USER_SELECT_TEST_ID} /> + + + ); }); diff --git a/x-pack/plugins/security_solution/public/notes/components/test_ids.ts b/x-pack/plugins/security_solution/public/notes/components/test_ids.ts index 1464ed17d8764..e056ca19d6a2e 100644 --- a/x-pack/plugins/security_solution/public/notes/components/test_ids.ts +++ b/x-pack/plugins/security_solution/public/notes/components/test_ids.ts @@ -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; diff --git a/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx b/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx index e34824d1ad814..83c581507a2f9 100644 --- a/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx +++ b/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx @@ -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 ( diff --git a/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx b/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx index e329f0d75b911..4795d6146be4d 100644 --- a/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx +++ b/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx @@ -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, ]); diff --git a/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts b/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts index 7cbaecf7d7135..65fa293bd824a 100644 --- a/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts +++ b/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts @@ -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, diff --git a/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts b/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts index d5a4e7d4ab14e..28bf609a4f210 100644 --- a/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts +++ b/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts @@ -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 { 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 { 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, diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts index bc6c83e2b159c..7b8c732ae54ca 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts @@ -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 }); diff --git a/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/notes.ts b/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/notes.ts index 8a636358c2649..5d1fefadb2f65 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/notes.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/investigation/saved_objects/trial_license_complete_tier/notes.ts @@ -407,11 +407,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' }), @@ -443,6 +439,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) }); }); }