mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
# 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:
parent
173b5259df
commit
74b1ca6df2
20 changed files with 409 additions and 40 deletions
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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>;
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
14
x-pack/plugins/security_solution/common/notes/constants.ts
Normal file
14
x-pack/plugins/security_solution/common/notes/constants.ts
Normal 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',
|
||||
}
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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: [],
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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,
|
||||
]);
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue