diff --git a/.buildkite/ftr_security_serverless_configs.yml b/.buildkite/ftr_security_serverless_configs.yml index 4c3b037ce9f8a9..9f54a402a8bbfe 100644 --- a/.buildkite/ftr_security_serverless_configs.yml +++ b/.buildkite/ftr_security_serverless_configs.yml @@ -35,6 +35,7 @@ enabled: - x-pack/test_serverless/functional/test_suites/security/config.cloud_security_posture.basic.ts - x-pack/test_serverless/functional/test_suites/security/config.cloud_security_posture.essentials.ts - x-pack/test_serverless/functional/test_suites/security/config.cloud_security_posture.agentless.ts + - x-pack/test_serverless/functional/test_suites/security/config.cloud_security_posture.agentless_api.ts - x-pack/test_serverless/functional/test_suites/security/config.saved_objects_management.ts - x-pack/test_serverless/functional/test_suites/security/config.context_awareness.ts - x-pack/test_serverless/functional/test_suites/security/common_configs/config.group1.ts diff --git a/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/breadcrumbs.tsx b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/breadcrumbs.tsx index fb1043d239523f..ac80702ab99ec0 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/breadcrumbs.tsx +++ b/packages/core/chrome/core-chrome-browser-internal/src/project_navigation/breadcrumbs.tsx @@ -144,6 +144,7 @@ function buildRootCrumb({ color="text" iconType="gear" data-test-subj="manageDeploymentBtn" + size="s" > {i18n.translate('core.ui.primaryNav.cloud.breadCrumbDropdown.manageDeploymentLabel', { defaultMessage: 'Manage this deployment', @@ -157,6 +158,7 @@ function buildRootCrumb({ color="text" iconType="spaces" data-test-subj="viewDeploymentsBtn" + size="s" > {cloudLinks.deployments.title} @@ -164,9 +166,9 @@ function buildRootCrumb({ ), popoverProps: { - panelPaddingSize: 'm', + panelPaddingSize: 's', zIndex: 6000, - panelStyle: { width: 260 }, + panelStyle: { maxWidth: 240 }, panelProps: { 'data-test-subj': 'deploymentLinksPanel', }, diff --git a/packages/kbn-alerts-grouping/index.ts b/packages/kbn-alerts-grouping/index.ts index e9e2476dde7a76..f124e57596191d 100644 --- a/packages/kbn-alerts-grouping/index.ts +++ b/packages/kbn-alerts-grouping/index.ts @@ -7,5 +7,9 @@ */ export { AlertsGrouping } from './src/components/alerts_grouping'; -export { type AlertsGroupingProps } from './src/types'; +export { + type AlertsGroupingProps, + type BaseAlertsGroupAggregations, + type AlertsGroupAggregationBucket, +} from './src/types'; export { useAlertsGroupingState } from './src/contexts/alerts_grouping_context'; diff --git a/packages/kbn-alerts-grouping/src/components/alerts_grouping.tsx b/packages/kbn-alerts-grouping/src/components/alerts_grouping.tsx index f17d7946683717..5db1ef5a5d0ff3 100644 --- a/packages/kbn-alerts-grouping/src/components/alerts_grouping.tsx +++ b/packages/kbn-alerts-grouping/src/components/alerts_grouping.tsx @@ -23,7 +23,7 @@ import { i18n } from '@kbn/i18n'; import { useAlertsDataView } from '@kbn/alerts-ui-shared/src/common/hooks/use_alerts_data_view'; import useLocalStorage from 'react-use/lib/useLocalStorage'; import { AlertsGroupingLevel, AlertsGroupingLevelProps } from './alerts_grouping_level'; -import { AlertsGroupingProps } from '../types'; +import type { AlertsGroupingProps, BaseAlertsGroupAggregations } from '../types'; import { AlertsGroupingContextProvider, useAlertsGroupingState, @@ -40,7 +40,10 @@ const NextLevel = ({ parentGroupingFilter, groupingFilters, getLevel, -}: Pick & { +}: Pick< + AlertsGroupingLevelProps, + 'children' | 'parentGroupingFilter' +> & { level: number; selectedGroups: string[]; groupingFilters: Filter[]; @@ -56,7 +59,9 @@ const NextLevel = ({ return children(nextGroupingFilters)!; }; -const AlertsGroupingInternal = (props: AlertsGroupingProps) => { +const AlertsGroupingInternal = ( + props: AlertsGroupingProps +) => { const { groupingId, services, @@ -230,6 +235,8 @@ const AlertsGroupingInternal = (props: AlertsGroupingProps) => { return getLevel(0, selectedGroups[0]); }; +const typedMemo: (c: T) => T = memo; + /** * A coordinator component to show multiple alert tables grouped by one or more fields * @@ -243,7 +250,7 @@ const AlertsGroupingInternal = (props: AlertsGroupingProps) => { * * * return ( - * * featureIds={[...]} * globalQuery={{ query: ..., language: 'kql' }} * globalFilters={...} @@ -274,11 +281,25 @@ const AlertsGroupingInternal = (props: AlertsGroupingProps) => { * * ); * ``` + * + * To define your aggregations result type, extend the `BaseAlertsGroupAggregations` type: + * + * ```ts + * import { BaseAlertsGroupAggregations } from '@kbn/alerts-grouping'; + * + * interface YourAggregationsType extends BaseAlertsGroupAggregations { + * // Your custom aggregations here + * } + * ``` + * + * Check {@link useGetAlertsGroupAggregationsQuery} for more info on alerts aggregations. */ -export const AlertsGrouping = memo((props: AlertsGroupingProps) => { - return ( - - - - ); -}); +export const AlertsGrouping = typedMemo( + (props: AlertsGroupingProps) => { + return ( + + + + ); + } +); diff --git a/packages/kbn-alerts-grouping/src/components/alerts_grouping_level.tsx b/packages/kbn-alerts-grouping/src/components/alerts_grouping_level.tsx index e4511e8dea774e..a82818215cbf4d 100644 --- a/packages/kbn-alerts-grouping/src/components/alerts_grouping_level.tsx +++ b/packages/kbn-alerts-grouping/src/components/alerts_grouping_level.tsx @@ -18,10 +18,11 @@ import { useGetAlertsGroupAggregationsQuery, UseGetAlertsGroupAggregationsQueryProps, } from '@kbn/alerts-ui-shared'; -import { AlertsGroupingProps } from '../types'; +import { AlertsGroupingProps, BaseAlertsGroupAggregations } from '../types'; -export interface AlertsGroupingLevelProps = {}> - extends AlertsGroupingProps { +export interface AlertsGroupingLevelProps< + T extends BaseAlertsGroupAggregations = BaseAlertsGroupAggregations +> extends AlertsGroupingProps { getGrouping: ( props: Omit, 'groupSelector' | 'pagination'> ) => ReactElement; @@ -40,8 +41,9 @@ const DEFAULT_FILTERS: Filter[] = []; /** * Renders an alerts grouping level */ -export const AlertsGroupingLevel = memo( - = {}>({ +const typedMemo: (c: T) => T = memo; +export const AlertsGroupingLevel = typedMemo( + ({ featureIds, defaultFilters = DEFAULT_FILTERS, from, diff --git a/packages/kbn-alerts-grouping/src/types.ts b/packages/kbn-alerts-grouping/src/types.ts index 8d226bb74e71f1..835941e8db95d5 100644 --- a/packages/kbn-alerts-grouping/src/types.ts +++ b/packages/kbn-alerts-grouping/src/types.ts @@ -29,7 +29,9 @@ export interface AlertsGroupingState { [groupingId: string]: GroupModel; } -export interface AlertsGroupingProps = {}> { +export interface AlertsGroupingProps< + T extends BaseAlertsGroupAggregations = BaseAlertsGroupAggregations +> { /** * The leaf component that will be rendered in the grouping panels */ @@ -96,3 +98,26 @@ export interface AlertsGroupingProps = {}> { http: HttpSetup; }; } + +export interface AlertsGroupAggregationBucket { + key: string; + doc_count: number; + isNullGroup?: boolean; + unitsCount?: { + value: number; + }; +} + +export interface BaseAlertsGroupAggregations { + groupByFields: { + doc_count_error_upper_bound: number; + sum_other_doc_count: number; + buckets: AlertsGroupAggregationBucket[]; + }; + groupsCount: { + value: number; + }; + unitsCount: { + value: number; + }; +} diff --git a/packages/kbn-alerts-ui-shared/src/common/hooks/use_get_alerts_group_aggregations_query.ts b/packages/kbn-alerts-ui-shared/src/common/hooks/use_get_alerts_group_aggregations_query.ts index eab5b9ac510fac..e9e24b7a20d5e9 100644 --- a/packages/kbn-alerts-ui-shared/src/common/hooks/use_get_alerts_group_aggregations_query.ts +++ b/packages/kbn-alerts-ui-shared/src/common/hooks/use_get_alerts_group_aggregations_query.ts @@ -45,6 +45,8 @@ export interface UseGetAlertsGroupAggregationsQueryProps { * * The provided `aggregations` are applied within `groupByFields`. Here the `groupByField` runtime * field can be used to perform grouping-based aggregations. + * `groupByField` buckets computed over a field with a null/absent value are marked with the + * `isNullGroup` flag set to true and their key is set to the `--` string. * * Applies alerting RBAC through featureIds. */ diff --git a/packages/kbn-unified-doc-viewer/src/components/field_name/field_name.tsx b/packages/kbn-unified-doc-viewer/src/components/field_name/field_name.tsx index d7f380195947a3..4c57fa6cc0dc9e 100644 --- a/packages/kbn-unified-doc-viewer/src/components/field_name/field_name.tsx +++ b/packages/kbn-unified-doc-viewer/src/components/field_name/field_name.tsx @@ -8,14 +8,7 @@ import React from 'react'; import './field_name.scss'; -import { - EuiBadge, - EuiFlexGroup, - EuiFlexItem, - EuiToolTip, - EuiHighlight, - EuiIcon, -} from '@elastic/eui'; +import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiToolTip, EuiHighlight } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import { FieldIcon, FieldIconProps } from '@kbn/react-field'; @@ -30,7 +23,6 @@ interface Props { fieldIconProps?: Omit; scripted?: boolean; highlight?: string; - isPinned?: boolean; } export function FieldName({ @@ -40,7 +32,6 @@ export function FieldName({ fieldIconProps, scripted = false, highlight = '', - isPinned = false, }: Props) { const typeName = getFieldTypeName(fieldType); const displayName = @@ -63,17 +54,6 @@ export function FieldName({ - {isPinned && ( - - - - )} diff --git a/src/core/server/integration_tests/saved_objects/migrations/group2/multiple_kibana_nodes.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group2/multiple_kibana_nodes.test.ts index c02f9bc47d36eb..8d6f83736d0104 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group2/multiple_kibana_nodes.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group2/multiple_kibana_nodes.test.ts @@ -182,7 +182,8 @@ describe('migration v2', () => { errors.push(err.message); }) ); - if (i < instances.length - 1) { + if (i < instances.length - 2) { + // We wait between instances, but not after the last one await delay(delayInSec * 1000); } } diff --git a/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx b/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx index 097278ac9cd66e..7b97db9b633184 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx @@ -183,8 +183,9 @@ export function AlertsPopover({ button={anchorElement} closePopover={onClose} isOpen={!alertFlyoutVisible} + panelPaddingSize="s" > - + ); diff --git a/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/__snapshots__/table_cell_actions.test.tsx.snap b/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/__snapshots__/table_cell_actions.test.tsx.snap index bbc8ee91569ad7..aecef4797f9bdf 100644 --- a/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/__snapshots__/table_cell_actions.test.tsx.snap +++ b/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/__snapshots__/table_cell_actions.test.tsx.snap @@ -43,95 +43,10 @@ Array [ } } />, - , ] `; -exports[`TableActions getFieldCellActions should render correctly for undefined functions 2`] = ` -Array [ - , -] -`; +exports[`TableActions getFieldCellActions should render correctly for undefined functions 2`] = `Array []`; exports[`TableActions getFieldCellActions should render the panels correctly for defined onFilter function 1`] = ` Array [ @@ -217,47 +132,6 @@ Array [ } } />, - , ] `; diff --git a/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/get_pin_control.test.tsx b/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/get_pin_control.test.tsx new file mode 100644 index 00000000000000..74282a52b86c41 --- /dev/null +++ b/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/get_pin_control.test.tsx @@ -0,0 +1,63 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { DataViewField } from '@kbn/data-views-plugin/common'; +import { TableRow } from './table_cell_actions'; +import { getPinColumnControl } from './get_pin_control'; +import { EuiDataGridCellValueElementProps } from '@elastic/eui/src/components/datagrid/data_grid_types'; + +describe('getPinControl', () => { + const rows: TableRow[] = [ + { + action: { + onFilter: jest.fn(), + flattenedField: 'flattenedField', + onToggleColumn: jest.fn(), + }, + field: { + pinned: true, + onTogglePinned: jest.fn(), + field: 'message', + fieldMapping: new DataViewField({ + type: 'keyword', + name: 'message', + searchable: true, + aggregatable: true, + }), + fieldType: 'keyword', + displayName: 'message', + scripted: false, + }, + value: { + ignored: undefined, + formattedValue: 'test', + }, + }, + ]; + + it('should render correctly', () => { + const control = getPinColumnControl({ rows }); + const Cell = control.rowCellRender as React.FC; + render( + + ); + + screen.getByTestId('unifiedDocViewer_pinControlButton_message').click(); + + expect(rows[0].field.onTogglePinned).toHaveBeenCalledWith('message'); + }); +}); diff --git a/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/get_pin_control.tsx b/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/get_pin_control.tsx new file mode 100644 index 00000000000000..0a2c45611dcdfa --- /dev/null +++ b/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/get_pin_control.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { + EuiButtonIcon, + EuiDataGridControlColumn, + EuiScreenReaderOnly, + EuiToolTip, + useEuiTheme, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { css } from '@emotion/react'; +import type { TableRow } from './table_cell_actions'; + +interface PinControlCellProps { + row: TableRow; +} + +const PinControlCell: React.FC = React.memo(({ row }) => { + const { euiTheme } = useEuiTheme(); + + const fieldName = row.field.field; + const isPinned = row.field.pinned; + const label = isPinned + ? i18n.translate('unifiedDocViewer.docViews.table.unpinFieldLabel', { + defaultMessage: 'Unpin field', + }) + : i18n.translate('unifiedDocViewer.docViews.table.pinFieldLabel', { + defaultMessage: 'Pin field', + }); + + return ( +
+ + { + row.field.onTogglePinned(fieldName); + }} + /> + +
+ ); +}); + +export const getPinColumnControl = ({ rows }: { rows: TableRow[] }): EuiDataGridControlColumn => { + return { + id: 'pin_field', + width: 32, + headerCellRender: () => ( + + + {i18n.translate('unifiedDocViewer.fieldsTable.pinControlColumnHeader', { + defaultMessage: 'Pin field column', + })} + + + ), + rowCellRender: ({ rowIndex }) => { + const row = rows[rowIndex]; + if (!row) { + return null; + } + return ; + }, + }; +}; diff --git a/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/table.scss b/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/table.scss index 91022cc47faf82..330cf364ae55e5 100644 --- a/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/table.scss +++ b/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/table.scss @@ -80,8 +80,26 @@ background-color: tintOrShade($euiColorLightShade, 50%, 0); } - & .euiDataGridRowCell--firstColumn .euiDataGridRowCell__content { + & [data-gridcell-column-id='name'] .euiDataGridRowCell__content { padding-top: 0; padding-bottom: 0; } + + & [data-gridcell-column-id='pin_field'] .euiDataGridRowCell__content { + padding: $euiSizeXS / 2 0 0 $euiSizeXS; + } + + .kbnDocViewer__fieldsGrid__pinAction { + opacity: 0; + } + + & [data-gridcell-column-id='pin_field']:focus-within { + .kbnDocViewer__fieldsGrid__pinAction { + opacity: 1; + } + } + + .euiDataGridRow:hover .kbnDocViewer__fieldsGrid__pinAction { + opacity: 1; + } } diff --git a/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/table.tsx b/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/table.tsx index 64659877910a70..008149966c49d7 100644 --- a/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/table.tsx +++ b/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/table.tsx @@ -53,6 +53,7 @@ import { } from '../doc_viewer_source/get_height'; import { TableFilters, TableFiltersProps, useTableFilters } from './table_filters'; import { TableCell } from './table_cell'; +import { getPinColumnControl } from './get_pin_control'; export type FieldRecord = TableRow; @@ -295,6 +296,10 @@ export const DocViewerTable = ({ const rows = useMemo(() => [...pinnedItems, ...restItems], [pinnedItems, restItems]); + const leadingControlColumns = useMemo(() => { + return [getPinColumnControl({ rows })]; + }, [rows]); + const { curPageIndex, pageSize, totalPages, changePageIndex, changePageSize } = usePager({ initialPageSize: getPageSize(storage), totalItems: rows.length, @@ -492,6 +497,7 @@ export const DocViewerTable = ({ renderCellValue={renderCellValue} renderCellPopover={renderCellPopover} pagination={pagination} + leadingControlColumns={leadingControlColumns} /> diff --git a/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/table_cell.tsx b/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/table_cell.tsx index 094050f2c3b493..ff1027a848cf59 100644 --- a/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/table_cell.tsx +++ b/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/table_cell.tsx @@ -33,7 +33,7 @@ export const TableCell: React.FC = React.memo( const { action: { flattenedField }, - field: { field, fieldMapping, fieldType, scripted, pinned }, + field: { field, fieldMapping, fieldType, scripted }, value: { formattedValue, ignored }, } = row; @@ -49,7 +49,6 @@ export const TableCell: React.FC = React.memo( fieldMapping?.displayName ?? field, searchTerm )} - isPinned={pinned} /> {isDetails && !!fieldMapping ? ( diff --git a/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/table_cell_actions.tsx b/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/table_cell_actions.tsx index 7814405e092018..b5e27837f44ef4 100644 --- a/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/table_cell_actions.tsx +++ b/src/plugins/unified_doc_viewer/public/components/doc_viewer_table/table_cell_actions.tsx @@ -202,38 +202,6 @@ export const FilterExist: React.FC = ({ Component, row }) => ); }; -export const PinToggle: React.FC = ({ Component, row }) => { - if (!row) { - return null; - } - - const { - field: { field, pinned, onTogglePinned }, - } = row; - - // Pinned - const pinnedLabel = pinned - ? i18n.translate('unifiedDocViewer.docViews.table.unpinFieldLabel', { - defaultMessage: 'Unpin field', - }) - : i18n.translate('unifiedDocViewer.docViews.table.pinFieldLabel', { - defaultMessage: 'Pin field', - }); - const pinnedIconType = pinned ? 'pinFilled' : 'pin'; - - return ( - onTogglePinned(field)} - > - {pinnedLabel} - - ); -}; - export const ToggleColumn: React.FC = ({ Component, row }) => { if (!row) { return null; @@ -293,9 +261,6 @@ export function getFieldCellActions({ }, ] : []), - ({ Component, rowIndex }: EuiDataGridColumnCellActionProps) => { - return ; - }, ]; } diff --git a/src/plugins/vis_types/timeseries/public/application/components/color_picker.test.tsx b/src/plugins/vis_types/timeseries/public/application/components/color_picker.test.tsx index 3c32275ea5ee5f..06192284041910 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/color_picker.test.tsx +++ b/src/plugins/vis_types/timeseries/public/application/components/color_picker.test.tsx @@ -8,60 +8,76 @@ import React from 'react'; import { ColorPicker, ColorPickerProps } from './color_picker'; -import { mount } from 'enzyme'; -import { ReactWrapper } from 'enzyme'; -import { EuiColorPicker, EuiIconTip } from '@elastic/eui'; -import { findTestSubject } from '@elastic/eui/lib/test'; +import { fireEvent, render, screen } from '@testing-library/react'; describe('ColorPicker', () => { + const onChange = jest.fn(); const defaultProps: ColorPickerProps = { name: 'color', value: null, - onChange: jest.fn(), + onChange, disableTrash: true, }; - let component: ReactWrapper; + + const renderColorPicker = (props?: Partial) => + render(); + + afterEach(() => { + jest.clearAllMocks(); + }); it('should render the EuiColorPicker', () => { - component = mount(); - expect(component.find(EuiColorPicker).length).toBe(1); + renderColorPicker(); + expect(screen.getByTestId('tvbColorPicker')).toBeInTheDocument(); }); it('should not render the clear button', () => { - component = mount(); - expect(findTestSubject(component, 'tvbColorPickerClear').length).toBe(0); + renderColorPicker(); + expect(screen.queryByTestId('tvbColorPickerClear')).toBeNull(); }); - it('should render the correct value to the input text if the prop value is hex', () => { - const props = { ...defaultProps, value: '#68BC00' }; - component = mount(); - findTestSubject(component, 'tvbColorPicker').find('button').simulate('click'); - const input = findTestSubject(component, 'euiColorPickerInput_top'); - expect(input.props().value).toBe('#68BC00'); + it('should render incorrect value to the input text but not call onChange prop', () => { + renderColorPicker({ value: '#68BC00' }); + fireEvent.click(screen.getByRole('button')); + fireEvent.change(screen.getAllByTestId('euiColorPickerInput_top')[0], { + target: { value: 'INVALID' }, + }); + expect(onChange).not.toHaveBeenCalled(); + expect(screen.getAllByTestId('euiColorPickerInput_top')[0]).toHaveValue('INVALID'); }); - - it('should render the correct value to the input text if the prop value is rgba', () => { - const props = { ...defaultProps, value: 'rgba(85,66,177,1)' }; - component = mount(); - findTestSubject(component, 'tvbColorPicker').find('button').simulate('click'); - const input = findTestSubject(component, 'euiColorPickerInput_top'); - expect(input.props().value).toBe('85,66,177,1'); + it('should render correct value to the input text and call onChange prop', () => { + renderColorPicker({ value: '#68BC00' }); + fireEvent.click(screen.getByRole('button')); + fireEvent.change(screen.getAllByTestId('euiColorPickerInput_top')[0], { + target: { value: '#FFF' }, + }); + expect(onChange).toHaveBeenCalled(); + expect(screen.getAllByTestId('euiColorPickerInput_top')[0]).toHaveValue('#FFF'); }); it('should render the correct aria label to the color swatch button', () => { - const props = { ...defaultProps, value: 'rgba(85,66,177,0.59)' }; - component = mount(); - const button = findTestSubject(component, 'tvbColorPicker').find('button'); - expect(button.prop('aria-label')).toBe('Color picker (rgba(85,66,177,0.59)), not accessible'); + renderColorPicker({ value: 'rgba(85,66,177,0.59)' }); + expect( + screen.getByLabelText('Color picker (rgba(85,66,177,0.59)), not accessible') + ).toBeInTheDocument(); }); it('should call clear function if the disableTrash prop is false', () => { - const props = { ...defaultProps, disableTrash: false, value: 'rgba(85,66,177,1)' }; - component = mount(); + const { container } = renderColorPicker({ disableTrash: false, value: 'rgba(85,66,177,1)' }); + fireEvent.click(screen.getByTestId('tvbColorPickerClear')); + expect(onChange).toHaveBeenCalled(); + expect(container.querySelector('[data-euiicon-type="cross"]')).toBeInTheDocument(); + }); - findTestSubject(component, 'tvbColorPickerClear').simulate('click'); + it('should render the correct value to the input text if the prop value is hex', () => { + renderColorPicker({ value: '#68BC00' }); + fireEvent.click(screen.getByRole('button')); + expect(screen.getAllByTestId('euiColorPickerInput_top')[0]).toHaveValue('#68BC00'); + }); - expect(component.find(EuiIconTip).length).toBe(1); - expect(defaultProps.onChange).toHaveBeenCalled(); + it('should render the correct value to the input text if the prop value is rgba', () => { + renderColorPicker({ value: 'rgba(85,66,177,1)' }); + fireEvent.click(screen.getByRole('button')); + expect(screen.getAllByTestId('euiColorPickerInput_top')[0]).toHaveValue('85,66,177,1'); }); }); diff --git a/src/plugins/vis_types/timeseries/public/application/components/color_picker.tsx b/src/plugins/vis_types/timeseries/public/application/components/color_picker.tsx index a44134dfa919cb..474ce95412797e 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/color_picker.tsx +++ b/src/plugins/vis_types/timeseries/public/application/components/color_picker.tsx @@ -41,8 +41,14 @@ export function ColorPicker({ name, value, disableTrash = false, onChange }: Col const { euiTheme } = useEuiTheme(); - const handleColorChange: EuiColorPickerProps['onChange'] = (text: string, { rgba, hex }) => { + const handleColorChange: EuiColorPickerProps['onChange'] = ( + text: string, + { rgba, hex, isValid } + ) => { setColor(text); + if (!isValid) { + return; + } onChange({ [name]: hex ? `rgba(${rgba.join(',')})` : '' }); }; diff --git a/test/functional/apps/discover/group3/_doc_viewer.ts b/test/functional/apps/discover/group3/_doc_viewer.ts index 66f1f74a4ddbec..0464f2e4f32d50 100644 --- a/test/functional/apps/discover/group3/_doc_viewer.ts +++ b/test/functional/apps/discover/group3/_doc_viewer.ts @@ -24,10 +24,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); const dataGrid = getService('dataGrid'); const monacoEditor = getService('monacoEditor'); + const browser = getService('browser'); describe('discover doc viewer', function describeIndexTests() { before(async function () { await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); + await browser.setWindowSize(1600, 1200); }); beforeEach(async () => { @@ -174,7 +176,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(initialFieldsCount).to.above(numberFieldsCount); const pinnedFieldsCount = 1; - await dataGrid.clickFieldActionInFlyout('agent', 'togglePinFilterButton'); + await dataGrid.togglePinActionInFlyout('agent'); await PageObjects.discover.openFilterByFieldTypeInDocViewer(); expect(await find.allByCssSelector('[data-test-subj*="typeFilter"]')).to.have.length(6); @@ -229,5 +231,43 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); }); + + describe('pinning fields', function () { + it('should be able to pin and unpin fields', async function () { + await dataGrid.clickRowToggle(); + await PageObjects.discover.isShowingDocViewer(); + await retry.waitFor('rendered items', async () => { + return (await find.allByCssSelector('.kbnDocViewer__fieldName')).length > 0; + }); + + let fieldNameCells = await find.allByCssSelector('.kbnDocViewer__fieldName'); + let fieldNames = await Promise.all(fieldNameCells.map((cell) => cell.getVisibleText())); + + expect(fieldNames.join(',').startsWith('_id,_ignored,_index,_score,@message')).to.be(true); + expect(await dataGrid.isFieldPinnedInFlyout('agent')).to.be(false); + + await dataGrid.togglePinActionInFlyout('agent'); + + fieldNameCells = await find.allByCssSelector('.kbnDocViewer__fieldName'); + fieldNames = await Promise.all(fieldNameCells.map((cell) => cell.getVisibleText())); + expect(fieldNames.join(',').startsWith('agent,_id,_ignored')).to.be(true); + expect(await dataGrid.isFieldPinnedInFlyout('agent')).to.be(true); + + await dataGrid.togglePinActionInFlyout('@message'); + + fieldNameCells = await find.allByCssSelector('.kbnDocViewer__fieldName'); + fieldNames = await Promise.all(fieldNameCells.map((cell) => cell.getVisibleText())); + expect(fieldNames.join(',').startsWith('@message,agent,_id,_ignored')).to.be(true); + expect(await dataGrid.isFieldPinnedInFlyout('@message')).to.be(true); + + await dataGrid.togglePinActionInFlyout('@message'); + + fieldNameCells = await find.allByCssSelector('.kbnDocViewer__fieldName'); + fieldNames = await Promise.all(fieldNameCells.map((cell) => cell.getVisibleText())); + expect(fieldNames.join(',').startsWith('agent,_id,_ignored')).to.be(true); + expect(await dataGrid.isFieldPinnedInFlyout('agent')).to.be(true); + expect(await dataGrid.isFieldPinnedInFlyout('@message')).to.be(false); + }); + }); }); } diff --git a/test/functional/apps/visualize/group5/_tsvb_time_series.ts b/test/functional/apps/visualize/group5/_tsvb_time_series.ts index 55c59e38f359a9..5e471b3ebb8252 100644 --- a/test/functional/apps/visualize/group5/_tsvb_time_series.ts +++ b/test/functional/apps/visualize/group5/_tsvb_time_series.ts @@ -145,8 +145,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(actualCountMin).to.be('3 hours'); }); - // FLAKY: https://github.com/elastic/kibana/issues/182136 - describe.skip('Dark mode', () => { + describe('Dark mode', () => { before(async () => { await kibanaServer.uiSettings.update({ 'theme:darkMode': true, @@ -156,7 +155,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { it(`viz should have light class when background color is white`, async () => { await visualBuilder.clickPanelOptions('timeSeries'); await visualBuilder.setBackgroundColor('#FFFFFF'); - await retry.try(async () => { expect(await visualBuilder.checkTimeSeriesIsLight()).to.be(true); }); diff --git a/test/functional/services/data_grid.ts b/test/functional/services/data_grid.ts index 70a67d33ffd002..fc6ed65631eaad 100644 --- a/test/functional/services/data_grid.ts +++ b/test/functional/services/data_grid.ts @@ -567,6 +567,24 @@ export class DataGridService extends FtrService { await this.testSubjects.click(`${actionName}-${fieldName}`); } + public async isFieldPinnedInFlyout(fieldName: string): Promise { + return !( + await this.testSubjects.getAttribute(`unifiedDocViewer_pinControl_${fieldName}`, 'class') + )?.includes('kbnDocViewer__fieldsGrid__pinAction'); + } + + public async togglePinActionInFlyout(fieldName: string): Promise { + await this.testSubjects.moveMouseTo(`unifiedDocViewer_pinControl_${fieldName}`); + const isPinned = await this.isFieldPinnedInFlyout(fieldName); + await this.retry.waitFor('pin action to appear', async () => { + return this.testSubjects.exists(`unifiedDocViewer_pinControlButton_${fieldName}`); + }); + await this.testSubjects.click(`unifiedDocViewer_pinControlButton_${fieldName}`); + await this.retry.waitFor('pin action to toggle', async () => { + return (await this.isFieldPinnedInFlyout(fieldName)) !== isPinned; + }); + } + public async expandFieldNameCellInFlyout(fieldName: string): Promise { const buttonSelector = 'euiDataGridCellExpandButton'; await this.testSubjects.click(`tableDocViewRow-${fieldName}-name`); diff --git a/x-pack/plugins/fleet/common/openapi/bundled.json b/x-pack/plugins/fleet/common/openapi/bundled.json index af5420aaad64e8..989955b748ed55 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.json +++ b/x-pack/plugins/fleet/common/openapi/bundled.json @@ -2520,16 +2520,8 @@ } } }, - "/agents/{agentId}/actions/{actionId}/cancel": { + "/agents/actions/{actionId}/cancel": { "parameters": [ - { - "schema": { - "type": "string" - }, - "name": "agentId", - "in": "path", - "required": true - }, { "schema": { "type": "string" diff --git a/x-pack/plugins/fleet/common/openapi/bundled.yaml b/x-pack/plugins/fleet/common/openapi/bundled.yaml index 9bb1027ef35c35..fe1c2fc68c58a7 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.yaml +++ b/x-pack/plugins/fleet/common/openapi/bundled.yaml @@ -1580,13 +1580,8 @@ paths: properties: action: $ref: '#/components/schemas/agent_action' - /agents/{agentId}/actions/{actionId}/cancel: + /agents/actions/{actionId}/cancel: parameters: - - schema: - type: string - name: agentId - in: path - required: true - schema: type: string name: actionId diff --git a/x-pack/plugins/fleet/common/openapi/entrypoint.yaml b/x-pack/plugins/fleet/common/openapi/entrypoint.yaml index 2de74e31a9a351..1ba15cb190f11e 100644 --- a/x-pack/plugins/fleet/common/openapi/entrypoint.yaml +++ b/x-pack/plugins/fleet/common/openapi/entrypoint.yaml @@ -91,8 +91,8 @@ paths: $ref: 'paths/agents@{agent_id}.yaml' '/agents/{agentId}/actions': $ref: 'paths/agents@{agent_id}@actions.yaml' - '/agents/{agentId}/actions/{actionId}/cancel': - $ref: 'paths/agents@{agent_id}@actions@{action_id}@cancel.yaml' + '/agents/actions/{actionId}/cancel': + $ref: 'paths/agents@actions@{action_id}@cancel.yaml' '/agents/files/{fileId}/{fileName}': $ref: 'paths/agents@files@{file_id}@{file_name}.yaml' '/agents/files/{fileId}': diff --git a/x-pack/plugins/fleet/common/openapi/paths/agents@{agent_id}@actions@{action_id}@cancel.yaml b/x-pack/plugins/fleet/common/openapi/paths/agents@actions@{action_id}@cancel.yaml similarity index 87% rename from x-pack/plugins/fleet/common/openapi/paths/agents@{agent_id}@actions@{action_id}@cancel.yaml rename to x-pack/plugins/fleet/common/openapi/paths/agents@actions@{action_id}@cancel.yaml index 5b939e8c5fdf40..d9ee5127e4b096 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/agents@{agent_id}@actions@{action_id}@cancel.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/agents@actions@{action_id}@cancel.yaml @@ -1,9 +1,4 @@ parameters: - - schema: - type: string - name: agentId - in: path - required: true - schema: type: string name: actionId diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx index 185c694a5c84e9..28d14b62a575e4 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx @@ -22,7 +22,6 @@ import { EuiText, EuiFlexGroup, EuiFlexItem, - EuiBetaBadge, EuiBadge, EuiSwitch, } from '@elastic/eui'; @@ -796,29 +795,14 @@ export const AgentPolicyAdvancedOptionsContent: React.FunctionComponent =

-   - - -

} description={ } > diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/package_policy_input_panel.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/package_policy_input_panel.test.tsx index c889dc862bf9e4..5accdf37e95e77 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/package_policy_input_panel.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/components/package_policy_input_panel.test.tsx @@ -363,8 +363,8 @@ describe('PackagePolicyInputPanel', () => { isAgentlessPackagePolicy: jest.fn(), isAgentlessAgentPolicy: jest.fn(), isAgentlessIntegration: jest.fn(), - isAgentlessCloudEnabled: true, - isAgentlessServerlessEnabled: false, + isAgentlessApiEnabled: true, + isDefaultAgentlessPolicyEnabled: false, }); }); @@ -398,8 +398,8 @@ describe('PackagePolicyInputPanel', () => { isAgentlessPackagePolicy: jest.fn(), isAgentlessAgentPolicy: jest.fn(), isAgentlessIntegration: jest.fn(), - isAgentlessCloudEnabled: true, - isAgentlessServerlessEnabled: false, + isAgentlessApiEnabled: true, + isDefaultAgentlessPolicyEnabled: false, }); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.test.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.test.ts index f32ea1cd007cee..be7884aad753d7 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.test.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.test.ts @@ -49,37 +49,18 @@ describe('useAgentless', () => { jest.clearAllMocks(); }); - it('should should not return return isAgentless when agentless is not enabled', () => { + it('should not return isAgentless when agentless is not enabled', () => { const { result } = renderHook(() => useAgentless()); expect(result.current.isAgentlessEnabled).toBeFalsy(); - expect(result.current.isAgentlessCloudEnabled).toBeFalsy(); - expect(result.current.isAgentlessServerlessEnabled).toBeFalsy(); - }); - it('should should return agentlessAPIUrl when agentless config is set', () => { - const agentlessAPIUrl = 'https://agentless.api.url'; - (useConfig as MockFn).mockReturnValue({ - agentless: { - api: { - url: agentlessAPIUrl, - }, - }, - } as any); - - const { result } = renderHook(() => useAgentless()); - - expect(result.current.isAgentlessEnabled).toBeFalsy(); - expect(result.current.isAgentlessCloudEnabled).toBeFalsy(); - expect(result.current.isAgentlessServerlessEnabled).toBeFalsy(); + expect(result.current.isAgentlessApiEnabled).toBeFalsy(); + expect(result.current.isDefaultAgentlessPolicyEnabled).toBeFalsy(); }); - it('should return isAgentlessEnabled as falsy if agentlessAPIUrl and experimental feature agentless is truthy without cloud or serverless', () => { - const agentlessAPIUrl = 'https://agentless.api.url'; + it('should return isAgentlessEnabled as falsy if agentless.enabled is true and experimental feature agentless is truthy without cloud or serverless', () => { (useConfig as MockFn).mockReturnValue({ agentless: { - api: { - url: agentlessAPIUrl, - }, + enabled: true, }, } as any); @@ -90,18 +71,14 @@ describe('useAgentless', () => { const { result } = renderHook(() => useAgentless()); expect(result.current.isAgentlessEnabled).toBeFalsy(); - expect(result.current.isAgentlessCloudEnabled).toBeFalsy(); - expect(result.current.isAgentlessServerlessEnabled).toBeFalsy(); + expect(result.current.isAgentlessApiEnabled).toBeFalsy(); + expect(result.current.isDefaultAgentlessPolicyEnabled).toBeFalsy(); }); - it('should return isAgentlessEnabled and isAgentlessCloudEnabled as truthy with isCloudEnabled', () => { - const agentlessAPIUrl = 'https://agentless.api.url'; + it('should return isAgentlessEnabled and isAgentlessApiEnabled as truthy with isCloudEnabled', () => { (useConfig as MockFn).mockReturnValue({ agentless: { enabled: true, - api: { - url: agentlessAPIUrl, - }, }, } as any); @@ -115,19 +92,10 @@ describe('useAgentless', () => { const { result } = renderHook(() => useAgentless()); expect(result.current.isAgentlessEnabled).toBeTruthy(); - expect(result.current.isAgentlessCloudEnabled).toBeTruthy(); - expect(result.current.isAgentlessServerlessEnabled).toBeFalsy(); + expect(result.current.isAgentlessApiEnabled).toBeTruthy(); + expect(result.current.isDefaultAgentlessPolicyEnabled).toBeFalsy(); }); - it('should return isAgentlessEnabled and isAgentlessServerlessEnabled as truthy with isServerlessEnabled', () => { - const agentlessAPIUrl = 'https://agentless.api.url'; - (useConfig as MockFn).mockReturnValue({ - agentless: { - api: { - url: agentlessAPIUrl, - }, - }, - } as any); - + it('should return isAgentlessEnabled and isDefaultAgentlessPolicyEnabled as truthy with isServerlessEnabled and experimental feature agentless is truthy', () => { mockedExperimentalFeaturesService.get.mockReturnValue({ agentless: true, } as any); @@ -142,8 +110,27 @@ describe('useAgentless', () => { const { result } = renderHook(() => useAgentless()); expect(result.current.isAgentlessEnabled).toBeTruthy(); - expect(result.current.isAgentlessCloudEnabled).toBeFalsy(); - expect(result.current.isAgentlessServerlessEnabled).toBeTruthy(); + expect(result.current.isAgentlessApiEnabled).toBeFalsy(); + expect(result.current.isDefaultAgentlessPolicyEnabled).toBeTruthy(); + }); + + it('should return isAgentlessEnabled as falsy and isDefaultAgentlessPolicyEnabled as falsy with isServerlessEnabled and experimental feature agentless is falsy', () => { + mockedExperimentalFeaturesService.get.mockReturnValue({ + agentless: false, + } as any); + + (useStartServices as MockFn).mockReturnValue({ + cloud: { + isServerlessEnabled: true, + isCloudEnabled: false, + }, + }); + + const { result } = renderHook(() => useAgentless()); + + expect(result.current.isAgentlessEnabled).toBeFalsy(); + expect(result.current.isAgentlessApiEnabled).toBeFalsy(); + expect(result.current.isDefaultAgentlessPolicyEnabled).toBeFalsy(); }); }); @@ -224,6 +211,7 @@ describe('useSetupTechnology', () => { it('should set agentless setup technology if agent policy supports agentless in edit page', async () => { (useConfig as MockFn).mockReturnValue({ agentless: { + enabled: true, api: { url: 'https://agentless.api.url', }, diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.ts index 7fc159a2d43481..5cafedee7db7b2 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/hooks/setup_technology.ts @@ -29,10 +29,11 @@ export const useAgentless = () => { const isServerless = !!cloud?.isServerlessEnabled; const isCloud = !!cloud?.isCloudEnabled; - const isAgentlessCloudEnabled = isCloud && !!config.agentless?.enabled; - const isAgentlessServerlessEnabled = isServerless && agentlessExperimentalFeatureEnabled; + const isAgentlessApiEnabled = (isCloud || isServerless) && config.agentless?.enabled; + const isDefaultAgentlessPolicyEnabled = + !isAgentlessApiEnabled && isServerless && agentlessExperimentalFeatureEnabled; - const isAgentlessEnabled = isAgentlessCloudEnabled || isAgentlessServerlessEnabled; + const isAgentlessEnabled = isAgentlessApiEnabled || isDefaultAgentlessPolicyEnabled; const isAgentlessAgentPolicy = (agentPolicy: AgentPolicy | undefined) => { if (!agentPolicy) return false; @@ -62,8 +63,8 @@ export const useAgentless = () => { return isAgentlessEnabled && packagePolicy.policy_ids.includes(AGENTLESS_POLICY_ID); }; return { - isAgentlessCloudEnabled, - isAgentlessServerlessEnabled, + isAgentlessApiEnabled, + isDefaultAgentlessPolicyEnabled, isAgentlessEnabled, isAgentlessAgentPolicy, isAgentlessIntegration, @@ -90,7 +91,7 @@ export function useSetupTechnology({ isEditPage?: boolean; agentPolicies?: AgentPolicy[]; }) { - const { isAgentlessEnabled, isAgentlessCloudEnabled, isAgentlessServerlessEnabled } = + const { isAgentlessEnabled, isAgentlessApiEnabled, isDefaultAgentlessPolicyEnabled } = useAgentless(); // this is a placeholder for the new agent-BASED policy that will be used when the user switches from agentless to agent-based and back @@ -110,7 +111,7 @@ export function useSetupTechnology({ setSelectedSetupTechnology(SetupTechnology.AGENTLESS); return; } - if (isAgentlessCloudEnabled && selectedSetupTechnology === SetupTechnology.AGENTLESS) { + if (isAgentlessApiEnabled && selectedSetupTechnology === SetupTechnology.AGENTLESS) { const nextNewAgentlessPolicy = { ...newAgentlessPolicy, name: getAgentlessAgentPolicyNameFromPackagePolicyName(packagePolicy.name), @@ -122,7 +123,7 @@ export function useSetupTechnology({ } } }, [ - isAgentlessCloudEnabled, + isAgentlessApiEnabled, isEditPage, newAgentlessPolicy, packagePolicy.name, @@ -145,10 +146,10 @@ export function useSetupTechnology({ } }; - if (isAgentlessServerlessEnabled) { + if (isDefaultAgentlessPolicyEnabled) { fetchAgentlessPolicy(); } - }, [isAgentlessServerlessEnabled]); + }, [isDefaultAgentlessPolicyEnabled]); const handleSetupTechnologyChange = useCallback( (setupTechnology: SetupTechnology) => { @@ -157,14 +158,14 @@ export function useSetupTechnology({ } if (setupTechnology === SetupTechnology.AGENTLESS) { - if (isAgentlessCloudEnabled) { + if (isAgentlessApiEnabled) { setNewAgentPolicy(newAgentlessPolicy as NewAgentPolicy); setSelectedPolicyTab(SelectedPolicyTab.NEW); updateAgentPolicies([newAgentlessPolicy] as AgentPolicy[]); } // tech debt: remove this when Serverless uses the Agentless API // https://github.com/elastic/security-team/issues/9781 - if (isAgentlessServerlessEnabled) { + if (isDefaultAgentlessPolicyEnabled) { setNewAgentPolicy(newAgentlessPolicy as AgentPolicy); updateAgentPolicies([newAgentlessPolicy] as AgentPolicy[]); setSelectedPolicyTab(SelectedPolicyTab.EXISTING); @@ -183,8 +184,8 @@ export function useSetupTechnology({ [ isAgentlessEnabled, selectedSetupTechnology, - isAgentlessCloudEnabled, - isAgentlessServerlessEnabled, + isAgentlessApiEnabled, + isDefaultAgentlessPolicyEnabled, setNewAgentPolicy, newAgentlessPolicy, setSelectedPolicyTab, diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_activity_flyout/activity_item.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_activity_flyout/activity_item.tsx index 96b48948320bde..ee83c49744d35e 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_activity_flyout/activity_item.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_activity_flyout/activity_item.tsx @@ -55,7 +55,7 @@ export const ActivityItem: React.FunctionComponent<{ ? action.nbAgentsAck : action.nbAgentsAck + ' of ' + action.nbAgentsActioned, agents: action.nbAgentsActioned === 1 ? 'agent' : 'agents', - completedText: getAction(action.type).completedText, + completedText: getAction(action.type, action.actionId).completedText, offlineText: action.status === 'ROLLOUT_PASSED' && action.nbAgentsActioned - action.nbAgentsAck > 0 ? `, ${ @@ -175,7 +175,7 @@ export const ActivityItem: React.FunctionComponent<{ id="xpack.fleet.agentActivityFlyout.cancelledTitle" defaultMessage="Agent {cancelledText} cancelled" values={{ - cancelledText: getAction(action.type).cancelledText, + cancelledText: getAction(action.type, action.actionId).cancelledText, }} /> @@ -201,7 +201,7 @@ export const ActivityItem: React.FunctionComponent<{ id="xpack.fleet.agentActivityFlyout.expiredTitle" defaultMessage="Agent {expiredText} expired" values={{ - expiredText: getAction(action.type).cancelledText, + expiredText: getAction(action.type, action.actionId).cancelledText, }} /> diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_activity_flyout/helpers.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_activity_flyout/helpers.tsx index a3c9d5807fae87..82fc266a04bccf 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_activity_flyout/helpers.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/agent_activity_flyout/helpers.tsx @@ -31,6 +31,11 @@ const actionNames: { completedText: 'force unenrolled', cancelledText: 'force unenrollment', }, + AUTOMATIC_FORCE_UNENROLL: { + inProgressText: 'Automatic unenrolling', + completedText: 'automatically unenrolled', + cancelledText: 'automatic unenrollment', + }, UPDATE_TAGS: { inProgressText: 'Updating tags of', completedText: 'updated tags', @@ -60,7 +65,13 @@ const actionNames: { ACTION: { inProgressText: 'Actioning', completedText: 'actioned', cancelledText: 'action' }, }; -export const getAction = (type?: string) => actionNames[type ?? 'ACTION'] ?? actionNames.ACTION; +export const getAction = (type?: string, actionId?: string) => { + // handling a special case of force unenrollment coming from an automatic task + // we know what kind of action is from the actionId prefix + if (actionId?.includes('UnenrollInactiveAgentsTask-')) + return actionNames.AUTOMATICAL_FORCE_UNENROLL; + return actionNames[type ?? 'ACTION'] ?? actionNames.ACTION; +}; export const inProgressTitle = (action: ActionStatus) => ( ( ? action.nbAgentsActioned : action.nbAgentsActioned - action.nbAgentsAck + ' of ' + action.nbAgentsActioned, agents: action.nbAgentsActioned === 1 ? 'agent' : 'agents', - inProgressText: getAction(action.type).inProgressText, + inProgressText: getAction(action.type, action.actionId).inProgressText, reassignText: action.type === 'POLICY_REASSIGN' && action.newPolicyId ? `to ${action.newPolicyId}` : '', upgradeText: action.type === 'UPGRADE' ? `to version ${action.version}` : '', diff --git a/x-pack/plugins/fleet/server/mocks/index.ts b/x-pack/plugins/fleet/server/mocks/index.ts index 6a91add910fb83..7b11654f8def40 100644 --- a/x-pack/plugins/fleet/server/mocks/index.ts +++ b/x-pack/plugins/fleet/server/mocks/index.ts @@ -128,6 +128,7 @@ export const createAppContextStartContractMock = ( }, } : {}), + unenrollInactiveAgentsTask: {} as any, }; }; diff --git a/x-pack/plugins/fleet/server/plugin.ts b/x-pack/plugins/fleet/server/plugin.ts index d05ef1de33641a..043b02ca93d7fd 100644 --- a/x-pack/plugins/fleet/server/plugin.ts +++ b/x-pack/plugins/fleet/server/plugin.ts @@ -118,6 +118,7 @@ import type { PackagePolicyService } from './services/package_policy_service'; import { PackagePolicyServiceImpl } from './services/package_policy'; import { registerFleetUsageLogger, startFleetUsageLogger } from './services/fleet_usage_logger'; import { CheckDeletedFilesTask } from './tasks/check_deleted_files_task'; +import { UnenrollInactiveAgentsTask } from './tasks/unenroll_inactive_agents_task'; import { UninstallTokenService, type UninstallTokenServiceInterface, @@ -178,6 +179,7 @@ export interface FleetAppContext { messageSigningService: MessageSigningServiceInterface; auditLogger?: AuditLogger; uninstallTokenService: UninstallTokenServiceInterface; + unenrollInactiveAgentsTask: UnenrollInactiveAgentsTask; } export type FleetSetupContract = void; @@ -266,6 +268,7 @@ export class FleetPlugin private fleetUsageSender?: FleetUsageSender; private checkDeletedFilesTask?: CheckDeletedFilesTask; private fleetMetricsTask?: FleetMetricsTask; + private unenrollInactiveAgentsTask?: UnenrollInactiveAgentsTask; private agentService?: AgentService; private packageService?: PackageService; @@ -599,6 +602,11 @@ export class FleetPlugin taskManager: deps.taskManager, logFactory: this.initializerContext.logger, }); + this.unenrollInactiveAgentsTask = new UnenrollInactiveAgentsTask({ + core, + taskManager: deps.taskManager, + logFactory: this.initializerContext.logger, + }); // Register fields metadata extractor registerIntegrationFieldsExtractor({ core, fieldsMetadata: deps.fieldsMetadata }); @@ -644,12 +652,14 @@ export class FleetPlugin bulkActionsResolver: this.bulkActionsResolver!, messageSigningService, uninstallTokenService, + unenrollInactiveAgentsTask: this.unenrollInactiveAgentsTask!, }); licenseService.start(plugins.licensing.license$); this.telemetryEventsSender.start(plugins.telemetry, core).catch(() => {}); this.bulkActionsResolver?.start(plugins.taskManager).catch(() => {}); this.fleetUsageSender?.start(plugins.taskManager).catch(() => {}); this.checkDeletedFilesTask?.start({ taskManager: plugins.taskManager }).catch(() => {}); + this.unenrollInactiveAgentsTask?.start({ taskManager: plugins.taskManager }).catch(() => {}); startFleetUsageLogger(plugins.taskManager).catch(() => {}); this.fleetMetricsTask ?.start(plugins.taskManager, core.elasticsearch.client.asInternalUser) diff --git a/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts b/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts index 9b5bbdccb35408..b11dcb719e2d29 100644 --- a/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts @@ -59,9 +59,15 @@ export const postCancelActionHandlerBuilder = function ( ): RequestHandler, undefined, undefined> { return async (context, request, response) => { try { - const esClient = (await context.core).elasticsearch.client.asInternalUser; + const core = await context.core; + const esClient = core.elasticsearch.client.asInternalUser; + const soClient = core.savedObjects.client; - const action = await actionsService.cancelAgentAction(esClient, request.params.actionId); + const action = await actionsService.cancelAgentAction( + esClient, + soClient, + request.params.actionId + ); const body: PostNewAgentActionResponse = { item: action, diff --git a/x-pack/plugins/fleet/server/routes/agent/handlers.ts b/x-pack/plugins/fleet/server/routes/agent/handlers.ts index 350eb24847d855..e328c738789808 100644 --- a/x-pack/plugins/fleet/server/routes/agent/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent/handlers.ts @@ -266,7 +266,7 @@ export const putAgentsReassignHandlerDeprecated: RequestHandler< } }; -export const postAgentsReassignHandler: RequestHandler< +export const postAgentReassignHandler: RequestHandler< TypeOf, undefined, TypeOf diff --git a/x-pack/plugins/fleet/server/routes/agent/index.ts b/x-pack/plugins/fleet/server/routes/agent/index.ts index 6c55835a1ed949..7d64bf365f74b2 100644 --- a/x-pack/plugins/fleet/server/routes/agent/index.ts +++ b/x-pack/plugins/fleet/server/routes/agent/index.ts @@ -59,7 +59,7 @@ import { getAgentUploadsHandler, getAgentUploadFileHandler, deleteAgentUploadFileHandler, - postAgentsReassignHandler, + postAgentReassignHandler, postRetrieveAgentsByActionsHandler, } from './handlers'; import { @@ -271,7 +271,7 @@ export const registerAPIRoutes = (router: FleetAuthzRouter, config: FleetConfigT version: API_VERSIONS.public.v1, validate: { request: PostAgentReassignRequestSchema }, }, - postAgentsReassignHandler + postAgentReassignHandler ); router.versioned diff --git a/x-pack/plugins/fleet/server/routes/package_policy/utils/index.ts b/x-pack/plugins/fleet/server/routes/package_policy/utils/index.ts index 032dab4a07acc7..518d8fc3f74c89 100644 --- a/x-pack/plugins/fleet/server/routes/package_policy/utils/index.ts +++ b/x-pack/plugins/fleet/server/routes/package_policy/utils/index.ts @@ -11,7 +11,7 @@ import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-ser import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; -import { isAgentlessCloudEnabled } from '../../../services/utils/agentless'; +import { isAgentlessApiEnabled } from '../../../services/utils/agentless'; import { getAgentlessAgentPolicyNameFromPackagePolicyName } from '../../../../common/services/agentless_policy_helper'; @@ -65,7 +65,7 @@ export async function renameAgentlessAgentPolicy( packagePolicy: PackagePolicy, name: string ) { - if (!isAgentlessCloudEnabled()) { + if (!isAgentlessApiEnabled()) { return; } // If agentless is enabled for cloud, we need to rename the agent policy diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index b2ba41b78586a3..250cd867ee875a 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -1245,10 +1245,6 @@ class AgentPolicyService { default_fleet_server: policy.is_default_fleet_server === true, }; - if (policy.unenroll_timeout) { - fleetServerPolicy.unenroll_timeout = policy.unenroll_timeout; - } - acc.push(fleetServerPolicy); return acc; }, [] as FleetServerPolicy[]); diff --git a/x-pack/plugins/fleet/server/services/agents/action_runner.ts b/x-pack/plugins/fleet/server/services/agents/action_runner.ts index 3abe4787e61328..f7eea6f3ac5606 100644 --- a/x-pack/plugins/fleet/server/services/agents/action_runner.ts +++ b/x-pack/plugins/fleet/server/services/agents/action_runner.ts @@ -16,6 +16,7 @@ import moment from 'moment'; import type { Agent } from '../../types'; import { appContextService } from '..'; import { SO_SEARCH_LIMIT } from '../../../common/constants'; +import { agentsKueryNamespaceFilter } from '../spaces/agent_namespaces'; import { getAgentActions } from './actions'; import { closePointInTime, getAgentsByKuery } from './crud'; @@ -29,6 +30,7 @@ export interface ActionParams { batchSize?: number; total?: number; actionId?: string; + spaceId?: string; // additional parameters specific to an action e.g. reassign to new policy id [key: string]: any; } @@ -195,15 +197,21 @@ export abstract class ActionRunner { appContextService.getLogger().debug('kuery: ' + this.actionParams.kuery); - const getAgents = () => - getAgentsByKuery(this.esClient, this.soClient, { - kuery: this.actionParams.kuery, + const getAgents = async () => { + const namespaceFilter = await agentsKueryNamespaceFilter(this.actionParams.spaceId); + const kuery = namespaceFilter + ? `${namespaceFilter} AND ${this.actionParams.kuery}` + : this.actionParams.kuery; + + return getAgentsByKuery(this.esClient, this.soClient, { + kuery, showInactive: this.actionParams.showInactive ?? false, page: 1, perPage, pitId, searchAfter: this.retryParams.searchAfter, }); + }; const res = await getAgents(); diff --git a/x-pack/plugins/fleet/server/services/agents/actions.test.ts b/x-pack/plugins/fleet/server/services/agents/actions.test.ts index 4a2fc9e743b2a4..b8cb2ce8c8d6ac 100644 --- a/x-pack/plugins/fleet/server/services/agents/actions.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/actions.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; +import { elasticsearchServiceMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; import type { NewAgentAction, AgentActionType } from '../../../common/types'; @@ -307,16 +307,17 @@ describe('Agent actions', () => { }); describe('cancelAgentAction', () => { - it('throw if the target action is not found', async () => { + it('should throw if the target action is not found', async () => { const esClient = elasticsearchServiceMock.createInternalClient(); esClient.search.mockResolvedValue({ hits: { hits: [], }, } as any); - await expect(() => cancelAgentAction(esClient, 'i-do-not-exists')).rejects.toThrowError( - /Action not found/ - ); + const soClient = savedObjectsClientMock.create(); + await expect(() => + cancelAgentAction(esClient, soClient, 'i-do-not-exists') + ).rejects.toThrowError(/Action not found/); }); it('should create one CANCEL action for each UPGRADE action found', async () => { @@ -343,7 +344,8 @@ describe('Agent actions', () => { ], }, } as any); - await cancelAgentAction(esClient, 'action1'); + const soClient = savedObjectsClientMock.create(); + await cancelAgentAction(esClient, soClient, 'action1'); expect(esClient.create).toBeCalledTimes(2); expect(esClient.create).toBeCalledWith( @@ -382,7 +384,8 @@ describe('Agent actions', () => { ], }, } as any); - await cancelAgentAction(esClient, 'action1'); + const soClient = savedObjectsClientMock.create(); + await cancelAgentAction(esClient, soClient, 'action1'); expect(mockedBulkUpdateAgents).toBeCalled(); expect(mockedBulkUpdateAgents).toBeCalledWith( diff --git a/x-pack/plugins/fleet/server/services/agents/actions.ts b/x-pack/plugins/fleet/server/services/agents/actions.ts index f344aa24e59dd3..11c7174e09312c 100644 --- a/x-pack/plugins/fleet/server/services/agents/actions.ts +++ b/x-pack/plugins/fleet/server/services/agents/actions.ts @@ -30,6 +30,9 @@ import { auditLoggingService } from '../audit_logging'; import { getAgentIdsForAgentPolicies } from '../agent_policies/agent_policies_to_agent_ids'; +import { getCurrentNamespace } from '../spaces/get_current_namespace'; +import { addNamespaceFilteringToQuery } from '../spaces/query_namespaces_filtering'; + import { bulkUpdateAgents } from './crud'; const ONE_MONTH_IN_MS = 2592000000; @@ -305,21 +308,28 @@ export async function getUnenrollAgentActions( return result; } -export async function cancelAgentAction(esClient: ElasticsearchClient, actionId: string) { +export async function cancelAgentAction( + esClient: ElasticsearchClient, + soClient: SavedObjectsClientContract, + actionId: string +) { + const currentNameSpace = getCurrentNamespace(soClient); + const getUpgradeActions = async () => { - const res = await esClient.search({ - index: AGENT_ACTIONS_INDEX, - query: { - bool: { - filter: [ - { - term: { - action_id: actionId, - }, + const query = { + bool: { + filter: [ + { + term: { + action_id: actionId, }, - ], - }, + }, + ], }, + }; + const res = await esClient.search({ + index: AGENT_ACTIONS_INDEX, + query: await addNamespaceFilteringToQuery(query, currentNameSpace), size: SO_SEARCH_LIMIT, }); @@ -348,9 +358,12 @@ export async function cancelAgentAction(esClient: ElasticsearchClient, actionId: const cancelledActions: Array<{ agents: string[] }> = []; const createAction = async (action: FleetServerAgentAction) => { + const namespaces = currentNameSpace ? { namespaces: [currentNameSpace] } : {}; + await createAgentAction(esClient, { id: cancelActionId, type: 'CANCEL', + ...namespaces, agents: action.agents!, data: { target_id: action.action_id, @@ -505,7 +518,11 @@ export interface ActionsService { agentId: string ) => Promise; - cancelAgentAction: (esClient: ElasticsearchClient, actionId: string) => Promise; + cancelAgentAction: ( + esClient: ElasticsearchClient, + soClient: SavedObjectsClientContract, + actionId: string + ) => Promise; createAgentAction: ( esClient: ElasticsearchClient, diff --git a/x-pack/plugins/fleet/server/services/agents/agentless_agent.test.ts b/x-pack/plugins/fleet/server/services/agents/agentless_agent.test.ts index dc057fd962c9b5..00a1ff4ff61cf9 100644 --- a/x-pack/plugins/fleet/server/services/agents/agentless_agent.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/agentless_agent.test.ts @@ -111,7 +111,7 @@ describe('Agentless Agent service', () => { namespace: 'default', supports_agentless: true, } as AgentPolicy) - ).rejects.toThrowError(new AgentlessAgentCreateError('Agentless agent not supported')); + ).rejects.toThrowError(new AgentlessAgentCreateError('missing agentless configuration')); }); it('should throw AgentlessAgentCreateError if agentless configuration is not found', async () => { diff --git a/x-pack/plugins/fleet/server/services/agents/agentless_agent.ts b/x-pack/plugins/fleet/server/services/agents/agentless_agent.ts index 21d3a6c8df73dc..627bdf38b8fe22 100644 --- a/x-pack/plugins/fleet/server/services/agents/agentless_agent.ts +++ b/x-pack/plugins/fleet/server/services/agents/agentless_agent.ts @@ -22,7 +22,7 @@ import { appContextService } from '../app_context'; import { listEnrollmentApiKeys } from '../api_keys'; import { listFleetServerHosts } from '../fleet_server_host'; -import { prependAgentlessApiBasePathToEndpoint } from '../utils/agentless'; +import { prependAgentlessApiBasePathToEndpoint, isAgentlessApiEnabled } from '../utils/agentless'; class AgentlessAgentService { public async createAgentlessAgent( @@ -33,8 +33,10 @@ class AgentlessAgentService { const logger = appContextService.getLogger(); logger.debug(`Creating agentless agent ${agentlessAgentPolicy.id}`); - if (!appContextService.getCloud()?.isCloudEnabled) { - logger.error('Creating agentless agent not supported in non-cloud environments'); + if (!isAgentlessApiEnabled) { + logger.error( + 'Creating agentless agent not supported in non-cloud or non-serverless environments' + ); throw new AgentlessAgentCreateError('Agentless agent not supported'); } if (!agentlessAgentPolicy.supports_agentless) { diff --git a/x-pack/plugins/fleet/server/services/agents/crud.ts b/x-pack/plugins/fleet/server/services/agents/crud.ts index 7fdf76c76992b0..847d0dd8335c6c 100644 --- a/x-pack/plugins/fleet/server/services/agents/crud.ts +++ b/x-pack/plugins/fleet/server/services/agents/crud.ts @@ -27,9 +27,10 @@ import { FleetUnauthorizedError, } from '../../errors'; import { auditLoggingService } from '../audit_logging'; -import { isAgentInNamespace } from '../spaces/agent_namespaces'; import { getCurrentNamespace } from '../spaces/get_current_namespace'; import { isSpaceAwarenessEnabled } from '../spaces/helpers'; +import { isAgentInNamespace } from '../spaces/agent_namespaces'; +import { addNamespaceFilteringToQuery } from '../spaces/query_namespaces_filtering'; import { searchHitToAgent, agentSOAttributesToFleetServerAgentDoc } from './helpers'; import { buildAgentStatusRuntimeField } from './build_status_runtime_field'; @@ -432,6 +433,7 @@ async function _filterAgents( }> { const { page = 1, perPage = 20, sortField = 'enrolled_at', sortOrder = 'desc' } = options; const runtimeFields = await buildAgentStatusRuntimeField(soClient); + const currentNameSpace = getCurrentNamespace(soClient); let res; try { @@ -443,7 +445,7 @@ async function _filterAgents( runtime_mappings: runtimeFields, fields: Object.keys(runtimeFields), sort: [{ [sortField]: { order: sortOrder } }], - query: { bool: { filter: query } }, + query: await addNamespaceFilteringToQuery({ bool: { filter: [query] } }, currentNameSpace), index: AGENTS_INDEX, ignore_unavailable: true, }); diff --git a/x-pack/plugins/fleet/server/services/agents/reassign.test.ts b/x-pack/plugins/fleet/server/services/agents/reassign.test.ts index 4ae78e07d3a8db..5d55fdd5da31e3 100644 --- a/x-pack/plugins/fleet/server/services/agents/reassign.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/reassign.test.ts @@ -15,11 +15,15 @@ import { reassignAgent, reassignAgents } from './reassign'; import { createClientMock } from './action.mock'; describe('reassignAgent', () => { + let mocks: ReturnType; + beforeEach(async () => { - const { soClient } = createClientMock(); + mocks = createClientMock(); + appContextService.start( createAppContextStartContractMock({}, false, { - withoutSpaceExtensions: soClient, + internal: mocks.soClient, + withoutSpaceExtensions: mocks.soClient, }) ); }); @@ -29,7 +33,7 @@ describe('reassignAgent', () => { }); describe('reassignAgent (singular)', () => { it('can reassign from regular agent policy to regular', async () => { - const { soClient, esClient, agentInRegularDoc, regularAgentPolicySO } = createClientMock(); + const { soClient, esClient, agentInRegularDoc, regularAgentPolicySO } = mocks; await reassignAgent(soClient, esClient, agentInRegularDoc._id, regularAgentPolicySO.id); // calls ES update with correct values @@ -43,7 +47,7 @@ describe('reassignAgent', () => { }); it('cannot reassign from regular agent policy to hosted', async () => { - const { soClient, esClient, agentInRegularDoc, hostedAgentPolicySO } = createClientMock(); + const { soClient, esClient, agentInRegularDoc, hostedAgentPolicySO } = mocks; await expect( reassignAgent(soClient, esClient, agentInRegularDoc._id, hostedAgentPolicySO.id) ).rejects.toThrowError(HostedAgentPolicyRestrictionRelatedError); @@ -54,7 +58,7 @@ describe('reassignAgent', () => { it('cannot reassign from hosted agent policy', async () => { const { soClient, esClient, agentInHostedDoc, hostedAgentPolicySO, regularAgentPolicySO } = - createClientMock(); + mocks; await expect( reassignAgent(soClient, esClient, agentInHostedDoc._id, regularAgentPolicySO.id) ).rejects.toThrowError(HostedAgentPolicyRestrictionRelatedError); @@ -78,7 +82,7 @@ describe('reassignAgent', () => { agentInHostedDoc, agentInHostedDoc2, regularAgentPolicySO2, - } = createClientMock(); + } = mocks; esClient.search.mockResponse({ hits: { @@ -116,7 +120,8 @@ describe('reassignAgent', () => { }); it('should report errors from ES agent update call', async () => { - const { soClient, esClient, agentInRegularDoc, regularAgentPolicySO2 } = createClientMock(); + const { soClient, esClient, agentInRegularDoc, regularAgentPolicySO2 } = mocks; + esClient.bulk.mockResponse({ items: [ { diff --git a/x-pack/plugins/fleet/server/services/agents/reassign.ts b/x-pack/plugins/fleet/server/services/agents/reassign.ts index 0a5c6f9b51ee00..d5a4d2ab675251 100644 --- a/x-pack/plugins/fleet/server/services/agents/reassign.ts +++ b/x-pack/plugins/fleet/server/services/agents/reassign.ts @@ -6,6 +6,8 @@ */ import type { SavedObjectsClientContract, ElasticsearchClient } from '@kbn/core/server'; +import { SavedObjectNotFound } from '@kbn/kibana-utils-plugin/common'; + import type { Agent } from '../../types'; import { agentPolicyService } from '../agent_policy'; import { @@ -16,52 +18,54 @@ import { import { SO_SEARCH_LIMIT } from '../../constants'; +import { agentsKueryNamespaceFilter } from '../spaces/agent_namespaces'; +import { getCurrentNamespace } from '../spaces/get_current_namespace'; + import { getAgentsById, getAgentPolicyForAgent, updateAgent, getAgentsByKuery, openPointInTime, + getAgentById, } from './crud'; import type { GetAgentsOptions } from '.'; import { createAgentAction } from './actions'; import { ReassignActionRunner, reassignBatch } from './reassign_action_runner'; -export async function reassignAgent( +async function verifyNewAgentPolicy( soClient: SavedObjectsClientContract, - esClient: ElasticsearchClient, - agentId: string, newAgentPolicyId: string ) { - const newAgentPolicy = await agentPolicyService.get(soClient, newAgentPolicyId); + let newAgentPolicy; + try { + newAgentPolicy = await agentPolicyService.get(soClient, newAgentPolicyId); + } catch (err) { + if (err instanceof SavedObjectNotFound) { + throw new AgentPolicyNotFoundError(`Agent policy not found: ${newAgentPolicyId}`); + } + } if (!newAgentPolicy) { throw new AgentPolicyNotFoundError(`Agent policy not found: ${newAgentPolicyId}`); } - - await reassignAgentIsAllowed(soClient, esClient, agentId, newAgentPolicyId); - - await updateAgent(esClient, agentId, { - policy_id: newAgentPolicyId, - policy_revision: null, - }); - - await createAgentAction(esClient, { - agents: [agentId], - created_at: new Date().toISOString(), - type: 'POLICY_REASSIGN', - data: { - policy_id: newAgentPolicyId, - }, - }); + if (newAgentPolicy?.is_managed) { + throw new HostedAgentPolicyRestrictionRelatedError( + `Cannot reassign agents to hosted agent policy ${newAgentPolicy.id}` + ); + } } -export async function reassignAgentIsAllowed( +export async function reassignAgent( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, agentId: string, newAgentPolicyId: string ) { + await verifyNewAgentPolicy(soClient, newAgentPolicyId); + + await getAgentById(esClient, soClient, agentId); // throw 404 if agent not in namespace + const agentPolicy = await getAgentPolicyForAgent(soClient, esClient, agentId); if (agentPolicy?.is_managed) { throw new HostedAgentPolicyRestrictionRelatedError( @@ -69,14 +73,23 @@ export async function reassignAgentIsAllowed( ); } - const newAgentPolicy = await agentPolicyService.get(soClient, newAgentPolicyId); - if (newAgentPolicy?.is_managed) { - throw new HostedAgentPolicyRestrictionRelatedError( - `Cannot reassign an agent to hosted agent policy ${newAgentPolicy.id}` - ); - } + await updateAgent(esClient, agentId, { + policy_id: newAgentPolicyId, + policy_revision: null, + }); + + const currentNameSpace = getCurrentNamespace(soClient); + const namespaces = currentNameSpace ? { namespaces: [currentNameSpace] } : {}; - return true; + await createAgentAction(esClient, { + agents: [agentId], + created_at: new Date().toISOString(), + type: 'POLICY_REASSIGN', + data: { + policy_id: newAgentPolicyId, + }, + ...namespaces, + }); } export async function reassignAgents( @@ -88,16 +101,9 @@ export async function reassignAgents( }, newAgentPolicyId: string ): Promise<{ actionId: string }> { - const newAgentPolicy = await agentPolicyService.get(soClient, newAgentPolicyId); - if (!newAgentPolicy) { - throw new AgentPolicyNotFoundError(`Agent policy not found: ${newAgentPolicyId}`); - } - if (newAgentPolicy.is_managed) { - throw new HostedAgentPolicyRestrictionRelatedError( - `Cannot reassign an agent to hosted agent policy ${newAgentPolicy.id}` - ); - } + await verifyNewAgentPolicy(soClient, newAgentPolicyId); + const currentNameSpace = getCurrentNamespace(soClient); const outgoingErrors: Record = {}; let givenAgents: Agent[] = []; if ('agents' in options) { @@ -115,8 +121,10 @@ export async function reassignAgents( } } else if ('kuery' in options) { const batchSize = options.batchSize ?? SO_SEARCH_LIMIT; + const namespaceFilter = await agentsKueryNamespaceFilter(currentNameSpace); + const kuery = namespaceFilter ? `${namespaceFilter} AND ${options.kuery}` : options.kuery; const res = await getAgentsByKuery(esClient, soClient, { - kuery: options.kuery, + kuery, showInactive: options.showInactive ?? false, page: 1, perPage: batchSize, @@ -130,6 +138,7 @@ export async function reassignAgents( soClient, { ...options, + spaceId: currentNameSpace, batchSize, total: res.total, newAgentPolicyId, @@ -139,5 +148,10 @@ export async function reassignAgents( } } - return await reassignBatch(soClient, esClient, { newAgentPolicyId }, givenAgents, outgoingErrors); + return await reassignBatch( + esClient, + { newAgentPolicyId, spaceId: currentNameSpace }, + givenAgents, + outgoingErrors + ); } diff --git a/x-pack/plugins/fleet/server/services/agents/reassign_action_runner.ts b/x-pack/plugins/fleet/server/services/agents/reassign_action_runner.ts index b03146ab6b387b..cd9183b0771bcc 100644 --- a/x-pack/plugins/fleet/server/services/agents/reassign_action_runner.ts +++ b/x-pack/plugins/fleet/server/services/agents/reassign_action_runner.ts @@ -5,7 +5,7 @@ * 2.0. */ import { v4 as uuidv4 } from 'uuid'; -import type { SavedObjectsClientContract, ElasticsearchClient } from '@kbn/core/server'; +import type { ElasticsearchClient } from '@kbn/core/server'; import type { Agent } from '../../types'; @@ -22,7 +22,7 @@ import { BulkActionTaskType } from './bulk_action_types'; export class ReassignActionRunner extends ActionRunner { protected async processAgents(agents: Agent[]): Promise<{ actionId: string }> { - return await reassignBatch(this.soClient, this.esClient, this.actionParams! as any, agents, {}); + return await reassignBatch(this.esClient, this.actionParams! as any, agents, {}); } protected getTaskType() { @@ -35,16 +35,18 @@ export class ReassignActionRunner extends ActionRunner { } export async function reassignBatch( - soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, options: { newAgentPolicyId: string; actionId?: string; total?: number; + spaceId?: string; }, givenAgents: Agent[], outgoingErrors: Record ): Promise<{ actionId: string }> { + const spaceId = options.spaceId; + const soClient = appContextService.getInternalUserSOClientForSpaceId(spaceId); const errors: Record = { ...outgoingErrors }; const hostedPolicies = await getHostedPolicies(soClient, givenAgents); @@ -86,8 +88,9 @@ export async function reassignBatch( const actionId = options.actionId ?? uuidv4(); const total = options.total ?? givenAgents.length; - const now = new Date().toISOString(); + const namespaces = spaceId ? { namespaces: [spaceId] } : {}; + await createAgentAction(esClient, { id: actionId, agents: agentsToUpdate.map((agent) => agent.id), @@ -97,6 +100,7 @@ export async function reassignBatch( data: { policy_id: options.newAgentPolicyId, }, + ...namespaces, }); await createErrorActionResults( diff --git a/x-pack/plugins/fleet/server/services/agents/update_agent_tags.test.ts b/x-pack/plugins/fleet/server/services/agents/update_agent_tags.test.ts index efeb5649cd5763..cd408d953e31a3 100644 --- a/x-pack/plugins/fleet/server/services/agents/update_agent_tags.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/update_agent_tags.test.ts @@ -423,30 +423,6 @@ describe('update_agent_tags', () => { jest.mocked(isSpaceAwarenessEnabled).mockResolvedValue(true); }); - it('should not update tags for agents in another space', async () => { - soClient.getCurrentNamespace.mockReturnValue('default'); - esClient.search.mockResolvedValue({ - hits: { - hits: [ - { - _id: 'agent1', - _source: { - tags: ['one', 'two', 'three'], - namespaces: ['myspace'], - }, - fields: { - status: 'online', - }, - }, - ], - }, - } as any); - - await updateAgentTags(soClient, esClient, { agentIds: ['agent1'] }, ['one'], ['two']); - - expect(esClient.updateByQuery).not.toHaveBeenCalled(); - }); - it('should add namespace filter to kuery in the default space', async () => { soClient.getCurrentNamespace.mockReturnValue('default'); diff --git a/x-pack/plugins/fleet/server/services/agents/update_agent_tags.ts b/x-pack/plugins/fleet/server/services/agents/update_agent_tags.ts index 7d37581cef997c..4e42ac121ccca6 100644 --- a/x-pack/plugins/fleet/server/services/agents/update_agent_tags.ts +++ b/x-pack/plugins/fleet/server/services/agents/update_agent_tags.ts @@ -27,9 +27,9 @@ export async function updateAgentTags( tagsToAdd: string[], tagsToRemove: string[] ): Promise<{ actionId: string }> { + const currentNameSpace = getCurrentNamespace(soClient); const outgoingErrors: Record = {}; const givenAgents: Agent[] = []; - const currentNameSpace = getCurrentNamespace(soClient); if ('agentIds' in options) { const maybeAgents = await getAgentsById(esClient, soClient, options.agentIds); @@ -48,8 +48,8 @@ export async function updateAgentTags( } } else if ('kuery' in options) { const batchSize = options.batchSize ?? SO_SEARCH_LIMIT; - const namespaceFilter = await agentsKueryNamespaceFilter(currentNameSpace); + const filters = namespaceFilter ? [namespaceFilter] : []; if (options.kuery !== '') { filters.push(options.kuery); @@ -86,8 +86,15 @@ export async function updateAgentTags( ).runActionAsyncWithRetry(); } - return await updateTagsBatch(soClient, esClient, givenAgents, outgoingErrors, { - tagsToAdd, - tagsToRemove, - }); + return await updateTagsBatch( + soClient, + esClient, + givenAgents, + outgoingErrors, + { + tagsToAdd, + tagsToRemove, + }, + currentNameSpace + ); } diff --git a/x-pack/plugins/fleet/server/services/agents/update_agent_tags_action_runner.ts b/x-pack/plugins/fleet/server/services/agents/update_agent_tags_action_runner.ts index 8b68e1b6e9fd83..bb3b5f71cb222b 100644 --- a/x-pack/plugins/fleet/server/services/agents/update_agent_tags_action_runner.ts +++ b/x-pack/plugins/fleet/server/services/agents/update_agent_tags_action_runner.ts @@ -17,8 +17,6 @@ import { appContextService } from '../app_context'; import { FleetError } from '../../errors'; -import { getCurrentNamespace } from '../spaces/get_current_namespace'; - import { ActionRunner } from './action_runner'; import { BulkActionTaskType } from './bulk_action_types'; @@ -63,7 +61,8 @@ export async function updateTagsBatch( total?: number; kuery?: string; retryCount?: number; - } + }, + spaceId?: string ): Promise<{ actionId: string; updated?: number; took?: number }> { const errors: Record = { ...outgoingErrors }; const hostedAgentError = `Cannot modify tags on a hosted agent`; @@ -151,8 +150,7 @@ export async function updateTagsBatch( const versionConflictCount = res.version_conflicts ?? 0; const versionConflictIds = isLastRetry ? getUuidArray(versionConflictCount) : []; - const currentNameSpace = getCurrentNamespace(soClient); - const namespaces = currentNameSpace ? { namespaces: [currentNameSpace] } : {}; + const namespaces = spaceId ? { namespaces: [spaceId] } : {}; // creating an action doc so that update tags shows up in activity // the logic only saves agent count in the action that updated, failed or in case of last retry, conflicted @@ -195,7 +193,7 @@ export async function updateTagsBatch( failures.map((failure) => ({ agentId: failure.id, actionId, - namespace: currentNameSpace, + namespace: spaceId, error: failure.cause.reason, })) ); @@ -210,7 +208,7 @@ export async function updateTagsBatch( versionConflictIds.map((id) => ({ agentId: id, actionId, - namespace: currentNameSpace, + namespace: spaceId, error: 'version conflict on last retry', })) ); diff --git a/x-pack/plugins/fleet/server/services/agents/upgrade.test.ts b/x-pack/plugins/fleet/server/services/agents/upgrade.test.ts index b1e78862fde0e2..7dbfaf86bd2723 100644 --- a/x-pack/plugins/fleet/server/services/agents/upgrade.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/upgrade.test.ts @@ -35,11 +35,15 @@ jest.mock('./action_status', () => { }); describe('sendUpgradeAgentsActions (plural)', () => { + let mocks: ReturnType; + beforeEach(async () => { - const { soClient } = createClientMock(); + mocks = createClientMock(); + appContextService.start( createAppContextStartContractMock({}, false, { - withoutSpaceExtensions: soClient, + internal: mocks.soClient, + withoutSpaceExtensions: mocks.soClient, }) ); }); @@ -48,7 +52,7 @@ describe('sendUpgradeAgentsActions (plural)', () => { appContextService.stop(); }); it('can upgrade from an regular agent policy', async () => { - const { soClient, esClient, agentInRegularDoc, agentInRegularDoc2 } = createClientMock(); + const { soClient, esClient, agentInRegularDoc, agentInRegularDoc2 } = mocks; const idsToAction = [agentInRegularDoc._id, agentInRegularDoc2._id]; await sendUpgradeAgentsActions(soClient, esClient, { agentIds: idsToAction, version: '8.5.0' }); @@ -68,8 +72,7 @@ describe('sendUpgradeAgentsActions (plural)', () => { } }); it('cannot upgrade from a hosted agent policy by default', async () => { - const { soClient, esClient, agentInHostedDoc, agentInRegularDoc, agentInRegularDoc2 } = - createClientMock(); + const { soClient, esClient, agentInHostedDoc, agentInRegularDoc, agentInRegularDoc2 } = mocks; const idsToAction = [agentInRegularDoc._id, agentInHostedDoc._id, agentInRegularDoc2._id]; await sendUpgradeAgentsActions(soClient, esClient, { agentIds: idsToAction, version: '8.5.0' }); @@ -104,8 +107,8 @@ describe('sendUpgradeAgentsActions (plural)', () => { }); it('can upgrade from hosted agent policy with force=true', async () => { - const { soClient, esClient, agentInHostedDoc, agentInRegularDoc, agentInRegularDoc2 } = - createClientMock(); + const { soClient, esClient, agentInHostedDoc, agentInRegularDoc, agentInRegularDoc2 } = mocks; + const idsToAction = [agentInRegularDoc._id, agentInHostedDoc._id, agentInRegularDoc2._id]; await sendUpgradeAgentsActions(soClient, esClient, { agentIds: idsToAction, @@ -129,9 +132,9 @@ describe('sendUpgradeAgentsActions (plural)', () => { }); it('skip upgrade if action id is cancelled', async () => { - const { soClient, esClient, agentInRegularDoc } = createClientMock(); + const { esClient, agentInRegularDoc } = mocks; const agents = [{ id: agentInRegularDoc._id } as Agent]; - await upgradeBatch(soClient, esClient, agents, {}, { + await upgradeBatch(esClient, agents, {}, { actionId: 'cancelled-action', } as any); }); diff --git a/x-pack/plugins/fleet/server/services/agents/upgrade.ts b/x-pack/plugins/fleet/server/services/agents/upgrade.ts index a164b9ff86399c..40d676a68e24df 100644 --- a/x-pack/plugins/fleet/server/services/agents/upgrade.ts +++ b/x-pack/plugins/fleet/server/services/agents/upgrade.ts @@ -10,6 +10,9 @@ import type { Agent } from '../../types'; import { AgentReassignmentError, HostedAgentPolicyRestrictionRelatedError } from '../../errors'; import { SO_SEARCH_LIMIT } from '../../constants'; +import { agentsKueryNamespaceFilter } from '../spaces/agent_namespaces'; +import { getCurrentNamespace } from '../spaces/get_current_namespace'; + import { createAgentAction } from './actions'; import type { GetAgentsOptions } from './crud'; import { openPointInTime } from './crud'; @@ -43,12 +46,16 @@ export async function sendUpgradeAgentAction({ ); } + const currentNameSpace = getCurrentNamespace(soClient); + const namespaces = currentNameSpace ? { namespaces: [currentNameSpace] } : {}; + await createAgentAction(esClient, { agents: [agentId], created_at: now, data, ack_data: data, type: 'UPGRADE', + ...namespaces, }); await updateAgent(esClient, agentId, { upgraded_at: null, @@ -69,9 +76,11 @@ export async function sendUpgradeAgentsActions( batchSize?: number; } ): Promise<{ actionId: string }> { + const currentNameSpace = getCurrentNamespace(soClient); // Full set of agents const outgoingErrors: Record = {}; let givenAgents: Agent[] = []; + if ('agents' in options) { givenAgents = options.agents; } else if ('agentIds' in options) { @@ -87,12 +96,16 @@ export async function sendUpgradeAgentsActions( } } else if ('kuery' in options) { const batchSize = options.batchSize ?? SO_SEARCH_LIMIT; + const namespaceFilter = await agentsKueryNamespaceFilter(currentNameSpace); + const kuery = namespaceFilter ? `${namespaceFilter} AND ${options.kuery}` : options.kuery; + const res = await getAgentsByKuery(esClient, soClient, { - kuery: options.kuery, + kuery, showInactive: options.showInactive ?? false, page: 1, perPage: batchSize, }); + if (res.total <= batchSize) { givenAgents = res.agents; } else { @@ -103,11 +116,12 @@ export async function sendUpgradeAgentsActions( ...options, batchSize, total: res.total, + spaceId: currentNameSpace, }, { pitId: await openPointInTime(esClient) } ).runActionAsyncWithRetry(); } } - return await upgradeBatch(soClient, esClient, givenAgents, outgoingErrors, options); + return await upgradeBatch(esClient, givenAgents, outgoingErrors, options, currentNameSpace); } diff --git a/x-pack/plugins/fleet/server/services/agents/upgrade_action_runner.ts b/x-pack/plugins/fleet/server/services/agents/upgrade_action_runner.ts index d123489fe9eadc..a11b43a5b3ee23 100644 --- a/x-pack/plugins/fleet/server/services/agents/upgrade_action_runner.ts +++ b/x-pack/plugins/fleet/server/services/agents/upgrade_action_runner.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { SavedObjectsClientContract, ElasticsearchClient } from '@kbn/core/server'; +import type { ElasticsearchClient } from '@kbn/core/server'; import { v4 as uuidv4 } from 'uuid'; import moment from 'moment'; @@ -34,7 +34,13 @@ import { getLatestAvailableAgentVersion } from './versions'; export class UpgradeActionRunner extends ActionRunner { protected async processAgents(agents: Agent[]): Promise<{ actionId: string }> { - return await upgradeBatch(this.soClient, this.esClient, agents, {}, this.actionParams! as any); + return await upgradeBatch( + this.esClient, + agents, + {}, + this.actionParams! as any, + this.actionParams?.spaceId + ); } protected getTaskType() { @@ -52,7 +58,6 @@ const isActionIdCancelled = async (esClient: ElasticsearchClient, actionId: stri }; export async function upgradeBatch( - soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, givenAgents: Agent[], outgoingErrors: Record, @@ -65,8 +70,10 @@ export async function upgradeBatch( upgradeDurationSeconds?: number; startTime?: string; total?: number; - } + }, + spaceId?: string ): Promise<{ actionId: string }> { + const soClient = appContextService.getInternalUserSOClientForSpaceId(spaceId); const errors: Record = { ...outgoingErrors }; const hostedPolicies = await getHostedPolicies(soClient, givenAgents); @@ -167,6 +174,7 @@ export async function upgradeBatch( const actionId = options.actionId ?? uuidv4(); const total = options.total ?? givenAgents.length; + const namespaces = spaceId ? { namespaces: [spaceId] } : {}; await createAgentAction(esClient, { id: actionId, @@ -177,6 +185,7 @@ export async function upgradeBatch( total, agents: agentsToUpdate.map((agent) => agent.id), ...rollingUpgradeOptions, + ...namespaces, }); await createErrorActionResults( diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts index 802edd93e05435..36b6d4fdbeb17a 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts @@ -309,6 +309,7 @@ jest.mock('./app_context', () => ({ getExperimentalFeatures: jest.fn().mockReturnValue({ agentless: false, }), + getConfig: jest.fn(), getInternalUserSOClientForSpaceId: jest.fn(), }, })); diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.ts b/x-pack/plugins/fleet/server/services/preconfiguration.ts index b12adbe4a2437c..853961f2fd77a2 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.ts @@ -43,7 +43,7 @@ import { type InputsOverride, packagePolicyService } from './package_policy'; import { preconfigurePackageInputs } from './package_policy'; import { appContextService } from './app_context'; import type { UpgradeManagedPackagePoliciesResult } from './managed_package_policies'; -import { isAgentlessServerlessEnabled } from './utils/agentless'; +import { isDefaultAgentlessPolicyEnabled } from './utils/agentless'; interface PreconfigurationResult { policies: Array<{ id: string; updated_at: string }>; @@ -164,7 +164,7 @@ export async function ensurePreconfiguredPackagesAndPolicies( } if ( - !isAgentlessServerlessEnabled() && + !isDefaultAgentlessPolicyEnabled() && preconfiguredAgentPolicy?.supports_agentless !== undefined ) { throw new FleetError( diff --git a/x-pack/plugins/fleet/server/services/utils/agentless.test.ts b/x-pack/plugins/fleet/server/services/utils/agentless.test.ts index 4a1bbdd5f7d845..5bf5116128d94c 100644 --- a/x-pack/plugins/fleet/server/services/utils/agentless.test.ts +++ b/x-pack/plugins/fleet/server/services/utils/agentless.test.ts @@ -10,9 +10,9 @@ import { securityMock } from '@kbn/security-plugin/server/mocks'; import { appContextService } from '../app_context'; import { - isAgentlessCloudEnabled, + isAgentlessApiEnabled, isAgentlessEnabled, - isAgentlessServerlessEnabled, + isDefaultAgentlessPolicyEnabled, prependAgentlessApiBasePathToEndpoint, } from './agentless'; @@ -23,9 +23,10 @@ mockedAppContextService.getSecuritySetup.mockImplementation(() => ({ ...securityMock.createSetup(), })); -describe('isAgentlessCloudEnabled', () => { +describe('isAgentlessApiEnabled', () => { afterEach(() => { jest.clearAllMocks(); + mockedAppContextService.getConfig.mockReset(); }); it('should return false if cloud is not enabled', () => { jest.spyOn(appContextService, 'getConfig').mockReturnValue({ @@ -35,7 +36,7 @@ describe('isAgentlessCloudEnabled', () => { } as any); jest.spyOn(appContextService, 'getCloud').mockReturnValue({ isCloudEnabled: false } as any); - expect(isAgentlessCloudEnabled()).toBe(false); + expect(isAgentlessApiEnabled()).toBe(false); }); it('should return false if cloud is enabled but agentless is not', () => { @@ -46,7 +47,7 @@ describe('isAgentlessCloudEnabled', () => { } as any); jest.spyOn(appContextService, 'getCloud').mockReturnValue({ isCloudEnabled: true } as any); - expect(isAgentlessCloudEnabled()).toBe(false); + expect(isAgentlessApiEnabled()).toBe(false); }); it('should return true if cloud is enabled and agentless is enabled', () => { @@ -57,13 +58,14 @@ describe('isAgentlessCloudEnabled', () => { } as any); jest.spyOn(appContextService, 'getCloud').mockReturnValue({ isCloudEnabled: true } as any); - expect(isAgentlessCloudEnabled()).toBe(true); + expect(isAgentlessApiEnabled()).toBe(true); }); }); -describe('isAgentlessServerlessEnabled', () => { +describe('isDefaultAgentlessPolicyEnabled', () => { afterEach(() => { jest.clearAllMocks(); + mockedAppContextService.getConfig.mockReset(); }); it('should return false if serverless is not enabled', () => { @@ -74,7 +76,7 @@ describe('isAgentlessServerlessEnabled', () => { .spyOn(appContextService, 'getCloud') .mockReturnValue({ isServerlessEnabled: false } as any); - expect(isAgentlessServerlessEnabled()).toBe(false); + expect(isDefaultAgentlessPolicyEnabled()).toBe(false); }); it('should return false if serverless is enabled but agentless is not', () => { @@ -83,7 +85,7 @@ describe('isAgentlessServerlessEnabled', () => { .mockReturnValue({ agentless: false } as any); jest.spyOn(appContextService, 'getCloud').mockReturnValue({ isServerlessEnabled: true } as any); - expect(isAgentlessServerlessEnabled()).toBe(false); + expect(isDefaultAgentlessPolicyEnabled()).toBe(false); }); it('should return true if serverless is enabled and agentless is enabled', () => { @@ -92,13 +94,14 @@ describe('isAgentlessServerlessEnabled', () => { .mockReturnValue({ agentless: true } as any); jest.spyOn(appContextService, 'getCloud').mockReturnValue({ isServerlessEnabled: true } as any); - expect(isAgentlessServerlessEnabled()).toBe(true); + expect(isDefaultAgentlessPolicyEnabled()).toBe(true); }); }); describe('isAgentlessEnabled', () => { afterEach(() => { jest.clearAllMocks(); + mockedAppContextService.getConfig.mockReset(); }); it('should return false if cloud and serverless are not enabled', () => { @@ -138,8 +141,8 @@ describe('isAgentlessEnabled', () => { it('should return true if cloud is enabled and agentless is enabled', () => { jest - .spyOn(appContextService, 'getExperimentalFeatures') - .mockReturnValue({ agentless: true } as any); + .spyOn(appContextService, 'getConfig') + .mockReturnValue({ agentless: { enabled: true } } as any); jest .spyOn(appContextService, 'getCloud') .mockReturnValue({ isCloudEnabled: true, isServerlessEnabled: false } as any); @@ -163,7 +166,10 @@ describe('prependAgentlessApiBasePathToEndpoint', () => { jest.clearAllMocks(); }); - it('should prepend the agentless api base path to the endpoint', () => { + it('should prepend the agentless api base path to the endpoint with ess if in cloud', () => { + jest + .spyOn(appContextService, 'getCloud') + .mockReturnValue({ isCloudEnabled: true, isServerlessEnabled: false } as any); const agentlessConfig = { api: { url: 'https://agentless-api.com', @@ -176,7 +182,27 @@ describe('prependAgentlessApiBasePathToEndpoint', () => { ); }); + it('should prepend the agentless api base path to the endpoint with serverless if in serverless', () => { + jest + .spyOn(appContextService, 'getCloud') + .mockReturnValue({ isCloudEnabled: false, isServerlessEnabled: true } as any); + const agentlessConfig = { + api: { + url: 'https://agentless-api.com', + }, + } as any; + const endpoint = '/deployments'; + + expect(prependAgentlessApiBasePathToEndpoint(agentlessConfig, endpoint)).toBe( + 'https://agentless-api.com/api/v1/serverless/deployments' + ); + }); + it('should prepend the agentless api base path to the endpoint with a dynamic path', () => { + jest + .spyOn(appContextService, 'getCloud') + .mockReturnValue({ isCloudEnabled: true, isServerlessEnabled: false } as any); + const agentlessConfig = { api: { url: 'https://agentless-api.com', diff --git a/x-pack/plugins/fleet/server/services/utils/agentless.ts b/x-pack/plugins/fleet/server/services/utils/agentless.ts index d54ea2bb3d00b5..5c544b1907b251 100644 --- a/x-pack/plugins/fleet/server/services/utils/agentless.ts +++ b/x-pack/plugins/fleet/server/services/utils/agentless.ts @@ -8,21 +8,23 @@ import { appContextService } from '..'; import type { FleetConfigType } from '../../config'; -export const isAgentlessCloudEnabled = () => { +export const isAgentlessApiEnabled = () => { const cloudSetup = appContextService.getCloud(); - return Boolean(cloudSetup?.isCloudEnabled && appContextService.getConfig()?.agentless?.enabled); + const isHosted = cloudSetup?.isCloudEnabled || cloudSetup?.isServerlessEnabled; + return Boolean(isHosted && appContextService.getConfig()?.agentless?.enabled); }; -export const isAgentlessServerlessEnabled = () => { +export const isDefaultAgentlessPolicyEnabled = () => { const cloudSetup = appContextService.getCloud(); return Boolean( cloudSetup?.isServerlessEnabled && appContextService.getExperimentalFeatures().agentless ); }; export const isAgentlessEnabled = () => { - return isAgentlessCloudEnabled() || isAgentlessServerlessEnabled(); + return isAgentlessApiEnabled() || isDefaultAgentlessPolicyEnabled(); }; -const AGENTLESS_API_BASE_PATH = '/api/v1/ess'; +const AGENTLESS_ESS_API_BASE_PATH = '/api/v1/ess'; +const AGENTLESS_SERVERLESS_API_BASE_PATH = '/api/v1/serverless'; type AgentlessApiEndpoints = '/deployments' | `/deployments/${string}`; @@ -30,5 +32,9 @@ export const prependAgentlessApiBasePathToEndpoint = ( agentlessConfig: FleetConfigType['agentless'], endpoint: AgentlessApiEndpoints ) => { - return `${agentlessConfig.api.url}${AGENTLESS_API_BASE_PATH}${endpoint}`; + const cloudSetup = appContextService.getCloud(); + const endpointPrefix = cloudSetup?.isServerlessEnabled + ? AGENTLESS_SERVERLESS_API_BASE_PATH + : AGENTLESS_ESS_API_BASE_PATH; + return `${agentlessConfig.api.url}${endpointPrefix}${endpoint}`; }; diff --git a/x-pack/plugins/fleet/server/tasks/unenroll_inactive_agents_task.test.ts b/x-pack/plugins/fleet/server/tasks/unenroll_inactive_agents_task.test.ts new file mode 100644 index 00000000000000..dd1121a9046275 --- /dev/null +++ b/x-pack/plugins/fleet/server/tasks/unenroll_inactive_agents_task.test.ts @@ -0,0 +1,181 @@ +/* + * 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 { coreMock } from '@kbn/core/server/mocks'; +import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; +import type { TaskManagerSetupContract } from '@kbn/task-manager-plugin/server'; +import { TaskStatus } from '@kbn/task-manager-plugin/server'; +import { getDeleteTaskRunResult } from '@kbn/task-manager-plugin/server/task'; +import type { CoreSetup } from '@kbn/core/server'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; + +import { agentPolicyService } from '../services'; +import { createAgentPolicyMock } from '../../common/mocks'; +import { createAppContextStartContractMock } from '../mocks'; +import { getAgentsByKuery } from '../services/agents'; + +import { appContextService } from '../services'; + +import { unenrollBatch } from '../services/agents/unenroll_action_runner'; + +import type { AgentPolicy } from '../types'; + +import { UnenrollInactiveAgentsTask, TYPE, VERSION } from './unenroll_inactive_agents_task'; + +jest.mock('../services'); +jest.mock('../services/agents'); +jest.mock('../services/agents/unenroll_action_runner'); + +const MOCK_TASK_INSTANCE = { + id: `${TYPE}:${VERSION}`, + runAt: new Date(), + attempts: 0, + ownerId: '', + status: TaskStatus.Running, + startedAt: new Date(), + scheduledAt: new Date(), + retryAt: new Date(), + params: {}, + state: {}, + taskType: TYPE, +}; + +const mockAgentPolicyService = agentPolicyService as jest.Mocked; +const mockedGetAgentsByKuery = getAgentsByKuery as jest.MockedFunction; + +describe('UnenrollInactiveAgentsTask', () => { + const { createSetup: coreSetupMock } = coreMock; + const { createSetup: tmSetupMock, createStart: tmStartMock } = taskManagerMock; + + let mockContract: ReturnType; + let mockTask: UnenrollInactiveAgentsTask; + let mockCore: CoreSetup; + let mockTaskManagerSetup: jest.Mocked; + const mockedUnenrollBatch = jest.mocked(unenrollBatch); + + const agents = [ + { + id: 'agent-1', + policy_id: 'agent-policy-2', + status: 'inactive', + }, + { + id: 'agent-2', + policy_id: 'agent-policy-1', + status: 'inactive', + }, + { + id: 'agent-3', + policy_id: 'agent-policy-1', + status: 'active', + }, + ]; + + const getMockAgentPolicyFetchAllAgentPolicies = (items: AgentPolicy[]) => + jest.fn().mockResolvedValue( + jest.fn(async function* () { + yield items; + })() + ); + + beforeEach(() => { + mockContract = createAppContextStartContractMock(); + appContextService.start(mockContract); + mockCore = coreSetupMock(); + mockTaskManagerSetup = tmSetupMock(); + mockTask = new UnenrollInactiveAgentsTask({ + core: mockCore, + taskManager: mockTaskManagerSetup, + logFactory: loggingSystemMock.create(), + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Task lifecycle', () => { + it('Should create task', () => { + expect(mockTask).toBeInstanceOf(UnenrollInactiveAgentsTask); + }); + + it('Should register task', () => { + expect(mockTaskManagerSetup.registerTaskDefinitions).toHaveBeenCalled(); + }); + + it('Should schedule task', async () => { + const mockTaskManagerStart = tmStartMock(); + await mockTask.start({ taskManager: mockTaskManagerStart }); + expect(mockTaskManagerStart.ensureScheduled).toHaveBeenCalled(); + }); + }); + + describe('Task logic', () => { + const runTask = async (taskInstance = MOCK_TASK_INSTANCE) => { + const mockTaskManagerStart = tmStartMock(); + await mockTask.start({ taskManager: mockTaskManagerStart }); + const createTaskRunner = + mockTaskManagerSetup.registerTaskDefinitions.mock.calls[0][0][TYPE].createTaskRunner; + const taskRunner = createTaskRunner({ taskInstance }); + return taskRunner.run(); + }; + + beforeEach(() => { + mockAgentPolicyService.fetchAllAgentPolicies = getMockAgentPolicyFetchAllAgentPolicies([ + createAgentPolicyMock({ unenroll_timeout: 3000 }), + createAgentPolicyMock({ id: 'agent-policy-2', unenroll_timeout: 1000 }), + ]); + + mockedGetAgentsByKuery.mockResolvedValue({ + agents, + } as any); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('Should unenroll eligible agents', async () => { + mockedUnenrollBatch.mockResolvedValueOnce({ actionId: 'actionid-01' }); + await runTask(); + expect(mockedUnenrollBatch).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + agents, + { + force: true, + revoke: true, + actionId: expect.stringContaining('UnenrollInactiveAgentsTask-'), + } + ); + }); + + it('Should not run if task is outdated', async () => { + const result = await runTask({ ...MOCK_TASK_INSTANCE, id: 'old-id' }); + + expect(mockedUnenrollBatch).not.toHaveBeenCalled(); + expect(result).toEqual(getDeleteTaskRunResult()); + }); + + it('Should exit if there are no agents policies with unenroll_timeout set', async () => { + mockAgentPolicyService.list.mockResolvedValue({ + items: [], + total: 0, + page: 1, + perPage: 1, + }); + expect(mockedUnenrollBatch).not.toHaveBeenCalled(); + }); + + it('Should exit if there are no eligible agents to unenroll', async () => { + mockedGetAgentsByKuery.mockResolvedValue({ + agents: [], + } as any); + expect(mockedUnenrollBatch).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/fleet/server/tasks/unenroll_inactive_agents_task.ts b/x-pack/plugins/fleet/server/tasks/unenroll_inactive_agents_task.ts new file mode 100644 index 00000000000000..d56c10cc61a202 --- /dev/null +++ b/x-pack/plugins/fleet/server/tasks/unenroll_inactive_agents_task.ts @@ -0,0 +1,204 @@ +/* + * 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 { SavedObjectsClient } from '@kbn/core/server'; +import { v4 as uuidv4 } from 'uuid'; +import type { + CoreSetup, + ElasticsearchClient, + Logger, + SavedObjectsClientContract, +} from '@kbn/core/server'; +import type { + ConcreteTaskInstance, + TaskManagerSetupContract, + TaskManagerStartContract, +} from '@kbn/task-manager-plugin/server'; +import { getDeleteTaskRunResult } from '@kbn/task-manager-plugin/server/task'; +import type { LoggerFactory } from '@kbn/core/server'; +import { errors } from '@elastic/elasticsearch'; + +import { AGENTS_PREFIX, AGENT_POLICY_SAVED_OBJECT_TYPE } from '../constants'; +import { getAgentsByKuery } from '../services/agents'; +import { unenrollBatch } from '../services/agents/unenroll_action_runner'; +import { agentPolicyService, auditLoggingService } from '../services'; + +export const TYPE = 'fleet:unenroll-inactive-agents-task'; +export const VERSION = '1.0.0'; +const TITLE = 'Fleet Unenroll Inactive Agent Task'; +const SCOPE = ['fleet']; +const INTERVAL = '10m'; +const TIMEOUT = '1m'; +const UNENROLLMENT_BATCHSIZE = 1000; +const POLICIES_BATCHSIZE = 500; + +interface UnenrollInactiveAgentsTaskSetupContract { + core: CoreSetup; + taskManager: TaskManagerSetupContract; + logFactory: LoggerFactory; +} + +interface UnenrollInactiveAgentsTaskStartContract { + taskManager: TaskManagerStartContract; +} + +export class UnenrollInactiveAgentsTask { + private logger: Logger; + private wasStarted: boolean = false; + private abortController = new AbortController(); + + constructor(setupContract: UnenrollInactiveAgentsTaskSetupContract) { + const { core, taskManager, logFactory } = setupContract; + this.logger = logFactory.get(this.taskId); + + taskManager.registerTaskDefinitions({ + [TYPE]: { + title: TITLE, + timeout: TIMEOUT, + createTaskRunner: ({ taskInstance }: { taskInstance: ConcreteTaskInstance }) => { + return { + run: async () => { + return this.runTask(taskInstance, core); + }, + cancel: async () => { + this.abortController.abort('Task timed out'); + }, + }; + }, + }, + }); + } + + public start = async ({ taskManager }: UnenrollInactiveAgentsTaskStartContract) => { + if (!taskManager) { + this.logger.error('[UnenrollInactiveAgentsTask] Missing required service during start'); + return; + } + + this.wasStarted = true; + this.logger.info(`[UnenrollInactiveAgentsTask] Started with interval of [${INTERVAL}]`); + + try { + await taskManager.ensureScheduled({ + id: this.taskId, + taskType: TYPE, + scope: SCOPE, + schedule: { + interval: INTERVAL, + }, + state: {}, + params: { version: VERSION }, + }); + } catch (e) { + this.logger.error(`Error scheduling task UnenrollInactiveAgentsTask, error: ${e.message}`, e); + } + }; + + private get taskId(): string { + return `${TYPE}:${VERSION}`; + } + + private endRun(msg: string = '') { + this.logger.info(`[UnenrollInactiveAgentsTask] runTask ended${msg ? ': ' + msg : ''}`); + } + + public async unenrollInactiveAgents( + esClient: ElasticsearchClient, + soClient: SavedObjectsClientContract + ) { + this.logger.debug( + `[UnenrollInactiveAgentsTask] Fetching agent policies with unenroll_timeout > 0` + ); + // find all agent policies that are not managed and having unenroll_timeout > 0 + // limit the search to POLICIES_BATCHSIZE at a time and loop until there are no agent policies left + const policiesKuery = `${AGENT_POLICY_SAVED_OBJECT_TYPE}.is_managed: false AND ${AGENT_POLICY_SAVED_OBJECT_TYPE}.unenroll_timeout > 0`; + let agentCounter = 0; + + const agentPolicyFetcher = await agentPolicyService.fetchAllAgentPolicies(soClient, { + kuery: policiesKuery, + perPage: POLICIES_BATCHSIZE, + }); + for await (const agentPolicyPageResults of agentPolicyFetcher) { + this.logger.debug( + `[UnenrollInactiveAgentsTask] Found "${agentPolicyPageResults.length}" agent policies with unenroll_timeout > 0` + ); + if (!agentPolicyPageResults.length) { + this.endRun('Found no policies to process'); + return; + } + + // find inactive agents enrolled on above policies + // limit batch size to UNENROLLMENT_BATCHSIZE to avoid scale issues + const kuery = `(${AGENTS_PREFIX}.policy_id:${agentPolicyPageResults + .map((policy) => `"${policy.id}"`) + .join(' or ')}) and ${AGENTS_PREFIX}.status: inactive`; + const res = await getAgentsByKuery(esClient, soClient, { + kuery, + showInactive: true, + page: 1, + perPage: UNENROLLMENT_BATCHSIZE, + }); + if (!res.agents.length) { + this.endRun('No inactive agents to unenroll'); + return; + } + agentCounter += res.agents.length; + if (agentCounter >= UNENROLLMENT_BATCHSIZE) { + this.endRun('Reached the maximum amount of agents to unenroll, exiting.'); + return; + } + this.logger.debug( + `[UnenrollInactiveAgentsTask] Found "${res.agents.length}" inactive agents to unenroll. Attempting unenrollment` + ); + const unenrolledBatch = await unenrollBatch(soClient, esClient, res.agents, { + revoke: true, + force: true, + actionId: `UnenrollInactiveAgentsTask-${uuidv4()}`, + }); + auditLoggingService.writeCustomAuditLog({ + message: `Recurrent unenrollment of ${agentCounter} inactive agents due to unenroll_timeout option set on agent policy. Fleet action [id=${unenrolledBatch.actionId}]`, + }); + this.logger.debug( + `[UnenrollInactiveAgentsTask] Executed unenrollment of ${agentCounter} inactive agents with actionId: ${unenrolledBatch.actionId}` + ); + } + } + + public runTask = async (taskInstance: ConcreteTaskInstance, core: CoreSetup) => { + if (!this.wasStarted) { + this.logger.debug('[UnenrollInactiveAgentsTask] runTask Aborted. Task not started yet'); + return; + } + // Check that this task is current + if (taskInstance.id !== this.taskId) { + this.logger.debug( + `[UnenrollInactiveAgentsTask] Outdated task version: Got [${taskInstance.id}] from task instance. Current version is [${this.taskId}]` + ); + return getDeleteTaskRunResult(); + } + + this.logger.info(`[runTask()] started`); + + const [coreStart] = await core.getStartServices(); + const esClient = coreStart.elasticsearch.client.asInternalUser; + const soClient = new SavedObjectsClient(coreStart.savedObjects.createInternalRepository()); + + try { + await this.unenrollInactiveAgents(esClient, soClient); + + this.endRun('success'); + } catch (err) { + if (err instanceof errors.RequestAbortedError) { + this.logger.warn(`[UnenrollInactiveAgentsTask] request aborted due to timeout: ${err}`); + this.endRun(); + return; + } + this.logger.error(`[UnenrollInactiveAgentsTask] error: ${err}`); + this.endRun('error'); + } + }; +} diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts index 4dac5ff4139092..e03a403037aa51 100644 --- a/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts +++ b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts @@ -27,12 +27,18 @@ import { } from '@kbn/rule-data-utils'; import { + AggregateName, + AggregationsAggregate, + AggregationsMultiBucketAggregateBase, InlineScript, MappingRuntimeFields, QueryDslQueryContainer, SortCombinations, -} from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { RuleTypeParams, PluginStartContract as AlertingStart } from '@kbn/alerting-plugin/server'; +} from '@elastic/elasticsearch/lib/api/types'; +import type { + RuleTypeParams, + PluginStartContract as AlertingStart, +} from '@kbn/alerting-plugin/server'; import { ReadOperations, AlertingAuthorization, @@ -279,7 +285,7 @@ export class AlertsClient { /** * Searches alerts by id or query and audits the results */ - private async searchAlerts({ + private async searchAlerts>({ id, query, aggs, @@ -335,7 +341,7 @@ export class AlertsClient { }; } - const result = await this.esClient.search({ + const result = await this.esClient.search({ index: index ?? '.alerts-*', ignore_unavailable: true, body: queryBody, @@ -975,7 +981,10 @@ export class AlertsClient { } } - public async find({ + public async find< + Params extends RuleTypeParams = never, + TAggregations = Record + >({ aggs, featureIds, index, @@ -1007,7 +1016,7 @@ export class AlertsClient { } } - const alertsSearchResponse = await this.searchAlerts({ + const alertsSearchResponse = await this.searchAlerts({ query, aggs, _source, @@ -1036,7 +1045,7 @@ export class AlertsClient { /** * Performs a `find` query to extract aggregations on alert groups */ - public getGroupAggregations({ + public async getGroupAggregations({ featureIds, groupByField, aggregations, @@ -1086,7 +1095,10 @@ export class AlertsClient { `The number of documents is too high. Paginating through more than ${MAX_PAGINATED_ALERTS} documents is not possible.` ); } - return this.find({ + const searchResult = await this.find< + never, + { groupByFields: AggregationsMultiBucketAggregateBase<{ key: string }> } + >({ featureIds, aggs: { groupByFields: { @@ -1139,6 +1151,20 @@ export class AlertsClient { size: 0, _source: false, }); + // Replace artificial uuid values with '--' in null-value buckets and mark them with `isNullGroup = true` + const groupsAggregation = searchResult.aggregations?.groupByFields; + if (groupsAggregation) { + const buckets = Array.isArray(groupsAggregation?.buckets) + ? groupsAggregation.buckets + : Object.values(groupsAggregation?.buckets ?? {}); + buckets.forEach((bucket) => { + if (bucket.key === uniqueValue) { + bucket.key = '--'; + (bucket as { isNullGroup?: boolean }).isNullGroup = true; + } + }); + } + return searchResult; } public async getAuthorizedAlertsIndices(featureIds: string[]): Promise { diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/tests/get_alerts_group_aggregations.test.ts b/x-pack/plugins/rule_registry/server/alert_data_client/tests/get_alerts_group_aggregations.test.ts index 8aedf715ff6687..af10edf3723838 100644 --- a/x-pack/plugins/rule_registry/server/alert_data_client/tests/get_alerts_group_aggregations.test.ts +++ b/x-pack/plugins/rule_registry/server/alert_data_client/tests/get_alerts_group_aggregations.test.ts @@ -72,7 +72,7 @@ beforeEach(() => { describe('getGroupAggregations()', () => { test('calls find() with the correct params', async () => { const alertsClient = new AlertsClient(alertsClientParams); - alertsClient.find = jest.fn(); + alertsClient.find = jest.fn().mockResolvedValue({ aggregations: {} }); const featureIds = [AlertConsumers.STACK_ALERTS]; const groupByField = 'kibana.alert.rule.name'; @@ -141,27 +141,57 @@ describe('getGroupAggregations()', () => { }); }); + test('replaces the key of null-value buckets and marks them with the `isNullGroup` flag', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + alertsClient.find = jest.fn().mockResolvedValue({ + aggregations: { + groupByFields: { + buckets: [ + { + key: 'unique-value', + doc_count: 1, + }, + ], + }, + }, + }); + + const result = await alertsClient.getGroupAggregations({ + featureIds: [AlertConsumers.STACK_ALERTS], + groupByField: 'kibana.alert.rule.name', + aggregations: {}, + filters: [], + pageIndex: 0, + pageSize: DEFAULT_ALERTS_GROUP_BY_FIELD_SIZE, + }); + + const firstBucket = (result.aggregations as any).groupByFields.buckets[0]; + + expect(firstBucket.isNullGroup).toBe(true); + expect(firstBucket.key).toEqual('--'); + }); + test('rejects with invalid pagination options', async () => { const alertsClient = new AlertsClient(alertsClientParams); - expect(() => + await expect(() => alertsClient.getGroupAggregations({ featureIds: ['apm', 'infrastructure', 'logs', 'observability', 'slo', 'uptime'], groupByField: 'kibana.alert.rule.name', pageIndex: 101, pageSize: 50, }) - ).toThrowErrorMatchingInlineSnapshot( + ).rejects.toThrowErrorMatchingInlineSnapshot( `"The provided pageIndex value is too high. The maximum allowed pageIndex value is 100."` ); - expect(() => + await expect(() => alertsClient.getGroupAggregations({ featureIds: ['apm', 'infrastructure', 'logs', 'observability', 'slo', 'uptime'], groupByField: 'kibana.alert.rule.name', pageIndex: 10, pageSize: 5000, }) - ).toThrowErrorMatchingInlineSnapshot( + ).rejects.toThrowErrorMatchingInlineSnapshot( `"The number of documents is too high. Paginating through more than 10000 documents is not possible."` ); }); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts b/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts index d4d129f57ae3a4..2362284b3f6964 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts @@ -7,28 +7,10 @@ import { i18n } from '@kbn/i18n'; -export const INVESTIGATION_GUIDE = i18n.translate( - 'xpack.securitySolution.alertDetails.overview.investigationGuide', - { - defaultMessage: 'Investigation guide', - } -); - export const TABLE = i18n.translate('xpack.securitySolution.eventDetails.table', { defaultMessage: 'Table', }); -export const OSQUERY_VIEW = i18n.translate('xpack.securitySolution.eventDetails.osqueryView', { - defaultMessage: 'Osquery Results', -}); - -export const RESPONSE_ACTIONS_VIEW = i18n.translate( - 'xpack.securitySolution.eventDetails.responseActionsView', - { - defaultMessage: 'Response Results', - } -); - export const DESCRIPTION = i18n.translate('xpack.securitySolution.eventDetails.description', { defaultMessage: 'Description', }); @@ -48,20 +30,6 @@ export const RULE_TYPE = i18n.translate('xpack.securitySolution.detections.alert defaultMessage: 'Rule type', }); -export const MULTI_FIELD_TOOLTIP = i18n.translate( - 'xpack.securitySolution.eventDetails.multiFieldTooltipContent', - { - defaultMessage: 'Multi-fields can have multiple values per field', - } -); - -export const MULTI_FIELD_BADGE = i18n.translate( - 'xpack.securitySolution.eventDetails.multiFieldBadge', - { - defaultMessage: 'multi-field', - } -); - export const ACTIONS = i18n.translate('xpack.securitySolution.eventDetails.table.actions', { defaultMessage: 'Actions', }); diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/index.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/index.tsx index 6eae6b723d5410..791bace753ff83 100644 --- a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/insight/index.tsx @@ -42,7 +42,7 @@ import { useAppToasts } from '../../../../hooks/use_app_toasts'; import { useKibana } from '../../../../lib/kibana'; import { useInsightQuery } from './use_insight_query'; import { useInsightDataProviders, type Provider } from './use_insight_data_providers'; -import { BasicAlertDataContext } from '../../../event_details/investigation_guide_view'; +import { BasicAlertDataContext } from '../../../../../flyout/document_details/left/components/investigation_guide_view'; import { InvestigateInTimelineButton } from '../../../event_details/table/investigate_in_timeline_button'; import { getTimeRangeSettings, diff --git a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/osquery/renderer.tsx b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/osquery/renderer.tsx index 04963e70f9cfaa..198f64bb252372 100644 --- a/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/osquery/renderer.tsx +++ b/x-pack/plugins/security_solution/public/common/components/markdown_editor/plugins/osquery/renderer.tsx @@ -13,7 +13,7 @@ import styled from 'styled-components'; import { EuiButton, EuiToolTip } from '@elastic/eui'; import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; import { useUpsellingMessage } from '../../../../hooks/use_upselling'; -import { BasicAlertDataContext } from '../../../event_details/investigation_guide_view'; +import { BasicAlertDataContext } from '../../../../../flyout/document_details/left/components/investigation_guide_view'; import { expandDottedObject } from '../../../../../../common/utils/expand_dotted'; import OsqueryLogo from './osquery_icon/osquery.svg'; import { OsqueryFlyout } from '../../../../../detections/components/osquery/osquery_flyout'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/investigation_guide.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/investigation_guide.tsx index 0bf6ca92b28fa0..ee1bebdb336ce5 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/investigation_guide.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/investigation_guide.tsx @@ -10,7 +10,7 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { useInvestigationGuide } from '../../shared/hooks/use_investigation_guide'; import { useDocumentDetailsContext } from '../../shared/context'; import { INVESTIGATION_GUIDE_TEST_ID, INVESTIGATION_GUIDE_LOADING_TEST_ID } from './test_ids'; -import { InvestigationGuideView } from '../../../../common/components/event_details/investigation_guide_view'; +import { InvestigationGuideView } from './investigation_guide_view'; import { FlyoutLoading } from '../../../shared/components/flyout_loading'; /** diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/investigation_guide_view.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/investigation_guide_view.test.tsx similarity index 92% rename from x-pack/plugins/security_solution/public/common/components/event_details/investigation_guide_view.test.tsx rename to x-pack/plugins/security_solution/public/flyout/document_details/left/components/investigation_guide_view.test.tsx index 355ad1f9129d7f..bc7de71c564175 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/investigation_guide_view.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/investigation_guide_view.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { render } from '@testing-library/react'; import { InvestigationGuideView } from './investigation_guide_view'; -import type { UseBasicDataFromDetailsDataResult } from '../../../flyout/document_details/shared/hooks/use_basic_data_from_details_data'; +import type { UseBasicDataFromDetailsDataResult } from '../../shared/hooks/use_basic_data_from_details_data'; const defaultProps = { basicData: { diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/investigation_guide_view.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/investigation_guide_view.tsx similarity index 81% rename from x-pack/plugins/security_solution/public/common/components/event_details/investigation_guide_view.tsx rename to x-pack/plugins/security_solution/public/flyout/document_details/left/components/investigation_guide_view.tsx index b3015bafe4536f..3d61c223fd47ff 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/investigation_guide_view.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/investigation_guide_view.tsx @@ -8,10 +8,17 @@ import { EuiSpacer, EuiTitle, EuiText } from '@elastic/eui'; import React, { createContext } from 'react'; import styled from 'styled-components'; -import type { UseBasicDataFromDetailsDataResult } from '../../../flyout/document_details/shared/hooks/use_basic_data_from_details_data'; -import * as i18n from './translations'; -import { MarkdownRenderer } from '../markdown_editor'; -import { LineClamp } from '../line_clamp'; +import { i18n } from '@kbn/i18n'; +import type { UseBasicDataFromDetailsDataResult } from '../../shared/hooks/use_basic_data_from_details_data'; +import { LineClamp } from '../../../../common/components/line_clamp'; +import { MarkdownRenderer } from '../../../../common/components/markdown_editor'; + +const INVESTIGATION_GUIDE = i18n.translate( + 'xpack.securitySolution.flyout.left.investigationGuide', + { + defaultMessage: 'Investigation guide', + } +); export const Indent = styled.div` padding: 0 8px; @@ -43,7 +50,6 @@ interface InvestigationGuideViewProps { /** * Investigation guide that shows the markdown text of rule.note */ -// TODO: MOVE TO FLYOUT FOLDER - https://github.com/elastic/security-team/issues/7462 const InvestigationGuideViewComponent: React.FC = ({ basicData, ruleNote, @@ -56,7 +62,7 @@ const InvestigationGuideViewComponent: React.FC = ( <> -
{i18n.INVESTIGATION_GUIDE}
+
{INVESTIGATION_GUIDE}
diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/response_details.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/response_details.tsx index 5081bdad9c17fc..a26e636749786b 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/response_details.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/response_details.tsx @@ -11,7 +11,7 @@ import styled from 'styled-components'; import { FormattedMessage } from '@kbn/i18n-react'; import { RESPONSE_DETAILS_TEST_ID } from './test_ids'; import { useDocumentDetailsContext } from '../../shared/context'; -import { useResponseActionsView } from '../../../../common/components/event_details/response_actions_view'; +import { useResponseActionsView } from '../hooks/use_response_actions_view'; const ExtendedFlyoutWrapper = styled.div` figure { diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/hooks/use_response_actions_view.test.ts b/x-pack/plugins/security_solution/public/flyout/document_details/left/hooks/use_response_actions_view.test.ts new file mode 100644 index 00000000000000..cafac9f3a0b9fe --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/hooks/use_response_actions_view.test.ts @@ -0,0 +1,61 @@ +/* + * 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 { useResponseActionsView } from './use_response_actions_view'; +import { mockSearchHit } from '../../shared/mocks/mock_search_hit'; +import { mockDataAsNestedObject } from '../../shared/mocks/mock_data_as_nested_object'; +import { useGetAutomatedActionList } from '../../../../management/hooks/response_actions/use_get_automated_action_list'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; + +const ecsData = mockDataAsNestedObject; +const rawEventData = mockSearchHit; + +jest.mock('../../../../common/hooks/use_experimental_features'); +jest.mock('../../../../management/hooks/response_actions/use_get_automated_action_list'); + +describe('useResponseActionsView', () => { + it('should return the normal component', () => { + (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(true); + (useGetAutomatedActionList as jest.Mock).mockReturnValue({ + data: [], + isFetched: true, + }); + + const { result } = renderHook(() => + useResponseActionsView({ + ecsData, + rawEventData, + }) + ); + + expect(result.current.id).toEqual('response-actions-results-view'); + expect(result.current.name).toEqual('Response Results'); + expect(result.current.append).toBeDefined(); + expect(result.current.content).toBeDefined(); + }); + + it('returns early return if rawEventData is undefined', () => { + (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(true); + (useGetAutomatedActionList as jest.Mock).mockReturnValue({ + data: [], + isFetched: true, + }); + + const { result } = renderHook(() => + useResponseActionsView({ + ecsData, + rawEventData: undefined, + }) + ); + + expect(result.current.id).toEqual('response-actions-results-view'); + expect(result.current.name).toEqual('Response Results'); + expect(result.current.append).not.toBeDefined(); + expect(result.current.content).toBeDefined(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/response_actions_view.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/hooks/use_response_actions_view.tsx similarity index 75% rename from x-pack/plugins/security_solution/public/common/components/event_details/response_actions_view.tsx rename to x-pack/plugins/security_solution/public/flyout/document_details/left/hooks/use_response_actions_view.tsx index 33760b7ab42424..b6966b529d3d6d 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/response_actions_view.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/hooks/use_response_actions_view.tsx @@ -5,23 +5,29 @@ * 2.0. */ -import React, { useMemo, useState, useEffect } from 'react'; -import styled from 'styled-components'; +import React, { useState, useEffect } from 'react'; +import styled from '@emotion/styled'; import type { EuiTabbedContentTab } from '@elastic/eui'; import { EuiLink, EuiNotificationBadge, EuiSpacer } from '@elastic/eui'; import type { Ecs } from '@kbn/cases-plugin/common'; import { FormattedMessage } from '@kbn/i18n-react'; -import { RESPONSE_NO_DATA_TEST_ID } from '../../../flyout/document_details/left/components/test_ids'; -import type { SearchHit } from '../../../../common/search_strategy'; +import { i18n } from '@kbn/i18n'; +import { RESPONSE_NO_DATA_TEST_ID } from '../components/test_ids'; +import type { SearchHit } from '../../../../../common/search_strategy'; import type { ExpandedEventFieldsObject, RawEventData, -} from '../../../../common/types/response_actions'; -import { ResponseActionsResults } from '../response_actions/response_actions_results'; -import { expandDottedObject } from '../../../../common/utils/expand_dotted'; -import { useGetAutomatedActionList } from '../../../management/hooks/response_actions/use_get_automated_action_list'; -import { EventsViewType } from './event_details'; -import * as i18n from './translations'; +} from '../../../../../common/types/response_actions'; +import { ResponseActionsResults } from '../../../../common/components/response_actions/response_actions_results'; +import { expandDottedObject } from '../../../../../common/utils/expand_dotted'; +import { useGetAutomatedActionList } from '../../../../management/hooks/response_actions/use_get_automated_action_list'; + +const RESPONSE_ACTIONS_VIEW = i18n.translate( + 'xpack.securitySolution.flyout.response.responseActionsView', + { + defaultMessage: 'Response Results', + } +); const TabContentWrapper = styled.div` height: 100%; @@ -56,23 +62,29 @@ const EmptyResponseActions = () => { ); }; -// TODO: MOVE TO FLYOUT FOLDER - https://github.com/elastic/security-team/issues/7462 +const viewData = { + id: 'response-actions-results-view', + name: RESPONSE_ACTIONS_VIEW, +}; + +export interface UseResponseActionsViewParams { + /** + * An object with top level fields from the ECS object + */ + ecsData?: Ecs | null; + /** + * The actual raw document object + */ + rawEventData: SearchHit | undefined; +} + +/** + * + */ export const useResponseActionsView = ({ rawEventData, ecsData, -}: { - ecsData?: Ecs | null; - rawEventData: SearchHit | undefined; -}): EuiTabbedContentTab | undefined => { - // can not be moved outside of the component, because then EventsViewType throws runtime error regarding not being initialized yet - const viewData = useMemo( - () => ({ - id: EventsViewType.responseActionsView, - 'data-test-subj': 'responseActionsViewTab', - name: i18n.RESPONSE_ACTIONS_VIEW, - }), - [] - ); +}: UseResponseActionsViewParams): EuiTabbedContentTab => { const expandedEventFieldsObject = rawEventData ? (expandDottedObject((rawEventData as RawEventData).fields) as ExpandedEventFieldsObject) : undefined; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/hooks/use_threat_intelligence_details.test.ts b/x-pack/plugins/security_solution/public/flyout/document_details/left/hooks/use_threat_intelligence_details.test.ts index 430eb52b095111..e40cd74709cfd6 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/hooks/use_threat_intelligence_details.test.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/hooks/use_threat_intelligence_details.test.ts @@ -49,7 +49,6 @@ describe('useThreatIntelligenceDetails', () => { dataViewId: '', loading: false, indicesExist: true, - patternList: [], selectedPatterns: [], indexPattern: { fields: [], title: '' }, sourcererDataView: undefined, diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview.test.tsx index 67d7438e8bb68c..724eaf979d8fd0 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview.test.tsx @@ -8,7 +8,7 @@ import { render } from '@testing-library/react'; import React from 'react'; import { TestProviders } from '../../../../common/mock'; -import { useAlertPrevalenceFromProcessTree } from '../../../../common/containers/alerts/use_alert_prevalence_from_process_tree'; +import { useAlertPrevalenceFromProcessTree } from '../../shared/hooks/use_alert_prevalence_from_process_tree'; import { useTimelineDataFilters } from '../../../../timelines/containers/use_timeline_data_filters'; import { mockContextValue } from '../../shared/mocks/mock_context'; import { mockDataFormattedForFieldBrowser } from '../../shared/mocks/mock_data_formatted_for_field_browser'; @@ -17,7 +17,7 @@ import { AnalyzerPreview } from './analyzer_preview'; import { ANALYZER_PREVIEW_TEST_ID } from './test_ids'; import * as mock from '../mocks/mock_analyzer_data'; -jest.mock('../../../../common/containers/alerts/use_alert_prevalence_from_process_tree', () => ({ +jest.mock('../../shared/hooks/use_alert_prevalence_from_process_tree', () => ({ useAlertPrevalenceFromProcessTree: jest.fn(), })); const mockUseAlertPrevalenceFromProcessTree = useAlertPrevalenceFromProcessTree as jest.Mock; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview.tsx index efae023e0d0929..bbdcc4f8e3d6b9 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview.tsx @@ -13,8 +13,8 @@ import { ANALYZER_PREVIEW_TEST_ID, ANALYZER_PREVIEW_LOADING_TEST_ID } from './te import { getTreeNodes } from '../utils/analyzer_helpers'; import { ANCESTOR_ID, RULE_INDICES } from '../../shared/constants/field_names'; import { useDocumentDetailsContext } from '../../shared/context'; -import { useAlertPrevalenceFromProcessTree } from '../../../../common/containers/alerts/use_alert_prevalence_from_process_tree'; -import type { StatsNode } from '../../../../common/containers/alerts/use_alert_prevalence_from_process_tree'; +import { useAlertPrevalenceFromProcessTree } from '../../shared/hooks/use_alert_prevalence_from_process_tree'; +import type { StatsNode } from '../../shared/hooks/use_alert_prevalence_from_process_tree'; import { isActiveTimeline } from '../../../../helpers'; import { getField } from '../../shared/utils'; import { useTimelineDataFilters } from '../../../../timelines/containers/use_timeline_data_filters'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview_container.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview_container.test.tsx index 5ce6fcebae76b2..7dae9400358c53 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview_container.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/analyzer_preview_container.test.tsx @@ -13,7 +13,7 @@ import { mockContextValue } from '../../shared/mocks/mock_context'; import { AnalyzerPreviewContainer } from './analyzer_preview_container'; import { useIsInvestigateInResolverActionEnabled } from '../../../../detections/components/alerts_table/timeline_actions/investigate_in_resolver'; import { ANALYZER_PREVIEW_TEST_ID } from './test_ids'; -import { useAlertPrevalenceFromProcessTree } from '../../../../common/containers/alerts/use_alert_prevalence_from_process_tree'; +import { useAlertPrevalenceFromProcessTree } from '../../shared/hooks/use_alert_prevalence_from_process_tree'; import * as mock from '../mocks/mock_analyzer_data'; import { EXPANDABLE_PANEL_CONTENT_TEST_ID, @@ -28,7 +28,7 @@ import { useInvestigateInTimeline } from '../../../../detections/components/aler jest.mock( '../../../../detections/components/alerts_table/timeline_actions/investigate_in_resolver' ); -jest.mock('../../../../common/containers/alerts/use_alert_prevalence_from_process_tree'); +jest.mock('../../shared/hooks/use_alert_prevalence_from_process_tree'); jest.mock( '../../../../detections/components/alerts_table/timeline_actions/use_investigate_in_timeline' ); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_section.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_section.test.tsx index eb1af2a74b8df0..96dff8150e6547 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_section.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/insights_section.test.tsx @@ -25,13 +25,13 @@ import { usePrevalence } from '../../shared/hooks/use_prevalence'; import { mockGetFieldsData } from '../../shared/mocks/mock_get_fields_data'; import { mockDataFormattedForFieldBrowser } from '../../shared/mocks/mock_data_formatted_for_field_browser'; import { InsightsSection } from './insights_section'; -import { useAlertPrevalence } from '../../../../common/containers/alerts/use_alert_prevalence'; +import { useAlertPrevalence } from '../../shared/hooks/use_alert_prevalence'; import { useRiskScore } from '../../../../entity_analytics/api/hooks/use_risk_score'; import { useExpandSection } from '../hooks/use_expand_section'; import { useTimelineDataFilters } from '../../../../timelines/containers/use_timeline_data_filters'; import { useTourContext } from '../../../../common/components/guided_onboarding_tour'; -jest.mock('../../../../common/containers/alerts/use_alert_prevalence'); +jest.mock('../../shared/hooks/use_alert_prevalence'); const mockDispatch = jest.fn(); jest.mock('react-redux', () => { diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/visualizations_section.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/visualizations_section.test.tsx index 36fe53aa41deab..f204c18f9036ac 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/visualizations_section.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/visualizations_section.test.tsx @@ -18,7 +18,7 @@ import { VisualizationsSection } from './visualizations_section'; import { mockContextValue } from '../../shared/mocks/mock_context'; import { mockDataFormattedForFieldBrowser } from '../../shared/mocks/mock_data_formatted_for_field_browser'; import { DocumentDetailsContext } from '../../shared/context'; -import { useAlertPrevalenceFromProcessTree } from '../../../../common/containers/alerts/use_alert_prevalence_from_process_tree'; +import { useAlertPrevalenceFromProcessTree } from '../../shared/hooks/use_alert_prevalence_from_process_tree'; import { useTimelineDataFilters } from '../../../../timelines/containers/use_timeline_data_filters'; import { TestProvider } from '@kbn/expandable-flyout/src/test/provider'; import { useExpandSection } from '../hooks/use_expand_section'; @@ -26,7 +26,7 @@ import { useInvestigateInTimeline } from '../../../../detections/components/aler import { useIsInvestigateInResolverActionEnabled } from '../../../../detections/components/alerts_table/timeline_actions/investigate_in_resolver'; jest.mock('../hooks/use_expand_section'); -jest.mock('../../../../common/containers/alerts/use_alert_prevalence_from_process_tree', () => ({ +jest.mock('../../shared/hooks/use_alert_prevalence_from_process_tree', () => ({ useAlertPrevalenceFromProcessTree: jest.fn(), })); const mockUseAlertPrevalenceFromProcessTree = useAlertPrevalenceFromProcessTree as jest.Mock; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/mocks/mock_analyzer_data.ts b/x-pack/plugins/security_solution/public/flyout/document_details/right/mocks/mock_analyzer_data.ts index fbd7dea83f79dd..e0d35b796e76f0 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/mocks/mock_analyzer_data.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/mocks/mock_analyzer_data.ts @@ -7,7 +7,7 @@ import React from 'react'; import { EuiToken } from '@elastic/eui'; import type { Node } from '@elastic/eui/src/components/tree_view/tree_view'; -import type { StatsNode } from '../../../../common/containers/alerts/use_alert_prevalence_from_process_tree'; +import type { StatsNode } from '../../shared/hooks/use_alert_prevalence_from_process_tree'; export const mockStatsNode: StatsNode = { id: '70e19mhyda', diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/utils/analyzer_helpers.ts b/x-pack/plugins/security_solution/public/flyout/document_details/right/utils/analyzer_helpers.ts index 15492f7e41377c..5db8665bc3bbc4 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/utils/analyzer_helpers.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/utils/analyzer_helpers.ts @@ -7,7 +7,7 @@ import React from 'react'; import type { Node } from '@elastic/eui/src/components/tree_view/tree_view'; import { EuiToken } from '@elastic/eui'; -import type { StatsNode } from '../../../../common/containers/alerts/use_alert_prevalence_from_process_tree'; +import type { StatsNode } from '../../shared/hooks/use_alert_prevalence_from_process_tree'; /** * Helper function to recursively create ancestor tree nodes diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_alert_document_analyzer_schema.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_alert_document_analyzer_schema.test.tsx new file mode 100644 index 00000000000000..3c31720b53f99e --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_alert_document_analyzer_schema.test.tsx @@ -0,0 +1,102 @@ +/* + * 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 { RenderHookResult } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-hooks'; +import { useQuery } from '@tanstack/react-query'; +import type { + UseAlertDocumentAnalyzerSchemaParams, + UseAlertDocumentAnalyzerSchemaResult, +} from './use_alert_document_analyzer_schema'; +import { useAlertDocumentAnalyzerSchema } from './use_alert_document_analyzer_schema'; +import { useHttp } from '../../../../common/lib/kibana'; + +jest.mock('../../../../common/lib/kibana'); +jest.mock('@tanstack/react-query'); + +describe('useAlertPrevalenceFromProcessTree', () => { + let hookResult: RenderHookResult< + UseAlertDocumentAnalyzerSchemaParams, + UseAlertDocumentAnalyzerSchemaResult + >; + + beforeEach(() => { + (useHttp as jest.Mock).mockReturnValue({ + get: jest.fn(), + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return all properties when loading', () => { + (useQuery as jest.Mock).mockReturnValue({ + isLoading: true, + data: [], + }); + + hookResult = renderHook(() => + useAlertDocumentAnalyzerSchema({ + documentId: 'documentId', + indices: [], + }) + ); + + expect(hookResult.result.current.loading).toEqual(true); + expect(hookResult.result.current.error).toEqual(false); + expect(hookResult.result.current.id).toEqual(null); + expect(hookResult.result.current.schema).toEqual(null); + expect(hookResult.result.current.agentId).toEqual(null); + }); + + it('should return all properties with data', () => { + (useQuery as jest.Mock).mockReturnValue({ + isLoading: false, + data: [ + { + schema: {}, + id: 'id', + agentId: 'agentId', + }, + ], + }); + + hookResult = renderHook(() => + useAlertDocumentAnalyzerSchema({ + documentId: 'documentId', + indices: [], + }) + ); + + expect(hookResult.result.current.loading).toEqual(false); + expect(hookResult.result.current.error).toEqual(false); + expect(hookResult.result.current.id).toEqual('id'); + expect(hookResult.result.current.schema).toEqual({}); + expect(hookResult.result.current.agentId).toEqual('agentId'); + }); + + it('should return error when no data', () => { + (useQuery as jest.Mock).mockReturnValue({ + isLoading: false, + data: [], + }); + + hookResult = renderHook(() => + useAlertDocumentAnalyzerSchema({ + documentId: 'documentId', + indices: [], + }) + ); + + expect(hookResult.result.current.loading).toEqual(false); + expect(hookResult.result.current.error).toEqual(true); + expect(hookResult.result.current.id).toEqual(null); + expect(hookResult.result.current.schema).toEqual(null); + expect(hookResult.result.current.agentId).toEqual(null); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_alert_document_analyzer_schema.ts b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_alert_document_analyzer_schema.ts new file mode 100644 index 00000000000000..63cf63398bd16f --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_alert_document_analyzer_schema.ts @@ -0,0 +1,95 @@ +/* + * 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 { useQuery } from '@tanstack/react-query'; +import { useHttp } from '../../../../common/lib/kibana'; + +interface EntityResponse { + id: string; + name: string; + schema: object; + agentId: string; +} + +export interface UseAlertDocumentAnalyzerSchemaParams { + /** + * The document ID of the alert to analyze + */ + documentId: string; + /** + * The indices to search for alerts + */ + indices: string[]; +} + +export interface UseAlertDocumentAnalyzerSchemaResult { + /** + * True if the request is still loading + */ + loading: boolean; + /** + * True if there was an error + */ + error: boolean; + /** + * The id returned by the API + */ + id: string | null; + /** + * The schema returned by the API + */ + schema: object | null; + /** + * The agent ID value returned byt the API + */ + agentId: string | null; +} + +export function useAlertDocumentAnalyzerSchema({ + documentId, + indices, +}: UseAlertDocumentAnalyzerSchemaParams): UseAlertDocumentAnalyzerSchemaResult { + const http = useHttp(); + + const query = useQuery(['getAlertPrevalenceSchema', documentId], () => { + return http.get(`/api/endpoint/resolver/entity`, { + query: { + _id: documentId, + indices, + }, + }); + }); + + if (query.isLoading) { + return { + loading: true, + error: false, + id: null, + schema: null, + agentId: null, + }; + } else if (query.data && query.data.length > 0) { + const { + data: [{ schema, id, agentId }], + } = query; + return { + loading: false, + error: false, + id, + schema, + agentId, + }; + } else { + return { + loading: false, + error: true, + id: null, + schema: null, + agentId: null, + }; + } +} diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_alert_prevalence.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_alert_prevalence.test.tsx new file mode 100644 index 00000000000000..231e0e54194414 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_alert_prevalence.test.tsx @@ -0,0 +1,151 @@ +/* + * 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 { RenderHookResult } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-hooks'; +import { ALERT_PREVALENCE_AGG, useAlertPrevalence } from './use_alert_prevalence'; +import type { UseAlertPrevalenceParams, UserAlertPrevalenceResult } from './use_alert_prevalence'; +import { useGlobalTime } from '../../../../common/containers/use_global_time'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import { useQueryAlerts } from '../../../../detections/containers/detection_engine/alerts/use_query'; + +jest.mock('../../../../common/containers/use_global_time'); +jest.mock('../../../../common/hooks/use_selector'); +jest.mock('../../../../detections/containers/detection_engine/alerts/use_query'); + +describe('useAlertPrevalence', () => { + let hookResult: RenderHookResult; + + beforeEach(() => { + (useDeepEqualSelector as jest.Mock).mockReturnValue({ + from: 'from', + to: 'to', + }); + (useGlobalTime as jest.Mock).mockReturnValue({ + from: 'from', + to: 'to', + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return all properties', () => { + (useQueryAlerts as jest.Mock).mockReturnValue({ + loading: true, + data: undefined, + setQuery: jest.fn(), + }); + + hookResult = renderHook(() => + useAlertPrevalence({ + field: 'field', + value: 'value', + indexName: 'index', + isActiveTimelines: true, + includeAlertIds: false, + ignoreTimerange: false, + }) + ); + + expect(hookResult.result.current.loading).toEqual(true); + expect(hookResult.result.current.error).toEqual(false); + expect(hookResult.result.current.alertIds).toEqual(undefined); + expect(hookResult.result.current.count).toEqual(undefined); + }); + + it('should return error true if loading is done and no data', () => { + (useQueryAlerts as jest.Mock).mockReturnValue({ + loading: false, + data: undefined, + setQuery: jest.fn(), + }); + + hookResult = renderHook(() => + useAlertPrevalence({ + field: 'field', + value: 'value', + indexName: 'index', + isActiveTimelines: true, + includeAlertIds: false, + ignoreTimerange: false, + }) + ); + + expect(hookResult.result.current.loading).toEqual(false); + expect(hookResult.result.current.error).toEqual(true); + expect(hookResult.result.current.alertIds).toEqual(undefined); + expect(hookResult.result.current.count).toEqual(undefined); + }); + + it('should return correct count from aggregation', () => { + (useQueryAlerts as jest.Mock).mockReturnValue({ + loading: false, + data: { + aggregations: { + [ALERT_PREVALENCE_AGG]: { + buckets: [{ doc_count: 1 }], + }, + }, + hits: { + hits: [], + }, + }, + setQuery: jest.fn(), + }); + + hookResult = renderHook(() => + useAlertPrevalence({ + field: 'field', + value: 'value', + indexName: 'index', + isActiveTimelines: true, + includeAlertIds: false, + ignoreTimerange: false, + }) + ); + + expect(hookResult.result.current.loading).toEqual(false); + expect(hookResult.result.current.error).toEqual(false); + expect(hookResult.result.current.alertIds).toEqual([]); + expect(hookResult.result.current.count).toEqual(1); + }); + + it('should return alertIds if includeAlertIds is true', () => { + (useQueryAlerts as jest.Mock).mockReturnValue({ + loading: false, + data: { + aggregations: { + [ALERT_PREVALENCE_AGG]: { + buckets: [{ doc_count: 1 }], + }, + }, + hits: { + hits: [{ _id: 'id' }], + }, + }, + setQuery: jest.fn(), + }); + + hookResult = renderHook(() => + useAlertPrevalence({ + field: 'field', + value: 'value', + indexName: 'index', + isActiveTimelines: true, + includeAlertIds: true, + ignoreTimerange: false, + }) + ); + + expect(hookResult.result.current.loading).toEqual(false); + expect(hookResult.result.current.error).toEqual(false); + expect(hookResult.result.current.alertIds).toEqual(['id']); + expect(hookResult.result.current.count).toEqual(1); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/containers/alerts/use_alert_prevalence.ts b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_alert_prevalence.ts similarity index 66% rename from x-pack/plugins/security_solution/public/common/containers/alerts/use_alert_prevalence.ts rename to x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_alert_prevalence.ts index cc3ff5507ec409..a68a462c0ec0af 100644 --- a/x-pack/plugins/security_solution/public/common/containers/alerts/use_alert_prevalence.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_alert_prevalence.ts @@ -7,41 +7,80 @@ import { useEffect, useMemo, useState } from 'react'; -import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../common/constants'; -import { useGlobalTime } from '../use_global_time'; -import type { GenericBuckets } from '../../../../common/search_strategy'; -import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query'; -import { ALERTS_QUERY_NAMES } from '../../../detections/containers/detection_engine/alerts/constants'; -import { useDeepEqualSelector } from '../../hooks/use_selector'; -import { inputsSelectors } from '../../store'; - -const ALERT_PREVALENCE_AGG = 'countOfAlertsWithSameFieldAndValue'; +import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../common/constants'; +import { useGlobalTime } from '../../../../common/containers/use_global_time'; +import type { GenericBuckets } from '../../../../../common/search_strategy'; +import { useQueryAlerts } from '../../../../detections/containers/detection_engine/alerts/use_query'; +import { ALERTS_QUERY_NAMES } from '../../../../detections/containers/detection_engine/alerts/constants'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import { inputsSelectors } from '../../../../common/store'; + +export const ALERT_PREVALENCE_AGG = 'countOfAlertsWithSameFieldAndValue'; +interface AlertPrevalenceAggregation { + [ALERT_PREVALENCE_AGG]: { + buckets: GenericBuckets[]; + }; +} -interface UseAlertPrevalenceOptions { +export interface UseAlertPrevalenceParams { + /** + * The field to search for + */ field: string; + /** + * The value to search for + */ value: string | string[] | undefined | null; + /** + * The index to search in + */ + indexName: string | null; + /** + * Whether to use the timeline time or the global time + */ isActiveTimelines: boolean; - signalIndexName: string | null; + /** + * Whether to include the alert ids in the response + */ includeAlertIds?: boolean; + /** + * Whether to ignore the timeline time and use the global time + */ ignoreTimerange?: boolean; } -interface UserAlertPrevalenceResult { +export interface UserAlertPrevalenceResult { + /** + * Whether the query is loading + */ loading: boolean; + /** + * The count of the prevalence aggregation + */ count: undefined | number; + /** + * Whether there was an error with the query + */ error: boolean; + /** + * The alert ids sorted by timestamp + */ alertIds?: string[]; } -// TODO: MOVE TO FLYOUT FOLDER - https://github.com/elastic/security-team/issues/7462 +/** + * Hook to get the prevalence of alerts with the field and value pair. + * By default, includeAlertIds is false, which means the call only returns the aggregation and not all the alerts themselves. If includeAlertIds is true, it will also return the alertIds sorted by timestamp. + * By default, includeAlertIds is false, which means we're fetching with the global time from and to values. If isActiveTimelines is true, we're getting the timeline time. + */ export const useAlertPrevalence = ({ field, value, + indexName, isActiveTimelines, - signalIndexName, includeAlertIds = false, ignoreTimerange = false, -}: UseAlertPrevalenceOptions): UserAlertPrevalenceResult => { +}: UseAlertPrevalenceParams): UserAlertPrevalenceResult => { const timelineTime = useDeepEqualSelector((state) => inputsSelectors.timelineTimeRangeSelector(state) ); @@ -57,7 +96,7 @@ export const useAlertPrevalence = ({ const { loading, data, setQuery } = useQueryAlerts<{ _id: string }, AlertPrevalenceAggregation>({ query: initialQuery, - indexName: signalIndexName, + indexName, queryName: ALERTS_QUERY_NAMES.PREVALENCE, }); @@ -165,9 +204,3 @@ const generateAlertPrevalenceQuery = ( runtime_mappings: {}, }; }; - -export interface AlertPrevalenceAggregation { - [ALERT_PREVALENCE_AGG]: { - buckets: GenericBuckets[]; - }; -} diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_alert_prevalence_from_process_tree.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_alert_prevalence_from_process_tree.test.tsx new file mode 100644 index 00000000000000..94b7cfa6235077 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_alert_prevalence_from_process_tree.test.tsx @@ -0,0 +1,154 @@ +/* + * 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 { RenderHookResult } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-hooks'; +import type { + UseAlertPrevalenceFromProcessTreeParams, + UserAlertPrevalenceFromProcessTreeResult, +} from './use_alert_prevalence_from_process_tree'; +import { useAlertPrevalenceFromProcessTree } from './use_alert_prevalence_from_process_tree'; +import { useHttp } from '../../../../common/lib/kibana'; +import { useTimelineDataFilters } from '../../../../timelines/containers/use_timeline_data_filters'; +import { useQuery } from '@tanstack/react-query'; +import { useAlertDocumentAnalyzerSchema } from './use_alert_document_analyzer_schema'; +import { mockStatsNode } from '../../right/mocks/mock_analyzer_data'; + +jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../timelines/containers/use_timeline_data_filters'); +jest.mock('./use_alert_document_analyzer_schema'); +jest.mock('@tanstack/react-query'); + +describe('useAlertPrevalenceFromProcessTree', () => { + let hookResult: RenderHookResult< + UseAlertPrevalenceFromProcessTreeParams, + UserAlertPrevalenceFromProcessTreeResult + >; + + beforeEach(() => { + (useHttp as jest.Mock).mockReturnValue({ + post: jest.fn(), + }); + (useTimelineDataFilters as jest.Mock).mockReturnValue({ + selectedPatterns: [], + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return all properties when query is loading', () => { + (useQuery as jest.Mock).mockReturnValue({ + isLoading: true, + data: {}, + }); + (useAlertDocumentAnalyzerSchema as jest.Mock).mockReturnValue({ + loading: false, + error: false, + id: null, + schema: null, + agentId: null, + }); + + hookResult = renderHook(() => + useAlertPrevalenceFromProcessTree({ + documentId: 'documentId', + isActiveTimeline: true, + indices: [], + }) + ); + + expect(hookResult.result.current.loading).toEqual(true); + expect(hookResult.result.current.error).toEqual(false); + expect(hookResult.result.current.alertIds).toEqual(undefined); + expect(hookResult.result.current.statsNodes).toEqual(undefined); + }); + + it('should return all properties when analyzer query is loading', () => { + (useQuery as jest.Mock).mockReturnValue({ + isLoading: false, + data: {}, + }); + (useAlertDocumentAnalyzerSchema as jest.Mock).mockReturnValue({ + loading: true, + error: false, + id: null, + schema: null, + agentId: null, + }); + + hookResult = renderHook(() => + useAlertPrevalenceFromProcessTree({ + documentId: 'documentId', + isActiveTimeline: true, + indices: [], + }) + ); + + expect(hookResult.result.current.loading).toEqual(true); + expect(hookResult.result.current.error).toEqual(false); + expect(hookResult.result.current.alertIds).toEqual(undefined); + expect(hookResult.result.current.statsNodes).toEqual(undefined); + }); + + it('should return all properties data exists', () => { + (useQuery as jest.Mock).mockReturnValue({ + isLoading: false, + data: { + alertIds: ['alertIds'], + statsNodes: [mockStatsNode], + }, + }); + (useAlertDocumentAnalyzerSchema as jest.Mock).mockReturnValue({ + loading: false, + error: false, + id: null, + schema: null, + agentId: null, + }); + + hookResult = renderHook(() => + useAlertPrevalenceFromProcessTree({ + documentId: 'documentId', + isActiveTimeline: true, + indices: [], + }) + ); + + expect(hookResult.result.current.loading).toEqual(false); + expect(hookResult.result.current.error).toEqual(false); + expect(hookResult.result.current.alertIds).toEqual(['alertIds']); + expect(hookResult.result.current.statsNodes).toEqual([mockStatsNode]); + }); + + it('should return all properties data undefined', () => { + (useQuery as jest.Mock).mockReturnValue({ + isLoading: false, + }); + (useAlertDocumentAnalyzerSchema as jest.Mock).mockReturnValue({ + loading: false, + error: false, + id: null, + schema: null, + agentId: null, + }); + + hookResult = renderHook(() => + useAlertPrevalenceFromProcessTree({ + documentId: 'documentId', + isActiveTimeline: true, + indices: [], + }) + ); + + expect(hookResult.result.current.loading).toEqual(false); + expect(hookResult.result.current.error).toEqual(true); + expect(hookResult.result.current.alertIds).toEqual(undefined); + expect(hookResult.result.current.statsNodes).toEqual(undefined); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/containers/alerts/use_alert_prevalence_from_process_tree.ts b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_alert_prevalence_from_process_tree.ts similarity index 57% rename from x-pack/plugins/security_solution/public/common/containers/alerts/use_alert_prevalence_from_process_tree.ts rename to x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_alert_prevalence_from_process_tree.ts index 4e6747384fe34c..f9c27f6e2ccb43 100644 --- a/x-pack/plugins/security_solution/public/common/containers/alerts/use_alert_prevalence_from_process_tree.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_alert_prevalence_from_process_tree.ts @@ -4,114 +4,120 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { useQuery } from '@tanstack/react-query'; -import { useHttp } from '../../lib/kibana'; -import { useTimelineDataFilters } from '../../../timelines/containers/use_timeline_data_filters'; -export const DETECTIONS_ALERTS_COUNT_ID = 'detections-alerts-count'; +import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { useAlertDocumentAnalyzerSchema } from './use_alert_document_analyzer_schema'; +import { useTimelineDataFilters } from '../../../../timelines/containers/use_timeline_data_filters'; +import { useHttp } from '../../../../common/lib/kibana'; export interface StatsNode { + /** + * The data of the node + */ data: object; + /** + * The ID of the node + */ id: string; + /** + * The name of the node + */ name: string; + /** + * The parent ID of the node + */ parent?: string; stats: { + /** + * The total number of alerts + */ total: number; + /** + * The total number of alerts by category + */ byCategory: { alerts?: number; }; }; } -interface UserAlertPrevalenceFromProcessTreeResult { - loading: boolean; - alertIds: undefined | string[]; - statsNodes: undefined | StatsNode[]; - count?: number; - error: boolean; -} - interface ProcessTreeAlertPrevalenceResponse { + /** + * The alert IDs found in the process tree + */ alertIds: string[] | undefined; + /** + * The stats nodes found in the process tree + */ statsNodes: StatsNode[] | undefined; } -interface EntityResponse { - id: string; - name: string; - schema: object; - agentId: string; +interface TreeResponse { + /** + * The alert IDs found in the process tree + */ + alertIds: string[]; + /** + * The stats nodes found in the process tree + */ + statsNodes: StatsNode[]; } -interface UseAlertPrevalenceFromProcessTree { +export interface UseAlertPrevalenceFromProcessTreeParams { + /** + * The document ID of the alert to analyze + */ documentId: string; + /** + * Whether or not the timeline is active + */ isActiveTimeline: boolean; + /** + * The indices to search for alerts + */ indices: string[]; } -interface UseAlertDocumentAnalyzerSchema { - documentId: string; - indices: string[]; -} - -interface TreeResponse { - statsNodes: StatsNode[]; - alertIds: string[]; -} - -function useAlertDocumentAnalyzerSchema({ documentId, indices }: UseAlertDocumentAnalyzerSchema) { - const http = useHttp(); - const query = useQuery(['getAlertPrevalenceSchema', documentId], () => { - return http.get(`/api/endpoint/resolver/entity`, { - query: { - _id: documentId, - indices, - }, - }); - }); - if (query.isLoading) { - return { - loading: true, - error: false, - id: null, - schema: null, - agentId: null, - }; - } else if (query.data && query.data.length > 0) { - const { - data: [{ schema, id, agentId }], - } = query; - return { - loading: false, - error: false, - id, - schema, - agentId, - }; - } else { - return { - loading: false, - error: true, - id: null, - schema: null, - agentId: null, - }; - } +export interface UserAlertPrevalenceFromProcessTreeResult { + /** + * Whether or not the query is loading + */ + loading: boolean; + /** + * The alert IDs found in the process tree + */ + alertIds: undefined | string[]; + /** + * The stats nodes found in the process tree + */ + statsNodes: undefined | StatsNode[]; + /** + * Whether or not the query errored + */ + error: boolean; } +/** + * Fetches the alert prevalence from the process tree + */ export function useAlertPrevalenceFromProcessTree({ documentId, isActiveTimeline, indices, -}: UseAlertPrevalenceFromProcessTree): UserAlertPrevalenceFromProcessTreeResult { +}: UseAlertPrevalenceFromProcessTreeParams): UserAlertPrevalenceFromProcessTreeResult { const http = useHttp(); const { selectedPatterns } = useTimelineDataFilters(isActiveTimeline); - const alertAndOriginalIndices = [...new Set(selectedPatterns.concat(indices))]; + const alertAndOriginalIndices = useMemo( + () => [...new Set(selectedPatterns.concat(indices))], + [indices, selectedPatterns] + ); const { loading, id, schema, agentId } = useAlertDocumentAnalyzerSchema({ documentId, indices: alertAndOriginalIndices, }); + const query = useQuery( ['getAlertPrevalenceFromProcessTree', id], () => { @@ -129,6 +135,7 @@ export function useAlertPrevalenceFromProcessTree({ }, { enabled: schema !== null && id !== null } ); + if (query.isLoading || loading) { return { loading: true, diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_ancestry.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_ancestry.test.tsx index 9291b5e9a0c1a8..4d65339c6b41a8 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_ancestry.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_ancestry.test.tsx @@ -12,9 +12,9 @@ import type { UseFetchRelatedAlertsByAncestryResult, } from './use_fetch_related_alerts_by_ancestry'; import { useFetchRelatedAlertsByAncestry } from './use_fetch_related_alerts_by_ancestry'; -import { useAlertPrevalenceFromProcessTree } from '../../../../common/containers/alerts/use_alert_prevalence_from_process_tree'; +import { useAlertPrevalenceFromProcessTree } from './use_alert_prevalence_from_process_tree'; -jest.mock('../../../../common/containers/alerts/use_alert_prevalence_from_process_tree'); +jest.mock('./use_alert_prevalence_from_process_tree'); const documentId = 'documentId'; const indices = ['index1']; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_ancestry.ts b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_ancestry.ts index b44349a06eec92..826290a3dd3e9e 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_ancestry.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_ancestry.ts @@ -6,7 +6,7 @@ */ import { useMemo } from 'react'; -import { useAlertPrevalenceFromProcessTree } from '../../../../common/containers/alerts/use_alert_prevalence_from_process_tree'; +import { useAlertPrevalenceFromProcessTree } from './use_alert_prevalence_from_process_tree'; import { isActiveTimeline } from '../../../../helpers'; export interface UseFetchRelatedAlertsByAncestryParams { diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_same_source_event.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_same_source_event.test.tsx index 4aaab73af12965..ff74774068adf1 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_same_source_event.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_same_source_event.test.tsx @@ -12,9 +12,9 @@ import type { UseFetchRelatedAlertsBySameSourceEventResult, } from './use_fetch_related_alerts_by_same_source_event'; import { useFetchRelatedAlertsBySameSourceEvent } from './use_fetch_related_alerts_by_same_source_event'; -import { useAlertPrevalence } from '../../../../common/containers/alerts/use_alert_prevalence'; +import { useAlertPrevalence } from './use_alert_prevalence'; -jest.mock('../../../../common/containers/alerts/use_alert_prevalence'); +jest.mock('./use_alert_prevalence'); const originalEventId = 'originalEventId'; const scopeId = 'scopeId'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_same_source_event.ts b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_same_source_event.ts index 1946cef3e7de4d..209bcb0c04051f 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_same_source_event.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_same_source_event.ts @@ -7,7 +7,7 @@ import { useMemo } from 'react'; import { ANCESTOR_ID } from '../constants/field_names'; -import { useAlertPrevalence } from '../../../../common/containers/alerts/use_alert_prevalence'; +import { useAlertPrevalence } from './use_alert_prevalence'; import { isActiveTimeline } from '../../../../helpers'; export interface UseFetchRelatedAlertsBySameSourceEventParams { @@ -50,7 +50,7 @@ export const useFetchRelatedAlertsBySameSourceEvent = ({ field: ANCESTOR_ID, value: originalEventId, isActiveTimelines: isActiveTimeline(scopeId), - signalIndexName: null, + indexName: null, includeAlertIds: true, }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_session.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_session.test.tsx index 6f6f2ea73158f1..b38ef44178f9f6 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_session.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_session.test.tsx @@ -13,9 +13,9 @@ import type { UseFetchRelatedAlertsBySessionResult, } from './use_fetch_related_alerts_by_session'; import { useFetchRelatedAlertsBySession } from './use_fetch_related_alerts_by_session'; -import { useAlertPrevalence } from '../../../../common/containers/alerts/use_alert_prevalence'; +import { useAlertPrevalence } from './use_alert_prevalence'; -jest.mock('../../../../common/containers/alerts/use_alert_prevalence'); +jest.mock('./use_alert_prevalence'); const entityId = 'entityId'; const scopeId = 'scopeId'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_session.ts b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_session.ts index 2c70714d07d5b4..606c3523f60be7 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_session.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_fetch_related_alerts_by_session.ts @@ -6,7 +6,7 @@ */ import { useMemo } from 'react'; -import { useAlertPrevalence } from '../../../../common/containers/alerts/use_alert_prevalence'; +import { useAlertPrevalence } from './use_alert_prevalence'; import { isActiveTimeline } from '../../../../helpers'; import { ENTRY_LEADER_ENTITY_ID } from '../constants/field_names'; @@ -50,7 +50,7 @@ export const useFetchRelatedAlertsBySession = ({ field: ENTRY_LEADER_ENTITY_ID, value: entityId, isActiveTimelines: isActiveTimeline(scopeId), - signalIndexName: null, + indexName: null, includeAlertIds: true, ignoreTimerange: true, }); diff --git a/x-pack/plugins/security_solution/public/management/cypress/fixtures/artifacts_page.ts b/x-pack/plugins/security_solution/public/management/cypress/fixtures/artifacts_page.ts index 47f09f65dd0946..97a6807a59f0af 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/fixtures/artifacts_page.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/fixtures/artifacts_page.ts @@ -188,14 +188,11 @@ export const getArtifactsListTestsData = (): ArtifactsFixtureType[] => [ selector: 'eventFilters-form-description-input', value: 'This is the event filter description', }, - { - type: 'click', - selector: 'fieldAutocompleteComboBox', - }, + { type: 'input', - customSelector: '[data-test-subj="fieldAutocompleteComboBox"] input', - value: '@timestamp{downArrow}{enter}', + selector: 'fieldAutocompleteComboBox', + value: '@timestamp', }, { type: 'click', @@ -239,12 +236,9 @@ export const getArtifactsListTestsData = (): ArtifactsFixtureType[] => [ value: 'This is the event filter description edited', }, { - type: 'click', + type: 'input', selector: 'fieldAutocompleteComboBox', - }, - { - type: 'click', - customSelector: 'button[title="agent.name"]', + value: '{selectAll}agent.name', }, { type: 'input', diff --git a/x-pack/plugins/security_solution/public/sourcerer/components/index.test.tsx b/x-pack/plugins/security_solution/public/sourcerer/components/index.test.tsx index 99209f44cfbeff..1f39bc02c68c46 100644 --- a/x-pack/plugins/security_solution/public/sourcerer/components/index.test.tsx +++ b/x-pack/plugins/security_solution/public/sourcerer/components/index.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import type { ReactWrapper } from 'enzyme'; import { mount } from 'enzyme'; -import { SourcererScopeName } from '../store/model'; +import { type SelectedDataView, SourcererScopeName } from '../store/model'; import { Sourcerer } from '.'; import { sourcererActions, sourcererModel } from '../store'; import { createMockStore, mockGlobalState, TestProviders } from '../../common/mock'; @@ -74,9 +74,13 @@ const { id, patternList, title } = mockGlobalState.sourcerer.defaultDataView; const patternListNoSignals = sortWithExcludesAtEnd( patternList.filter((p) => p !== mockGlobalState.sourcerer.signalIndexName) ); -const sourcererDataView = { +const sourcererDataView: Partial = { indicesExist: true, loading: false, + sourcererDataView: { + title: + 'apm-*-transaction*,auditbeat-*,endgame-*,filebeat-*,logs-*,packetbeat-*,traces-apm*,winlogbeat-*,-*elastic-cloud-logs-*', + }, }; describe('Sourcerer component', () => { diff --git a/x-pack/plugins/security_solution/public/sourcerer/components/index.tsx b/x-pack/plugins/security_solution/public/sourcerer/components/index.tsx index 3dcdf49391d3fe..0f946bd8c247c9 100644 --- a/x-pack/plugins/security_solution/public/sourcerer/components/index.tsx +++ b/x-pack/plugins/security_solution/public/sourcerer/components/index.tsx @@ -149,12 +149,19 @@ export const Sourcerer = React.memo(({ scope: scopeId } } }, [isDetectionsSourcerer, isTimelineSourcerer, pollForSignalIndex]); - const { activePatterns, indicesExist, loading } = useSourcererDataView(scopeId); + const { indicesExist, loading, sourcererDataView } = useSourcererDataView(scopeId); + + const activePatterns = useMemo( + () => (sourcererDataView?.title || '')?.split(',').filter(Boolean) as string[], + [sourcererDataView?.title] + ); + const [missingPatterns, setMissingPatterns] = useState( activePatterns && activePatterns.length > 0 ? sourcererMissingPatterns.filter((p) => activePatterns.includes(p)) : [] ); + useEffect(() => { if (activePatterns && activePatterns.length > 0) { setMissingPatterns(sourcererMissingPatterns.filter((p) => activePatterns.includes(p))); diff --git a/x-pack/plugins/security_solution/public/sourcerer/components/misc.test.tsx b/x-pack/plugins/security_solution/public/sourcerer/components/misc.test.tsx index 8a1d333355e43f..1897acca1c6dd9 100644 --- a/x-pack/plugins/security_solution/public/sourcerer/components/misc.test.tsx +++ b/x-pack/plugins/security_solution/public/sourcerer/components/misc.test.tsx @@ -10,7 +10,7 @@ import type { ReactWrapper } from 'enzyme'; import { mount } from 'enzyme'; import { cloneDeep } from 'lodash'; -import { initialSourcererState, SourcererScopeName } from '../store/model'; +import { initialSourcererState, type SelectedDataView, SourcererScopeName } from '../store/model'; import { Sourcerer } from '.'; import { sourcererActions, sourcererModel } from '../store'; import { createMockStore, mockGlobalState, TestProviders } from '../../common/mock'; @@ -74,9 +74,12 @@ const { id, patternList } = mockGlobalState.sourcerer.defaultDataView; const patternListNoSignals = sortWithExcludesAtEnd( patternList.filter((p) => p !== mockGlobalState.sourcerer.signalIndexName) ); -const sourcererDataView = { +const sourcererDataView: Partial = { indicesExist: true, loading: false, + sourcererDataView: { + title: 'myFakebeat-*', + }, }; describe('No data', () => { diff --git a/x-pack/plugins/security_solution/public/sourcerer/components/sourcerer_integration.test.tsx b/x-pack/plugins/security_solution/public/sourcerer/components/sourcerer_integration.test.tsx index de4bd8b6394793..d43a3a47ed267d 100644 --- a/x-pack/plugins/security_solution/public/sourcerer/components/sourcerer_integration.test.tsx +++ b/x-pack/plugins/security_solution/public/sourcerer/components/sourcerer_integration.test.tsx @@ -109,7 +109,6 @@ describe('Sourcerer integration tests', () => { (useSourcererDataView as jest.Mock).mockReturnValue({ ...sourcererDataView, - activePatterns: ['myFakebeat-*'], }); jest.clearAllMocks(); }); diff --git a/x-pack/plugins/security_solution/public/sourcerer/containers/mocks.ts b/x-pack/plugins/security_solution/public/sourcerer/containers/mocks.ts index e3c30a0eb65214..283f41bc8be674 100644 --- a/x-pack/plugins/security_solution/public/sourcerer/containers/mocks.ts +++ b/x-pack/plugins/security_solution/public/sourcerer/containers/mocks.ts @@ -56,5 +56,4 @@ export const mockSourcererScope: SelectedDataView = { indicesExist: true, loading: false, dataViewId: mockGlobalState.sourcerer.defaultDataView.id, - patternList: mockPatterns, }; diff --git a/x-pack/plugins/security_solution/public/sourcerer/store/model.ts b/x-pack/plugins/security_solution/public/sourcerer/store/model.ts index 6120b3c66cf7e0..3b3f8c56b261c4 100644 --- a/x-pack/plugins/security_solution/public/sourcerer/store/model.ts +++ b/x-pack/plugins/security_solution/public/sourcerer/store/model.ts @@ -97,21 +97,8 @@ export interface SelectedDataView { indicesExist: boolean; /** is an update being made to the data view */ loading: boolean; - /** - * @deprecated use sourcererDataView.title or sourcererDataView.matchedIndices - * all active & inactive patterns from SourcererDataView['title'] - */ - patternList: string[]; - /** - * @deprecated use sourcererDataView.title or sourcererDataView.matchedIndices - * all selected patterns from SourcererScope['selectedPatterns'] */ + /* all selected patterns from SourcererScope['selectedPatterns'] */ selectedPatterns: SourcererScope['selectedPatterns']; - /** - * @deprecated use sourcererDataView.title or sourcererDataView.matchedIndices - * active patterns when dataViewId == null - */ - activePatterns?: string[]; - /** * Easier to add this additional data rather than * try to extend the SelectedDataView type from DataView. diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 421ccac4a3df13..cc0f7067d89e07 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -7929,7 +7929,6 @@ "unifiedDocViewer.json.codeEditorAriaLabel": "Affichage JSON en lecture seule d’un document Elasticsearch", "unifiedDocViewer.json.copyToClipboardLabel": "Copier dans le presse-papiers", "unifiedDocViewer.loadingJSON": "Chargement de JSON", - "unifiedDocViewer.pinnedFieldTooltipContent": "Champ épinglé", "unifiedDocViewer.sourceViewer.errorMessage": "Impossible de récupérer les données pour le moment. Actualisez l'onglet et réessayez.", "unifiedDocViewer.sourceViewer.errorMessageTitle": "Une erreur s'est produite.", "unifiedDocViewer.sourceViewer.refresh": "Actualiser", @@ -19627,10 +19626,8 @@ "xpack.fleet.agentPolicyForm.tamperingSwitchLabel": "Empêcher la falsification des agents", "xpack.fleet.agentPolicyForm.tamperingSwitchLabel.disabledWarning": "L'intégration Elastic Defend est nécessaire pour activer cette fonctionnalité", "xpack.fleet.agentPolicyForm.tamperingUninstallLink": "Obtenir la commande de désinstallation", - "xpack.fleet.agentPolicyForm.unenrollmentTimeoutDeprecatedLabel": "Déclassé", "xpack.fleet.agentPolicyForm.unenrollmentTimeoutDescription": "Délai d'expiration facultatif en secondes. Si une valeur est indiquée et que la version du serveur Fleet est inférieure à 8.7.0, un agent est automatiquement désenregistré après une période d'inactivité équivalente à ce délai.", "xpack.fleet.agentPolicyForm.unenrollmentTimeoutLabel": "Délai d'expiration pour le désenregistrement", - "xpack.fleet.agentPolicyForm.unenrollmentTimeoutTooltip": "Ce paramètre est déclassé et sera retiré dans une prochaine version. Envisagez d'utiliser le délai d'inactivité à la place", "xpack.fleet.agentPolicyForm.unenrollTimeoutMinValueErrorMessage": "Le délai de désenregistrement doit être supérieur à zéro.", "xpack.fleet.agentPolicyList.actionsColumnTitle": "Actions", "xpack.fleet.agentPolicyList.addButton": "Créer une stratégie d'agent", @@ -35562,7 +35559,6 @@ "xpack.securitySolution.alertCountByRuleByStatus.tooltipTitle": "Nom de règle", "xpack.securitySolution.alertDetails.overview.hostRiskDataTitle": "Données de risque de {riskEntity}", "xpack.securitySolution.alertDetails.overview.insights.suppressedAlertsCountTechnicalPreview": "Version d'évaluation technique", - "xpack.securitySolution.alertDetails.overview.investigationGuide": "Guide d'investigation", "xpack.securitySolution.alertDetails.summary.readLess": "Lire moins", "xpack.securitySolution.alertDetails.summary.readMore": "En savoir plus", "xpack.securitySolution.alerts.badge.readOnly.tooltip": "Impossible de mettre à jour les alertes", @@ -38927,14 +38923,10 @@ "xpack.securitySolution.event.summary.threat_indicator.showMatches": "Afficher les {count} alertes de correspondance d'indicateur", "xpack.securitySolution.eventDetails.alertReason": "Raison d'alerte", "xpack.securitySolution.eventDetails.description": "Description", - "xpack.securitySolution.eventDetails.multiFieldBadge": "champ multiple", - "xpack.securitySolution.eventDetails.multiFieldTooltipContent": "Les champs multiples peuvent avoir plusieurs valeurs.", - "xpack.securitySolution.eventDetails.osqueryView": "Résultats Osquery", "xpack.securitySolution.eventDetails.responseActions.endpoint.executed": "a exécuté la commande {command}", "xpack.securitySolution.eventDetails.responseActions.endpoint.failed": "n'a pas pu exécuter la commande {command}", "xpack.securitySolution.eventDetails.responseActions.endpoint.pending": "exécute la commande {command}", "xpack.securitySolution.eventDetails.responseActions.endpoint.tried": "a tenté d'exécuter la commande {command}", - "xpack.securitySolution.eventDetails.responseActionsView": "Résultats de la réponse", "xpack.securitySolution.eventDetails.summaryView": "résumé", "xpack.securitySolution.eventDetails.table": "Tableau", "xpack.securitySolution.eventDetails.table.actions": "Actions", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 3a26962f3fb3f2..519c9f2a428a92 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7923,7 +7923,6 @@ "unifiedDocViewer.json.codeEditorAriaLabel": "Elasticsearch ドキュメントの JSON ビューのみを読み込む", "unifiedDocViewer.json.copyToClipboardLabel": "クリップボードにコピー", "unifiedDocViewer.loadingJSON": "JSONを読み込んでいます", - "unifiedDocViewer.pinnedFieldTooltipContent": "固定されたフィールド", "unifiedDocViewer.sourceViewer.errorMessage": "現在データを取得できませんでした。タブを更新して、再試行してください。", "unifiedDocViewer.sourceViewer.errorMessageTitle": "エラーが発生しました", "unifiedDocViewer.sourceViewer.refresh": "更新", @@ -19615,10 +19614,8 @@ "xpack.fleet.agentPolicyForm.tamperingSwitchLabel": "エージェントの改ざんを防止", "xpack.fleet.agentPolicyForm.tamperingSwitchLabel.disabledWarning": "この機能を有効にするには、Elastic Defend統合が必要です。", "xpack.fleet.agentPolicyForm.tamperingUninstallLink": "アンインストールコマンドを取得", - "xpack.fleet.agentPolicyForm.unenrollmentTimeoutDeprecatedLabel": "非推奨", "xpack.fleet.agentPolicyForm.unenrollmentTimeoutDescription": "任意のタイムアウト(秒)。指定され、Fleetサーバーのバージョンが8.7.0より前の場合、この期間が経過した後、エージェントは自動的に登録解除されます。", "xpack.fleet.agentPolicyForm.unenrollmentTimeoutLabel": "登録解除タイムアウト", - "xpack.fleet.agentPolicyForm.unenrollmentTimeoutTooltip": "この設定はサポートが終了し、今後のリリースでは削除されます。代わりに、非アクティブタイムアウトの使用を検討してください。", "xpack.fleet.agentPolicyForm.unenrollTimeoutMinValueErrorMessage": "登録解除タイムアウトは0よりも大きい値でなければなりません。", "xpack.fleet.agentPolicyList.actionsColumnTitle": "アクション", "xpack.fleet.agentPolicyList.addButton": "エージェントポリシーを作成", @@ -35546,7 +35543,6 @@ "xpack.securitySolution.alertCountByRuleByStatus.tooltipTitle": "ルール名", "xpack.securitySolution.alertDetails.overview.hostRiskDataTitle": "{riskEntity}リスクデータ", "xpack.securitySolution.alertDetails.overview.insights.suppressedAlertsCountTechnicalPreview": "テクニカルプレビュー", - "xpack.securitySolution.alertDetails.overview.investigationGuide": "調査ガイド", "xpack.securitySolution.alertDetails.summary.readLess": "表示を減らす", "xpack.securitySolution.alertDetails.summary.readMore": "続きを読む", "xpack.securitySolution.alerts.badge.readOnly.tooltip": "アラートを更新できません", @@ -38908,14 +38904,10 @@ "xpack.securitySolution.event.summary.threat_indicator.showMatches": "すべての{count}件のインジケーター一致アラートを表示", "xpack.securitySolution.eventDetails.alertReason": "アラートの理由", "xpack.securitySolution.eventDetails.description": "説明", - "xpack.securitySolution.eventDetails.multiFieldBadge": "複数フィールド", - "xpack.securitySolution.eventDetails.multiFieldTooltipContent": "複数フィールドにはフィールドごとに複数の値を入力できます", - "xpack.securitySolution.eventDetails.osqueryView": "Osquery結果", "xpack.securitySolution.eventDetails.responseActions.endpoint.executed": "{command}コマンドを実行しました", "xpack.securitySolution.eventDetails.responseActions.endpoint.failed": "{command}コマンドを実行できませんでした", "xpack.securitySolution.eventDetails.responseActions.endpoint.pending": "{command}コマンドを実行しています", "xpack.securitySolution.eventDetails.responseActions.endpoint.tried": "{command}コマンドを実行しようとしました", - "xpack.securitySolution.eventDetails.responseActionsView": "対応の結果", "xpack.securitySolution.eventDetails.summaryView": "まとめ", "xpack.securitySolution.eventDetails.table": "表", "xpack.securitySolution.eventDetails.table.actions": "アクション", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d601c699a9319a..328008ebf39526 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7936,7 +7936,6 @@ "unifiedDocViewer.json.codeEditorAriaLabel": "Elasticsearch 文档的只读 JSON 视图", "unifiedDocViewer.json.copyToClipboardLabel": "复制到剪贴板", "unifiedDocViewer.loadingJSON": "正在加载 JSON", - "unifiedDocViewer.pinnedFieldTooltipContent": "已固定字段", "unifiedDocViewer.sourceViewer.errorMessage": "当前无法获取数据。请刷新选项卡以重试。", "unifiedDocViewer.sourceViewer.errorMessageTitle": "发生错误", "unifiedDocViewer.sourceViewer.refresh": "刷新", @@ -19641,10 +19640,8 @@ "xpack.fleet.agentPolicyForm.tamperingSwitchLabel": "防止篡改代理", "xpack.fleet.agentPolicyForm.tamperingSwitchLabel.disabledWarning": "需要 Elastic Defend 集成才能启用此功能", "xpack.fleet.agentPolicyForm.tamperingUninstallLink": "获取卸载命令", - "xpack.fleet.agentPolicyForm.unenrollmentTimeoutDeprecatedLabel": "(已过时)", "xpack.fleet.agentPolicyForm.unenrollmentTimeoutDescription": "可选超时(秒)。若提供,且 Fleet 服务器的版本低于 8.7.0,代理断开连接此段时间后,将自动注销。", "xpack.fleet.agentPolicyForm.unenrollmentTimeoutLabel": "注销超时", - "xpack.fleet.agentPolicyForm.unenrollmentTimeoutTooltip": "此设置已过时,将在未来版本中移除。考虑改用非活动超时", "xpack.fleet.agentPolicyForm.unenrollTimeoutMinValueErrorMessage": "取消注册超时必须大于零。", "xpack.fleet.agentPolicyList.actionsColumnTitle": "操作", "xpack.fleet.agentPolicyList.addButton": "创建代理策略", @@ -35587,7 +35584,6 @@ "xpack.securitySolution.alertCountByRuleByStatus.tooltipTitle": "规则名称", "xpack.securitySolution.alertDetails.overview.hostRiskDataTitle": "{riskEntity}风险数据", "xpack.securitySolution.alertDetails.overview.insights.suppressedAlertsCountTechnicalPreview": "技术预览", - "xpack.securitySolution.alertDetails.overview.investigationGuide": "调查指南", "xpack.securitySolution.alertDetails.summary.readLess": "阅读更少内容", "xpack.securitySolution.alertDetails.summary.readMore": "阅读更多内容", "xpack.securitySolution.alerts.badge.readOnly.tooltip": "无法更新告警", @@ -38952,14 +38948,10 @@ "xpack.securitySolution.event.summary.threat_indicator.showMatches": "显示所有 {count} 个指标匹配告警", "xpack.securitySolution.eventDetails.alertReason": "告警原因", "xpack.securitySolution.eventDetails.description": "描述", - "xpack.securitySolution.eventDetails.multiFieldBadge": "多字段", - "xpack.securitySolution.eventDetails.multiFieldTooltipContent": "多字段的每个字段可以有多个值", - "xpack.securitySolution.eventDetails.osqueryView": "Osquery 结果", "xpack.securitySolution.eventDetails.responseActions.endpoint.executed": "已执行 {command} 命令", "xpack.securitySolution.eventDetails.responseActions.endpoint.failed": "无法执行 {command} 命令", "xpack.securitySolution.eventDetails.responseActions.endpoint.pending": "正在执行 {command} 命令", "xpack.securitySolution.eventDetails.responseActions.endpoint.tried": "已尝试执行 {command} 命令", - "xpack.securitySolution.eventDetails.responseActionsView": "响应结果", "xpack.securitySolution.eventDetails.summaryView": "摘要", "xpack.securitySolution.eventDetails.table": "表", "xpack.securitySolution.eventDetails.table.actions": "操作", diff --git a/x-pack/test/fleet_api_integration/apis/space_awareness/actions.ts b/x-pack/test/fleet_api_integration/apis/space_awareness/actions.ts index 4f458cd7190cc8..14c3dff3389559 100644 --- a/x-pack/test/fleet_api_integration/apis/space_awareness/actions.ts +++ b/x-pack/test/fleet_api_integration/apis/space_awareness/actions.ts @@ -19,8 +19,7 @@ export default function (providerContext: FtrProviderContext) { const esClient = getService('es'); const kibanaServer = getService('kibanaServer'); - // Failing: See https://github.com/elastic/kibana/issues/189805 - describe.skip('actions', async function () { + describe('actions', async function () { skipIfNoDockerRegistry(providerContext); const apiClient = new SpaceTestApiClient(supertest); @@ -221,16 +220,14 @@ export default function (providerContext: FtrProviderContext) { .set('kbn-xsrf', 'xxxx') .send({ action: { type: 'UNENROLL' } }) .expect(404); - expect(resInDefaultSpace.body.message).to.eql(`${testSpaceAgent1} not found in namespace`); + expect(resInDefaultSpace.body.message).to.eql(`Agent ${testSpaceAgent1} not found`); const resInCustomSpace = await supertest .post(`/s/${TEST_SPACE_1}/api/fleet/agents/${defaultSpaceAgent1}/actions`) .set('kbn-xsrf', 'xxxx') .send({ action: { type: 'UNENROLL' } }) .expect(404); - expect(resInCustomSpace.body.message).to.eql( - `${defaultSpaceAgent1} not found in namespace` - ); + expect(resInCustomSpace.body.message).to.eql(`Agent ${defaultSpaceAgent1} not found`); }); it('should create an action with set namespace in the default space', async () => { @@ -253,5 +250,51 @@ export default function (providerContext: FtrProviderContext) { expect(actionStatusInCustomSpace.items.length).to.eql(1); }); }); + + describe('post /agents/actions/{actionId}/cancel', () => { + it('should return 200 and a CANCEL action if the action is in the same space', async () => { + // Create UPDATE_TAGS action for agents in custom space + await apiClient.bulkUpdateAgentTags( + { + agents: [testSpaceAgent1, testSpaceAgent2], + tagsToAdd: ['tag1'], + }, + TEST_SPACE_1 + ); + + const actionStatusInCustomSpace = await apiClient.getActionStatus(TEST_SPACE_1); + expect(actionStatusInCustomSpace.items.length).to.eql(1); + + const res = await apiClient.cancelAction( + actionStatusInCustomSpace.items[0].actionId, + TEST_SPACE_1 + ); + expect(res.item.type).to.eql('CANCEL'); + }); + + it('should return 404 if the action is in a different space', async () => { + // Create UPDATE_TAGS action for agents in custom space + await apiClient.bulkUpdateAgentTags( + { + agents: [testSpaceAgent1, testSpaceAgent2], + tagsToAdd: ['tag1'], + }, + TEST_SPACE_1 + ); + + const actionStatusInCustomSpace = await apiClient.getActionStatus(TEST_SPACE_1); + expect(actionStatusInCustomSpace.items.length).to.eql(1); + + let err: Error | undefined; + try { + await apiClient.cancelAction(actionStatusInCustomSpace.items[0].actionId); + } catch (_err) { + err = _err; + } + + expect(err).to.be.an(Error); + expect(err?.message).to.match(/404 "Not Found"/); + }); + }); }); } diff --git a/x-pack/test/fleet_api_integration/apis/space_awareness/agents.ts b/x-pack/test/fleet_api_integration/apis/space_awareness/agents.ts index b4f7241dec0fb6..f41f83f71ccb73 100644 --- a/x-pack/test/fleet_api_integration/apis/space_awareness/agents.ts +++ b/x-pack/test/fleet_api_integration/apis/space_awareness/agents.ts @@ -10,7 +10,12 @@ import { CreateAgentPolicyResponse, GetAgentsResponse } from '@kbn/fleet-plugin/ import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; import { skipIfNoDockerRegistry } from '../../helpers'; import { SpaceTestApiClient } from './api_helper'; -import { cleanFleetIndices, createFleetAgent } from './helpers'; +import { + cleanFleetAgents, + cleanFleetIndices, + createFleetAgent, + makeAgentsUpgradeable, +} from './helpers'; import { setupTestSpaces, TEST_SPACE_1 } from './space_helpers'; export default function (providerContext: FtrProviderContext) { @@ -41,6 +46,7 @@ export default function (providerContext: FtrProviderContext) { setupTestSpaces(providerContext); let defaultSpacePolicy1: CreateAgentPolicyResponse; + let defaultSpacePolicy2: CreateAgentPolicyResponse; let spaceTest1Policy1: CreateAgentPolicyResponse; let spaceTest1Policy2: CreateAgentPolicyResponse; @@ -48,36 +54,50 @@ export default function (providerContext: FtrProviderContext) { let defaultSpaceAgent2: string; let testSpaceAgent1: string; let testSpaceAgent2: string; + let testSpaceAgent3: string; + + async function createAgents() { + const [ + _defaultSpaceAgent1, + _defaultSpaceAgent2, + _testSpaceAgent1, + _testSpaceAgent2, + _testSpaceAgent3, + ] = await Promise.all([ + createFleetAgent(esClient, defaultSpacePolicy1.item.id, 'default'), + createFleetAgent(esClient, defaultSpacePolicy2.item.id), + createFleetAgent(esClient, spaceTest1Policy1.item.id, TEST_SPACE_1), + createFleetAgent(esClient, spaceTest1Policy2.item.id, TEST_SPACE_1), + createFleetAgent(esClient, spaceTest1Policy1.item.id, TEST_SPACE_1), + ]); + defaultSpaceAgent1 = _defaultSpaceAgent1; + defaultSpaceAgent2 = _defaultSpaceAgent2; + testSpaceAgent1 = _testSpaceAgent1; + testSpaceAgent2 = _testSpaceAgent2; + testSpaceAgent3 = _testSpaceAgent3; + } before(async () => { await apiClient.postEnableSpaceAwareness(); - - const [_defaultSpacePolicy1, _spaceTest1Policy1, _spaceTest1Policy2] = await Promise.all([ - apiClient.createAgentPolicy(), - apiClient.createAgentPolicy(TEST_SPACE_1), - apiClient.createAgentPolicy(TEST_SPACE_1), - ]); + const [_defaultSpacePolicy1, _defaultSpacePolicy2, _spaceTest1Policy1, _spaceTest1Policy2] = + await Promise.all([ + apiClient.createAgentPolicy(), + apiClient.createAgentPolicy(), + apiClient.createAgentPolicy(TEST_SPACE_1), + apiClient.createAgentPolicy(TEST_SPACE_1), + ]); defaultSpacePolicy1 = _defaultSpacePolicy1; + defaultSpacePolicy2 = _defaultSpacePolicy2; spaceTest1Policy1 = _spaceTest1Policy1; spaceTest1Policy2 = _spaceTest1Policy2; - const [_defaultSpaceAgent1, _defaultSpaceAgent2, _testSpaceAgent1, _testSpaceAgent2] = - await Promise.all([ - createFleetAgent(esClient, defaultSpacePolicy1.item.id, 'default'), - createFleetAgent(esClient, defaultSpacePolicy1.item.id), - createFleetAgent(esClient, spaceTest1Policy1.item.id, TEST_SPACE_1), - createFleetAgent(esClient, spaceTest1Policy2.item.id, TEST_SPACE_1), - ]); - defaultSpaceAgent1 = _defaultSpaceAgent1; - defaultSpaceAgent2 = _defaultSpaceAgent2; - testSpaceAgent1 = _testSpaceAgent1; - testSpaceAgent2 = _testSpaceAgent2; + await createAgents(); }); - describe('GET /agents', () => { + describe('GET /agent', () => { it('should return agents in a specific space', async () => { const agents = await apiClient.getAgents(TEST_SPACE_1); - expect(agents.total).to.eql(2); + expect(agents.total).to.eql(3); const agentIds = agents.items?.map((item) => item.id); expect(agentIds).to.contain(testSpaceAgent1); expect(agentIds).to.contain(testSpaceAgent2); @@ -92,7 +112,7 @@ export default function (providerContext: FtrProviderContext) { }); }); - describe('GET /agents/{id}', () => { + describe('GET /agents/{agentId}', () => { it('should allow to retrieve agent in the same space', async () => { await apiClient.getAgent(testSpaceAgent1, TEST_SPACE_1); }); @@ -110,13 +130,13 @@ export default function (providerContext: FtrProviderContext) { }); }); - describe('PUT /agents/{id}', () => { - it('should allow to update an agent in the same space', async () => { + describe('PUT /agents/{agentId}', () => { + it('should allow updating an agent in the same space', async () => { await apiClient.updateAgent(testSpaceAgent1, { tags: ['foo'] }, TEST_SPACE_1); await apiClient.updateAgent(testSpaceAgent1, { tags: ['tag1'] }, TEST_SPACE_1); }); - it('should not allow to update an agent from a different space from the default space', async () => { + it('should not allow updating an agent from a different space', async () => { let err: Error | undefined; try { await apiClient.updateAgent(testSpaceAgent1, { tags: ['foo'] }); @@ -131,15 +151,15 @@ export default function (providerContext: FtrProviderContext) { describe('DELETE /agents/{id}', () => { it('should allow to delete an agent in the same space', async () => { - const testSpaceAgent3 = await createFleetAgent( + const testSpaceDeleteAgent = await createFleetAgent( esClient, spaceTest1Policy2.item.id, TEST_SPACE_1 ); - await apiClient.deleteAgent(testSpaceAgent3, TEST_SPACE_1); + await apiClient.deleteAgent(testSpaceDeleteAgent, TEST_SPACE_1); }); - it('should not allow to delete an agent from a different space from the default space', async () => { + it('should not allow deleting an agent from a different space', async () => { let err: Error | undefined; try { await apiClient.deleteAgent(testSpaceAgent1); @@ -229,5 +249,333 @@ export default function (providerContext: FtrProviderContext) { expect(agentInTestSpaceTags[testSpaceAgent2]).to.eql(['tag1']); }); }); + + describe('POST /agents/{agentId}/upgrade', () => { + beforeEach(async () => { + await cleanFleetAgents(esClient); + await createAgents(); + }); + + it('should allow upgrading an agent in the same space', async () => { + await makeAgentsUpgradeable(esClient, [testSpaceAgent1], '8.14.0'); + await apiClient.upgradeAgent(testSpaceAgent1, { version: '8.15.0' }, TEST_SPACE_1); + }); + + it('should forbid upgrading an agent from a different space', async () => { + await makeAgentsUpgradeable(esClient, [testSpaceAgent1], '8.14.0'); + const res = await supertest + .post(`/api/fleet/agents/${testSpaceAgent1}/upgrade`) + .set('kbn-xsrf', 'xxxx') + .send({ version: '8.15.0' }) + .expect(404); + expect(res.body.message).to.eql(`Agent ${testSpaceAgent1} not found`); + }); + }); + + describe('POST /agents/bulk_upgrade', () => { + beforeEach(async () => { + await cleanFleetAgents(esClient); + await createAgents(); + }); + + function getAgentStatus(agents: GetAgentsResponse) { + return agents.items?.reduce((acc, item) => { + acc[item.id] = item.status; + return acc; + }, {} as any); + } + + it('should only upgrade agents in the same space when passing a list of agent ids', async () => { + await makeAgentsUpgradeable( + esClient, + [defaultSpaceAgent1, defaultSpaceAgent2, testSpaceAgent1, testSpaceAgent2], + '8.14.0' + ); + + let agents = await apiClient.getAgents(); + let agentStatus = getAgentStatus(agents); + expect(agentStatus).to.eql({ + [defaultSpaceAgent1]: 'online', + [defaultSpaceAgent2]: 'online', + }); + + agents = await apiClient.getAgents(TEST_SPACE_1); + agentStatus = getAgentStatus(agents); + expect(agentStatus).to.eql({ + [testSpaceAgent1]: 'online', + [testSpaceAgent2]: 'online', + [testSpaceAgent3]: 'online', + }); + + await apiClient.bulkUpgradeAgents( + { + agents: [defaultSpaceAgent1, testSpaceAgent1], + version: '8.15.0', + skipRateLimitCheck: true, + }, + TEST_SPACE_1 + ); + + agents = await apiClient.getAgents(); + agentStatus = getAgentStatus(agents); + expect(agentStatus).to.eql({ + [defaultSpaceAgent1]: 'online', + [defaultSpaceAgent2]: 'online', + }); + + agents = await apiClient.getAgents(TEST_SPACE_1); + agentStatus = getAgentStatus(agents); + expect(agentStatus).to.eql({ + [testSpaceAgent1]: 'updating', + [testSpaceAgent2]: 'online', + [testSpaceAgent3]: 'online', + }); + }); + + it('should only upgrade agents in the same space when passing a kuery', async () => { + await makeAgentsUpgradeable( + esClient, + [defaultSpaceAgent1, defaultSpaceAgent2, testSpaceAgent1, testSpaceAgent2], + '8.14.0' + ); + + let agents = await apiClient.getAgents(); + let agentStatus = getAgentStatus(agents); + expect(agentStatus).to.eql({ + [defaultSpaceAgent1]: 'online', + [defaultSpaceAgent2]: 'online', + }); + + agents = await apiClient.getAgents(TEST_SPACE_1); + agentStatus = getAgentStatus(agents); + expect(agentStatus).to.eql({ + [testSpaceAgent1]: 'online', + [testSpaceAgent2]: 'online', + [testSpaceAgent3]: 'online', + }); + + await apiClient.bulkUpgradeAgents( + { + agents: 'status:online', + version: '8.15.0', + skipRateLimitCheck: true, + }, + TEST_SPACE_1 + ); + + agents = await apiClient.getAgents(); + agentStatus = getAgentStatus(agents); + expect(agentStatus).to.eql({ + [defaultSpaceAgent1]: 'online', + [defaultSpaceAgent2]: 'online', + }); + + agents = await apiClient.getAgents(TEST_SPACE_1); + agentStatus = getAgentStatus(agents); + expect(agentStatus).to.eql({ + [testSpaceAgent1]: 'updating', + [testSpaceAgent2]: 'updating', + [testSpaceAgent3]: 'updating', + }); + }); + }); + + describe('POST /agents/{agentId}/reassign', () => { + beforeEach(async () => { + await cleanFleetAgents(esClient); + await createAgents(); + }); + it('should allow reassigning an agent in the current space to a policy in the current space', async () => { + let agent = await apiClient.getAgent(defaultSpaceAgent1); + expect(agent.item.policy_id).to.eql(defaultSpacePolicy1.item.id); + await apiClient.reassignAgent(defaultSpaceAgent1, defaultSpacePolicy2.item.id); + agent = await apiClient.getAgent(defaultSpaceAgent1); + expect(agent.item.policy_id).to.eql(defaultSpacePolicy2.item.id); + + agent = await apiClient.getAgent(testSpaceAgent1, TEST_SPACE_1); + expect(agent.item.policy_id).to.eql(spaceTest1Policy1.item.id); + await apiClient.reassignAgent(testSpaceAgent1, spaceTest1Policy2.item.id, TEST_SPACE_1); + agent = await apiClient.getAgent(testSpaceAgent1, TEST_SPACE_1); + expect(agent.item.policy_id).to.eql(spaceTest1Policy2.item.id); + + await apiClient.reassignAgent(defaultSpaceAgent1, defaultSpacePolicy1.item.id); + await apiClient.reassignAgent(testSpaceAgent1, spaceTest1Policy1.item.id, TEST_SPACE_1); + }); + + it('should not allow reassigning an agent in a different space', async () => { + let err: Error | undefined; + try { + await apiClient.reassignAgent(testSpaceAgent1, defaultSpacePolicy2.item.id); + } catch (_err) { + err = _err; + } + + expect(err).to.be.an(Error); + expect(err?.message).to.match(/404 "Not Found"/); + }); + + it('should not allow reassigning an agent in the current space to a policy in a different space', async () => { + let err: Error | undefined; + try { + await apiClient.reassignAgent(defaultSpaceAgent1, spaceTest1Policy2.item.id); + } catch (_err) { + err = _err; + } + + expect(err).to.be.an(Error); + expect(err?.message).to.match(/404 "Not Found"/); + }); + }); + + describe('POST /agents/bulk_reassign', () => { + beforeEach(async () => { + await cleanFleetAgents(esClient); + await createAgents(); + }); + function getAgentPolicyIds(agents: GetAgentsResponse) { + return agents.items?.reduce((acc, item) => { + acc[item.id] = item.policy_id; + return acc; + }, {} as any); + } + + it('should return 404 if the policy is in another space', async () => { + let err: Error | undefined; + try { + await apiClient.bulkReassignAgents({ + agents: [defaultSpaceAgent1, testSpaceAgent1], + policy_id: spaceTest1Policy2.item.id, + }); + } catch (_err) { + err = _err; + } + + expect(err).to.be.an(Error); + expect(err?.message).to.match(/404 "Not Found"/); + }); + + it('should only reassign agents in the same space when passing a list of agent ids', async () => { + let agent = await apiClient.getAgent(defaultSpaceAgent1); + expect(agent.item.policy_id).to.eql(defaultSpacePolicy1.item.id); + agent = await apiClient.getAgent(testSpaceAgent1, TEST_SPACE_1); + expect(agent.item.policy_id).to.eql(spaceTest1Policy1.item.id); + + await apiClient.bulkReassignAgents( + { + agents: [defaultSpaceAgent1, testSpaceAgent1], + policy_id: spaceTest1Policy2.item.id, + }, + TEST_SPACE_1 + ); + + agent = await apiClient.getAgent(defaultSpaceAgent1); + expect(agent.item.policy_id).to.eql(defaultSpacePolicy1.item.id); + agent = await apiClient.getAgent(testSpaceAgent1, TEST_SPACE_1); + expect(agent.item.policy_id).to.eql(spaceTest1Policy2.item.id); + + await apiClient.reassignAgent(testSpaceAgent1, spaceTest1Policy1.item.id, TEST_SPACE_1); + }); + + it('should only reassign agents in the same space when passing a kuery', async () => { + let agents = await apiClient.getAgents(); + let agentPolicyIds = getAgentPolicyIds(agents); + expect(agentPolicyIds).to.eql({ + [defaultSpaceAgent1]: defaultSpacePolicy1.item.id, + [defaultSpaceAgent2]: defaultSpacePolicy2.item.id, + }); + agents = await apiClient.getAgents(TEST_SPACE_1); + agentPolicyIds = getAgentPolicyIds(agents); + expect(agentPolicyIds).to.eql({ + [testSpaceAgent1]: spaceTest1Policy1.item.id, + [testSpaceAgent2]: spaceTest1Policy2.item.id, + [testSpaceAgent3]: spaceTest1Policy1.item.id, + }); + + await apiClient.bulkReassignAgents( + { + agents: '*', + policy_id: spaceTest1Policy2.item.id, + }, + TEST_SPACE_1 + ); + + agents = await apiClient.getAgents(); + agentPolicyIds = getAgentPolicyIds(agents); + expect(agentPolicyIds).to.eql({ + [defaultSpaceAgent1]: defaultSpacePolicy1.item.id, + [defaultSpaceAgent2]: defaultSpacePolicy2.item.id, + }); + agents = await apiClient.getAgents(TEST_SPACE_1); + agentPolicyIds = getAgentPolicyIds(agents); + expect(agentPolicyIds).to.eql({ + [testSpaceAgent1]: spaceTest1Policy2.item.id, + [testSpaceAgent2]: spaceTest1Policy2.item.id, + [testSpaceAgent3]: spaceTest1Policy2.item.id, + }); + + await apiClient.reassignAgent(testSpaceAgent1, spaceTest1Policy1.item.id, TEST_SPACE_1); + await apiClient.reassignAgent(testSpaceAgent2, spaceTest1Policy1.item.id, TEST_SPACE_1); + }); + + it('should reassign agents in the same space by kuery in batches', async () => { + let agents = await apiClient.getAgents(); + let agentPolicyIds = getAgentPolicyIds(agents); + expect(agentPolicyIds).to.eql({ + [defaultSpaceAgent1]: defaultSpacePolicy1.item.id, + [defaultSpaceAgent2]: defaultSpacePolicy2.item.id, + }); + agents = await apiClient.getAgents(TEST_SPACE_1); + agentPolicyIds = getAgentPolicyIds(agents); + expect(agentPolicyIds).to.eql({ + [testSpaceAgent1]: spaceTest1Policy1.item.id, + [testSpaceAgent2]: spaceTest1Policy2.item.id, + [testSpaceAgent3]: spaceTest1Policy1.item.id, + }); + + const res = await apiClient.bulkReassignAgents( + { + agents: `not fleet-agents.policy_id:"${spaceTest1Policy2.item.id}"`, + policy_id: spaceTest1Policy2.item.id, + batchSize: 1, + }, + TEST_SPACE_1 + ); + + const verifyActionResult = async () => { + const { body: result } = await supertest + .get(`/s/${TEST_SPACE_1}/api/fleet/agents`) + .set('kbn-xsrf', 'xxx'); + expect(result.total).to.eql(3); + result.items.forEach((agent: any) => { + expect(agent.policy_id).to.eql(spaceTest1Policy2.item.id); + }); + }; + + await new Promise((resolve, reject) => { + let attempts = 0; + const intervalId = setInterval(async () => { + if (attempts > 20) { + clearInterval(intervalId); + reject(new Error('action timed out')); + } + ++attempts; + const { + body: { items: actionStatuses }, + } = await supertest + .get(`/s/${TEST_SPACE_1}/api/fleet/agents/action_status`) + .set('kbn-xsrf', 'xxx'); + + const action = actionStatuses.find((a: any) => a.actionId === res.actionId); + if (action && action.nbAgentsActioned === action.nbAgentsActionCreated) { + clearInterval(intervalId); + await verifyActionResult(); + resolve({}); + } + }, 1000); + }).catch((e) => { + throw e; + }); + }); + }); }); } diff --git a/x-pack/test/fleet_api_integration/apis/space_awareness/api_helper.ts b/x-pack/test/fleet_api_integration/apis/space_awareness/api_helper.ts index 58f372ac0d7ee7..0695dd8868d4a4 100644 --- a/x-pack/test/fleet_api_integration/apis/space_awareness/api_helper.ts +++ b/x-pack/test/fleet_api_integration/apis/space_awareness/api_helper.ts @@ -260,6 +260,38 @@ export class SpaceTestApiClient { return res; } + async reassignAgent(agentId: string, policyId: string, spaceId?: string) { + await this.supertest + .post(`${this.getBaseUrl(spaceId)}/api/fleet/agents/${agentId}/reassign`) + .set('kbn-xsrf', 'xxx') + .send({ + policy_id: policyId, + }) + .expect(200); + } + async bulkReassignAgents(data: any, spaceId?: string) { + const { body: res } = await this.supertest + .post(`${this.getBaseUrl(spaceId)}/api/fleet/agents/bulk_reassign`) + .set('kbn-xsrf', 'xxxx') + .send(data) + .expect(200); + + return res; + } + async upgradeAgent(agentId: string, data: any, spaceId?: string) { + await this.supertest + .post(`${this.getBaseUrl(spaceId)}/api/fleet/agents/${agentId}/upgrade`) + .set('kbn-xsrf', 'xxxx') + .send(data) + .expect(200); + } + async bulkUpgradeAgents(data: any, spaceId?: string) { + await this.supertest + .post(`${this.getBaseUrl(spaceId)}/api/fleet/agents/bulk_upgrade`) + .set('kbn-xsrf', 'xxxx') + .send(data) + .expect(200); + } async bulkUpdateAgentTags(data: any, spaceId?: string) { const { body: res } = await this.supertest .post(`${this.getBaseUrl(spaceId)}/api/fleet/agents/bulk_update_agent_tags`) @@ -366,7 +398,6 @@ export class SpaceTestApiClient { return res; } - async postNewAgentAction(agentId: string, spaceId?: string): Promise { const { body: res } = await this.supertest .post(`${this.getBaseUrl(spaceId)}/api/fleet/agents/${agentId}/actions`) @@ -376,6 +407,14 @@ export class SpaceTestApiClient { return res; } + + async cancelAction(actionId: string, spaceId?: string): Promise { + const { body: res } = await this.supertest + .post(`${this.getBaseUrl(spaceId)}/api/fleet/agents/actions/${actionId}/cancel`) + .set('kbn-xsrf', 'xxxx') + .expect(200); + return res; + } // Enable space awareness async postEnableSpaceAwareness(spaceId?: string): Promise { const { body: res } = await this.supertest diff --git a/x-pack/test/fleet_api_integration/apis/space_awareness/helpers.ts b/x-pack/test/fleet_api_integration/apis/space_awareness/helpers.ts index c54291dc588a32..a82bf55c352a02 100644 --- a/x-pack/test/fleet_api_integration/apis/space_awareness/helpers.ts +++ b/x-pack/test/fleet_api_integration/apis/space_awareness/helpers.ts @@ -15,6 +15,7 @@ import { AGENT_POLICY_INDEX, } from '@kbn/fleet-plugin/common'; import { ENROLLMENT_API_KEYS_INDEX } from '@kbn/fleet-plugin/common/constants'; +import { asyncForEach } from '@kbn/std'; const ES_INDEX_OPTIONS = { headers: { 'X-elastic-product-origin': 'fleet' } }; @@ -50,6 +51,15 @@ export async function cleanFleetIndices(esClient: Client) { ]); } +export async function cleanFleetAgents(esClient: Client) { + await esClient.deleteByQuery({ + index: AGENTS_INDEX, + q: '*', + ignore_unavailable: true, + refresh: true, + }); +} + export async function cleanFleetActionIndices(esClient: Client) { try { await Promise.all([ @@ -78,11 +88,7 @@ export async function cleanFleetActionIndices(esClient: Client) { } } -export const createFleetAgent = async ( - esClient: Client, - agentPolicyId: string, - spaceId?: string -) => { +export async function createFleetAgent(esClient: Client, agentPolicyId: string, spaceId?: string) { const agentResponse = await esClient.index({ index: '.fleet-agents', refresh: true, @@ -106,4 +112,19 @@ export const createFleetAgent = async ( }); return agentResponse._id; -}; +} + +export async function makeAgentsUpgradeable(esClient: Client, agentIds: string[], version: string) { + await asyncForEach(agentIds, async (agentId) => { + await esClient.update({ + id: agentId, + refresh: 'wait_for', + index: AGENTS_INDEX, + body: { + doc: { + local_metadata: { elastic: { agent: { upgradeable: true, version } } }, + }, + }, + }); + }); +} diff --git a/x-pack/test/functional/apps/ml/anomaly_detection_integrations/lens_to_ml_with_wizard.ts b/x-pack/test/functional/apps/ml/anomaly_detection_integrations/lens_to_ml_with_wizard.ts index 87f4bfd36335a8..b17f2657d1eaea 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection_integrations/lens_to_ml_with_wizard.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection_integrations/lens_to_ml_with_wizard.ts @@ -136,7 +136,7 @@ export default function ({ getService, getPageObject, getPageObjects }: FtrProvi await ml.lensVisualizations.assertNumberOfIncompatibleLensLayers(numberOfIncompatibleLayers); - ml.lensVisualizations.clickCreateJobFromLayerWithWizard(0); + await ml.lensVisualizations.clickCreateJobFromLayerWithWizard(0); await retrySwitchTab(1, 10); tabsCount++; @@ -161,7 +161,7 @@ export default function ({ getService, getPageObject, getPageObjects }: FtrProvi await ml.lensVisualizations.assertNumberOfIncompatibleLensLayers(numberOfIncompatibleLayers); - ml.lensVisualizations.clickCreateJobFromLayerWithWizard(0); + await ml.lensVisualizations.clickCreateJobFromLayerWithWizard(0); await retrySwitchTab(1, 10); tabsCount++; @@ -186,7 +186,7 @@ export default function ({ getService, getPageObject, getPageObjects }: FtrProvi await ml.lensVisualizations.assertNumberOfIncompatibleLensLayers(numberOfIncompatibleLayers); - ml.lensVisualizations.clickCreateJobFromLayerWithWizard(1); + await ml.lensVisualizations.clickCreateJobFromLayerWithWizard(1); await retrySwitchTab(1, 10); tabsCount++; @@ -215,7 +215,7 @@ export default function ({ getService, getPageObject, getPageObjects }: FtrProvi await dashboardPreparation(selectedPanelTitle); - ml.lensVisualizations.assertMLJobMenuActionDoesNotExist(selectedPanelTitle); + await ml.lensVisualizations.assertMLJobMenuActionDoesNotExist(selectedPanelTitle); }); }); } diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts index 576f08d890e5b5..810db7295a79fb 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts @@ -140,6 +140,7 @@ export default function ({ getService }: FtrProviderContext) { 'fleet:check-deleted-files-task', 'fleet:reassign_action:retry', 'fleet:request_diagnostics:retry', + 'fleet:unenroll-inactive-agents-task', 'fleet:unenroll_action:retry', 'fleet:update_agent_tags:retry', 'fleet:upgrade_action:retry', diff --git a/x-pack/test_serverless/functional/test_suites/security/config.cloud_security_posture.agentless_api.ts b/x-pack/test_serverless/functional/test_suites/security/config.cloud_security_posture.agentless_api.ts new file mode 100644 index 00000000000000..0f37f224197ef5 --- /dev/null +++ b/x-pack/test_serverless/functional/test_suites/security/config.cloud_security_posture.agentless_api.ts @@ -0,0 +1,34 @@ +/* + * 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 { CLOUD_CREDENTIALS_PACKAGE_VERSION } from '@kbn/cloud-security-posture-plugin/common/constants'; +import { CA_CERT_PATH, KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils'; +import { createTestConfig } from '../../config.base'; + +export default createTestConfig({ + serverlessProject: 'security', + junit: { + reportName: 'Serverless Security Cloud Security Agentless API Onboarding Functional Tests', + }, + kbnServerArgs: [ + `--xpack.fleet.packages.0.name=cloud_security_posture`, + `--xpack.fleet.packages.0.version=${CLOUD_CREDENTIALS_PACKAGE_VERSION}`, + + `--xpack.fleet.agents.fleet_server.hosts=["https://ftr.kibana:8220"]`, + `--xpack.fleet.internal.fleetServerStandalone=true`, + + // Agentless Configuration based on Serverless Security Dev Yaml - config/serverless.security.dev.yml + `--xpack.fleet.agentless.enabled=true`, + `--xpack.fleet.agentless.api.url=http://localhost:8089`, + `--xpack.fleet.agentless.api.tls.certificate=${KBN_CERT_PATH}`, + `--xpack.fleet.agentless.api.tls.key=${KBN_KEY_PATH}`, + `--xpack.fleet.agentless.api.tls.ca=${CA_CERT_PATH}`, + `--xpack.cloud.serverless.project_id=some_fake_project_id`, + ], + // load tests in the index file + testFiles: [require.resolve('./ftr/cloud_security_posture/agentless_api')], +}); diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cloud_security_posture/agentless_api/create_agent.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/cloud_security_posture/agentless_api/create_agent.ts new file mode 100644 index 00000000000000..d2d797a5c107d7 --- /dev/null +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/cloud_security_posture/agentless_api/create_agent.ts @@ -0,0 +1,97 @@ +/* + * 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 { CLOUD_CREDENTIALS_PACKAGE_VERSION } from '@kbn/cloud-security-posture-plugin/common/constants'; +import * as http from 'http'; +import expect from '@kbn/expect'; +import { setupMockServer } from './mock_agentless_api'; +import type { FtrProviderContext } from '../../../../../ftr_provider_context'; +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const mockAgentlessApiService = setupMockServer(); + const pageObjects = getPageObjects([ + 'svlCommonPage', + 'cspSecurity', + 'security', + 'header', + 'cisAddIntegration', + ]); + + const CIS_AWS_OPTION_TEST_ID = 'cisAwsTestId'; + + const AWS_SINGLE_ACCOUNT_TEST_ID = 'awsSingleTestId'; + + describe('Agentless API Serverless', function () { + let mockApiServer: http.Server; + let cisIntegration: typeof pageObjects.cisAddIntegration; + + before(async () => { + mockApiServer = mockAgentlessApiService.listen(8089); // Start the usage api mock server on port 8089 + await pageObjects.svlCommonPage.loginAsAdmin(); + cisIntegration = pageObjects.cisAddIntegration; + }); + + after(async () => { + mockApiServer.close(); + }); + + it(`should create agentless-agent`, async () => { + const integrationPolicyName = `cloud_security_posture-${new Date().toISOString()}`; + await cisIntegration.navigateToAddIntegrationCspmWithVersionPage( + CLOUD_CREDENTIALS_PACKAGE_VERSION + ); + + await cisIntegration.clickOptionButton(CIS_AWS_OPTION_TEST_ID); + await cisIntegration.clickOptionButton(AWS_SINGLE_ACCOUNT_TEST_ID); + + await cisIntegration.inputIntegrationName(integrationPolicyName); + + await cisIntegration.selectSetupTechnology('agentless'); + await cisIntegration.selectAwsCredentials('direct'); + + await pageObjects.header.waitUntilLoadingHasFinished(); + + await cisIntegration.clickSaveButton(); + await pageObjects.header.waitUntilLoadingHasFinished(); + + await cisIntegration.navigateToIntegrationCspList(); + await pageObjects.header.waitUntilLoadingHasFinished(); + + expect(await cisIntegration.getFirstCspmIntegrationPageIntegration()).to.be( + integrationPolicyName + ); + expect(await cisIntegration.getFirstCspmIntegrationPageAgent()).to.be( + `Agentless policy for ${integrationPolicyName}` + ); + }); + + it(`should create default agent-based agent`, async () => { + const integrationPolicyName = `cloud_security_posture-${new Date().toISOString()}`; + + await cisIntegration.navigateToAddIntegrationCspmWithVersionPage( + CLOUD_CREDENTIALS_PACKAGE_VERSION + ); + + await cisIntegration.clickOptionButton(CIS_AWS_OPTION_TEST_ID); + await cisIntegration.clickOptionButton(AWS_SINGLE_ACCOUNT_TEST_ID); + + await cisIntegration.inputIntegrationName(integrationPolicyName); + + await cisIntegration.clickSaveButton(); + await pageObjects.header.waitUntilLoadingHasFinished(); + + const agentPolicyName = await cisIntegration.getAgentBasedPolicyValue(); + + await cisIntegration.navigateToIntegrationCspList(); + await pageObjects.header.waitUntilLoadingHasFinished(); + + expect(await cisIntegration.getFirstCspmIntegrationPageIntegration()).to.be( + integrationPolicyName + ); + expect(await cisIntegration.getFirstCspmIntegrationPageAgent()).to.be(agentPolicyName); + }); + }); +} diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cloud_security_posture/agentless_api/index.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/cloud_security_posture/agentless_api/index.ts new file mode 100644 index 00000000000000..44aea818827d7b --- /dev/null +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/cloud_security_posture/agentless_api/index.ts @@ -0,0 +1,15 @@ +/* + * 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 { FtrProviderContext } from '../../../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('cloud_security_posture', function () { + this.tags(['cloud_security_posture_agentless']); + loadTestFile(require.resolve('./create_agent')); + }); +} diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cloud_security_posture/agentless_api/mock_agentless_api.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/cloud_security_posture/agentless_api/mock_agentless_api.ts new file mode 100644 index 00000000000000..8688db0fc018f0 --- /dev/null +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/cloud_security_posture/agentless_api/mock_agentless_api.ts @@ -0,0 +1,28 @@ +/* + * 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 { createServer } from '@mswjs/http-middleware'; + +import { http, HttpResponse, StrictResponse } from 'msw'; + +export const setupMockServer = () => { + const server = createServer(deploymentHandler); + return server; +}; + +interface AgentlessApiResponse { + status: number; +} + +const deploymentHandler = http.post( + 'api/v1/serverless/deployments', + async ({ request }): Promise> => { + return HttpResponse.json({ + status: 200, + }); + } +);