[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
<img width="2259" alt="Screen Shot 2021-02-03 at 4 16 00 PM" src="https://user-images.githubusercontent.com/1151048/106926989-eb2fb300-66ce-11eb-877c-1210357af108.png">

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:
<img width="1526" alt="Screen Shot 2021-02-03 at 5 41 57 PM" src="https://user-images.githubusercontent.com/1151048/106927465-6b561880-66cf-11eb-8c0f-dfdfa624c24b.png">

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):
<img width="1243" alt="Screen Shot 2021-02-05 at 1 49 02 PM" src="https://user-images.githubusercontent.com/1151048/107087773-30301400-67b9-11eb-9ac9-0a67fafd8231.png">

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)
This commit is contained in:
Frank Hassanabad 2021-02-16 20:58:52 -07:00 committed by GitHub
parent adc50dd267
commit 8ce6ed42a3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 765 additions and 92 deletions

View file

@ -36,6 +36,12 @@ export const stringEnum = <T>(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
*/

View file

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

View file

@ -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);
});
context('indicating read-only access to resources', () => {
context('On Detections home page', () => {
beforeEach(() => {
loadPageAsReadOnlyUser(DETECTIONS_URL);
});
it('We show one primary callout', () => {
waitForCallOutToBeShown(ALERTS_CALLOUT, 'primary');
});
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('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');
});
});
});
context('On Rules Management page', () => {
beforeEach(() => {
loadPageAsReadOnlyUser(DETECTIONS_RULE_MANAGEMENT_URL);
});
it('We show one primary callout', () => {
waitForCallOutToBeShown(RULES_CALLOUT, 'primary');
});
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');
});
});
});
context('On Rule Details page', () => {
beforeEach(() => {
createCustomRule(newRule);
loadPageAsReadOnlyUser(DETECTIONS_RULE_MANAGEMENT_URL);
waitForPageTitleToBeShown();
goToRuleDetails();
});
afterEach(() => {
deleteCustomRule();
});
it('We show two primary callouts', () => {
waitForCallOutToBeShown(ALERTS_CALLOUT, 'primary');
waitForCallOutToBeShown(RULES_CALLOUT, 'primary');
});
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('On Rules Management page', () => {
beforeEach(() => {
loadPageAsReadOnlyUser(DETECTIONS_RULE_MANAGEMENT_URL);
});
context('indicating read-write access to resources', () => {
context('On Detections home page', () => {
beforeEach(() => {
loadPageAsPlatformEngineer(DETECTIONS_URL);
});
it('We show one primary callout', () => {
waitForCallOutToBeShown(RULES_CALLOUT, 'primary');
});
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();
it('We show no callout', () => {
getCallOut(ALERTS_CALLOUT).should('not.exist');
getCallOut(RULES_CALLOUT).should('not.exist');
});
});
});
context('On Rule Details page', () => {
beforeEach(() => {
createCustomRule(newRule);
loadPageAsReadOnlyUser(DETECTIONS_RULE_MANAGEMENT_URL);
waitForPageTitleToBeShown();
goToRuleDetails();
});
afterEach(() => {
deleteCustomRule();
});
it('We show two primary callouts', () => {
waitForCallOutToBeShown(ALERTS_CALLOUT, 'primary');
waitForCallOutToBeShown(RULES_CALLOUT, 'primary');
});
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();
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');
});

View file

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

View file

@ -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(
<TestProviders>
<CallOut message={message} />
</TestProviders>
);
expect(wrapper.exists('[data-test-subj="callout-some-id"]')).toEqual(true);
});
test('renders the callout dismiss button by default', () => {
const wrapper = mount(
<TestProviders>
<CallOut message={message} />
</TestProviders>
);
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(
<TestProviders>
<CallOut message={message} showDismissButton={true} />
</TestProviders>
);
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(
<TestProviders>
<CallOut message={message} showDismissButton={false} />
</TestProviders>
);
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(
<TestProviders>
<CallOut message={message} onDismiss={onDismiss} />
</TestProviders>
);
wrapper.find('[data-test-subj="callout-dismiss-btn"]').first().simulate('click');
expect(onDismiss).toBeCalledWith(message);
});
test('dismissButtonText can be set', () => {
const wrapper = mount(
<TestProviders>
<CallOut message={message} dismissButtonText={'Some other text'} />
</TestProviders>
);
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(
<TestProviders>
<CallOut message={message} />
</TestProviders>
);
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(
<TestProviders>
<CallOut message={message} iconType={'something_else'} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="callout-some-id"]').first().prop('iconType')).toEqual(
'something_else'
);
});
});

View file

@ -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<CallOutProps> = ({
@ -24,8 +25,9 @@ const CallOutComponent: FC<CallOutProps> = ({
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<CallOutProps> = ({
data-test-subj={`callout-${id}`}
data-test-messages={`[${id}]`}
>
<CallOutDescription messages={message} />
<CallOutDismissButton message={message} text={dismissButtonText} onClick={onDismiss} />
{description}
{showDismissButton && (
<CallOutDismissButton message={message} text={dismissButtonText} onClick={onDismiss} />
)}
</EuiCallOut>
);
};
@ -53,7 +57,7 @@ const getDefaultIconType = (type: CallOutType): string => {
case 'danger':
return 'alert';
default:
return '';
return assertUnreachable(type);
}
};

View file

@ -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<CallOutDescriptionProps> = ({ messages }) => {
if (!Array.isArray(messages)) {
return messages.description;
}
if (messages.length < 1) {
return null;
}
return <EuiDescriptionList listItems={messages} />;
};

View file

@ -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<CallOutPersistentSwitcherProps> = ({
condition,
message,
}): JSX.Element | null =>
condition ? <CallOut message={message} showDismissButton={false} /> : null;
export const CallOutPersistentSwitcher = memo(CallOutPersistentSwitcherComponent);

View file

@ -5,7 +5,9 @@
* 2.0.
*/
export type CallOutType = 'primary' | 'success' | 'warning' | 'danger';
import { EuiCallOutProps } from '@elastic/eui';
export type CallOutType = NonNullable<EuiCallOutProps['color']>;
export interface CallOutMessage {
type: CallOutType;

View file

@ -8,3 +8,4 @@
export * from './callout_switcher';
export * from './callout_types';
export * from './callout';
export * from './callout_persistent_switcher';

View file

@ -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(
<TestProviders>
<NeedAdminForUpdateRulesCallOut />
</TestProviders>
);
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(
<TestProviders>
<NeedAdminForUpdateRulesCallOut />
</TestProviders>
);
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(
<TestProviders>
<NeedAdminForUpdateRulesCallOut />
</TestProviders>
);
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(
<TestProviders>
<NeedAdminForUpdateRulesCallOut />
</TestProviders>
);
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(
<TestProviders>
<NeedAdminForUpdateRulesCallOut />
</TestProviders>
);
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(
<TestProviders>
<NeedAdminForUpdateRulesCallOut />
</TestProviders>
);
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(
<TestProviders>
<NeedAdminForUpdateRulesCallOut />
</TestProviders>
);
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(
<TestProviders>
<NeedAdminForUpdateRulesCallOut />
</TestProviders>
);
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(
<TestProviders>
<NeedAdminForUpdateRulesCallOut />
</TestProviders>
);
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(
<TestProviders>
<NeedAdminForUpdateRulesCallOut />
</TestProviders>
);
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(
<TestProviders>
<NeedAdminForUpdateRulesCallOut />
</TestProviders>
);
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(
<TestProviders>
<NeedAdminForUpdateRulesCallOut />
</TestProviders>
);
expect(wrapper.exists('[data-test-subj="callout-need-admin-for-update-rules"]')).toEqual(
false
);
});
});
});

View file

@ -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 (
<CallOutPersistentSwitcher
condition={signalIndexMappingIsOutdated && userDoesntHaveIndexManage}
message={needAdminForUpdateRulesMessage}
/>
);
};
export const NeedAdminForUpdateRulesCallOut = memo(NeedAdminForUpdateCallOutComponent);

View file

@ -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 => (
<FormattedMessage
id="xpack.securitySolution.detectionEngine.needAdminForUpdateCallOutBody.messageBody.messageDetail"
defaultMessage="{essence} Related documentation: {docs}"
values={{
essence: (
<p>
<FormattedMessage
id="xpack.securitySolution.detectionEngine.needAdminForUpdateCallOutBody.messageBody.essenceDescription"
defaultMessage="You are currently missing the required permissions to auto migrate your alert data. Please have your administrator visit this page one time to auto migrate your alert data."
/>
</p>
),
docs: (
<ul>
<li>
<DetectionsRequirementsLink />
</li>
<li>
<SecuritySolutionRequirementsLink />
</li>
</ul>
),
}}
/>
);

View file

@ -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 && <NoApiIntegrationKeyCallOut />}
<ReadOnlyAlertsCallOut />
<NeedAdminForUpdateRulesCallOut />
{indicesExist ? (
<StyledFullHeightContainer onKeyDown={onKeyDown} ref={containerElement}>
<EuiWindowEvent event="resize" handler={noop} />

View file

@ -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 (
<>
<NeedAdminForUpdateRulesCallOut />
<ReadOnlyAlertsCallOut />
<ReadOnlyRulesCallOut />
{indicesExist ? (

View file

@ -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<void>;
@ -158,6 +159,7 @@ const RulesPageComponent: React.FC = () => {
return (
<>
<NeedAdminForUpdateRulesCallOut />
<ReadOnlyRulesCallOut />
<ValueListsModal
showModal={showValueListsModal}

View file

@ -87,6 +87,6 @@ const getQueryOrder = (sort: SortField<HostsFields>): QueryOrder => {
case HostsFields.hostName:
return { _key: sort.direction };
default:
return assertUnreachable(sort.field as never);
return assertUnreachable(sort.field);
}
};