From 586c35869310c03f266a359eeeb61f25139c85f0 Mon Sep 17 00:00:00 2001 From: Ievgen Sorokopud Date: Mon, 22 Jan 2024 14:29:15 +0100 Subject: [PATCH] [Security Solution] User Alert Assignment feature: Assignee column in Alerts Table not displaying after upgrading to 8.12 (#173695) (#174370) ## Summary Addresses https://github.com/elastic/kibana/issues/173695 This PR fixes the issue with the missing "Assignees" column specs in the alert's table after user upgrades to `v8.12+`. This happens because we store table mode specs in the local storage and do not update it after the upgrade. With this changes we add missing column specs if needed. To easily reproduce the bug (without actual upgrades): 1. Run latest main 2. Open web inspector > local storage data 3. Find and update next items: `securityDataTable`, `detection-engine-alert-table-securitySolution-alerts-page-gridView` and `detection-engine-alert-table-securitySolution-rule-details-gridView`. You need to remove "Assignees" column specs which look like this: `{"columnHeaderType":"not-filtered","displayAsText":"Assignees","id":"kibana.alert.workflow_assignee_ids","initialWidth":190,"schema":"string"}`. Also, for the last to items remove `"kibana.alert.workflow_assignee_ids"` from `visibleColumns`. --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/common/mock/timeline_results.ts | 5 + .../alerts_table/default_config.test.tsx | 178 ++++++++++++++++++ .../alerts_table/default_config.tsx | 2 +- .../security_solution_detections/columns.ts | 14 +- .../containers/local_storage/index.test.ts | 97 ++++++++++ .../containers/local_storage/index.tsx | 88 ++++++++- 6 files changed, 376 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts index 732097aedbc2b..fe8ec46b752c5 100644 --- a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts +++ b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts @@ -1947,6 +1947,11 @@ const mockTimelineModelColumns: TimelineModel['columns'] = [ id: 'user.name', initialWidth: 180, }, + { + columnHeaderType: 'not-filtered', + id: 'kibana.alert.workflow_assignee_ids', + initialWidth: 180, + }, ]; export const mockTimelineModel: TimelineModel = { activeTab: TimelineTabs.query, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.test.tsx index b4b0a07fd7ab2..fc5d91bcaf8e5 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.test.tsx @@ -6,16 +6,78 @@ */ import type { ExistsFilter, Filter } from '@kbn/es-query'; +import { tableDefaults } from '@kbn/securitysolution-data-table'; +import { createLicenseServiceMock } from '../../../../common/license/mocks'; import { buildAlertAssigneesFilter, buildAlertsFilter, buildAlertStatusesFilter, buildAlertStatusFilter, buildThreatMatchFilter, + getAlertsDefaultModel, + getAlertsPreviewDefaultModel, } from './default_config'; jest.mock('./actions'); +const basicBaseColumns = [ + { + columnHeaderType: 'not-filtered', + displayAsText: 'Severity', + id: 'kibana.alert.severity', + initialWidth: 105, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Risk Score', + id: 'kibana.alert.risk_score', + initialWidth: 100, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Reason', + id: 'kibana.alert.reason', + initialWidth: 450, + }, + { columnHeaderType: 'not-filtered', id: 'host.name' }, + { columnHeaderType: 'not-filtered', id: 'user.name' }, + { columnHeaderType: 'not-filtered', id: 'process.name' }, + { columnHeaderType: 'not-filtered', id: 'file.name' }, + { columnHeaderType: 'not-filtered', id: 'source.ip' }, + { columnHeaderType: 'not-filtered', id: 'destination.ip' }, +]; + +const platinumBaseColumns = [ + { + columnHeaderType: 'not-filtered', + displayAsText: 'Severity', + id: 'kibana.alert.severity', + initialWidth: 105, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Risk Score', + id: 'kibana.alert.risk_score', + initialWidth: 100, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Reason', + id: 'kibana.alert.reason', + initialWidth: 450, + }, + { columnHeaderType: 'not-filtered', id: 'host.name' }, + { columnHeaderType: 'not-filtered', id: 'host.risk.calculated_level' }, + { columnHeaderType: 'not-filtered', id: 'user.name' }, + { columnHeaderType: 'not-filtered', id: 'user.risk.calculated_level' }, + { columnHeaderType: 'not-filtered', id: 'kibana.alert.host.criticality_level' }, + { columnHeaderType: 'not-filtered', id: 'kibana.alert.user.criticality_level' }, + { columnHeaderType: 'not-filtered', id: 'process.name' }, + { columnHeaderType: 'not-filtered', id: 'file.name' }, + { columnHeaderType: 'not-filtered', id: 'source.ip' }, + { columnHeaderType: 'not-filtered', id: 'destination.ip' }, +]; + describe('alerts default_config', () => { describe('buildAlertsRuleIdFilter', () => { test('given a rule id this will return an array with a single filter', () => { @@ -200,6 +262,122 @@ describe('alerts default_config', () => { }); }); + describe('getAlertsDefaultModel', () => { + test('returns correct model for Basic license', () => { + const licenseServiceMock = createLicenseServiceMock(); + licenseServiceMock.isPlatinumPlus.mockReturnValue(false); + const model = getAlertsDefaultModel(licenseServiceMock); + + const expected = { + ...tableDefaults, + showCheckboxes: true, + columns: [ + { columnHeaderType: 'not-filtered', id: '@timestamp', initialWidth: 200 }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Rule', + id: 'kibana.alert.rule.name', + initialWidth: 180, + linkField: 'kibana.alert.rule.uuid', + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Assignees', + id: 'kibana.alert.workflow_assignee_ids', + initialWidth: 190, + }, + ...basicBaseColumns, + ], + }; + expect(model).toEqual(expected); + }); + + test('returns correct model for Platinum license', () => { + const licenseServiceMock = createLicenseServiceMock(); + const model = getAlertsDefaultModel(licenseServiceMock); + + const expected = { + ...tableDefaults, + showCheckboxes: true, + columns: [ + { columnHeaderType: 'not-filtered', id: '@timestamp', initialWidth: 200 }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Rule', + id: 'kibana.alert.rule.name', + initialWidth: 180, + linkField: 'kibana.alert.rule.uuid', + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Assignees', + id: 'kibana.alert.workflow_assignee_ids', + initialWidth: 190, + }, + ...platinumBaseColumns, + ], + }; + expect(model).toEqual(expected); + }); + }); + + describe('getAlertsPreviewDefaultModel', () => { + test('returns correct model for Basic license', () => { + const licenseServiceMock = createLicenseServiceMock(); + licenseServiceMock.isPlatinumPlus.mockReturnValue(false); + const model = getAlertsPreviewDefaultModel(licenseServiceMock); + + const expected = { + ...tableDefaults, + showCheckboxes: false, + defaultColumns: [ + { columnHeaderType: 'not-filtered', id: 'kibana.alert.original_time', initialWidth: 200 }, + ...basicBaseColumns, + ], + columns: [ + { columnHeaderType: 'not-filtered', id: 'kibana.alert.original_time', initialWidth: 200 }, + ...basicBaseColumns, + ], + sort: [ + { + columnId: 'kibana.alert.original_time', + columnType: 'date', + esTypes: ['date'], + sortDirection: 'desc', + }, + ], + }; + expect(model).toEqual(expected); + }); + + test('returns correct model for Platinum license', () => { + const licenseServiceMock = createLicenseServiceMock(); + const model = getAlertsPreviewDefaultModel(licenseServiceMock); + + const expected = { + ...tableDefaults, + showCheckboxes: false, + defaultColumns: [ + { columnHeaderType: 'not-filtered', id: 'kibana.alert.original_time', initialWidth: 200 }, + ...platinumBaseColumns, + ], + columns: [ + { columnHeaderType: 'not-filtered', id: 'kibana.alert.original_time', initialWidth: 200 }, + ...platinumBaseColumns, + ], + sort: [ + { + columnId: 'kibana.alert.original_time', + columnType: 'date', + esTypes: ['date'], + sortDirection: 'desc', + }, + ], + }; + expect(model).toEqual(expected); + }); + }); + // TODO: move these tests to ../timelines/components/timeline/body/events/event_column_view.tsx // describe.skip('getAlertActions', () => { // let setEventsLoading: ({ eventIds, isLoading }: SetEventsLoadingProps) => void; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx index 6065e617c1254..9db07f4824652 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx @@ -192,7 +192,7 @@ export const getAlertsDefaultModel = (license?: LicenseService): SubsetDataTable export const getAlertsPreviewDefaultModel = (license?: LicenseService): SubsetDataTableModel => ({ ...getAlertsDefaultModel(license), - columns: getColumns(license), + columns: getRulePreviewColumns(license), defaultColumns: getRulePreviewColumns(license), sort: [ { diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts index 384c6bf955e51..b22bf5e2ed429 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts @@ -25,6 +25,13 @@ import { DEFAULT_TABLE_DATE_COLUMN_MIN_WIDTH, } from './translations'; +export const assigneesColumn: ColumnHeaderOptions = { + columnHeaderType: defaultColumnHeaderType, + displayAsText: i18n.ALERTS_HEADERS_ASSIGNEES, + id: 'kibana.alert.workflow_assignee_ids', + initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH, +}; + const getBaseColumns = ( license?: LicenseService ): Array< @@ -32,12 +39,6 @@ const getBaseColumns = ( > => { const isPlatinumPlus = license?.isPlatinumPlus?.() ?? false; return [ - { - columnHeaderType: defaultColumnHeaderType, - displayAsText: i18n.ALERTS_HEADERS_ASSIGNEES, - id: 'kibana.alert.workflow_assignee_ids', - initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH, - }, { columnHeaderType: defaultColumnHeaderType, displayAsText: i18n.ALERTS_HEADERS_SEVERITY, @@ -131,6 +132,7 @@ export const getColumns = ( initialWidth: DEFAULT_COLUMN_MIN_WIDTH, linkField: 'kibana.alert.rule.uuid', }, + assigneesColumn, ...getBaseColumns(license), ]; diff --git a/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.test.ts b/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.test.ts index f715169e2abb9..94f03268a23b2 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.test.ts @@ -16,6 +16,7 @@ import { addTableInStorage, migrateAlertTableStateToTriggerActionsState, migrateTriggerActionsVisibleColumnsAlertTable88xTo89, + addAssigneesSpecsToSecurityDataTableIfNeeded, } from '.'; import { mockDataTableModel, createSecuritySolutionStorageMock } from '../../../common/mock'; @@ -566,6 +567,12 @@ describe('SiemLocalStorage', () => { { columnHeaderType: 'not-filtered', id: 'file.name' }, { columnHeaderType: 'not-filtered', id: 'source.ip' }, { columnHeaderType: 'not-filtered', id: 'destination.ip' }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Assignees', + id: 'kibana.alert.workflow_assignee_ids', + initialWidth: 190, + }, ], defaultColumns: [ { columnHeaderType: 'not-filtered', id: '@timestamp', initialWidth: 200 }, @@ -600,6 +607,12 @@ describe('SiemLocalStorage', () => { { columnHeaderType: 'not-filtered', id: 'file.name' }, { columnHeaderType: 'not-filtered', id: 'source.ip' }, { columnHeaderType: 'not-filtered', id: 'destination.ip' }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Assignees', + id: 'kibana.alert.workflow_assignee_ids', + initialWidth: 190, + }, ], dataViewId: 'security-solution-default', deletedEventIds: [], @@ -1527,4 +1540,88 @@ describe('SiemLocalStorage', () => { ).toBeNull(); }); }); + + describe('addMissingColumnsToSecurityDataTable', () => { + it('should add missing "Assignees" column specs', () => { + const dataTableState: DataTableState['dataTable']['tableById'] = { + 'alerts-page': { + columns: [{ columnHeaderType: 'not-filtered', id: '@timestamp', initialWidth: 200 }], + defaultColumns: [ + { columnHeaderType: 'not-filtered', id: 'host.name' }, + { columnHeaderType: 'not-filtered', id: 'user.name' }, + { columnHeaderType: 'not-filtered', id: 'process.name' }, + ], + isLoading: false, + queryFields: [], + dataViewId: 'security-solution-default', + deletedEventIds: [], + expandedDetail: {}, + filters: [], + indexNames: ['.alerts-security.alerts-default'], + isSelectAllChecked: false, + itemsPerPage: 25, + itemsPerPageOptions: [10, 25, 50, 100], + loadingEventIds: [], + showCheckboxes: true, + sort: [ + { + columnId: '@timestamp', + columnType: 'date', + esTypes: ['date'], + sortDirection: 'desc', + }, + ], + graphEventId: undefined, + selectedEventIds: {}, + sessionViewConfig: null, + selectAll: false, + id: 'alerts-page', + title: '', + initialized: true, + updated: 1665943295913, + totalCount: 0, + viewMode: VIEW_SELECTION.gridView, + additionalFilters: { + showBuildingBlockAlerts: false, + showOnlyThreatIndicatorAlerts: false, + }, + }, + }; + storage.set(LOCAL_STORAGE_TABLE_KEY, dataTableState); + migrateAlertTableStateToTriggerActionsState(storage, dataTableState); + migrateTriggerActionsVisibleColumnsAlertTable88xTo89(storage); + + const expectedColumns = [ + { columnHeaderType: 'not-filtered', id: '@timestamp', initialWidth: 200 }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Assignees', + id: 'kibana.alert.workflow_assignee_ids', + initialWidth: 190, + }, + ]; + const expectedDefaultColumns = [ + { columnHeaderType: 'not-filtered', id: 'host.name' }, + { columnHeaderType: 'not-filtered', id: 'user.name' }, + { columnHeaderType: 'not-filtered', id: 'process.name' }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Assignees', + id: 'kibana.alert.workflow_assignee_ids', + initialWidth: 190, + }, + ]; + + addAssigneesSpecsToSecurityDataTableIfNeeded(storage, dataTableState); + + expect(dataTableState['alerts-page'].columns).toMatchObject(expectedColumns); + expect(dataTableState['alerts-page'].defaultColumns).toMatchObject(expectedDefaultColumns); + + const tableKey = 'detection-engine-alert-table-securitySolution-alerts-page-gridView'; + expect(storage.get(tableKey)).toMatchObject({ + columns: expectedColumns, + visibleColumns: expectedColumns.map((col) => col.id), + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.tsx index bab84294e0a67..8191f800ff934 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.tsx @@ -12,8 +12,9 @@ import type { DataTableModel, TableIdLiteral, } from '@kbn/securitysolution-data-table'; -import { TableId } from '@kbn/securitysolution-data-table'; +import { tableEntity, TableEntityType, TableId } from '@kbn/securitysolution-data-table'; import type { ColumnHeaderOptions } from '@kbn/timelines-plugin/common'; +import { assigneesColumn } from '../../../detections/configurations/security_solution_detections/columns'; import { ALERTS_TABLE_REGISTRY_CONFIG_IDS, VIEW_SELECTION } from '../../../../common/constants'; import type { DataTablesStorage } from './types'; import { useKibana } from '../../../common/lib/kibana'; @@ -195,6 +196,90 @@ export const migrateColumnLabelToDisplayAsText = ( : {}), }); +/** + * Adds "Assignees" column and makes it visible in alerts table + */ +const addAssigneesColumnToAlertsTable = (storage: Storage) => { + const localStorageKeys = [ + `detection-engine-alert-table-${ALERTS_TABLE_REGISTRY_CONFIG_IDS.ALERTS_PAGE}-gridView`, + `detection-engine-alert-table-${ALERTS_TABLE_REGISTRY_CONFIG_IDS.RULE_DETAILS}-gridView`, + ]; + + localStorageKeys.forEach((key) => { + const alertTableData = storage.get(key); + if (!alertTableData) { + return; + } + // Make "Assignees" field selected in the table + if ('columns' in alertTableData) { + let updatedAlertsTableState = false; + const columns = + alertTableData.columns as DataTableState['dataTable']['tableById'][string]['columns']; + const hasAssigneesColumn = columns.findIndex((col) => col.id === assigneesColumn.id) !== -1; + if (!hasAssigneesColumn) { + // Insert "Assignees" column at the index 1 to mimic behaviour of adding field to alerts table + alertTableData.columns.splice(1, 0, assigneesColumn); + updatedAlertsTableState = true; + } + // Make "Assignees" column visible in the table + if ('visibleColumns' in alertTableData) { + const visibleColumns = alertTableData.visibleColumns as string[]; + const assigneesColumnExists = + visibleColumns.findIndex((col) => col === assigneesColumn.id) !== -1; + if (!assigneesColumnExists) { + alertTableData.visibleColumns.splice(1, 0, assigneesColumn.id); + updatedAlertsTableState = true; + } + } + if (updatedAlertsTableState) { + storage.set(key, alertTableData); + } + } + }); +}; + +/** + * Adds "Assignees" column specs to table data model + */ +export const addAssigneesSpecsToSecurityDataTableIfNeeded = ( + storage: Storage, + dataTableState: DataTableState['dataTable']['tableById'] +) => { + // Add "Assignees" column specs to the table data model + let updatedTableModel = false; + for (const [tableId, tableModel] of Object.entries(dataTableState)) { + // Only add "Assignees" column specs to alerts tables + if (tableEntity[tableId as TableId] !== TableEntityType.alert) { + // eslint-disable-next-line no-continue + continue; + } + + // We added a new base column for "Assignees" in 8.12 + // In order to show correct custom header label after user upgrades to 8.12 we need to make sure the appropriate specs are in the table model. + const columns = tableModel.columns; + if (Array.isArray(columns)) { + const hasAssigneesColumn = columns.findIndex((col) => col.id === assigneesColumn.id) !== -1; + if (!hasAssigneesColumn) { + updatedTableModel = true; + tableModel.columns.push(assigneesColumn); + } + } + const defaultColumns = tableModel.defaultColumns; + if (defaultColumns) { + const hasAssigneesColumn = + defaultColumns.findIndex((col) => col.id === assigneesColumn.id) !== -1; + if (!hasAssigneesColumn) { + updatedTableModel = true; + tableModel.defaultColumns.push(assigneesColumn); + } + } + } + if (updatedTableModel) { + storage.set(LOCAL_STORAGE_TABLE_KEY, dataTableState); + addAssigneesColumnToAlertsTable(storage); + } +}; + export const getDataTablesInStorageByIds = (storage: Storage, tableIds: TableIdLiteral[]) => { let allDataTables = storage.get(LOCAL_STORAGE_TABLE_KEY); const legacyTimelineTables = storage.get(LOCAL_STORAGE_TIMELINE_KEY_LEGACY); @@ -209,6 +294,7 @@ export const getDataTablesInStorageByIds = (storage: Storage, tableIds: TableIdL migrateAlertTableStateToTriggerActionsState(storage, allDataTables); migrateTriggerActionsVisibleColumnsAlertTable88xTo89(storage); + addAssigneesSpecsToSecurityDataTableIfNeeded(storage, allDataTables); return tableIds.reduce((acc, tableId) => { const tableModel = allDataTables[tableId];