[Security solution] [Endpoint] Event filters license downgrade experience (#121738)

* Hide assignment section and show a banner when license has been downgraded. Also fixes some edge cases on trusted apps

* Adds unit tests for licence downgrade experience

* Adds more tests

* Adds tests and move code outside form to be in flyout component

* Fixes unit test and added api mocks for event filter calls

* Updates API docs

* Revert Trusted Apps changes because they need to be included in a separated pr

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
David Sánchez 2021-12-22 12:55:44 +01:00 committed by GitHub
parent 7b0ab30de3
commit 9fe39bad91
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 244 additions and 28 deletions

View file

@ -213,6 +213,7 @@ readonly links: {
};
readonly securitySolution: {
readonly trustedApps: string;
readonly eventFilters: string;
};
readonly query: {
readonly eql: string;

File diff suppressed because one or more lines are too long

View file

@ -311,6 +311,7 @@ export class DocLinksService {
},
securitySolution: {
trustedApps: `${ELASTIC_WEBSITE_URL}guide/en/security/${DOC_LINK_VERSION}/trusted-apps-ov.html`,
eventFilters: `${ELASTIC_WEBSITE_URL}guide/en/security/${DOC_LINK_VERSION}/event-filters.html`,
},
query: {
eql: `${ELASTICSEARCH_DOCS}eql.html`,
@ -801,6 +802,7 @@ export interface DocLinksStart {
};
readonly securitySolution: {
readonly trustedApps: string;
readonly eventFilters: string;
};
readonly query: {
readonly eql: string;

View file

@ -696,6 +696,7 @@ export interface DocLinksStart {
};
readonly securitySolution: {
readonly trustedApps: string;
readonly eventFilters: string;
};
readonly query: {
readonly eql: string;

View file

@ -104,6 +104,9 @@ export const createdEventFilterEntryMock = (): ExceptionListItemSchema => ({
export type EventFiltersListQueryHttpMockProviders = ResponseProvidersInterface<{
eventFiltersList: () => FoundExceptionListItemSchema;
eventFiltersCreateList: () => ExceptionListItemSchema;
eventFiltersGetOne: () => ExceptionListItemSchema;
eventFiltersCreateOne: () => ExceptionListItemSchema;
eventFiltersUpdateOne: () => ExceptionListItemSchema;
}>;
export const esResponseData = () => ({
@ -228,4 +231,28 @@ export const eventFiltersListQueryHttpMock =
return getFoundExceptionListItemSchemaMock();
},
},
{
id: 'eventFiltersGetOne',
method: 'get',
path: `${EXCEPTION_LIST_ITEM_URL}`,
handler: (): ExceptionListItemSchema => {
return getExceptionListItemSchemaMock();
},
},
{
id: 'eventFiltersCreateOne',
method: 'post',
path: `${EXCEPTION_LIST_ITEM_URL}`,
handler: (): ExceptionListItemSchema => {
return getExceptionListItemSchemaMock();
},
},
{
id: 'eventFiltersUpdateOne',
method: 'put',
path: `${EXCEPTION_LIST_ITEM_URL}`,
handler: (): ExceptionListItemSchema => {
return getExceptionListItemSchemaMock();
},
},
]);

View file

@ -19,15 +19,15 @@ import type {
CreateExceptionListItemSchema,
ExceptionListItemSchema,
} from '@kbn/securitysolution-io-ts-list-types';
import { EventFiltersHttpService } from '../../../service';
import { createdEventFilterEntryMock, ecsEventMock, esResponseData } from '../../../test_utils';
import { ecsEventMock, esResponseData, eventFiltersListQueryHttpMock } from '../../../test_utils';
import { getFormEntryState, isUninitialisedForm } from '../../../store/selector';
import { EventFiltersListPageState } from '../../../types';
import { useKibana } from '../../../../../../common/lib/kibana';
import { licenseService } from '../../../../../../common/hooks/use_license';
import { getExceptionListItemSchemaMock } from '../../../../../../../../lists/common/schemas/response/exception_list_item_schema.mock';
jest.mock('../../../../../../common/lib/kibana');
jest.mock('../form');
jest.mock('../../../service');
jest.mock('../../../../../services/policies/policies');
jest.mock('../../hooks', () => {
@ -40,6 +40,18 @@ jest.mock('../../hooks', () => {
};
});
jest.mock('../../../../../../common/hooks/use_license', () => {
const licenseServiceInstance = {
isPlatinumPlus: jest.fn(),
};
return {
licenseService: licenseServiceInstance,
useLicense: () => {
return licenseServiceInstance;
},
};
});
(sendGetEndpointSpecificPackagePolicies as jest.Mock).mockImplementation(
sendGetEndpointSpecificPackagePoliciesMock
);
@ -52,29 +64,31 @@ let render: (
) => ReturnType<AppContextTestRender['render']>;
const act = reactTestingLibrary.act;
let onCancelMock: jest.Mock;
const EventFiltersHttpServiceMock = EventFiltersHttpService as jest.Mock;
let getState: () => EventFiltersListPageState;
let mockedApi: ReturnType<typeof eventFiltersListQueryHttpMock>;
describe('Event filter flyout', () => {
beforeAll(() => {
EventFiltersHttpServiceMock.mockImplementation(() => {
return {
getOne: () => createdEventFilterEntryMock(),
addEventFilters: () => createdEventFilterEntryMock(),
updateOne: () => createdEventFilterEntryMock(),
};
});
});
beforeEach(() => {
(licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(true);
mockedContext = createAppRootMockRenderer();
waitForAction = mockedContext.middlewareSpy.waitForAction;
onCancelMock = jest.fn();
getState = () => mockedContext.store.getState().management.eventFilters;
render = (props) =>
mockedContext.render(<EventFiltersFlyout {...props} onCancel={onCancelMock} />);
mockedApi = eventFiltersListQueryHttpMock(mockedContext.coreStart.http);
render = (props) => {
return mockedContext.render(<EventFiltersFlyout {...props} onCancel={onCancelMock} />);
};
(useKibana as jest.Mock).mockReturnValue({
services: {
docLinks: {
links: {
securitySolution: {
eventFilters: '',
},
},
},
http: {},
data: {
search: {
@ -227,6 +241,46 @@ describe('Event filter flyout', () => {
});
expect(getFormEntryState(getState())).not.toBeUndefined();
expect(getFormEntryState(getState())?.item_id).toBe(createdEventFilterEntryMock().item_id);
expect(getFormEntryState(getState())?.item_id).toBe(
mockedApi.responseProvider.eventFiltersGetOne.getMockImplementation()!().item_id
);
});
it('should not display banner when platinum license', async () => {
await act(async () => {
component = render({ id: 'fakeId', type: 'edit' });
await waitForAction('eventFiltersInitFromId');
});
expect(component.queryByTestId('expired-license-callout')).toBeNull();
});
it('should not display banner when under platinum license and create mode', async () => {
component = render();
expect(component.queryByTestId('expired-license-callout')).toBeNull();
});
it('should not display banner when under platinum license and edit mode with global assignment', async () => {
mockedApi.responseProvider.eventFiltersGetOne.mockReturnValue({
...getExceptionListItemSchemaMock(),
tags: ['policy:all'],
});
(licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false);
await act(async () => {
component = render({ id: 'fakeId', type: 'edit' });
await waitForAction('eventFiltersInitFromId');
});
expect(component.queryByTestId('expired-license-callout')).toBeNull();
});
it('should display banner when under platinum license and edit mode with by policy assignment', async () => {
(licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false);
await act(async () => {
component = render({ id: 'fakeId', type: 'edit' });
await waitForAction('eventFiltersInitFromId');
});
expect(component.queryByTestId('expired-license-callout')).not.toBeNull();
});
});

View file

@ -10,6 +10,7 @@ import { useDispatch } from 'react-redux';
import { Dispatch } from 'redux';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import {
EuiFlyout,
EuiFlyoutHeader,
@ -21,11 +22,14 @@ import {
EuiFlexGroup,
EuiFlexItem,
EuiTextColor,
EuiCallOut,
EuiLink,
} from '@elastic/eui';
import { AppAction } from '../../../../../../common/store/actions';
import { EventFiltersForm } from '../form';
import { useEventFiltersSelector, useEventFiltersNotification } from '../../hooks';
import {
getFormEntryStateMutable,
getFormHasError,
isCreationInProgress,
isCreationSuccessful,
@ -35,6 +39,8 @@ import { Ecs } from '../../../../../../../common/ecs';
import { useKibana, useToasts } from '../../../../../../common/lib/kibana';
import { useGetEndpointSpecificPolicies } from '../../../../../services/policies/hooks';
import { getLoadPoliciesError } from '../../../../../common/translations';
import { useLicense } from '../../../../../../common/hooks/use_license';
import { isGlobalPolicyEffected } from '../../../../../components/effected_policy_select/utils';
export interface EventFiltersFlyoutProps {
type?: 'create' | 'edit';
@ -52,8 +58,10 @@ export const EventFiltersFlyout: React.FC<EventFiltersFlyoutProps> = memo(
const formHasError = useEventFiltersSelector(getFormHasError);
const creationInProgress = useEventFiltersSelector(isCreationInProgress);
const creationSuccessful = useEventFiltersSelector(isCreationSuccessful);
const exception = useEventFiltersSelector(getFormEntryStateMutable);
const {
data: { search },
docLinks,
} = useKibana().services;
// load the list of policies>
@ -63,6 +71,20 @@ export const EventFiltersFlyout: React.FC<EventFiltersFlyoutProps> = memo(
},
});
const isPlatinumPlus = useLicense().isPlatinumPlus();
const isEditMode = useMemo(() => type === 'edit' && !!id, [type, id]);
const [wasByPolicy, setWasByPolicy] = useState<boolean | undefined>(undefined);
const showExpiredLicenseBanner = useMemo(() => {
return !isPlatinumPlus && isEditMode && wasByPolicy;
}, [isPlatinumPlus, isEditMode, wasByPolicy]);
useEffect(() => {
if (exception && wasByPolicy === undefined) {
setWasByPolicy(!isGlobalPolicyEffected(exception?.tags));
}
}, [exception, wasByPolicy]);
useEffect(() => {
if (creationSuccessful) {
onCancel();
@ -219,6 +241,28 @@ export const EventFiltersFlyout: React.FC<EventFiltersFlyoutProps> = memo(
) : null}
</EuiFlyoutHeader>
{showExpiredLicenseBanner && (
<EuiCallOut
title={i18n.translate('xpack.securitySolution.eventFilters.expiredLicenseTitle', {
defaultMessage: 'Expired License',
})}
color="warning"
iconType="help"
data-test-subj="expired-license-callout"
>
<FormattedMessage
id="xpack.securitySolution.eventFilters.expiredLicenseMessage"
defaultMessage="Your Kibana license has been downgraded. Future policy configurations will now be globally assigned to all policies. For more information, see our "
/>
<EuiLink target="_blank" href={`${docLinks.links.securitySolution.eventFilters}`}>
<FormattedMessage
id="xpack.securitySolution.eventFilters.docsLink"
defaultMessage="Event Filters documentation."
/>
</EuiLink>
</EuiCallOut>
)}
<EuiFlyoutBody>
<EventFiltersForm
allowSelectOs={!data}

View file

@ -14,6 +14,7 @@ import { useFetchIndex } from '../../../../../../common/containers/source';
import { ecsEventMock } from '../../../test_utils';
import { NAME_ERROR, NAME_PLACEHOLDER } from './translations';
import { useCurrentUser, useKibana } from '../../../../../../common/lib/kibana';
import { licenseService } from '../../../../../../common/hooks/use_license';
import {
AppContextTestRender,
createAppRootMockRenderer,
@ -22,9 +23,21 @@ import { EventFiltersListPageState } from '../../../types';
import { sendGetEndpointSpecificPackagePoliciesMock } from '../../../../../services/policies/test_mock_utilts';
import { GetPolicyListResponse } from '../../../../policy/types';
import userEvent from '@testing-library/user-event';
import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
jest.mock('../../../../../../common/lib/kibana');
jest.mock('../../../../../../common/containers/source');
jest.mock('../../../../../../common/hooks/use_license', () => {
const licenseServiceInstance = {
isPlatinumPlus: jest.fn(),
};
return {
licenseService: licenseServiceInstance,
useLicense: () => {
return licenseServiceInstance;
},
};
});
describe('Event filter form', () => {
let component: RenderResult;
@ -32,11 +45,14 @@ describe('Event filter form', () => {
let render: (
props?: Partial<React.ComponentProps<typeof EventFiltersForm>>
) => ReturnType<AppContextTestRender['render']>;
let renderWithData: () => Promise<ReturnType<AppContextTestRender['render']>>;
let renderWithData: (
customEventFilterProps?: Partial<ExceptionListItemSchema>
) => Promise<ReturnType<AppContextTestRender['render']>>;
let getState: () => EventFiltersListPageState;
let policiesRequest: GetPolicyListResponse;
beforeEach(async () => {
(licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(true);
mockedContext = createAppRootMockRenderer();
policiesRequest = await sendGetEndpointSpecificPackagePoliciesMock();
getState = () => mockedContext.store.getState().management.eventFilters;
@ -44,13 +60,14 @@ describe('Event filter form', () => {
mockedContext.render(
<EventFiltersForm policies={policiesRequest.items} arePoliciesLoading={false} {...props} />
);
renderWithData = async () => {
renderWithData = async (customEventFilterProps = {}) => {
const renderResult = render();
const entry = getInitialExceptionFromEvent(ecsEventMock());
act(() => {
mockedContext.store.dispatch({
type: 'eventFiltersInitForm',
payload: { entry },
payload: { entry: { ...entry, ...customEventFilterProps } },
});
});
await waitFor(() => {
@ -208,4 +225,34 @@ describe('Event filter form', () => {
// on change called with the previous policy
expect(getState().form.entry?.tags).toEqual([`policy:${policyId}`]);
});
it('should hide assignment section when no license', async () => {
(licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false);
component = await renderWithData();
expect(component.queryByTestId('perPolicy')).toBeNull();
});
it('should hide assignment section when create mode and no license even with by policy', async () => {
const policyId = policiesRequest.items[0].id;
(licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false);
component = await renderWithData({ tags: [`policy:${policyId}`] });
expect(component.queryByTestId('perPolicy')).toBeNull();
});
it('should show disabled assignment section when edit mode and no license with by policy', async () => {
const policyId = policiesRequest.items[0].id;
(licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false);
component = await renderWithData({ tags: [`policy:${policyId}`], item_id: '1' });
expect(component.queryByTestId('perPolicy')).not.toBeNull();
expect(component.getByTestId(`policy-${policyId}`).getAttribute('aria-disabled')).toBe('true');
});
it('should change from by policy to global when edit mode and no license with by policy', async () => {
const policyId = policiesRequest.items[0].id;
(licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false);
component = await renderWithData({ tags: [`policy:${policyId}`], item_id: '1' });
userEvent.click(component.getByTestId('globalPolicy'));
expect(component.queryByTestId('effectedPolicies-select-policiesSelectable')).toBeFalsy();
expect(getState().form.entry?.tags).toEqual([`policy:all`]);
});
});

View file

@ -8,6 +8,8 @@
import React, { memo, useMemo, useCallback, useState, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { Dispatch } from 'redux';
import { isEqual } from 'lodash';
import {
EuiFieldText,
EuiSpacer,
@ -49,6 +51,7 @@ import {
getEffectedPolicySelectionByTags,
isGlobalPolicyEffected,
} from '../../../../../components/effected_policy_select/utils';
import { useLicense } from '../../../../../../common/hooks/use_license';
const OPERATING_SYSTEMS: readonly OperatingSystem[] = [
OperatingSystem.MAC,
@ -70,6 +73,8 @@ export const EventFiltersForm: React.FC<EventFiltersFormProps> = memo(
const hasNameError = useEventFiltersSelector(getHasNameError);
const newComment = useEventFiltersSelector(getNewComment);
const [hasBeenInputNameVisited, setHasBeenInputNameVisited] = useState(false);
const isPlatinumPlus = useLicense().isPlatinumPlus();
const [hasFormChanged, setHasFormChanged] = useState(false);
// This value has to be memoized to avoid infinite useEffect loop on useFetchIndex
const indexNames = useMemo(() => ['logs-endpoint.events.*'], []);
@ -80,6 +85,17 @@ export const EventFiltersForm: React.FC<EventFiltersFormProps> = memo(
isGlobal: isGlobalPolicyEffected(exception?.tags),
});
const isEditMode = useMemo(() => !!exception?.item_id, [exception?.item_id]);
const [wasByPolicy, setWasByPolicy] = useState(!isGlobalPolicyEffected(exception?.tags));
const showAssignmentSection = useMemo(() => {
return (
isPlatinumPlus ||
(isEditMode &&
(!selection.isGlobal || (wasByPolicy && selection.isGlobal && hasFormChanged)))
);
}, [isEditMode, selection.isGlobal, hasFormChanged, isPlatinumPlus, wasByPolicy]);
// set current policies if not previously selected
useEffect(() => {
if (selection.selected.length === 0 && exception?.tags) {
@ -87,6 +103,13 @@ export const EventFiltersForm: React.FC<EventFiltersFormProps> = memo(
}
}, [exception?.tags, policies, selection.selected.length]);
// set initial state of `wasByPolicy` that checks if the initial state of the exception was by policy or not
useEffect(() => {
if (!hasFormChanged && exception?.tags) {
setWasByPolicy(!isGlobalPolicyEffected(exception?.tags));
}
}, [exception?.tags, hasFormChanged]);
const osOptions: Array<EuiSuperSelectOption<OperatingSystem>> = useMemo(
() => OPERATING_SYSTEMS.map((os) => ({ value: os, inputDisplay: OS_TITLES[os] })),
[]
@ -94,6 +117,15 @@ export const EventFiltersForm: React.FC<EventFiltersFormProps> = memo(
const handleOnBuilderChange = useCallback(
(arg: OnChangeProps) => {
if (
(hasFormChanged === false && arg.exceptionItems[0] === undefined) ||
(arg.exceptionItems[0] !== undefined &&
exception !== undefined &&
isEqual(exception?.entries, arg.exceptionItems[0].entries))
) {
return;
}
setHasFormChanged(true);
dispatch({
type: 'eventFiltersChangeForm',
payload: {
@ -114,12 +146,13 @@ export const EventFiltersForm: React.FC<EventFiltersFormProps> = memo(
},
});
},
[dispatch, exception?.name, exception?.comments, exception?.os_types, exception?.tags]
[dispatch, exception, hasFormChanged]
);
const handleOnChangeName = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (!exception) return;
setHasFormChanged(true);
const name = e.target.value.toString().trim();
dispatch({
type: 'eventFiltersChangeForm',
@ -135,6 +168,7 @@ export const EventFiltersForm: React.FC<EventFiltersFormProps> = memo(
const handleOnChangeComment = useCallback(
(value: string) => {
if (!exception) return;
setHasFormChanged(true);
dispatch({
type: 'eventFiltersChangeForm',
payload: {
@ -299,6 +333,7 @@ export const EventFiltersForm: React.FC<EventFiltersFormProps> = memo(
}
if (!exception) return;
setHasFormChanged(true);
dispatch({
type: 'eventFiltersChangeForm',
@ -321,12 +356,12 @@ export const EventFiltersForm: React.FC<EventFiltersFormProps> = memo(
selected={selection.selected}
options={policies}
isGlobal={selection.isGlobal}
isPlatinumPlus={true}
isPlatinumPlus={isPlatinumPlus}
onChange={handleOnChangeEffectScope}
data-test-subj={'effectedPolicies-select'}
/>
),
[policies, selection, handleOnChangeEffectScope]
[policies, selection, isPlatinumPlus, handleOnChangeEffectScope]
);
const commentsSection = useMemo(
@ -356,18 +391,23 @@ export const EventFiltersForm: React.FC<EventFiltersFormProps> = memo(
[commentsInputMemo]
);
return !isIndexPatternLoading && exception && !arePoliciesLoading ? (
if (isIndexPatternLoading || !exception || arePoliciesLoading) {
return <Loader size="xl" />;
}
return (
<EuiForm component="div">
{detailsSection}
<EuiHorizontalRule />
{criteriaSection}
<EuiHorizontalRule />
{policiesSection}
{showAssignmentSection && (
<>
<EuiHorizontalRule /> {policiesSection}
</>
)}
<EuiHorizontalRule />
{commentsSection}
</EuiForm>
) : (
<Loader size="xl" />
);
}
);