From dc9fcfff461e44743e8ec87f1ba93822466f2eb5 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Tue, 10 Oct 2023 14:33:59 +0200 Subject: [PATCH] [Security Solution][Detections] Add assignees UI into alerts table (#7661) (#167079) ## Summary Closes https://github.com/elastic/security-team/issues/7661 This PR adds Alert user assignment UI within alerts table. https://github.com/elastic/kibana/assets/2700761/df81928a-b0c7-46ba-98b3-3803774ed239 --- .../detection_engine/alert_assignees/index.ts | 8 + .../detection_engine/alert_assignees/mocks.ts | 8 + .../set_alert_assignees_route.mock.ts | 17 ++ .../set_alert_assignees_route.ts | 20 ++ .../api/detection_engine/model/schemas.ts | 13 ++ .../api/detection_engine/users/index.ts | 8 + .../suggest_user_profiles_route.ts | 19 ++ .../security_solution/common/constants.ts | 4 + .../alert_bulk_assignees.test.tsx | 189 ++++++++++++++++++ .../bulk_actions/alert_bulk_assignees.tsx | 142 +++++++++++++ .../toolbar/bulk_actions/translations.ts | 62 ++++++ .../use_bulk_alert_assignees_items.test.tsx | 108 ++++++++++ .../use_bulk_alert_assignees_items.tsx | 100 +++++++++ .../bulk_actions/use_set_alert_assignees.tsx | 86 ++++++++ .../common/containers/alert_assignees/api.ts | 31 +++ .../alert_context_menu.test.tsx | 13 ++ .../timeline_actions/alert_context_menu.tsx | 12 +- .../use_alert_assignees_actions.test.tsx | 177 ++++++++++++++++ .../use_alert_assignees_actions.tsx | 81 ++++++++ .../components/alerts_table/translations.ts | 7 + .../security_solution_detections/columns.ts | 6 + .../render_cell_value.tsx | 61 +++++- .../detection_engine/alerts/__mocks__/api.ts | 15 +- .../detection_engine/alerts/api.test.ts | 26 +++ .../containers/detection_engine/alerts/api.ts | 19 ++ .../detection_engine/alerts/mock.ts | 6 + .../detection_engine/alerts/translations.ts | 5 + .../detection_engine/alerts/types.ts | 4 + .../alerts/use_get_user_profiles.test.tsx | 49 +++++ .../alerts/use_get_user_profiles.tsx | 55 +++++ .../alerts/use_suggest_users.test.tsx | 36 ++++ .../alerts/use_suggest_users.tsx | 48 +++++ .../use_bulk_actions.tsx | 9 +- .../timeline/body/renderers/constants.tsx | 1 + .../routes/__mocks__/request_responses.ts | 5 + .../routes/signals/helpers.ts | 15 +- .../signals/set_alert_assignees_route.test.ts | 115 +++++++++++ .../signals/set_alert_assignees_route.ts | 123 ++++++++++++ .../routes/signals/translations.ts | 7 + .../users/suggest_user_profiles_route.test.ts | 65 ++++++ .../users/suggest_user_profiles_route.ts | 73 +++++++ .../security_solution/server/routes/index.ts | 4 + .../bulk_actions/bulk_actions.test.tsx | 16 ++ .../bulk_actions/components/toolbar.tsx | 2 + .../rule_execution_logic/esql.ts | 1 + .../detection_alerts/alert_assignees.cy.ts | 75 +++++++ .../cypress/screens/alerts.ts | 13 ++ .../cypress/tasks/alert_assignees.ts | 55 +++++ .../cypress/tasks/alerts.ts | 31 +++ 49 files changed, 2037 insertions(+), 8 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/index.ts create mode 100644 x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/mocks.ts create mode 100644 x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees/set_alert_assignees_route.mock.ts create mode 100644 x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees/set_alert_assignees_route.ts create mode 100644 x-pack/plugins/security_solution/common/api/detection_engine/users/index.ts create mode 100644 x-pack/plugins/security_solution/common/api/detection_engine/users/suggest_user_profiles/suggest_user_profiles_route.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_assignees.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_assignees.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_set_alert_assignees.tsx create mode 100644 x-pack/plugins/security_solution/public/common/containers/alert_assignees/api.ts create mode 100644 x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_get_user_profiles.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_get_user_profiles.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_suggest_users.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_suggest_users.tsx create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_assignees_route.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_assignees_route.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/routes/users/suggest_user_profiles_route.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/routes/users/suggest_user_profiles_route.ts create mode 100644 x-pack/test/security_solution_cypress/cypress/e2e/detection_alerts/alert_assignees.cy.ts create mode 100644 x-pack/test/security_solution_cypress/cypress/tasks/alert_assignees.ts diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/index.ts b/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/index.ts new file mode 100644 index 0000000000000..e0fa0d8eb6408 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/index.ts @@ -0,0 +1,8 @@ +/* + * 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 * from './set_alert_assignees/set_alert_assignees_route'; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/mocks.ts b/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/mocks.ts new file mode 100644 index 0000000000000..15b16eecb2868 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/mocks.ts @@ -0,0 +1,8 @@ +/* + * 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 * from './set_alert_assignees/set_alert_assignees_route.mock'; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees/set_alert_assignees_route.mock.ts b/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees/set_alert_assignees_route.mock.ts new file mode 100644 index 0000000000000..9678131010702 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees/set_alert_assignees_route.mock.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +import type { SetAlertAssigneesRequestBody } from './set_alert_assignees_route'; + +export const getSetAlertAssigneesRequestMock = ( + assigneesToAdd: string[] = [], + assigneesToRemove: string[] = [], + ids: string[] = [] +): SetAlertAssigneesRequestBody => ({ + assignees: { assignees_to_add: assigneesToAdd, assignees_to_remove: assigneesToRemove }, + ids, +}); diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees/set_alert_assignees_route.ts b/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees/set_alert_assignees_route.ts new file mode 100644 index 0000000000000..6cc6514e6d9ee --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/alert_assignees/set_alert_assignees/set_alert_assignees_route.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ + +import * as t from 'io-ts'; + +import { alert_assignee_ids, alert_assignees } from '../../model'; + +export const setAlertAssigneesRequestBody = t.exact( + t.type({ + assignees: alert_assignees, + ids: alert_assignee_ids, + }) +); + +export type SetAlertAssigneesRequestBody = t.TypeOf; +export type SetAlertAssigneesRequestBodyDecoded = SetAlertAssigneesRequestBody; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/model/schemas.ts b/x-pack/plugins/security_solution/common/api/detection_engine/model/schemas.ts index 7e8cb0ebbe58b..148ed979d21b1 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/model/schemas.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/model/schemas.ts @@ -48,6 +48,9 @@ export const signal_status_query = t.object; export const alert_tag_ids = t.array(t.string); export type AlertTagIds = t.TypeOf; +export const alert_assignee_ids = t.array(t.string); +export type AlertAssigneeIds = t.TypeOf; + export const fields = t.array(t.string); export type Fields = t.TypeOf; export const fieldsOrUndefined = t.union([fields, t.undefined]); @@ -135,3 +138,13 @@ export const alert_tags = t.type({ }); export type AlertTags = t.TypeOf; + +export const alert_assignees = t.type({ + assignees_to_add: t.array(t.string), + assignees_to_remove: t.array(t.string), +}); + +export type AlertAssignees = t.TypeOf; + +export const user_search_term = t.string; +export type UserSearchTerm = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/users/index.ts b/x-pack/plugins/security_solution/common/api/detection_engine/users/index.ts new file mode 100644 index 0000000000000..f931f063971a3 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/users/index.ts @@ -0,0 +1,8 @@ +/* + * 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 * from './suggest_user_profiles/suggest_user_profiles_route'; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/users/suggest_user_profiles/suggest_user_profiles_route.ts b/x-pack/plugins/security_solution/common/api/detection_engine/users/suggest_user_profiles/suggest_user_profiles_route.ts new file mode 100644 index 0000000000000..12f87860fb002 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/users/suggest_user_profiles/suggest_user_profiles_route.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as t from 'io-ts'; + +import { user_search_term } from '../../model'; + +export const suggestUserProfilesRequestQuery = t.exact( + t.partial({ + searchTerm: user_search_term, + }) +); + +export type SuggestUserProfilesRequestQuery = t.TypeOf; +export type SuggestUserProfilesRequestQueryDecoded = SuggestUserProfilesRequestQuery; diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index b282b127f36f5..11a578eb09724 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -316,6 +316,10 @@ export const DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL = export const DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL = `${DETECTION_ENGINE_SIGNALS_URL}/finalize_migration` as const; export const DETECTION_ENGINE_ALERT_TAGS_URL = `${DETECTION_ENGINE_SIGNALS_URL}/tags` as const; +export const DETECTION_ENGINE_ALERT_ASSIGNEES_URL = + `${DETECTION_ENGINE_SIGNALS_URL}/assignees` as const; +export const DETECTION_ENGINE_ALERT_SUGGEST_USERS_URL = + `${DETECTION_ENGINE_SIGNALS_URL}/suggest_users` as const; export const ALERTS_AS_DATA_URL = '/internal/rac/alerts' as const; export const ALERTS_AS_DATA_FIND_URL = `${ALERTS_AS_DATA_URL}/find` as const; diff --git a/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_assignees.test.tsx b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_assignees.test.tsx new file mode 100644 index 0000000000000..a3a76c4bea327 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_assignees.test.tsx @@ -0,0 +1,189 @@ +/* + * 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. + */ + +import type { TimelineItem } from '@kbn/timelines-plugin/common'; +import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import { act, fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { TestProviders } from '../../../mock'; +import { useSuggestUsers } from '../../../../detections/containers/detection_engine/alerts/use_suggest_users'; + +import { BulkAlertAssigneesPanel } from './alert_bulk_assignees'; +import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils'; + +jest.mock('../../../../detections/containers/detection_engine/alerts/use_suggest_users'); + +const mockUserProfiles: UserProfileWithAvatar[] = [ + { uid: 'default-test-assignee-id-1', enabled: true, user: { username: 'user1' }, data: {} }, + { uid: 'default-test-assignee-id-2', enabled: true, user: { username: 'user2' }, data: {} }, +]; + +const mockAssigneeItems = [ + { + _id: 'test-id', + data: [{ field: ALERT_WORKFLOW_ASSIGNEE_IDS, value: ['assignee-id-1', 'assignee-id-2'] }], + ecs: { _id: 'test-id' }, + }, +]; + +(useSuggestUsers as jest.Mock).mockReturnValue({ loading: false, userProfiles: mockUserProfiles }); + +const renderAssigneesMenu = ( + items: TimelineItem[], + closePopover: () => void = jest.fn(), + onSubmit: () => Promise = jest.fn(), + setIsLoading: () => void = jest.fn() +) => { + return render( + + + + ); +}; + +describe('BulkAlertAssigneesPanel', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('it renders', () => { + const wrapper = renderAssigneesMenu(mockAssigneeItems); + + expect(wrapper.getByTestId('alert-assignees-update-button')).toBeInTheDocument(); + expect(useSuggestUsers).toHaveBeenCalled(); + }); + + test('it calls expected functions on submit when nothing has changed', () => { + const mockedClosePopover = jest.fn(); + const mockedOnSubmit = jest.fn(); + const mockedSetIsLoading = jest.fn(); + + const mockAssignees = [ + { + _id: 'test-id', + data: [{ field: ALERT_WORKFLOW_ASSIGNEE_IDS, value: ['default-test-assignee-id-1'] }], + ecs: { _id: 'test-id' }, + }, + { + _id: 'test-id', + data: [ + { + field: ALERT_WORKFLOW_ASSIGNEE_IDS, + value: ['default-test-assignee-id-1', 'default-test-assignee-id-2'], + }, + ], + ecs: { _id: 'test-id' }, + }, + ]; + const wrapper = renderAssigneesMenu( + mockAssignees, + mockedClosePopover, + mockedOnSubmit, + mockedSetIsLoading + ); + + act(() => { + fireEvent.click(wrapper.getByTestId('alert-assignees-update-button')); + }); + expect(mockedClosePopover).toHaveBeenCalled(); + expect(mockedOnSubmit).not.toHaveBeenCalled(); + expect(mockedSetIsLoading).not.toHaveBeenCalled(); + }); + + test('it updates state correctly', () => { + const mockAssignees = [ + { + _id: 'test-id', + data: [{ field: ALERT_WORKFLOW_ASSIGNEE_IDS, value: ['default-test-assignee-id-1'] }], + ecs: { _id: 'test-id' }, + }, + { + _id: 'test-id', + data: [ + { + field: ALERT_WORKFLOW_ASSIGNEE_IDS, + value: ['default-test-assignee-id-1', 'default-test-assignee-id-2'], + }, + ], + ecs: { _id: 'test-id' }, + }, + ]; + const wrapper = renderAssigneesMenu(mockAssignees); + + expect(wrapper.getAllByRole('option')[0]).toHaveAttribute('title', 'user1'); + expect(wrapper.getAllByRole('option')[0]).toBeChecked(); + act(() => { + fireEvent.click(wrapper.getByText('user1')); + }); + expect(wrapper.getAllByRole('option')[0]).toHaveAttribute('title', 'user1'); + expect(wrapper.getAllByRole('option')[0]).not.toBeChecked(); + + expect(wrapper.getAllByRole('option')[1]).toHaveAttribute('title', 'user2'); + expect(wrapper.getAllByRole('option')[1]).not.toBeChecked(); + act(() => { + fireEvent.click(wrapper.getByText('user2')); + }); + expect(wrapper.getAllByRole('option')[1]).toHaveAttribute('title', 'user2'); + expect(wrapper.getAllByRole('option')[1]).toBeChecked(); + }); + + test('it calls expected functions on submit when alerts have changed', () => { + const mockedClosePopover = jest.fn(); + const mockedOnSubmit = jest.fn(); + const mockedSetIsLoading = jest.fn(); + + const mockAssignees = [ + { + _id: 'test-id', + data: [{ field: ALERT_WORKFLOW_ASSIGNEE_IDS, value: ['default-test-assignee-id-1'] }], + ecs: { _id: 'test-id' }, + }, + { + _id: 'test-id', + data: [ + { + field: ALERT_WORKFLOW_ASSIGNEE_IDS, + value: ['default-test-assignee-id-1', 'default-test-assignee-id-2'], + }, + ], + ecs: { _id: 'test-id' }, + }, + ]; + const wrapper = renderAssigneesMenu( + mockAssignees, + mockedClosePopover, + mockedOnSubmit, + mockedSetIsLoading + ); + act(() => { + fireEvent.click(wrapper.getByText('user1')); + }); + act(() => { + fireEvent.click(wrapper.getByText('user2')); + }); + + act(() => { + fireEvent.click(wrapper.getByTestId('alert-assignees-update-button')); + }); + expect(mockedClosePopover).toHaveBeenCalled(); + expect(mockedOnSubmit).toHaveBeenCalled(); + expect(mockedOnSubmit).toHaveBeenCalledWith( + { + assignees_to_add: ['default-test-assignee-id-2'], + assignees_to_remove: ['default-test-assignee-id-1'], + }, + ['test-id', 'test-id'], + expect.anything(), // An anonymous callback defined in the onSubmit function + mockedSetIsLoading + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_assignees.tsx b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_assignees.tsx new file mode 100644 index 0000000000000..c720c70a8b4aa --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_assignees.tsx @@ -0,0 +1,142 @@ +/* + * 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. + */ + +import { isEqual } from 'lodash/fp'; +import { intersection } from 'lodash'; +import { EuiButton } from '@elastic/eui'; +import type { TimelineItem } from '@kbn/timelines-plugin/common'; +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils'; +import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import { UserProfilesSelectable } from '@kbn/user-profile-components'; +import { useSuggestUsers } from '../../../../detections/containers/detection_engine/alerts/use_suggest_users'; +import * as i18n from './translations'; +import type { SetAlertAssigneesFunc } from './use_set_alert_assignees'; + +interface BulkAlertAssigneesPanelComponentProps { + alertItems: TimelineItem[]; + refetchQuery?: () => void; + setIsLoading: (isLoading: boolean) => void; + refresh?: () => void; + clearSelection?: () => void; + closePopoverMenu: () => void; + onSubmit: SetAlertAssigneesFunc; +} +const BulkAlertAssigneesPanelComponent: React.FC = ({ + alertItems, + refresh, + refetchQuery, + setIsLoading, + clearSelection, + closePopoverMenu, + onSubmit, +}) => { + const [searchTerm, setSearchTerm] = useState(''); + const { loading: isLoadingUsers, userProfiles } = useSuggestUsers(searchTerm); + + const [selectedAssignees, setSelectedAssignees] = useState([]); + + const existingAssigneesIds = useMemo( + () => + intersection( + ...alertItems.map( + (item) => + item.data.find((data) => data.field === ALERT_WORKFLOW_ASSIGNEE_IDS)?.value ?? [] + ) + ), + [alertItems] + ); + useEffect(() => { + if (isLoadingUsers) { + return; + } + const assignees = userProfiles.filter((user) => existingAssigneesIds.includes(user.uid)); + setSelectedAssignees(assignees); + }, [existingAssigneesIds, isLoadingUsers, userProfiles]); + + const onAssigneesUpdate = useCallback(async () => { + const existingIds = existingAssigneesIds; + const updatedIds = selectedAssignees.map((user) => user?.uid); + + const assigneesToAddArray = updatedIds.filter((uid) => !existingIds.includes(uid)); + const assigneesToRemoveArray = existingIds.filter((uid) => !updatedIds.includes(uid)); + if (assigneesToAddArray.length === 0 && assigneesToRemoveArray.length === 0) { + closePopoverMenu(); + return; + } + + const ids = alertItems.map((item) => item._id); + const assignees = { + assignees_to_add: assigneesToAddArray, + assignees_to_remove: assigneesToRemoveArray, + }; + const onSuccess = () => { + if (refetchQuery) refetchQuery(); + if (refresh) refresh(); + if (clearSelection) clearSelection(); + }; + if (onSubmit != null) { + closePopoverMenu(); + await onSubmit(assignees, ids, onSuccess, setIsLoading); + } + }, [ + alertItems, + clearSelection, + closePopoverMenu, + existingAssigneesIds, + onSubmit, + refetchQuery, + refresh, + selectedAssignees, + setIsLoading, + ]); + + const handleSelectedAssignees = useCallback( + (newAssignees: UserProfileWithAvatar[]) => { + if (!isEqual(newAssignees, selectedAssignees)) { + setSelectedAssignees(newAssignees); + } + }, + [selectedAssignees] + ); + + const selectedStatusMessage = useCallback( + (selectedCount: number) => i18n.ALERT_TOTAL_ASSIGNEES_FILTERED(selectedCount), + [] + ); + + return ( +
+ { + setSearchTerm(term); + }} + selectedStatusMessage={selectedStatusMessage} + options={userProfiles} + selectedOptions={selectedAssignees} + isLoading={isLoadingUsers} + height={'full'} + searchPlaceholder={i18n.ALERT_ASSIGNEES_SEARCH_USERS} + clearButtonLabel={i18n.ALERT_ASSIGNEES_CLEAR_FILTERS} + singleSelection={false} + nullOptionLabel={i18n.ALERT_ASSIGNEES_NO_ASSIGNEES} + /> + + {i18n.ALERT_ASSIGNEES_APPLY_BUTTON_MESSAGE} + +
+ ); +}; + +export const BulkAlertAssigneesPanel = memo(BulkAlertAssigneesPanelComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/translations.ts b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/translations.ts index a99ad3cb76a43..30df492ee6aa2 100644 --- a/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/translations.ts @@ -211,3 +211,65 @@ export const ALERT_TAGS_CONTEXT_MENU_ITEM_TOOLTIP_INFO = i18n.translate( defaultMessage: 'Change alert tag options in Kibana Advanced Settings.', } ); + +export const UPDATE_ALERT_ASSIGNEES_SUCCESS_TOAST = (totalAlerts: number) => + i18n.translate('xpack.securitySolution.bulkActions.updateAlertAssigneesSuccessToastMessage', { + values: { totalAlerts }, + defaultMessage: + 'Successfully updated assignees for {totalAlerts} {totalAlerts, plural, =1 {alert} other {alerts}}.', + }); + +export const UPDATE_ALERT_ASSIGNEES_FAILURE = i18n.translate( + 'xpack.securitySolution.bulkActions.updateAlertAssigneesFailedToastMessage', + { + defaultMessage: 'Failed to update alert assignees.', + } +); + +export const ALERT_ASSIGNEES_APPLY_BUTTON_MESSAGE = i18n.translate( + 'xpack.securitySolution.bulkActions.alertAssigneesApplyButtonMessage', + { + defaultMessage: 'Apply assignees', + } +); + +export const ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TITLE = i18n.translate( + 'xpack.securitySolution.bulkActions.alertAssigneesContextMenuItemTitle', + { + defaultMessage: 'Apply alert assignees', + } +); + +export const ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TOOLTIP_INFO = i18n.translate( + 'xpack.securitySolution.bulkActions.alertAssigneesContextMenuItemTooltip', + { + defaultMessage: 'Change alert assignees options in Kibana Advanced Settings.', + } +); + +export const ALERT_TOTAL_ASSIGNEES_FILTERED = (total: number) => + i18n.translate('xpack.securitySolution.bulkActions.totalFilteredUsers', { + defaultMessage: '{total, plural, one {# filter} other {# filters}} selected', + values: { total }, + }); + +export const ALERT_ASSIGNEES_SEARCH_USERS = i18n.translate( + 'xpack.securitySolution.bulkActions.userProfile.selectableSearchPlaceholder', + { + defaultMessage: 'Search users', + } +); + +export const ALERT_ASSIGNEES_CLEAR_FILTERS = i18n.translate( + 'xpack.securitySolution.bulkActions.userProfile.clearFilters', + { + defaultMessage: 'Clear filters', + } +); + +export const ALERT_ASSIGNEES_NO_ASSIGNEES = i18n.translate( + 'xpack.securitySolution.bulkActions.userProfile.noAssigneesLabel', + { + defaultMessage: 'No assignees', + } +); diff --git a/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.test.tsx b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.test.tsx new file mode 100644 index 0000000000000..7a87744b49190 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.test.tsx @@ -0,0 +1,108 @@ +/* + * 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. + */ + +import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils'; +import { TestProviders } from '@kbn/timelines-plugin/public/mock'; +import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import { act, fireEvent, render } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; +import type { + UseBulkAlertAssigneesItemsProps, + UseBulkAlertAssigneesPanel, +} from './use_bulk_alert_assignees_items'; +import { useBulkAlertAssigneesItems } from './use_bulk_alert_assignees_items'; +import { useSetAlertAssignees } from './use_set_alert_assignees'; +import { useSuggestUsers } from '../../../../detections/containers/detection_engine/alerts/use_suggest_users'; + +jest.mock('./use_set_alert_assignees'); +jest.mock('../../../../detections/containers/detection_engine/alerts/use_suggest_users'); + +const mockUserProfiles: UserProfileWithAvatar[] = [ + { uid: 'user-id-1', enabled: true, user: { username: 'fakeUser1' }, data: {} }, + { uid: 'user-id-2', enabled: true, user: { username: 'fakeUser2' }, data: {} }, +]; + +const defaultProps: UseBulkAlertAssigneesItemsProps = { + refetch: () => {}, +}; + +const mockAssigneeItems = [ + { + _id: 'test-id', + data: [{ field: ALERT_WORKFLOW_ASSIGNEE_IDS, value: ['user-id-1', 'user-id-2'] }], + ecs: { _id: 'test-id', _index: 'test-index' }, + }, +]; + +const renderPanel = (panel: UseBulkAlertAssigneesPanel) => { + const content = panel.renderContent({ + closePopoverMenu: jest.fn(), + setIsBulkActionsLoading: jest.fn(), + alertItems: mockAssigneeItems, + }); + return render(content); +}; + +describe('useBulkAlertAssigneesItems', () => { + beforeEach(() => { + (useSetAlertAssignees as jest.Mock).mockReturnValue(jest.fn()); + (useSuggestUsers as jest.Mock).mockReturnValue({ + loading: false, + userProfiles: mockUserProfiles, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render alert assignees actions', () => { + const { result } = renderHook(() => useBulkAlertAssigneesItems(defaultProps), { + wrapper: TestProviders, + }); + expect(result.current.alertAssigneesItems.length).toEqual(1); + expect(result.current.alertAssigneesPanels.length).toEqual(1); + + expect(result.current.alertAssigneesItems[0]['data-test-subj']).toEqual( + 'alert-assignees-context-menu-item' + ); + expect(result.current.alertAssigneesPanels[0]['data-test-subj']).toEqual( + 'alert-assignees-context-menu-panel' + ); + }); + + it('should still render alert assignees panel when useSetAlertAssignees is null', () => { + (useSetAlertAssignees as jest.Mock).mockReturnValue(null); + const { result } = renderHook(() => useBulkAlertAssigneesItems(defaultProps), { + wrapper: TestProviders, + }); + + expect(result.current.alertAssigneesPanels[0]['data-test-subj']).toEqual( + 'alert-assignees-context-menu-panel' + ); + const wrapper = renderPanel(result.current.alertAssigneesPanels[0]); + expect(wrapper.getByTestId('alert-assignees-selectable-menu')).toBeInTheDocument(); + }); + + it('should call setAlertAssignees on submit', () => { + const mockSetAlertAssignees = jest.fn(); + (useSetAlertAssignees as jest.Mock).mockReturnValue(mockSetAlertAssignees); + const { result } = renderHook(() => useBulkAlertAssigneesItems(defaultProps), { + wrapper: TestProviders, + }); + + const wrapper = renderPanel(result.current.alertAssigneesPanels[0]); + expect(wrapper.getByTestId('alert-assignees-selectable-menu')).toBeInTheDocument(); + act(() => { + fireEvent.click(wrapper.getByText('fakeUser2')); // Won't fire unless component assignees selection has been changed + }); + act(() => { + fireEvent.click(wrapper.getByTestId('alert-assignees-update-button')); + }); + expect(mockSetAlertAssignees).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.tsx b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.tsx new file mode 100644 index 0000000000000..07db301074aae --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items.tsx @@ -0,0 +1,100 @@ +/* + * 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. + */ + +import { EuiFlexGroup, EuiIconTip, EuiFlexItem } from '@elastic/eui'; +import type { RenderContentPanelProps } from '@kbn/triggers-actions-ui-plugin/public/types'; +import React, { useCallback, useMemo } from 'react'; +import { BulkAlertAssigneesPanel } from './alert_bulk_assignees'; +import * as i18n from './translations'; +import { useSetAlertAssignees } from './use_set_alert_assignees'; + +export interface UseBulkAlertAssigneesItemsProps { + refetch?: () => void; +} + +export interface UseBulkAlertAssigneesPanel { + id: number; + title: JSX.Element; + 'data-test-subj': string; + renderContent: (props: RenderContentPanelProps) => JSX.Element; +} + +export const useBulkAlertAssigneesItems = ({ refetch }: UseBulkAlertAssigneesItemsProps) => { + const setAlertAssignees = useSetAlertAssignees(); + const handleOnAlertAssigneesSubmit = useCallback( + async (assignees, ids, onSuccess, setIsLoading) => { + if (setAlertAssignees) { + await setAlertAssignees(assignees, ids, onSuccess, setIsLoading); + } + }, + [setAlertAssignees] + ); + + const alertAssigneesItems = [ + { + key: 'manage-alert-assignees', + 'data-test-subj': 'alert-assignees-context-menu-item', + name: i18n.ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TITLE, + panel: 2, + label: i18n.ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TITLE, + disableOnQuery: true, + }, + ]; + + const TitleContent = useMemo( + () => ( + + {i18n.ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TITLE} + + + + + ), + [] + ); + + const renderContent = useCallback( + ({ + alertItems, + refresh, + setIsBulkActionsLoading, + clearSelection, + closePopoverMenu, + }: RenderContentPanelProps) => ( + + ), + [handleOnAlertAssigneesSubmit, refetch] + ); + + const alertAssigneesPanels: UseBulkAlertAssigneesPanel[] = useMemo( + () => [ + { + id: 2, + title: TitleContent, + 'data-test-subj': 'alert-assignees-context-menu-panel', + renderContent, + }, + ], + [TitleContent, renderContent] + ); + + return { + alertAssigneesItems, + alertAssigneesPanels, + }; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_set_alert_assignees.tsx b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_set_alert_assignees.tsx new file mode 100644 index 0000000000000..43630cda420c7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_set_alert_assignees.tsx @@ -0,0 +1,86 @@ +/* + * 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. + */ + +import type { CoreStart } from '@kbn/core/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { useCallback, useEffect, useRef } from 'react'; +import type { AlertAssignees } from '../../../../../common/api/detection_engine'; +import { useAppToasts } from '../../../hooks/use_app_toasts'; +import * as i18n from './translations'; +import { setAlertAssignees } from '../../../containers/alert_assignees/api'; + +export type SetAlertAssigneesFunc = ( + assignees: AlertAssignees, + ids: string[], + onSuccess: () => void, + setTableLoading: (param: boolean) => void +) => Promise; +export type ReturnSetAlertAssignees = SetAlertAssigneesFunc | null; + +/** + * Update alert assignees by query + * + * @param assignees to add and/or remove from a batch of alerts + * @param ids alert ids that will be used to create the update query. + * @param onSuccess a callback function that will be called on successful api response + * @param setTableLoading a function that sets the alert table in a loading state for bulk actions + + * + * @throws An error if response is not OK + */ +export const useSetAlertAssignees = (): ReturnSetAlertAssignees => { + const { http } = useKibana().services; + const { addSuccess, addError } = useAppToasts(); + const setAlertAssigneesRef = useRef(null); + + const onUpdateSuccess = useCallback( + (updated: number = 0) => addSuccess(i18n.UPDATE_ALERT_ASSIGNEES_SUCCESS_TOAST(updated)), + [addSuccess] + ); + + const onUpdateFailure = useCallback( + (error: Error) => { + addError(error.message, { title: i18n.UPDATE_ALERT_ASSIGNEES_FAILURE }); + }, + [addError] + ); + + useEffect(() => { + let ignore = false; + const abortCtrl = new AbortController(); + + const onSetAlertAssignees: SetAlertAssigneesFunc = async ( + assignees, + ids, + onSuccess, + setTableLoading + ) => { + try { + setTableLoading(true); + const response = await setAlertAssignees({ assignees, ids, signal: abortCtrl.signal }); + if (!ignore) { + onSuccess(); + setTableLoading(false); + onUpdateSuccess(response.updated); + } + } catch (error) { + if (!ignore) { + setTableLoading(false); + onUpdateFailure(error); + } + } + }; + + setAlertAssigneesRef.current = onSetAlertAssignees; + return (): void => { + ignore = true; + abortCtrl.abort(); + }; + }, [http, onUpdateFailure, onUpdateSuccess]); + + return setAlertAssigneesRef.current; +}; diff --git a/x-pack/plugins/security_solution/public/common/containers/alert_assignees/api.ts b/x-pack/plugins/security_solution/public/common/containers/alert_assignees/api.ts new file mode 100644 index 0000000000000..8652a51138d62 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/alert_assignees/api.ts @@ -0,0 +1,31 @@ +/* + * 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. + */ + +import type { estypes } from '@elastic/elasticsearch'; +import { DETECTION_ENGINE_ALERT_ASSIGNEES_URL } from '../../../../common/constants'; +import type { AlertAssignees } from '../../../../common/api/detection_engine'; +import { KibanaServices } from '../../lib/kibana'; + +export const setAlertAssignees = async ({ + assignees, + ids, + signal, +}: { + assignees: AlertAssignees; + ids: string[]; + signal: AbortSignal | undefined; +}): Promise => { + return KibanaServices.get().http.fetch( + DETECTION_ENGINE_ALERT_ASSIGNEES_URL, + { + method: 'POST', + version: '2023-10-31', + body: JSON.stringify({ assignees, ids }), + signal, + } + ); +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx index b180856da2b29..c0350f54d152c 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx @@ -101,6 +101,7 @@ const markAsClosedButton = '[data-test-subj="close-alert-status"]'; const addEndpointEventFilterButton = '[data-test-subj="add-event-filter-menu-item"]'; const openAlertDetailsPageButton = '[data-test-subj="open-alert-details-page-menu-item"]'; const applyAlertTagsButton = '[data-test-subj="alert-tags-context-menu-item"]'; +const applyAlertAssigneesButton = '[data-test-subj="alert-assignees-context-menu-item"]'; describe('Alert table context menu', () => { describe('Case actions', () => { @@ -338,4 +339,16 @@ describe('Alert table context menu', () => { expect(wrapper.find(applyAlertTagsButton).first().exists()).toEqual(true); }); }); + + describe('Apply alert assignees action', () => { + test('it renders the apply alert assignees action button', () => { + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); + + wrapper.find(actionMenuButton).simulate('click'); + + expect(wrapper.find(applyAlertAssigneesButton).first().exists()).toEqual(true); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index a05c351f3d22d..378072ac1f981 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -47,6 +47,7 @@ import type { Rule } from '../../../../detection_engine/rule_management/logic/ty import { useOpenAlertDetailsAction } from './use_open_alert_details'; import type { AlertTableContextMenuItem } from '../types'; import { useAlertTagsActions } from './use_alert_tags_actions'; +import { useAlertAssigneesActions } from './use_alert_assignees_actions'; interface AlertContextMenuProps { ariaLabel?: string; @@ -228,6 +229,12 @@ const AlertContextMenuComponent: React.FC !isEvent && ruleId @@ -235,6 +242,7 @@ const AlertContextMenuComponent: React.FC { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.test.tsx new file mode 100644 index 0000000000000..5110f512a4610 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.test.tsx @@ -0,0 +1,177 @@ +/* + * 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. + */ + +import { TestProviders } from '@kbn/timelines-plugin/public/mock'; +import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import { renderHook } from '@testing-library/react-hooks'; +import type { UseAlertAssigneesActionsProps } from './use_alert_assignees_actions'; +import { useAlertAssigneesActions } from './use_alert_assignees_actions'; +import { useAlertsPrivileges } from '../../../containers/detection_engine/alerts/use_alerts_privileges'; +import type { AlertTableContextMenuItem } from '../types'; +import { render } from '@testing-library/react'; +import React from 'react'; +import type { EuiContextMenuPanelDescriptor } from '@elastic/eui'; +import { EuiPopover, EuiContextMenu } from '@elastic/eui'; +import { useSetAlertAssignees } from '../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees'; +import { useSuggestUsers } from '../../../containers/detection_engine/alerts/use_suggest_users'; + +jest.mock('../../../containers/detection_engine/alerts/use_alerts_privileges'); +jest.mock('../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees'); +jest.mock('../../../containers/detection_engine/alerts/use_suggest_users'); + +const mockUserProfiles: UserProfileWithAvatar[] = [ + { uid: 'user-id-1', enabled: true, user: { username: 'fakeUser1' }, data: {} }, + { uid: 'user-id-2', enabled: true, user: { username: 'fakeUser2' }, data: {} }, +]; + +const defaultProps: UseAlertAssigneesActionsProps = { + closePopover: jest.fn(), + ecsRowData: { + _id: '123', + kibana: { + alert: { + workflow_assignee_ids: [], + }, + }, + }, + refetch: jest.fn(), +}; + +const renderContextMenu = ( + items: AlertTableContextMenuItem[], + panels: EuiContextMenuPanelDescriptor[] +) => { + const panelsToRender = [{ id: 0, items }, ...panels]; + return render( + {}} + button={<>} + > + + + ); +}; + +describe('useAlertAssigneesActions', () => { + beforeEach(() => { + (useAlertsPrivileges as jest.Mock).mockReturnValue({ + hasIndexWrite: true, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render alert assignees actions', () => { + const { result } = renderHook(() => useAlertAssigneesActions(defaultProps), { + wrapper: TestProviders, + }); + expect(result.current.alertAssigneesItems.length).toEqual(1); + expect(result.current.alertAssigneesPanels.length).toEqual(1); + + expect(result.current.alertAssigneesItems[0]['data-test-subj']).toEqual( + 'alert-assignees-context-menu-item' + ); + expect(result.current.alertAssigneesPanels[0].content).toMatchInlineSnapshot(` + + `); + }); + + it("should not render alert assignees actions if user doesn't have write permissions", () => { + (useAlertsPrivileges as jest.Mock).mockReturnValue({ + hasIndexWrite: false, + }); + const { result } = renderHook(() => useAlertAssigneesActions(defaultProps), { + wrapper: TestProviders, + }); + expect(result.current.alertAssigneesItems.length).toEqual(0); + }); + + it('should still render if workflow_assignee_ids field does not exist', () => { + const newProps = { + ...defaultProps, + ecsRowData: { + _id: '123', + }, + }; + const { result } = renderHook(() => useAlertAssigneesActions(newProps), { + wrapper: TestProviders, + }); + expect(result.current.alertAssigneesItems.length).toEqual(1); + expect(result.current.alertAssigneesPanels.length).toEqual(1); + expect(result.current.alertAssigneesPanels[0].content).toMatchInlineSnapshot(` + + `); + }); + + it('should render the nested panel', async () => { + (useSetAlertAssignees as jest.Mock).mockReturnValue(jest.fn()); + (useSuggestUsers as jest.Mock).mockReturnValue({ + loading: false, + userProfiles: mockUserProfiles, + }); + + const { result } = renderHook(() => useAlertAssigneesActions(defaultProps), { + wrapper: TestProviders, + }); + const alertAssigneesItems = result.current.alertAssigneesItems; + const alertAssigneesPanels = result.current.alertAssigneesPanels; + const { getByTestId } = renderContextMenu(alertAssigneesItems, alertAssigneesPanels); + + expect(getByTestId('alert-assignees-selectable-menu')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.tsx new file mode 100644 index 0000000000000..087c8bbd04981 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EuiContextMenuPanelDescriptor } from '@elastic/eui'; +import { useMemo } from 'react'; + +import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; +import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils'; +import { useBulkAlertAssigneesItems } from '../../../../common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items'; +import { useAlertsPrivileges } from '../../../containers/detection_engine/alerts/use_alerts_privileges'; +import type { AlertTableContextMenuItem } from '../types'; + +export interface UseAlertAssigneesActionsProps { + closePopover: () => void; + ecsRowData: Ecs; + refetch?: () => void; +} + +export const useAlertAssigneesActions = ({ + closePopover, + ecsRowData, + refetch, +}: UseAlertAssigneesActionsProps) => { + const { hasIndexWrite } = useAlertsPrivileges(); + const alertId = ecsRowData._id; + const alertAssigneeData = useMemo(() => { + return [ + { + _id: alertId, + _index: ecsRowData._index ?? '', + data: [ + { + field: ALERT_WORKFLOW_ASSIGNEE_IDS, + value: ecsRowData?.kibana?.alert.workflow_assignee_ids ?? [], + }, + ], + ecs: { + _id: alertId, + _index: ecsRowData._index ?? '', + }, + }, + ]; + }, [alertId, ecsRowData._index, ecsRowData?.kibana?.alert.workflow_assignee_ids]); + + const { alertAssigneesItems, alertAssigneesPanels } = useBulkAlertAssigneesItems({ + refetch, + }); + + const itemsToReturn: AlertTableContextMenuItem[] = useMemo( + () => + alertAssigneesItems.map((item) => ({ + name: item.name, + panel: item.panel, + 'data-test-subj': item['data-test-subj'], + key: item.key, + })), + [alertAssigneesItems] + ); + + const panelsToReturn: EuiContextMenuPanelDescriptor[] = useMemo( + () => + alertAssigneesPanels.map((panel) => { + const content = panel.renderContent({ + closePopoverMenu: closePopover, + setIsBulkActionsLoading: () => {}, + alertItems: alertAssigneeData, + }); + return { title: panel.title, content, id: panel.id }; + }), + [alertAssigneeData, alertAssigneesPanels, closePopover] + ); + + return { + alertAssigneesItems: hasIndexWrite ? itemsToReturn : [], + alertAssigneesPanels: panelsToReturn, + }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts index 5115bf0130e6d..6b69bb5f8724c 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts @@ -95,6 +95,13 @@ export const ALERTS_HEADERS_RISK_SCORE = i18n.translate( } ); +export const ALERTS_HEADERS_ASSIGNEES = i18n.translate( + 'xpack.securitySolution.eventsViewer.alerts.defaultHeaders.assigneesTitle', + { + defaultMessage: 'Assignees', + } +); + export const ALERTS_HEADERS_THRESHOLD_COUNT = i18n.translate( 'xpack.securitySolution.eventsViewer.alerts.defaultHeaders.thresholdCount', { diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts index 29c8cb4ec0962..bfce842096448 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts @@ -28,6 +28,12 @@ const getBaseColumns = ( > => { const isPlatinumPlus = license?.isPlatinumPlus?.() ?? false; return [ + { + columnHeaderType: defaultColumnHeaderType, + displayAsText: i18n.ALERTS_HEADERS_ASSIGNEES, + id: 'kibana.alert.workflow_assignee_ids', + initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH, + }, { columnHeaderType: defaultColumnHeaderType, displayAsText: i18n.ALERTS_HEADERS_SEVERITY, diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx index 47be8b0739346..ae4e2428f5e86 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx @@ -6,13 +6,21 @@ */ import type { EuiDataGridCellValueElementProps } from '@elastic/eui'; -import { EuiIcon, EuiToolTip, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { + EuiIcon, + EuiToolTip, + EuiFlexGroup, + EuiFlexItem, + EuiNotificationBadge, + EuiLoadingSpinner, +} from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import type { GetRenderCellValue } from '@kbn/triggers-actions-ui-plugin/public'; import { find, getOr } from 'lodash/fp'; import type { TimelineNonEcsData } from '@kbn/timelines-plugin/common'; import { tableDefaults, dataTableSelectors } from '@kbn/securitysolution-data-table'; import type { TableId } from '@kbn/securitysolution-data-table'; +import { UserAvatar } from '@kbn/user-profile-components'; import { useLicense } from '../../../common/hooks/use_license'; import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers'; @@ -23,7 +31,10 @@ import { AlertsCasesTourSteps, SecurityStepId, } from '../../../common/components/guided_onboarding_tour/tour_config'; -import { SIGNAL_RULE_NAME_FIELD_NAME } from '../../../timelines/components/timeline/body/renderers/constants'; +import { + SIGNAL_ASSIGNEE_IDS_FIELD_NAME, + SIGNAL_RULE_NAME_FIELD_NAME, +} from '../../../timelines/components/timeline/body/renderers/constants'; import { useSourcererDataView } from '../../../common/containers/sourcerer'; import type { CellValueElementProps } from '../../../timelines/components/timeline/cell_rendering'; @@ -33,6 +44,7 @@ import { SUPPRESSED_ALERT_TOOLTIP } from './translations'; import { VIEW_SELECTION } from '../../../../common/constants'; import { getAllFieldsByName } from '../../../common/containers/source'; import { eventRenderedViewColumns, getColumns } from './columns'; +import { useGetUserProfiles } from '../../containers/detection_engine/alerts/use_get_user_profiles'; /** * This implementation of `EuiDataGrid`'s `renderCellValue` @@ -62,6 +74,49 @@ export const RenderCellValue: React.FC { + const ecsAssignees = props.ecsData?.kibana?.alert.workflow_assignee_ids; + const dataAssignees = find({ field: 'kibana.alert.workflow_assignee_ids' }, props.data) as + | string[] + | undefined; + return ecsAssignees ?? dataAssignees ?? []; + }, [props.data, props.ecsData?.kibana?.alert.workflow_assignee_ids]); + const { loading: isLoadingProfiles, userProfiles } = useGetUserProfiles(actualAssignees); + const assignees = userProfiles?.filter((user) => actualAssignees.includes(user.uid)) ?? []; + if ( + columnId === SIGNAL_ASSIGNEE_IDS_FIELD_NAME && + (actualAssignees.length || isLoadingProfiles) + ) { + // Show spinner if loading profiles or if there are no fetched profiles yet + if (isLoadingProfiles || !assignees.length) { + return ; + } + return ( + + {assignees.length > 2 ? ( + ( +
{user.user.email ?? user.user.username}
+ ))} + repositionOnScroll={true} + > + {assignees.length} +
+ ) : ( + assignees.map((user) => ( + + )) + )} +
+ ); + } + const component = ( ); }, - [browserFieldsByName, browserFields, columnHeaders] + [browserFieldsByName, columnHeaders, browserFields] ); return result; }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/__mocks__/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/__mocks__/api.ts index d2eda8a8762e1..80eef1e998956 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/__mocks__/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/__mocks__/api.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; import type { QueryAlerts, AlertSearchResponse, @@ -13,7 +14,13 @@ import type { Privilege, CasesFromAlertsResponse, } from '../types'; -import { alertsMock, mockSignalIndex, mockUserPrivilege, mockCaseIdsFromAlertId } from '../mock'; +import { + alertsMock, + mockSignalIndex, + mockUserPrivilege, + mockCaseIdsFromAlertId, + mockUserProfiles, +} from '../mock'; export const fetchQueryAlerts = async ({ query, @@ -36,3 +43,9 @@ export const getCaseIdsFromAlertId = async ({ }: { alertId: string; }): Promise => Promise.resolve(mockCaseIdsFromAlertId); + +export const suggestUsers = async ({ + searchTerm, +}: { + searchTerm: string; +}): Promise => Promise.resolve(mockUserProfiles); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts index 92801daeba514..13c2cd1bafeed 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts @@ -13,6 +13,7 @@ import { mockSignalIndex, mockUserPrivilege, mockHostIsolation, + mockUserProfiles, } from './mock'; import { fetchQueryAlerts, @@ -22,6 +23,7 @@ import { createHostIsolation, updateAlertStatusByQuery, updateAlertStatusByIds, + suggestUsers, } from './api'; import { coreMock } from '@kbn/core/public/mocks'; @@ -264,4 +266,28 @@ describe('Detections Alerts API', () => { expect(hostIsolationResponse).toEqual(mockHostIsolation); }); }); + + describe('suggestUsers', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(mockUserProfiles); + }); + + test('check parameter url', async () => { + await suggestUsers({ searchTerm: 'name1' }); + expect(fetchMock).toHaveBeenCalledWith( + '/api/detection_engine/signals/suggest_users', + expect.objectContaining({ + method: 'GET', + version: '2023-10-31', + query: { searchTerm: 'name1' }, + }) + ); + }); + + test('happy path', async () => { + const alertsResp = await suggestUsers({ searchTerm: '' }); + expect(alertsResp).toEqual(mockUserProfiles); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts index ecd53bbf76a89..3959d6f922625 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts @@ -7,6 +7,7 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { getCasesFromAlertsUrl } from '@kbn/cases-plugin/common'; +import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; import type { ResponseActionApiResponse, HostInfo } from '../../../../../common/endpoint/types'; import { DETECTION_ENGINE_QUERY_SIGNALS_URL, @@ -15,6 +16,7 @@ import { DETECTION_ENGINE_PRIVILEGES_URL, ALERTS_AS_DATA_FIND_URL, DETECTION_ENGINE_ALERTS_INDEX_URL, + DETECTION_ENGINE_ALERT_SUGGEST_USERS_URL, } from '../../../../../common/constants'; import { HOST_METADATA_GET_ROUTE } from '../../../../../common/endpoint/constants'; import { KibanaServices } from '../../../../common/lib/kibana'; @@ -28,6 +30,7 @@ import type { CasesFromAlertsResponse, CheckSignalIndex, UpdateAlertStatusByIdsProps, + SuggestUsersProps, } from './types'; import { isolateHost, unIsolateHost } from '../../../../common/lib/endpoint_isolation'; import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables'; @@ -256,3 +259,19 @@ export const getHostMetadata = async ({ resolvePathVariables(HOST_METADATA_GET_ROUTE, { id: agentId }), { method: 'GET', signal, version: '2023-10-31' } ); + +/** + * Fetches suggested user profiles + */ +export const suggestUsers = async ({ + searchTerm, +}: SuggestUsersProps): Promise => { + return KibanaServices.get().http.fetch( + DETECTION_ENGINE_ALERT_SUGGEST_USERS_URL, + { + method: 'GET', + version: '2023-10-31', + query: { searchTerm }, + } + ); +}; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts index 8782f9ecad631..94d6112c3adbe 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/mock.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; import type { HostIsolationResponse } from '../../../../../common/endpoint/types/actions'; import type { AlertSearchResponse, AlertsIndex, Privilege, CasesFromAlertsResponse } from './types'; @@ -1334,3 +1335,8 @@ export const mockCaseIdsFromAlertId: CasesFromAlertsResponse = [ { id: '818601a0-b26b-11eb-8759-6b318e8cf4bc', title: 'Case 1' }, { id: '8a774850-b26b-11eb-8759-6b318e8cf4bc', title: 'Case 2' }, ]; + +export const mockUserProfiles: UserProfileWithAvatar[] = [ + { uid: 'user-id-1', enabled: true, user: { username: 'user1' }, data: {} }, + { uid: 'user-id-2', enabled: true, user: { username: 'user2' }, data: {} }, +]; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/translations.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/translations.ts index 1f667cc42be1e..3af1ddd6c0fce 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/translations.ts @@ -30,3 +30,8 @@ export const CASES_FROM_ALERTS_FAILURE = i18n.translate( 'xpack.securitySolution.endpoint.hostIsolation.casesFromAlerts.title', { defaultMessage: 'Failed to find associated cases' } ); + +export const USER_PROFILES_FAILURE = i18n.translate( + 'xpack.securitySolution.containers.detectionEngine.users.userProfiles.title', + { defaultMessage: 'Failed to find users' } +); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts index 0f89ac8f451b5..4ee41993995d9 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts @@ -118,3 +118,7 @@ export interface Privilege { is_authenticated: boolean; has_encryption_key: boolean; } + +export interface SuggestUsersProps { + searchTerm: string; +} diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_get_user_profiles.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_get_user_profiles.test.tsx new file mode 100644 index 0000000000000..709dd27e1b82b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_get_user_profiles.test.tsx @@ -0,0 +1,49 @@ +/* + * 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. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { useGetUserProfiles } from './use_get_user_profiles'; +import { useKibana } from '../../../../common/lib/kibana'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { useAppToastsMock } from '../../../../common/hooks/use_app_toasts.mock'; +import { mockUserProfiles } from './mock'; +import { createStartServicesMock } from '../../../../common/lib/kibana/kibana_react.mock'; +import { securityMock } from '@kbn/security-plugin/public/mocks'; + +jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../common/hooks/use_app_toasts'); + +describe('useGetUserProfiles hook', () => { + let appToastsMock: jest.Mocked>; + beforeEach(() => { + jest.clearAllMocks(); + appToastsMock = useAppToastsMock.create(); + (useAppToasts as jest.Mock).mockReturnValue(appToastsMock); + const security = securityMock.createStart(); + security.userProfiles.bulkGet.mockReturnValue(Promise.resolve(mockUserProfiles)); + (useKibana as jest.Mock).mockReturnValue({ + services: { + ...createStartServicesMock(), + security, + }, + }); + }); + + it('returns an array of userProfiles', async () => { + const userProfiles = useKibana().services.security.userProfiles; + const spyOnUserProfiles = jest.spyOn(userProfiles, 'bulkGet'); + const assigneesIds = ['user1']; + const { result, waitForNextUpdate } = renderHook(() => useGetUserProfiles(assigneesIds)); + await waitForNextUpdate(); + + expect(spyOnUserProfiles).toHaveBeenCalledTimes(1); + expect(result.current).toEqual({ + loading: false, + userProfiles: mockUserProfiles, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_get_user_profiles.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_get_user_profiles.tsx new file mode 100644 index 0000000000000..bea9301bdbdd1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_get_user_profiles.tsx @@ -0,0 +1,55 @@ +/* + * 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. + */ + +import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import { useEffect, useState } from 'react'; +import { useKibana } from '../../../../common/lib/kibana'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { USER_PROFILES_FAILURE } from './translations'; + +interface GetUserProfilesReturn { + loading: boolean; + userProfiles: UserProfileWithAvatar[]; +} + +export const useGetUserProfiles = (userIds: string[]): GetUserProfilesReturn => { + const [loading, setLoading] = useState(false); + const [users, setUsers] = useState([]); + const { addError } = useAppToasts(); + const userProfiles = useKibana().services.security.userProfiles; + + useEffect(() => { + // isMounted tracks if a component is mounted before changing state + let isMounted = true; + setLoading(true); + const fetchData = async () => { + try { + const profiles = + userIds.length > 0 + ? await userProfiles.bulkGet({ + uids: new Set(userIds), + dataPath: 'avatar', + }) + : []; + if (isMounted) { + setUsers(profiles); + } + } catch (error) { + addError(error.message, { title: USER_PROFILES_FAILURE }); + } + if (isMounted) { + setLoading(false); + } + }; + fetchData(); + return () => { + // updates to show component is unmounted + isMounted = false; + }; + }, [addError, userProfiles, userIds]); + return { loading, userProfiles: users }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_suggest_users.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_suggest_users.test.tsx new file mode 100644 index 0000000000000..f22e0cdd8f59a --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_suggest_users.test.tsx @@ -0,0 +1,36 @@ +/* + * 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. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { useSuggestUsers } from './use_suggest_users'; +import * as api from './api'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { useAppToastsMock } from '../../../../common/hooks/use_app_toasts.mock'; +import { mockUserProfiles } from './mock'; + +jest.mock('./api'); +jest.mock('../../../../common/hooks/use_app_toasts'); + +describe('useSuggestUsers hook', () => { + let appToastsMock: jest.Mocked>; + beforeEach(() => { + jest.clearAllMocks(); + appToastsMock = useAppToastsMock.create(); + (useAppToasts as jest.Mock).mockReturnValue(appToastsMock); + }); + + it('returns an array of userProfiles', async () => { + const spyOnUserProfiles = jest.spyOn(api, 'suggestUsers'); + const { result, waitForNextUpdate } = renderHook(() => useSuggestUsers('')); + await waitForNextUpdate(); + expect(spyOnUserProfiles).toHaveBeenCalledTimes(1); + expect(result.current).toEqual({ + loading: false, + userProfiles: mockUserProfiles, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_suggest_users.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_suggest_users.tsx new file mode 100644 index 0000000000000..3d44d1ef5596e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_suggest_users.tsx @@ -0,0 +1,48 @@ +/* + * 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. + */ + +import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import { useEffect, useState } from 'react'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { suggestUsers } from './api'; +import { USER_PROFILES_FAILURE } from './translations'; + +interface SuggestUsersReturn { + loading: boolean; + userProfiles: UserProfileWithAvatar[]; +} + +export const useSuggestUsers = (searchTerm: string): SuggestUsersReturn => { + const [loading, setLoading] = useState(false); + const [users, setUsers] = useState([]); + const { addError } = useAppToasts(); + + useEffect(() => { + // isMounted tracks if a component is mounted before changing state + let isMounted = true; + setLoading(true); + const fetchData = async () => { + try { + const usersResponse = await suggestUsers({ searchTerm }); + if (isMounted) { + setUsers(usersResponse); + } + } catch (error) { + addError(error.message, { title: USER_PROFILES_FAILURE }); + } + if (isMounted) { + setLoading(false); + } + }; + fetchData(); + return () => { + // updates to show component is unmounted + isMounted = false; + }; + }, [addError, searchTerm]); + return { loading, userProfiles: users }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_bulk_actions.tsx b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_bulk_actions.tsx index 30e86f6185c33..495f3eedeaaa7 100644 --- a/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_bulk_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_bulk_actions.tsx @@ -13,6 +13,7 @@ import type { Filter } from '@kbn/es-query'; import { useCallback } from 'react'; import type { TableId } from '@kbn/securitysolution-data-table'; import { useBulkAlertTagsItems } from '../../../common/components/toolbar/bulk_actions/use_bulk_alert_tags_items'; +import { useBulkAlertAssigneesItems } from '../../../common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items'; import type { inputsModel, State } from '../../../common/store'; import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; import { inputsSelectors } from '../../../common/store'; @@ -93,7 +94,11 @@ export const getBulkActionHook = refetch: refetchGlobalQuery, }); - const items = [...alertActions, timelineAction, ...alertTagsItems]; + const { alertAssigneesItems, alertAssigneesPanels } = useBulkAlertAssigneesItems({ + refetch: refetchGlobalQuery, + }); + + const items = [...alertActions, timelineAction, ...alertTagsItems, ...alertAssigneesItems]; - return [{ id: 0, items }, ...alertTagsPanels]; + return [{ id: 0, items }, ...alertTagsPanels, ...alertAssigneesPanels]; }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/constants.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/constants.tsx index 9308204e69318..4c3c62b5a61f8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/constants.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/constants.tsx @@ -17,6 +17,7 @@ export const REFERENCE_URL_FIELD_NAME = 'reference.url'; export const EVENT_URL_FIELD_NAME = 'event.url'; export const SIGNAL_RULE_NAME_FIELD_NAME = 'kibana.alert.rule.name'; export const SIGNAL_STATUS_FIELD_NAME = 'kibana.alert.workflow_status'; +export const SIGNAL_ASSIGNEE_IDS_FIELD_NAME = 'kibana.alert.workflow_assignee_ids'; export const AGENT_STATUS_FIELD_NAME = 'agent.status'; export const QUARANTINED_PATH_FIELD_NAME = 'quarantined.path'; export const REASON_FIELD_NAME = 'kibana.alert.reason'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts index 78d8eecc109f3..2f6ea38b40b81 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -506,6 +506,11 @@ export const getSignalsMigrationStatusRequest = () => query: getSignalsMigrationStatusSchemaMock(), }); +export const getMockUserProfiles = () => [ + { uid: 'default-test-assignee-id-1', enabled: true, user: { username: 'user1' }, data: {} }, + { uid: 'default-test-assignee-id-2', enabled: true, user: { username: 'user2' }, data: {} }, +]; + /** * @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function */ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/helpers.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/helpers.ts index a557586a008fd..ce563ae0384f3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/helpers.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/helpers.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { AlertTags } from '../../../../../common/api/detection_engine'; +import type { AlertTags, AlertAssignees } from '../../../../../common/api/detection_engine'; import * as i18n from './translations'; export const validateAlertTagsArrays = (tags: AlertTags, ids: string[]) => { @@ -20,3 +20,16 @@ export const validateAlertTagsArrays = (tags: AlertTags, ids: string[]) => { } return validationErrors; }; + +export const validateAlertAssigneesArrays = (assignees: AlertAssignees, ids: string[]) => { + const validationErrors = []; + if (ids.length === 0) { + validationErrors.push(i18n.NO_IDS_VALIDATION_ERROR); + } + const { assignees_to_add: assigneesToAdd, assignees_to_remove: assigneesToRemove } = assignees; + const duplicates = assigneesToAdd.filter((assignee) => assigneesToRemove.includes(assignee)); + if (duplicates.length) { + validationErrors.push(i18n.ALERT_ASSIGNEES_VALIDATION_ERROR(JSON.stringify(duplicates))); + } + return validationErrors; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_assignees_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_assignees_route.test.ts new file mode 100644 index 0000000000000..c92fa9a70c86d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_assignees_route.test.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getSetAlertAssigneesRequestMock } from '../../../../../common/api/detection_engine/alert_assignees/mocks'; +import { DETECTION_ENGINE_ALERT_ASSIGNEES_URL } from '../../../../../common/constants'; +import { requestContextMock, serverMock, requestMock } from '../__mocks__'; +import { getSuccessfulSignalUpdateResponse } from '../__mocks__/request_responses'; +import { setAlertAssigneesRoute } from './set_alert_assignees_route'; + +describe('setAlertAssigneesRoute', () => { + let server: ReturnType; + let request: ReturnType; + let { context } = requestContextMock.createTools(); + + beforeEach(() => { + server = serverMock.create(); + ({ context } = requestContextMock.createTools()); + setAlertAssigneesRoute(server.router); + }); + + describe('happy path', () => { + test('returns 200 when adding/removing empty arrays of assignees', async () => { + request = requestMock.create({ + method: 'post', + path: DETECTION_ENGINE_ALERT_ASSIGNEES_URL, + body: getSetAlertAssigneesRequestMock(['assignee-id-1'], ['assignee-id-2'], ['alert-id']), + }); + + context.core.elasticsearch.client.asCurrentUser.bulk.mockResponse({ + errors: false, + took: 0, + items: [{ update: { result: 'updated', status: 200, _index: 'test-index' } }], + }); + + const response = await server.inject(request, requestContextMock.convertContext(context)); + + expect(response.status).toEqual(200); + }); + }); + + describe('validation', () => { + test('returns 400 if duplicate assignees are in both the add and remove arrays', async () => { + request = requestMock.create({ + method: 'post', + path: DETECTION_ENGINE_ALERT_ASSIGNEES_URL, + body: getSetAlertAssigneesRequestMock(['assignee-id-1'], ['assignee-id-1'], ['test-id']), + }); + + context.core.elasticsearch.client.asCurrentUser.updateByQuery.mockResponse( + getSuccessfulSignalUpdateResponse() + ); + + const response = await server.inject(request, requestContextMock.convertContext(context)); + + context.core.elasticsearch.client.asCurrentUser.updateByQuery.mockRejectedValue( + new Error('Test error') + ); + + expect(response.body).toEqual({ + message: [ + `Duplicate assignees [\"assignee-id-1\"] were found in the assignees_to_add and assignees_to_remove parameters.`, + ], + status_code: 400, + }); + }); + + test('returns 400 if no alert ids are provided', async () => { + request = requestMock.create({ + method: 'post', + path: DETECTION_ENGINE_ALERT_ASSIGNEES_URL, + body: getSetAlertAssigneesRequestMock(['assignee-id-1'], ['assignee-id-2']), + }); + + context.core.elasticsearch.client.asCurrentUser.updateByQuery.mockResponse( + getSuccessfulSignalUpdateResponse() + ); + + const response = await server.inject(request, requestContextMock.convertContext(context)); + + context.core.elasticsearch.client.asCurrentUser.updateByQuery.mockRejectedValue( + new Error('Test error') + ); + + expect(response.body).toEqual({ + message: [`No alert ids were provided`], + status_code: 400, + }); + }); + }); + + describe('500s', () => { + test('returns 500 if asCurrentUser throws error', async () => { + request = requestMock.create({ + method: 'post', + path: DETECTION_ENGINE_ALERT_ASSIGNEES_URL, + body: getSetAlertAssigneesRequestMock(['assignee-id-1'], ['assignee-id-2'], ['test-id']), + }); + + context.core.elasticsearch.client.asCurrentUser.updateByQuery.mockRejectedValue( + new Error('Test error') + ); + + const response = await server.inject(request, requestContextMock.convertContext(context)); + + expect(response.body).toEqual({ + message: 'Test error', + status_code: 500, + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_assignees_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_assignees_route.ts new file mode 100644 index 0000000000000..4498041e54286 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/set_alert_assignees_route.ts @@ -0,0 +1,123 @@ +/* + * 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. + */ + +import { transformError } from '@kbn/securitysolution-es-utils'; +import { uniq } from 'lodash/fp'; +import type { SetAlertAssigneesRequestBodyDecoded } from '../../../../../common/api/detection_engine/alert_assignees'; +import { setAlertAssigneesRequestBody } from '../../../../../common/api/detection_engine/alert_assignees'; +import type { SecuritySolutionPluginRouter } from '../../../../types'; +import { + DEFAULT_ALERTS_INDEX, + DETECTION_ENGINE_ALERT_ASSIGNEES_URL, +} from '../../../../../common/constants'; +import { buildSiemResponse } from '../utils'; +import { buildRouteValidation } from '../../../../utils/build_validation/route_validation'; +import { validateAlertAssigneesArrays } from './helpers'; + +export const setAlertAssigneesRoute = (router: SecuritySolutionPluginRouter) => { + router.versioned + .post({ + path: DETECTION_ENGINE_ALERT_ASSIGNEES_URL, + access: 'public', + options: { + tags: ['access:securitySolution'], + }, + }) + .addVersion( + { + version: '2023-10-31', + validate: { + request: { + body: buildRouteValidation< + typeof setAlertAssigneesRequestBody, + SetAlertAssigneesRequestBodyDecoded + >(setAlertAssigneesRequestBody), + }, + }, + }, + async (context, request, response) => { + const { assignees, ids } = request.body; + const core = await context.core; + const securitySolution = await context.securitySolution; + const esClient = core.elasticsearch.client.asCurrentUser; + const siemClient = securitySolution?.getAppClient(); + const siemResponse = buildSiemResponse(response); + const validationErrors = validateAlertAssigneesArrays(assignees, ids); + const spaceId = securitySolution?.getSpaceId() ?? 'default'; + + if (validationErrors.length) { + return siemResponse.error({ statusCode: 400, body: validationErrors }); + } + + if (!siemClient) { + return siemResponse.error({ statusCode: 404 }); + } + + const assigneesToAdd = uniq(assignees.assignees_to_add); + const assigneesToRemove = uniq(assignees.assignees_to_remove); + + const painlessScript = { + params: { assigneesToAdd, assigneesToRemove }, + source: `List newAssigneesArray = []; + if (ctx._source["kibana.alert.workflow_assignee_ids"] != null) { + for (assignee in ctx._source["kibana.alert.workflow_assignee_ids"]) { + if (!params.assigneesToRemove.contains(assignee)) { + newAssigneesArray.add(assignee); + } + } + for (assignee in params.assigneesToAdd) { + if (!newAssigneesArray.contains(assignee)) { + newAssigneesArray.add(assignee) + } + } + ctx._source["kibana.alert.workflow_assignee_ids"] = newAssigneesArray; + } else { + ctx._source["kibana.alert.workflow_assignee_ids"] = params.assigneesToAdd; + } + `, + lang: 'painless', + }; + + const bulkUpdateRequest = []; + for (const id of ids) { + bulkUpdateRequest.push( + { + update: { + _index: `${DEFAULT_ALERTS_INDEX}-${spaceId}`, + _id: id, + }, + }, + { + script: painlessScript, + } + ); + } + + try { + const body = await esClient.updateByQuery({ + index: `${DEFAULT_ALERTS_INDEX}-${spaceId}`, + refresh: false, + body: { + script: painlessScript, + query: { + bool: { + filter: { terms: { _id: ids } }, + }, + }, + }, + }); + return response.ok({ body }); + } catch (err) { + const error = transformError(err); + return siemResponse.error({ + body: error.message, + statusCode: error.statusCode, + }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/translations.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/translations.ts index 715537fee47ab..704e06e96e5bb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/translations.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/translations.ts @@ -20,3 +20,10 @@ export const NO_IDS_VALIDATION_ERROR = i18n.translate( defaultMessage: 'No alert ids were provided', } ); + +export const ALERT_ASSIGNEES_VALIDATION_ERROR = (duplicates: string) => + i18n.translate('xpack.securitySolution.api.alertAssignees.validationError', { + values: { duplicates }, + defaultMessage: + 'Duplicate assignees { duplicates } were found in the assignees_to_add and assignees_to_remove parameters.', + }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/users/suggest_user_profiles_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/users/suggest_user_profiles_route.test.ts new file mode 100644 index 0000000000000..bd36547a5c964 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/users/suggest_user_profiles_route.test.ts @@ -0,0 +1,65 @@ +/* + * 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. + */ + +import { securityMock } from '@kbn/security-plugin/server/mocks'; + +import { DETECTION_ENGINE_ALERT_ASSIGNEES_URL } from '../../../../../common/constants'; +import { requestContextMock, serverMock, requestMock } from '../__mocks__'; +import { getMockUserProfiles } from '../__mocks__/request_responses'; +import { suggestUserProfilesRoute } from './suggest_user_profiles_route'; + +describe('suggestUserProfilesRoute', () => { + let server: ReturnType; + let { context } = requestContextMock.createTools(); + let mockSecurityStart: ReturnType; + let getStartServicesMock: jest.Mock; + + beforeEach(() => { + server = serverMock.create(); + ({ context } = requestContextMock.createTools()); + mockSecurityStart = securityMock.createStart(); + mockSecurityStart.userProfiles.suggest.mockResolvedValue(getMockUserProfiles()); + }); + + const buildRequest = () => { + return requestMock.create({ + method: 'get', + path: DETECTION_ENGINE_ALERT_ASSIGNEES_URL, + body: { searchTerm: '' }, + }); + }; + + describe('normal status codes', () => { + beforeEach(() => { + getStartServicesMock = jest.fn().mockResolvedValue([{}, { security: mockSecurityStart }]); + suggestUserProfilesRoute(server.router, getStartServicesMock); + }); + + it('returns 200 when doing a normal request', async () => { + const request = buildRequest(); + const response = await server.inject(request, requestContextMock.convertContext(context)); + expect(response.status).toEqual(200); + }); + + test('returns the payload when doing a normal request', async () => { + const request = buildRequest(); + const response = await server.inject(request, requestContextMock.convertContext(context)); + const expectedBody = getMockUserProfiles(); + expect(response.status).toEqual(200); + expect(response.body).toEqual(expectedBody); + }); + + test('returns 500 if `security.userProfiles.suggest` throws error', async () => { + mockSecurityStart.userProfiles.suggest.mockRejectedValue(new Error('something went wrong')); + const request = buildRequest(); + const response = await server.inject(request, requestContextMock.convertContext(context)); + + expect(response.status).toEqual(500); + expect(response.body.message).toEqual('something went wrong'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/users/suggest_user_profiles_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/users/suggest_user_profiles_route.ts new file mode 100644 index 0000000000000..6b48dfcf84380 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/users/suggest_user_profiles_route.ts @@ -0,0 +1,73 @@ +/* + * 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. + */ + +import type { IKibanaResponse, StartServicesAccessor } from '@kbn/core/server'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; +import type { SecuritySolutionPluginRouter } from '../../../../types'; +import { DETECTION_ENGINE_ALERT_SUGGEST_USERS_URL } from '../../../../../common/constants'; +import { buildSiemResponse } from '../utils'; +import type { StartPlugins } from '../../../../plugin'; +import { buildRouteValidation } from '../../../../utils/build_validation/route_validation'; + +import type { SuggestUserProfilesRequestQueryDecoded } from '../../../../../common/api/detection_engine/users'; +import { suggestUserProfilesRequestQuery } from '../../../../../common/api/detection_engine/users'; + +export const suggestUserProfilesRoute = ( + router: SecuritySolutionPluginRouter, + getStartServices: StartServicesAccessor +) => { + router.versioned + .get({ + path: DETECTION_ENGINE_ALERT_SUGGEST_USERS_URL, + access: 'public', + options: { + tags: ['access:securitySolution'], + }, + }) + .addVersion( + { + version: '2023-10-31', + validate: { + request: { + query: buildRouteValidation< + typeof suggestUserProfilesRequestQuery, + SuggestUserProfilesRequestQueryDecoded + >(suggestUserProfilesRequestQuery), + }, + }, + }, + async (context, request, response): Promise> => { + const { searchTerm } = request.query; + const siemResponse = buildSiemResponse(response); + const [_, { security }] = await getStartServices(); + const securitySolution = await context.securitySolution; + const spaceId = securitySolution.getSpaceId(); + + try { + const users = await security.userProfiles.suggest({ + name: searchTerm, + dataPath: 'avatar', + requiredPrivileges: { + spaceId, + privileges: { + kibana: [security.authz.actions.login], + }, + }, + }); + + return response.ok({ body: users }); + } catch (err) { + const error = transformError(err); + return siemResponse.error({ + body: error.message, + statusCode: error.statusCode, + }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/routes/index.ts b/x-pack/plugins/security_solution/server/routes/index.ts index d3786ea8acb88..180a30f6da29a 100644 --- a/x-pack/plugins/security_solution/server/routes/index.ts +++ b/x-pack/plugins/security_solution/server/routes/index.ts @@ -74,6 +74,7 @@ import { registerManageExceptionsRoutes } from '../lib/exceptions/api/register_r import { registerDashboardsRoutes } from '../lib/dashboards/routes'; import { registerTagsRoutes } from '../lib/tags/routes'; import { setAlertTagsRoute } from '../lib/detection_engine/routes/signals/set_alert_tags_route'; +import { setAlertAssigneesRoute } from '../lib/detection_engine/routes/signals/set_alert_assignees_route'; import { riskScorePreviewRoute, riskEngineDisableRoute, @@ -82,6 +83,7 @@ import { riskEngineStatusRoute, } from '../lib/risk_engine/routes'; import { riskScoreCalculationRoute } from '../lib/risk_engine/routes/risk_score_calculation_route'; +import { suggestUserProfilesRoute } from '../lib/detection_engine/routes/users/suggest_user_profiles_route'; export const initRoutes = ( router: SecuritySolutionPluginRouter, @@ -144,11 +146,13 @@ export const initRoutes = ( // Example usage can be found in security_solution/server/lib/detection_engine/scripts/signals setSignalsStatusRoute(router, logger, security, telemetrySender); setAlertTagsRoute(router); + setAlertAssigneesRoute(router); querySignalsRoute(router, ruleDataClient); getSignalsMigrationStatusRoute(router); createSignalsMigrationRoute(router, security); finalizeSignalsMigrationRoute(router, ruleDataService, security); deleteSignalsMigrationRoute(router, security); + suggestUserProfilesRoute(router, getStartServices); // Detection Engine index routes that have the REST endpoints of /api/detection_engine/index // All REST index creation, policy management for spaces diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/bulk_actions.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/bulk_actions.test.tsx index e028ae1867a8e..c59c4cade2aca 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/bulk_actions.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/bulk_actions.test.tsx @@ -393,6 +393,10 @@ describe('AlertsTable.BulkActions', () => { field: 'kibana.alert.workflow_tags', value: [], }, + { + field: 'kibana.alert.workflow_assignee_ids', + value: [], + }, ], ecs: { _id: 'alert0', @@ -640,6 +644,10 @@ describe('AlertsTable.BulkActions', () => { field: 'kibana.alert.workflow_tags', value: [], }, + { + field: 'kibana.alert.workflow_assignee_ids', + value: [], + }, ], ecs: { _id: 'alert1', @@ -868,6 +876,10 @@ describe('AlertsTable.BulkActions', () => { field: 'kibana.alert.workflow_tags', value: [], }, + { + field: 'kibana.alert.workflow_assignee_ids', + value: [], + }, ], ecs: { _id: 'alert0', @@ -894,6 +906,10 @@ describe('AlertsTable.BulkActions', () => { field: 'kibana.alert.workflow_tags', value: [], }, + { + field: 'kibana.alert.workflow_assignee_ids', + value: [], + }, ], ecs: { _id: 'alert1', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/components/toolbar.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/components/toolbar.tsx index f75dbc43c1fe0..ef3ba30e12082 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/components/toolbar.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/components/toolbar.tsx @@ -13,6 +13,7 @@ import { ALERT_CASE_IDS, ALERT_RULE_NAME, ALERT_RULE_UUID, + ALERT_WORKFLOW_ASSIGNEE_IDS, ALERT_WORKFLOW_TAGS, } from '@kbn/rule-data-utils'; import { @@ -64,6 +65,7 @@ const selectedIdsToTimelineItemMapper = ( { field: ALERT_RULE_UUID, value: alert[ALERT_RULE_UUID] }, { field: ALERT_CASE_IDS, value: alert[ALERT_CASE_IDS] ?? [] }, { field: ALERT_WORKFLOW_TAGS, value: alert[ALERT_WORKFLOW_TAGS] ?? [] }, + { field: ALERT_WORKFLOW_ASSIGNEE_IDS, value: alert[ALERT_WORKFLOW_ASSIGNEE_IDS] ?? [] }, ], ecs: { _id: alert._id, diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/esql.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/esql.ts index daac0f6c17ddd..a290f064295a8 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/esql.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/esql.ts @@ -150,6 +150,7 @@ export default ({ getService }: FtrProviderContext) => { 'kibana.alert.rule.updated_by': 'elastic', 'kibana.alert.rule.version': 1, 'kibana.alert.workflow_tags': [], + 'kibana.alert.workflow_assignee_ids': [], 'kibana.alert.rule.risk_score': 55, 'kibana.alert.rule.severity': 'high', }); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_alerts/alert_assignees.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_alerts/alert_assignees.cy.ts new file mode 100644 index 0000000000000..a281df40ac994 --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_alerts/alert_assignees.cy.ts @@ -0,0 +1,75 @@ +/* + * 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. + */ + +import { getNewRule } from '../../objects/rule'; +import { + clickAlertAssignee, + findSelectedAlertAssignee, + findUnselectedAlertAssignee, + openAlertAssigningBulkActionMenu, + selectNumberOfAlerts, + updateAlertAssignees, +} from '../../tasks/alerts'; +import { createRule } from '../../tasks/api_calls/rules'; +import { cleanKibana, deleteAlertsAndRules } from '../../tasks/common'; +import { login } from '../../tasks/login'; +import { visitWithTimeRange } from '../../tasks/navigation'; +import { ALERTS_URL } from '../../urls/navigation'; +import { waitForAlertsToPopulate } from '../../tasks/create_new_rule'; +import { ALERTS_TABLE_ROW_LOADER } from '../../screens/alerts'; +import { + waitForAssigneesToPopulatePopover, + waitForAssigneeToAppearInTable, + waitForAssigneeToDisappearInTable, +} from '../../tasks/alert_assignees'; + +describe('Alert assigning', { tags: ['@ess', '@serverless'] }, () => { + before(() => { + cleanKibana(); + cy.task('esArchiverResetKibana'); + }); + + beforeEach(() => { + login(); + deleteAlertsAndRules(); + cy.task('esArchiverLoad', { archiveName: 'endpoint' }); + createRule(getNewRule({ rule_id: 'new custom rule' })); + visitWithTimeRange(ALERTS_URL); + waitForAlertsToPopulate(); + }); + + afterEach(() => { + cy.task('esArchiverUnload', 'endpoint'); + }); + + it('Add and remove an assignee using the alert bulk action menu', () => { + const userName = Cypress.env('ELASTICSEARCH_USERNAME'); + + // Add an assignee to one alert + selectNumberOfAlerts(1); + openAlertAssigningBulkActionMenu(); + waitForAssigneesToPopulatePopover(); + clickAlertAssignee(userName); + updateAlertAssignees(); + cy.get(ALERTS_TABLE_ROW_LOADER).should('not.exist'); + waitForAssigneeToAppearInTable(userName); + selectNumberOfAlerts(1); + openAlertAssigningBulkActionMenu(); + waitForAssigneesToPopulatePopover(); + findSelectedAlertAssignee(userName); + + // Remove assignee from that alert + clickAlertAssignee(userName); + updateAlertAssignees(); + cy.get(ALERTS_TABLE_ROW_LOADER).should('not.exist'); + waitForAssigneeToDisappearInTable(userName); + selectNumberOfAlerts(1); + openAlertAssigningBulkActionMenu(); + waitForAssigneesToPopulatePopover(); + findUnselectedAlertAssignee(userName); + }); +}); diff --git a/x-pack/test/security_solution_cypress/cypress/screens/alerts.ts b/x-pack/test/security_solution_cypress/cypress/screens/alerts.ts index 86a1703959ea9..257180258c535 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/alerts.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/alerts.ts @@ -204,3 +204,16 @@ export const ALERT_RENDERER_HOST_NAME = '[data-test-subj="alertFieldBadge"] [data-test-subj="render-content-host.name"]'; export const HOVER_ACTIONS_CONTAINER = getDataTestSubjectSelector('hover-actions-container'); + +export const ALERT_ASSIGNING_CONTEXT_MENU_ITEM = + '[data-test-subj="alert-assignees-context-menu-item"]'; + +export const ALERT_ASSIGNING_SELECTABLE_MENU_ITEM = + '[data-test-subj="alert-assignees-selectable-menu"]'; + +export const ALERT_ASSIGNING_CONTEXT_MENU = '[data-test-subj="alert-assignees-selectable-menu"]'; + +export const ALERT_ASSIGNING_UPDATE_BUTTON = '[data-test-subj="alert-assignees-update-button"]'; + +export const ALERT_ASSIGNING_USER_AVATAR = (assignee: string) => + `[data-test-subj="alertTableAssigneeAvatar"][title='${assignee}']`; diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/alert_assignees.ts b/x-pack/test/security_solution_cypress/cypress/tasks/alert_assignees.ts new file mode 100644 index 0000000000000..8e89bc3e2d52c --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/tasks/alert_assignees.ts @@ -0,0 +1,55 @@ +/* + * 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. + */ + +import { ALERT_ASSIGNING_UPDATE_BUTTON, ALERT_ASSIGNING_USER_AVATAR } from '../screens/alerts'; + +export const waitForAssigneesToPopulatePopover = () => { + cy.waitUntil( + () => { + cy.log('Waiting for assignees to appear in popover'); + return cy.root().then(($el) => { + const $updateButton = $el.find(ALERT_ASSIGNING_UPDATE_BUTTON); + return !$updateButton.prop('disabled'); + }); + }, + { interval: 500, timeout: 12000 } + ); +}; + +export const waitForAssigneeToAppearInTable = (userName: string) => { + cy.reload(); + cy.waitUntil( + () => { + cy.log('Waiting for assignees to appear in the "Assignees" column'); + return cy.root().then(($el) => { + const assigneesState = $el.find(`.euiAvatar${ALERT_ASSIGNING_USER_AVATAR(userName)}`); + if (assigneesState.length > 0) { + return true; + } + return false; + }); + }, + { interval: 500, timeout: 12000 } + ); +}; + +export const waitForAssigneeToDisappearInTable = (userName: string) => { + cy.reload(); + cy.waitUntil( + () => { + cy.log('Waiting for assignees to disappear in the "Assignees" column'); + return cy.root().then(($el) => { + const assigneesState = $el.find(`.euiAvatar${ALERT_ASSIGNING_USER_AVATAR(userName)}`); + if (assigneesState.length > 0) { + return false; + } + return true; + }); + }, + { interval: 500, timeout: 12000 } + ); +}; diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/alerts.ts b/x-pack/test/security_solution_cypress/cypress/tasks/alerts.ts index 1bbdc9eac1539..1b4177ff3a874 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/alerts.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/alerts.ts @@ -47,6 +47,10 @@ import { ALERTS_HISTOGRAM_LEGEND, LEGEND_ACTIONS, SESSION_VIEWER_BUTTON, + ALERT_ASSIGNING_CONTEXT_MENU_ITEM, + ALERT_ASSIGNING_CONTEXT_MENU, + ALERT_ASSIGNING_SELECTABLE_MENU_ITEM, + ALERT_ASSIGNING_UPDATE_BUTTON, ALERT_TAGGING_CONTEXT_MENU_ITEM, ALERT_TAGGING_CONTEXT_MENU, ALERT_TAGGING_UPDATE_BUTTON, @@ -508,3 +512,30 @@ export const switchAlertTableToGridView = () => { cy.get(ALERT_TABLE_SUMMARY_VIEW_SELECTABLE).should('be.visible').trigger('click'); cy.get(ALERT_TABLE_GRID_VIEW_OPTION).should('be.visible').trigger('click'); }; + +export const openAlertAssigningBulkActionMenu = () => { + cy.get(TAKE_ACTION_POPOVER_BTN).click(); + cy.get(ALERT_ASSIGNING_CONTEXT_MENU_ITEM).click(); +}; + +export const clickAlertAssignee = (assignee: string) => { + cy.get(ALERT_ASSIGNING_CONTEXT_MENU).contains(assignee).click(); +}; + +export const updateAlertAssignees = () => { + cy.get(ALERT_ASSIGNING_UPDATE_BUTTON).click(); +}; + +export const findSelectedAlertAssignee = (assignee: string) => { + cy.get(ALERT_ASSIGNING_SELECTABLE_MENU_ITEM) + .find('[aria-checked="true"]') + .first() + .contains(assignee); +}; + +export const findUnselectedAlertAssignee = (assignee: string) => { + cy.get(ALERT_ASSIGNING_SELECTABLE_MENU_ITEM) + .find('[aria-checked="false"]') + .first() + .contains(assignee); +};