From 123980dc0cf2b7e3f5467a12067a27999f8bb9f0 Mon Sep 17 00:00:00 2001 From: Esteban Beltran Date: Tue, 21 Dec 2021 09:48:28 +0100 Subject: [PATCH] [Security Solution] Allow to remove a host isolation exception from a policy (#121280) --- .../components/delete_modal.test.tsx | 120 ++++++++++++++++ .../components/delete_modal.tsx | 128 ++++++++++++++++++ .../components/list.test.tsx | 57 +++++++- .../components/list.tsx | 52 ++++++- 4 files changed, 351 insertions(+), 6 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/delete_modal.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/delete_modal.tsx diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/delete_modal.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/delete_modal.test.tsx new file mode 100644 index 0000000000000..bd9bfbf5d653d --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/delete_modal.test.tsx @@ -0,0 +1,120 @@ +/* + * 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 { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { act, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import uuid from 'uuid'; +import { getExceptionListItemSchemaMock } from '../../../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; +import { + AppContextTestRender, + createAppRootMockRenderer, +} from '../../../../../../common/mock/endpoint'; +import { getPolicyHostIsolationExceptionsPath } from '../../../../../common/routing'; +import { updateOneHostIsolationExceptionItem } from '../../../../host_isolation_exceptions/service'; +import { PolicyHostIsolationExceptionsDeleteModal } from './delete_modal'; + +jest.mock('../../../../host_isolation_exceptions/service'); + +const updateOneHostIsolationExceptionItemMock = updateOneHostIsolationExceptionItem as jest.Mock; + +describe('Policy details host isolation exceptions delete modal', () => { + let policyId: string; + let render: () => ReturnType; + let renderResult: ReturnType; + let history: AppContextTestRender['history']; + let mockedContext: AppContextTestRender; + let exception: ExceptionListItemSchema; + let onCancel: () => void; + + beforeEach(() => { + policyId = uuid.v4(); + mockedContext = createAppRootMockRenderer(); + exception = getExceptionListItemSchemaMock(); + onCancel = jest.fn(); + updateOneHostIsolationExceptionItemMock.mockClear(); + ({ history } = mockedContext); + render = () => + (renderResult = mockedContext.render( + + )); + + act(() => { + history.push(getPolicyHostIsolationExceptionsPath(policyId)); + }); + }); + + it('should render with enabled buttons', () => { + render(); + expect(renderResult.getByTestId('confirmModalCancelButton')).toBeEnabled(); + expect(renderResult.getByTestId('confirmModalConfirmButton')).toBeEnabled(); + }); + + it('should disable the submit button while deleting ', async () => { + updateOneHostIsolationExceptionItemMock.mockImplementation(() => { + return new Promise((resolve) => setImmediate(resolve)); + }); + render(); + const confirmButton = renderResult.getByTestId('confirmModalConfirmButton'); + userEvent.click(confirmButton); + + await waitFor(() => { + expect(confirmButton).toBeDisabled(); + }); + }); + + it('should call the API with the removed policy from the exception tags', async () => { + exception.tags = ['policy:1234', 'policy:4321', `policy:${policyId}`, 'not-a-policy-tag']; + render(); + const confirmButton = renderResult.getByTestId('confirmModalConfirmButton'); + userEvent.click(confirmButton); + + await waitFor(() => { + expect(updateOneHostIsolationExceptionItemMock).toHaveBeenCalledWith( + mockedContext.coreStart.http, + expect.objectContaining({ + id: exception.id, + tags: ['policy:1234', 'policy:4321', 'not-a-policy-tag'], + }) + ); + }); + }); + + it('should show a success toast if the operation was success', async () => { + updateOneHostIsolationExceptionItemMock.mockReturnValue('all good'); + render(); + const confirmButton = renderResult.getByTestId('confirmModalConfirmButton'); + userEvent.click(confirmButton); + + await waitFor(() => { + expect(updateOneHostIsolationExceptionItemMock).toHaveBeenCalled(); + }); + + expect(mockedContext.coreStart.notifications.toasts.addSuccess).toHaveBeenCalled(); + }); + + it('should show an error toast if the operation failed', async () => { + const error = new Error('the server is too far away'); + updateOneHostIsolationExceptionItemMock.mockRejectedValue(error); + render(); + const confirmButton = renderResult.getByTestId('confirmModalConfirmButton'); + userEvent.click(confirmButton); + + await waitFor(() => { + expect(updateOneHostIsolationExceptionItemMock).toHaveBeenCalled(); + }); + + expect(mockedContext.coreStart.notifications.toasts.addError).toHaveBeenCalledWith(error, { + title: 'Error while attempt to remove host isolation exception', + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/delete_modal.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/delete_modal.tsx new file mode 100644 index 0000000000000..655107b8f357d --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/delete_modal.tsx @@ -0,0 +1,128 @@ +/* + * 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 { EuiCallOut, EuiConfirmModal, EuiSpacer, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import React from 'react'; +import { useMutation, useQueryClient } from 'react-query'; +import { useHttp, useToasts } from '../../../../../../common/lib/kibana'; +import { ServerApiError } from '../../../../../../common/types'; +import { updateOneHostIsolationExceptionItem } from '../../../../host_isolation_exceptions/service'; + +export const PolicyHostIsolationExceptionsDeleteModal = ({ + policyId, + exception, + onCancel, +}: { + policyId: string; + exception: ExceptionListItemSchema; + onCancel: () => void; +}) => { + const toasts = useToasts(); + const http = useHttp(); + const queryClient = useQueryClient(); + + const onDeleteError = (error: ServerApiError) => { + toasts.addError(error as unknown as Error, { + title: i18n.translate( + 'xpack.securitySolution.endpoint.policy.hostIsolationExceptions.list.removeDialog.errorToastTitle', + { + defaultMessage: 'Error while attempt to remove host isolation exception', + } + ), + }); + onCancel(); + }; + + const onDeleteSuccess = () => { + queryClient.invalidateQueries(['endpointSpecificPolicies']); + queryClient.invalidateQueries(['hostIsolationExceptions']); + toasts.addSuccess({ + title: i18n.translate( + 'xpack.securitySolution.endpoint.policy.hostIsolationExceptions.list.removeDialog.successToastTitle', + { defaultMessage: 'Successfully removed' } + ), + text: i18n.translate( + 'xpack.securitySolution.endpoint.policy.hostIsolationExceptions.list.removeDialog.successToastText', + { + defaultMessage: '"{exception}" has been removed from policy', + values: { exception: exception.name }, + } + ), + }); + onCancel(); + }; + + const mutation = useMutation( + async () => { + const modifiedException = { + ...exception, + tags: exception.tags.filter((tag) => tag !== `policy:${policyId}`), + }; + return updateOneHostIsolationExceptionItem(http, modifiedException); + }, + { + onSuccess: onDeleteSuccess, + onError: onDeleteError, + } + ); + + const handleModalConfirm = () => { + mutation.mutate(); + }; + + const handleCancel = () => { + if (!mutation.isLoading) { + onCancel(); + } + }; + + return ( + + +

+ +

+
+ + + + +

+ +

+
+
+ ); +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/list.test.tsx index cb126afac24e8..b37732e68ce26 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/list.test.tsx @@ -7,16 +7,17 @@ import { FoundExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; import { act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; import uuid from 'uuid'; -import { getPolicyHostIsolationExceptionsPath } from '../../../../../common/routing'; +import { getExceptionListItemSchemaMock } from '../../../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import { getFoundExceptionListItemSchemaMock } from '../../../../../../../../lists/common/schemas/response/found_exception_list_item_schema.mock'; import { AppContextTestRender, createAppRootMockRenderer, } from '../../../../../../common/mock/endpoint'; +import { getPolicyHostIsolationExceptionsPath } from '../../../../../common/routing'; import { PolicyHostIsolationExceptionsList } from './list'; -import userEvent from '@testing-library/user-event'; const emptyList = { data: [], @@ -91,4 +92,56 @@ describe('Policy details host isolation exceptions tab', () => { userEvent.type(renderResult.getByTestId('searchField'), 'search me{enter}'); expect(history.location.search).toBe('?filter=search%20me'); }); + + it('should disable the "remove from policy" option to global exceptions', () => { + const testException = getExceptionListItemSchemaMock({ tags: ['policy:all'] }); + const exceptions = { + ...emptyList, + data: [testException], + total: 1, + }; + render(exceptions); + // click the actions button + userEvent.click( + renderResult.getByTestId('hostIsolationExceptions-collapsed-list-card-header-actions-button') + ); + expect(renderResult.getByTestId('remove-from-policy-action')).toBeDisabled(); + }); + + it('should enable the "remove from policy" option to policy-specific exceptions ', () => { + const testException = getExceptionListItemSchemaMock({ + tags: [`policy:${policyId}`, 'policy:1234', 'not-a-policy-tag'], + }); + const exceptions = { + ...emptyList, + data: [testException], + total: 1, + }; + render(exceptions); + // click the actions button + userEvent.click( + renderResult.getByTestId('hostIsolationExceptions-collapsed-list-card-header-actions-button') + ); + expect(renderResult.getByTestId('remove-from-policy-action')).toBeEnabled(); + }); + + it('should render the delete dialog when the "remove from policy" button is clicked', () => { + const testException = getExceptionListItemSchemaMock({ + tags: [`policy:${policyId}`, 'policy:1234', 'not-a-policy-tag'], + }); + const exceptions = { + ...emptyList, + data: [testException], + total: 1, + }; + render(exceptions); + // click the actions button + userEvent.click( + renderResult.getByTestId('hostIsolationExceptions-collapsed-list-card-header-actions-button') + ); + userEvent.click(renderResult.getByTestId('remove-from-policy-action')); + + // check the dialog is there + expect(renderResult.getByTestId('remove-from-policy-dialog')).toBeTruthy(); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/list.tsx index 4bc52d7f191c1..1ec7eed79da73 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/host_isolation_exceptions/components/list.tsx @@ -7,7 +7,10 @@ import { EuiSpacer, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FoundExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { + ExceptionListItemSchema, + FoundExceptionListItemSchema, +} from '@kbn/securitysolution-io-ts-list-types'; import React, { useCallback, useMemo, useState } from 'react'; import { useHistory } from 'react-router-dom'; import { @@ -20,10 +23,12 @@ import { ArtifactCardGridProps, } from '../../../../../components/artifact_card_grid'; import { useEndpointPoliciesToArtifactPolicies } from '../../../../../components/artifact_entry_card/hooks/use_endpoint_policies_to_artifact_policies'; +import { isGlobalPolicyEffected } from '../../../../../components/effected_policy_select/utils'; import { SearchExceptions } from '../../../../../components/search_exceptions'; import { useGetEndpointSpecificPolicies } from '../../../../../services/policies/hooks'; import { getCurrentArtifactsLocation } from '../../../store/policy_details/selectors'; import { usePolicyDetailsSelector } from '../../policy_hooks'; +import { PolicyHostIsolationExceptionsDeleteModal } from './delete_modal'; export const PolicyHostIsolationExceptionsList = ({ exceptions, @@ -37,6 +42,10 @@ export const PolicyHostIsolationExceptionsList = ({ const policiesRequest = useGetEndpointSpecificPolicies(); const urlParams = usePolicyDetailsSelector(getCurrentArtifactsLocation); + const [exceptionItemToDelete, setExceptionItemToDelete] = useState< + ExceptionListItemSchema | undefined + >(); + const [expandedItemsMap, setExpandedItemsMap] = useState>(new Map()); const pagination = { @@ -73,11 +82,35 @@ export const PolicyHostIsolationExceptionsList = ({ ); const artifactCardPolicies = useEndpointPoliciesToArtifactPolicies(policiesRequest.data?.items); - - const provideCardProps: ArtifactCardGridProps['cardComponentProps'] = (item) => { + const provideCardProps: ArtifactCardGridProps['cardComponentProps'] = (artifact) => { + const item = artifact as ExceptionListItemSchema; + const isGlobal = isGlobalPolicyEffected(item.tags); return { expanded: expandedItemsMap.get(item.id) || false, - actions: [], + actions: [ + { + icon: 'trash', + children: i18n.translate( + 'xpack.securitySolution.endpoint.policy.hostIsolationExceptions.list.removeAction', + { defaultMessage: 'Remove from policy' } + ), + onClick: () => { + setExceptionItemToDelete(item); + }, + disabled: isGlobal, + toolTipContent: isGlobal + ? i18n.translate( + 'xpack.securitySolution.endpoint.policy.hostIsolationExceptions.list.removeActionNotAllowed', + { + defaultMessage: + 'Globally applied host isolation exceptions cannot be removed from policy.', + } + ) + : undefined, + toolTipPosition: 'top', + 'data-test-subj': 'remove-from-policy-action', + }, + ], policies: artifactCardPolicies, }; }; @@ -96,6 +129,10 @@ export const PolicyHostIsolationExceptionsList = ({ setExpandedItemsMap(newExpandedMap); }; + const handleDeleteModalClose = useCallback(() => { + setExceptionItemToDelete(undefined); + }, [setExceptionItemToDelete]); + const totalItemsCountLabel = useMemo(() => { return i18n.translate( 'xpack.securitySolution.endpoint.policy.hostIsolationExceptions.list.totalItemCount', @@ -108,6 +145,13 @@ export const PolicyHostIsolationExceptionsList = ({ return ( <> + {exceptionItemToDelete ? ( + + ) : null}