diff --git a/x-pack/plugins/security_solution/common/utility_types.ts b/x-pack/plugins/security_solution/common/utility_types.ts index 3c13e6af837bc..498b18dccaca5 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 0000000000000..1c6c604b84fbb --- /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 85257f7d9176f..d807857cd72bd 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 4139c911e4063..8440409f80f38 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 0000000000000..f908a79361d0a --- /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 f6e0c89cab266..2077e421c427a 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 dbb1267c73323..0000000000000 --- 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 0000000000000..5b67410bb904a --- /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 604f7b3e61c79..e04638a57ad06 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 222bf5daee6f5..0b7ec42744a6e 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 0000000000000..66b2bae98c1ae --- /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 0000000000000..fd0be8e002193 --- /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 0000000000000..791093788b8e1 --- /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 0b3511ffe7c87..8d2f07e19b36a 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 ed88ca41146f1..c4dc9b62c74cd 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 fee7f443e95a0..89cec16851010 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); } };