From 1e6652626fded3bf1372492fa7e556608aa07b5d Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Tue, 16 Feb 2021 20:58:52 -0700 Subject: [PATCH] [Security Solutions][Detection Engine] Adds a warning banner when the alerts data has not been migrated yet. (#90258) ## Summary Adds a warning banner for when the alerting/signals data has not been migrated to the new structure. Although we are planning on supporting some backwards compatibility where the rules don't completely blow up, this support of backwards compatibility is going to be best effort and not have explicit tests and checks against backwards compatibility. Hence the reason we need to alert any users of the system when we can that they should have an administrator visit the detections page to start a migration. From previous reasons why we don't migrate on startup of Kibana is that there are multiple instances running and it might be a worse situation so we migrate on page visit by an administrator to reduce chances of issues. In the future we might revisit this decision but for now this is what we have moved forward with. If the user does not have sufficient privileges such as t1 analyst to see if they have should upgrade, no message is shown to those users. This PR adds the following banner which is non-dismissible to: * Main detections page * Manage rules page * View/Edit rules page Screen Shot 2021-02-03 at 4 16 00 PM If other dismissible alerts are on the page then you will get a stacked effect until you dismiss those messages. Again, this message cannot be dismissed intentionally to let the user know that they should contact an administrator to update/upgrade the alerting/signal data: Screen Shot 2021-02-03 at 5 41 57 PM Other items of note: * Added ability to remove the button from the callouts * Consolidated in one area some types * Removed one part of the callout that has branching logic we never activate. We can re-add that later if we do have a need for it * e2e Cypress tests added to detect when the banner should be present * Backfilled unit tests for enzyme for some of the callout code Manual testing: Bump this number in your dev env: https://github.com/elastic/kibana/blob/master/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.ts#L11 Give yourself these permissions (or use one of the scripts for creating these roles): Screen Shot 2021-02-05 at 1 49 02 PM Visit the page. ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) --- .../security_solution/common/utility_types.ts | 6 + ..._detection_callouts_index_outdated.spec.ts | 196 ++++++++++++++++++ ...alerts_detection_callouts_readonly.spec.ts | 147 ++++++++----- .../cypress/tasks/common/callouts.ts | 6 +- .../components/callouts/callout.test.tsx | 114 ++++++++++ .../common/components/callouts/callout.tsx | 14 +- .../callouts/callout_description.tsx | 26 --- .../callouts/callout_persistent_switcher.tsx | 24 +++ .../components/callouts/callout_types.ts | 4 +- .../common/components/callouts/index.ts | 1 + .../index.test.tsx | 195 +++++++++++++++++ .../need_admin_for_update_callout/index.tsx | 52 +++++ .../translations.tsx | 52 +++++ .../detection_engine/detection_engine.tsx | 2 + .../detection_engine/rules/details/index.tsx | 2 + .../pages/detection_engine/rules/index.tsx | 2 + .../factory/hosts/all/query.all_hosts.dsl.ts | 2 +- 17 files changed, 759 insertions(+), 86 deletions(-) create mode 100644 x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_detection_callouts_index_outdated.spec.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/callouts/callout.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/common/components/callouts/callout_description.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/callouts/callout_persistent_switcher.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/callouts/need_admin_for_update_callout/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/callouts/need_admin_for_update_callout/index.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/callouts/need_admin_for_update_callout/translations.tsx diff --git a/x-pack/plugins/security_solution/common/utility_types.ts b/x-pack/plugins/security_solution/common/utility_types.ts index 3c13e6af837bc0..498b18dccaca56 100644 --- a/x-pack/plugins/security_solution/common/utility_types.ts +++ b/x-pack/plugins/security_solution/common/utility_types.ts @@ -36,6 +36,12 @@ export const stringEnum = (enumObj: T, enumName = 'enum') => * * Optionally you can avoid the use of this by using early returns and TypeScript will clear your type checking without complaints * but there are situations and times where this function might still be needed. + * + * If you see an error, DO NOT cast "as never" such as: + * assertUnreachable(x as never) // BUG IN YOUR CODE NOW AND IT WILL THROW DURING RUNTIME + * If you see code like that remove it, as that deactivates the intent of this utility. + * If you need to do that, then you should remove assertUnreachable from your code and + * use a default at the end of the switch instead. * @param x Unreachable field * @param message Message of error thrown */ diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_detection_callouts_index_outdated.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_detection_callouts_index_outdated.spec.ts new file mode 100644 index 00000000000000..1c6c604b84fbb2 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_detection_callouts_index_outdated.spec.ts @@ -0,0 +1,196 @@ +/* + * 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 { ROLES } from '../../../common/test'; +import { DETECTIONS_RULE_MANAGEMENT_URL, DETECTIONS_URL } from '../../urls/navigation'; +import { newRule } from '../../objects/rule'; +import { PAGE_TITLE } from '../../screens/common/page'; + +import { + login, + loginAndWaitForPageWithoutDateRange, + waitForPageWithoutDateRange, +} from '../../tasks/login'; +import { waitForAlertsIndexToBeCreated } from '../../tasks/alerts'; +import { goToRuleDetails } from '../../tasks/alerts_detection_rules'; +import { createCustomRule, deleteCustomRule } from '../../tasks/api_calls/rules'; +import { getCallOut, waitForCallOutToBeShown } from '../../tasks/common/callouts'; +import { cleanKibana } from '../../tasks/common'; + +const loadPageAsPlatformEngineerUser = (url: string) => { + waitForPageWithoutDateRange(url, ROLES.soc_manager); + waitForPageTitleToBeShown(); +}; + +const waitForPageTitleToBeShown = () => { + cy.get(PAGE_TITLE).should('be.visible'); +}; + +describe('Detections > Need Admin Callouts indicating an admin is needed to migrate the alert data set', () => { + const NEED_ADMIN_FOR_UPDATE_CALLOUT = 'need-admin-for-update-rules'; + + before(() => { + // First, we have to open the app on behalf of a privileged user in order to initialize it. + // Otherwise the app will be disabled and show a "welcome"-like page. + cleanKibana(); + loginAndWaitForPageWithoutDateRange(DETECTIONS_URL, ROLES.platform_engineer); + waitForAlertsIndexToBeCreated(); + + // After that we can login as a soc manager. + login(ROLES.soc_manager); + }); + + context( + 'The users index_mapping_outdated is "true" and their admin callouts should show up', + () => { + beforeEach(() => { + // Index mapping outdated is forced to return true as being outdated so that we get the + // need admin callouts being shown. + cy.intercept('GET', '/api/detection_engine/index', { + index_mapping_outdated: true, + name: '.siem-signals-default', + }); + }); + context('On Detections home page', () => { + beforeEach(() => { + loadPageAsPlatformEngineerUser(DETECTIONS_URL); + }); + + it('We show the need admin primary callout', () => { + waitForCallOutToBeShown(NEED_ADMIN_FOR_UPDATE_CALLOUT, 'primary'); + }); + }); + + context('On Rules Management page', () => { + beforeEach(() => { + loadPageAsPlatformEngineerUser(DETECTIONS_RULE_MANAGEMENT_URL); + }); + + it('We show 1 primary callout of need admin', () => { + waitForCallOutToBeShown(NEED_ADMIN_FOR_UPDATE_CALLOUT, 'primary'); + }); + }); + + context('On Rule Details page', () => { + beforeEach(() => { + createCustomRule(newRule); + loadPageAsPlatformEngineerUser(DETECTIONS_RULE_MANAGEMENT_URL); + waitForPageTitleToBeShown(); + goToRuleDetails(); + }); + + afterEach(() => { + deleteCustomRule(); + }); + + it('We show 1 primary callout', () => { + waitForCallOutToBeShown(NEED_ADMIN_FOR_UPDATE_CALLOUT, 'primary'); + }); + }); + } + ); + + context( + 'The users index_mapping_outdated is "false" and their admin callouts should not show up ', + () => { + beforeEach(() => { + // Index mapping outdated is forced to return true as being outdated so that we get the + // need admin callouts being shown. + cy.intercept('GET', '/api/detection_engine/index', { + index_mapping_outdated: false, + name: '.siem-signals-default', + }); + }); + context('On Detections home page', () => { + beforeEach(() => { + loadPageAsPlatformEngineerUser(DETECTIONS_URL); + }); + + it('We show the need admin primary callout', () => { + getCallOut(NEED_ADMIN_FOR_UPDATE_CALLOUT).should('not.exist'); + }); + }); + + context('On Rules Management page', () => { + beforeEach(() => { + loadPageAsPlatformEngineerUser(DETECTIONS_RULE_MANAGEMENT_URL); + }); + + it('We show 1 primary callout of need admin', () => { + getCallOut(NEED_ADMIN_FOR_UPDATE_CALLOUT).should('not.exist'); + }); + }); + + context('On Rule Details page', () => { + beforeEach(() => { + createCustomRule(newRule); + loadPageAsPlatformEngineerUser(DETECTIONS_RULE_MANAGEMENT_URL); + waitForPageTitleToBeShown(); + goToRuleDetails(); + }); + + afterEach(() => { + deleteCustomRule(); + }); + + it('We show 1 primary callout', () => { + getCallOut(NEED_ADMIN_FOR_UPDATE_CALLOUT).should('not.exist'); + }); + }); + } + ); + + context( + 'The users index_mapping_outdated is "null" and their admin callouts should not show up ', + () => { + beforeEach(() => { + // Index mapping outdated is forced to return true as being outdated so that we get the + // need admin callouts being shown. + cy.intercept('GET', '/api/detection_engine/index', { + index_mapping_outdated: null, + name: '.siem-signals-default', + }); + }); + context('On Detections home page', () => { + beforeEach(() => { + loadPageAsPlatformEngineerUser(DETECTIONS_URL); + }); + + it('We show the need admin primary callout', () => { + getCallOut(NEED_ADMIN_FOR_UPDATE_CALLOUT).should('not.exist'); + }); + }); + + context('On Rules Management page', () => { + beforeEach(() => { + loadPageAsPlatformEngineerUser(DETECTIONS_RULE_MANAGEMENT_URL); + }); + + it('We show 1 primary callout of need admin', () => { + getCallOut(NEED_ADMIN_FOR_UPDATE_CALLOUT).should('not.exist'); + }); + }); + + context('On Rule Details page', () => { + beforeEach(() => { + createCustomRule(newRule); + loadPageAsPlatformEngineerUser(DETECTIONS_RULE_MANAGEMENT_URL); + waitForPageTitleToBeShown(); + goToRuleDetails(); + }); + + afterEach(() => { + deleteCustomRule(); + }); + + it('We show 1 primary callout', () => { + getCallOut(NEED_ADMIN_FOR_UPDATE_CALLOUT).should('not.exist'); + }); + }); + } + ); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_detection_callouts_readonly.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_detection_callouts_readonly.spec.ts index 85257f7d9176f5..d807857cd72bde 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_detection_callouts_readonly.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_detection_callouts_readonly.spec.ts @@ -26,6 +26,11 @@ const loadPageAsReadOnlyUser = (url: string) => { waitForPageTitleToBeShown(); }; +const loadPageAsPlatformEngineer = (url: string) => { + waitForPageWithoutDateRange(url, ROLES.platform_engineer); + waitForPageTitleToBeShown(); +}; + const reloadPage = () => { cy.reload(); waitForPageTitleToBeShown(); @@ -35,7 +40,7 @@ const waitForPageTitleToBeShown = () => { cy.get(PAGE_TITLE).should('be.visible'); }; -describe('Detections > Callouts indicating read-only access to resources', () => { +describe('Detections > Callouts', () => { const ALERTS_CALLOUT = 'read-only-access-to-alerts'; const RULES_CALLOUT = 'read-only-access-to-rules'; @@ -50,75 +55,119 @@ describe('Detections > Callouts indicating read-only access to resources', () => login(ROLES.reader); }); - context('On Detections home page', () => { - beforeEach(() => { - loadPageAsReadOnlyUser(DETECTIONS_URL); - }); - - it('We show one primary callout', () => { - waitForCallOutToBeShown(ALERTS_CALLOUT, 'primary'); - }); + context('indicating read-only access to resources', () => { + context('On Detections home page', () => { + beforeEach(() => { + loadPageAsReadOnlyUser(DETECTIONS_URL); + }); - context('When a user clicks Dismiss on the callout', () => { - it('We hide it and persist the dismissal', () => { + it('We show one primary callout', () => { waitForCallOutToBeShown(ALERTS_CALLOUT, 'primary'); - dismissCallOut(ALERTS_CALLOUT); - reloadPage(); - getCallOut(ALERTS_CALLOUT).should('not.exist'); }); - }); - }); - context('On Rules Management page', () => { - beforeEach(() => { - loadPageAsReadOnlyUser(DETECTIONS_RULE_MANAGEMENT_URL); + context('When a user clicks Dismiss on the callout', () => { + it('We hide it and persist the dismissal', () => { + waitForCallOutToBeShown(ALERTS_CALLOUT, 'primary'); + dismissCallOut(ALERTS_CALLOUT); + reloadPage(); + getCallOut(ALERTS_CALLOUT).should('not.exist'); + }); + }); }); - it('We show one primary callout', () => { - waitForCallOutToBeShown(RULES_CALLOUT, 'primary'); - }); + context('On Rules Management page', () => { + beforeEach(() => { + loadPageAsReadOnlyUser(DETECTIONS_RULE_MANAGEMENT_URL); + }); - context('When a user clicks Dismiss on the callout', () => { - it('We hide it and persist the dismissal', () => { + it('We show one primary callout', () => { waitForCallOutToBeShown(RULES_CALLOUT, 'primary'); - dismissCallOut(RULES_CALLOUT); - reloadPage(); - getCallOut(RULES_CALLOUT).should('not.exist'); }); - }); - }); - context('On Rule Details page', () => { - beforeEach(() => { - createCustomRule(newRule); - loadPageAsReadOnlyUser(DETECTIONS_RULE_MANAGEMENT_URL); - waitForPageTitleToBeShown(); - goToRuleDetails(); + context('When a user clicks Dismiss on the callout', () => { + it('We hide it and persist the dismissal', () => { + waitForCallOutToBeShown(RULES_CALLOUT, 'primary'); + dismissCallOut(RULES_CALLOUT); + reloadPage(); + getCallOut(RULES_CALLOUT).should('not.exist'); + }); + }); }); - afterEach(() => { - deleteCustomRule(); - }); + context('On Rule Details page', () => { + beforeEach(() => { + createCustomRule(newRule); + loadPageAsReadOnlyUser(DETECTIONS_RULE_MANAGEMENT_URL); + waitForPageTitleToBeShown(); + goToRuleDetails(); + }); - it('We show two primary callouts', () => { - waitForCallOutToBeShown(ALERTS_CALLOUT, 'primary'); - waitForCallOutToBeShown(RULES_CALLOUT, 'primary'); - }); + afterEach(() => { + deleteCustomRule(); + }); - context('When a user clicks Dismiss on the callouts', () => { - it('We hide them and persist the dismissal', () => { + it('We show two primary callouts', () => { waitForCallOutToBeShown(ALERTS_CALLOUT, 'primary'); waitForCallOutToBeShown(RULES_CALLOUT, 'primary'); + }); - dismissCallOut(ALERTS_CALLOUT); - reloadPage(); + context('When a user clicks Dismiss on the callouts', () => { + it('We hide them and persist the dismissal', () => { + waitForCallOutToBeShown(ALERTS_CALLOUT, 'primary'); + waitForCallOutToBeShown(RULES_CALLOUT, 'primary'); + dismissCallOut(ALERTS_CALLOUT); + reloadPage(); + + getCallOut(ALERTS_CALLOUT).should('not.exist'); + getCallOut(RULES_CALLOUT).should('be.visible'); + + dismissCallOut(RULES_CALLOUT); + reloadPage(); + + getCallOut(ALERTS_CALLOUT).should('not.exist'); + getCallOut(RULES_CALLOUT).should('not.exist'); + }); + }); + }); + }); + + context('indicating read-write access to resources', () => { + context('On Detections home page', () => { + beforeEach(() => { + loadPageAsPlatformEngineer(DETECTIONS_URL); + }); + + it('We show no callout', () => { + getCallOut(ALERTS_CALLOUT).should('not.exist'); + getCallOut(RULES_CALLOUT).should('not.exist'); + }); + }); + + context('On Rules Management page', () => { + beforeEach(() => { + loadPageAsPlatformEngineer(DETECTIONS_RULE_MANAGEMENT_URL); + }); + + it('We show no callout', () => { getCallOut(ALERTS_CALLOUT).should('not.exist'); - getCallOut(RULES_CALLOUT).should('be.visible'); + getCallOut(RULES_CALLOUT).should('not.exist'); + }); + }); - dismissCallOut(RULES_CALLOUT); - reloadPage(); + context('On Rule Details page', () => { + beforeEach(() => { + createCustomRule(newRule); + loadPageAsPlatformEngineer(DETECTIONS_RULE_MANAGEMENT_URL); + waitForPageTitleToBeShown(); + goToRuleDetails(); + }); + + afterEach(() => { + deleteCustomRule(); + }); + it('We show no callouts', () => { getCallOut(ALERTS_CALLOUT).should('not.exist'); getCallOut(RULES_CALLOUT).should('not.exist'); }); diff --git a/x-pack/plugins/security_solution/cypress/tasks/common/callouts.ts b/x-pack/plugins/security_solution/cypress/tasks/common/callouts.ts index 4139c911e4063d..8440409f80f38e 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/common/callouts.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/common/callouts.ts @@ -12,13 +12,11 @@ export const getCallOut = (id: string, options?: Cypress.Timeoutable) => { }; export const waitForCallOutToBeShown = (id: string, color: string) => { - getCallOut(id, { timeout: 10000 }) - .should('be.visible') - .should('have.class', `euiCallOut--${color}`); + getCallOut(id).should('be.visible').should('have.class', `euiCallOut--${color}`); }; export const dismissCallOut = (id: string) => { - getCallOut(id, { timeout: 10000 }).within(() => { + getCallOut(id).within(() => { cy.get(CALLOUT_DISMISS_BTN).should('be.visible').click(); cy.root().should('not.exist'); }); diff --git a/x-pack/plugins/security_solution/public/common/components/callouts/callout.test.tsx b/x-pack/plugins/security_solution/public/common/components/callouts/callout.test.tsx new file mode 100644 index 00000000000000..f908a79361d0a5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/callouts/callout.test.tsx @@ -0,0 +1,114 @@ +/* + * 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 { mount } from 'enzyme'; +import React from 'react'; +import { TestProviders } from '../../mock'; +import { CallOut } from './callout'; +import { CallOutMessage } from './callout_types'; + +describe('callout', () => { + let message: CallOutMessage = { + type: 'primary', + id: 'some-id', + title: 'title', + description: <>{'some description'}, + }; + + beforeEach(() => { + message = { + type: 'primary', + id: 'some-id', + title: 'title', + description: <>{'some description'}, + }; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('renders the callout data-test-subj from the given id', () => { + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-some-id"]')).toEqual(true); + }); + + test('renders the callout dismiss button by default', () => { + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-dismiss-btn"]')).toEqual(true); + }); + + test('renders the callout dismiss button if given an explicit true to enable it', () => { + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-dismiss-btn"]')).toEqual(true); + }); + + test('Does NOT render the callout dismiss button if given an explicit false to disable it', () => { + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-dismiss-btn"]')).toEqual(false); + }); + + test('onDismiss callback operates when dismiss button is clicked', () => { + const onDismiss = jest.fn(); + const wrapper = mount( + + + + ); + wrapper.find('[data-test-subj="callout-dismiss-btn"]').first().simulate('click'); + expect(onDismiss).toBeCalledWith(message); + }); + + test('dismissButtonText can be set', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="callout-dismiss-btn"]').first().text()).toEqual( + 'Some other text' + ); + }); + + test('a default icon type of "iInCircle" will be chosen if no iconType is set and the message type is "primary"', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="callout-some-id"]').first().prop('iconType')).toEqual( + 'iInCircle' + ); + }); + + test('icon type can be changed from the type within the message', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="callout-some-id"]').first().prop('iconType')).toEqual( + 'something_else' + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/callouts/callout.tsx b/x-pack/plugins/security_solution/public/common/components/callouts/callout.tsx index f6e0c89cab266c..2077e421c427a1 100644 --- a/x-pack/plugins/security_solution/public/common/components/callouts/callout.tsx +++ b/x-pack/plugins/security_solution/public/common/components/callouts/callout.tsx @@ -8,8 +8,8 @@ import React, { FC, memo } from 'react'; import { EuiCallOut } from '@elastic/eui'; +import { assertUnreachable } from '../../../../common/utility_types'; import { CallOutType, CallOutMessage } from './callout_types'; -import { CallOutDescription } from './callout_description'; import { CallOutDismissButton } from './callout_dismiss_button'; export interface CallOutProps { @@ -17,6 +17,7 @@ export interface CallOutProps { iconType?: string; dismissButtonText?: string; onDismiss?: (message: CallOutMessage) => void; + showDismissButton?: boolean; } const CallOutComponent: FC = ({ @@ -24,8 +25,9 @@ const CallOutComponent: FC = ({ iconType, dismissButtonText, onDismiss, + showDismissButton = true, }) => { - const { type, id, title } = message; + const { type, id, title, description } = message; const finalIconType = iconType ?? getDefaultIconType(type); return ( @@ -36,8 +38,10 @@ const CallOutComponent: FC = ({ data-test-subj={`callout-${id}`} data-test-messages={`[${id}]`} > - - + {description} + {showDismissButton && ( + + )} ); }; @@ -53,7 +57,7 @@ const getDefaultIconType = (type: CallOutType): string => { case 'danger': return 'alert'; default: - return ''; + return assertUnreachable(type); } }; diff --git a/x-pack/plugins/security_solution/public/common/components/callouts/callout_description.tsx b/x-pack/plugins/security_solution/public/common/components/callouts/callout_description.tsx deleted file mode 100644 index dbb1267c73323d..00000000000000 --- a/x-pack/plugins/security_solution/public/common/components/callouts/callout_description.tsx +++ /dev/null @@ -1,26 +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, { FC } from 'react'; -import { EuiDescriptionList } from '@elastic/eui'; -import { CallOutMessage } from './callout_types'; - -export interface CallOutDescriptionProps { - messages: CallOutMessage | CallOutMessage[]; -} - -export const CallOutDescription: FC = ({ messages }) => { - if (!Array.isArray(messages)) { - return messages.description; - } - - if (messages.length < 1) { - return null; - } - - return ; -}; diff --git a/x-pack/plugins/security_solution/public/common/components/callouts/callout_persistent_switcher.tsx b/x-pack/plugins/security_solution/public/common/components/callouts/callout_persistent_switcher.tsx new file mode 100644 index 00000000000000..5b67410bb904a8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/callouts/callout_persistent_switcher.tsx @@ -0,0 +1,24 @@ +/* + * 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, { FC, memo } from 'react'; + +import { CallOutMessage } from './callout_types'; +import { CallOut } from './callout'; + +export interface CallOutPersistentSwitcherProps { + condition: boolean; + message: CallOutMessage; +} + +const CallOutPersistentSwitcherComponent: FC = ({ + condition, + message, +}): JSX.Element | null => + condition ? : null; + +export const CallOutPersistentSwitcher = memo(CallOutPersistentSwitcherComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/callouts/callout_types.ts b/x-pack/plugins/security_solution/public/common/components/callouts/callout_types.ts index 604f7b3e61c794..e04638a57ad06a 100644 --- a/x-pack/plugins/security_solution/public/common/components/callouts/callout_types.ts +++ b/x-pack/plugins/security_solution/public/common/components/callouts/callout_types.ts @@ -5,7 +5,9 @@ * 2.0. */ -export type CallOutType = 'primary' | 'success' | 'warning' | 'danger'; +import { EuiCallOutProps } from '@elastic/eui'; + +export type CallOutType = NonNullable; export interface CallOutMessage { type: CallOutType; diff --git a/x-pack/plugins/security_solution/public/common/components/callouts/index.ts b/x-pack/plugins/security_solution/public/common/components/callouts/index.ts index 222bf5daee6f57..0b7ec42744a6e4 100644 --- a/x-pack/plugins/security_solution/public/common/components/callouts/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/callouts/index.ts @@ -8,3 +8,4 @@ export * from './callout_switcher'; export * from './callout_types'; export * from './callout'; +export * from './callout_persistent_switcher'; diff --git a/x-pack/plugins/security_solution/public/detections/components/callouts/need_admin_for_update_callout/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/callouts/need_admin_for_update_callout/index.test.tsx new file mode 100644 index 00000000000000..66b2bae98c1ae4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/callouts/need_admin_for_update_callout/index.test.tsx @@ -0,0 +1,195 @@ +/* + * 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 { mount } from 'enzyme'; +import React from 'react'; +import { NeedAdminForUpdateRulesCallOut } from './index'; +import { TestProviders } from '../../../../common/mock'; +import * as userInfo from '../../user_info'; + +describe('need_admin_for_update_callout', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('hasIndexManage is "null"', () => { + const hasIndexManage = null; + test('Does NOT render when "signalIndexMappingOutdated" is true', () => { + jest + .spyOn(userInfo, 'useUserData') + .mockImplementation( + jest.fn().mockReturnValue([{ signalIndexMappingOutdated: true, hasIndexManage }]) + ); + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-need-admin-for-update-rules"]')).toEqual( + false + ); + }); + + test('Does not render a button as this is always persistent', () => { + jest + .spyOn(userInfo, 'useUserData') + .mockImplementation(jest.fn().mockReturnValue([{ signalIndexMappingOutdated: true }])); + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-dismiss-btn"]')).toEqual(false); + }); + + test('Does NOT render when signalIndexMappingOutdated is false', () => { + jest + .spyOn(userInfo, 'useUserData') + .mockImplementation(jest.fn().mockReturnValue([{ signalIndexMappingOutdated: false }])); + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-need-admin-for-update-rules"]')).toEqual( + false + ); + }); + + test('Does NOT render when signalIndexMappingOutdated is null', () => { + jest + .spyOn(userInfo, 'useUserData') + .mockImplementation(jest.fn().mockReturnValue([{ signalIndexMappingOutdated: null }])); + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-need-admin-for-update-rules"]')).toEqual( + false + ); + }); + }); + + describe('hasIndexManage is "false"', () => { + const hasIndexManage = false; + test('renders when "signalIndexMappingOutdated" is true', () => { + jest + .spyOn(userInfo, 'useUserData') + .mockImplementation( + jest.fn().mockReturnValue([{ signalIndexMappingOutdated: true, hasIndexManage }]) + ); + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-need-admin-for-update-rules"]')).toEqual( + true + ); + }); + + test('Does not render a button as this is always persistent', () => { + jest + .spyOn(userInfo, 'useUserData') + .mockImplementation(jest.fn().mockReturnValue([{ signalIndexMappingOutdated: true }])); + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-dismiss-btn"]')).toEqual(false); + }); + + test('Does NOT render when signalIndexMappingOutdated is false', () => { + jest + .spyOn(userInfo, 'useUserData') + .mockImplementation(jest.fn().mockReturnValue([{ signalIndexMappingOutdated: false }])); + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-need-admin-for-update-rules"]')).toEqual( + false + ); + }); + + test('Does NOT render when signalIndexMappingOutdated is null', () => { + jest + .spyOn(userInfo, 'useUserData') + .mockImplementation(jest.fn().mockReturnValue([{ signalIndexMappingOutdated: null }])); + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-need-admin-for-update-rules"]')).toEqual( + false + ); + }); + }); + + describe('hasIndexManage is "true"', () => { + const hasIndexManage = true; + test('Does not render when "signalIndexMappingOutdated" is true', () => { + jest + .spyOn(userInfo, 'useUserData') + .mockImplementation( + jest.fn().mockReturnValue([{ signalIndexMappingOutdated: true, hasIndexManage }]) + ); + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-need-admin-for-update-rules"]')).toEqual( + false + ); + }); + + test('Does not render a button as this is always persistent', () => { + jest + .spyOn(userInfo, 'useUserData') + .mockImplementation(jest.fn().mockReturnValue([{ signalIndexMappingOutdated: true }])); + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-dismiss-btn"]')).toEqual(false); + }); + + test('Does NOT render when signalIndexMappingOutdated is false', () => { + jest + .spyOn(userInfo, 'useUserData') + .mockImplementation(jest.fn().mockReturnValue([{ signalIndexMappingOutdated: false }])); + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-need-admin-for-update-rules"]')).toEqual( + false + ); + }); + + test('Does NOT render when signalIndexMappingOutdated is null', () => { + jest + .spyOn(userInfo, 'useUserData') + .mockImplementation(jest.fn().mockReturnValue([{ signalIndexMappingOutdated: null }])); + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-need-admin-for-update-rules"]')).toEqual( + false + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/callouts/need_admin_for_update_callout/index.tsx b/x-pack/plugins/security_solution/public/detections/components/callouts/need_admin_for_update_callout/index.tsx new file mode 100644 index 00000000000000..fd0be8e0021933 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/callouts/need_admin_for_update_callout/index.tsx @@ -0,0 +1,52 @@ +/* + * 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, { memo } from 'react'; +import { CallOutMessage, CallOutPersistentSwitcher } from '../../../../common/components/callouts'; +import { useUserData } from '../../user_info'; + +import * as i18n from './translations'; + +const needAdminForUpdateRulesMessage: CallOutMessage = { + type: 'primary', + id: 'need-admin-for-update-rules', + title: i18n.NEED_ADMIN_CALLOUT_TITLE, + description: i18n.needAdminForUpdateCallOutBody(), +}; + +/** + * Callout component that lets the user know that an administrator is needed for performing + * and auto-update of signals or not. For this component to render the user must: + * - Have the permissions to be able to read "signalIndexMappingOutdated" and that condition is "true" + * - Have the permissions to be able to read "hasIndexManage" and that condition is "false" + * + * Some users do not have sufficient privileges to be able to determine if "signalIndexMappingOutdated" + * is outdated or not. Same could apply to "hasIndexManage". When users do not have enough permissions + * to determine if "signalIndexMappingOutdated" is true or false, the permissions system returns a "null" + * instead. + * + * If the user has the permissions to see that signalIndexMappingOutdated is true and that + * hasIndexManage is also true, then the user should be performing the update on the page which is + * why we do not show it for that condition. + */ +const NeedAdminForUpdateCallOutComponent = (): JSX.Element => { + const [{ signalIndexMappingOutdated, hasIndexManage }] = useUserData(); + + const signalIndexMappingIsOutdated = + signalIndexMappingOutdated != null && signalIndexMappingOutdated; + + const userDoesntHaveIndexManage = hasIndexManage != null && !hasIndexManage; + + return ( + + ); +}; + +export const NeedAdminForUpdateRulesCallOut = memo(NeedAdminForUpdateCallOutComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/callouts/need_admin_for_update_callout/translations.tsx b/x-pack/plugins/security_solution/public/detections/components/callouts/need_admin_for_update_callout/translations.tsx new file mode 100644 index 00000000000000..791093788b8e1e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/callouts/need_admin_for_update_callout/translations.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + SecuritySolutionRequirementsLink, + DetectionsRequirementsLink, +} from '../../../../common/components/links_to_docs'; + +export const NEED_ADMIN_CALLOUT_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.needAdminForUpdateCallOutBody.messageTitle', + { + defaultMessage: 'Administration permissions required for alert migration', + } +); + +/** + * Returns the formatted message of the call out body as a JSX Element with both the message + * and two documentation links. + */ +export const needAdminForUpdateCallOutBody = (): JSX.Element => ( + + +

+ ), + docs: ( +
    +
  • + +
  • +
  • + +
  • +
+ ), + }} + /> +); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx index 0b3511ffe7c876..8d2f07e19b36af 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx @@ -53,6 +53,7 @@ import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; import { buildShowBuildingBlockFilter } from '../../components/alerts_table/default_config'; import { useSourcererScope } from '../../../common/containers/sourcerer'; import { SourcererScopeName } from '../../../common/store/sourcerer/model'; +import { NeedAdminForUpdateRulesCallOut } from '../../components/callouts/need_admin_for_update_callout'; /** * Need a 100% height here to account for the graph/analyze tool, which sets no explicit height parameters, but fills the available space. @@ -193,6 +194,7 @@ const DetectionEnginePageComponent = () => { <> {hasEncryptionKey != null && !hasEncryptionKey && } + {indicesExist ? ( 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 ed88ca41146f18..c4dc9b62c74cd5 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 @@ -103,6 +103,7 @@ import * as detectionI18n from '../../translations'; import * as ruleI18n from '../translations'; import * as i18n from './translations'; import { isTab } from '../../../../../common/components/accessibility/helpers'; +import { NeedAdminForUpdateRulesCallOut } from '../../../../components/callouts/need_admin_for_update_callout'; /** * Need a 100% height here to account for the graph/analyze tool, which sets no explicit height parameters, but fills the available space. @@ -468,6 +469,7 @@ const RuleDetailsPageComponent = () => { return ( <> + {indicesExist ? ( 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 fee7f443e95a04..89cec168510103 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 @@ -35,6 +35,7 @@ import * as i18n from './translations'; import { SecurityPageName } from '../../../../app/types'; import { LinkButton } from '../../../../common/components/links'; import { useFormatUrl } from '../../../../common/components/link_to'; +import { NeedAdminForUpdateRulesCallOut } from '../../../components/callouts/need_admin_for_update_callout'; type Func = () => Promise; @@ -158,6 +159,7 @@ const RulesPageComponent: React.FC = () => { return ( <> + ): QueryOrder => { case HostsFields.hostName: return { _key: sort.direction }; default: - return assertUnreachable(sort.field as never); + return assertUnreachable(sort.field); } };