diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 91b48afdc4ed1..87e99a4b472e7 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -186,6 +186,7 @@ export const DETECTION_ENGINE_INDEX_URL = `${DETECTION_ENGINE_URL}/index`; export const DETECTION_ENGINE_TAGS_URL = `${DETECTION_ENGINE_URL}/tags`; export const DETECTION_ENGINE_RULES_STATUS_URL = `${DETECTION_ENGINE_RULES_URL}/_find_statuses`; export const DETECTION_ENGINE_PREPACKAGED_RULES_STATUS_URL = `${DETECTION_ENGINE_RULES_URL}/prepackaged/_status`; +export const DETECTION_ENGINE_RULES_BULK_ACTION = `${DETECTION_ENGINE_RULES_URL}/_bulk_action`; export const TIMELINE_URL = '/api/timeline'; export const TIMELINES_URL = '/api/timelines'; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts index 7b49b68ab79a1..c9a9d3bdcb24c 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts @@ -82,6 +82,8 @@ export const ruleIdOrUndefined = t.union([rule_id, t.undefined]); export type RuleIdOrUndefined = t.TypeOf; export const id = UUID; +export type Id = t.TypeOf; + export const idOrUndefined = t.union([id, t.undefined]); export type IdOrUndefined = t.TypeOf; @@ -408,3 +410,13 @@ export const privilege = t.type({ }); export type Privilege = t.TypeOf; + +export enum BulkAction { + 'enable' = 'enable', + 'disable' = 'disable', + 'export' = 'export', + 'delete' = 'delete', + 'duplicate' = 'duplicate', +} + +export const bulkAction = t.keyof(BulkAction); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/index.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/index.ts index 1035e9128305c..7722feb5f080d 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/index.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/index.ts @@ -17,3 +17,4 @@ export * from './query_signals_index_schema'; export * from './set_signal_status_schema'; export * from './update_rules_bulk_schema'; export * from './rule_schemas'; +export * from './perform_bulk_action_schema'; diff --git a/x-pack/plugins/security_solution/public/common/components/generic_downloader/translations.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.mock.ts similarity index 50% rename from x-pack/plugins/security_solution/public/common/components/generic_downloader/translations.ts rename to x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.mock.ts index 9104d4e7c0b45..cb78168fbec6e 100644 --- a/x-pack/plugins/security_solution/public/common/components/generic_downloader/translations.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.mock.ts @@ -5,11 +5,10 @@ * 2.0. */ -import { i18n } from '@kbn/i18n'; +import { BulkAction } from '../common/schemas'; +import { PerformBulkActionSchema } from './perform_bulk_action_schema'; -export const EXPORT_FAILURE = i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.components.genericDownloader.exportFailureTitle', - { - defaultMessage: 'Failed to export data…', - } -); +export const getPerformBulkActionSchemaMock = (): PerformBulkActionSchema => ({ + query: '', + action: BulkAction.disable, +}); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.test.ts new file mode 100644 index 0000000000000..a9707b88f5240 --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.test.ts @@ -0,0 +1,68 @@ +/* + * 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 { performBulkActionSchema, PerformBulkActionSchema } from './perform_bulk_action_schema'; +import { exactCheck, foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils'; +import { left } from 'fp-ts/lib/Either'; +import { BulkAction } from '../common/schemas'; + +describe('perform_bulk_action_schema', () => { + test('query and action is valid', () => { + const payload: PerformBulkActionSchema = { + query: 'name: test', + action: BulkAction.enable, + }; + + const decoded = performBulkActionSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = foldLeftRight(checked); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('missing query is valid', () => { + const payload: PerformBulkActionSchema = { + query: undefined, + action: BulkAction.enable, + }; + + const decoded = performBulkActionSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = foldLeftRight(checked); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('missing action is invalid', () => { + const payload: Omit = { + query: 'name: test', + }; + + const decoded = performBulkActionSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = foldLeftRight(checked); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "action"', + ]); + expect(message.schema).toEqual({}); + }); + + test('unknown action is invalid', () => { + const payload: Omit & { action: 'unknown' } = { + query: 'name: test', + action: 'unknown', + }; + + const decoded = performBulkActionSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = foldLeftRight(checked); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "unknown" supplied to "action"', + ]); + expect(message.schema).toEqual({}); + }); +}); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.ts new file mode 100644 index 0000000000000..adb26f107c8cd --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as t from 'io-ts'; +import { bulkAction, queryOrUndefined } from '../common/schemas'; + +export const performBulkActionSchema = t.exact( + t.type({ + query: queryOrUndefined, + action: bulkAction, + }) +); + +export type PerformBulkActionSchema = t.TypeOf; diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts index 2f98cb15287d6..8210c7c6d8b20 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts @@ -80,7 +80,7 @@ import { waitForAlertsPanelToBeLoaded, } from '../../tasks/alerts'; import { - changeRowsPerPageTo300, + changeRowsPerPageTo100, deleteFirstRule, deleteSelectedRules, editFirstRule, @@ -159,7 +159,7 @@ describe('Custom detection rules creation', () => { cy.get(CUSTOM_RULES_BTN).should('have.text', 'Custom rules (1)'); - changeRowsPerPageTo300(); + changeRowsPerPageTo100(); cy.get(RULES_TABLE).then(($table) => { cy.wrap($table.find(RULES_ROW).length).should('eql', expectedNumberOfRules); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/event_correlation_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/event_correlation_rule.spec.ts index 0dbecde3d4d3f..b38796cca373d 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/event_correlation_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/event_correlation_rule.spec.ts @@ -56,7 +56,7 @@ import { waitForAlertsPanelToBeLoaded, } from '../../tasks/alerts'; import { - changeRowsPerPageTo300, + changeRowsPerPageTo100, filterByCustomRules, goToCreateNewRule, goToRuleDetails, @@ -113,7 +113,7 @@ describe('Detection rules, EQL', () => { cy.get(CUSTOM_RULES_BTN).should('have.text', 'Custom rules (1)'); - changeRowsPerPageTo300(); + changeRowsPerPageTo100(); cy.get(RULES_TABLE).then(($table) => { cy.wrap($table.find(RULES_ROW).length).should('eql', expectedNumberOfRules); @@ -208,7 +208,7 @@ describe('Detection rules, sequence EQL', () => { cy.get(CUSTOM_RULES_BTN).should('have.text', 'Custom rules (1)'); - changeRowsPerPageTo300(); + changeRowsPerPageTo100(); cy.get(RULES_TABLE).then(($table) => { cy.wrap($table.find(RULES_ROW).length).should('eql', expectedNumberOfRules); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts index d0f2cd9f45743..bc8cf0137fa83 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts @@ -78,7 +78,7 @@ import { scrollJsonViewToBottom, } from '../../tasks/alerts_details'; import { - changeRowsPerPageTo300, + changeRowsPerPageTo100, duplicateFirstRule, duplicateSelectedRules, duplicateRuleFromMenu, @@ -424,7 +424,7 @@ describe('indicator match', () => { cy.get(CUSTOM_RULES_BTN).should('have.text', 'Custom rules (1)'); - changeRowsPerPageTo300(); + changeRowsPerPageTo100(); cy.get(RULES_TABLE).then(($table) => { cy.wrap($table.find(RULES_ROW).length).should('eql', expectedNumberOfRules); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/machine_learning_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/machine_learning_rule.spec.ts index 0fe1326947a12..65dde40bbd76b 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/machine_learning_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/machine_learning_rule.spec.ts @@ -46,7 +46,7 @@ import { waitForAlertsPanelToBeLoaded, } from '../../tasks/alerts'; import { - changeRowsPerPageTo300, + changeRowsPerPageTo100, filterByCustomRules, goToCreateNewRule, goToRuleDetails, @@ -90,7 +90,7 @@ describe('Detection rules, machine learning', () => { cy.get(CUSTOM_RULES_BTN).should('have.text', 'Custom rules (1)'); - changeRowsPerPageTo300(); + changeRowsPerPageTo100(); cy.get(RULES_TABLE).then(($table) => { cy.wrap($table.find(RULES_ROW).length).should('eql', expectedNumberOfRules); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/override.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/override.spec.ts index eb10f32bb8989..f9f1ca14c8164 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/override.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/override.spec.ts @@ -68,7 +68,7 @@ import { waitForAlertsPanelToBeLoaded, } from '../../tasks/alerts'; import { - changeRowsPerPageTo300, + changeRowsPerPageTo100, filterByCustomRules, goToCreateNewRule, goToRuleDetails, @@ -121,7 +121,7 @@ describe('Detection rules, override', () => { cy.get(CUSTOM_RULES_BTN).should('have.text', 'Custom rules (1)'); - changeRowsPerPageTo300(); + changeRowsPerPageTo100(); const expectedNumberOfRules = 1; cy.get(RULES_TABLE).then(($table) => { diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/prebuilt_rules.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/prebuilt_rules.spec.ts index fb0a01bd1c7d3..74e1d082ae410 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/prebuilt_rules.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/prebuilt_rules.spec.ts @@ -8,23 +8,29 @@ import { COLLAPSED_ACTION_BTN, ELASTIC_RULES_BTN, + pageSelector, RELOAD_PREBUILT_RULES_BTN, - RULES_ROW, - RULES_TABLE, + RULES_EMPTY_PROMPT, + RULE_SWITCH, SHOWING_RULES_TEXT, } from '../../screens/alerts_detection_rules'; import { goToManageAlertsDetectionRules, waitForAlertsIndexToBeCreated } from '../../tasks/alerts'; import { - changeRowsPerPageTo300, + changeRowsPerPageTo100, deleteFirstRule, deleteSelectedRules, loadPrebuiltDetectionRules, - goToNextPage, reloadDeletedRules, selectNumberOfRules, waitForRulesTableToBeLoaded, waitForPrebuiltDetectionRulesToBeLoaded, + selectAllRules, + confirmRulesDelete, + activateSelectedRules, + waitForRuleToChangeStatus, + deactivateSelectedRules, + changeRowsPerPageTo, } from '../../tasks/alerts_detection_rules'; import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; @@ -39,7 +45,9 @@ describe('Alerts rules, prebuilt rules', () => { }); it('Loads prebuilt rules', () => { + const rowsPerPage = 100; const expectedNumberOfRules = totalNumberOfPrebuiltRules; + const expectedNumberOfPages = Math.ceil(totalNumberOfPrebuiltRules / rowsPerPage); const expectedElasticRulesBtnText = `Elastic rules (${expectedNumberOfRules})`; loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); @@ -51,23 +59,14 @@ describe('Alerts rules, prebuilt rules', () => { cy.get(ELASTIC_RULES_BTN).should('have.text', expectedElasticRulesBtnText); - changeRowsPerPageTo300(); + changeRowsPerPageTo(rowsPerPage); cy.get(SHOWING_RULES_TEXT).should('have.text', `Showing ${expectedNumberOfRules} rules`); - cy.get(RULES_TABLE).then(($table1) => { - const firstScreenRules = $table1.find(RULES_ROW).length; - goToNextPage(); - cy.get(RULES_TABLE).then(($table2) => { - const secondScreenRules = $table2.find(RULES_ROW).length; - const totalNumberOfRules = firstScreenRules + secondScreenRules; - - expect(totalNumberOfRules).to.eql(expectedNumberOfRules); - }); - }); + cy.get(pageSelector(expectedNumberOfPages)).should('exist'); }); }); -describe('Deleting prebuilt rules', () => { +describe('Actions with prebuilt rules', () => { beforeEach(() => { const expectedNumberOfRules = totalNumberOfPrebuiltRules; const expectedElasticRulesBtnText = `Elastic rules (${expectedNumberOfRules})`; @@ -81,11 +80,30 @@ describe('Deleting prebuilt rules', () => { waitForPrebuiltDetectionRulesToBeLoaded(); cy.get(ELASTIC_RULES_BTN).should('have.text', expectedElasticRulesBtnText); + }); + + it('Allows to activate/deactivate all rules at once', () => { + selectAllRules(); + activateSelectedRules(); + waitForRuleToChangeStatus(); + cy.get(RULE_SWITCH).should('have.attr', 'aria-checked', 'true'); - changeRowsPerPageTo300(); + selectAllRules(); + deactivateSelectedRules(); + waitForRuleToChangeStatus(); + cy.get(RULE_SWITCH).should('have.attr', 'aria-checked', 'false'); + }); + + it('Allows to delete all rules at once', () => { + selectAllRules(); + deleteSelectedRules(); + confirmRulesDelete(); + cy.get(RULES_EMPTY_PROMPT).should('be.visible'); }); it('Does not allow to delete one rule when more than one is selected', () => { + changeRowsPerPageTo100(); + const numberOfRulesToBeSelected = 2; selectNumberOfRules(numberOfRulesToBeSelected); @@ -95,12 +113,14 @@ describe('Deleting prebuilt rules', () => { }); it('Deletes and recovers one rule', () => { + changeRowsPerPageTo100(); + const expectedNumberOfRulesAfterDeletion = totalNumberOfPrebuiltRules - 1; const expectedNumberOfRulesAfterRecovering = totalNumberOfPrebuiltRules; deleteFirstRule(); cy.reload(); - changeRowsPerPageTo300(); + changeRowsPerPageTo100(); cy.get(ELASTIC_RULES_BTN).should( 'have.text', @@ -114,7 +134,7 @@ describe('Deleting prebuilt rules', () => { cy.get(RELOAD_PREBUILT_RULES_BTN).should('not.exist'); cy.reload(); - changeRowsPerPageTo300(); + changeRowsPerPageTo100(); cy.get(ELASTIC_RULES_BTN).should( 'have.text', @@ -123,6 +143,8 @@ describe('Deleting prebuilt rules', () => { }); it('Deletes and recovers more than one rule', () => { + changeRowsPerPageTo100(); + const numberOfRulesToBeSelected = 2; const expectedNumberOfRulesAfterDeletion = totalNumberOfPrebuiltRules - 2; const expectedNumberOfRulesAfterRecovering = totalNumberOfPrebuiltRules; @@ -130,7 +152,7 @@ describe('Deleting prebuilt rules', () => { selectNumberOfRules(numberOfRulesToBeSelected); deleteSelectedRules(); cy.reload(); - changeRowsPerPageTo300(); + changeRowsPerPageTo100(); cy.get(RELOAD_PREBUILT_RULES_BTN).should('exist'); cy.get(RELOAD_PREBUILT_RULES_BTN).should( @@ -147,7 +169,7 @@ describe('Deleting prebuilt rules', () => { cy.get(RELOAD_PREBUILT_RULES_BTN).should('not.exist'); cy.reload(); - changeRowsPerPageTo300(); + changeRowsPerPageTo100(); cy.get(ELASTIC_RULES_BTN).should( 'have.text', diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/sorting.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/sorting.spec.ts index 0cf3caa09814c..f1ee0d39f545f 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/sorting.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/sorting.spec.ts @@ -31,7 +31,7 @@ import { resetAllRulesIdleModalTimeout, sortByActivatedRules, waitForRulesTableToBeLoaded, - waitForRuleToBeActivated, + waitForRuleToChangeStatus, } from '../../tasks/alerts_detection_rules'; import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; import { DEFAULT_RULE_REFRESH_INTERVAL_VALUE } from '../../../common/constants'; @@ -62,13 +62,13 @@ describe('Alerts detection rules', () => { .invoke('text') .then((secondInitialRuleName) => { activateRule(SECOND_RULE); - waitForRuleToBeActivated(); + waitForRuleToChangeStatus(); cy.get(RULE_NAME) .eq(FOURTH_RULE) .invoke('text') .then((fourthInitialRuleName) => { activateRule(FOURTH_RULE); - waitForRuleToBeActivated(); + waitForRuleToChangeStatus(); sortByActivatedRules(); cy.get(RULE_NAME) .eq(FIRST_RULE) diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/threshold_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/threshold_rule.spec.ts index 7c09b311807be..0f4095372f92a 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/threshold_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/threshold_rule.spec.ts @@ -59,7 +59,7 @@ import { waitForAlertsPanelToBeLoaded, } from '../../tasks/alerts'; import { - changeRowsPerPageTo300, + changeRowsPerPageTo100, filterByCustomRules, goToCreateNewRule, goToRuleDetails, @@ -113,7 +113,7 @@ describe('Detection rules, threshold', () => { cy.get(CUSTOM_RULES_BTN).should('have.text', 'Custom rules (1)'); - changeRowsPerPageTo300(); + changeRowsPerPageTo100(); const expectedNumberOfRules = 1; cy.get(RULES_TABLE).then(($table) => { diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts index 70dde344c88b6..ba071184d98eb 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts @@ -25,6 +25,12 @@ export const DUPLICATE_RULE_MENU_PANEL_BTN = '[data-test-subj="rules-details-dup export const REFRESH_BTN = '[data-test-subj="refreshRulesAction"] button'; +export const ACTIVATE_RULE_BULK_BTN = '[data-test-subj="activateRuleBulk"]'; + +export const DEACTIVATE_RULE_BULK_BTN = '[data-test-subj="deactivateRuleBulk"]'; + +export const EXPORT_RULE_BULK_BTN = '[data-test-subj="exportRuleBulk"]'; + export const DELETE_RULE_BULK_BTN = '[data-test-subj="deleteRuleBulk"]'; export const DUPLICATE_RULE_BULK_BTN = '[data-test-subj="duplicateRuleBulk"]'; @@ -87,3 +93,11 @@ export const pageSelector = (pageNumber: number) => `[data-test-subj="pagination-button-${pageNumber - 1}"]`; export const NEXT_BTN = '[data-test-subj="pagination-button-next"]'; + +export const SELECT_ALL_RULES_BTN = '[data-test-subj="selectAllRules"]'; + +export const RULES_EMPTY_PROMPT = '[data-test-subj="rulesEmptyPrompt"]'; + +export const RULES_DELETE_CONFIRMATION_MODAL = '[data-test-subj="allRulesDeleteConfirmationModal"]'; + +export const MODAL_CONFIRMATION_BTN = '[data-test-subj="confirmModalConfirmButton"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts index cc14c54a4d84e..78298c9881077 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts @@ -36,6 +36,12 @@ import { DUPLICATE_RULE_MENU_PANEL_BTN, DUPLICATE_RULE_BULK_BTN, RULES_ROW, + SELECT_ALL_RULES_BTN, + MODAL_CONFIRMATION_BTN, + RULES_DELETE_CONFIRMATION_MODAL, + ACTIVATE_RULE_BULK_BTN, + DEACTIVATE_RULE_BULK_BTN, + EXPORT_RULE_BULK_BTN, } from '../screens/alerts_detection_rules'; import { ALL_ACTIONS, DELETE_RULE } from '../screens/rule_details'; @@ -57,11 +63,6 @@ export const duplicateFirstRule = () => { cy.get(DUPLICATE_RULE_ACTION_BTN).click(); }; -export const duplicateSelectedRules = () => { - cy.get(BULK_ACTIONS_BTN).click({ force: true }); - cy.get(DUPLICATE_RULE_BULK_BTN).click(); -}; - /** * Duplicates the rule from the menu and does additional * pipes and checking that the elements are present on the @@ -106,6 +107,26 @@ export const deleteSelectedRules = () => { cy.get(DELETE_RULE_BULK_BTN).click(); }; +export const duplicateSelectedRules = () => { + cy.get(BULK_ACTIONS_BTN).click({ force: true }); + cy.get(DUPLICATE_RULE_BULK_BTN).click(); +}; + +export const activateSelectedRules = () => { + cy.get(BULK_ACTIONS_BTN).click({ force: true }); + cy.get(ACTIVATE_RULE_BULK_BTN).click(); +}; + +export const deactivateSelectedRules = () => { + cy.get(BULK_ACTIONS_BTN).click({ force: true }); + cy.get(DEACTIVATE_RULE_BULK_BTN).click(); +}; + +export const exportSelectedRules = () => { + cy.get(BULK_ACTIONS_BTN).click({ force: true }); + cy.get(EXPORT_RULE_BULK_BTN).click(); +}; + export const exportFirstRule = () => { cy.get(COLLAPSED_ACTION_BTN).first().click({ force: true }); cy.get(EXPORT_ACTION_BTN).click(); @@ -149,6 +170,17 @@ export const selectNumberOfRules = (numberOfRules: number) => { } }; +export const selectAllRules = () => { + cy.get(SELECT_ALL_RULES_BTN).contains('Select all').click(); + cy.get(SELECT_ALL_RULES_BTN).contains('Clear'); +}; + +export const confirmRulesDelete = () => { + cy.get(RULES_DELETE_CONFIRMATION_MODAL).should('be.visible'); + cy.get(MODAL_CONFIRMATION_BTN).click(); + cy.get(RULES_DELETE_CONFIRMATION_MODAL).should('not.exist'); +}; + export const sortByActivatedRules = () => { cy.get(SORT_RULES_BTN).contains('Activated').click({ force: true }); waitForRulesTableToBeRefreshed(); @@ -174,9 +206,10 @@ export const waitForRulesTableToBeAutoRefreshed = () => { export const waitForPrebuiltDetectionRulesToBeLoaded = () => { cy.get(LOAD_PREBUILT_RULES_BTN).should('not.exist'); cy.get(RULES_TABLE).should('exist'); + cy.get(RULES_TABLE_REFRESH_INDICATOR).should('not.exist'); }; -export const waitForRuleToBeActivated = () => { +export const waitForRuleToChangeStatus = () => { cy.get(RULE_SWITCH_LOADER).should('exist'); cy.get(RULE_SWITCH_LOADER).should('not.exist'); }; @@ -215,8 +248,8 @@ export const changeRowsPerPageTo = (rowsCount: number) => { waitForRulesTableToBeRefreshed(); }; -export const changeRowsPerPageTo300 = () => { - changeRowsPerPageTo(300); +export const changeRowsPerPageTo100 = () => { + changeRowsPerPageTo(100); }; export const goToPage = (pageNumber: number) => { diff --git a/x-pack/plugins/security_solution/public/common/components/generic_downloader/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/generic_downloader/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 219be8cbda311..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/generic_downloader/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,3 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`GenericDownloader renders correctly against snapshot 1`] = ``; diff --git a/x-pack/plugins/security_solution/public/common/components/generic_downloader/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/generic_downloader/index.test.tsx deleted file mode 100644 index b8066c836de72..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/generic_downloader/index.test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/* - * 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 { shallow, mount } from 'enzyme'; -import React from 'react'; -import { GenericDownloaderComponent, ExportSelectedData } from './index'; -import { errorToToaster } from '../toasters'; - -jest.mock('../toasters', () => ({ - useStateToaster: jest.fn(() => [jest.fn(), jest.fn()]), - errorToToaster: jest.fn(), -})); - -describe('GenericDownloader', () => { - test('renders correctly against snapshot', () => { - const wrapper = shallow( - - ); - expect(wrapper).toMatchSnapshot(); - }); - - test('show toaster with correct error message if error occurrs', () => { - mount( - - ); - expect((errorToToaster as jest.Mock).mock.calls[0][0].title).toEqual('Failed to export data…'); - }); -}); diff --git a/x-pack/plugins/security_solution/public/common/components/generic_downloader/index.tsx b/x-pack/plugins/security_solution/public/common/components/generic_downloader/index.tsx deleted file mode 100644 index 2a2e425702755..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/generic_downloader/index.tsx +++ /dev/null @@ -1,112 +0,0 @@ -/* - * 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, { useEffect, useRef } from 'react'; -import styled from 'styled-components'; -import { isFunction } from 'lodash/fp'; -import * as i18n from './translations'; - -import { ExportDocumentsProps } from '../../../detections/containers/detection_engine/rules'; -import { useStateToaster, errorToToaster } from '../toasters'; -import { TimelineErrorResponse } from '../../../../common/types/timeline'; - -const InvisibleAnchor = styled.a` - display: none; -`; - -export type ExportSelectedData = ({ - excludeExportDetails, - filename, - ids, - signal, -}: ExportDocumentsProps) => Promise; - -export interface GenericDownloaderProps { - filename: string; - ids?: string[]; - exportSelectedData: ExportSelectedData; - onExportSuccess?: (exportCount: number) => void; - onExportFailure?: () => void; -} - -/** - * Component for downloading Rules as an exported .ndjson file. Download will occur on each update to `rules` param - * - * @param filename of file to be downloaded - * @param payload Rule[] - * - */ - -export const GenericDownloaderComponent = ({ - exportSelectedData, - filename, - ids, - onExportSuccess, - onExportFailure, -}: GenericDownloaderProps) => { - const anchorRef = useRef(null); - const [, dispatchToaster] = useStateToaster(); - - useEffect(() => { - let isSubscribed = true; - const abortCtrl = new AbortController(); - - const exportData = async () => { - if (anchorRef && anchorRef.current && ids != null && ids.length > 0) { - try { - const exportResponse = await exportSelectedData({ - ids, - signal: abortCtrl.signal, - }); - - if (isSubscribed) { - // this is for supporting IE - if (isFunction(window.navigator.msSaveOrOpenBlob)) { - window.navigator.msSaveBlob(exportResponse); - } else { - const objectURL = window.URL.createObjectURL(exportResponse); - // These are safe-assignments as writes to anchorRef are isolated to exportData - anchorRef.current.href = objectURL; // eslint-disable-line require-atomic-updates - anchorRef.current.download = filename; // eslint-disable-line require-atomic-updates - anchorRef.current.click(); - - if (typeof window.URL.revokeObjectURL === 'function') { - window.URL.revokeObjectURL(objectURL); - } - } - if (onExportSuccess != null) { - onExportSuccess(ids.length); - } - } - } catch (error) { - if (isSubscribed) { - if (onExportFailure != null) { - onExportFailure(); - } - errorToToaster({ title: i18n.EXPORT_FAILURE, error, dispatchToaster }); - } - } - } - }; - - exportData(); - - return () => { - isSubscribed = false; - abortCtrl.abort(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ids]); - - return ; -}; - -GenericDownloaderComponent.displayName = 'GenericDownloaderComponent'; - -export const GenericDownloader = React.memo(GenericDownloaderComponent); - -GenericDownloader.displayName = 'GenericDownloader'; diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_bool_state.ts b/x-pack/plugins/security_solution/public/common/hooks/use_bool_state.ts new file mode 100644 index 0000000000000..f9204de38d680 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_bool_state.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. + */ + +import { useCallback, useState } from 'react'; + +type UseBoolStateReturn = [ + state: boolean, + setTrue: () => void, + setFalse: () => void, + toggle: () => void +]; + +export const useBoolState = (initial = false): UseBoolStateReturn => { + const [state, setState] = useState(initial); + + const setTrue = useCallback(() => { + setState(true); + }, []); + + const setFalse = useCallback(() => { + setState(false); + }, []); + + const toggle = useCallback(() => { + setState((val) => !val); + }, []); + + return [state, setTrue, setFalse, toggle]; +}; diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_value_changed.ts b/x-pack/plugins/security_solution/public/common/hooks/use_value_changed.ts new file mode 100644 index 0000000000000..ef054d0539757 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_value_changed.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useRef } from 'react'; + +/** + * Use this method to watch value for changes. + * + * CAUTION: you probably don't need this hook. Try to use useEffect first. + * It is only useful in rare cases when a value differs by reference but not by content between renders. + * + * @param callback A callback to call when the value changes + * @param nextValue A value to observe for changes + */ +export const useValueChanged = (callback: (value: T) => void, nextValue: T) => { + const prevValue = useRef(nextValue); + + useEffect(() => { + if (JSON.stringify(prevValue.current) !== JSON.stringify(nextValue)) { + prevValue.current = nextValue; + callback(nextValue); + } + }, [callback, nextValue]); +}; diff --git a/x-pack/plugins/security_solution/public/common/utils/download_blob.ts b/x-pack/plugins/security_solution/public/common/utils/download_blob.ts new file mode 100644 index 0000000000000..80f32a8bdaa0c --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/utils/download_blob.ts @@ -0,0 +1,23 @@ +/* + * 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. + */ + +/** + * Method for downloading any file + * + * @param blob raw data + * @param filename of file to be downloaded + * + */ +export const downloadBlob = (blob: Blob, filename: string) => { + const objectURL = window.URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = objectURL; + anchor.download = filename; + anchor.click(); + window.URL.revokeObjectURL(objectURL); + anchor.remove(); +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/all_rules_tables/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/all_rules_tables/index.test.tsx index 3400a960bbc60..d1dfd6ccfd565 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/all_rules_tables/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/all_rules_tables/index.test.tsx @@ -21,7 +21,7 @@ describe('AllRulesTables', () => { { { ; - hasNoPermissions: boolean; + hasPermissions: boolean; monitoringColumns: Array>; pagination: { pageIndex: number; @@ -55,7 +55,7 @@ const emptyPrompt = ( export const AllRulesTablesComponent: React.FC = ({ euiBasicTableSelectionProps, - hasNoPermissions, + hasPermissions, monitoringColumns, pagination, rules, @@ -72,7 +72,7 @@ export const AllRulesTablesComponent: React.FC = ({ = ({ pagination={pagination} ref={tableRef} sorting={sorting} - selection={hasNoPermissions ? undefined : euiBasicTableSelectionProps} + selection={hasPermissions ? euiBasicTableSelectionProps : undefined} /> )} {selectedTab === AllRulesTabs.monitoring && ( void; loading: boolean; - userHasNoPermissions: boolean; + userHasPermissions: boolean; } const PrePackagedRulesPromptComponent: React.FC = ({ createPrePackagedRules, loading = false, - userHasNoPermissions = true, + userHasPermissions = false, }) => { const history = useHistory(); const handlePreBuiltCreation = useCallback(() => { @@ -64,16 +64,17 @@ const PrePackagedRulesPromptComponent: React.FC = ( const loadPrebuiltRulesAndTemplatesButton = useMemo( () => getLoadPrebuiltRulesAndTemplatesButton({ - isDisabled: userHasNoPermissions, + isDisabled: !userHasPermissions, onClick: handlePreBuiltCreation, fill: true, 'data-test-subj': 'load-prebuilt-rules', }), - [getLoadPrebuiltRulesAndTemplatesButton, handlePreBuiltCreation, userHasNoPermissions] + [getLoadPrebuiltRulesAndTemplatesButton, handlePreBuiltCreation, userHasPermissions] ); return ( {i18n.PRE_BUILT_TITLE}} body={

{i18n.PRE_BUILT_MSG}

} actions={ @@ -81,7 +82,7 @@ const PrePackagedRulesPromptComponent: React.FC = ( {loadPrebuiltRulesAndTemplatesButton} - `; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx index 53f478da28055..3a27469ba2539 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.test.tsx @@ -28,6 +28,16 @@ jest.mock('../../../pages/detection_engine/rules/all/actions', () => ({ editRuleAction: jest.fn(), })); +jest.mock('../../../../common/lib/kibana', () => { + return { + KibanaServices: { + get: () => ({ + http: { fetch: jest.fn() }, + }), + }, + }; +}); + const duplicateRulesActionMock = duplicateRulesAction as jest.Mock; const flushPromises = () => new Promise(setImmediate); @@ -41,7 +51,7 @@ describe('RuleActionsOverflow', () => { const wrapper = shallow( ); @@ -54,7 +64,7 @@ describe('RuleActionsOverflow', () => { const wrapper = mount( ); @@ -70,11 +80,7 @@ describe('RuleActionsOverflow', () => { test('items are empty when there is a null rule within the rules-details-menu-panel', () => { const wrapper = mount( - + ); wrapper.find('[data-test-subj="rules-details-popover-button-icon"] button').simulate('click'); wrapper.update(); @@ -85,11 +91,7 @@ describe('RuleActionsOverflow', () => { test('items are empty when there is an undefined rule within the rules-details-menu-panel', () => { const wrapper = mount( - + ); wrapper.find('[data-test-subj="rules-details-popover-button-icon"] button').simulate('click'); wrapper.update(); @@ -102,7 +104,7 @@ describe('RuleActionsOverflow', () => { const wrapper = mount( ); @@ -119,7 +121,7 @@ describe('RuleActionsOverflow', () => { const wrapper = mount( ); @@ -137,7 +139,7 @@ describe('RuleActionsOverflow', () => { const wrapper = mount( ); @@ -152,7 +154,7 @@ describe('RuleActionsOverflow', () => { const wrapper = mount( ); @@ -167,7 +169,7 @@ describe('RuleActionsOverflow', () => { const wrapper = mount( ); @@ -184,7 +186,7 @@ describe('RuleActionsOverflow', () => { const wrapper = mount( ); @@ -198,11 +200,7 @@ describe('RuleActionsOverflow', () => { test('it calls duplicateRulesAction with the rule and rule.id when rules-details-duplicate-rule is clicked', () => { const rule = mockRule('id'); const wrapper = mount( - + ); wrapper.find('[data-test-subj="rules-details-popover-button-icon"] button').simulate('click'); wrapper.update(); @@ -222,11 +220,7 @@ describe('RuleActionsOverflow', () => { const ruleDuplicate = mockRule('newRule'); duplicateRulesActionMock.mockImplementation(() => Promise.resolve([ruleDuplicate])); const wrapper = mount( - + ); wrapper.find('[data-test-subj="rules-details-popover-button-icon"] button').simulate('click'); wrapper.update(); @@ -244,7 +238,7 @@ describe('RuleActionsOverflow', () => { const wrapper = mount( ); @@ -259,7 +253,7 @@ describe('RuleActionsOverflow', () => { const wrapper = mount( ); @@ -272,33 +266,11 @@ describe('RuleActionsOverflow', () => { ).toEqual(false); }); - test('it sets the rule.rule_id on the generic downloader when rules-details-export-rule is clicked', () => { - const rule = mockRule('id'); - const wrapper = mount( - - ); - wrapper.find('[data-test-subj="rules-details-popover-button-icon"] button').simulate('click'); - wrapper.update(); - wrapper.find('[data-test-subj="rules-details-export-rule"] button').simulate('click'); - wrapper.update(); - expect( - wrapper.find('[data-test-subj="rules-details-generic-downloader"]').prop('ids') - ).toEqual([rule.rule_id]); - }); - test('it does not close the pop over on rules-details-export-rule when the rule is an immutable rule and the user does a click', () => { const rule = mockRule('id'); rule.immutable = true; const wrapper = mount( - + ); wrapper.find('[data-test-subj="rules-details-popover-button-icon"] button').simulate('click'); wrapper.update(); @@ -308,25 +280,6 @@ describe('RuleActionsOverflow', () => { wrapper.find('[data-test-subj="rules-details-popover"]').first().prop('isOpen') ).toEqual(true); }); - - test('it does not set the rule.rule_id on rules-details-export-rule when the rule is an immutable rule', () => { - const rule = mockRule('id'); - rule.immutable = true; - const wrapper = mount( - - ); - wrapper.find('[data-test-subj="rules-details-popover-button-icon"] button').simulate('click'); - wrapper.update(); - wrapper.find('[data-test-subj="rules-details-export-rule"] button').simulate('click'); - wrapper.update(); - expect( - wrapper.find('[data-test-subj="rules-details-generic-downloader"]').prop('ids') - ).toEqual([]); - }); }); describe('rules details delete rule', () => { @@ -335,7 +288,7 @@ describe('RuleActionsOverflow', () => { const wrapper = mount( ); @@ -350,7 +303,7 @@ describe('RuleActionsOverflow', () => { const wrapper = mount( ); @@ -367,7 +320,7 @@ describe('RuleActionsOverflow', () => { const wrapper = mount( ); @@ -381,11 +334,7 @@ describe('RuleActionsOverflow', () => { test('it calls deleteRulesAction with the rule.id when rules-details-delete-rule is clicked', () => { const rule = mockRule('id'); const wrapper = mount( - + ); wrapper.find('[data-test-subj="rules-details-popover-button-icon"] button').simulate('click'); wrapper.update(); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx index 0482e1997c9d1..e0841824d512f 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_overflow/index.tsx @@ -12,23 +12,24 @@ import { EuiPopover, EuiToolTip, } from '@elastic/eui'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import { noop } from 'lodash/fp'; import { useHistory } from 'react-router-dom'; -import { Rule, exportRules } from '../../../containers/detection_engine/rules'; +import { Rule } from '../../../containers/detection_engine/rules'; import * as i18n from './translations'; import * as i18nActions from '../../../pages/detection_engine/rules/translations'; -import { displaySuccessToast, useStateToaster } from '../../../../common/components/toasters'; +import { useStateToaster } from '../../../../common/components/toasters'; import { deleteRulesAction, duplicateRulesAction, editRuleAction, + exportRulesAction, } from '../../../pages/detection_engine/rules/all/actions'; -import { GenericDownloader } from '../../../../common/components/generic_downloader'; import { getRulesUrl } from '../../../../common/components/link_to/redirect_to_detection_engine'; import { getToolTipContent } from '../../../../common/utils/privileges'; +import { useBoolState } from '../../../../common/hooks/use_bool_state'; const MyEuiButtonIcon = styled(EuiButtonIcon)` &.euiButtonIcon { @@ -43,7 +44,7 @@ const MyEuiButtonIcon = styled(EuiButtonIcon)` interface RuleActionsOverflowComponentProps { rule: Rule | null; - userHasNoPermissions: boolean; + userHasPermissions: boolean; canDuplicateRuleWithActions: boolean; } @@ -52,11 +53,10 @@ interface RuleActionsOverflowComponentProps { */ const RuleActionsOverflowComponent = ({ rule, - userHasNoPermissions, + userHasPermissions, canDuplicateRuleWithActions, }: RuleActionsOverflowComponentProps) => { - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const [rulesToExport, setRulesToExport] = useState([]); + const [isPopoverOpen, , closePopover, togglePopover] = useBoolState(); const history = useHistory(); const [, dispatchToaster] = useStateToaster(); @@ -71,10 +71,10 @@ const RuleActionsOverflowComponent = ({ { - setIsPopoverOpen(false); + closePopover(); const createdRules = await duplicateRulesAction( [rule], [rule.id], @@ -96,11 +96,11 @@ const RuleActionsOverflowComponent = ({ { - setIsPopoverOpen(false); - setRulesToExport([rule.rule_id]); + onClick={async () => { + closePopover(); + await exportRulesAction([rule.rule_id], noop, dispatchToaster); }} > {i18nActions.EXPORT_RULE} @@ -108,10 +108,10 @@ const RuleActionsOverflowComponent = ({ { - setIsPopoverOpen(false); + closePopover(); await deleteRulesAction([rule.id], noop, dispatchToaster, onRuleDeletedCallback); }} > @@ -119,27 +119,30 @@ const RuleActionsOverflowComponent = ({ , ] : [], - // eslint-disable-next-line react-hooks/exhaustive-deps - [rule, userHasNoPermissions] + [ + canDuplicateRuleWithActions, + closePopover, + dispatchToaster, + history, + onRuleDeletedCallback, + rule, + userHasPermissions, + ] ); - const handlePopoverOpen = useCallback(() => { - setIsPopoverOpen(!isPopoverOpen); - }, [setIsPopoverOpen, isPopoverOpen]); - const button = useMemo( () => ( ), - [handlePopoverOpen, userHasNoPermissions] + [togglePopover, userHasPermissions] ); return ( @@ -147,7 +150,7 @@ const RuleActionsOverflowComponent = ({ setIsPopoverOpen(false)} + closePopover={closePopover} id="ruleActionsOverflow" isOpen={isPopoverOpen} data-test-subj="rules-details-popover" @@ -157,18 +160,6 @@ const RuleActionsOverflowComponent = ({ > - { - displaySuccessToast( - i18nActions.SUCCESSFULLY_EXPORTED_RULES(exportCount), - dispatchToaster - ); - }} - /> ); }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts index d4c4e10813172..7de91a07a68a0 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts @@ -13,6 +13,7 @@ import { DETECTION_ENGINE_RULES_STATUS_URL, DETECTION_ENGINE_PREPACKAGED_RULES_STATUS_URL, DETECTION_ENGINE_TAGS_URL, + DETECTION_ENGINE_RULES_BULK_ACTION, } from '../../../../../common/constants'; import { UpdateRulesProps, @@ -32,10 +33,14 @@ import { PrePackagedRulesStatusResponse, BulkRuleResponse, PatchRuleProps, + BulkActionProps, + BulkActionResponse, } from './types'; import { KibanaServices } from '../../../../common/lib/kibana'; import * as i18n from '../../../pages/detection_engine/rules/translations'; import { RulesSchema } from '../../../../../common/detection_engine/schemas/response'; +import { convertRulesFilterToKQL } from './utils'; +import { BulkAction } from '../../../../../common/detection_engine/schemas/common/schemas'; /** * Create provided Rule @@ -110,26 +115,7 @@ export const fetchRules = async ({ }, signal, }: FetchRulesProps): Promise => { - const showCustomRuleFilter = filterOptions.showCustomRules - ? [`alert.attributes.tags: "__internal_immutable:false"`] - : []; - const showElasticRuleFilter = filterOptions.showElasticRules - ? [`alert.attributes.tags: "__internal_immutable:true"`] - : []; - const filtersWithoutTags = [ - ...(filterOptions.filter.length ? [`alert.attributes.name: ${filterOptions.filter}`] : []), - ...showCustomRuleFilter, - ...showElasticRuleFilter, - ].join(' AND '); - - const tags = filterOptions.tags - .map((t) => `alert.attributes.tags: "${t.replace(/"/g, '\\"')}"`) - .join(' AND '); - - const filterString = - filtersWithoutTags !== '' && tags !== '' - ? `${filtersWithoutTags} AND (${tags})` - : filtersWithoutTags + tags; + const filterString = convertRulesFilterToKQL(filterOptions); const getFieldNameForSortField = (field: string) => { return field === 'name' ? `${field}.keyword` : field; @@ -243,6 +229,23 @@ export const duplicateRules = async ({ rules }: DuplicateRulesProps): Promise({ + action, + query, +}: BulkActionProps): Promise> => + KibanaServices.get().http.fetch>(DETECTION_ENGINE_RULES_BULK_ACTION, { + method: 'POST', + body: JSON.stringify({ action, query }), + }); + /** * Create Prepackaged Rules * diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_reducer.test.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_reducer.test.ts index 60edeaf0de983..2a983117db524 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_reducer.test.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_reducer.test.ts @@ -7,7 +7,7 @@ import { mockRule } from '../../../../pages/detection_engine/rules/all/__mocks__/mock'; import { FilterOptions, PaginationOptions } from '../types'; -import { RulesTableAction, RulesTableState, createRulesTableReducer } from './rules_table_reducer'; +import { RulesTableState, rulesTableReducer } from './rules_table_reducer'; const initialState: RulesTableState = { rules: [], @@ -24,10 +24,10 @@ const initialState: RulesTableState = { showCustomRules: false, showElasticRules: false, }, + isAllSelected: false, loadingRulesAction: null, loadingRuleIds: [], selectedRuleIds: [], - exportRuleIds: [], lastUpdated: 0, isRefreshOn: false, isRefreshing: false, @@ -35,36 +35,20 @@ const initialState: RulesTableState = { }; describe('allRulesReducer', () => { - let reducer: (state: RulesTableState, action: RulesTableAction) => RulesTableState; - beforeEach(() => { jest.useFakeTimers(); jest .spyOn(global.Date, 'now') .mockImplementationOnce(() => new Date('2020-10-31T11:01:58.135Z').valueOf()); - reducer = createRulesTableReducer({ current: null }); }); afterEach(() => { jest.clearAllMocks(); }); - describe('#exportRuleIds', () => { - test('should update state with rules to be exported', () => { - const { loadingRuleIds, loadingRulesAction, exportRuleIds } = reducer(initialState, { - type: 'exportRuleIds', - ids: ['123', '456'], - }); - - expect(loadingRuleIds).toEqual(['123', '456']); - expect(exportRuleIds).toEqual(['123', '456']); - expect(loadingRulesAction).toEqual('export'); - }); - }); - describe('#loadingRuleIds', () => { - test('should update state with rule ids with a pending action', () => { - const { loadingRuleIds, loadingRulesAction } = reducer(initialState, { + it('should update state with rule ids with a pending action', () => { + const { loadingRuleIds, loadingRulesAction } = rulesTableReducer(initialState, { type: 'loadingRuleIds', ids: ['123', '456'], actionType: 'enable', @@ -74,8 +58,8 @@ describe('allRulesReducer', () => { expect(loadingRulesAction).toEqual('enable'); }); - test('should update loadingIds to empty array if action is null', () => { - const { loadingRuleIds, loadingRulesAction } = reducer(initialState, { + it('should update loadingIds to empty array if action is null', () => { + const { loadingRuleIds, loadingRulesAction } = rulesTableReducer(initialState, { type: 'loadingRuleIds', ids: ['123', '456'], actionType: null, @@ -85,8 +69,8 @@ describe('allRulesReducer', () => { expect(loadingRulesAction).toBeNull(); }); - test('should append rule ids to any existing loading ids', () => { - const { loadingRuleIds, loadingRulesAction } = reducer( + it('should append rule ids to any existing loading ids', () => { + const { loadingRuleIds, loadingRulesAction } = rulesTableReducer( { ...initialState, loadingRuleIds: ['abc'] }, { type: 'loadingRuleIds', @@ -101,8 +85,8 @@ describe('allRulesReducer', () => { }); describe('#selectedRuleIds', () => { - test('should update state with selected rule ids', () => { - const { selectedRuleIds } = reducer(initialState, { + it('should update state with selected rule ids', () => { + const { selectedRuleIds } = rulesTableReducer(initialState, { type: 'selectedRuleIds', ids: ['123', '456'], }); @@ -112,19 +96,22 @@ describe('allRulesReducer', () => { }); describe('#setRules', () => { - test('should update rules and reset loading/selected rule ids', () => { - const { selectedRuleIds, loadingRuleIds, loadingRulesAction, pagination, rules } = reducer( - initialState, - { - type: 'setRules', - rules: [mockRule('someRuleId')], - pagination: { - page: 1, - perPage: 20, - total: 0, - }, - } - ); + it('should update rules and reset loading/selected rule ids', () => { + const { + selectedRuleIds, + loadingRuleIds, + loadingRulesAction, + pagination, + rules, + } = rulesTableReducer(initialState, { + type: 'setRules', + rules: [mockRule('someRuleId')], + pagination: { + page: 1, + perPage: 20, + total: 0, + }, + }); expect(rules).toEqual([mockRule('someRuleId')]); expect(selectedRuleIds).toEqual([]); @@ -139,9 +126,9 @@ describe('allRulesReducer', () => { }); describe('#updateRules', () => { - test('should return existing and new rules', () => { + it('should return existing and new rules', () => { const existingRule = { ...mockRule('123'), rule_id: 'rule-123' }; - const { rules, loadingRulesAction } = reducer( + const { rules, loadingRulesAction } = rulesTableReducer( { ...initialState, rules: [existingRule] }, { type: 'updateRules', @@ -153,9 +140,9 @@ describe('allRulesReducer', () => { expect(loadingRulesAction).toBeNull(); }); - test('should return updated rule', () => { + it('should return updated rule', () => { const updatedRule = { ...mockRule('someRuleId'), description: 'updated rule' }; - const { rules, loadingRulesAction } = reducer( + const { rules, loadingRulesAction } = rulesTableReducer( { ...initialState, rules: [mockRule('someRuleId')] }, { type: 'updateRules', @@ -167,9 +154,9 @@ describe('allRulesReducer', () => { expect(loadingRulesAction).toBeNull(); }); - test('should return updated existing loading rule ids', () => { + it('should return updated existing loading rule ids', () => { const existingRule = { ...mockRule('someRuleId'), id: '123', rule_id: 'rule-123' }; - const { loadingRuleIds, loadingRulesAction } = reducer( + const { loadingRuleIds, loadingRulesAction } = rulesTableReducer( { ...initialState, rules: [existingRule], @@ -188,7 +175,7 @@ describe('allRulesReducer', () => { }); describe('#updateFilterOptions', () => { - test('should return existing and new rules', () => { + it('should return existing and new rules', () => { const paginationMock: PaginationOptions = { page: 1, perPage: 20, @@ -202,7 +189,7 @@ describe('allRulesReducer', () => { showCustomRules: false, showElasticRules: false, }; - const { filterOptions, pagination } = reducer(initialState, { + const { filterOptions, pagination } = rulesTableReducer(initialState, { type: 'updateFilterOptions', filterOptions: filterMock, pagination: paginationMock, @@ -214,8 +201,8 @@ describe('allRulesReducer', () => { }); describe('#failure', () => { - test('should reset rules value to empty array', () => { - const { rules } = reducer(initialState, { + it('should reset rules value to empty array', () => { + const { rules } = rulesTableReducer(initialState, { type: 'failure', }); @@ -224,8 +211,8 @@ describe('allRulesReducer', () => { }); describe('#setLastRefreshDate', () => { - test('should update last refresh date with current date', () => { - const { lastUpdated } = reducer(initialState, { + it('should update last refresh date with current date', () => { + const { lastUpdated } = rulesTableReducer(initialState, { type: 'setLastRefreshDate', }); @@ -234,8 +221,8 @@ describe('allRulesReducer', () => { }); describe('#setShowIdleModal', () => { - test('should hide idle modal and restart refresh if "show" is false', () => { - const { showIdleModal, isRefreshOn } = reducer(initialState, { + it('should hide idle modal and restart refresh if "show" is false', () => { + const { showIdleModal, isRefreshOn } = rulesTableReducer(initialState, { type: 'setShowIdleModal', show: false, }); @@ -244,8 +231,8 @@ describe('allRulesReducer', () => { expect(isRefreshOn).toBeTruthy(); }); - test('should show idle modal and pause refresh if "show" is true', () => { - const { showIdleModal, isRefreshOn } = reducer(initialState, { + it('should show idle modal and pause refresh if "show" is true', () => { + const { showIdleModal, isRefreshOn } = rulesTableReducer(initialState, { type: 'setShowIdleModal', show: true, }); @@ -256,8 +243,8 @@ describe('allRulesReducer', () => { }); describe('#setAutoRefreshOn', () => { - test('should pause auto refresh if "paused" is true', () => { - const { isRefreshOn } = reducer(initialState, { + it('should pause auto refresh if "paused" is true', () => { + const { isRefreshOn } = rulesTableReducer(initialState, { type: 'setAutoRefreshOn', on: true, }); @@ -265,8 +252,8 @@ describe('allRulesReducer', () => { expect(isRefreshOn).toBeTruthy(); }); - test('should resume auto refresh if "paused" is false', () => { - const { isRefreshOn } = reducer(initialState, { + it('should resume auto refresh if "paused" is false', () => { + const { isRefreshOn } = rulesTableReducer(initialState, { type: 'setAutoRefreshOn', on: false, }); @@ -274,4 +261,58 @@ describe('allRulesReducer', () => { expect(isRefreshOn).toBeFalsy(); }); }); + + describe('#selectAllRules', () => { + it('should select all rules', () => { + const state = rulesTableReducer( + { + ...initialState, + rules: [mockRule('1'), mockRule('2'), mockRule('3')], + }, + { + type: 'setIsAllSelected', + isAllSelected: true, + } + ); + + expect(state.isAllSelected).toBe(true); + expect(state.selectedRuleIds).toEqual(['1', '2', '3']); + }); + + it('should deselect all rules', () => { + const state = rulesTableReducer( + { + ...initialState, + rules: [mockRule('1'), mockRule('2'), mockRule('3')], + isAllSelected: true, + selectedRuleIds: ['1', '2', '3'], + }, + { + type: 'setIsAllSelected', + isAllSelected: false, + } + ); + + expect(state.isAllSelected).toBe(false); + expect(state.selectedRuleIds).toEqual([]); + }); + + it('should unset "isAllSelected" on selected rules modification', () => { + const state = rulesTableReducer( + { + ...initialState, + rules: [mockRule('1'), mockRule('2'), mockRule('3')], + isAllSelected: true, + selectedRuleIds: ['1', '2', '3'], + }, + { + type: 'selectedRuleIds', + ids: ['1', '2'], + } + ); + + expect(state.isAllSelected).toBe(false); + expect(state.selectedRuleIds).toEqual(['1', '2']); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_reducer.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_reducer.ts index 01a87fef2b723..7d32785222fed 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_reducer.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_reducer.ts @@ -5,8 +5,6 @@ * 2.0. */ -import type React from 'react'; -import { EuiBasicTable } from '@elastic/eui'; import { FilterOptions, PaginationOptions, Rule } from '../types'; export type LoadingRuleAction = @@ -25,11 +23,11 @@ export interface RulesTableState { loadingRulesAction: LoadingRuleAction; loadingRuleIds: string[]; selectedRuleIds: string[]; - exportRuleIds: string[]; lastUpdated: number; isRefreshOn: boolean; isRefreshing: boolean; showIdleModal: boolean; + isAllSelected: boolean; } export type RulesTableAction = @@ -42,128 +40,119 @@ export type RulesTableAction = } | { type: 'loadingRuleIds'; ids: string[]; actionType: LoadingRuleAction } | { type: 'selectedRuleIds'; ids: string[] } - | { type: 'exportRuleIds'; ids: string[] } | { type: 'setLastRefreshDate' } | { type: 'setAutoRefreshOn'; on: boolean } | { type: 'setIsRefreshing'; isRefreshing: boolean } + | { type: 'setIsAllSelected'; isAllSelected: boolean } | { type: 'setShowIdleModal'; show: boolean } | { type: 'failure' }; -export const createRulesTableReducer = ( - tableRef: React.MutableRefObject | null> -) => { - const rulesTableReducer = (state: RulesTableState, action: RulesTableAction): RulesTableState => { - switch (action.type) { - case 'setRules': { - if (tableRef?.current?.changeSelection != null) { - // for future devs: eui basic table is not giving us a prop to set the value, so - // we are using the ref in setTimeout to reset on the next loop so that we - // do not get a warning telling us we are trying to update during a render - window.setTimeout(() => tableRef?.current?.changeSelection([]), 0); +export const rulesTableReducer = ( + state: RulesTableState, + action: RulesTableAction +): RulesTableState => { + switch (action.type) { + case 'setRules': { + return { + ...state, + rules: action.rules, + selectedRuleIds: state.isAllSelected ? action.rules.map(({ id }) => id) : [], + loadingRuleIds: [], + loadingRulesAction: null, + pagination: { + ...state.pagination, + ...action.pagination, + }, + }; + } + case 'updateRules': { + const ruleIds = state.rules.map((r) => r.id); + const updatedRules = action.rules.reduce((rules, updatedRule) => { + let newRules = rules; + if (ruleIds.includes(updatedRule.id)) { + newRules = newRules.map((r) => (updatedRule.id === r.id ? updatedRule : r)); + } else { + newRules = [...newRules, updatedRule]; } - - return { - ...state, - rules: action.rules, - selectedRuleIds: [], - loadingRuleIds: [], - loadingRulesAction: null, - pagination: { - ...state.pagination, - ...action.pagination, - }, - }; - } - case 'updateRules': { - const ruleIds = state.rules.map((r) => r.id); - const updatedRules = action.rules.reduce((rules, updatedRule) => { - let newRules = rules; - if (ruleIds.includes(updatedRule.id)) { - newRules = newRules.map((r) => (updatedRule.id === r.id ? updatedRule : r)); - } else { - newRules = [...newRules, updatedRule]; - } - return newRules; - }, state.rules); - const updatedRuleIds = action.rules.map((r) => r.id); - const newLoadingRuleIds = state.loadingRuleIds.filter((id) => !updatedRuleIds.includes(id)); - return { - ...state, - rules: updatedRules, - loadingRuleIds: newLoadingRuleIds, - loadingRulesAction: newLoadingRuleIds.length === 0 ? null : state.loadingRulesAction, - }; - } - case 'updateFilterOptions': { - return { - ...state, - filterOptions: { - ...state.filterOptions, - ...action.filterOptions, - }, - pagination: { - ...state.pagination, - ...action.pagination, - }, - }; - } - case 'loadingRuleIds': { - return { - ...state, - loadingRuleIds: action.actionType == null ? [] : [...state.loadingRuleIds, ...action.ids], - loadingRulesAction: action.actionType, - }; - } - case 'selectedRuleIds': { - return { - ...state, - selectedRuleIds: action.ids, - }; - } - case 'exportRuleIds': { - return { - ...state, - loadingRuleIds: action.ids, - loadingRulesAction: 'export', - exportRuleIds: action.ids, - }; - } - case 'setLastRefreshDate': { - return { - ...state, - lastUpdated: Date.now(), - }; - } - case 'setAutoRefreshOn': { - return { - ...state, - isRefreshOn: action.on, - }; - } - case 'setIsRefreshing': { - return { - ...state, - isRefreshing: action.isRefreshing, - }; - } - case 'setShowIdleModal': { - return { - ...state, - showIdleModal: action.show, - isRefreshOn: !action.show, - }; - } - case 'failure': { - return { - ...state, - rules: [], - }; - } - default: { - return state; - } + return newRules; + }, state.rules); + const updatedRuleIds = action.rules.map((r) => r.id); + const newLoadingRuleIds = state.loadingRuleIds.filter((id) => !updatedRuleIds.includes(id)); + return { + ...state, + rules: updatedRules, + loadingRuleIds: newLoadingRuleIds, + loadingRulesAction: newLoadingRuleIds.length === 0 ? null : state.loadingRulesAction, + }; } - }; - - return rulesTableReducer; + case 'updateFilterOptions': { + return { + ...state, + filterOptions: { + ...state.filterOptions, + ...action.filterOptions, + }, + pagination: { + ...state.pagination, + ...action.pagination, + }, + }; + } + case 'loadingRuleIds': { + return { + ...state, + loadingRuleIds: action.actionType == null ? [] : [...state.loadingRuleIds, ...action.ids], + loadingRulesAction: action.actionType, + }; + } + case 'selectedRuleIds': { + return { + ...state, + isAllSelected: false, + selectedRuleIds: action.ids, + }; + } + case 'setLastRefreshDate': { + return { + ...state, + lastUpdated: Date.now(), + }; + } + case 'setAutoRefreshOn': { + return { + ...state, + isRefreshOn: action.on, + }; + } + case 'setIsRefreshing': { + return { + ...state, + isRefreshing: action.isRefreshing, + }; + } + case 'setIsAllSelected': { + const { isAllSelected } = action; + return { + ...state, + isAllSelected, + selectedRuleIds: isAllSelected ? state.rules.map(({ id }) => id) : [], + }; + } + case 'setShowIdleModal': { + return { + ...state, + showIdleModal: action.show, + isRefreshOn: !action.show, + }; + } + case 'failure': { + return { + ...state, + rules: [], + }; + } + default: { + return state; + } + } }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/use_async_confirmation.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/use_async_confirmation.ts new file mode 100644 index 0000000000000..cce45f87d8ce3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/use_async_confirmation.ts @@ -0,0 +1,46 @@ +/* + * 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 { useCallback, useRef } from 'react'; + +type UseAsyncConfirmationReturn = [ + initConfirmation: () => Promise, + confirm: () => void, + cancel: () => void +]; + +interface UseAsyncConfirmationArgs { + onInit: () => void; + onFinish: () => void; +} + +export const useAsyncConfirmation = ({ + onInit, + onFinish, +}: UseAsyncConfirmationArgs): UseAsyncConfirmationReturn => { + const confirmationPromiseRef = useRef<(result: boolean) => void>(); + + const confirm = useCallback(() => { + confirmationPromiseRef.current?.(true); + }, []); + + const cancel = useCallback(() => { + confirmationPromiseRef.current?.(false); + }, []); + + const initConfirmation = useCallback(() => { + onInit(); + + return new Promise((resolve) => { + confirmationPromiseRef.current = resolve; + }).finally(() => { + onFinish(); + }); + }, [onInit, onFinish]); + + return [initConfirmation, confirm, cancel]; +}; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/use_rules_table.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/use_rules_table.ts index 8969843f61a1c..cb41401ee2f40 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/use_rules_table.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/use_rules_table.ts @@ -5,14 +5,11 @@ * 2.0. */ -import { Dispatch, useMemo, useReducer, useEffect, useRef } from 'react'; -import { EuiBasicTable } from '@elastic/eui'; - +import { Dispatch, useReducer, useEffect, useRef } from 'react'; import { useAppToasts } from '../../../../../common/hooks/use_app_toasts'; import * as i18n from '../translations'; - import { fetchRules } from '../api'; -import { createRulesTableReducer, RulesTableState, RulesTableAction } from './rules_table_reducer'; +import { rulesTableReducer, RulesTableState, RulesTableAction } from './rules_table_reducer'; import { createRulesTableFacade, RulesTableFacade } from './rules_table_facade'; const INITIAL_SORT_FIELD = 'enabled'; @@ -35,15 +32,14 @@ const initialStateDefaults: RulesTableState = { loadingRulesAction: null, loadingRuleIds: [], selectedRuleIds: [], - exportRuleIds: [], lastUpdated: 0, isRefreshOn: true, isRefreshing: false, + isAllSelected: false, showIdleModal: false, }; export interface UseRulesTableParams { - tableRef: React.MutableRefObject | null>; initialStateOverride?: Partial; } @@ -54,7 +50,7 @@ export interface UseRulesTableReturn extends RulesTableFacade { } export const useRulesTable = (params: UseRulesTableParams): UseRulesTableReturn => { - const { tableRef, initialStateOverride } = params; + const { initialStateOverride } = params; const initialState: RulesTableState = { ...initialStateDefaults, @@ -62,8 +58,7 @@ export const useRulesTable = (params: UseRulesTableParams): UseRulesTableReturn ...initialStateOverride, }; - const reducer = useMemo(() => createRulesTableReducer(tableRef), [tableRef]); - const [state, dispatch] = useReducer(reducer, initialState); + const [state, dispatch] = useReducer(rulesTableReducer, initialState); const facade = useRef(createRulesTableFacade(dispatch)); const { addError } = useAppToasts(); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts index 85ff0f9ac1457..20bdeaf7e6378 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts @@ -28,6 +28,7 @@ import { rule_name_override, timestamp_override, threshold, + BulkAction, } from '../../../../../common/detection_engine/schemas/common/schemas'; import { CreateRulesSchema, @@ -212,6 +213,24 @@ export interface DuplicateRulesProps { rules: Rule[]; } +export interface BulkActionProps { + action: Action; + query: string; +} + +export interface BulkActionResult { + success: boolean; + rules_count: number; +} + +export type BulkActionResponse = { + [BulkAction.delete]: BulkActionResult; + [BulkAction.disable]: BulkActionResult; + [BulkAction.enable]: BulkActionResult; + [BulkAction.duplicate]: BulkActionResult; + [BulkAction.export]: Blob; +}[Action]; + export interface BasicFetchProps { signal: AbortSignal; } @@ -248,7 +267,7 @@ export interface ExportDocumentsProps { ids: string[]; filename?: string; excludeExportDetails?: boolean; - signal: AbortSignal; + signal?: AbortSignal; } export interface RuleStatus { diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/utils.test.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/utils.test.ts new file mode 100644 index 0000000000000..c293e26f1740c --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/utils.test.ts @@ -0,0 +1,64 @@ +/* + * 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 { INTERNAL_IMMUTABLE_KEY } from '../../../../../common/constants'; +import { FilterOptions } from './types'; +import { convertRulesFilterToKQL } from './utils'; + +describe('convertRulesFilterToKQL', () => { + const filterOptions: FilterOptions = { + filter: '', + sortField: 'name', + sortOrder: 'asc', + showCustomRules: false, + showElasticRules: false, + tags: [], + }; + + it('returns empty string if filter options are empty', () => { + const kql = convertRulesFilterToKQL(filterOptions); + + expect(kql).toBe(''); + }); + + it('handles presence of "filter" properly', () => { + const kql = convertRulesFilterToKQL({ ...filterOptions, filter: 'foo' }); + + expect(kql).toBe('alert.attributes.name: foo'); + }); + + it('handles presence of "showCustomRules" properly', () => { + const kql = convertRulesFilterToKQL({ ...filterOptions, showCustomRules: true }); + + expect(kql).toBe(`alert.attributes.tags: "${INTERNAL_IMMUTABLE_KEY}:false"`); + }); + + it('handles presence of "showElasticRules" properly', () => { + const kql = convertRulesFilterToKQL({ ...filterOptions, showElasticRules: true }); + + expect(kql).toBe(`alert.attributes.tags: "${INTERNAL_IMMUTABLE_KEY}:true"`); + }); + + it('handles presence of "tags" properly', () => { + const kql = convertRulesFilterToKQL({ ...filterOptions, tags: ['tag1', 'tag2'] }); + + expect(kql).toBe('alert.attributes.tags: "tag1" AND alert.attributes.tags: "tag2"'); + }); + + it('handles combination of different properties properly', () => { + const kql = convertRulesFilterToKQL({ + ...filterOptions, + filter: 'foo', + showElasticRules: true, + tags: ['tag1', 'tag2'], + }); + + expect(kql).toBe( + `alert.attributes.name: foo AND alert.attributes.tags: "${INTERNAL_IMMUTABLE_KEY}:true" AND (alert.attributes.tags: "tag1" AND alert.attributes.tags: "tag2")` + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/utils.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/utils.ts new file mode 100644 index 0000000000000..841b2adca09e0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/utils.ts @@ -0,0 +1,41 @@ +/* + * 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 { INTERNAL_IMMUTABLE_KEY } from '../../../../../common/constants'; +import { FilterOptions } from './types'; + +/** + * Convert rules filter options object to KQL query + * + * @param filterOptions desired filters (e.g. filter/sortField/sortOrder) + * + * @returns KQL string + */ +export const convertRulesFilterToKQL = (filterOptions: FilterOptions): string => { + const showCustomRuleFilter = filterOptions.showCustomRules + ? [`alert.attributes.tags: "${INTERNAL_IMMUTABLE_KEY}:false"`] + : []; + const showElasticRuleFilter = filterOptions.showElasticRules + ? [`alert.attributes.tags: "${INTERNAL_IMMUTABLE_KEY}:true"`] + : []; + const filtersWithoutTags = [ + ...(filterOptions.filter.length ? [`alert.attributes.name: ${filterOptions.filter}`] : []), + ...showCustomRuleFilter, + ...showElasticRuleFilter, + ].join(' AND '); + + const tags = filterOptions.tags + .map((t) => `alert.attributes.tags: "${t.replace(/"/g, '\\"')}"`) + .join(' AND '); + + const filterString = + filtersWithoutTags !== '' && tags !== '' + ? `${filtersWithoutTags} AND (${tags})` + : filtersWithoutTags + tags; + + return filterString; +}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.tsx index de33d414398a8..78fac10815d45 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/actions.tsx @@ -7,29 +7,29 @@ import * as H from 'history'; import React, { Dispatch } from 'react'; - +import { BulkAction } from '../../../../../../common/detection_engine/schemas/common/schemas'; import { CreateRulesSchema } from '../../../../../../common/detection_engine/schemas/request'; -import { - deleteRules, - duplicateRules, - enableRules, - Rule, - RulesTableAction, -} from '../../../../containers/detection_engine/rules'; - import { getEditRuleUrl } from '../../../../../common/components/link_to/redirect_to_detection_engine'; - import { ActionToaster, displayErrorToast, displaySuccessToast, errorToToaster, } from '../../../../../common/components/toasters'; -import { track, METRIC_TYPE, TELEMETRY_EVENT } from '../../../../../common/lib/telemetry'; - -import * as i18n from '../translations'; -import { bucketRulesResponse } from './helpers'; +import { METRIC_TYPE, TELEMETRY_EVENT, track } from '../../../../../common/lib/telemetry'; +import { downloadBlob } from '../../../../../common/utils/download_blob'; +import { + deleteRules, + duplicateRules, + enableRules, + exportRules, + performBulkAction, + Rule, + RulesTableAction, +} from '../../../../containers/detection_engine/rules'; import { transformOutput } from '../../../../containers/detection_engine/rules/transforms'; +import * as i18n from '../translations'; +import { bucketRulesResponse, getExportedRulesCount } from './helpers'; export const editRuleAction = (rule: Rule, history: H.History) => { history.push(getEditRuleUrl(rule.id)); @@ -58,20 +58,34 @@ export const duplicateRulesAction = async ( } else { displaySuccessToast(i18n.SUCCESSFULLY_DUPLICATED_RULES(ruleIds.length), dispatchToaster); } - dispatch({ type: 'loadingRuleIds', ids: [], actionType: null }); - return createdRules; } catch (error) { - dispatch({ type: 'loadingRuleIds', ids: [], actionType: null }); errorToToaster({ title: i18n.DUPLICATE_RULE_ERROR, error, dispatchToaster }); + } finally { + dispatch({ type: 'loadingRuleIds', ids: [], actionType: null }); } }; -export const exportRulesAction = ( +export const exportRulesAction = async ( exportRuleId: string[], - dispatch: React.Dispatch + dispatch: React.Dispatch, + dispatchToaster: Dispatch ) => { - dispatch({ type: 'exportRuleIds', ids: exportRuleId }); + try { + dispatch({ type: 'loadingRuleIds', ids: exportRuleId, actionType: 'export' }); + const blob = await exportRules({ ids: exportRuleId }); + downloadBlob(blob, `${i18n.EXPORT_FILENAME}.ndjson`); + + const exportedRulesCount = await getExportedRulesCount(blob); + displaySuccessToast( + i18n.SUCCESSFULLY_EXPORTED_RULES(exportedRulesCount, exportRuleId.length), + dispatchToaster + ); + } catch (e) { + displayErrorToast(i18n.BULK_ACTION_FAILED, [e.message], dispatchToaster); + } finally { + dispatch({ type: 'loadingRuleIds', ids: [], actionType: null }); + } }; export const deleteRulesAction = async ( @@ -84,7 +98,6 @@ export const deleteRulesAction = async ( dispatch({ type: 'loadingRuleIds', ids: ruleIds, actionType: 'delete' }); const response = await deleteRules({ ids: ruleIds }); const { errors } = bucketRulesResponse(response); - dispatch({ type: 'loadingRuleIds', ids: [], actionType: null }); if (errors.length > 0) { displayErrorToast( i18n.BATCH_ACTION_DELETE_SELECTED_ERROR(ruleIds.length), @@ -95,12 +108,13 @@ export const deleteRulesAction = async ( onRuleDeleted(); } } catch (error) { - dispatch({ type: 'loadingRuleIds', ids: [], actionType: null }); errorToToaster({ title: i18n.BATCH_ACTION_DELETE_SELECTED_ERROR(ruleIds.length), error, dispatchToaster, }); + } finally { + dispatch({ type: 'loadingRuleIds', ids: [], actionType: null }); } }; @@ -144,6 +158,37 @@ export const enableRulesAction = async ( } } catch (e) { displayErrorToast(errorTitle, [e.message], dispatchToaster); + } finally { + dispatch({ type: 'loadingRuleIds', ids: [], actionType: null }); + } +}; + +export const rulesBulkActionByQuery = async ( + visibleRuleIds: string[], + selectedItemsCount: number, + query: string, + action: BulkAction, + dispatch: React.Dispatch, + dispatchToaster: Dispatch +) => { + try { + dispatch({ type: 'loadingRuleIds', ids: visibleRuleIds, actionType: action }); + + if (action === BulkAction.export) { + const blob = await performBulkAction({ query, action }); + downloadBlob(blob, `${i18n.EXPORT_FILENAME}.ndjson`); + + const exportedRulesCount = await getExportedRulesCount(blob); + displaySuccessToast( + i18n.SUCCESSFULLY_EXPORTED_RULES(exportedRulesCount, selectedItemsCount), + dispatchToaster + ); + } else { + await performBulkAction({ query, action }); + } + } catch (e) { + displayErrorToast(i18n.BULK_ACTION_FAILED, [e.message], dispatchToaster); + } finally { dispatch({ type: 'loadingRuleIds', ids: [], actionType: null }); } }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/batch_actions.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/batch_actions.tsx index 648d653d6a3c8..5b558824b4659 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/batch_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/batch_actions.tsx @@ -10,6 +10,7 @@ import React, { Dispatch } from 'react'; import * as i18n from '../translations'; import { RulesTableAction } from '../../../../containers/detection_engine/rules/rules_table'; import { + rulesBulkActionByQuery, deleteRulesAction, duplicateRulesAction, enableRulesAction, @@ -20,6 +21,7 @@ import { Rule } from '../../../../containers/detection_engine/rules'; import * as detectionI18n from '../../translations'; import { isMlRule } from '../../../../../../common/machine_learning/helpers'; import { canEditRuleWithActions } from '../../../../../common/utils/privileges'; +import { BulkAction } from '../../../../../../common/detection_engine/schemas/common/schemas'; interface GetBatchItems { closePopover: () => void; @@ -32,6 +34,10 @@ interface GetBatchItems { refetchPrePackagedRulesStatus: () => Promise; rules: Rule[]; selectedRuleIds: string[]; + isAllSelected: boolean; + filterQuery: string; + confirmDeletion: () => Promise; + selectedItemsCount: number; } export const getBatchItems = ({ @@ -45,51 +51,138 @@ export const getBatchItems = ({ rules, selectedRuleIds, hasActionsPrivileges, + isAllSelected, + filterQuery, + confirmDeletion, + selectedItemsCount, }: GetBatchItems) => { - const selectedRules = selectedRuleIds.reduce>((acc, id) => { - const found = rules.find((r) => r.id === id); - if (found != null) { - return { [id]: found, ...acc }; - } - return acc; - }, {}); + const selectedRules = rules.filter(({ id }) => selectedRuleIds.includes(id)); - const containsEnabled = selectedRuleIds.some((id) => selectedRules[id]?.enabled ?? false); - const containsDisabled = selectedRuleIds.some((id) => !selectedRules[id]?.enabled ?? false); + const containsEnabled = selectedRules.some(({ enabled }) => enabled); + const containsDisabled = selectedRules.some(({ enabled }) => !enabled); const containsLoading = selectedRuleIds.some((id) => loadingRuleIds.includes(id)); - const containsImmutable = selectedRuleIds.some((id) => selectedRules[id]?.immutable ?? false); + const containsImmutable = selectedRules.some(({ immutable }) => immutable); const missingActionPrivileges = !hasActionsPrivileges && - selectedRuleIds.some((id) => { - return !canEditRuleWithActions(selectedRules[id], hasActionsPrivileges); - }); + selectedRules.some((rule) => !canEditRuleWithActions(rule, hasActionsPrivileges)); + + const handleActivateAction = async () => { + closePopover(); + const deactivatedRules = selectedRules.filter(({ enabled }) => !enabled); + const deactivatedRulesNoML = deactivatedRules.filter(({ type }) => !isMlRule(type)); + + const mlRuleCount = deactivatedRules.length - deactivatedRulesNoML.length; + if (!hasMlPermissions && mlRuleCount > 0) { + displayWarningToast(detectionI18n.ML_RULES_UNAVAILABLE(mlRuleCount), dispatchToaster); + } + + const ruleIds = hasMlPermissions + ? deactivatedRules.map(({ id }) => id) + : deactivatedRulesNoML.map(({ id }) => id); + + if (isAllSelected) { + await rulesBulkActionByQuery( + ruleIds, + selectedItemsCount, + filterQuery, + BulkAction.enable, + dispatch, + dispatchToaster + ); + await reFetchRules(); + } else { + await enableRulesAction(ruleIds, true, dispatch, dispatchToaster); + } + }; + + const handleDeactivateActions = async () => { + closePopover(); + const activatedIds = selectedRules.filter(({ enabled }) => enabled).map(({ id }) => id); + if (isAllSelected) { + await rulesBulkActionByQuery( + activatedIds, + selectedItemsCount, + filterQuery, + BulkAction.disable, + dispatch, + dispatchToaster + ); + await reFetchRules(); + } else { + await enableRulesAction(activatedIds, false, dispatch, dispatchToaster); + } + }; + + const handleDuplicateAction = async () => { + closePopover(); + if (isAllSelected) { + await rulesBulkActionByQuery( + selectedRuleIds, + selectedItemsCount, + filterQuery, + BulkAction.duplicate, + dispatch, + dispatchToaster + ); + await reFetchRules(); + } else { + await duplicateRulesAction(selectedRules, selectedRuleIds, dispatch, dispatchToaster); + } + await reFetchRules(); + await refetchPrePackagedRulesStatus(); + }; + + const handleDeleteAction = async () => { + closePopover(); + if (isAllSelected) { + if ((await confirmDeletion()) === false) { + // User has cancelled deletion + return; + } + + await rulesBulkActionByQuery( + selectedRuleIds, + selectedItemsCount, + filterQuery, + BulkAction.delete, + dispatch, + dispatchToaster + ); + } else { + await deleteRulesAction(selectedRuleIds, dispatch, dispatchToaster); + } + await reFetchRules(); + await refetchPrePackagedRulesStatus(); + }; + + const handleExportAction = async () => { + closePopover(); + if (isAllSelected) { + await rulesBulkActionByQuery( + selectedRuleIds, + selectedItemsCount, + filterQuery, + BulkAction.export, + dispatch, + dispatchToaster + ); + } else { + await exportRulesAction( + selectedRules.map((r) => r.rule_id), + dispatch, + dispatchToaster + ); + } + }; return [ { - closePopover(); - const deactivatedIds = selectedRuleIds.filter((id) => !selectedRules[id]?.enabled ?? false); - - const deactivatedIdsNoML = deactivatedIds.filter( - (id) => !isMlRule(selectedRules[id]?.type) - ); - - const mlRuleCount = deactivatedIds.length - deactivatedIdsNoML.length; - if (!hasMlPermissions && mlRuleCount > 0) { - displayWarningToast(detectionI18n.ML_RULES_UNAVAILABLE(mlRuleCount), dispatchToaster); - } - - await enableRulesAction( - hasMlPermissions ? deactivatedIds : deactivatedIdsNoML, - true, - dispatch, - dispatchToaster - ); - }} + disabled={missingActionPrivileges || containsLoading || (!containsDisabled && !isAllSelected)} + onClick={handleActivateAction} > , { - closePopover(); - const activatedIds = selectedRuleIds.filter((id) => selectedRules[id]?.enabled ?? false); - await enableRulesAction(activatedIds, false, dispatch, dispatchToaster); - }} + disabled={missingActionPrivileges || containsLoading || (!containsEnabled && !isAllSelected)} + onClick={handleDeactivateActions} > , { - closePopover(); - exportRulesAction( - rules.filter((r) => selectedRuleIds.includes(r.id)).map((r) => r.rule_id), - dispatch - ); - }} + disabled={ + (containsImmutable && !isAllSelected) || containsLoading || selectedRuleIds.length === 0 + } + onClick={handleExportAction} > {i18n.BATCH_ACTION_EXPORT_SELECTED} , @@ -135,17 +222,7 @@ export const getBatchItems = ({ data-test-subj="duplicateRuleBulk" icon="copy" disabled={missingActionPrivileges || containsLoading || selectedRuleIds.length === 0} - onClick={async () => { - closePopover(); - await duplicateRulesAction( - rules.filter((r) => selectedRuleIds.includes(r.id)), - selectedRuleIds, - dispatch, - dispatchToaster - ); - await reFetchRules(); - await refetchPrePackagedRulesStatus(); - }} + onClick={handleDuplicateAction} > { - closePopover(); - await deleteRulesAction(selectedRuleIds, dispatch, dispatchToaster); - await reFetchRules(); - await refetchPrePackagedRulesStatus(); - }} + onClick={handleDeleteAction} > {i18n.BATCH_ACTION_DELETE_SELECTED} , diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx index 83bb530827fa2..28a65c3e64e1f 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx @@ -96,7 +96,7 @@ export const getActions = ( description: i18n.EXPORT_RULE, icon: 'exportAction', name: i18n.EXPORT_RULE, - onClick: (rowItem: Rule) => exportRulesAction([rowItem.rule_id], dispatch), + onClick: (rowItem: Rule) => exportRulesAction([rowItem.rule_id], dispatch, dispatchToaster), enabled: (rowItem: Rule) => !rowItem.immutable, }, { @@ -125,7 +125,7 @@ interface GetColumns { formatUrl: FormatUrl; history: H.History; hasMlPermissions: boolean; - hasNoPermissions: boolean; + hasPermissions: boolean; loadingRuleIds: string[]; reFetchRules: () => Promise; refetchPrePackagedRulesStatus: () => Promise; @@ -142,7 +142,7 @@ export const getColumns = ({ formatUrl, history, hasMlPermissions, - hasNoPermissions, + hasPermissions, loadingRuleIds, reFetchRules, refetchPrePackagedRulesStatus, @@ -275,7 +275,7 @@ export const getColumns = ({ enabled={item.enabled} isDisabled={ !canEditRuleWithActions(item, hasReadActionsPrivileges) || - hasNoPermissions || + !hasPermissions || (isMlRule(item.type) && !hasMlPermissions && !item.enabled) } isLoading={loadingRuleIds.includes(item.id)} @@ -300,7 +300,7 @@ export const getColumns = ({ } as EuiTableActionsColumnType, ]; - return hasNoPermissions ? cols : [...cols, ...actions]; + return hasPermissions ? [...cols, ...actions] : cols; }; export const getMonitoringColumns = ( diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.test.tsx index 6a0f4dc4e2dea..dd3549ea20d36 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.test.tsx @@ -79,7 +79,7 @@ describe('ExceptionListsTable', () => { diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx index 1dfa83da1637a..7f734b10fd020 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx @@ -38,7 +38,7 @@ export type Func = () => Promise; interface ExceptionListsTableProps { history: History; - hasNoPermissions: boolean; + hasPermissions: boolean; loading: boolean; formatUrl: FormatUrl; } @@ -60,7 +60,7 @@ const exceptionReferenceModalInitialState: ReferenceModalState = { }; export const ExceptionListsTable = React.memo( - ({ formatUrl, history, hasNoPermissions, loading }) => { + ({ formatUrl, history, hasPermissions, loading }) => { const { services: { http, notifications }, } = useKibana(); @@ -359,7 +359,7 @@ export const ExceptionListsTable = React.memo( <> ( => { + const blobContent = await blob.text(); + // The Blob content is an NDJSON file, the last line of which contains export details. + const exportDetailsJson = blobContent.split('\n').filter(Boolean).slice(-1)[0]; + const exportDetails = JSON.parse(exportDetailsJson); + + return exportDetails.exported_count; +}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx index 1f4586754cb33..9597c221843be 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx @@ -124,10 +124,10 @@ describe('AllRules', () => { loadingRulesAction: null, loadingRuleIds: [], selectedRuleIds: [], - exportRuleIds: [], lastUpdated: 0, isRefreshOn: true, isRefreshing: false, + isAllSelected: false, showIdleModal: false, }; @@ -189,7 +189,7 @@ describe('AllRules', () => { const wrapper = shallow( { { { { { Promise; @@ -64,7 +64,7 @@ const allRulesTabs = [ export const AllRules = React.memo( ({ createPrePackagedRules, - hasNoPermissions, + hasPermissions, loading, loadingCreatePrePackagedRules, refetchPrePackagedRulesStatus, @@ -110,7 +110,7 @@ export const AllRules = React.memo( formatUrl={formatUrl} selectedTab={allRulesTab} createPrePackagedRules={createPrePackagedRules} - hasNoPermissions={hasNoPermissions} + hasPermissions={hasPermissions} loading={loading} loadingCreatePrePackagedRules={loadingCreatePrePackagedRules} refetchPrePackagedRulesStatus={refetchPrePackagedRulesStatus} @@ -125,7 +125,7 @@ export const AllRules = React.memo( )} diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx index 353cc657f2116..8fd82a495e52f 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_tables.tsx @@ -15,7 +15,6 @@ import { EuiWindowEvent, } from '@elastic/eui'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import uuid from 'uuid'; import { debounce } from 'lodash/fp'; import { History } from 'history'; @@ -25,7 +24,6 @@ import { CreatePreBuiltRules, FilterOptions, Rule, - exportRules, RulesSortingFields, } from '../../../../containers/detection_engine/rules'; @@ -36,7 +34,6 @@ import { useStateToaster } from '../../../../../common/components/toasters'; import { Loader } from '../../../../../common/components/loader'; import { Panel } from '../../../../../common/components/panel'; import { PrePackagedRulesPrompt } from '../../../../components/rules/pre_packaged_rules/load_empty_prompt'; -import { GenericDownloader } from '../../../../../common/components/generic_downloader'; import { AllRulesTables, SortingType } from '../../../../components/rules/all_rules_tables'; import { getPrePackagedRuleStatus } from '../helpers'; import * as i18n from '../translations'; @@ -53,6 +50,10 @@ import { AllRulesUtilityBar } from './utility_bar'; import { LastUpdatedAt } from '../../../../../common/components/last_updated'; import { DEFAULT_RULES_TABLE_REFRESH_SETTING } from '../../../../../../common/constants'; import { AllRulesTabs } from '.'; +import { useValueChanged } from '../../../../../common/hooks/use_value_changed'; +import { convertRulesFilterToKQL } from '../../../../containers/detection_engine/rules/utils'; +import { useBoolState } from '../../../../../common/hooks/use_bool_state'; +import { useAsyncConfirmation } from '../../../../containers/detection_engine/rules/rules_table/use_async_confirmation'; const INITIAL_SORT_FIELD = 'enabled'; @@ -60,7 +61,7 @@ interface RulesTableProps { history: History; formatUrl: FormatUrl; createPrePackagedRules: CreatePreBuiltRules | null; - hasNoPermissions: boolean; + hasPermissions: boolean; loading: boolean; loadingCreatePrePackagedRules: boolean; refetchPrePackagedRulesStatus: () => Promise; @@ -85,7 +86,7 @@ export const RulesTables = React.memo( history, formatUrl, createPrePackagedRules, - hasNoPermissions, + hasPermissions, loading, loadingCreatePrePackagedRules, refetchPrePackagedRulesStatus, @@ -115,14 +116,12 @@ export const RulesTables = React.memo( }>(DEFAULT_RULES_TABLE_REFRESH_SETTING); const rulesTable = useRulesTable({ - tableRef, initialStateOverride: { isRefreshOn: defaultAutoRefreshSetting.on, }, }); const { - exportRuleIds, filterOptions, loadingRuleIds, loadingRulesAction, @@ -133,12 +132,12 @@ export const RulesTables = React.memo( showIdleModal, isRefreshOn, isRefreshing, + isAllSelected, } = rulesTable.state; const { dispatch, updateOptions, - actionStopped, setShowIdleModal, setLastRefreshDate, setAutoRefreshOn, @@ -186,9 +185,24 @@ export const RulesTables = React.memo( actions, ]); + const [ + isDeleteConfirmationVisible, + showDeleteConfirmation, + hideDeleteConfirmation, + ] = useBoolState(); + + const [confirmDeletion, handleDeletionConfirm, handleDeletionCancel] = useAsyncConfirmation({ + onInit: showDeleteConfirmation, + onFinish: hideDeleteConfirmation, + }); + + const selectedItemsCount = isAllSelected ? pagination.total : selectedRuleIds.length; + const hasPagination = pagination.total > pagination.perPage; + const getBatchItemsPopoverContent = useCallback( (closePopover: () => void): JSX.Element[] => { return getBatchItems({ + isAllSelected, closePopover, dispatch, dispatchToaster, @@ -199,9 +213,13 @@ export const RulesTables = React.memo( reFetchRules, refetchPrePackagedRulesStatus, rules, + filterQuery: convertRulesFilterToKQL(filterOptions), + confirmDeletion, + selectedItemsCount, }); }, [ + isAllSelected, dispatch, dispatchToaster, hasMlPermissions, @@ -211,6 +229,9 @@ export const RulesTables = React.memo( rules, selectedRuleIds, hasActionsPrivileges, + filterOptions, + confirmDeletion, + selectedItemsCount, ] ); @@ -219,7 +240,7 @@ export const RulesTables = React.memo( pageIndex: pagination.page - 1, pageSize: pagination.perPage, totalItemCount: pagination.total, - pageSizeOptions: [5, 10, 20, 50, 100, 200, 300, 400, 500, 600], + pageSizeOptions: [5, 10, 20, 50, 100], }), [pagination] ); @@ -252,7 +273,7 @@ export const RulesTables = React.memo( formatUrl, history, hasMlPermissions, - hasNoPermissions, + hasPermissions, loadingRuleIds: loadingRulesAction != null && (loadingRulesAction === 'enable' || loadingRulesAction === 'disable') @@ -268,7 +289,7 @@ export const RulesTables = React.memo( formatUrl, refetchPrePackagedRulesStatus, hasActionsPrivileges, - hasNoPermissions, + hasPermissions, hasMlPermissions, history, loadingRuleIds, @@ -299,15 +320,43 @@ export const RulesTables = React.memo( } }, [createPrePackagedRules, reFetchRules, refetchPrePackagedRulesStatus]); + const isSelectAllCalled = useRef(false); + + // Synchronize selectedRuleIds with EuiBasicTable's selected rows + useValueChanged((ruleIds) => { + if (tableRef.current?.changeSelection != null) { + tableRef.current.setSelection(rules.filter((rule) => ruleIds.includes(rule.id))); + } + }, selectedRuleIds); + const euiBasicTableSelectionProps = useMemo( () => ({ selectable: (item: Rule) => !loadingRuleIds.includes(item.id), - onSelectionChange: (selected: Rule[]) => - dispatch({ type: 'selectedRuleIds', ids: selected.map((r) => r.id) }), + onSelectionChange: (selected: Rule[]) => { + /** + * EuiBasicTable doesn't provide declarative API to control selected rows. + * This limitation requires us to synchronize selection state manually using setSelection(). + * But it creates a chain reaction when the user clicks Select All: + * selectAll() -> setSelection() -> onSelectionChange() -> setSelection(). + * To break the chain we should check whether the onSelectionChange was triggered + * by the Select All action or not. + * + */ + if (isSelectAllCalled.current) { + isSelectAllCalled.current = false; + } else { + dispatch({ type: 'selectedRuleIds', ids: selected.map(({ id }) => id) }); + } + }, }), [loadingRuleIds, dispatch] ); + const toggleSelectAll = useCallback(() => { + isSelectAllCalled.current = true; + dispatch({ type: 'setIsAllSelected', isAllSelected: !isAllSelected }); + }, [dispatch, isAllSelected]); + const refreshTable = useCallback( async (mode: 'auto' | 'manual' = 'manual'): Promise => { if (isLoadingAnActionOnRule) { @@ -397,22 +446,6 @@ export const RulesTables = React.memo( [initLoading, prePackagedRuleStatus, rulesCustomInstalled] ); - const handleGenericDownloaderSuccess = useCallback( - (exportCount) => { - actionStopped(); - dispatchToaster({ - type: 'addToaster', - toast: { - id: uuid.v4(), - title: i18n.SUCCESSFULLY_EXPORTED_RULES(exportCount), - color: 'success', - iconType: 'check', - }, - }); - }, - [actionStopped, dispatchToaster] - ); - return ( <> @@ -421,13 +454,6 @@ export const RulesTables = React.memo( - - ( )} {initLoading && ( @@ -492,22 +518,39 @@ export const RulesTables = React.memo(

{i18n.REFRESH_PROMPT_BODY}

)} + {isDeleteConfirmationVisible && ( + +

{i18n.DELETE_CONFIRMATION_BODY}

+
+ )} {shouldShowRulesTable && ( <> { const wrapper = mount( { const wrapper = mount( { const wrapper = mount( { const wrapper = mount( { const wrapper = mount( { const wrapper = mount( void; + numberSelectedItems: number; onGetBatchItemsPopoverContent?: (closePopover: () => void) => JSX.Element[]; + onRefresh?: (refreshRule: boolean) => void; onRefreshSwitch?: (checked: boolean) => void; + onToggleSelectAll?: () => void; + paginationTotal: number; + showBulkActions: boolean; + hasPagination?: boolean; } export const AllRulesUtilityBar = React.memo( ({ - userHasNoPermissions, - onRefresh, - paginationTotal, + canBulkEdit, + isAllSelected, + isAutoRefreshOn, numberSelectedItems, onGetBatchItemsPopoverContent, - isAutoRefreshOn, - showBulkActions = true, + onRefresh, onRefreshSwitch, + onToggleSelectAll, + paginationTotal, + showBulkActions = true, + hasPagination, }) => { const handleGetBatchItemsPopoverContent = useCallback( (closePopover: () => void): JSX.Element | null => { @@ -99,7 +105,19 @@ export const AllRulesUtilityBar = React.memo( {i18n.SELECTED_RULES(numberSelectedItems)} - {!userHasNoPermissions && ( + + {canBulkEdit && onToggleSelectAll && hasPagination && ( + + {isAllSelected ? i18n.CLEAR_SELECTION : i18n.SELECT_ALL_RULES(paginationTotal)} + + )} + + {canBulkEdit && ( { ) { history.replace(getDetectionEngineUrl()); return null; - } else if (userHasNoPermissions(canUserCRUD)) { + } else if (!userHasPermissions(canUserCRUD)) { history.replace(getRulesUrl()); return null; } diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx index 8dac9e03514d1..6727db8aba3b4 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx @@ -70,7 +70,7 @@ import { } from '../../../../components/alerts_table/default_config'; import { RuleSwitch } from '../../../../components/rules/rule_switch'; import { StepPanel } from '../../../../components/rules/step_panel'; -import { getStepsData, redirectToDetections, userHasNoPermissions } from '../helpers'; +import { getStepsData, redirectToDetections, userHasPermissions } from '../helpers'; import { useGlobalTime } from '../../../../../common/containers/use_global_time'; import { alertsHistogramOptions } from '../../../../components/alerts_histogram_panel/config'; import { inputsSelectors } from '../../../../../common/store/inputs'; @@ -461,7 +461,7 @@ const RuleDetailsPageComponent = () => { {ruleI18n.EDIT_RULE_SETTINGS} @@ -608,7 +608,7 @@ const RuleDetailsPageComponent = () => { isDisabled={ !isExistingRule || !canEditRuleWithActions(rule, hasActionsPrivileges) || - userHasNoPermissions(canUserCRUD) || + !userHasPermissions(canUserCRUD) || (!hasMlPermissions && !rule?.enabled) } enabled={isExistingRule && (rule?.enabled ?? false)} @@ -625,9 +625,7 @@ const RuleDetailsPageComponent = () => { { ) { history.replace(getDetectionEngineUrl()); return null; - } else if (userHasNoPermissions(canUserCRUD)) { + } else if (!userHasPermissions(canUserCRUD)) { history.replace(getRuleDetailsUrl(ruleId ?? '')); return null; } diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx index 4c3e5b18d4c1b..fa600d9ce4a0e 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx @@ -18,7 +18,7 @@ import { getPrePackagedRuleStatus, getPrePackagedTimelineStatus, determineDetailsValue, - userHasNoPermissions, + userHasPermissions, fillEmptySeverityMappings, } from './helpers'; import { mockRuleWithEverything, mockRule } from './all/__mocks__/mock'; @@ -403,26 +403,26 @@ describe('rule helpers', () => { }); }); - describe('userHasNoPermissions', () => { - test("returns false when user's CRUD operations are null", () => { - const result: boolean = userHasNoPermissions(null); - const userHasNoPermissionsExpectedResult = false; + describe('userHasPermissions', () => { + test("returns true when user's CRUD operations are null", () => { + const result: boolean = userHasPermissions(null); + const userHasPermissionsExpectedResult = true; - expect(result).toEqual(userHasNoPermissionsExpectedResult); + expect(result).toEqual(userHasPermissionsExpectedResult); }); - test('returns true when user cannot CRUD', () => { - const result: boolean = userHasNoPermissions(false); - const userHasNoPermissionsExpectedResult = true; + test('returns false when user cannot CRUD', () => { + const result: boolean = userHasPermissions(false); + const userHasPermissionsExpectedResult = false; - expect(result).toEqual(userHasNoPermissionsExpectedResult); + expect(result).toEqual(userHasPermissionsExpectedResult); }); - test('returns false when user can CRUD', () => { - const result: boolean = userHasNoPermissions(true); - const userHasNoPermissionsExpectedResult = false; + test('returns true when user can CRUD', () => { + const result: boolean = userHasPermissions(true); + const userHasPermissionsExpectedResult = true; - expect(result).toEqual(userHasNoPermissionsExpectedResult); + expect(result).toEqual(userHasPermissionsExpectedResult); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx index a88ff9bb2c921..f20ace09ed2b6 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx @@ -405,8 +405,8 @@ export const getActionMessageParams = memoizeOne( ); // typed as null not undefined as the initial state for this value is null. -export const userHasNoPermissions = (canUserCRUD: boolean | null): boolean => - canUserCRUD != null ? !canUserCRUD : false; +export const userHasPermissions = (canUserCRUD: boolean | null): boolean => + canUserCRUD != null ? canUserCRUD : true; export const MaxWidthEuiFlexItem = styled(EuiFlexItem)` max-width: 1000px; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx index 8aca1cb960c1d..8bacb10444a7d 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx @@ -28,7 +28,7 @@ import { getPrePackagedRuleStatus, getPrePackagedTimelineStatus, redirectToDetections, - userHasNoPermissions, + userHasPermissions, } from './helpers'; import * as i18n from './translations'; import { SecurityPageName } from '../../../../app/types'; @@ -131,7 +131,7 @@ const RulesPageComponent: React.FC = () => { const loadPrebuiltRulesAndTemplatesButton = useMemo( () => getLoadPrebuiltRulesAndTemplatesButton({ - isDisabled: userHasNoPermissions(canUserCRUD) || loading, + isDisabled: !userHasPermissions(canUserCRUD) || loading, onClick: handleCreatePrePackagedRules, }), [canUserCRUD, getLoadPrebuiltRulesAndTemplatesButton, handleCreatePrePackagedRules, loading] @@ -140,7 +140,7 @@ const RulesPageComponent: React.FC = () => { const reloadPrebuiltRulesAndTemplatesButton = useMemo( () => getReloadPrebuiltRulesAndTemplatesButton({ - isDisabled: userHasNoPermissions(canUserCRUD) || loading, + isDisabled: !userHasPermissions(canUserCRUD) || loading, onClick: handleCreatePrePackagedRules, }), [canUserCRUD, getReloadPrebuiltRulesAndTemplatesButton, handleCreatePrePackagedRules, loading] @@ -213,7 +213,7 @@ const RulesPageComponent: React.FC = () => { { setShowImportModal(true); }} @@ -228,7 +228,7 @@ const RulesPageComponent: React.FC = () => { onClick={goToNewRule} href={formatUrl(getCreateRuleUrl())} iconType="plusInCircle" - isDisabled={userHasNoPermissions(canUserCRUD) || loading} + isDisabled={!userHasPermissions(canUserCRUD) || loading} > {i18n.ADD_NEW_RULE} @@ -250,7 +250,7 @@ const RulesPageComponent: React.FC = () => { data-test-subj="all-rules" loading={loading || prePackagedRuleLoading} loadingCreatePrePackagedRules={loadingCreatePrePackagedRules} - hasNoPermissions={userHasNoPermissions(canUserCRUD)} + hasPermissions={userHasPermissions(canUserCRUD)} refetchPrePackagedRulesStatus={handleRefetchPrePackagedRulesStatus} rulesCustomInstalled={rulesCustomInstalled} rulesInstalled={rulesInstalled} diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts index 1bfa62e9b77c0..defd976a04c4b 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts @@ -165,13 +165,13 @@ export const EXPORT_FILENAME = i18n.translate( } ); -export const SUCCESSFULLY_EXPORTED_RULES = (totalRules: number) => +export const SUCCESSFULLY_EXPORTED_RULES = (exportedRules: number, totalRules: number) => i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.allRules.successfullyExportedRulesTitle', + 'xpack.securitySolution.detectionEngine.rules.allRules.successfullyExportedXofYRulesTitle', { - values: { totalRules }, + values: { totalRules, exportedRules }, defaultMessage: - 'Successfully exported {totalRules, plural, =0 {all rules} =1 {{totalRules} rule} other {{totalRules} rules}}', + 'Successfully exported {exportedRules} of {totalRules} {totalRules, plural, =1 {rule} other {rules}}. Prebuilt rules were excluded from the resulting file.', } ); @@ -202,6 +202,19 @@ export const SHOWING_RULES = (totalRules: number) => defaultMessage: 'Showing {totalRules} {totalRules, plural, =1 {rule} other {rules}}', }); +export const SELECT_ALL_RULES = (totalRules: number) => + i18n.translate('xpack.securitySolution.detectionEngine.rules.allRules.selectAllRulesTitle', { + values: { totalRules }, + defaultMessage: 'Select all {totalRules} {totalRules, plural, =1 {rule} other {rules}}', + }); + +export const CLEAR_SELECTION = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.clearSelectionTitle', + { + defaultMessage: 'Clear selection', + } +); + export const SELECTED_RULES = (selectedRules: number) => i18n.translate('xpack.securitySolution.detectionEngine.rules.allRules.selectedRulesTitle', { values: { selectedRules }, @@ -253,6 +266,13 @@ export const DUPLICATE_RULE_ERROR = i18n.translate( } ); +export const BULK_ACTION_FAILED = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.actions.bulkActionFailedDescription', + { + defaultMessage: 'Failed to execte bulk action', + } +); + export const EXPORT_RULE = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.allRules.actions.exportRuleDescription', { @@ -577,6 +597,35 @@ export const REFRESH_PROMPT_BODY = i18n.translate( } ); +export const DELETE_CONFIRMATION_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.allRules.deleteConfirmationTitle', + { + defaultMessage: 'Confirm bulk deletion', + } +); + +export const DELETE_CONFIRMATION_CONFIRM = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.allRules.deleteConfirmationConfirm', + { + defaultMessage: 'Confirm', + } +); + +export const DELETE_CONFIRMATION_CANCEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.allRules.deleteConfirmationCancel', + { + defaultMessage: 'Cancel', + } +); + +export const DELETE_CONFIRMATION_BODY = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.allRules.deleteConfirmationBody', + { + defaultMessage: + 'This action will delete all rules that match current filter query. Click "Confirm" to continue.', + } +); + export const REFRESH_RULE_POPOVER_DESCRIPTION = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.refreshRulePopoverDescription', { diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.test.tsx index a273ef1df9788..738d166fcb9a4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.test.tsx @@ -10,14 +10,20 @@ import React from 'react'; import { TimelineDownloader } from './export_timeline'; import { mockSelectedTimeline } from './mocks'; import * as i18n from '../translations'; +import { downloadBlob } from '../../../../common/utils/download_blob'; import { ReactWrapper, mount } from 'enzyme'; import { waitFor } from '@testing-library/react'; import { useParams } from 'react-router-dom'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { exportSelectedTimeline } from '../../../containers/api'; jest.mock('../../../../common/hooks/use_app_toasts'); +jest.mock('../../../../common/utils/download_blob'); +jest.mock('../../../containers/api', () => ({ + exportSelectedTimeline: jest.fn(), +})); jest.mock('.', () => { return { @@ -37,6 +43,7 @@ jest.mock('react-router-dom', () => { describe('TimelineDownloader', () => { const mockAddSuccess = jest.fn(); (useAppToasts as jest.Mock).mockReturnValue({ addSuccess: mockAddSuccess }); + (exportSelectedTimeline as jest.Mock).mockReturnValue(new Blob()); let wrapper: ReactWrapper; const exportedIds = ['baa20980-6301-11ea-9223-95b6d4dd806c']; @@ -56,14 +63,14 @@ describe('TimelineDownloader', () => { mockAddSuccess.mockClear(); }); - describe('should not render a downloader', () => { - test('Without exportedIds', () => { + describe('ExportTimeline', () => { + it('should not start download without exportedIds', () => { const testProps = { ...defaultTestProps, exportedIds: undefined, }; wrapper = mount(); - expect(wrapper.find('[data-test-subj="export-timeline-downloader"]').exists()).toBeFalsy(); + expect(downloadBlob).toHaveBeenCalledTimes(0); }); test('With isEnableDownloader is false', () => { @@ -72,18 +79,23 @@ describe('TimelineDownloader', () => { isEnableDownloader: false, }; wrapper = mount(); - expect(wrapper.find('[data-test-subj="export-timeline-downloader"]').exists()).toBeFalsy(); + expect(downloadBlob).toHaveBeenCalledTimes(0); }); }); - describe('should render a downloader', () => { - test('With selectedItems and exportedIds is given and isEnableDownloader is true', () => { + describe('should start download', () => { + test('With selectedItems and exportedIds is given and isEnableDownloader is true', async () => { const testProps = { ...defaultTestProps, selectedItems: mockSelectedTimeline, }; wrapper = mount(); - expect(wrapper.find('[data-test-subj="export-timeline-downloader"]').exists()).toBeTruthy(); + + await waitFor(() => { + wrapper.update(); + + expect(downloadBlob).toHaveBeenCalledTimes(1); + }); }); test('With correct toast message on success for exported timelines', async () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.tsx index b8b1c76ffd6d7..10e6ea9ee085c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.tsx @@ -5,23 +5,20 @@ * 2.0. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { useParams } from 'react-router-dom'; -import { - GenericDownloader, - ExportSelectedData, -} from '../../../../common/components/generic_downloader'; import * as i18n from '../translations'; import { TimelineType } from '../../../../../common/types/timeline'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { exportSelectedTimeline } from '../../../containers/api'; +import { downloadBlob } from '../../../../common/utils/download_blob'; const ExportTimeline: React.FC<{ exportedIds: string[] | undefined; - getExportedData: ExportSelectedData; isEnableDownloader: boolean; onComplete?: () => void; -}> = ({ onComplete, isEnableDownloader, exportedIds, getExportedData }) => { +}> = ({ onComplete, isEnableDownloader, exportedIds }) => { const { tabName: timelineType } = useParams<{ tabName: TimelineType }>(); const { addSuccess } = useAppToasts(); @@ -47,20 +44,28 @@ const ExportTimeline: React.FC<{ } }, [onComplete]); - return ( - <> - {exportedIds != null && isEnableDownloader && ( - - )} - - ); + useEffect(() => { + const downloadTimeline = async () => { + if (exportedIds?.length && isEnableDownloader) { + const result = await exportSelectedTimeline({ ids: exportedIds }); + if (result instanceof Blob) { + downloadBlob(result, `${i18n.EXPORT_FILENAME}.ndjson`); + onExportSuccess(exportedIds.length); + } else { + onExportFailure(); + } + } + }; + + downloadTimeline(); + // We probably don't need to have ExportTimeline in the form of a React component. + // See https://github.com/elastic/kibana/issues/101571 for more detail. + // But for now, it uses isEnableDownloader as a signal to start downloading. + // Other variables are excluded from the deps array to avoid false positives + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [exportedIds, isEnableDownloader]); + + return null; }; ExportTimeline.displayName = 'ExportTimeline'; export const TimelineDownloader = React.memo(ExportTimeline); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/index.tsx index 250e7847edb5c..aa447c9e84f97 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/index.tsx @@ -10,7 +10,6 @@ import { DeleteTimelines } from '../types'; import { TimelineDownloader } from './export_timeline'; import { DeleteTimelineModalOverlay } from '../delete_timeline_modal'; -import { exportSelectedTimeline } from '../../../containers/api'; export interface ExportTimeline { disableExportTimelineDownloader: () => void; @@ -37,7 +36,6 @@ export const EditTimelineActionsComponent: React.FC<{ @@ -55,4 +53,3 @@ export const EditTimelineActionsComponent: React.FC<{ ); export const EditTimelineActions = React.memo(EditTimelineActionsComponent); -export const EditOneTimelineAction = React.memo(EditTimelineActionsComponent); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx index 316b6cff766ea..922e40d6d860e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx @@ -24,7 +24,7 @@ import { importTimelines } from '../../containers/api'; import { useEditTimelineBatchActions } from './edit_timeline_batch_actions'; import { useEditTimelineActions } from './edit_timeline_actions'; -import { EditOneTimelineAction } from './export_timeline'; +import { EditTimelineActions } from './export_timeline'; import { SearchRow } from './search_row'; import { TimelinesTable } from './timelines_table'; import * as i18n from './translations'; @@ -170,7 +170,7 @@ export const OpenTimeline = React.memo( return ( <> - => { +}: ExportDocumentsProps): Promise => { let requestBody; try { requestBody = ids.length > 0 ? JSON.stringify({ ids }) : undefined; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts index 857762dec45e9..3942d1637fedd 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -18,6 +18,7 @@ import { DETECTION_ENGINE_PREPACKAGED_URL, DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL, DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL, + DETECTION_ENGINE_RULES_BULK_ACTION, } from '../../../../../common/constants'; import { ShardsResponse } from '../../../types'; import { @@ -36,6 +37,7 @@ import { getSignalsMigrationStatusSchemaMock } from '../../../../../common/detec import { RuleParams } from '../../schemas/rule_schemas'; import { Alert } from '../../../../../../alerting/common'; import { getQueryRuleParams } from '../../schemas/rule_schemas.mock'; +import { getPerformBulkActionSchemaMock } from '../../../../../common/detection_engine/schemas/request/perform_bulk_action_schema.mock'; export const typicalSetStatusSignalByIdsPayload = (): SetSignalsStatusSchemaDecoded => ({ signal_ids: ['somefakeid1', 'somefakeid2'], @@ -107,6 +109,13 @@ export const getPatchBulkRequest = () => body: [getCreateRulesSchemaMock()], }); +export const getBulkActionRequest = () => + requestMock.create({ + method: 'patch', + path: DETECTION_ENGINE_RULES_BULK_ACTION, + body: getPerformBulkActionSchemaMock(), + }); + export const getDeleteBulkRequest = () => requestMock.create({ method: 'delete', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts index 1e7ba976d6915..3068521682f8f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts @@ -23,9 +23,8 @@ import { getIdBulkError } from './utils'; import { transformValidateBulkError } from './validate'; import { transformBulkError, buildSiemResponse, createBulkErrorObject } from '../utils'; import { deleteRules } from '../../rules/delete_rules'; -import { deleteNotifications } from '../../notifications/delete_notifications'; -import { deleteRuleActionsSavedObject } from '../../rule_actions/delete_rule_actions_saved_object'; import { ruleStatusSavedObjectsClientFactory } from '../../signals/rule_status_saved_objects_client'; +import { readRules } from '../../rules/read_rules'; type Config = RouteConfig; type Handler = RequestHandler< @@ -74,27 +73,24 @@ export const deleteRulesBulkRoute = (router: SecuritySolutionPluginRouter) => { } try { - const rule = await deleteRules({ - alertsClient, - id, - ruleId, - }); - if (rule != null) { - await deleteNotifications({ alertsClient, ruleAlertId: rule.id }); - await deleteRuleActionsSavedObject({ - ruleAlertId: rule.id, - savedObjectsClient, - }); - const ruleStatuses = await ruleStatusClient.find({ - perPage: 6, - search: rule.id, - searchFields: ['alertId'], - }); - ruleStatuses.saved_objects.forEach(async (obj) => ruleStatusClient.delete(obj.id)); - return transformValidateBulkError(idOrRuleIdOrUnknown, rule, undefined, ruleStatuses); - } else { + const rule = await readRules({ alertsClient, id, ruleId }); + if (!rule) { return getIdBulkError({ id, ruleId }); } + + const ruleStatuses = await ruleStatusClient.find({ + perPage: 6, + search: rule.id, + searchFields: ['alertId'], + }); + await deleteRules({ + alertsClient, + savedObjectsClient, + ruleStatusClient, + ruleStatuses, + id: rule.id, + }); + return transformValidateBulkError(idOrRuleIdOrUnknown, rule, undefined, ruleStatuses); } catch (err) { return transformBulkError(idOrRuleIdOrUnknown, err); } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_route.ts index 4b05f603b85b7..4a6b41230f799 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/delete_rules_route.ts @@ -19,9 +19,8 @@ import { deleteRules } from '../../rules/delete_rules'; import { getIdError, transform } from './utils'; import { buildSiemResponse } from '../utils'; -import { deleteNotifications } from '../../notifications/delete_notifications'; -import { deleteRuleActionsSavedObject } from '../../rule_actions/delete_rule_actions_saved_object'; import { ruleStatusSavedObjectsClientFactory } from '../../signals/rule_status_saved_objects_client'; +import { readRules } from '../../rules/read_rules'; export const deleteRulesRoute = ( router: SecuritySolutionPluginRouter, @@ -57,36 +56,33 @@ export const deleteRulesRoute = ( } const ruleStatusClient = ruleStatusSavedObjectsClientFactory(savedObjectsClient); - const rule = await deleteRules({ - alertsClient, - id, - ruleId, - }); - if (rule != null) { - await deleteNotifications({ alertsClient, ruleAlertId: rule.id }); - await deleteRuleActionsSavedObject({ - ruleAlertId: rule.id, - savedObjectsClient, - }); - const ruleStatuses = await ruleStatusClient.find({ - perPage: 6, - search: rule.id, - searchFields: ['alertId'], - }); - ruleStatuses.saved_objects.forEach(async (obj) => ruleStatusClient.delete(obj.id)); - const transformed = transform(rule, undefined, ruleStatuses.saved_objects[0]); - if (transformed == null) { - return siemResponse.error({ statusCode: 500, body: 'failed to transform alert' }); - } else { - return response.ok({ body: transformed ?? {} }); - } - } else { + const rule = await readRules({ alertsClient, id, ruleId }); + if (!rule) { const error = getIdError({ id, ruleId }); return siemResponse.error({ body: error.message, statusCode: error.statusCode, }); } + + const ruleStatuses = await ruleStatusClient.find({ + perPage: 6, + search: rule.id, + searchFields: ['alertId'], + }); + await deleteRules({ + alertsClient, + savedObjectsClient, + ruleStatusClient, + ruleStatuses, + id: rule.id, + }); + const transformed = transform(rule, undefined, ruleStatuses.saved_objects[0]); + if (transformed == null) { + return siemResponse.error({ statusCode: 500, body: 'failed to transform alert' }); + } else { + return response.ok({ body: transformed ?? {} }); + } } catch (err) { const error = transformError(err); return siemResponse.error({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.test.ts new file mode 100644 index 0000000000000..60677fd8eda90 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.test.ts @@ -0,0 +1,148 @@ +/* + * 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 { DETECTION_ENGINE_RULES_BULK_ACTION } from '../../../../../common/constants'; +import { mlServicesMock, mlAuthzMock as mockMlAuthzFactory } from '../../../machine_learning/mocks'; +import { buildMlAuthz } from '../../../machine_learning/authz'; +import { + getEmptyFindResult, + getFindResultStatus, + getBulkActionRequest, + getFindResultWithSingleHit, + getFindResultWithMultiHits, +} from '../__mocks__/request_responses'; +import { requestContextMock, serverMock, requestMock } from '../__mocks__'; +import { performBulkActionRoute } from './perform_bulk_action_route'; +import { getPerformBulkActionSchemaMock } from '../../../../../common/detection_engine/schemas/request/perform_bulk_action_schema.mock'; + +jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); + +describe('perform_bulk_action', () => { + let server: ReturnType; + let { clients, context } = requestContextMock.createTools(); + let ml: ReturnType; + + beforeEach(() => { + server = serverMock.create(); + ({ clients, context } = requestContextMock.createTools()); + ml = mlServicesMock.createSetupContract(); + + clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); + clients.savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); + + performBulkActionRoute(server.router, ml); + }); + + describe('status codes', () => { + it('returns 200 when performing bulk action with all dependencies present', async () => { + const response = await server.inject(getBulkActionRequest(), context); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ success: true, rules_count: 1 }); + }); + + it("returns 200 when provided filter query doesn't match any rules", async () => { + clients.alertsClient.find.mockResolvedValue(getEmptyFindResult()); + const response = await server.inject(getBulkActionRequest(), context); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ success: true, rules_count: 0 }); + }); + + it('returns 400 when provided filter query matches too many rules', async () => { + clients.alertsClient.find.mockResolvedValue( + getFindResultWithMultiHits({ data: [], total: Infinity }) + ); + const response = await server.inject(getBulkActionRequest(), context); + expect(response.status).toEqual(400); + expect(response.body).toEqual({ + message: 'More than 10000 rules matched the filter query. Try to narrow it down.', + status_code: 400, + }); + }); + + it('returns 404 if alertClient is not available on the route', async () => { + context.alerting!.getAlertsClient = jest.fn(); + const response = await server.inject(getBulkActionRequest(), context); + expect(response.status).toEqual(404); + expect(response.body).toEqual({ message: 'Not Found', status_code: 404 }); + }); + + it('catches error if disable throws error', async () => { + clients.alertsClient.disable.mockImplementation(async () => { + throw new Error('Test error'); + }); + const response = await server.inject(getBulkActionRequest(), context); + expect(response.status).toEqual(500); + expect(response.body).toEqual({ + message: 'Test error', + status_code: 500, + }); + }); + + it('rejects patching a rule if mlAuthz fails', async () => { + (buildMlAuthz as jest.Mock).mockReturnValueOnce({ + validateRuleType: jest + .fn() + .mockResolvedValue({ valid: false, message: 'mocked validation message' }), + }); + const response = await server.inject(getBulkActionRequest(), context); + + expect(response.status).toEqual(403); + expect(response.body).toEqual({ + message: 'mocked validation message', + status_code: 403, + }); + }); + }); + + describe('request validation', () => { + it('rejects payloads with no action', async () => { + const request = requestMock.create({ + method: 'patch', + path: DETECTION_ENGINE_RULES_BULK_ACTION, + body: { ...getPerformBulkActionSchemaMock(), action: undefined }, + }); + const result = server.validate(request); + expect(result.badRequest).toHaveBeenCalledWith( + 'Invalid value "undefined" supplied to "action"' + ); + }); + + it('rejects payloads with unknown action', async () => { + const request = requestMock.create({ + method: 'patch', + path: DETECTION_ENGINE_RULES_BULK_ACTION, + body: { ...getPerformBulkActionSchemaMock(), action: 'unknown' }, + }); + const result = server.validate(request); + expect(result.badRequest).toHaveBeenCalledWith( + 'Invalid value "unknown" supplied to "action"' + ); + }); + + it('accepts payloads with no query', async () => { + const request = requestMock.create({ + method: 'patch', + path: DETECTION_ENGINE_RULES_BULK_ACTION, + body: { ...getPerformBulkActionSchemaMock(), query: undefined }, + }); + const result = server.validate(request); + + expect(result.ok).toHaveBeenCalled(); + }); + + it('accepts payloads with query and action', async () => { + const request = requestMock.create({ + method: 'patch', + path: DETECTION_ENGINE_RULES_BULK_ACTION, + body: getPerformBulkActionSchemaMock(), + }); + const result = server.validate(request); + + expect(result.ok).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts new file mode 100644 index 0000000000000..9d569acf3782a --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts @@ -0,0 +1,172 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { transformError } from '@kbn/securitysolution-es-utils'; +import { DETECTION_ENGINE_RULES_BULK_ACTION } from '../../../../../common/constants'; +import { BulkAction } from '../../../../../common/detection_engine/schemas/common/schemas'; +import { performBulkActionSchema } from '../../../../../common/detection_engine/schemas/request/perform_bulk_action_schema'; +import { SetupPlugins } from '../../../../plugin'; +import type { SecuritySolutionPluginRouter } from '../../../../types'; +import { buildRouteValidation } from '../../../../utils/build_validation/route_validation'; +import { buildMlAuthz } from '../../../machine_learning/authz'; +import { throwHttpError } from '../../../machine_learning/validation'; +import { deleteRules } from '../../rules/delete_rules'; +import { duplicateRule } from '../../rules/duplicate_rule'; +import { enableRule } from '../../rules/enable_rule'; +import { findRules } from '../../rules/find_rules'; +import { getExportByObjectIds } from '../../rules/get_export_by_object_ids'; +import { updateRulesNotifications } from '../../rules/update_rules_notifications'; +import { getRuleActionsSavedObject } from '../../rule_actions/get_rule_actions_saved_object'; +import { ruleStatusSavedObjectsClientFactory } from '../../signals/rule_status_saved_objects_client'; +import { buildSiemResponse } from '../utils'; + +const BULK_ACTION_RULES_LIMIT = 10000; + +export const performBulkActionRoute = ( + router: SecuritySolutionPluginRouter, + ml: SetupPlugins['ml'] +) => { + router.post( + { + path: DETECTION_ENGINE_RULES_BULK_ACTION, + validate: { + body: buildRouteValidation(performBulkActionSchema), + }, + options: { + tags: ['access:securitySolution'], + }, + }, + async (context, request, response) => { + const { body } = request; + const siemResponse = buildSiemResponse(response); + + try { + const alertsClient = context.alerting?.getAlertsClient(); + const savedObjectsClient = context.core.savedObjects.client; + const ruleStatusClient = ruleStatusSavedObjectsClientFactory(savedObjectsClient); + + const mlAuthz = buildMlAuthz({ + license: context.licensing.license, + ml, + request, + savedObjectsClient, + }); + + if (!alertsClient) { + return siemResponse.error({ statusCode: 404 }); + } + + const rules = await findRules({ + alertsClient, + perPage: BULK_ACTION_RULES_LIMIT, + filter: body.query !== '' ? body.query : undefined, + page: undefined, + sortField: undefined, + sortOrder: undefined, + fields: undefined, + }); + + if (rules.total > BULK_ACTION_RULES_LIMIT) { + return siemResponse.error({ + body: `More than ${BULK_ACTION_RULES_LIMIT} rules matched the filter query. Try to narrow it down.`, + statusCode: 400, + }); + } + + switch (body.action) { + case BulkAction.enable: + await Promise.all( + rules.data.map(async (rule) => { + if (!rule.enabled) { + throwHttpError(await mlAuthz.validateRuleType(rule.params.type)); + await enableRule({ rule, alertsClient, savedObjectsClient }); + } + }) + ); + break; + case BulkAction.disable: + await Promise.all( + rules.data.map(async (rule) => { + if (rule.enabled) { + throwHttpError(await mlAuthz.validateRuleType(rule.params.type)); + await alertsClient.disable({ id: rule.id }); + } + }) + ); + break; + case BulkAction.delete: + await Promise.all( + rules.data.map(async (rule) => { + const ruleStatuses = await ruleStatusClient.find({ + perPage: 6, + search: rule.id, + searchFields: ['alertId'], + }); + await deleteRules({ + alertsClient, + savedObjectsClient, + ruleStatusClient, + ruleStatuses, + id: rule.id, + }); + }) + ); + break; + case BulkAction.duplicate: + await Promise.all( + rules.data.map(async (rule) => { + throwHttpError(await mlAuthz.validateRuleType(rule.params.type)); + + const createdRule = await alertsClient.create({ + data: duplicateRule(rule), + }); + + const ruleActions = await getRuleActionsSavedObject({ + savedObjectsClient, + ruleAlertId: rule.id, + }); + + await updateRulesNotifications({ + ruleAlertId: createdRule.id, + alertsClient, + savedObjectsClient, + enabled: createdRule.enabled, + actions: ruleActions?.actions || [], + throttle: ruleActions?.alertThrottle, + name: createdRule.name, + }); + }) + ); + break; + case BulkAction.export: + const exported = await getExportByObjectIds( + alertsClient, + rules.data.map(({ params }) => ({ rule_id: params.ruleId })) + ); + + const responseBody = `${exported.rulesNdjson}${exported.exportDetails}`; + + return response.ok({ + headers: { + 'Content-Disposition': `attachment; filename="rules_export.ndjson"`, + 'Content-Type': 'application/ndjson', + }, + body: responseBody, + }); + } + + return response.ok({ body: { success: true, rules_count: rules.data.length } }); + } catch (err) { + const error = transformError(err); + return siemResponse.error({ + body: error.message, + statusCode: error.statusCode, + }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/add_tags.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/add_tags.test.ts index 972e44c718f94..a871c7157d5e8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/add_tags.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/add_tags.test.ts @@ -33,8 +33,8 @@ describe('add_tags', () => { const tags2 = addTags(tags1, 'rule-1', false); expect(tags2).toEqual([ 'tag-1', - `${INTERNAL_RULE_ID_KEY}:rule-1`, `${INTERNAL_IMMUTABLE_KEY}:false`, + `${INTERNAL_RULE_ID_KEY}:rule-1`, ]); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/add_tags.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/add_tags.ts index 84a2483938421..6ff4a54ad8e54 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/add_tags.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/add_tags.ts @@ -10,7 +10,7 @@ import { INTERNAL_RULE_ID_KEY, INTERNAL_IMMUTABLE_KEY } from '../../../../common export const addTags = (tags: string[], ruleId: string, immutable: boolean): string[] => { return Array.from( new Set([ - ...tags, + ...tags.filter((tag) => !tag.startsWith(INTERNAL_RULE_ID_KEY)), `${INTERNAL_RULE_ID_KEY}:${ruleId}`, `${INTERNAL_IMMUTABLE_KEY}:${immutable}`, ]) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/delete_rules.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/delete_rules.test.ts index e7a4df790d62d..f581be9e1f62b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/delete_rules.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/delete_rules.test.ts @@ -5,138 +5,74 @@ * 2.0. */ +import { savedObjectsClientMock } from '../../../../../../../src/core/server/mocks'; import { alertsClientMock } from '../../../../../alerting/server/mocks'; +import { ruleStatusSavedObjectsClientMock } from '../signals/__mocks__/rule_status_saved_objects_client.mock'; import { deleteRules } from './delete_rules'; -import { readRules } from './read_rules'; -jest.mock('./read_rules'); +import { deleteNotifications } from '../notifications/delete_notifications'; +import { deleteRuleActionsSavedObject } from '../rule_actions/delete_rule_actions_saved_object'; +import { SavedObjectsFindResult } from '../../../../../../../src/core/server'; +import { IRuleStatusSOAttributes } from './types'; + +jest.mock('../notifications/delete_notifications'); +jest.mock('../rule_actions/delete_rule_actions_saved_object'); describe('deleteRules', () => { let alertsClient: ReturnType; - const notificationId = 'notification-52128c15-0d1b-4716-a4c5-46997ac7f3bd'; - const ruleId = 'rule-04128c15-0d1b-4716-a4c5-46997ac7f3bd'; + let ruleStatusClient: ReturnType; + let savedObjectsClient: ReturnType; beforeEach(() => { alertsClient = alertsClientMock.create(); + savedObjectsClient = savedObjectsClientMock.create(); + ruleStatusClient = ruleStatusSavedObjectsClientMock.create(); }); - it('should return null if notification was not found', async () => { - (readRules as jest.Mock).mockResolvedValue(null); - - const result = await deleteRules({ - alertsClient, - id: notificationId, - ruleId, - }); - - expect(result).toBe(null); - }); - - it('should call alertsClient.delete if notification was found', async () => { - (readRules as jest.Mock).mockResolvedValue({ - id: notificationId, - }); - - const result = await deleteRules({ - alertsClient, - id: notificationId, - ruleId, - }); - - expect(alertsClient.delete).toHaveBeenCalledWith( - expect.objectContaining({ - id: notificationId, - }) - ); - expect(result).toEqual({ id: notificationId }); - }); - - it('should call alertsClient.delete if ruleId was undefined', async () => { - (readRules as jest.Mock).mockResolvedValue({ - id: null, - }); - - const result = await deleteRules({ - alertsClient, - id: notificationId, - ruleId: undefined, - }); - - expect(alertsClient.delete).toHaveBeenCalledWith( - expect.objectContaining({ - id: notificationId, - }) - ); - expect(result).toEqual({ id: null }); - }); - - it('should return null if alertsClient.delete rejects with 404 if ruleId was undefined', async () => { - (readRules as jest.Mock).mockResolvedValue({ - id: null, - }); - - alertsClient.delete.mockRejectedValue({ - output: { - statusCode: 404, + it('should delete the rule along with its notifications, actions, and statuses', async () => { + const ruleStatus: SavedObjectsFindResult = { + id: 'statusId', + type: '', + references: [], + attributes: { + alertId: 'alertId', + statusDate: '', + lastFailureAt: null, + lastFailureMessage: null, + lastSuccessAt: null, + lastSuccessMessage: null, + status: null, + lastLookBackDate: null, + gap: null, + bulkCreateTimeDurations: null, + searchAfterTimeDurations: null, }, - }); + score: 0, + }; - const result = await deleteRules({ + const rule = { alertsClient, - id: notificationId, - ruleId: undefined, - }); - - expect(alertsClient.delete).toHaveBeenCalledWith( - expect.objectContaining({ - id: notificationId, - }) - ); - expect(result).toEqual(null); - }); - - it('should return error object if alertsClient.delete rejects with status different than 404 and if ruleId was undefined', async () => { - (readRules as jest.Mock).mockResolvedValue({ - id: null, - }); - - const errorObject = { - output: { - statusCode: 500, + savedObjectsClient, + ruleStatusClient, + id: 'ruleId', + ruleStatuses: { + total: 0, + per_page: 0, + page: 0, + saved_objects: [ruleStatus], }, }; - alertsClient.delete.mockRejectedValue(errorObject); + await deleteRules(rule); - let errorResult; - try { - await deleteRules({ - alertsClient, - id: notificationId, - ruleId: undefined, - }); - } catch (error) { - errorResult = error; - } - - expect(alertsClient.delete).toHaveBeenCalledWith( - expect.objectContaining({ - id: notificationId, - }) - ); - expect(errorResult).toEqual(errorObject); - }); - - it('should return null if ruleId and id was undefined', async () => { - (readRules as jest.Mock).mockResolvedValue({ - id: null, + expect(alertsClient.delete).toHaveBeenCalledWith({ id: rule.id }); + expect(deleteNotifications).toHaveBeenCalledWith({ + ruleAlertId: rule.id, + alertsClient: expect.any(Object), }); - - const result = await deleteRules({ - alertsClient, - id: undefined, - ruleId: undefined, + expect(deleteRuleActionsSavedObject).toHaveBeenCalledWith({ + ruleAlertId: rule.id, + savedObjectsClient: expect.any(Object), }); - - expect(result).toEqual(null); + expect(ruleStatusClient.delete).toHaveBeenCalledWith(ruleStatus.id); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/delete_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/delete_rules.ts index 3947103e7625d..ed5477599253b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/delete_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/delete_rules.ts @@ -5,30 +5,19 @@ * 2.0. */ -import { readRules } from './read_rules'; +import { deleteNotifications } from '../notifications/delete_notifications'; +import { deleteRuleActionsSavedObject } from '../rule_actions/delete_rule_actions_saved_object'; import { DeleteRuleOptions } from './types'; -export const deleteRules = async ({ alertsClient, id, ruleId }: DeleteRuleOptions) => { - const rule = await readRules({ alertsClient, id, ruleId }); - if (rule == null) { - return null; - } - - if (ruleId != null) { - await alertsClient.delete({ id: rule.id }); - return rule; - } else if (id != null) { - try { - await alertsClient.delete({ id }); - return rule; - } catch (err) { - if (err.output.statusCode === 404) { - return null; - } else { - throw err; - } - } - } else { - return null; - } +export const deleteRules = async ({ + alertsClient, + savedObjectsClient, + ruleStatusClient, + ruleStatuses, + id, +}: DeleteRuleOptions) => { + await alertsClient.delete({ id }); + await deleteNotifications({ alertsClient, ruleAlertId: id }); + await deleteRuleActionsSavedObject({ ruleAlertId: id, savedObjectsClient }); + ruleStatuses.saved_objects.forEach(async (obj) => ruleStatusClient.delete(obj.id)); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.test.ts new file mode 100644 index 0000000000000..3046999a632c6 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.test.ts @@ -0,0 +1,133 @@ +/* + * 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 uuid from 'uuid'; +import { INTERNAL_IMMUTABLE_KEY } from '../../../../common/constants'; +import { duplicateRule } from './duplicate_rule'; + +jest.mock('uuid', () => ({ + v4: jest.fn(), +})); + +describe('duplicateRule', () => { + it('should return a copy of rule with new ruleId', () => { + (uuid.v4 as jest.Mock).mockReturnValue('newId'); + + expect( + duplicateRule({ + id: 'oldTestRuleId', + notifyWhen: 'onActiveAlert', + name: 'test', + tags: ['test', '__internal_rule_id:oldTestRuleId', `${INTERNAL_IMMUTABLE_KEY}:false`], + alertTypeId: 'siem.signals', + consumer: 'siem', + params: { + savedId: undefined, + author: [], + description: 'test', + ruleId: 'oldTestRuleId', + falsePositives: [], + from: 'now-360s', + immutable: false, + license: '', + outputIndex: '.siem-signals-default', + meta: undefined, + maxSignals: 100, + riskScore: 42, + riskScoreMapping: [], + severity: 'low', + severityMapping: [], + threat: [], + to: 'now', + references: [], + version: 1, + exceptionsList: [], + type: 'query', + language: 'kuery', + index: [], + query: 'process.args : "chmod"', + filters: [], + buildingBlockType: undefined, + note: undefined, + timelineId: undefined, + timelineTitle: undefined, + ruleNameOverride: undefined, + timestampOverride: undefined, + }, + schedule: { + interval: '5m', + }, + enabled: false, + actions: [], + throttle: null, + apiKeyOwner: 'kibana', + createdBy: 'kibana', + updatedBy: 'kibana', + muteAll: false, + mutedInstanceIds: [], + updatedAt: new Date(2021, 0), + createdAt: new Date(2021, 0), + scheduledTaskId: undefined, + executionStatus: { + lastExecutionDate: new Date(2021, 0), + status: 'ok', + }, + }) + ).toMatchInlineSnapshot(` + Object { + "actions": Array [], + "alertTypeId": "siem.signals", + "consumer": "siem", + "enabled": false, + "name": "test [Duplicate]", + "notifyWhen": null, + "params": Object { + "author": Array [], + "buildingBlockType": undefined, + "description": "test", + "exceptionsList": Array [], + "falsePositives": Array [], + "filters": Array [], + "from": "now-360s", + "immutable": false, + "index": Array [], + "language": "kuery", + "license": "", + "maxSignals": 100, + "meta": undefined, + "note": undefined, + "outputIndex": ".siem-signals-default", + "query": "process.args : \\"chmod\\"", + "references": Array [], + "riskScore": 42, + "riskScoreMapping": Array [], + "ruleId": "newId", + "ruleNameOverride": undefined, + "savedId": undefined, + "severity": "low", + "severityMapping": Array [], + "threat": Array [], + "timelineId": undefined, + "timelineTitle": undefined, + "timestampOverride": undefined, + "to": "now", + "type": "query", + "version": 1, + }, + "schedule": Object { + "interval": "5m", + }, + "tags": Array [ + "test", + "__internal_immutable:false", + "__internal_rule_id:newId", + ], + "throttle": null, + } + `); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.ts new file mode 100644 index 0000000000000..2f12e33507422 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.ts @@ -0,0 +1,40 @@ +/* + * 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 uuid from 'uuid'; +import { i18n } from '@kbn/i18n'; +import { SanitizedAlert } from '../../../../../alerting/common'; +import { SERVER_APP_ID, SIGNALS_ID } from '../../../../common/constants'; +import { InternalRuleCreate, RuleParams } from '../schemas/rule_schemas'; +import { addTags } from './add_tags'; + +const DUPLICATE_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.cloneRule.duplicateTitle', + { + defaultMessage: 'Duplicate', + } +); + +export const duplicateRule = (rule: SanitizedAlert): InternalRuleCreate => { + const newRuleId = uuid.v4(); + return { + name: `${rule.name} [${DUPLICATE_TITLE}]`, + tags: addTags(rule.tags, newRuleId, false), + alertTypeId: SIGNALS_ID, + consumer: SERVER_APP_ID, + params: { + ...rule.params, + immutable: false, + ruleId: newRuleId, + }, + schedule: rule.schedule, + enabled: false, + actions: rule.actions, + throttle: null, + notifyWhen: null, + }; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/enable_rule.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/enable_rule.ts new file mode 100644 index 0000000000000..dc4cca2059b3e --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/enable_rule.ts @@ -0,0 +1,47 @@ +/* + * 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 { SavedObjectsClientContract } from 'kibana/server'; +import { SanitizedAlert } from '../../../../../alerting/common'; +import { AlertsClient } from '../../../../../alerting/server'; +import { RuleParams } from '../schemas/rule_schemas'; +import { ruleStatusSavedObjectsClientFactory } from '../signals/rule_status_saved_objects_client'; + +interface EnableRuleArgs { + rule: SanitizedAlert; + alertsClient: AlertsClient; + savedObjectsClient: SavedObjectsClientContract; +} + +/** + * Enables the rule and updates its status to 'going to run' + * + * @param rule - rule to enable + * @param alertsClient - Alerts client + * @param savedObjectsClient - Saved Objects client + */ +export const enableRule = async ({ rule, alertsClient, savedObjectsClient }: EnableRuleArgs) => { + await alertsClient.enable({ id: rule.id }); + + const ruleStatusClient = ruleStatusSavedObjectsClientFactory(savedObjectsClient); + const ruleCurrentStatus = await ruleStatusClient.find({ + perPage: 1, + sortField: 'statusDate', + sortOrder: 'desc', + search: rule.id, + searchFields: ['alertId'], + }); + + // set current status for this rule to be 'going to run' + if (ruleCurrentStatus && ruleCurrentStatus.saved_objects.length > 0) { + const currentStatusToDisable = ruleCurrentStatus.saved_objects[0]; + await ruleStatusClient.update(currentStatusToDisable.id, { + ...currentStatusToDisable.attributes, + status: 'going to run', + }); + } +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/find_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/find_rules.ts index 754aaf67c3224..eae5ccd2f6ffd 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/find_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/find_rules.ts @@ -18,7 +18,7 @@ export const getFilter = (filter: string | null | undefined) => { } }; -export const findRules = async ({ +export const findRules = ({ alertsClient, perPage, page, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts index b9a88bc36a812..72af7ebc340cd 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts @@ -5,19 +5,19 @@ * 2.0. */ -import { defaults } from 'lodash/fp'; import { validate } from '@kbn/securitysolution-io-ts-utils'; +import { defaults } from 'lodash/fp'; import { PartialAlert } from '../../../../../alerting/server'; import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; -import { PatchRulesOptions } from './types'; -import { addTags } from './add_tags'; -import { calculateVersion, calculateName, calculateInterval, removeUndefined } from './utils'; -import { ruleStatusSavedObjectsClientFactory } from '../signals/rule_status_saved_objects_client'; -import { internalRuleUpdate, RuleParams } from '../schemas/rule_schemas'; import { normalizeMachineLearningJobIds, normalizeThresholdObject, } from '../../../../common/detection_engine/utils'; +import { internalRuleUpdate, RuleParams } from '../schemas/rule_schemas'; +import { addTags } from './add_tags'; +import { enableRule } from './enable_rule'; +import { PatchRulesOptions } from './types'; +import { calculateInterval, calculateName, calculateVersion, removeUndefined } from './utils'; class PatchError extends Error { public readonly statusCode: number; @@ -200,25 +200,7 @@ export const patchRules = async ({ if (rule.enabled && enabled === false) { await alertsClient.disable({ id: rule.id }); } else if (!rule.enabled && enabled === true) { - await alertsClient.enable({ id: rule.id }); - - const ruleStatusClient = ruleStatusSavedObjectsClientFactory(savedObjectsClient); - const ruleCurrentStatus = await ruleStatusClient.find({ - perPage: 1, - sortField: 'statusDate', - sortOrder: 'desc', - search: rule.id, - searchFields: ['alertId'], - }); - - // set current status for this rule to be 'going to run' - if (ruleCurrentStatus && ruleCurrentStatus.saved_objects.length > 0) { - const currentStatusToDisable = ruleCurrentStatus.saved_objects[0]; - await ruleStatusClient.update(currentStatusToDisable.id, { - ...currentStatusToDisable.attributes, - status: 'going to run', - }); - } + await enableRule({ rule, alertsClient, savedObjectsClient }); } else { // enabled is null or undefined and we do not touch the rule } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts index 601f3ebaa0f9e..d029393ce781e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts @@ -68,6 +68,7 @@ import { MetaOrUndefined, Description, Enabled, + Id, IdOrUndefined, RuleIdOrUndefined, EnabledOrUndefined, @@ -105,6 +106,7 @@ import { Alert, SanitizedAlert } from '../../../../../alerting/common'; import { SIGNALS_ID } from '../../../../common/constants'; import { PartialFilter } from '../types'; import { RuleParams } from '../schemas/rule_schemas'; +import { RuleStatusSavedObjectsClient } from '../signals/rule_status_saved_objects_client'; export type RuleAlertType = Alert; @@ -329,8 +331,10 @@ export interface ReadRuleOptions { export interface DeleteRuleOptions { alertsClient: AlertsClient; - id: IdOrUndefined; - ruleId: RuleIdOrUndefined; + savedObjectsClient: SavedObjectsClientContract; + ruleStatusClient: RuleStatusSavedObjectsClient; + ruleStatuses: SavedObjectsFindResponse; + id: Id; } export interface FindRuleOptions { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts index 38cae8d1cf50f..0fac804163afa 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts @@ -13,9 +13,9 @@ import { PartialAlert } from '../../../../../alerting/server'; import { readRules } from './read_rules'; import { UpdateRulesOptions } from './types'; import { addTags } from './add_tags'; -import { ruleStatusSavedObjectsClientFactory } from '../signals/rule_status_saved_objects_client'; import { typeSpecificSnakeToCamel } from '../schemas/rule_converters'; import { InternalRuleUpdate, RuleParams } from '../schemas/rule_schemas'; +import { enableRule } from './enable_rule'; export const updateRules = async ({ alertsClient, @@ -88,25 +88,7 @@ export const updateRules = async ({ if (existingRule.enabled && enabled === false) { await alertsClient.disable({ id: existingRule.id }); } else if (!existingRule.enabled && enabled === true) { - await alertsClient.enable({ id: existingRule.id }); - - const ruleStatusClient = ruleStatusSavedObjectsClientFactory(savedObjectsClient); - const ruleCurrentStatus = await ruleStatusClient.find({ - perPage: 1, - sortField: 'statusDate', - sortOrder: 'desc', - search: existingRule.id, - searchFields: ['alertId'], - }); - - // set current status for this rule to be 'going to run' - if (ruleCurrentStatus && ruleCurrentStatus.saved_objects.length > 0) { - const currentStatusToDisable = ruleCurrentStatus.saved_objects[0]; - await ruleStatusClient.update(currentStatusToDisable.id, { - ...currentStatusToDisable.attributes, - status: 'going to run', - }); - } + await enableRule({ rule: existingRule, alertsClient, savedObjectsClient }); } return { ...update, enabled }; }; diff --git a/x-pack/plugins/security_solution/server/routes/index.ts b/x-pack/plugins/security_solution/server/routes/index.ts index 0245d4cb99cc0..00de66c0dec28 100644 --- a/x-pack/plugins/security_solution/server/routes/index.ts +++ b/x-pack/plugins/security_solution/server/routes/index.ts @@ -31,6 +31,7 @@ import { createRulesBulkRoute } from '../lib/detection_engine/routes/rules/creat import { updateRulesBulkRoute } from '../lib/detection_engine/routes/rules/update_rules_bulk_route'; import { patchRulesBulkRoute } from '../lib/detection_engine/routes/rules/patch_rules_bulk_route'; import { deleteRulesBulkRoute } from '../lib/detection_engine/routes/rules/delete_rules_bulk_route'; +import { performBulkActionRoute } from '../lib/detection_engine/routes/rules/perform_bulk_action_route'; import { importRulesRoute } from '../lib/detection_engine/routes/rules/import_rules_route'; import { exportRulesRoute } from '../lib/detection_engine/routes/rules/export_rules_route'; import { findRulesStatusesRoute } from '../lib/detection_engine/routes/rules/find_rules_status_route'; @@ -81,6 +82,7 @@ export const initRoutes = ( updateRulesBulkRoute(router, ml); patchRulesBulkRoute(router, ml); deleteRulesBulkRoute(router); + performBulkActionRoute(router, ml); createTimelinesRoute(router, config, security); patchTimelinesRoute(router, config, security); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 7f82d9f32a868..879c666cff672 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -20103,7 +20103,6 @@ "xpack.securitySolution.detectionEngine.rules.allRules.tabs.monitoring": "ルール監視", "xpack.securitySolution.detectionEngine.rules.allRules.tabs.rules": "ルール", "xpack.securitySolution.detectionEngine.rules.backOptionsHeader": "検出に戻る", - "xpack.securitySolution.detectionEngine.rules.components.genericDownloader.exportFailureTitle": "データをエクスポートできませんでした…", "xpack.securitySolution.detectionEngine.rules.components.ruleActionsOverflow.allActionsTitle": "すべてのアクション", "xpack.securitySolution.detectionEngine.rules.continueButtonTitle": "続行", "xpack.securitySolution.detectionEngine.rules.create.successfullyCreatedRuleTitle": "{ruleName}が作成されました", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index c3c0402ef27ab..ac1a8d69796a2 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -20388,13 +20388,11 @@ "xpack.securitySolution.detectionEngine.rules.allRules.showingExceptionLists": "正在显示 {totalLists} 个{totalLists, plural, other {列表}}", "xpack.securitySolution.detectionEngine.rules.allRules.showingRulesTitle": "正在显示 {totalRules} 个{totalRules, plural, other {规则}}", "xpack.securitySolution.detectionEngine.rules.allRules.successfullyDuplicatedRulesTitle": "已成功复制 {totalRules, plural, other {{totalRules} 个规则}}", - "xpack.securitySolution.detectionEngine.rules.allRules.successfullyExportedRulesTitle": "已成功导出{totalRules, plural, =0 {所有规则} other { {totalRules} 个规则}}", "xpack.securitySolution.detectionEngine.rules.allRules.tableTitle": "所有规则", "xpack.securitySolution.detectionEngine.rules.allRules.tabs.exceptions": "例外列表", "xpack.securitySolution.detectionEngine.rules.allRules.tabs.monitoring": "规则监测", "xpack.securitySolution.detectionEngine.rules.allRules.tabs.rules": "规则", "xpack.securitySolution.detectionEngine.rules.backOptionsHeader": "返回到检测", - "xpack.securitySolution.detectionEngine.rules.components.genericDownloader.exportFailureTitle": "无法导出数据……", "xpack.securitySolution.detectionEngine.rules.components.ruleActionsOverflow.allActionsTitle": "所有操作", "xpack.securitySolution.detectionEngine.rules.continueButtonTitle": "继续", "xpack.securitySolution.detectionEngine.rules.create.successfullyCreatedRuleTitle": "{ruleName} 已创建", diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts index fd3675a2e47e6..3c5e04ee1f64e 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts @@ -35,6 +35,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./update_rules')); loadTestFile(require.resolve('./update_rules_bulk')); loadTestFile(require.resolve('./patch_rules_bulk')); + loadTestFile(require.resolve('./perform_bulk_action')); loadTestFile(require.resolve('./patch_rules')); loadTestFile(require.resolve('./read_privileges')); loadTestFile(require.resolve('./query_signals')); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/perform_bulk_action.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/perform_bulk_action.ts new file mode 100644 index 0000000000000..53613624067e1 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/perform_bulk_action.ts @@ -0,0 +1,152 @@ +/* + * 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 expect from '@kbn/expect'; + +import { + DETECTION_ENGINE_RULES_BULK_ACTION, + DETECTION_ENGINE_RULES_URL, +} from '../../../../plugins/security_solution/common/constants'; +import { BulkAction } from '../../../../plugins/security_solution/common/detection_engine/schemas/common/schemas'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + binaryToString, + createRule, + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getSimpleRule, + getSimpleRuleOutput, + removeServerGeneratedProperties, +} from '../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + + describe('perform_bulk_action', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(supertest); + }); + + it('should export rules', async () => { + await createRule(supertest, getSimpleRule()); + + const { body } = await supertest + .post(DETECTION_ENGINE_RULES_BULK_ACTION) + .set('kbn-xsrf', 'true') + .send({ query: '', action: BulkAction.export }) + .expect(200) + .expect('Content-Type', 'application/ndjson') + .expect('Content-Disposition', 'attachment; filename="rules_export.ndjson"') + .parse(binaryToString); + + const [ruleJson, exportDetailsJson] = body.toString().split(/\n/); + + const rule = removeServerGeneratedProperties(JSON.parse(ruleJson)); + expect(rule).to.eql(getSimpleRuleOutput()); + + const exportDetails = JSON.parse(exportDetailsJson); + expect(exportDetails).to.eql({ + exported_count: 1, + missing_rules: [], + missing_rules_count: 0, + }); + }); + + it('should delete rules', async () => { + const ruleId = 'ruleId'; + await createRule(supertest, getSimpleRule(ruleId)); + + const { body } = await supertest + .post(DETECTION_ENGINE_RULES_BULK_ACTION) + .set('kbn-xsrf', 'true') + .send({ query: '', action: BulkAction.delete }) + .expect(200); + + expect(body).to.eql({ success: true, rules_count: 1 }); + + await supertest + .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=${ruleId}`) + .set('kbn-xsrf', 'true') + .expect(404); + }); + + it('should enable rules', async () => { + const ruleId = 'ruleId'; + await createRule(supertest, getSimpleRule(ruleId)); + + const { body } = await supertest + .post(DETECTION_ENGINE_RULES_BULK_ACTION) + .set('kbn-xsrf', 'true') + .send({ query: '', action: BulkAction.enable }) + .expect(200); + + expect(body).to.eql({ success: true, rules_count: 1 }); + + const { body: ruleBody } = await supertest + .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=${ruleId}`) + .set('kbn-xsrf', 'true') + .expect(200); + + const referenceRule = getSimpleRuleOutput(ruleId); + referenceRule.enabled = true; + + const storedRule = removeServerGeneratedProperties(ruleBody); + + expect(storedRule).to.eql(referenceRule); + }); + + it('should disable rules', async () => { + const ruleId = 'ruleId'; + await createRule(supertest, getSimpleRule(ruleId, true)); + + const { body } = await supertest + .post(DETECTION_ENGINE_RULES_BULK_ACTION) + .set('kbn-xsrf', 'true') + .send({ query: '', action: BulkAction.disable }) + .expect(200); + + expect(body).to.eql({ success: true, rules_count: 1 }); + + const { body: ruleBody } = await supertest + .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=${ruleId}`) + .set('kbn-xsrf', 'true') + .expect(200); + + const referenceRule = getSimpleRuleOutput(ruleId); + const storedRule = removeServerGeneratedProperties(ruleBody); + + expect(storedRule).to.eql(referenceRule); + }); + + it('should duplicate rules', async () => { + const ruleId = 'ruleId'; + await createRule(supertest, getSimpleRule(ruleId)); + + const { body } = await supertest + .post(DETECTION_ENGINE_RULES_BULK_ACTION) + .set('kbn-xsrf', 'true') + .send({ query: '', action: BulkAction.duplicate }) + .expect(200); + + expect(body).to.eql({ success: true, rules_count: 1 }); + + const { body: rulesResponse } = await supertest + .get(`${DETECTION_ENGINE_RULES_URL}/_find`) + .set('kbn-xsrf', 'true') + .expect(200); + + expect(rulesResponse.total).to.eql(2); + }); + }); +};