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);
}
};