diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/hooks/use_endpoint_policies_to_artifact_policies.ts b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/hooks/use_endpoint_policies_to_artifact_policies.ts new file mode 100644 index 00000000000000..d28c6cbe3b1078 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/hooks/use_endpoint_policies_to_artifact_policies.ts @@ -0,0 +1,38 @@ +/* + * 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 { useMemo } from 'react'; +import { MenuItemPropsByPolicyId } from '..'; +import { PolicyData } from '../../../../../common/endpoint/types'; +import { useAppUrl } from '../../../../common/lib/kibana'; +import { getPolicyDetailPath } from '../../../common/routing'; + +/** + * Takes a list of EndpointPolicies (PolicyData) and turn them + * into MenuItemPropsByPolicyId required by the artifact card. + * + * The resulting menu will open the policies in a new tab + * + */ +export const useEndpointPoliciesToArtifactPolicies = ( + policies: PolicyData[] = [] +): MenuItemPropsByPolicyId => { + const { getAppUrl } = useAppUrl(); + return useMemo(() => { + const data = policies.reduce((policiesMap, policy) => { + const policyId = policy.id; + const policyDetailsPath = getPolicyDetailPath(policyId); + policiesMap[policyId] = { + href: getAppUrl({ path: policyDetailsPath }), + children: policy.name ?? policyId, + target: '_blank', + }; + return policiesMap; + }, {}); + return data; + }, [getAppUrl, policies]); +}; diff --git a/x-pack/plugins/security_solution/public/management/components/effected_policy_select/utils.ts b/x-pack/plugins/security_solution/public/management/components/effected_policy_select/utils.ts new file mode 100644 index 00000000000000..f273ecaa96c562 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/effected_policy_select/utils.ts @@ -0,0 +1,75 @@ +/* + * 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 { PolicyData } from '../../../../common/endpoint/types'; +import { EffectedPolicySelection } from './effected_policy_select'; + +export const GLOBAL_POLICY_TAG = 'policy:all'; + +/** + * Given a list of artifact tags, returns the tags that are not policy tags + * policy tags follow the format: `policy:id` + */ +export function getArtifactTagsWithoutPolicies(tags?: string[]): string[] { + return tags?.filter((tag) => !tag.startsWith('policy:')) || []; +} + +/** + * Return a list of artifact policy tags based on a current + * selection by the EffectedPolicySelection component. + */ +export function getArtifactTagsByEffectedPolicySelection( + selection: EffectedPolicySelection, + otherTags: string[] = [] +): string[] { + if (selection.isGlobal) { + return [GLOBAL_POLICY_TAG, ...otherTags]; + } + const newTags = selection.selected.map((policy) => { + return `policy:${policy.id}`; + }); + + return newTags.concat(otherTags); +} + +/** + * Given a list of an Exception item tags it will return + * the parsed policies from it. + * + * Policy tags follow the pattern `policy:id` + * non policy tags will be ignored. + */ +export function getEffectedPolicySelectionByTags( + tags: string[], + policies: PolicyData[] +): EffectedPolicySelection { + if (tags.length === 0 || tags.find((tag) => tag === GLOBAL_POLICY_TAG)) { + return { + isGlobal: true, + selected: [], + }; + } + const selected: PolicyData[] = tags.reduce((acc, tag) => { + // edge case: a left over tag with a non-existed policy + // will be removed by veryfing the policy exists + const id = tag.split(':')[1]; + const foundPolicy = policies.find((policy) => policy.id === id); + if (foundPolicy !== undefined) { + acc.push(foundPolicy); + } + return acc; + }, [] as PolicyData[]); + + return { + isGlobal: false, + selected, + }; +} + +export function isGlobalPolicyEffected(tags?: string[]): boolean { + return tags !== undefined && tags.find((tag) => tag === GLOBAL_POLICY_TAG) !== undefined; +} diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts index d82518c303c6ea..2759a358415247 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts @@ -5,11 +5,20 @@ * 2.0. */ +import type { DataViewBase, Query } from '@kbn/es-query'; +import { CoreStart, HttpStart } from 'kibana/public'; import { Dispatch } from 'redux'; import semverGte from 'semver/functions/gte'; - -import { CoreStart, HttpStart } from 'kibana/public'; -import type { DataViewBase, Query } from '@kbn/es-query'; +import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../../../../../../fleet/common'; +import { METADATA_TRANSFORM_STATS_URL } from '../../../../../common/constants'; +import { + BASE_POLICY_RESPONSE_ROUTE, + ENDPOINT_ACTION_LOG_ROUTE, + HOST_METADATA_GET_ROUTE, + HOST_METADATA_LIST_ROUTE, + metadataCurrentIndexPattern, + METADATA_UNITED_INDEX, +} from '../../../../../common/endpoint/constants'; import { ActivityLog, GetHostPolicyResponse, @@ -21,67 +30,57 @@ import { ImmutableObject, MetadataListResponse, } from '../../../../../common/endpoint/types'; -import { GetPolicyListResponse } from '../../policy/types'; +import { isolateHost, unIsolateHost } from '../../../../common/lib/endpoint_isolation'; +import { fetchPendingActionsByAgentId } from '../../../../common/lib/endpoint_pending_actions'; import { ImmutableMiddlewareAPI, ImmutableMiddlewareFactory } from '../../../../common/store'; +import { AppAction } from '../../../../common/store/actions'; +import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables'; +import { sendGetEndpointSpecificPackagePolicies } from '../../../services/policies/policies'; import { - isOnEndpointPage, - hasSelectedEndpoint, - selectedAgent, - uiQueryParams, - listData, + asStaleResourceState, + createFailedResourceState, + createLoadedResourceState, + createLoadingResourceState, +} from '../../../state'; +import { + sendGetAgentPolicyList, + sendGetEndpointSecurityPackage, + sendGetFleetAgentsWithEndpoint, +} from '../../policy/store/services/ingest'; +import { GetPolicyListResponse } from '../../policy/types'; +import { + AgentIdsPendingActions, + EndpointState, + PolicyIds, + TransformStats, + TransformStatsResponse, +} from '../types'; +import { getIsInvalidDateRange } from '../utils'; +import { EndpointPackageInfoStateChanged } from './action'; +import { + detailsData, endpointPackageInfo, - nonExistingPolicies, - patterns, - searchBarQuery, - getIsIsolationRequestPending, - getCurrentIsolationRequestState, + endpointPackageVersion, getActivityLogData, getActivityLogDataPaging, - getLastLoadedActivityLogData, getActivityLogError, - detailsData, + getActivityLogIsUninitializedOrHasSubsequentAPIError, + getCurrentIsolationRequestState, getIsEndpointPackageInfoUninitialized, + getIsIsolationRequestPending, getIsOnEndpointDetailsActivityLog, + getLastLoadedActivityLogData, getMetadataTransformStats, + hasSelectedEndpoint, isMetadataTransformStatsLoading, - getActivityLogIsUninitializedOrHasSubsequentAPIError, - endpointPackageVersion, + isOnEndpointPage, + listData, + nonExistingPolicies, + patterns, + searchBarQuery, + selectedAgent, + uiQueryParams, } from './selectors'; -import { - AgentIdsPendingActions, - EndpointState, - PolicyIds, - TransformStats, - TransformStatsResponse, -} from '../types'; -import { - sendGetEndpointSecurityPackage, - sendGetAgentPolicyList, - sendGetFleetAgentsWithEndpoint, -} from '../../policy/store/services/ingest'; -import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../../../../../../fleet/common'; -import { - ENDPOINT_ACTION_LOG_ROUTE, - HOST_METADATA_GET_ROUTE, - HOST_METADATA_LIST_ROUTE, - BASE_POLICY_RESPONSE_ROUTE, - metadataCurrentIndexPattern, - METADATA_UNITED_INDEX, -} from '../../../../../common/endpoint/constants'; -import { - asStaleResourceState, - createFailedResourceState, - createLoadedResourceState, - createLoadingResourceState, -} from '../../../state'; -import { isolateHost, unIsolateHost } from '../../../../common/lib/endpoint_isolation'; -import { AppAction } from '../../../../common/store/actions'; -import { resolvePathVariables } from '../../../../common/utils/resolve_path_variables'; -import { EndpointPackageInfoStateChanged } from './action'; -import { fetchPendingActionsByAgentId } from '../../../../common/lib/endpoint_pending_actions'; -import { getIsInvalidDateRange } from '../utils'; -import { METADATA_TRANSFORM_STATS_URL } from '../../../../../common/constants'; -import { sendGetEndpointSpecificPackagePolicies } from '../../../services/policies'; type EndpointPageStore = ImmutableMiddlewareAPI; diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.test.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.test.tsx index eb8294a1f46588..10180d378457a4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.test.tsx @@ -5,35 +5,44 @@ * 2.0. */ -import { createEmptyHostIsolationException } from '../../utils'; -import { HostIsolationExceptionsForm } from './form'; -import React from 'react'; -import { - AppContextTestRender, - createAppRootMockRenderer, -} from '../../../../../common/mock/endpoint'; import { CreateExceptionListItemSchema, UpdateExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; import userEvent from '@testing-library/user-event'; +import React from 'react'; import uuid from 'uuid'; +import { + AppContextTestRender, + createAppRootMockRenderer, +} from '../../../../../common/mock/endpoint'; +import { sendGetEndpointSpecificPackagePoliciesMock } from '../../../../services/policies/test_mock_utilts'; +import { GetPolicyListResponse } from '../../../policy/types'; +import { createEmptyHostIsolationException } from '../../utils'; +import { HostIsolationExceptionsForm } from './form'; -describe('When on the host isolation exceptions add entry form', () => { +describe('When on the host isolation exceptions entry form', () => { let render: ( exception: CreateExceptionListItemSchema | UpdateExceptionListItemSchema ) => ReturnType; let renderResult: ReturnType; const onChange = jest.fn(); const onError = jest.fn(); + let policiesRequest: GetPolicyListResponse; - beforeEach(() => { + beforeEach(async () => { onChange.mockReset(); onError.mockReset(); const mockedContext = createAppRootMockRenderer(); + policiesRequest = await sendGetEndpointSpecificPackagePoliciesMock(); render = (exception) => { return mockedContext.render( - + ); }; }); @@ -77,19 +86,80 @@ describe('When on the host isolation exceptions add entry form', () => { } ); - it('should call onChange when a value is introduced in a field', () => { + it('should call onChange with the partial change when a value is introduced in a field', () => { const ipInput = renderResult.getByTestId('hostIsolationExceptions-form-ip-input'); userEvent.type(ipInput, '10.0.0.1'); expect(onChange).toHaveBeenLastCalledWith({ - ...newException, entries: [ { field: 'destination.ip', operator: 'included', type: 'match', value: '10.0.0.1' }, ], }); }); + + it('should select the "global" policy by default', () => { + expect( + renderResult + .getByTestId('effectedPolicies-select-global') + .classList.contains('euiButtonGroupButton-isSelected') + ).toBe(true); + // policy selector should be hidden + expect(renderResult.queryByTestId('effectedPolicies-select-policiesSelectable')).toBeFalsy(); + }); + + it('should display the policy list when "per policy" is selected', () => { + userEvent.click(renderResult.getByTestId('perPolicy')); + + // policy selector should show up + expect(renderResult.getByTestId('effectedPolicies-select-policiesSelectable')).toBeTruthy(); + }); + + it('should call onChange when a policy is selected from the policy selectiion', () => { + const policyId = policiesRequest.items[0].id; + userEvent.click(renderResult.getByTestId('perPolicy')); + userEvent.click(renderResult.getByTestId(`policy-${policyId}`)); + expect(onChange).toHaveBeenLastCalledWith({ + tags: [`policy:${policyId}`], + }); + }); + + it('should retain the previous policy selection when switching from per-policy to global', () => { + const policyId = policiesRequest.items[0].id; + + // move to per-policy and select the first + userEvent.click(renderResult.getByTestId('perPolicy')); + userEvent.click(renderResult.getByTestId(`policy-${policyId}`)); + expect(renderResult.queryByTestId('effectedPolicies-select-policiesSelectable')).toBeTruthy(); + expect(onChange).toHaveBeenLastCalledWith({ + tags: [`policy:${policyId}`], + }); + + // move back to global + userEvent.click(renderResult.getByTestId('globalPolicy')); + expect(renderResult.queryByTestId('effectedPolicies-select-policiesSelectable')).toBeFalsy(); + expect(onChange).toHaveBeenLastCalledWith({ + tags: [`policy:all`], + }); + + // move back to per-policy + userEvent.click(renderResult.getByTestId('perPolicy')); + // the previous selected policy should be selected + expect(renderResult.getByTestId(`policy-${policyId}`)).toHaveAttribute( + 'aria-selected', + 'true' + ); + // on change called with the previous policy + expect(onChange).toHaveBeenLastCalledWith({ + tags: [`policy:${policyId}`], + }); + }); }); - describe('When editing an existing exception', () => { + /** + * NOTE: fewer tests exists for update as the form component + * behaves the same for edit and add with the only + * difference of having pre-filled fields + */ + describe('When editing an existing exception with global policy', () => { let existingException: UpdateExceptionListItemSchema; beforeEach(() => { existingException = { @@ -98,6 +168,7 @@ describe('When on the host isolation exceptions add entry form', () => { description: 'initial description', id: uuid.v4(), item_id: uuid.v4(), + tags: ['policy:all'], entries: [ { field: 'destination.ip', @@ -107,10 +178,10 @@ describe('When on the host isolation exceptions add entry form', () => { }, ], }; - renderResult = render(existingException); }); it('should render the form with pre-filled inputs', () => { + renderResult = render(existingException); expect(renderResult.getByTestId('hostIsolationExceptions-form-name-input')).toHaveValue( 'name edit me' ); @@ -122,16 +193,45 @@ describe('When on the host isolation exceptions add entry form', () => { ).toHaveValue('initial description'); }); - it('should call onChange when a value is introduced in a field', () => { + it('should call onChange with the partial change when a value is introduced in a field', () => { + renderResult = render(existingException); const ipInput = renderResult.getByTestId('hostIsolationExceptions-form-ip-input'); userEvent.clear(ipInput); userEvent.type(ipInput, '10.0.100.1'); expect(onChange).toHaveBeenCalledWith({ - ...existingException, entries: [ { field: 'destination.ip', operator: 'included', type: 'match', value: '10.0.100.1' }, ], }); }); + + it('should show global pre-selected', () => { + renderResult = render(existingException); + expect( + renderResult + .getByTestId('effectedPolicies-select-global') + .classList.contains('euiButtonGroupButton-isSelected') + ).toBe(true); + // policy selector should be hidden + expect(renderResult.queryByTestId('effectedPolicies-select-policiesSelectable')).toBeFalsy(); + }); + + it('should show pre-selected policies', () => { + const policyId1 = policiesRequest.items[0].id; + const policyId2 = policiesRequest.items[3].id; + existingException.tags = [`policy:${policyId1}`, `policy:${policyId2}`]; + + renderResult = render(existingException); + + expect(renderResult.queryByTestId('effectedPolicies-select-policiesSelectable')).toBeTruthy(); + expect(renderResult.getByTestId(`policy-${policyId1}`)).toHaveAttribute( + 'aria-selected', + 'true' + ); + expect(renderResult.getByTestId(`policy-${policyId2}`)).toHaveAttribute( + 'aria-selected', + 'true' + ); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.tsx index 38f75dd5fb07a3..a9d1572ba5cba2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.tsx @@ -21,6 +21,18 @@ import { UpdateExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { PolicyData } from '../../../../../../common/endpoint/types'; +import { + EffectedPolicySelect, + EffectedPolicySelection, + EffectedPolicySelectProps, +} from '../../../../components/effected_policy_select'; +import { + getArtifactTagsByEffectedPolicySelection, + getArtifactTagsWithoutPolicies, + getEffectedPolicySelectionByTags, + isGlobalPolicyEffected, +} from '../../../../components/effected_policy_select/utils'; import { isValidIPv4OrCIDR } from '../../utils'; import { DESCRIPTION_LABEL, @@ -42,15 +54,30 @@ interface ExceptionIpEntry { export const HostIsolationExceptionsForm: React.FC<{ exception: CreateExceptionListItemSchema | UpdateExceptionListItemSchema; + policies: PolicyData[]; onError: (error: boolean) => void; - onChange: (exception: CreateExceptionListItemSchema | UpdateExceptionListItemSchema) => void; -}> = memo(({ exception, onError, onChange }) => { + onChange: ( + exception: Partial | Partial + ) => void; +}> = memo(({ exception, onError, policies, onChange }) => { const ipEntry = exception.entries[0] as ExceptionIpEntry; const [hasBeenInputNameVisited, setHasBeenInputNameVisited] = useState(false); const [hasBeenInputIpVisited, setHasBeenInputIpVisited] = useState(false); const [hasNameError, setHasNameError] = useState(!exception.name); const [hasIpError, setHasIpError] = useState(!ipEntry.value); + const [selectedPolicies, setSelectedPolicies] = useState({ + isGlobal: isGlobalPolicyEffected(exception.tags), + selected: [], + }); + + // set current policies if not previously selected + useEffect(() => { + if (selectedPolicies.selected.length === 0 && exception.tags) { + setSelectedPolicies(getEffectedPolicySelectionByTags(exception.tags, policies)); + } + }, [exception.tags, policies, selectedPolicies.selected.length]); + useEffect(() => { onError(hasNameError || hasIpError); }, [hasNameError, hasIpError, onError]); @@ -63,9 +90,9 @@ export const HostIsolationExceptionsForm: React.FC<{ return; } setHasNameError(false); - onChange({ ...exception, name }); + onChange({ name }); }, - [exception, onChange] + [onChange] ); const handleOnIpChange = useCallback( @@ -77,7 +104,6 @@ export const HostIsolationExceptionsForm: React.FC<{ } setHasIpError(false); onChange({ - ...exception, entries: [ { field: 'destination.ip', @@ -88,14 +114,33 @@ export const HostIsolationExceptionsForm: React.FC<{ ], }); }, - [exception, onChange] + [onChange] + ); + + const handlePolicySelectChange: EffectedPolicySelectProps['onChange'] = useCallback( + (selection) => { + // preseve the previous selection between global and not global toggle + if (selection.isGlobal) { + setSelectedPolicies({ isGlobal: true, selected: selection.selected }); + } else { + setSelectedPolicies(selection); + } + + onChange({ + tags: getArtifactTagsByEffectedPolicySelection( + selection, + getArtifactTagsWithoutPolicies(exception.tags) + ), + }); + }, + [exception.tags, onChange] ); const handleOnDescriptionChange = useCallback( (event: React.ChangeEvent) => { - onChange({ ...exception, description: event.target.value }); + onChange({ description: event.target.value }); }, - [exception, onChange] + [onChange] ); const nameInput = useMemo( @@ -120,7 +165,7 @@ export const HostIsolationExceptionsForm: React.FC<{ /> ), - [hasNameError, hasBeenInputNameVisited, exception.name, handleOnChangeName] + [exception.name, handleOnChangeName, hasBeenInputNameVisited, hasNameError] ); const ipInput = useMemo( @@ -145,7 +190,7 @@ export const HostIsolationExceptionsForm: React.FC<{ /> ), - [hasIpError, hasBeenInputIpVisited, exception.entries, handleOnIpChange] + [exception.entries, handleOnIpChange, hasBeenInputIpVisited, hasIpError] ); const descriptionInput = useMemo( @@ -203,6 +248,17 @@ export const HostIsolationExceptionsForm: React.FC<{ {ipInput} + + + + ); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.test.tsx index 4ed79cabdb32b9..e9735c6bad5ef8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.test.tsx @@ -5,29 +5,35 @@ * 2.0. */ +import { waitFor, waitForElementToBeRemoved } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; +import uuid from 'uuid'; import { AppContextTestRender, createAppRootMockRenderer, } from '../../../../../common/mock/endpoint'; -import userEvent from '@testing-library/user-event'; -import { HostIsolationExceptionsFormFlyout } from './form_flyout'; -import uuid from 'uuid'; -import { createEmptyHostIsolationException } from '../../utils'; -import { waitFor, waitForElementToBeRemoved } from '@testing-library/react'; +import { getHostIsolationExceptionsListPath } from '../../../../common/routing'; +import { sendGetEndpointSpecificPackagePolicies } from '../../../../services/policies/policies'; +import { sendGetEndpointSpecificPackagePoliciesMock } from '../../../../services/policies/test_mock_utilts'; import { createHostIsolationExceptionItem, - updateOneHostIsolationExceptionItem, getOneHostIsolationExceptionItem, + updateOneHostIsolationExceptionItem, } from '../../service'; -import { getHostIsolationExceptionsListPath } from '../../../../common/routing'; +import { createEmptyHostIsolationException } from '../../utils'; +import { HostIsolationExceptionsFormFlyout } from './form_flyout'; jest.mock('../../service.ts'); jest.mock('../../../../../common/hooks/use_license'); +jest.mock('../../../../services/policies/policies'); const createHostIsolationExceptionItemMock = createHostIsolationExceptionItem as jest.Mock; const updateOneHostIsolationExceptionItemMock = updateOneHostIsolationExceptionItem as jest.Mock; const getOneHostIsolationExceptionItemMock = getOneHostIsolationExceptionItem as jest.Mock; +(sendGetEndpointSpecificPackagePolicies as jest.Mock).mockImplementation( + sendGetEndpointSpecificPackagePoliciesMock +); describe('When on the host isolation exceptions flyout form', () => { let mockedContext: AppContextTestRender; diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.tsx index 64ead8a94f037f..d48343f0f4b1cf 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.tsx @@ -22,10 +22,17 @@ import { UpdateExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; import React, { memo, useCallback, useMemo, useState } from 'react'; -import { useMutation, useQuery, useQueryClient } from 'react-query'; -import { ServerApiError } from '../../../../../common/types'; +import { useMutation, useQueryClient } from 'react-query'; import { Loader } from '../../../../../common/components/loader'; import { useHttp, useToasts } from '../../../../../common/lib/kibana'; +import { ServerApiError } from '../../../../../common/types'; +import { useGetEndpointSpecificPolicies } from '../../../../services/policies/hooks'; +import { + createHostIsolationExceptionItem, + updateOneHostIsolationExceptionItem, +} from '../../service'; +import { useGetHostIsolationExceptionFormEntry } from '../hooks'; +import { HostIsolationExceptionsForm } from './form'; import { getCreateErrorMessage, getCreationSuccessMessage, @@ -33,13 +40,6 @@ import { getUpdateErrorMessage, getUpdateSuccessMessage, } from './translations'; -import { createEmptyHostIsolationException } from '../../utils'; -import { HostIsolationExceptionsForm } from './form'; -import { - createHostIsolationExceptionItem, - getOneHostIsolationExceptionItem, - updateOneHostIsolationExceptionItem, -} from '../../service'; export const HostIsolationExceptionsFormFlyout = memo( ({ onCancel, id }: { onCancel: () => void; id?: string }) => { @@ -52,28 +52,23 @@ export const HostIsolationExceptionsFormFlyout = memo( CreateExceptionListItemSchema | UpdateExceptionListItemSchema | undefined >(undefined); - useQuery( - ['hostIsolationExceptions', 'form', id], - async () => { - // for editing, fetch from the API - if (id !== undefined) { - return getOneHostIsolationExceptionItem(http, id); - } - // for adding, return a new empty object - return createEmptyHostIsolationException(); + // Load the entry to create or edit + useGetHostIsolationExceptionFormEntry({ + id, + onSuccess: (data) => setException(data), + onError: (error) => { + toasts.addWarning(getLoadErrorMessage(error)); + onCancel(); }, - { - refetchIntervalInBackground: false, - refetchOnWindowFocus: false, - onSuccess: (data) => { - setException(data); - }, - onError: (error) => { - toasts.addWarning(getLoadErrorMessage(error)); - onCancel(); - }, - } - ); + }); + + // load the list of policies> + const policiesRequest = useGetEndpointSpecificPolicies({ + onError: (error) => { + toasts.addWarning(getLoadErrorMessage(error)); + onCancel(); + }, + }); const mutation = useMutation( () => { @@ -144,7 +139,13 @@ export const HostIsolationExceptionsFormFlyout = memo( [formHasError, handleOnSubmit, isEditing, mutation.isLoading] ); - return exception ? ( + const handleFormChange = ( + change: Partial | Partial + ) => { + setException(Object.assign(exception, change)); + }; + + return exception && policiesRequest.data?.items ? ( diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/translations.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/translations.ts index 9504aa0673e544..de8f8bb9f0d23c 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/translations.ts @@ -127,3 +127,10 @@ export const getCreationSuccessMessage = (name: string) => { } ); }; + +export const getLoadPoliciesError = (error: ServerApiError) => { + return i18n.translate('xpack.securitySolution.hostIsolationExceptions.failedLoadPolicies', { + defaultMessage: 'There was an error loading policies: "{error}"', + values: { error: error.message }, + }); +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/hooks.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/hooks.ts index 0b18a5b61bd012..efdc481b2fe424 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/hooks.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/hooks.ts @@ -4,24 +4,33 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { + CreateExceptionListItemSchema, + FoundExceptionListItemSchema, + UpdateExceptionListItemSchema, +} from '@kbn/securitysolution-io-ts-list-types'; import { useCallback, useEffect, useState } from 'react'; +import { QueryObserverResult, useQuery } from 'react-query'; import { useSelector } from 'react-redux'; import { useHistory } from 'react-router-dom'; -import { FoundExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; -import { QueryObserverResult, useQuery } from 'react-query'; -import { ServerApiError } from '../../../../common/types'; -import { useHttp } from '../../../../common/lib/kibana/hooks'; import { useEndpointPrivileges } from '../../../../common/components/user_privileges/endpoint'; +import { useHttp } from '../../../../common/lib/kibana/hooks'; import { State } from '../../../../common/store'; +import { ServerApiError } from '../../../../common/types'; import { MANAGEMENT_STORE_GLOBAL_NAMESPACE, MANAGEMENT_STORE_HOST_ISOLATION_EXCEPTIONS_NAMESPACE, } from '../../../common/constants'; import { getHostIsolationExceptionsListPath } from '../../../common/routing'; -import { getHostIsolationExceptionItems, getHostIsolationExceptionSummary } from '../service'; +import { parseQueryFilterToKQL } from '../../../common/utils'; +import { + getHostIsolationExceptionItems, + getHostIsolationExceptionSummary, + getOneHostIsolationExceptionItem, +} from '../service'; import { getCurrentLocation } from '../store/selector'; import { HostIsolationExceptionsPageLocation, HostIsolationExceptionsPageState } from '../types'; -import { parseQueryFilterToKQL } from '../../../common/utils'; +import { createEmptyHostIsolationException } from '../utils'; export function useHostIsolationExceptionsSelector( selector: (state: HostIsolationExceptionsPageState) => R @@ -95,3 +104,32 @@ export function useFetchHostIsolationExceptionsList(): QueryObserverResult< } ); } + +export function useGetHostIsolationExceptionFormEntry({ + id, + onSuccess, + onError, +}: { + id?: string; + onSuccess: (data: CreateExceptionListItemSchema | UpdateExceptionListItemSchema) => void; + onError: (error: ServerApiError) => void; +}): QueryObserverResult { + const http = useHttp(); + return useQuery( + ['hostIsolationExceptions', 'form', id], + async () => { + // for editing, fetch from the API + if (id !== undefined) { + return getOneHostIsolationExceptionItem(http, id); + } + // for adding, return a new empty object + return createEmptyHostIsolationException(); + }, + { + refetchIntervalInBackground: false, + refetchOnWindowFocus: false, + onSuccess, + onError, + } + ); +} diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx index 00a3ab9b2102d0..94bd6ea73d7fa8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx @@ -11,6 +11,8 @@ import React from 'react'; import { getFoundExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/found_exception_list_item_schema.mock'; import { HOST_ISOLATION_EXCEPTIONS_PATH } from '../../../../../common/constants'; import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint'; +import { sendGetEndpointSpecificPackagePolicies } from '../../../services/policies/policies'; +import { sendGetEndpointSpecificPackagePoliciesMock } from '../../../services/policies/test_mock_utilts'; import { getHostIsolationExceptionItems } from '../service'; import { HostIsolationExceptionsList } from './host_isolation_exceptions_list'; import { useUserPrivileges as _useUserPrivileges } from '../../../../common/components/user_privileges'; @@ -19,8 +21,12 @@ import { EndpointPrivileges } from '../../../../../common/endpoint/types'; jest.mock('../service'); jest.mock('../../../../common/hooks/use_license'); jest.mock('../../../../common/components/user_privileges'); +jest.mock('../../../services/policies/policies'); const getHostIsolationExceptionItemsMock = getHostIsolationExceptionItems as jest.Mock; +(sendGetEndpointSpecificPackagePolicies as jest.Mock).mockImplementation( + sendGetEndpointSpecificPackagePoliciesMock +); describe('When on the host isolation exceptions page', () => { let render: () => ReturnType; @@ -43,7 +49,9 @@ describe('When on the host isolation exceptions page', () => { }; const waitForApiCall = () => { - return waitFor(() => expect(getHostIsolationExceptionItemsMock).toHaveBeenCalled()); + return waitFor(() => { + expect(getHostIsolationExceptionItemsMock).toHaveBeenCalled(); + }); }; beforeEach(() => { @@ -185,6 +193,9 @@ describe('When on the host isolation exceptions page', () => { await waitForApiCall(); userEvent.click(renderResult.getByTestId('hostIsolationExceptionsListAddButton')); await waitForApiCall(); + await waitFor(() => { + expect(sendGetEndpointSpecificPackagePolicies).toHaveBeenCalled(); + }); expect(renderResult.getByTestId('hostIsolationExceptionsCreateEditFlyout')).toBeTruthy(); }); @@ -192,6 +203,9 @@ describe('When on the host isolation exceptions page', () => { history.push(`${HOST_ISOLATION_EXCEPTIONS_PATH}?show=create`); render(); await waitForApiCall(); + await waitFor(() => { + expect(sendGetEndpointSpecificPackagePolicies).toHaveBeenCalled(); + }); expect(renderResult.getByTestId('hostIsolationExceptionsCreateEditFlyout')).toBeTruthy(); expect(renderResult.queryByTestId('hostIsolationExceptionsCreateEditFlyout')).toBeTruthy(); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx index 816aef5ca2dce5..d4dcb105cdb51d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx @@ -5,37 +5,41 @@ * 2.0. */ -import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { EuiButton, EuiSpacer, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React, { useCallback, useEffect, useState } from 'react'; -import { EuiButton, EuiText, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import React, { useCallback, useEffect, useState } from 'react'; import { useHistory } from 'react-router-dom'; +import { Immutable } from '../../../../../common/endpoint/types'; import { ExceptionItem } from '../../../../common/components/exceptions/viewer/exception_item'; -import { getCurrentLocation } from '../store/selector'; +import { useUserPrivileges } from '../../../../common/components/user_privileges'; +import { useToasts } from '../../../../common/lib/kibana'; import { - useFetchHostIsolationExceptionsList, - useHostIsolationExceptionsNavigateCallback, - useHostIsolationExceptionsSelector, -} from './hooks'; -import { PaginatedContent, PaginatedContentProps } from '../../../components/paginated_content'; -import { Immutable } from '../../../../../common/endpoint/types'; + MANAGEMENT_DEFAULT_PAGE_SIZE, + MANAGEMENT_PAGE_SIZE_OPTIONS, +} from '../../../common/constants'; +import { getEndpointListPath } from '../../../common/routing'; import { AdministrationListPage } from '../../../components/administration_list_page'; -import { SearchExceptions } from '../../../components/search_exceptions'; import { ArtifactEntryCard, ArtifactEntryCardProps } from '../../../components/artifact_entry_card'; -import { HostIsolationExceptionsEmptyState } from './components/empty'; +import { useEndpointPoliciesToArtifactPolicies } from '../../../components/artifact_entry_card/hooks/use_endpoint_policies_to_artifact_policies'; +import { PaginatedContent, PaginatedContentProps } from '../../../components/paginated_content'; +import { SearchExceptions } from '../../../components/search_exceptions'; +import { useGetEndpointSpecificPolicies } from '../../../services/policies/hooks'; +import { getCurrentLocation } from '../store/selector'; import { HostIsolationExceptionDeleteModal } from './components/delete_modal'; +import { HostIsolationExceptionsEmptyState } from './components/empty'; import { HostIsolationExceptionsFormFlyout } from './components/form_flyout'; import { DELETE_HOST_ISOLATION_EXCEPTION_LABEL, EDIT_HOST_ISOLATION_EXCEPTION_LABEL, + getLoadPoliciesError, } from './components/translations'; -import { getEndpointListPath } from '../../../common/routing'; import { - MANAGEMENT_DEFAULT_PAGE_SIZE, - MANAGEMENT_PAGE_SIZE_OPTIONS, -} from '../../../common/constants'; -import { useUserPrivileges } from '../../../../common/components/user_privileges'; + useFetchHostIsolationExceptionsList, + useHostIsolationExceptionsNavigateCallback, + useHostIsolationExceptionsSelector, +} from './hooks'; type HostIsolationExceptionPaginatedContent = PaginatedContentProps< Immutable, @@ -52,6 +56,14 @@ export const HostIsolationExceptionsList = () => { const [itemToDelete, setItemToDelete] = useState(null); const { isLoading, data, error, refetch } = useFetchHostIsolationExceptionsList(); + const toasts = useToasts(); + + // load the list of policies> + const policiesRequest = useGetEndpointSpecificPolicies({ + onError: (err) => { + toasts.addDanger(getLoadPoliciesError(err)); + }, + }); const pagination = { totalItemCount: data?.total ?? 0, @@ -79,6 +91,8 @@ export const HostIsolationExceptionsList = () => { [navigateCallback] ); + const artifactCardPolicies = useEndpointPoliciesToArtifactPolicies(policiesRequest.data?.items); + function handleItemComponentProps(element: ExceptionListItemSchema): ArtifactEntryCardProps { const editAction = { icon: 'controlsHorizontal', @@ -103,6 +117,7 @@ export const HostIsolationExceptionsList = () => { item: element, 'data-test-subj': `hostIsolationExceptionsCard`, actions: privileges.canIsolateHost ? [editAction, deleteAction] : [deleteAction], + policies: artifactCardPolicies, }; } diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/trusted_apps_http_service.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/trusted_apps_http_service.ts index b12d5e9c65cd1a..add5f73b49b913 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/trusted_apps_http_service.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/trusted_apps_http_service.ts @@ -5,19 +5,20 @@ * 2.0. */ -import pMap from 'p-map'; -import { HttpStart } from 'kibana/public'; -import { - ENDPOINT_TRUSTED_APPS_LIST_ID, - EXCEPTION_LIST_ITEM_URL, - EXCEPTION_LIST_URL, -} from '@kbn/securitysolution-list-constants'; import { ExceptionListItemSchema, ExceptionListSchema, ExceptionListSummarySchema, FoundExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; +import { + ENDPOINT_TRUSTED_APPS_LIST_ID, + EXCEPTION_LIST_ITEM_URL, + EXCEPTION_LIST_URL, +} from '@kbn/securitysolution-list-constants'; +import { HttpStart } from 'kibana/public'; +import pMap from 'p-map'; +import { toUpdateTrustedApp } from '../../../../../common/endpoint/service/trusted_apps/to_update_trusted_app'; import { DeleteTrustedAppsRequestParams, GetOneTrustedAppRequestParams, @@ -33,16 +34,15 @@ import { PutTrustedAppUpdateResponse, TrustedApp, } from '../../../../../common/endpoint/types'; -import { sendGetEndpointSpecificPackagePolicies } from '../../../services/policies'; +import { sendGetEndpointSpecificPackagePolicies } from '../../../services/policies/policies'; +import { TRUSTED_APPS_EXCEPTION_LIST_DEFINITION } from '../constants'; import { isGlobalEffectScope } from '../state/type_guards'; -import { toUpdateTrustedApp } from '../../../../../common/endpoint/service/trusted_apps/to_update_trusted_app'; -import { validateTrustedAppHttpRequestBody } from './validate_trusted_app_http_request_body'; import { exceptionListItemToTrustedApp, newTrustedAppToCreateExceptionListItem, updatedTrustedAppToUpdateExceptionListItem, } from './mappers'; -import { TRUSTED_APPS_EXCEPTION_LIST_DEFINITION } from '../constants'; +import { validateTrustedAppHttpRequestBody } from './validate_trusted_app_http_request_body'; export interface TrustedAppsService { getTrustedApp(params: GetOneTrustedAppRequestParams): Promise; diff --git a/x-pack/plugins/security_solution/public/management/services/policies/hooks.ts b/x-pack/plugins/security_solution/public/management/services/policies/hooks.ts new file mode 100644 index 00000000000000..d59b2ca9841318 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/services/policies/hooks.ts @@ -0,0 +1,35 @@ +/* + * 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 { QueryObserverResult, useQuery } from 'react-query'; +import { useHttp } from '../../../common/lib/kibana/hooks'; +import { ServerApiError } from '../../../common/types'; +import { GetPolicyListResponse } from '../../pages/policy/types'; +import { sendGetEndpointSpecificPackagePolicies } from './policies'; + +export function useGetEndpointSpecificPolicies({ + onError, +}: { + onError: (error: ServerApiError) => void; +}): QueryObserverResult { + const http = useHttp(); + return useQuery( + ['endpointSpecificPolicies'], + () => { + return sendGetEndpointSpecificPackagePolicies(http, { + query: { + page: 1, + perPage: 1000, + }, + }); + }, + { + refetchIntervalInBackground: false, + refetchOnWindowFocus: false, + onError, + } + ); +} diff --git a/x-pack/plugins/security_solution/public/management/services/policies.test.ts b/x-pack/plugins/security_solution/public/management/services/policies/policies.test.ts similarity index 85% rename from x-pack/plugins/security_solution/public/management/services/policies.test.ts rename to x-pack/plugins/security_solution/public/management/services/policies/policies.test.ts index 0b93dffb71d3c8..acdecd70723660 100644 --- a/x-pack/plugins/security_solution/public/management/services/policies.test.ts +++ b/x-pack/plugins/security_solution/public/management/services/policies/policies.test.ts @@ -5,9 +5,9 @@ * 2.0. */ -import { httpServiceMock } from '../../../../../../src/core/public/mocks'; -import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../../../../fleet/common'; -import { PACKAGE_POLICY_API_ROUTES } from '../../../../fleet/common/constants/routes'; +import { httpServiceMock } from '../../../../../../../src/core/public/mocks'; +import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../../../../../fleet/common'; +import { PACKAGE_POLICY_API_ROUTES } from '../../../../../fleet/common/constants/routes'; import { sendGetEndpointSpecificPackagePolicies } from './policies'; describe('ingest service', () => { diff --git a/x-pack/plugins/security_solution/public/management/services/policies.ts b/x-pack/plugins/security_solution/public/management/services/policies/policies.ts similarity index 84% rename from x-pack/plugins/security_solution/public/management/services/policies.ts rename to x-pack/plugins/security_solution/public/management/services/policies/policies.ts index 63810ad499c099..2cd71fb26655a3 100644 --- a/x-pack/plugins/security_solution/public/management/services/policies.ts +++ b/x-pack/plugins/security_solution/public/management/services/policies/policies.ts @@ -9,9 +9,9 @@ import { HttpFetchOptions, HttpStart } from 'kibana/public'; import { GetPackagePoliciesRequest, PACKAGE_POLICY_SAVED_OBJECT_TYPE, -} from '../../../../fleet/common'; -import { INGEST_API_PACKAGE_POLICIES } from '../pages/policy/store/services/ingest'; -import { GetPolicyListResponse } from '../pages/policy/types'; +} from '../../../../../fleet/common'; +import { INGEST_API_PACKAGE_POLICIES } from '../../pages/policy/store/services/ingest'; +import { GetPolicyListResponse } from '../../pages/policy/types'; /** * Retrieves a list of endpoint specific package policies (those created with a `package.name` of diff --git a/x-pack/plugins/security_solution/public/management/services/policies/test_mock_utilts.ts b/x-pack/plugins/security_solution/public/management/services/policies/test_mock_utilts.ts new file mode 100644 index 00000000000000..026fb8e243b0bc --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/services/policies/test_mock_utilts.ts @@ -0,0 +1,25 @@ +/* + * 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 { FleetPackagePolicyGenerator } from '../../../../common/endpoint/data_generators/fleet_package_policy_generator'; +import { GetPolicyListResponse } from '../../pages/policy/types'; + +export const sendGetEndpointSpecificPackagePoliciesMock = + async (): Promise => { + const generator = new FleetPackagePolicyGenerator(); + const items = Array.from({ length: 5 }, (_, index) => { + const policy = generator.generateEndpointPackagePolicy(); + policy.name += ` ${index}`; + return policy; + }); + + return { + items, + total: 5, + page: 1, + perPage: 10, + }; + };