diff --git a/oas_docs/output/kibana.serverless.staging.yaml b/oas_docs/output/kibana.serverless.staging.yaml index 46b4191056f35..58abfc3e1f7f9 100644 --- a/oas_docs/output/kibana.serverless.staging.yaml +++ b/oas_docs/output/kibana.serverless.staging.yaml @@ -15392,6 +15392,39 @@ paths: tags: - Security Timeline API - access:securitySolution + /api/risk_score/engine/dangerously_delete_data: + delete: + description: >- + Cleaning up the the Risk Engine by removing the indices, mapping and + transforms + operationId: CleanUpRiskEngine + responses: + '200': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + type: object + properties: + cleanup_successful: + type: boolean + description: Successful response + '400': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: >- + #/components/schemas/Security_Entity_Analytics_API_TaskManagerUnavailableResponse + description: Task manager is unavailable + default: + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: >- + #/components/schemas/Security_Entity_Analytics_API_CleanUpRiskEngineErrorResponse + description: Unexpected error + summary: Cleanup the Risk Engine + tags: + - Security Entity Analytics API /api/risk_score/engine/schedule_now: post: description: >- @@ -29750,6 +29783,27 @@ components: required: - id_value - id_field + Security_Entity_Analytics_API_CleanUpRiskEngineErrorResponse: + type: object + properties: + cleanup_successful: + example: false + type: boolean + errors: + items: + type: object + properties: + error: + type: string + seq: + type: integer + required: + - seq + - error + type: array + required: + - cleanup_successful + - errors Security_Entity_Analytics_API_CreateAssetCriticalityRecord: allOf: - $ref: >- diff --git a/oas_docs/output/kibana.staging.yaml b/oas_docs/output/kibana.staging.yaml index ea8c34440c3b2..59a22166f3498 100644 --- a/oas_docs/output/kibana.staging.yaml +++ b/oas_docs/output/kibana.staging.yaml @@ -18822,6 +18822,39 @@ paths: tags: - Security Timeline API - access:securitySolution + /api/risk_score/engine/dangerously_delete_data: + delete: + description: >- + Cleaning up the the Risk Engine by removing the indices, mapping and + transforms + operationId: CleanUpRiskEngine + responses: + '200': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + type: object + properties: + cleanup_successful: + type: boolean + description: Successful response + '400': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: >- + #/components/schemas/Security_Entity_Analytics_API_TaskManagerUnavailableResponse + description: Task manager is unavailable + default: + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: >- + #/components/schemas/Security_Entity_Analytics_API_CleanUpRiskEngineErrorResponse + description: Unexpected error + summary: Cleanup the Risk Engine + tags: + - Security Entity Analytics API /api/risk_score/engine/schedule_now: post: description: >- @@ -37759,6 +37792,27 @@ components: required: - id_value - id_field + Security_Entity_Analytics_API_CleanUpRiskEngineErrorResponse: + type: object + properties: + cleanup_successful: + example: false + type: boolean + errors: + items: + type: object + properties: + error: + type: string + seq: + type: integer + required: + - seq + - error + type: array + required: + - cleanup_successful + - errors Security_Entity_Analytics_API_CreateAssetCriticalityRecord: allOf: - $ref: >- diff --git a/packages/kbn-esql-utils/src/utils/append_to_query.test.ts b/packages/kbn-esql-utils/src/utils/append_to_query.test.ts index c9db52076222f..9dc15454cbbdf 100644 --- a/packages/kbn-esql-utils/src/utils/append_to_query.test.ts +++ b/packages/kbn-esql-utils/src/utils/append_to_query.test.ts @@ -168,5 +168,11 @@ AND \`dest\`=="Crete"` and \`ip\`::string!="127.0.0.2/32"` ); }); + + it('returns undefined for multivalue fields', () => { + expect( + appendWhereClauseToESQLQuery('from logstash-*', 'dest', ['meow'], '+', 'string') + ).toBeUndefined(); + }); }); }); diff --git a/packages/kbn-esql-utils/src/utils/append_to_query.ts b/packages/kbn-esql-utils/src/utils/append_to_query.ts index f4161be073a8d..2820881810387 100644 --- a/packages/kbn-esql-utils/src/utils/append_to_query.ts +++ b/packages/kbn-esql-utils/src/utils/append_to_query.ts @@ -21,7 +21,11 @@ export function appendWhereClauseToESQLQuery( value: unknown, operation: '+' | '-' | 'is_not_null' | 'is_null', fieldType?: string -): string { +): string | undefined { + // multivalues filtering is not supported yet + if (Array.isArray(value)) { + return undefined; + } let operator; switch (operation) { case 'is_not_null': diff --git a/packages/kbn-management/settings/application/hooks/use_fields.ts b/packages/kbn-management/settings/application/hooks/use_fields.ts index 829e368812512..f0dc538f54189 100644 --- a/packages/kbn-management/settings/application/hooks/use_fields.ts +++ b/packages/kbn-management/settings/application/hooks/use_fields.ts @@ -7,11 +7,13 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { Query } from '@elastic/eui'; +import { Ast, Query } from '@elastic/eui'; import { getFieldDefinitions } from '@kbn/management-settings-field-definition'; import { FieldDefinition } from '@kbn/management-settings-types'; import { UiSettingsScope } from '@kbn/core-ui-settings-common'; +import { Clause } from '@elastic/eui/src/components/search_bar/query/ast'; import { useServices } from '../services'; +import { CATEGORY_FIELD } from '../query_input'; import { useSettings } from './use_settings'; /** @@ -29,7 +31,19 @@ export const useFields = (scope: UiSettingsScope, query?: Query): FieldDefinitio isOverridden: (key) => isOverriddenSetting(key, scope), }); if (query) { - return Query.execute(query, fields); + const clauses: Clause[] = query.ast.clauses.map((clause) => + // If the clause value contains `:` and is not a category filter, add it as a term clause + // This allows searching for settings that include `:` in their names + clause.type === 'field' && clause.field !== CATEGORY_FIELD + ? { + type: 'term', + match: 'must', + value: `${clause.field}:${clause.value}`, + } + : clause + ); + + return Query.execute(new Query(Ast.create(clauses), undefined, query.text), fields); } return fields; }; diff --git a/packages/kbn-unified-data-table/__mocks__/table_context.ts b/packages/kbn-unified-data-table/__mocks__/table_context.ts index a253b02ab5838..739e04a954e07 100644 --- a/packages/kbn-unified-data-table/__mocks__/table_context.ts +++ b/packages/kbn-unified-data-table/__mocks__/table_context.ts @@ -69,6 +69,7 @@ export function buildSelectedDocsState(selectedDocIds: string[]): UseSelectedDoc selectedDocsCount: selectedDocsSet.size, docIdsInSelectionOrder: selectedDocIds, toggleDocSelection: jest.fn(), + toggleMultipleDocsSelection: jest.fn(), selectAllDocs: jest.fn(), selectMoreDocs: jest.fn(), deselectSomeDocs: jest.fn(), diff --git a/packages/kbn-unified-data-table/src/components/data_table.tsx b/packages/kbn-unified-data-table/src/components/data_table.tsx index 7eeab83fdecb7..662c8526dd567 100644 --- a/packages/kbn-unified-data-table/src/components/data_table.tsx +++ b/packages/kbn-unified-data-table/src/components/data_table.tsx @@ -497,8 +497,14 @@ export const UnifiedDataTable = ({ const [isCompareActive, setIsCompareActive] = useState(false); const displayedColumns = getDisplayedColumns(columns, dataView); const defaultColumns = displayedColumns.includes('_source'); - const docMap = useMemo(() => new Map(rows?.map((row) => [row.id, row]) ?? []), [rows]); - const getDocById = useCallback((id: string) => docMap.get(id), [docMap]); + const docMap = useMemo( + () => + new Map( + rows?.map((row, docIndex) => [row.id, { doc: row, docIndex }]) ?? [] + ), + [rows] + ); + const getDocById = useCallback((id: string) => docMap.get(id)?.doc, [docMap]); const selectedDocsState = useSelectedDocs(docMap); const { isDocSelected, diff --git a/packages/kbn-unified-data-table/src/components/data_table_columns.tsx b/packages/kbn-unified-data-table/src/components/data_table_columns.tsx index c5bba429ed934..876db4b0e7149 100644 --- a/packages/kbn-unified-data-table/src/components/data_table_columns.tsx +++ b/packages/kbn-unified-data-table/src/components/data_table_columns.tsx @@ -189,7 +189,13 @@ function buildEuiGridColumn({ cellActions = columnCellActions; } else { cellActions = dataViewField - ? buildCellActions(dataViewField, toastNotifications, valueToStringConverter, onFilter) + ? buildCellActions( + dataViewField, + isPlainRecord, + toastNotifications, + valueToStringConverter, + onFilter + ) : []; if (columnCellActions?.length && cellActionsHandling === 'append') { diff --git a/packages/kbn-unified-data-table/src/components/data_table_document_selection.tsx b/packages/kbn-unified-data-table/src/components/data_table_document_selection.tsx index b6b8ab20cc577..c101d8c20f751 100644 --- a/packages/kbn-unified-data-table/src/components/data_table_document_selection.tsx +++ b/packages/kbn-unified-data-table/src/components/data_table_document_selection.tsx @@ -38,7 +38,7 @@ export const SelectButton = (props: EuiDataGridCellValueElementProps) => { const { record, rowIndex } = useControlColumn(props); const { euiTheme } = useEuiTheme(); const { selectedDocsState } = useContext(UnifiedDataTableContext); - const { isDocSelected, toggleDocSelection } = selectedDocsState; + const { isDocSelected, toggleDocSelection, toggleMultipleDocsSelection } = selectedDocsState; const toggleDocumentSelectionLabel = i18n.translate('unifiedDataTable.grid.selectDoc', { defaultMessage: `Select document ''{rowNumber}''`, @@ -66,8 +66,12 @@ export const SelectButton = (props: EuiDataGridCellValueElementProps) => { aria-label={toggleDocumentSelectionLabel} checked={isDocSelected(record.id)} data-test-subj={`dscGridSelectDoc-${record.id}`} - onChange={() => { - toggleDocSelection(record.id); + onChange={(event) => { + if ((event.nativeEvent as MouseEvent)?.shiftKey) { + toggleMultipleDocsSelection(record.id); + } else { + toggleDocSelection(record.id); + } }} /> diff --git a/packages/kbn-unified-data-table/src/components/default_cell_actions.test.tsx b/packages/kbn-unified-data-table/src/components/default_cell_actions.test.tsx index 628d3b5a29697..d097a2becd035 100644 --- a/packages/kbn-unified-data-table/src/components/default_cell_actions.test.tsx +++ b/packages/kbn-unified-data-table/src/components/default_cell_actions.test.tsx @@ -45,6 +45,7 @@ describe('Default cell actions ', function () { it('should not show cell actions for unfilterable fields', async () => { const cellActions = buildCellActions( { name: 'foo', filterable: false } as DataViewField, + false, servicesMock.toastNotifications, dataTableContextMock.valueToStringConverter ); @@ -61,6 +62,7 @@ describe('Default cell actions ', function () { it('should show filter actions for filterable fields', async () => { const cellActions = buildCellActions( { name: 'foo', filterable: true } as DataViewField, + false, servicesMock.toastNotifications, dataTableContextMock.valueToStringConverter, jest.fn() @@ -71,6 +73,7 @@ describe('Default cell actions ', function () { it('should show Copy action for _source field', async () => { const cellActions = buildCellActions( { name: '_source', type: '_source', filterable: false } as DataViewField, + false, servicesMock.toastNotifications, dataTableContextMock.valueToStringConverter ); @@ -87,65 +90,97 @@ describe('Default cell actions ', function () { const component = mountWithIntl( } - rowIndex={1} - colIndex={1} - columnId="extension" - isExpanded={false} + cellActionProps={{ + Component: (props: any) => , + rowIndex: 1, + colIndex: 1, + columnId: 'extension', + isExpanded: false, + }} + field={{ name: 'extension', filterable: true } as DataViewField} + isPlainRecord={false} /> ); const button = findTestSubject(component, 'filterForButton'); await button.simulate('click'); - expect(dataTableContextMock.onFilter).toHaveBeenCalledWith({}, 'jpg', '+'); + expect(dataTableContextMock.onFilter).toHaveBeenCalledWith( + { name: 'extension', filterable: true }, + 'jpg', + '+' + ); }); it('triggers filter function when FilterInBtn is clicked for a non-provided value', async () => { const component = mountWithIntl( } - rowIndex={0} - colIndex={1} - columnId="extension" - isExpanded={false} + cellActionProps={{ + Component: (props: any) => , + rowIndex: 0, + colIndex: 1, + columnId: 'extension', + isExpanded: false, + }} + field={{ name: 'extension', filterable: true } as DataViewField} + isPlainRecord={false} /> ); const button = findTestSubject(component, 'filterForButton'); await button.simulate('click'); - expect(dataTableContextMock.onFilter).toHaveBeenCalledWith({}, undefined, '+'); + expect(dataTableContextMock.onFilter).toHaveBeenCalledWith( + { name: 'extension', filterable: true }, + undefined, + '+' + ); }); it('triggers filter function when FilterInBtn is clicked for an empty string value', async () => { const component = mountWithIntl( } - rowIndex={4} - colIndex={1} - columnId="message" - isExpanded={false} + cellActionProps={{ + Component: (props: any) => , + rowIndex: 4, + colIndex: 1, + columnId: 'message', + isExpanded: false, + }} + field={{ name: 'message', filterable: true } as DataViewField} + isPlainRecord={false} /> ); const button = findTestSubject(component, 'filterForButton'); await button.simulate('click'); - expect(dataTableContextMock.onFilter).toHaveBeenCalledWith({}, '', '+'); + expect(dataTableContextMock.onFilter).toHaveBeenCalledWith( + { name: 'message', filterable: true }, + '', + '+' + ); }); it('triggers filter function when FilterOutBtn is clicked', async () => { const component = mountWithIntl( } - rowIndex={1} - colIndex={1} - columnId="extension" - isExpanded={false} + cellActionProps={{ + Component: (props: any) => , + rowIndex: 1, + colIndex: 1, + columnId: 'extension', + isExpanded: false, + }} + field={{ name: 'extension', filterable: true } as DataViewField} + isPlainRecord={false} /> ); const button = findTestSubject(component, 'filterOutButton'); await button.simulate('click'); - expect(dataTableContextMock.onFilter).toHaveBeenCalledWith({}, 'jpg', '-'); + expect(dataTableContextMock.onFilter).toHaveBeenCalledWith( + { name: 'extension', filterable: true }, + 'jpg', + '-' + ); }); it('triggers clipboard copy when CopyBtn is clicked', async () => { const component = mountWithIntl( diff --git a/packages/kbn-unified-data-table/src/components/default_cell_actions.tsx b/packages/kbn-unified-data-table/src/components/default_cell_actions.tsx index 0be9803633825..f2260a7d910de 100644 --- a/packages/kbn-unified-data-table/src/components/default_cell_actions.tsx +++ b/packages/kbn-unified-data-table/src/components/default_cell_actions.tsx @@ -32,11 +32,25 @@ function onFilterCell( } } -export const FilterInBtn = ( - { Component, rowIndex, columnId }: EuiDataGridColumnCellActionProps, - field: DataViewField -) => { +const esqlMultivalueFilteringDisabled = i18n.translate( + 'unifiedDataTable.grid.esqlMultivalueFilteringDisabled', + { + defaultMessage: 'Multivalue filtering is not supported in ES|QL', + } +); + +export const FilterInBtn = ({ + cellActionProps: { Component, rowIndex, columnId }, + field, + isPlainRecord, +}: { + cellActionProps: EuiDataGridColumnCellActionProps; + field: DataViewField; + isPlainRecord: boolean | undefined; +}) => { const context = useContext(UnifiedDataTableContext); + const filteringDisabled = + isPlainRecord && Array.isArray(context.getRowByIndex(rowIndex)?.flattened[columnId]); const buttonTitle = i18n.translate('unifiedDataTable.grid.filterForAria', { defaultMessage: 'Filter for this {value}', values: { value: columnId }, @@ -49,7 +63,8 @@ export const FilterInBtn = ( }} iconType="plusInCircle" aria-label={buttonTitle} - title={buttonTitle} + title={filteringDisabled ? esqlMultivalueFilteringDisabled : buttonTitle} + disabled={filteringDisabled} data-test-subj="filterForButton" > {i18n.translate('unifiedDataTable.grid.filterFor', { @@ -59,11 +74,18 @@ export const FilterInBtn = ( ); }; -export const FilterOutBtn = ( - { Component, rowIndex, columnId }: EuiDataGridColumnCellActionProps, - field: DataViewField -) => { +export const FilterOutBtn = ({ + cellActionProps: { Component, rowIndex, columnId }, + field, + isPlainRecord, +}: { + cellActionProps: EuiDataGridColumnCellActionProps; + field: DataViewField; + isPlainRecord: boolean | undefined; +}) => { const context = useContext(UnifiedDataTableContext); + const filteringDisabled = + isPlainRecord && Array.isArray(context.getRowByIndex(rowIndex)?.flattened[columnId]); const buttonTitle = i18n.translate('unifiedDataTable.grid.filterOutAria', { defaultMessage: 'Filter out this {value}', values: { value: columnId }, @@ -76,7 +98,8 @@ export const FilterOutBtn = ( }} iconType="minusInCircle" aria-label={buttonTitle} - title={buttonTitle} + title={filteringDisabled ? esqlMultivalueFilteringDisabled : buttonTitle} + disabled={filteringDisabled} data-test-subj="filterOutButton" > {i18n.translate('unifiedDataTable.grid.filterOut', { @@ -120,6 +143,7 @@ export function buildCopyValueButton( export function buildCellActions( field: DataViewField, + isPlainRecord: boolean | undefined, toastNotifications: ToastsStart, valueToStringConverter: ValueToStringConverter, onFilter?: DocViewFilterFn @@ -127,16 +151,18 @@ export function buildCellActions( return [ ...(onFilter && field.filterable ? [ - ({ Component, rowIndex, columnId }: EuiDataGridColumnCellActionProps) => - FilterInBtn( - { Component, rowIndex, columnId } as EuiDataGridColumnCellActionProps, - field - ), - ({ Component, rowIndex, columnId }: EuiDataGridColumnCellActionProps) => - FilterOutBtn( - { Component, rowIndex, columnId } as EuiDataGridColumnCellActionProps, - field - ), + (cellActionProps: EuiDataGridColumnCellActionProps) => + FilterInBtn({ + cellActionProps, + field, + isPlainRecord, + }), + (cellActionProps: EuiDataGridColumnCellActionProps) => + FilterOutBtn({ + cellActionProps, + field, + isPlainRecord, + }), ] : []), ({ Component, rowIndex, columnId }: EuiDataGridColumnCellActionProps) => diff --git a/packages/kbn-unified-data-table/src/hooks/use_selected_docs.test.ts b/packages/kbn-unified-data-table/src/hooks/use_selected_docs.test.ts index 743a3006d25d9..3865e412bdcd2 100644 --- a/packages/kbn-unified-data-table/src/hooks/use_selected_docs.test.ts +++ b/packages/kbn-unified-data-table/src/hooks/use_selected_docs.test.ts @@ -17,7 +17,7 @@ describe('useSelectedDocs', () => { const docs = generateEsHits(dataViewWithTimefieldMock, 5).map((hit) => buildDataTableRecord(hit, dataViewWithTimefieldMock) ); - const docsMap = new Map(docs.map((doc) => [doc.id, doc])); + const docsMap = new Map(docs.map((doc, docIndex) => [doc.id, { doc, docIndex }])); test('should have a correct default state', () => { const { result } = renderHook(() => useSelectedDocs(docsMap)); @@ -223,4 +223,30 @@ describe('useSelectedDocs', () => { expect(result.current.getCountOfFilteredSelectedDocs([docs[0].id])).toBe(0); expect(result.current.getCountOfFilteredSelectedDocs([docs[2].id, docs[3].id])).toBe(0); }); + + test('should toggleMultipleDocsSelection correctly', () => { + const { result } = renderHook(() => useSelectedDocs(docsMap)); + const docIds = docs.map((doc) => doc.id); + + // select `0` + act(() => { + result.current.toggleDocSelection(docs[0].id); + }); + + expect(result.current.getCountOfFilteredSelectedDocs(docIds)).toBe(1); + + // select from `0` to `4` + act(() => { + result.current.toggleMultipleDocsSelection(docs[4].id); + }); + + expect(result.current.getCountOfFilteredSelectedDocs(docIds)).toBe(5); + + // deselect from `2` to `4` + act(() => { + result.current.toggleMultipleDocsSelection(docs[2].id); + }); + + expect(result.current.getCountOfFilteredSelectedDocs(docIds)).toBe(2); + }); }); diff --git a/packages/kbn-unified-data-table/src/hooks/use_selected_docs.ts b/packages/kbn-unified-data-table/src/hooks/use_selected_docs.ts index fd839c88c6363..f6538f185b32f 100644 --- a/packages/kbn-unified-data-table/src/hooks/use_selected_docs.ts +++ b/packages/kbn-unified-data-table/src/hooks/use_selected_docs.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import type { DataTableRecord } from '@kbn/discover-utils'; export interface UseSelectedDocsState { @@ -17,6 +17,7 @@ export interface UseSelectedDocsState { selectedDocsCount: number; docIdsInSelectionOrder: string[]; toggleDocSelection: (docId: string) => void; + toggleMultipleDocsSelection: (toDocId: string) => void; selectAllDocs: () => void; selectMoreDocs: (docIds: string[]) => void; deselectSomeDocs: (docIds: string[]) => void; @@ -25,8 +26,11 @@ export interface UseSelectedDocsState { getSelectedDocsOrderedByRows: (rows: DataTableRecord[]) => DataTableRecord[]; } -export const useSelectedDocs = (docMap: Map): UseSelectedDocsState => { +export const useSelectedDocs = ( + docMap: Map +): UseSelectedDocsState => { const [selectedDocsSet, setSelectedDocsSet] = useState>(new Set()); + const lastCheckboxToggledDocId = useRef(); const toggleDocSelection = useCallback((docId: string) => { setSelectedDocsSet((prevSelectedRowsSet) => { @@ -38,6 +42,7 @@ export const useSelectedDocs = (docMap: Map): UseSelect } return newSelectedRowsSet; }); + lastCheckboxToggledDocId.current = docId; }, []); const replaceSelectedDocs = useCallback((docIds: string[]) => { @@ -73,6 +78,42 @@ export const useSelectedDocs = (docMap: Map): UseSelect [selectedDocsSet, docMap] ); + const toggleMultipleDocsSelection = useCallback( + (toDocId: string) => { + const shouldSelect = !isDocSelected(toDocId); + + const lastToggledDocIdIndex = docMap.get( + lastCheckboxToggledDocId.current ?? toDocId + )?.docIndex; + const currentToggledDocIdIndex = docMap.get(toDocId)?.docIndex; + const docIds: string[] = []; + + if ( + typeof lastToggledDocIdIndex === 'number' && + typeof currentToggledDocIdIndex === 'number' && + lastToggledDocIdIndex !== currentToggledDocIdIndex + ) { + const startIndex = Math.min(lastToggledDocIdIndex, currentToggledDocIdIndex); + const endIndex = Math.max(lastToggledDocIdIndex, currentToggledDocIdIndex); + + docMap.forEach(({ doc, docIndex }) => { + if (docIndex >= startIndex && docIndex <= endIndex) { + docIds.push(doc.id); + } + }); + } + + if (shouldSelect) { + selectMoreDocs(docIds); + } else { + deselectSomeDocs(docIds); + } + + lastCheckboxToggledDocId.current = toDocId; + }, + [selectMoreDocs, deselectSomeDocs, docMap, isDocSelected] + ); + const getSelectedDocsOrderedByRows = useCallback( (rows: DataTableRecord[]) => { return rows.filter((row) => isDocSelected(row.id)); @@ -101,6 +142,7 @@ export const useSelectedDocs = (docMap: Map): UseSelect docIdsInSelectionOrder: selectedDocIds, getCountOfFilteredSelectedDocs, toggleDocSelection, + toggleMultipleDocsSelection, selectAllDocs, selectMoreDocs, deselectSomeDocs, @@ -112,6 +154,7 @@ export const useSelectedDocs = (docMap: Map): UseSelect isDocSelected, getCountOfFilteredSelectedDocs, toggleDocSelection, + toggleMultipleDocsSelection, selectAllDocs, selectMoreDocs, deselectSomeDocs, diff --git a/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx b/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx index 82403ad38c710..49e645e3f2206 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx @@ -213,6 +213,9 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) { getOperator(fieldName, values, operation), fieldType ); + if (!updatedQuery) { + return; + } data.query.queryString.setQuery({ esql: updatedQuery, }); diff --git a/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/constants.ts b/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/constants.ts index 60bd01c2bd78c..24e236a9858a3 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/constants.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/constants.ts @@ -58,6 +58,7 @@ export const DATA_DATASETS_INDEX_PATTERNS = [ { pattern: 'fluent-bit*', patternName: 'fluentbit' }, { pattern: '*nginx*', patternName: 'nginx' }, { pattern: '*apache*', patternName: 'apache' }, // Already in Security (keeping it in here for documentation) + { pattern: 'logs-*-*', patternName: 'dsns-logs' }, { pattern: '*logs*', patternName: 'generic-logs' }, // Security - Elastic diff --git a/test/functional/apps/discover/group2_data_grid3/_data_grid_row_selection.ts b/test/functional/apps/discover/group2_data_grid3/_data_grid_row_selection.ts index 97e56a1de868c..66d3f1323650e 100644 --- a/test/functional/apps/discover/group2_data_grid3/_data_grid_row_selection.ts +++ b/test/functional/apps/discover/group2_data_grid3/_data_grid_row_selection.ts @@ -84,6 +84,60 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); + it('should be able to select multiple rows holding Shift key', async () => { + expect(await dataGrid.isSelectedRowsMenuVisible()).to.be(false); + + // select 1 row + await dataGrid.selectRow(1); + + await retry.try(async () => { + expect(await dataGrid.isSelectedRowsMenuVisible()).to.be(true); + expect(await dataGrid.getNumberOfSelectedRowsOnCurrentPage()).to.be(1); + expect(await dataGrid.getNumberOfSelectedRows()).to.be(1); + }); + + // select 3 more + await dataGrid.selectRow(4, { pressShiftKey: true }); + + await retry.try(async () => { + expect(await dataGrid.isSelectedRowsMenuVisible()).to.be(true); + expect(await dataGrid.getNumberOfSelectedRowsOnCurrentPage()).to.be(4); + expect(await dataGrid.getNumberOfSelectedRows()).to.be(4); + }); + + // deselect index 3 and 4 + await dataGrid.selectRow(3, { pressShiftKey: true }); + + await retry.try(async () => { + expect(await dataGrid.isSelectedRowsMenuVisible()).to.be(true); + expect(await dataGrid.getNumberOfSelectedRowsOnCurrentPage()).to.be(2); + expect(await dataGrid.getNumberOfSelectedRows()).to.be(2); + }); + + // select from index 3 to 0 + await dataGrid.selectRow(0, { pressShiftKey: true }); + + await retry.try(async () => { + expect(await dataGrid.isSelectedRowsMenuVisible()).to.be(true); + expect(await dataGrid.getNumberOfSelectedRowsOnCurrentPage()).to.be(4); + expect(await dataGrid.getNumberOfSelectedRows()).to.be(4); + }); + + // select from both pages + await testSubjects.click('pagination-button-1'); + await retry.try(async () => { + expect(await dataGrid.getNumberOfSelectedRowsOnCurrentPage()).to.be(0); + }); + + await dataGrid.selectRow(2, { pressShiftKey: true }); + + await retry.try(async () => { + expect(await dataGrid.isSelectedRowsMenuVisible()).to.be(true); + expect(await dataGrid.getNumberOfSelectedRowsOnCurrentPage()).to.be(3); + expect(await dataGrid.getNumberOfSelectedRows()).to.be(8); + }); + }); + it('should be able to bulk select rows', async () => { expect(await dataGrid.isSelectedRowsMenuVisible()).to.be(false); expect(await testSubjects.getAttribute('selectAllDocsOnPageToggle', 'title')).to.be( diff --git a/test/functional/services/data_grid.ts b/test/functional/services/data_grid.ts index c5c8db4a9886b..efdaeb49933f2 100644 --- a/test/functional/services/data_grid.ts +++ b/test/functional/services/data_grid.ts @@ -700,12 +700,21 @@ export class DataGridService extends FtrService { await this.checkCurrentRowsPerPageToBe(newValue); } - public async selectRow(rowIndex: number) { + public async selectRow(rowIndex: number, { pressShiftKey }: { pressShiftKey?: boolean } = {}) { const checkbox = await this.find.byCssSelector( `.euiDataGridRow[data-grid-visible-row-index="${rowIndex}"] [data-gridcell-column-id="select"] .euiCheckbox__input` ); - await checkbox.click(); + if (pressShiftKey) { + await this.browser + .getActions() + .keyDown(Key.SHIFT) + .click(checkbox._webElement) + .keyUp(Key.SHIFT) + .perform(); + } else { + await checkbox.click(); + } } public async getNumberOfSelectedRows() { diff --git a/x-pack/plugins/observability_solution/dataset_quality/server/services/data_telemetry/constants.ts b/x-pack/plugins/observability_solution/dataset_quality/server/services/data_telemetry/constants.ts index 619a6efc3bfdd..7f03b4d67ce5c 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/server/services/data_telemetry/constants.ts +++ b/x-pack/plugins/observability_solution/dataset_quality/server/services/data_telemetry/constants.ts @@ -25,7 +25,8 @@ export const EXCLUDE_ELASTIC_LOGS = ['logs-synth', 'logs-elastic', 'logs-endpoin export const TELEMETRY_CHANNEL = 'logs-data-telemetry'; -const LOGS_INDEX_PATTERN_NAMES = [ +type ObsPatternName = (typeof DATA_DATASETS_INDEX_PATTERNS_UNIQUE)[number]['patternName']; +const LOGS_INDEX_PATTERN_NAMES: ObsPatternName[] = [ 'filebeat', 'generic-filebeat', 'metricbeat', @@ -43,6 +44,7 @@ const LOGS_INDEX_PATTERN_NAMES = [ 'fluentbit', 'nginx', 'apache', + 'dsns-logs', 'generic-logs', ]; diff --git a/x-pack/plugins/observability_solution/inventory/common/entities.ts b/x-pack/plugins/observability_solution/inventory/common/entities.ts index cc69a4c4a687e..2135688d75467 100644 --- a/x-pack/plugins/observability_solution/inventory/common/entities.ts +++ b/x-pack/plugins/observability_solution/inventory/common/entities.ts @@ -23,6 +23,18 @@ export const ENTITIES_LATEST_ALIAS = entitiesAliasPattern({ dataset: ENTITY_LATEST, }); +const BUILTIN_SERVICES_FROM_ECS_DATA = 'builtin_services_from_ecs_data'; +const BUILTIN_HOSTS_FROM_ECS_DATA = 'builtin_hosts_from_ecs_data'; +const BUILTIN_CONTAINERS_FROM_ECS_DATA = 'builtin_containers_from_ecs_data'; + +export const defaultEntityDefinitions = [ + BUILTIN_SERVICES_FROM_ECS_DATA, + BUILTIN_HOSTS_FROM_ECS_DATA, + BUILTIN_CONTAINERS_FROM_ECS_DATA, +]; + +export const defaultEntityTypes: EntityType[] = ['service', 'host', 'container']; + const entityArrayRt = t.array(entityTypeRt); export const entityTypesRt = new t.Type( 'entityTypesRt', diff --git a/x-pack/plugins/observability_solution/inventory/public/components/badge_filter_with_popover/badge_filter_with_popover.test.tsx b/x-pack/plugins/observability_solution/inventory/public/components/badge_filter_with_popover/badge_filter_with_popover.test.tsx new file mode 100644 index 0000000000000..cda2f0bcb42d3 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/components/badge_filter_with_popover/badge_filter_with_popover.test.tsx @@ -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 React from 'react'; +import { render, fireEvent, screen } from '@testing-library/react'; +import { BadgeFilterWithPopover } from '.'; +import { EuiThemeProvider, copyToClipboard } from '@elastic/eui'; +import { ENTITY_TYPE } from '../../../common/es_fields/entities'; + +jest.mock('@elastic/eui', () => ({ + ...jest.requireActual('@elastic/eui'), + copyToClipboard: jest.fn(), +})); + +describe('BadgeFilterWithPopover', () => { + const mockOnFilter = jest.fn(); + const field = ENTITY_TYPE; + const value = 'host'; + const label = 'Host'; + const popoverContentDataTestId = 'inventoryBadgeFilterWithPopoverContent'; + const popoverContentTitleTestId = 'inventoryBadgeFilterWithPopoverTitle'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the badge with the correct label', () => { + render( + , + { wrapper: EuiThemeProvider } + ); + expect(screen.queryByText(label)).toBeInTheDocument(); + expect(screen.getByText(label).textContent).toBe(label); + }); + + it('opens the popover when the badge is clicked', () => { + render(); + expect(screen.queryByTestId(popoverContentDataTestId)).not.toBeInTheDocument(); + fireEvent.click(screen.getByText(value)); + expect(screen.queryByTestId(popoverContentDataTestId)).toBeInTheDocument(); + expect(screen.queryByTestId(popoverContentTitleTestId)?.textContent).toBe(`${field}:${value}`); + }); + + it('calls onFilter when the "Filter for" button is clicked', () => { + render(); + fireEvent.click(screen.getByText(value)); + fireEvent.click(screen.getByTestId('inventoryBadgeFilterWithPopoverFilterForButton')); + expect(mockOnFilter).toHaveBeenCalled(); + }); + + it('copies value to clipboard when the "Copy value" button is clicked', () => { + render(); + fireEvent.click(screen.getByText(value)); + fireEvent.click(screen.getByTestId('inventoryBadgeFilterWithPopoverCopyValueButton')); + expect(copyToClipboard).toHaveBeenCalledWith(value); + }); +}); diff --git a/x-pack/plugins/observability_solution/inventory/public/components/badge_filter_with_popover/index.tsx b/x-pack/plugins/observability_solution/inventory/public/components/badge_filter_with_popover/index.tsx new file mode 100644 index 0000000000000..d1e952e189d6e --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/components/badge_filter_with_popover/index.tsx @@ -0,0 +1,103 @@ +/* + * 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 { + EuiBadge, + EuiButtonEmpty, + EuiFlexGrid, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiPopoverFooter, + copyToClipboard, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; + +interface Props { + field: string; + value: string; + label?: string; + onFilter: () => void; +} + +export function BadgeFilterWithPopover({ field, value, onFilter, label }: Props) { + const [isOpen, setIsOpen] = useState(false); + const theme = useEuiTheme(); + + return ( + setIsOpen((state) => !state)} + onClickAriaLabel={i18n.translate( + 'xpack.inventory.badgeFilterWithPopover.openPopoverBadgeLabel', + { defaultMessage: 'Open popover' } + )} + > + {label || value} + + } + isOpen={isOpen} + closePopover={() => setIsOpen(false)} + > + + + + + {field}: + + + + {value} + + + + + + + + {i18n.translate('xpack.inventory.badgeFilterWithPopover.filterForButtonEmptyLabel', { + defaultMessage: 'Filter for', + })} + + + + copyToClipboard(value)} + > + {i18n.translate('xpack.inventory.badgeFilterWithPopover.copyValueButtonEmptyLabel', { + defaultMessage: 'Copy value', + })} + + + + + + ); +} diff --git a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entities_grid.stories.tsx b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entities_grid.stories.tsx index b0e6c2fcc5ee4..996f0ec951581 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entities_grid.stories.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entities_grid.stories.tsx @@ -5,12 +5,13 @@ * 2.0. */ -import { EuiDataGridSorting } from '@elastic/eui'; +import { EuiDataGridSorting, EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; import { Meta, Story } from '@storybook/react'; -import React, { useMemo, useState } from 'react'; import { orderBy } from 'lodash'; +import React, { useMemo, useState } from 'react'; import { EntitiesGrid } from '.'; -import { ENTITY_LAST_SEEN } from '../../../common/es_fields/entities'; +import { EntityType } from '../../../common/entities'; +import { ENTITY_LAST_SEEN, ENTITY_TYPE } from '../../../common/es_fields/entities'; import { entitiesMock } from './mock/entities_mock'; const stories: Meta<{}> = { @@ -25,22 +26,44 @@ export const Example: Story<{}> = () => { id: ENTITY_LAST_SEEN, direction: 'desc', }); - - const sortedItems = useMemo( - () => orderBy(entitiesMock, sort.id, sort.direction), - [sort.direction, sort.id] + const [selectedEntityType, setSelectedEntityType] = useState(); + const filteredAndSortedItems = useMemo( + () => + orderBy( + selectedEntityType + ? entitiesMock.filter((mock) => mock[ENTITY_TYPE] === selectedEntityType) + : entitiesMock, + sort.id, + sort.direction + ), + [selectedEntityType, sort.direction, sort.id] ); return ( - + + + {`Entity filter: ${selectedEntityType || 'N/A'}`} + setSelectedEntityType(undefined)} + > + Clear filter + + + + + + ); }; @@ -60,6 +83,7 @@ export const EmptyGridExample: Story<{}> = () => { onChangePage={setPageIndex} onChangeSort={setSort} pageIndex={pageIndex} + onFilterByType={() => {}} /> ); }; diff --git a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx index d5ab911605109..dbd1f0806895a 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx @@ -5,7 +5,6 @@ * 2.0. */ import { - EuiBadge, EuiButtonIcon, EuiDataGrid, EuiDataGridCellValueElementProps, @@ -20,6 +19,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedDate, FormattedMessage, FormattedTime } from '@kbn/i18n-react'; import { last } from 'lodash'; import React, { useCallback, useState } from 'react'; +import { EntityType } from '../../../common/entities'; import { ENTITY_DISPLAY_NAME, ENTITY_LAST_SEEN, @@ -27,11 +27,14 @@ import { } from '../../../common/es_fields/entities'; import { APIReturnType } from '../../api'; import { getEntityTypeLabel } from '../../utils/get_entity_type_label'; -import { EntityType } from '../../../common/entities'; +import { BadgeFilterWithPopover } from '../badge_filter_with_popover'; type InventoryEntitiesAPIReturnType = APIReturnType<'GET /internal/inventory/entities'>; -type EntityColumnIds = typeof ENTITY_DISPLAY_NAME | typeof ENTITY_LAST_SEEN | typeof ENTITY_TYPE; +export type EntityColumnIds = + | typeof ENTITY_DISPLAY_NAME + | typeof ENTITY_LAST_SEEN + | typeof ENTITY_TYPE; const CustomHeaderCell = ({ title, tooltipContent }: { title: string; tooltipContent: string }) => ( <> @@ -106,6 +109,7 @@ interface Props { pageIndex: number; onChangeSort: (sorting: EuiDataGridSorting['columns'][0]) => void; onChangePage: (nextPage: number) => void; + onFilterByType: (entityType: EntityType) => void; } const PAGE_SIZE = 20; @@ -118,6 +122,7 @@ export function EntitiesGrid({ pageIndex, onChangePage, onChangeSort, + onFilterByType, }: Props) { const [visibleColumns, setVisibleColumns] = useState(columns.map(({ id }) => id)); @@ -141,10 +146,14 @@ export function EntitiesGrid({ const columnEntityTableId = columnId as EntityColumnIds; switch (columnEntityTableId) { case ENTITY_TYPE: + const entityType = entity[columnEntityTableId] as EntityType; return ( - - {getEntityTypeLabel(entity[columnEntityTableId] as EntityType)} - + onFilterByType(entityType)} + /> ); case ENTITY_LAST_SEEN: return ( @@ -183,7 +192,7 @@ export function EntitiesGrid({ return entity[columnId as EntityColumnIds] || ''; } }, - [entities] + [entities, onFilterByType] ); if (loading) { diff --git a/x-pack/plugins/observability_solution/inventory/public/components/search_bar/discover_button.tsx b/x-pack/plugins/observability_solution/inventory/public/components/search_bar/discover_button.tsx new file mode 100644 index 0000000000000..d91ca5bf7d2d9 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/components/search_bar/discover_button.tsx @@ -0,0 +1,83 @@ +/* + * 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 React, { useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiButton } from '@elastic/eui'; +import { DataView } from '@kbn/data-views-plugin/public'; +import { buildPhrasesFilter, PhrasesFilter } from '@kbn/es-query'; + +import { useKibana } from '../../hooks/use_kibana'; +import { + ENTITY_DEFINITION_ID, + ENTITY_DISPLAY_NAME, + ENTITY_LAST_SEEN, + ENTITY_TYPE, +} from '../../../common/es_fields/entities'; +import { EntityColumnIds } from '../entities_grid'; +import { defaultEntityDefinitions } from '../../../common/entities'; +import { useInventoryParams } from '../../hooks/use_inventory_params'; + +const ACTIVE_COLUMNS: EntityColumnIds[] = [ENTITY_DISPLAY_NAME, ENTITY_TYPE, ENTITY_LAST_SEEN]; + +export function DiscoverButton({ dataView }: { dataView: DataView }) { + const { + services: { share, application }, + } = useKibana(); + const { + query: { kuery, entityTypes }, + } = useInventoryParams('/*'); + + const discoverLocator = useMemo( + () => share.url.locators.get('DISCOVER_APP_LOCATOR'), + [share.url.locators] + ); + + const filters: PhrasesFilter[] = []; + + const entityDefinitionField = dataView.getFieldByName(ENTITY_DEFINITION_ID); + + if (entityDefinitionField) { + const entityDefinitionFilter = buildPhrasesFilter( + entityDefinitionField!, + defaultEntityDefinitions, + dataView + ); + filters.push(entityDefinitionFilter); + } + + const entityTypeField = dataView.getFieldByName(ENTITY_TYPE); + + if (entityTypes && entityTypeField) { + const entityTypeFilter = buildPhrasesFilter(entityTypeField, entityTypes, dataView); + filters.push(entityTypeFilter); + } + + const discoverLink = discoverLocator?.getRedirectUrl({ + indexPatternId: dataView?.id ?? '', + columns: ACTIVE_COLUMNS, + query: { query: kuery ?? '', language: 'kuery' }, + filters, + }); + + if (!application.capabilities.discover?.show || !discoverLink) { + return null; + } + + return ( + + {i18n.translate('xpack.inventory.searchBar.discoverButton', { + defaultMessage: 'Open in discover', + })} + + ); +} diff --git a/x-pack/plugins/observability_solution/inventory/public/components/search_bar/index.tsx b/x-pack/plugins/observability_solution/inventory/public/components/search_bar/index.tsx index 0b3beb5e00f8c..46ef45cfc195d 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/search_bar/index.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/search_bar/index.tsx @@ -8,12 +8,14 @@ import { i18n } from '@kbn/i18n'; import { SearchBarOwnProps } from '@kbn/unified-search-plugin/public/search_bar'; import deepEqual from 'fast-deep-equal'; import React, { useCallback, useEffect } from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { EntityType } from '../../../common/entities'; import { useInventorySearchBarContext } from '../../context/inventory_search_bar_context_provider'; import { useAdHocInventoryDataView } from '../../hooks/use_adhoc_inventory_data_view'; import { useInventoryParams } from '../../hooks/use_inventory_params'; import { useKibana } from '../../hooks/use_kibana'; import { EntityTypesControls } from './entity_types_controls'; +import { DiscoverButton } from './discover_button'; export function SearchBar() { const { searchBarContentSubject$ } = useInventorySearchBarContext(); @@ -68,18 +70,28 @@ export function SearchBar() { ); return ( - } - onQuerySubmit={handleQuerySubmit} - placeholder={i18n.translate('xpack.inventory.searchBar.placeholder', { - defaultMessage: - 'Search for your entities by name or its metadata (e.g. entity.type : service)', - })} - /> + + + } + onQuerySubmit={handleQuerySubmit} + placeholder={i18n.translate('xpack.inventory.searchBar.placeholder', { + defaultMessage: + 'Search for your entities by name or its metadata (e.g. entity.type : service)', + })} + /> + + + {dataView ? ( + + + + ) : null} + ); } diff --git a/x-pack/plugins/observability_solution/inventory/public/pages/inventory_page/index.tsx b/x-pack/plugins/observability_solution/inventory/public/pages/inventory_page/index.tsx index 2fcb147cbe61f..7af9a9fc21acc 100644 --- a/x-pack/plugins/observability_solution/inventory/public/pages/inventory_page/index.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/pages/inventory_page/index.tsx @@ -7,6 +7,7 @@ import { EuiDataGridSorting } from '@elastic/eui'; import React from 'react'; import useEffectOnce from 'react-use/lib/useEffectOnce'; +import { EntityType } from '../../../common/entities'; import { EntitiesGrid } from '../../components/entities_grid'; import { useInventorySearchBarContext } from '../../context/inventory_search_bar_context_provider'; import { useInventoryAbortableAsync } from '../../hooks/use_inventory_abortable_async'; @@ -81,6 +82,17 @@ export function InventoryPage() { }); } + function handleTypeFilter(entityType: EntityType) { + inventoryRoute.push('/', { + path: {}, + query: { + ...query, + // Override the current entity types + entityTypes: [entityType], + }, + }); + } + return ( ); } diff --git a/x-pack/plugins/observability_solution/inventory/server/routes/entities/query_helper.ts b/x-pack/plugins/observability_solution/inventory/server/routes/entities/query_helper.ts index c27e5ffd103aa..cb0257010f3c0 100644 --- a/x-pack/plugins/observability_solution/inventory/server/routes/entities/query_helper.ts +++ b/x-pack/plugins/observability_solution/inventory/server/routes/entities/query_helper.ts @@ -5,23 +5,13 @@ * 2.0. */ -import { EntityType } from '../../../common/entities'; +import { EntityType, defaultEntityTypes, defaultEntityDefinitions } from '../../../common/entities'; import { ENTITY_DEFINITION_ID, ENTITY_TYPE } from '../../../common/es_fields/entities'; -const defaultEntityTypes: EntityType[] = ['service', 'host', 'container']; - export const getEntityTypesWhereClause = (entityTypes: EntityType[] = defaultEntityTypes) => `WHERE ${ENTITY_TYPE} IN (${entityTypes.map((entityType) => `"${entityType}"`).join()})`; -const BUILTIN_SERVICES_FROM_ECS_DATA = 'builtin_services_from_ecs_data'; -const BUILTIN_HOSTS_FROM_ECS_DATA = 'builtin_hosts_from_ecs_data'; -const BUILTIN_CONTAINERS_FROM_ECS_DATA = 'builtin_containers_from_ecs_data'; - export const getEntityDefinitionIdWhereClause = () => - `WHERE ${ENTITY_DEFINITION_ID} IN (${[ - BUILTIN_SERVICES_FROM_ECS_DATA, - BUILTIN_HOSTS_FROM_ECS_DATA, - BUILTIN_CONTAINERS_FROM_ECS_DATA, - ] + `WHERE ${ENTITY_DEFINITION_ID} IN (${[...defaultEntityDefinitions] .map((buildin) => `"${buildin}"`) .join()})`; diff --git a/x-pack/plugins/observability_solution/inventory/tsconfig.json b/x-pack/plugins/observability_solution/inventory/tsconfig.json index a391737762c52..c4b6b55d41f4c 100644 --- a/x-pack/plugins/observability_solution/inventory/tsconfig.json +++ b/x-pack/plugins/observability_solution/inventory/tsconfig.json @@ -40,6 +40,7 @@ "@kbn/data-plugin", "@kbn/core-analytics-browser", "@kbn/core-http-browser", + "@kbn/es-query", "@kbn/shared-svg" ] } diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/engine_cleanup_route.gen.ts b/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/engine_cleanup_route.gen.ts new file mode 100644 index 0000000000000..13194051244cb --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/engine_cleanup_route.gen.ts @@ -0,0 +1,33 @@ +/* + * 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. + */ + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + * + * info: + * title: Risk Scoring API + * version: 1 + */ + +import { z } from '@kbn/zod'; + +export type CleanUpRiskEngineErrorResponse = z.infer; +export const CleanUpRiskEngineErrorResponse = z.object({ + cleanup_successful: z.boolean(), + errors: z.array( + z.object({ + seq: z.number().int(), + error: z.string(), + }) + ), +}); + +export type CleanUpRiskEngineResponse = z.infer; +export const CleanUpRiskEngineResponse = z.object({ + cleanup_successful: z.boolean().optional(), +}); diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/engine_cleanup_route.schema.yaml b/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/engine_cleanup_route.schema.yaml new file mode 100644 index 0000000000000..2dffe3879961e --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/engine_cleanup_route.schema.yaml @@ -0,0 +1,60 @@ +openapi: 3.0.0 +info: + version: '1' + title: Risk Scoring API + description: These APIs allow the consumer to manage Entity Risk Scores within Entity Analytics. +paths: + /api/risk_score/engine/dangerously_delete_data: + delete: + x-labels: [ess, serverless] + x-codegen-enabled: true + operationId: CleanUpRiskEngine + summary: Cleanup the Risk Engine + description: Cleaning up the the Risk Engine by removing the indices, mapping and transforms + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + cleanup_successful: + type: boolean + '400': + description: Task manager is unavailable + content: + application/json: + schema: + $ref: '../common/common.schema.yaml#/components/schemas/TaskManagerUnavailableResponse' + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/CleanUpRiskEngineErrorResponse' + +components: + schemas: + CleanUpRiskEngineErrorResponse: + type: object + required: + - cleanup_successful + - errors + properties: + cleanup_successful: + type: boolean + example: false + errors: + type: array + items: + type: object + required: + - seq + - error + properties: + seq: + type: integer + error: + type: string + diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/index.ts b/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/index.ts index 94d587cd2bfc7..21dc89544c8d8 100644 --- a/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/index.ts +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/index.ts @@ -15,3 +15,4 @@ export * from './calculation_route.gen'; export * from './preview_route.gen'; export * from './entity_calculation_route.gen'; export * from './get_risk_engine_privileges.gen'; +export * from './engine_cleanup_route.gen'; diff --git a/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts b/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts index 1af4e60124ef1..9b057bb19d7e2 100644 --- a/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts +++ b/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts @@ -274,6 +274,7 @@ import type { ListEntitiesRequestQueryInput, ListEntitiesResponse, } from './entity_analytics/entity_store/entities/list_entities.gen'; +import type { CleanUpRiskEngineResponse } from './entity_analytics/risk_engine/engine_cleanup_route.gen'; import type { DisableRiskEngineResponse } from './entity_analytics/risk_engine/engine_disable_route.gen'; import type { EnableRiskEngineResponse } from './entity_analytics/risk_engine/engine_enable_route.gen'; import type { InitRiskEngineResponse } from './entity_analytics/risk_engine/engine_init_route.gen'; @@ -540,6 +541,21 @@ If asset criticality records already exist for the specified entities, those rec }) .catch(catchAxiosErrorFormatAndThrow); } + /** + * Cleaning up the the Risk Engine by removing the indices, mapping and transforms + */ + async cleanUpRiskEngine() { + this.log.info(`${new Date().toISOString()} Calling API CleanUpRiskEngine`); + return this.kbnClient + .request({ + path: '/api/risk_score/engine/dangerously_delete_data', + headers: { + [ELASTIC_HTTP_VERSION_HEADER]: '1', + }, + method: 'DELETE', + }) + .catch(catchAxiosErrorFormatAndThrow); + } async createAlertsIndex() { this.log.info(`${new Date().toISOString()} Calling API CreateAlertsIndex`); return this.kbnClient diff --git a/x-pack/plugins/security_solution/common/entity_analytics/risk_engine/constants.ts b/x-pack/plugins/security_solution/common/entity_analytics/risk_engine/constants.ts index 17cfcf1da8e84..0eda694aed24b 100644 --- a/x-pack/plugins/security_solution/common/entity_analytics/risk_engine/constants.ts +++ b/x-pack/plugins/security_solution/common/entity_analytics/risk_engine/constants.ts @@ -16,6 +16,7 @@ export const RISK_ENGINE_SETTINGS_URL = `${RISK_ENGINE_URL}/settings` as const; // Public Risk Score routes export const PUBLIC_RISK_ENGINE_URL = `${PUBLIC_RISK_SCORE_URL}/engine` as const; export const RISK_ENGINE_SCHEDULE_NOW_URL = `${RISK_ENGINE_URL}/schedule_now` as const; +export const RISK_ENGINE_CLEANUP_URL = `${PUBLIC_RISK_ENGINE_URL}/dangerously_delete_data` as const; type ClusterPrivilege = 'manage_index_templates' | 'manage_transform'; export const RISK_ENGINE_REQUIRED_ES_CLUSTER_PRIVILEGES = [ diff --git a/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_entity_analytics_api_1.bundled.schema.yaml b/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_entity_analytics_api_1.bundled.schema.yaml new file mode 100644 index 0000000000000..9d6d57abd382a --- /dev/null +++ b/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_entity_analytics_api_1.bundled.schema.yaml @@ -0,0 +1,88 @@ +openapi: 3.0.3 +info: + description: '' + title: Security Entity Analytics API (Elastic Cloud and self-hosted) + version: '1' +servers: + - url: http://{kibana_host}:{port} + variables: + kibana_host: + default: localhost + port: + default: '5601' +paths: + /api/risk_score/engine/dangerously_delete_data: + delete: + description: >- + Cleaning up the the Risk Engine by removing the indices, mapping and + transforms + operationId: CleanUpRiskEngine + responses: + '200': + content: + application/json: + schema: + type: object + properties: + cleanup_successful: + type: boolean + description: Successful response + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/TaskManagerUnavailableResponse' + description: Task manager is unavailable + default: + content: + application/json: + schema: + $ref: '#/components/schemas/CleanUpRiskEngineErrorResponse' + description: Unexpected error + summary: Cleanup the Risk Engine + tags: + - Security Entity Analytics API +components: + schemas: + CleanUpRiskEngineErrorResponse: + type: object + properties: + cleanup_successful: + example: false + type: boolean + errors: + items: + type: object + properties: + error: + type: string + seq: + type: integer + required: + - seq + - error + type: array + required: + - cleanup_successful + - errors + TaskManagerUnavailableResponse: + description: Task manager is unavailable + type: object + properties: + message: + type: string + status_code: + minimum: 400 + type: integer + required: + - status_code + - message + securitySchemes: + BasicAuth: + scheme: basic + type: http +security: + - BasicAuth: [] +tags: + - description: '' + name: Security Entity Analytics API diff --git a/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_entity_analytics_api_1.bundled.schema.yaml b/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_entity_analytics_api_1.bundled.schema.yaml new file mode 100644 index 0000000000000..835d8f79b1fea --- /dev/null +++ b/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_entity_analytics_api_1.bundled.schema.yaml @@ -0,0 +1,88 @@ +openapi: 3.0.3 +info: + description: '' + title: Security Entity Analytics API (Elastic Cloud Serverless) + version: '1' +servers: + - url: http://{kibana_host}:{port} + variables: + kibana_host: + default: localhost + port: + default: '5601' +paths: + /api/risk_score/engine/dangerously_delete_data: + delete: + description: >- + Cleaning up the the Risk Engine by removing the indices, mapping and + transforms + operationId: CleanUpRiskEngine + responses: + '200': + content: + application/json: + schema: + type: object + properties: + cleanup_successful: + type: boolean + description: Successful response + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/TaskManagerUnavailableResponse' + description: Task manager is unavailable + default: + content: + application/json: + schema: + $ref: '#/components/schemas/CleanUpRiskEngineErrorResponse' + description: Unexpected error + summary: Cleanup the Risk Engine + tags: + - Security Entity Analytics API +components: + schemas: + CleanUpRiskEngineErrorResponse: + type: object + properties: + cleanup_successful: + example: false + type: boolean + errors: + items: + type: object + properties: + error: + type: string + seq: + type: integer + required: + - seq + - error + type: array + required: + - cleanup_successful + - errors + TaskManagerUnavailableResponse: + description: Task manager is unavailable + type: object + properties: + message: + type: string + status_code: + minimum: 400 + type: integer + required: + - status_code + - message + securitySchemes: + BasicAuth: + scheme: basic + type: http +security: + - BasicAuth: [] +tags: + - description: '' + name: Security Entity Analytics API diff --git a/x-pack/plugins/security_solution/public/entity_analytics/api/api.ts b/x-pack/plugins/security_solution/public/entity_analytics/api/api.ts index f958d20d7c96b..18cb9ef570bd5 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/api/api.ts +++ b/x-pack/plugins/security_solution/public/entity_analytics/api/api.ts @@ -7,12 +7,12 @@ import { useMemo } from 'react'; import { LIST_ENTITIES_URL } from '../../../common/entity_analytics/entity_store/constants'; -import type { RiskEngineScheduleNowResponse } from '../../../common/api/entity_analytics/risk_engine/engine_schedule_now_route.gen'; -import type { DisableRiskEngineResponse } from '../../../common/api/entity_analytics/risk_engine/engine_disable_route.gen'; import type { UploadAssetCriticalityRecordsResponse } from '../../../common/api/entity_analytics/asset_criticality/upload_asset_criticality_csv.gen'; +import type { DisableRiskEngineResponse } from '../../../common/api/entity_analytics/risk_engine/engine_disable_route.gen'; import type { RiskEngineStatusResponse } from '../../../common/api/entity_analytics/risk_engine/engine_status_route.gen'; import type { InitRiskEngineResponse } from '../../../common/api/entity_analytics/risk_engine/engine_init_route.gen'; import type { EnableRiskEngineResponse } from '../../../common/api/entity_analytics/risk_engine/engine_enable_route.gen'; +import type { RiskEngineScheduleNowResponse } from '../../../common/api/entity_analytics/risk_engine/engine_schedule_now_route.gen'; import type { RiskScoresPreviewRequest, RiskScoresPreviewResponse, @@ -40,6 +40,7 @@ import { ASSET_CRITICALITY_PUBLIC_CSV_UPLOAD_URL, RISK_SCORE_ENTITY_CALCULATION_URL, API_VERSIONS, + RISK_ENGINE_CLEANUP_URL, RISK_ENGINE_SCHEDULE_NOW_URL, } from '../../../common/constants'; import type { SnakeToCamelCase } from '../common/utils'; @@ -191,12 +192,18 @@ export const useEntityAnalyticsRoutes = () => { }); const deleteAssetCriticality = async ( - params: Pick & { refresh?: 'wait_for' } + params: Pick & { + refresh?: 'wait_for'; + } ): Promise<{ deleted: true }> => { await http.fetch(ASSET_CRITICALITY_PUBLIC_URL, { version: API_VERSIONS.public.v1, method: 'DELETE', - query: { id_value: params.idValue, id_field: params.idField, refresh: params.refresh }, + query: { + id_value: params.idValue, + id_field: params.idField, + refresh: params.refresh, + }, }); // spoof a response to allow us to better distnguish a delete from a create in use_asset_criticality.ts @@ -220,7 +227,9 @@ export const useEntityAnalyticsRoutes = () => { fileContent: string, fileName: string ): Promise => { - const file = new File([new Blob([fileContent])], fileName, { type: 'text/csv' }); + const file = new File([new Blob([fileContent])], fileName, { + type: 'text/csv', + }); const body = new FormData(); body.append('file', file); @@ -267,6 +276,16 @@ export const useEntityAnalyticsRoutes = () => { method: 'GET', }); + /** + * Deletes Risk engine installation and associated data + */ + + const cleanUpRiskEngine = () => + http.fetch(RISK_ENGINE_CLEANUP_URL, { + version: '1', + method: 'DELETE', + }); + return { fetchRiskScorePreview, fetchRiskEngineStatus, @@ -283,6 +302,7 @@ export const useEntityAnalyticsRoutes = () => { getRiskScoreIndexStatus, fetchRiskEngineSettings, calculateEntityRiskScore, + cleanUpRiskEngine, fetchEntitiesList, }; }, [http]); diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/risk_engine_data_client.mock.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/risk_engine_data_client.mock.ts index a8d7b7a9c763b..e9819c5b290d3 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/risk_engine_data_client.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/risk_engine_data_client.mock.ts @@ -15,6 +15,9 @@ const createRiskEngineDataClientMock = () => getConfiguration: jest.fn(), getStatus: jest.fn(), init: jest.fn(), + tearDown: jest.fn(), } as unknown as jest.Mocked); -export const riskEngineDataClientMock = { create: createRiskEngineDataClientMock }; +export const riskEngineDataClientMock = { + create: createRiskEngineDataClientMock, +}; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/delete.test.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/delete.test.ts new file mode 100644 index 0000000000000..5c66b70c75c13 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/delete.test.ts @@ -0,0 +1,186 @@ +/* + * 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 { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; +import { RISK_ENGINE_CLEANUP_URL } from '../../../../../common/constants'; +import { + serverMock, + requestContextMock, + requestMock, +} from '../../../detection_engine/routes/__mocks__'; +import { riskEnginePrivilegesMock } from './risk_engine_privileges.mock'; +import { riskEngineDataClientMock } from '../risk_engine_data_client.mock'; +import { riskEngineCleanupRoute } from './delete'; + +describe('risk engine cleanup route', () => { + let server: ReturnType; + let context: ReturnType; + let mockTaskManagerStart: ReturnType; + let mockRiskEngineDataClient: ReturnType; + let getStartServicesMock: jest.Mock; + + beforeEach(() => { + jest.resetAllMocks(); + + server = serverMock.create(); + const { clients } = requestContextMock.createTools(); + mockRiskEngineDataClient = riskEngineDataClientMock.create(); + context = requestContextMock.convertContext( + requestContextMock.create({ + ...clients, + riskEngineDataClient: mockRiskEngineDataClient, + }) + ); + mockTaskManagerStart = taskManagerMock.createStart(); + }); + + const buildRequest = () => { + return requestMock.create({ + method: 'delete', + path: RISK_ENGINE_CLEANUP_URL, + body: {}, + }); + }; + describe('invokes the risk engine cleanup route', () => { + beforeEach(() => { + getStartServicesMock = jest.fn().mockResolvedValue([ + {}, + { + taskManager: mockTaskManagerStart, + security: riskEnginePrivilegesMock.createMockSecurityStartWithFullRiskEngineAccess(), + }, + ]); + riskEngineCleanupRoute(server.router, getStartServicesMock); + }); + + it('should call the router with the correct route and handler', async () => { + const request = buildRequest(); + await server.inject(request, context); + expect(mockRiskEngineDataClient.tearDown).toHaveBeenCalled(); + }); + + it('returns a 200 when cleanup is successful', async () => { + const request = buildRequest(); + const response = await server.inject(request, context); + expect(response.status).toBe(200); + expect(response.body).toEqual({ cleanup_successful: true }); + }); + + it('returns a 400 when cleanup endpoint is called multiple times', async () => { + mockRiskEngineDataClient.tearDown.mockImplementation(async () => { + return [Error('Risk engine is disabled or deleted already.')]; + }); + const request = buildRequest(); + const response = await server.inject(request, context); + expect(response.status).toBe(400); + expect(response.body).toEqual({ + cleanup_successful: false, + errors: [ + { + seq: 1, + error: 'Error: Risk engine is disabled or deleted already.', + }, + ], + status_code: 400, + }); + }); + + it('returns a 500 when cleanup is unsuccessful', async () => { + mockRiskEngineDataClient.tearDown.mockImplementation(() => { + throw new Error('Error tearing down'); + }); + const request = buildRequest(); + const response = await server.inject(request, context); + expect(response.status).toBe(500); + expect(response.body).toEqual({ + errors: { + error: '{}', + seq: 1, + }, + cleanup_successful: false, + status_code: 500, + }); + }); + + it('returns a 500 when cleanup is unsuccessful with multiple errors', async () => { + mockRiskEngineDataClient.tearDown.mockImplementation(async () => { + return [ + Error('Error while removing risk scoring task'), + Error('Error while deleting saved objects'), + Error('Error while removing risk score index'), + ]; + }); + const request = buildRequest(); + const response = await server.inject(request, context); + expect(response.status).toBe(500); + expect(response.body).toEqual({ + errors: [ + { + seq: 1, + error: 'Error: Error while removing risk scoring task', + }, + { + seq: 2, + error: 'Error: Error while deleting saved objects', + }, + { + seq: 3, + error: 'Error: Error while removing risk score index', + }, + ], + cleanup_successful: false, + status_code: 500, + }); + }); + }); + describe('when task manager is unavailable', () => { + beforeEach(() => { + getStartServicesMock = jest.fn().mockResolvedValue([ + {}, + { + security: riskEnginePrivilegesMock.createMockSecurityStartWithFullRiskEngineAccess(), + }, + ]); + riskEngineCleanupRoute(server.router, getStartServicesMock); + }); + + it('returns a 400 when task manager is unavailable', async () => { + const request = buildRequest(); + const response = await server.inject(request, context); + expect(response.status).toBe(400); + expect(response.body).toEqual({ + message: + 'Task Manager is unavailable, but is required by the risk engine. Please enable the taskManager plugin and try again.', + status_code: 400, + }); + }); + }); + + describe('when user does not have the required privileges', () => { + beforeEach(() => { + getStartServicesMock = jest.fn().mockResolvedValue([ + {}, + { + taskManager: mockTaskManagerStart, + security: riskEnginePrivilegesMock.createMockSecurityStartWithNoRiskEngineAccess(), + }, + ]); + riskEngineCleanupRoute(server.router, getStartServicesMock); + }); + + it('returns a 403 when user does not have the required privileges', async () => { + const request = buildRequest(); + const response = await server.inject(request, context); + expect(response.status).toBe(403); + expect(response.body).toEqual({ + message: + 'User is missing risk engine privileges. Missing cluster privileges: manage_index_templates, manage_transform.', + status_code: 403, + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/delete.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/delete.ts new file mode 100644 index 0000000000000..1776ddcca69b1 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/delete.ts @@ -0,0 +1,103 @@ +/* + * 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 { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils'; +import type { IKibanaResponse } from '@kbn/core-http-server'; +import { withRiskEnginePrivilegeCheck } from '../risk_engine_privileges'; +import { RISK_ENGINE_CLEANUP_URL, APP_ID, API_VERSIONS } from '../../../../../common/constants'; +import type { EntityAnalyticsRoutesDeps } from '../../types'; +import { RiskEngineAuditActions } from '../audit'; +import { AUDIT_CATEGORY, AUDIT_OUTCOME, AUDIT_TYPE } from '../../audit'; +import { TASK_MANAGER_UNAVAILABLE_ERROR } from './translations'; +import type { CleanUpRiskEngineResponse } from '../../../../../common/api/entity_analytics'; + +export const riskEngineCleanupRoute = ( + router: EntityAnalyticsRoutesDeps['router'], + getStartServices: EntityAnalyticsRoutesDeps['getStartServices'] +) => { + router.versioned + .delete({ + access: 'public', + path: RISK_ENGINE_CLEANUP_URL, + options: { + tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + }, + }) + .addVersion( + { version: API_VERSIONS.public.v1, validate: {} }, + withRiskEnginePrivilegeCheck( + getStartServices, + async (context, request, response): Promise> => { + const siemResponse = buildSiemResponse(response); + const securitySolution = await context.securitySolution; + const [_, { taskManager }] = await getStartServices(); + const riskEngineClient = securitySolution.getRiskEngineDataClient(); + const riskScoreDataClient = securitySolution.getRiskScoreDataClient(); + + if (!taskManager) { + securitySolution.getAuditLogger()?.log({ + message: + 'User attempted to perform a cleanup of risk engine, but the Kibana Task Manager was unavailable', + event: { + action: RiskEngineAuditActions.RISK_ENGINE_REMOVE_TASK, + category: AUDIT_CATEGORY.DATABASE, + type: AUDIT_TYPE.DELETION, + outcome: AUDIT_OUTCOME.FAILURE, + }, + error: { + message: + 'User attempted to perform a cleanup of risk engine, but the Kibana Task Manager was unavailable', + }, + }); + + return siemResponse.error({ + statusCode: 400, + body: TASK_MANAGER_UNAVAILABLE_ERROR, + }); + } + + try { + const errors = await riskEngineClient.tearDown({ + taskManager, + riskScoreDataClient, + }); + if (errors && errors.length > 0) { + return siemResponse.error({ + statusCode: errors.some((error) => + error.message.includes('Risk engine is disabled or deleted already.') + ) + ? 400 + : 500, + body: { + cleanup_successful: false, + errors: errors.map((error, seq) => ({ + seq: seq + 1, + error: error.toString(), + })), + }, + bypassErrorFormat: true, + }); + } else { + return response.ok({ body: { cleanup_successful: true } }); + } + } catch (error) { + return siemResponse.error({ + statusCode: 500, + body: { + cleanup_successful: false, + errors: { + seq: 1, + error: JSON.stringify(error), + }, + }, + bypassErrorFormat: true, + }); + } + } + ) + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/register_risk_engine_routes.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/register_risk_engine_routes.ts index 99b0bbe5a5e87..f4edb7d798188 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/register_risk_engine_routes.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/register_risk_engine_routes.ts @@ -12,6 +12,7 @@ import { riskEnginePrivilegesRoute } from './privileges'; import { riskEngineSettingsRoute } from './settings'; import type { EntityAnalyticsRoutesDeps } from '../../types'; import { riskEngineScheduleNowRoute } from './schedule_now'; +import { riskEngineCleanupRoute } from './delete'; export const registerRiskEngineRoutes = ({ router, @@ -24,4 +25,5 @@ export const registerRiskEngineRoutes = ({ riskEngineScheduleNowRoute(router, getStartServices); riskEngineSettingsRoute(router); riskEnginePrivilegesRoute(router, getStartServices); + riskEngineCleanupRoute(router, getStartServices); }; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/risk_engine_privileges.mock.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/risk_engine_privileges.mock.ts index 10c772cfcf05e..189e72624c15c 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/risk_engine_privileges.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/risk_engine_privileges.mock.ts @@ -29,6 +29,27 @@ const createMockSecurityStartWithFullRiskEngineAccess = () => { return mockSecurityStart; }; +const createMockSecurityStartWithNoRiskEngineAccess = () => { + const mockSecurityStart = securityMock.createStart(); + + const mockCheckPrivileges = jest.fn().mockResolvedValue({ + hasAllRequested: false, + privileges: { + elasticsearch: { + cluster: [], + index: [], + }, + }, + }); + + mockSecurityStart.authz.checkPrivilegesDynamicallyWithRequest = jest + .fn() + .mockReturnValue(mockCheckPrivileges); + + return mockSecurityStart; +}; + export const riskEnginePrivilegesMock = { createMockSecurityStartWithFullRiskEngineAccess, + createMockSecurityStartWithNoRiskEngineAccess, }; diff --git a/x-pack/test/api_integration/services/security_solution_api.gen.ts b/x-pack/test/api_integration/services/security_solution_api.gen.ts index bb229ddcd693f..74f14c2c5f6ad 100644 --- a/x-pack/test/api_integration/services/security_solution_api.gen.ts +++ b/x-pack/test/api_integration/services/security_solution_api.gen.ts @@ -256,6 +256,16 @@ If asset criticality records already exist for the specified entities, those rec .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') .send(props.body as object); }, + /** + * Cleaning up the the Risk Engine by removing the indices, mapping and transforms + */ + cleanUpRiskEngine() { + return supertest + .delete('/api/risk_score/engine/dangerously_delete_data') + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana'); + }, createAlertsIndex() { return supertest .post('/api/detection_engine/index') diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/index.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/index.ts index 4879cce14f3a6..2aa04a898a449 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/index.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/index.ts @@ -10,6 +10,7 @@ import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('Entity Analytics - Risk Engine', function () { loadTestFile(require.resolve('./init_and_status_apis')); + loadTestFile(require.resolve('./risk_engine_cleanup_api')); loadTestFile(require.resolve('./risk_score_preview')); loadTestFile(require.resolve('./risk_scoring_task/task_execution')); loadTestFile(require.resolve('./risk_scoring_task/task_execution_nondefault_spaces')); diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/risk_engine_cleanup_api.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/risk_engine_cleanup_api.ts new file mode 100644 index 0000000000000..48344403093b3 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/risk_engine_cleanup_api.ts @@ -0,0 +1,67 @@ +/* + * 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 { v4 as uuidv4 } from 'uuid'; +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { + buildDocument, + riskEngineRouteHelpersFactory, + waitForRiskScoresToBePresent, + createAndSyncRuleAndAlertsFactory, +} from '../../utils'; +import { dataGeneratorFactory } from '../../../detections_response/utils'; + +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const riskEngineRoutes = riskEngineRouteHelpersFactory(supertest); + const es = getService('es'); + const log = getService('log'); + const esArchiver = getService('esArchiver'); + + describe('@ess @ serverless @serverless QA risk_engine_cleanup_api', () => { + const createAndSyncRuleAndAlerts = createAndSyncRuleAndAlertsFactory({ supertest, log }); + const { indexListOfDocuments } = dataGeneratorFactory({ + es, + index: 'ecs_compliant', + log, + }); + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/security_solution/ecs_compliant'); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/security_solution/ecs_compliant'); + }); + + it('should return response with success status', async () => { + const status1 = await riskEngineRoutes.getStatus(); + expect(status1.body.risk_engine_status).to.be('NOT_INSTALLED'); + expect(status1.body.legacy_risk_engine_status).to.be('NOT_INSTALLED'); + + const firstDocumentId = uuidv4(); + await indexListOfDocuments([buildDocument({ host: { name: 'host-1' } }, firstDocumentId)]); + await createAndSyncRuleAndAlerts({ query: `id: ${firstDocumentId}` }); + + await riskEngineRoutes.init(); + await waitForRiskScoresToBePresent({ es, log, scoreCount: 1 }); + + const status2 = await riskEngineRoutes.getStatus(); + expect(status2.body.risk_engine_status).to.be('ENABLED'); + expect(status2.body.legacy_risk_engine_status).to.be('NOT_INSTALLED'); + + const response = await riskEngineRoutes.delete(); + expect(response.body).to.eql({ + cleanup_successful: true, + }); + + const status3 = await riskEngineRoutes.getStatus(); + expect(status3.body.risk_engine_status).to.be('NOT_INSTALLED'); + expect(status3.body.legacy_risk_engine_status).to.be('NOT_INSTALLED'); + }); + }); +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/utils/risk_engine.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/utils/risk_engine.ts index 3b96bc61cc7ba..977ab1b3675f9 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/utils/risk_engine.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/utils/risk_engine.ts @@ -22,6 +22,7 @@ import { RISK_ENGINE_ENABLE_URL, RISK_ENGINE_STATUS_URL, RISK_ENGINE_PRIVILEGES_URL, + RISK_ENGINE_CLEANUP_URL, RISK_ENGINE_SCHEDULE_NOW_URL, } from '@kbn/security-solution-plugin/common/constants'; import { MappingTypeMapping } from '@elastic/elasticsearch/lib/api/types'; @@ -574,6 +575,14 @@ export const riskEngineRouteHelpersFactory = (supertest: SuperTest.Agent, namesp .send() .expect(expectStatusCode), + delete: async (expectStatusCode: number = 200) => + await supertest + .delete(routeWithNamespace(RISK_ENGINE_CLEANUP_URL, namespace)) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') + .send() + .expect(expectStatusCode), + scheduleNow: async (expectStatusCode: number = 200) => await supertest .post(routeWithNamespace(RISK_ENGINE_SCHEDULE_NOW_URL, namespace))