From a6670380aa920fa1049d4d05f55f296fc1178511 Mon Sep 17 00:00:00 2001 From: Esteban Beltran Date: Thu, 30 Sep 2021 13:43:22 +0200 Subject: [PATCH] [Security Solution] Add host isolation exceptions UI (#111253) --- .../src/common/exception_list/index.ts | 2 + .../src/common/lists/index.test.ts | 6 +- .../src/index.ts | 6 + .../security_solution/common/constants.ts | 3 + .../public/app/deep_links/index.ts | 22 +-- .../public/app/home/home_navigations.ts | 8 + .../public/app/translations.ts | 6 + .../common/components/navigation/types.ts | 1 + .../index.test.tsx | 10 ++ .../use_navigation_items.tsx | 7 +- .../public/common/store/actions.ts | 4 +- .../public/management/common/breadcrumbs.ts | 2 + .../public/management/common/constants.ts | 2 + .../public/management/common/routing.ts | 50 ++++++ .../pages/event_filters/store/middleware.ts | 9 +- .../host_isolation_exceptions/constants.ts | 23 +++ .../pages/host_isolation_exceptions/index.tsx | 30 ++++ .../host_isolation_exceptions/service.ts | 66 ++++++++ .../host_isolation_exceptions/store/action.ts | 16 ++ .../store/builders.ts | 19 +++ .../store/middleware.test.ts | 142 ++++++++++++++++++ .../store/middleware.ts | 90 +++++++++++ .../store/reducer.test.ts | 44 ++++++ .../store/reducer.ts | 63 ++++++++ .../store/selector.ts | 75 +++++++++ .../pages/host_isolation_exceptions/types.ts | 23 +++ .../view/components/empty.tsx | 42 ++++++ .../host_isolation_exceptions/view/hooks.ts | 38 +++++ .../host_isolation_exceptions_list.test.tsx | 107 +++++++++++++ .../view/host_isolation_exceptions_list.tsx | 106 +++++++++++++ .../public/management/pages/index.tsx | 13 ++ .../pages/trusted_apps/store/middleware.ts | 5 +- .../public/management/store/middleware.ts | 6 + .../public/management/store/reducer.ts | 5 + .../public/management/types.ts | 3 + 35 files changed, 1030 insertions(+), 24 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/constants.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/index.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/service.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/action.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/builders.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.test.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.test.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/selector.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/types.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/empty.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/hooks.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/common/exception_list/index.ts b/packages/kbn-securitysolution-io-ts-list-types/src/common/exception_list/index.ts index a68dc2fc76a9..7d1aa44e7d90 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/common/exception_list/index.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/common/exception_list/index.ts @@ -12,6 +12,7 @@ export const exceptionListType = t.keyof({ detection: null, endpoint: null, endpoint_events: null, + endpoint_host_isolation_exceptions: null, }); export const exceptionListTypeOrUndefined = t.union([exceptionListType, t.undefined]); export type ExceptionListType = t.TypeOf; @@ -20,4 +21,5 @@ export enum ExceptionListTypeEnum { DETECTION = 'detection', ENDPOINT = 'endpoint', ENDPOINT_EVENTS = 'endpoint_events', + ENDPOINT_HOST_ISOLATION_EXCEPTIONS = 'endpoint_host_isolation_exceptions', } diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/common/lists/index.test.ts b/packages/kbn-securitysolution-io-ts-list-types/src/common/lists/index.test.ts index 83e75a924f43..b4de979b19a9 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/common/lists/index.test.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/common/lists/index.test.ts @@ -86,7 +86,7 @@ describe('Lists', () => { const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "1" supplied to "Array<{| id: NonEmptyString, list_id: NonEmptyString, type: "detection" | "endpoint" | "endpoint_events", namespace_type: "agnostic" | "single" |}>"', + 'Invalid value "1" supplied to "Array<{| id: NonEmptyString, list_id: NonEmptyString, type: "detection" | "endpoint" | "endpoint_events" | "endpoint_host_isolation_exceptions", namespace_type: "agnostic" | "single" |}>"', ]); expect(message.schema).toEqual({}); }); @@ -117,8 +117,8 @@ describe('Lists', () => { const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "1" supplied to "(Array<{| id: NonEmptyString, list_id: NonEmptyString, type: "detection" | "endpoint" | "endpoint_events", namespace_type: "agnostic" | "single" |}> | undefined)"', - 'Invalid value "[1]" supplied to "(Array<{| id: NonEmptyString, list_id: NonEmptyString, type: "detection" | "endpoint" | "endpoint_events", namespace_type: "agnostic" | "single" |}> | undefined)"', + 'Invalid value "1" supplied to "(Array<{| id: NonEmptyString, list_id: NonEmptyString, type: "detection" | "endpoint" | "endpoint_events" | "endpoint_host_isolation_exceptions", namespace_type: "agnostic" | "single" |}> | undefined)"', + 'Invalid value "[1]" supplied to "(Array<{| id: NonEmptyString, list_id: NonEmptyString, type: "detection" | "endpoint" | "endpoint_events" | "endpoint_host_isolation_exceptions", namespace_type: "agnostic" | "single" |}> | undefined)"', ]); expect(message.schema).toEqual({}); }); diff --git a/packages/kbn-securitysolution-list-constants/src/index.ts b/packages/kbn-securitysolution-list-constants/src/index.ts index dae414aad0de..8f5ea4668e00 100644 --- a/packages/kbn-securitysolution-list-constants/src/index.ts +++ b/packages/kbn-securitysolution-list-constants/src/index.ts @@ -70,3 +70,9 @@ export const ENDPOINT_EVENT_FILTERS_LIST_NAME = 'Endpoint Security Event Filters /** Description of event filters agnostic list */ export const ENDPOINT_EVENT_FILTERS_LIST_DESCRIPTION = 'Endpoint Security Event Filters List'; + +export const ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID = 'endpoint_host_isolation_exceptions'; +export const ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_NAME = + 'Endpoint Security Host Isolation Exceptions List'; +export const ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_DESCRIPTION = + 'Endpoint Security Host Isolation Exceptions List'; diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index c88f5c7abd7a..d77a555991df 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -76,6 +76,7 @@ export enum SecurityPageName { detections = 'detections', endpoints = 'endpoints', eventFilters = 'event_filters', + hostIsolationExceptions = 'host_isolation_exceptions', events = 'events', exceptions = 'exceptions', explore = 'explore', @@ -113,6 +114,7 @@ export const MANAGEMENT_PATH = '/administration'; export const ENDPOINTS_PATH = `${MANAGEMENT_PATH}/endpoints`; export const TRUSTED_APPS_PATH = `${MANAGEMENT_PATH}/trusted_apps`; export const EVENT_FILTERS_PATH = `${MANAGEMENT_PATH}/event_filters`; +export const HOST_ISOLATION_EXCEPTIONS_PATH = `${MANAGEMENT_PATH}/host_isolation_exceptions`; export const APP_OVERVIEW_PATH = `${APP_PATH}${OVERVIEW_PATH}`; export const APP_MANAGEMENT_PATH = `${APP_PATH}${MANAGEMENT_PATH}`; @@ -129,6 +131,7 @@ export const APP_CASES_PATH = `${APP_PATH}${CASES_PATH}`; export const APP_ENDPOINTS_PATH = `${APP_PATH}${ENDPOINTS_PATH}`; export const APP_TRUSTED_APPS_PATH = `${APP_PATH}${TRUSTED_APPS_PATH}`; export const APP_EVENT_FILTERS_PATH = `${APP_PATH}${EVENT_FILTERS_PATH}`; +export const APP_HOST_ISOLATION_EXCEPTIONS_PATH = `${APP_PATH}${HOST_ISOLATION_EXCEPTIONS_PATH}`; /** The comma-delimited list of Elasticsearch indices from which the SIEM app collects events */ export const DEFAULT_INDEX_PATTERN = [ diff --git a/x-pack/plugins/security_solution/public/app/deep_links/index.ts b/x-pack/plugins/security_solution/public/app/deep_links/index.ts index 277c99217f32..e8d4f5d09e5f 100644 --- a/x-pack/plugins/security_solution/public/app/deep_links/index.ts +++ b/x-pack/plugins/security_solution/public/app/deep_links/index.ts @@ -30,6 +30,10 @@ import { CASE, MANAGE, UEBA, + HOST_ISOLATION_EXCEPTIONS, + EVENT_FILTERS, + TRUSTED_APPLICATIONS, + ENDPOINTS, } from '../translations'; import { OVERVIEW_PATH, @@ -44,6 +48,7 @@ import { TRUSTED_APPS_PATH, EVENT_FILTERS_PATH, UEBA_PATH, + HOST_ISOLATION_EXCEPTIONS_PATH, } from '../../../common/constants'; import { ExperimentalFeatures } from '../../../common/experimental_features'; @@ -313,26 +318,25 @@ export const securitySolutionsDeepLinks: AppDeepLink[] = [ { id: SecurityPageName.endpoints, navLinkStatus: AppNavLinkStatus.visible, - title: i18n.translate('xpack.securitySolution.search.administration.endpoints', { - defaultMessage: 'Endpoints', - }), + title: ENDPOINTS, order: 9006, path: ENDPOINTS_PATH, }, { id: SecurityPageName.trustedApps, - title: i18n.translate('xpack.securitySolution.search.administration.trustedApps', { - defaultMessage: 'Trusted applications', - }), + title: TRUSTED_APPLICATIONS, path: TRUSTED_APPS_PATH, }, { id: SecurityPageName.eventFilters, - title: i18n.translate('xpack.securitySolution.search.administration.eventFilters', { - defaultMessage: 'Event filters', - }), + title: EVENT_FILTERS, path: EVENT_FILTERS_PATH, }, + { + id: SecurityPageName.hostIsolationExceptions, + title: HOST_ISOLATION_EXCEPTIONS, + path: HOST_ISOLATION_EXCEPTIONS_PATH, + }, ], }, ]; diff --git a/x-pack/plugins/security_solution/public/app/home/home_navigations.ts b/x-pack/plugins/security_solution/public/app/home/home_navigations.ts index 686dafca76d9..38c7ab06a52d 100644 --- a/x-pack/plugins/security_solution/public/app/home/home_navigations.ts +++ b/x-pack/plugins/security_solution/public/app/home/home_navigations.ts @@ -26,6 +26,7 @@ import { APP_EVENT_FILTERS_PATH, APP_UEBA_PATH, SecurityPageName, + APP_HOST_ISOLATION_EXCEPTIONS_PATH, } from '../../../common/constants'; export const navTabs: SecurityNav = { @@ -120,6 +121,13 @@ export const navTabs: SecurityNav = { disabled: false, urlKey: 'administration', }, + [SecurityPageName.hostIsolationExceptions]: { + id: SecurityPageName.hostIsolationExceptions, + name: i18n.HOST_ISOLATION_EXCEPTIONS, + href: APP_HOST_ISOLATION_EXCEPTIONS_PATH, + disabled: false, + urlKey: 'administration', + }, }; export const securityNavGroup: SecurityNavGroup = { diff --git a/x-pack/plugins/security_solution/public/app/translations.ts b/x-pack/plugins/security_solution/public/app/translations.ts index c3cf11f35211..da680bf45dc8 100644 --- a/x-pack/plugins/security_solution/public/app/translations.ts +++ b/x-pack/plugins/security_solution/public/app/translations.ts @@ -62,6 +62,12 @@ export const EVENT_FILTERS = i18n.translate( } ); +export const HOST_ISOLATION_EXCEPTIONS = i18n.translate( + 'xpack.securitySolution.search.administration.hostIsolationExceptions', + { + defaultMessage: 'Host Isolation Exceptions', + } +); export const DETECT = i18n.translate('xpack.securitySolution.navigation.detect', { defaultMessage: 'Detect', }); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts index 16bf53751515..878ff074685e 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts @@ -47,6 +47,7 @@ export type SecurityNavKey = | SecurityPageName.endpoints | SecurityPageName.eventFilters | SecurityPageName.exceptions + | SecurityPageName.hostIsolationExceptions | SecurityPageName.hosts | SecurityPageName.network | SecurityPageName.overview diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx index 820d90087ce4..29861b143414 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx @@ -233,6 +233,16 @@ describe('useSecuritySolutionNavigation', () => { "name": "Event filters", "onClick": [Function], }, + Object { + "data-href": "securitySolution/host_isolation_exceptions", + "data-test-subj": "navigation-host_isolation_exceptions", + "disabled": false, + "href": "securitySolution/host_isolation_exceptions", + "id": "host_isolation_exceptions", + "isSelected": false, + "name": "Host Isolation Exceptions", + "onClick": [Function], + }, ], "name": "Manage", }, diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx index 1630bc47fd0c..976f15586b55 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx @@ -83,7 +83,12 @@ function usePrimaryNavigationItemsToDisplay(navTabs: Record) { }, { ...securityNavGroup.manage, - items: [navTabs.endpoints, navTabs.trusted_apps, navTabs.event_filters], + items: [ + navTabs.endpoints, + navTabs.trusted_apps, + navTabs.event_filters, + navTabs.host_isolation_exceptions, + ], }, ], [navTabs, hasCasesReadPermissions] diff --git a/x-pack/plugins/security_solution/public/common/store/actions.ts b/x-pack/plugins/security_solution/public/common/store/actions.ts index 1987edc0e730..ff6b0e80b78a 100644 --- a/x-pack/plugins/security_solution/public/common/store/actions.ts +++ b/x-pack/plugins/security_solution/public/common/store/actions.ts @@ -9,6 +9,7 @@ import { EndpointAction } from '../../management/pages/endpoint_hosts/store/acti import { PolicyDetailsAction } from '../../management/pages/policy/store/policy_details'; import { TrustedAppsPageAction } from '../../management/pages/trusted_apps/store/action'; import { EventFiltersPageAction } from '../../management/pages/event_filters/store/action'; +import { HostIsolationExceptionsPageAction } from '../../management/pages/host_isolation_exceptions/store/action'; export { appActions } from './app'; export { dragAndDropActions } from './drag_and_drop'; @@ -21,4 +22,5 @@ export type AppAction = | RoutingAction | PolicyDetailsAction | TrustedAppsPageAction - | EventFiltersPageAction; + | EventFiltersPageAction + | HostIsolationExceptionsPageAction; diff --git a/x-pack/plugins/security_solution/public/management/common/breadcrumbs.ts b/x-pack/plugins/security_solution/public/management/common/breadcrumbs.ts index 9c3d781f514e..ffda54d0deda 100644 --- a/x-pack/plugins/security_solution/public/management/common/breadcrumbs.ts +++ b/x-pack/plugins/security_solution/public/management/common/breadcrumbs.ts @@ -9,12 +9,14 @@ import { ChromeBreadcrumb } from 'kibana/public'; import { AdministrationSubTab } from '../types'; import { ENDPOINTS_TAB, EVENT_FILTERS_TAB, POLICIES_TAB, TRUSTED_APPS_TAB } from './translations'; import { AdministrationRouteSpyState } from '../../common/utils/route/types'; +import { HOST_ISOLATION_EXCEPTIONS } from '../../app/translations'; const TabNameMappedToI18nKey: Record = { [AdministrationSubTab.endpoints]: ENDPOINTS_TAB, [AdministrationSubTab.policies]: POLICIES_TAB, [AdministrationSubTab.trustedApps]: TRUSTED_APPS_TAB, [AdministrationSubTab.eventFilters]: EVENT_FILTERS_TAB, + [AdministrationSubTab.hostIsolationExceptions]: HOST_ISOLATION_EXCEPTIONS, }; export function getBreadcrumbs(params: AdministrationRouteSpyState): ChromeBreadcrumb[] { diff --git a/x-pack/plugins/security_solution/public/management/common/constants.ts b/x-pack/plugins/security_solution/public/management/common/constants.ts index 01569eae59c1..ad17522a8130 100644 --- a/x-pack/plugins/security_solution/public/management/common/constants.ts +++ b/x-pack/plugins/security_solution/public/management/common/constants.ts @@ -17,6 +17,7 @@ export const MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH = `${MANAGEMENT export const MANAGEMENT_ROUTING_POLICY_DETAILS_PATH_OLD = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.policies})/:policyId`; export const MANAGEMENT_ROUTING_TRUSTED_APPS_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.trustedApps})`; export const MANAGEMENT_ROUTING_EVENT_FILTERS_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.eventFilters})`; +export const MANAGEMENT_ROUTING_HOST_ISOLATION_EXCEPTIONS_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.hostIsolationExceptions})`; // --[ STORE ]--------------------------------------------------------------------------- /** The SIEM global store namespace where the management state will be mounted */ @@ -29,6 +30,7 @@ export const MANAGEMENT_STORE_ENDPOINTS_NAMESPACE = 'endpoints'; export const MANAGEMENT_STORE_TRUSTED_APPS_NAMESPACE = 'trustedApps'; /** Namespace within the Management state where event filters page state is maintained */ export const MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE = 'eventFilters'; +export const MANAGEMENT_STORE_HOST_ISOLATION_EXCEPTIONS_NAMESPACE = 'hostIsolationExceptions'; export const MANAGEMENT_PAGE_SIZE_OPTIONS: readonly number[] = [10, 20, 50]; export const MANAGEMENT_DEFAULT_PAGE = 0; diff --git a/x-pack/plugins/security_solution/public/management/common/routing.ts b/x-pack/plugins/security_solution/public/management/common/routing.ts index 58fbd64faf8a..ed3307100677 100644 --- a/x-pack/plugins/security_solution/public/management/common/routing.ts +++ b/x-pack/plugins/security_solution/public/management/common/routing.ts @@ -16,6 +16,7 @@ import { MANAGEMENT_PAGE_SIZE_OPTIONS, MANAGEMENT_ROUTING_ENDPOINTS_PATH, MANAGEMENT_ROUTING_EVENT_FILTERS_PATH, + MANAGEMENT_ROUTING_HOST_ISOLATION_EXCEPTIONS_PATH, MANAGEMENT_ROUTING_POLICIES_PATH, MANAGEMENT_ROUTING_POLICY_DETAILS_FORM_PATH, MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH, @@ -26,6 +27,7 @@ import { appendSearch } from '../../common/components/link_to/helpers'; import { EndpointIndexUIQueryParams } from '../pages/endpoint_hosts/types'; import { TrustedAppsListPageLocation } from '../pages/trusted_apps/state'; import { EventFiltersPageLocation } from '../pages/event_filters/types'; +import { HostIsolationExceptionsPageLocation } from '../pages/host_isolation_exceptions/types'; import { PolicyDetailsArtifactsPageLocation } from '../pages/policy/types'; // Taken from: https://github.com/microsoft/TypeScript/issues/12936#issuecomment-559034150 @@ -200,6 +202,26 @@ const normalizeEventFiltersPageLocation = ( } }; +const normalizeHostIsolationExceptionsPageLocation = ( + location?: Partial +): Partial => { + if (location) { + return { + ...(!isDefaultOrMissing(location.page_index, MANAGEMENT_DEFAULT_PAGE) + ? { page_index: location.page_index } + : {}), + ...(!isDefaultOrMissing(location.page_size, MANAGEMENT_DEFAULT_PAGE_SIZE) + ? { page_size: location.page_size } + : {}), + ...(!isDefaultOrMissing(location.show, undefined) ? { show: location.show } : {}), + ...(!isDefaultOrMissing(location.id, undefined) ? { id: location.id } : {}), + ...(!isDefaultOrMissing(location.filter, '') ? { filter: location.filter } : ''), + }; + } else { + return {}; + } +}; + /** * Given an object with url params, and a given key, return back only the first param value (case multiples were defined) * @param query @@ -327,3 +349,31 @@ export const getEventFiltersListPath = (location?: Partial { + const showParamValue = extractFirstParamValue( + query, + 'show' + ) as HostIsolationExceptionsPageLocation['show']; + + return { + ...extractListPaginationParams(query), + show: + showParamValue && ['edit', 'create'].includes(showParamValue) ? showParamValue : undefined, + id: extractFirstParamValue(query, 'id'), + }; +}; + +export const getHostIsolationExceptionsListPath = ( + location?: Partial +): string => { + const path = generatePath(MANAGEMENT_ROUTING_HOST_ISOLATION_EXCEPTIONS_PATH, { + tabName: AdministrationSubTab.hostIsolationExceptions, + }); + + return `${path}${appendSearch( + querystring.stringify(normalizeHostIsolationExceptionsPageLocation(location)) + )}`; +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.ts index 60920a7420d1..0c90e21b4953 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/store/middleware.ts @@ -203,8 +203,7 @@ const checkIfEventFilterDataExist: MiddlewareActionHandler = async ( ) => { dispatch({ type: 'eventFiltersListPageDataExistsChanged', - // Ignore will be fixed with when AsyncResourceState is refactored (#830) - // @ts-ignore + // @ts-expect-error-next-line will be fixed with when AsyncResourceState is refactored (#830) payload: createLoadingResourceState(getListPageDataExistsState(getState())), }); @@ -232,9 +231,8 @@ const refreshListDataIfNeeded: MiddlewareActionHandler = async (store, eventFilt dispatch({ type: 'eventFiltersListPageDataChanged', payload: { - // Ignore will be fixed with when AsyncResourceState is refactored (#830) - // @ts-ignore type: 'LoadingResourceState', + // @ts-expect-error-next-line will be fixed with when AsyncResourceState is refactored (#830) previousState: getCurrentListPageDataState(state), }, }); @@ -300,8 +298,7 @@ const eventFilterDeleteEntry: MiddlewareActionHandler = async ( dispatch({ type: 'eventFilterDeleteStatusChanged', - // Ignore will be fixed with when AsyncResourceState is refactored (#830) - // @ts-ignore + // @ts-expect-error-next-line will be fixed with when AsyncResourceState is refactored (#830) payload: createLoadingResourceState(getDeletionState(state).status), }); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/constants.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/constants.ts new file mode 100644 index 000000000000..dab3b528a181 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/constants.ts @@ -0,0 +1,23 @@ +/* + * 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 { ExceptionListType, ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; +import { + ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_DESCRIPTION, + ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, + ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_NAME, +} from '@kbn/securitysolution-list-constants'; + +export const HOST_ISOLATION_EXCEPTIONS_LIST_TYPE: ExceptionListType = + ExceptionListTypeEnum.ENDPOINT_HOST_ISOLATION_EXCEPTIONS; + +export const HOST_ISOLATION_EXCEPTIONS_LIST = { + name: ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_NAME, + namespace_type: 'agnostic', + description: ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_DESCRIPTION, + list_id: ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, + type: HOST_ISOLATION_EXCEPTIONS_LIST_TYPE, +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/index.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/index.tsx new file mode 100644 index 000000000000..7ed2adf8c94f --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/index.tsx @@ -0,0 +1,30 @@ +/* + * 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 { Switch, Route } from 'react-router-dom'; +import React, { memo } from 'react'; +import { MANAGEMENT_ROUTING_HOST_ISOLATION_EXCEPTIONS_PATH } from '../../common/constants'; +import { NotFoundPage } from '../../../app/404'; +import { HostIsolationExceptionsList } from './view/host_isolation_exceptions_list'; + +/** + * Provides the routing container for the hosts related views + */ +export const HostIsolationExceptionsContainer = memo(() => { + return ( + + + + + ); +}); + +HostIsolationExceptionsContainer.displayName = 'HostIsolationExceptionsContainer'; diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/service.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/service.ts new file mode 100644 index 000000000000..85545303c7df --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/service.ts @@ -0,0 +1,66 @@ +/* + * 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, + FoundExceptionListItemSchema, +} from '@kbn/securitysolution-io-ts-list-types'; +import { ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID } from '@kbn/securitysolution-list-constants'; +import { HttpStart } from 'kibana/public'; +import { EXCEPTION_LIST_ITEM_URL, EXCEPTION_LIST_URL } from '../event_filters/constants'; +import { HOST_ISOLATION_EXCEPTIONS_LIST } from './constants'; + +async function createHostIsolationExceptionList(http: HttpStart): Promise { + try { + await http.post(EXCEPTION_LIST_URL, { + body: JSON.stringify(HOST_ISOLATION_EXCEPTIONS_LIST), + }); + } catch (err) { + // Ignore 409 errors. List already created + if (err.response?.status !== 409) { + throw err; + } + } +} + +let listExistsPromise: Promise; +async function ensureHostIsolationExceptionsListExists(http: HttpStart): Promise { + if (!listExistsPromise) { + listExistsPromise = createHostIsolationExceptionList(http); + } + await listExistsPromise; +} + +export async function getHostIsolationExceptionItems({ + http, + perPage, + page, + sortField, + sortOrder, + filter, +}: { + http: HttpStart; + page?: number; + perPage?: number; + sortField?: keyof ExceptionListItemSchema; + sortOrder?: 'asc' | 'desc'; + filter?: string; +}): Promise { + await ensureHostIsolationExceptionsListExists(http); + const entries: FoundExceptionListItemSchema = await http.get(`${EXCEPTION_LIST_ITEM_URL}/_find`, { + query: { + list_id: [ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID], + namespace_type: ['agnostic'], + page, + per_page: perPage, + sort_field: sortField, + sort_order: sortOrder, + filter, + }, + }); + return entries; +} diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/action.ts new file mode 100644 index 000000000000..793c44ce79db --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/action.ts @@ -0,0 +1,16 @@ +/* + * 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 { Action } from 'redux'; +import { HostIsolationExceptionsPageState } from '../types'; + +export type HostIsolationExceptionsPageDataChanged = + Action<'hostIsolationExceptionsPageDataChanged'> & { + payload: HostIsolationExceptionsPageState['entries']; + }; + +export type HostIsolationExceptionsPageAction = HostIsolationExceptionsPageDataChanged; diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/builders.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/builders.ts new file mode 100644 index 000000000000..f5ea3c27bde7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/builders.ts @@ -0,0 +1,19 @@ +/* + * 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 { MANAGEMENT_DEFAULT_PAGE, MANAGEMENT_DEFAULT_PAGE_SIZE } from '../../../common/constants'; +import { createUninitialisedResourceState } from '../../../state'; +import { HostIsolationExceptionsPageState } from '../types'; + +export const initialHostIsolationExceptionsPageState = (): HostIsolationExceptionsPageState => ({ + entries: createUninitialisedResourceState(), + location: { + page_index: MANAGEMENT_DEFAULT_PAGE, + page_size: MANAGEMENT_DEFAULT_PAGE_SIZE, + filter: '', + }, +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.test.ts new file mode 100644 index 000000000000..cde9d8944390 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.test.ts @@ -0,0 +1,142 @@ +/* + * 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 { applyMiddleware, createStore, Store } from 'redux'; +import { HOST_ISOLATION_EXCEPTIONS_PATH } from '../../../../../common/constants'; +import { coreMock } from '../../../../../../../../src/core/public/mocks'; +import { getFoundExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/found_exception_list_item_schema.mock'; +import { AppAction } from '../../../../common/store/actions'; +import { + createSpyMiddleware, + MiddlewareActionSpyHelper, +} from '../../../../common/store/test_utils'; +import { isFailedResourceState, isLoadedResourceState } from '../../../state'; +import { getHostIsolationExceptionItems } from '../service'; +import { HostIsolationExceptionsPageState } from '../types'; +import { initialHostIsolationExceptionsPageState } from './builders'; +import { createHostIsolationExceptionsPageMiddleware } from './middleware'; +import { hostIsolationExceptionsPageReducer } from './reducer'; +import { getListFetchError } from './selector'; + +jest.mock('../service'); +const getHostIsolationExceptionItemsMock = getHostIsolationExceptionItems as jest.Mock; + +const fakeCoreStart = coreMock.createStart({ basePath: '/mock' }); + +const createStoreSetup = () => { + const spyMiddleware = createSpyMiddleware(); + + return { + spyMiddleware, + store: createStore( + hostIsolationExceptionsPageReducer, + applyMiddleware( + createHostIsolationExceptionsPageMiddleware(fakeCoreStart), + spyMiddleware.actionSpyMiddleware + ) + ), + }; +}; + +describe('Host isolation exceptions middleware', () => { + let store: Store; + let spyMiddleware: MiddlewareActionSpyHelper; + let initialState: HostIsolationExceptionsPageState; + + beforeEach(() => { + initialState = initialHostIsolationExceptionsPageState(); + + const storeSetup = createStoreSetup(); + + store = storeSetup.store as Store; + spyMiddleware = storeSetup.spyMiddleware; + }); + + describe('initial state', () => { + it('sets initial state properly', async () => { + expect(createStoreSetup().store.getState()).toStrictEqual(initialState); + }); + }); + + describe('when on the List page', () => { + const changeUrl = (searchParams: string = '') => { + store.dispatch({ + type: 'userChangedUrl', + payload: { + pathname: HOST_ISOLATION_EXCEPTIONS_PATH, + search: searchParams, + hash: '', + key: 'miniMe', + }, + }); + }; + + beforeEach(() => { + getHostIsolationExceptionItemsMock.mockClear(); + getHostIsolationExceptionItemsMock.mockImplementation(getFoundExceptionListItemSchemaMock); + }); + + it.each([ + [undefined, undefined], + [3, 50], + ])( + 'should trigger api call to retrieve host isolation exceptions params page_index[%s] page_size[%s]', + async (pageIndex, perPage) => { + changeUrl((pageIndex && perPage && `?page_index=${pageIndex}&page_size=${perPage}`) || ''); + await spyMiddleware.waitForAction('hostIsolationExceptionsPageDataChanged', { + validate({ payload }) { + return isLoadedResourceState(payload); + }, + }); + + expect(getHostIsolationExceptionItemsMock).toHaveBeenCalledWith( + expect.objectContaining({ + page: (pageIndex ?? 0) + 1, + perPage: perPage ?? 10, + filter: undefined, + }) + ); + } + ); + + it('should clear up previous page and apply a filter configuration when a filter is used', async () => { + changeUrl('?filter=testMe'); + await spyMiddleware.waitForAction('hostIsolationExceptionsPageDataChanged', { + validate({ payload }) { + return isLoadedResourceState(payload); + }, + }); + expect(getHostIsolationExceptionItemsMock).toHaveBeenCalledWith( + expect.objectContaining({ + page: 1, + perPage: 10, + filter: + '(exception-list-agnostic.attributes.name:(*testMe*) OR exception-list-agnostic.attributes.description:(*testMe*) OR exception-list-agnostic.attributes.entries.value:(*testMe*))', + }) + ); + }); + + it('should dispatch a Failure if an API error was encountered', async () => { + getHostIsolationExceptionItemsMock.mockRejectedValue({ + body: { message: 'error message', statusCode: 500, error: 'Internal Server Error' }, + }); + + changeUrl(); + await spyMiddleware.waitForAction('hostIsolationExceptionsPageDataChanged', { + validate({ payload }) { + return isFailedResourceState(payload); + }, + }); + + expect(getListFetchError(store.getState())).toEqual({ + message: 'error message', + statusCode: 500, + error: 'Internal Server Error', + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.ts new file mode 100644 index 000000000000..1df0ef229d2e --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.ts @@ -0,0 +1,90 @@ +/* + * 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 { FoundExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { CoreStart, HttpStart } from 'kibana/public'; +import { matchPath } from 'react-router-dom'; +import { AppLocation, Immutable } from '../../../../../common/endpoint/types'; +import { ImmutableMiddleware, ImmutableMiddlewareAPI } from '../../../../common/store'; +import { AppAction } from '../../../../common/store/actions'; +import { MANAGEMENT_ROUTING_HOST_ISOLATION_EXCEPTIONS_PATH } from '../../../common/constants'; +import { parseQueryFilterToKQL } from '../../../common/utils'; +import { + createFailedResourceState, + createLoadedResourceState, +} from '../../../state/async_resource_builders'; +import { getHostIsolationExceptionItems } from '../service'; +import { HostIsolationExceptionsPageState } from '../types'; +import { getCurrentListPageDataState, getCurrentLocation } from './selector'; + +export const SEARCHABLE_FIELDS: Readonly = [`name`, `description`, `entries.value`]; + +export function hostIsolationExceptionsMiddlewareFactory(coreStart: CoreStart) { + return createHostIsolationExceptionsPageMiddleware(coreStart); +} + +export const createHostIsolationExceptionsPageMiddleware = ( + coreStart: CoreStart +): ImmutableMiddleware => { + return (store) => (next) => async (action) => { + next(action); + + if (action.type === 'userChangedUrl' && isHostIsolationExceptionsPage(action.payload)) { + loadHostIsolationExceptionsList(store, coreStart.http); + } + }; +}; + +async function loadHostIsolationExceptionsList( + store: ImmutableMiddlewareAPI, + http: HttpStart +) { + const { dispatch } = store; + try { + const { + page_size: pageSize, + page_index: pageIndex, + filter, + } = getCurrentLocation(store.getState()); + const query = { + http, + page: pageIndex + 1, + perPage: pageSize, + filter: parseQueryFilterToKQL(filter, SEARCHABLE_FIELDS) || undefined, + }; + + dispatch({ + type: 'hostIsolationExceptionsPageDataChanged', + payload: { + type: 'LoadingResourceState', + // @ts-expect-error-next-line will be fixed with when AsyncResourceState is refactored (#830) + previousState: getCurrentListPageDataState(store.getState()), + }, + }); + + const entries = await getHostIsolationExceptionItems(query); + + dispatch({ + type: 'hostIsolationExceptionsPageDataChanged', + payload: createLoadedResourceState(entries), + }); + } catch (error) { + dispatch({ + type: 'hostIsolationExceptionsPageDataChanged', + payload: createFailedResourceState(error.body ?? error), + }); + } +} + +function isHostIsolationExceptionsPage(location: Immutable) { + return ( + matchPath(location.pathname ?? '', { + path: MANAGEMENT_ROUTING_HOST_ISOLATION_EXCEPTIONS_PATH, + exact: true, + }) !== null + ); +} diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.test.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.test.ts new file mode 100644 index 000000000000..211b03f36d96 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.test.ts @@ -0,0 +1,44 @@ +/* + * 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 { UserChangedUrl } from '../../../../common/store/routing/action'; +import { HostIsolationExceptionsPageState } from '../types'; +import { initialHostIsolationExceptionsPageState } from './builders'; +import { HOST_ISOLATION_EXCEPTIONS_PATH } from '../../../../../common/constants'; +import { hostIsolationExceptionsPageReducer } from './reducer'; +import { getCurrentLocation } from './selector'; + +describe('Host Isolation Exceptions Reducer', () => { + let initialState: HostIsolationExceptionsPageState; + + beforeEach(() => { + initialState = initialHostIsolationExceptionsPageState(); + }); + + describe('UserChangedUrl', () => { + const userChangedUrlAction = ( + search = '', + pathname = HOST_ISOLATION_EXCEPTIONS_PATH + ): UserChangedUrl => ({ + type: 'userChangedUrl', + payload: { search, pathname, hash: '' }, + }); + + describe('When the url is set to host isolation exceptions', () => { + it('should set the default page size and index', () => { + const result = hostIsolationExceptionsPageReducer(initialState, userChangedUrlAction()); + expect(getCurrentLocation(result)).toEqual({ + filter: '', + id: undefined, + page_index: 0, + page_size: 10, + show: undefined, + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.ts new file mode 100644 index 000000000000..1bce76c1bfd0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.ts @@ -0,0 +1,63 @@ +/* + * 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. + */ + +// eslint-disable-next-line import/no-nodejs-modules +import { parse } from 'querystring'; +import { matchPath } from 'react-router-dom'; +import { ImmutableReducer } from '../../../../common/store'; +import { AppAction } from '../../../../common/store/actions'; +import { AppLocation, Immutable } from '../../../../../common/endpoint/types'; +import { extractHostIsolationExceptionsPageLocation } from '../../../common/routing'; +import { HostIsolationExceptionsPageState } from '../types'; +import { initialHostIsolationExceptionsPageState } from './builders'; +import { MANAGEMENT_ROUTING_HOST_ISOLATION_EXCEPTIONS_PATH } from '../../../common/constants'; +import { UserChangedUrl } from '../../../../common/store/routing/action'; + +type StateReducer = ImmutableReducer; +type CaseReducer = ( + state: Immutable, + action: Immutable +) => Immutable; + +const isHostIsolationExceptionsPageLocation = (location: Immutable) => { + return ( + matchPath(location.pathname ?? '', { + path: MANAGEMENT_ROUTING_HOST_ISOLATION_EXCEPTIONS_PATH, + exact: true, + }) !== null + ); +}; + +export const hostIsolationExceptionsPageReducer: StateReducer = ( + state = initialHostIsolationExceptionsPageState(), + action +) => { + switch (action.type) { + case 'hostIsolationExceptionsPageDataChanged': { + return { + ...state, + entries: action.payload, + }; + } + case 'userChangedUrl': + return userChangedUrl(state, action); + } + return state; +}; + +const userChangedUrl: CaseReducer = (state, action) => { + if (isHostIsolationExceptionsPageLocation(action.payload)) { + const location = extractHostIsolationExceptionsPageLocation( + parse(action.payload.search.slice(1)) + ); + return { + ...state, + location, + }; + } + return state; +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/selector.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/selector.ts new file mode 100644 index 000000000000..0ddfc0953263 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/selector.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 { Pagination } from '@elastic/eui'; +import { + ExceptionListItemSchema, + FoundExceptionListItemSchema, +} from '@kbn/securitysolution-io-ts-list-types'; +import { createSelector } from 'reselect'; +import { Immutable } from '../../../../../common/endpoint/types'; +import { ServerApiError } from '../../../../common/types'; +import { + MANAGEMENT_DEFAULT_PAGE_SIZE, + MANAGEMENT_PAGE_SIZE_OPTIONS, +} from '../../../common/constants'; +import { + getLastLoadedResourceState, + isFailedResourceState, + isLoadingResourceState, +} from '../../../state/async_resource_state'; +import { HostIsolationExceptionsPageState } from '../types'; + +type StoreState = Immutable; +type HostIsolationExceptionsSelector = (state: StoreState) => T; + +export const getCurrentListPageState: HostIsolationExceptionsSelector = (state) => { + return state; +}; + +export const getCurrentListPageDataState: HostIsolationExceptionsSelector = ( + state +) => state.entries; + +const getListApiSuccessResponse: HostIsolationExceptionsSelector< + Immutable | undefined +> = createSelector(getCurrentListPageDataState, (listPageData) => { + return getLastLoadedResourceState(listPageData)?.data; +}); + +export const getListItems: HostIsolationExceptionsSelector> = + createSelector(getListApiSuccessResponse, (apiResponseData) => { + return apiResponseData?.data || []; + }); + +export const getListPagination: HostIsolationExceptionsSelector = createSelector( + getListApiSuccessResponse, + // memoized via `reselect` until the API response changes + (response) => { + return { + totalItemCount: response?.total ?? 0, + pageSize: response?.per_page ?? MANAGEMENT_DEFAULT_PAGE_SIZE, + pageSizeOptions: [...MANAGEMENT_PAGE_SIZE_OPTIONS], + pageIndex: (response?.page ?? 1) - 1, + }; + } +); + +export const getListIsLoading: HostIsolationExceptionsSelector = createSelector( + getCurrentListPageDataState, + (listDataState) => isLoadingResourceState(listDataState) +); + +export const getListFetchError: HostIsolationExceptionsSelector< + Immutable | undefined +> = createSelector(getCurrentListPageDataState, (listPageDataState) => { + return (isFailedResourceState(listPageDataState) && listPageDataState.error) || undefined; +}); + +export const getCurrentLocation: HostIsolationExceptionsSelector = ( + state +) => state.location; diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/types.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/types.ts new file mode 100644 index 000000000000..44f3d2a9df76 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/types.ts @@ -0,0 +1,23 @@ +/* + * 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 type { FoundExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { AsyncResourceState } from '../../state/async_resource_state'; + +export interface HostIsolationExceptionsPageLocation { + page_index: number; + page_size: number; + show?: 'create' | 'edit'; + /** Used for editing. The ID of the selected event filter */ + id?: string; + filter: string; +} + +export interface HostIsolationExceptionsPageState { + entries: AsyncResourceState; + location: HostIsolationExceptionsPageLocation; +} diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/empty.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/empty.tsx new file mode 100644 index 000000000000..d7c512794173 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/empty.tsx @@ -0,0 +1,42 @@ +/* + * 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 styled, { css } from 'styled-components'; +import { EuiEmptyPrompt } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +const EmptyPrompt = styled(EuiEmptyPrompt)` + ${() => css` + max-width: 100%; + `} +`; + +export const HostIsolationExceptionsEmptyState = memo<{}>(() => { + return ( + + + + } + body={ + + } + /> + ); +}); + +HostIsolationExceptionsEmptyState.displayName = 'HostIsolationExceptionsEmptyState'; 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 new file mode 100644 index 000000000000..db9ec467e717 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/hooks.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 { useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { useHistory } from 'react-router-dom'; +import { State } from '../../../../common/store'; +import { + MANAGEMENT_STORE_GLOBAL_NAMESPACE, + MANAGEMENT_STORE_HOST_ISOLATION_EXCEPTIONS_NAMESPACE, +} from '../../../common/constants'; +import { getHostIsolationExceptionsListPath } from '../../../common/routing'; +import { getCurrentLocation } from '../store/selector'; +import { HostIsolationExceptionsPageLocation, HostIsolationExceptionsPageState } from '../types'; + +export function useHostIsolationExceptionsSelector( + selector: (state: HostIsolationExceptionsPageState) => R +): R { + return useSelector((state: State) => + selector( + state[MANAGEMENT_STORE_GLOBAL_NAMESPACE][MANAGEMENT_STORE_HOST_ISOLATION_EXCEPTIONS_NAMESPACE] + ) + ); +} + +export function useHostIsolationExceptionsNavigateCallback() { + const location = useHostIsolationExceptionsSelector(getCurrentLocation); + const history = useHistory(); + + return useCallback( + (args: Partial) => + history.push(getHostIsolationExceptionsListPath({ ...location, ...args })), + [history, location] + ); +} 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 new file mode 100644 index 000000000000..53b8bc33c252 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx @@ -0,0 +1,107 @@ +/* + * 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 { act } from '@testing-library/react'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint'; +import { HOST_ISOLATION_EXCEPTIONS_PATH } from '../../../../../common/constants'; +import { HostIsolationExceptionsList } from './host_isolation_exceptions_list'; +import { isFailedResourceState, isLoadedResourceState } from '../../../state'; +import { getHostIsolationExceptionItems } from '../service'; +import { getFoundExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/found_exception_list_item_schema.mock'; + +jest.mock('../service'); +const getHostIsolationExceptionItemsMock = getHostIsolationExceptionItems as jest.Mock; + +describe('When on the host isolation exceptions page', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let history: AppContextTestRender['history']; + let waitForAction: AppContextTestRender['middlewareSpy']['waitForAction']; + beforeEach(() => { + getHostIsolationExceptionItemsMock.mockReset(); + const mockedContext = createAppRootMockRenderer(); + ({ history } = mockedContext); + render = () => (renderResult = mockedContext.render()); + waitForAction = mockedContext.middlewareSpy.waitForAction; + + act(() => { + history.push(HOST_ISOLATION_EXCEPTIONS_PATH); + }); + }); + describe('When on the host isolation list page', () => { + const dataReceived = () => + act(async () => { + await waitForAction('hostIsolationExceptionsPageDataChanged', { + validate(action) { + return isLoadedResourceState(action.payload); + }, + }); + }); + describe('And no data exists', () => { + beforeEach(async () => { + getHostIsolationExceptionItemsMock.mockReturnValue({ + data: [], + page: 1, + per_page: 10, + total: 0, + }); + }); + + it('should show the Empty message', async () => { + render(); + await dataReceived(); + expect(renderResult.getByTestId('hostIsolationExceptionsEmpty')).toBeTruthy(); + }); + }); + describe('And data exists', () => { + beforeEach(async () => { + getHostIsolationExceptionItemsMock.mockImplementation(getFoundExceptionListItemSchemaMock); + }); + it('should show loading indicator while retrieving data', async () => { + let releaseApiResponse: (value?: unknown) => void; + + getHostIsolationExceptionItemsMock.mockReturnValue( + new Promise((resolve) => (releaseApiResponse = resolve)) + ); + render(); + + expect(renderResult.getByTestId('hostIsolationExceptionsContent-loader')).toBeTruthy(); + + const wasReceived = dataReceived(); + releaseApiResponse!(); + await wasReceived; + expect(renderResult.container.querySelector('.euiProgress')).toBeNull(); + }); + + it('should show items on the list', async () => { + render(); + await dataReceived(); + + expect(renderResult.getByTestId('hostIsolationExceptionsCard')).toBeTruthy(); + }); + + it('should show API error if one is encountered', async () => { + getHostIsolationExceptionItemsMock.mockImplementation(() => { + throw new Error('Server is too far away'); + }); + const errorDispatched = act(async () => { + await waitForAction('hostIsolationExceptionsPageDataChanged', { + validate(action) { + return isFailedResourceState(action.payload); + }, + }); + }); + render(); + await errorDispatched; + expect( + renderResult.getByTestId('hostIsolationExceptionsContent-error').textContent + ).toEqual(' Server is too far away'); + }); + }); + }); +}); 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 new file mode 100644 index 000000000000..f6198e4e1aa5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx @@ -0,0 +1,106 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import React, { useCallback } from 'react'; +import { EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ExceptionItem } from '../../../../common/components/exceptions/viewer/exception_item'; +import { + getCurrentLocation, + getListFetchError, + getListIsLoading, + getListItems, + getListPagination, +} from '../store/selector'; +import { + useHostIsolationExceptionsNavigateCallback, + useHostIsolationExceptionsSelector, +} from './hooks'; +import { PaginatedContent, PaginatedContentProps } from '../../../components/paginated_content'; +import { Immutable } from '../../../../../common/endpoint/types'; +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'; + +type HostIsolationExceptionPaginatedContent = PaginatedContentProps< + Immutable, + typeof ExceptionItem +>; + +export const HostIsolationExceptionsList = () => { + const listItems = useHostIsolationExceptionsSelector(getListItems); + const pagination = useHostIsolationExceptionsSelector(getListPagination); + const isLoading = useHostIsolationExceptionsSelector(getListIsLoading); + const fetchError = useHostIsolationExceptionsSelector(getListFetchError); + const location = useHostIsolationExceptionsSelector(getCurrentLocation); + + const navigateCallback = useHostIsolationExceptionsNavigateCallback(); + + const handleOnSearch = useCallback( + (query: string) => { + navigateCallback({ filter: query }); + }, + [navigateCallback] + ); + + const handleItemComponentProps = (element: ExceptionListItemSchema): ArtifactEntryCardProps => ({ + item: element, + 'data-test-subj': `hostIsolationExceptionsCard`, + }); + + const handlePaginatedContentChange: HostIsolationExceptionPaginatedContent['onChange'] = + useCallback( + ({ pageIndex, pageSize }) => { + navigateCallback({ + page_index: pageIndex, + page_size: pageSize, + }); + }, + [navigateCallback] + ); + + return ( + + } + actions={[]} + > + + + + items={listItems} + ItemComponent={ArtifactEntryCard} + itemComponentProps={handleItemComponentProps} + onChange={handlePaginatedContentChange} + error={fetchError?.message} + loading={isLoading} + pagination={pagination} + contentClassName="host-isolation-exceptions-container" + data-test-subj="hostIsolationExceptionsContent" + noItemsMessage={} + /> + + ); +}; + +HostIsolationExceptionsList.displayName = 'HostIsolationExceptionsList'; diff --git a/x-pack/plugins/security_solution/public/management/pages/index.tsx b/x-pack/plugins/security_solution/public/management/pages/index.tsx index f348be608992..51eb56d23d54 100644 --- a/x-pack/plugins/security_solution/public/management/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/index.tsx @@ -12,6 +12,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { MANAGEMENT_ROUTING_ENDPOINTS_PATH, MANAGEMENT_ROUTING_EVENT_FILTERS_PATH, + MANAGEMENT_ROUTING_HOST_ISOLATION_EXCEPTIONS_PATH, MANAGEMENT_ROUTING_POLICIES_PATH, MANAGEMENT_ROUTING_TRUSTED_APPS_PATH, } from '../common/constants'; @@ -25,6 +26,7 @@ import { SpyRoute } from '../../common/utils/route/spy_routes'; import { EventFiltersContainer } from './event_filters'; import { getEndpointListPath } from '../common/routing'; import { useUserPrivileges } from '../../common/components/user_privileges'; +import { HostIsolationExceptionsContainer } from './host_isolation_exceptions'; const NoPermissions = memo(() => { return ( @@ -79,6 +81,13 @@ const EventFilterTelemetry = () => ( ); +const HostIsolationExceptionsTelemetry = () => ( + + + + +); + export const ManagementContainer = memo(() => { const { loading, canAccessEndpointManagement } = useUserPrivileges().endpointPrivileges; @@ -97,6 +106,10 @@ export const ManagementContainer = memo(() => { + diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts index aa34550d7584..f772986bff14 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts @@ -412,11 +412,8 @@ const fetchEditTrustedAppIfNeeded = async ( dispatch({ type: 'trustedAppCreationEditItemStateChanged', payload: { - // No easy way to get around this that I can see. `previousState` does not - // seem to allow everything that `editItem` state can hold, so not even sure if using - // type guards would work here - // @ts-ignore type: 'LoadingResourceState', + // @ts-expect-error-next-line will be fixed with when AsyncResourceState is refactored (#830) previousState: editItemState(currentState)!, }, }); diff --git a/x-pack/plugins/security_solution/public/management/store/middleware.ts b/x-pack/plugins/security_solution/public/management/store/middleware.ts index d011a9dcb91a..b67b4687da01 100644 --- a/x-pack/plugins/security_solution/public/management/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/store/middleware.ts @@ -16,11 +16,13 @@ import { MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE, MANAGEMENT_STORE_TRUSTED_APPS_NAMESPACE, MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE, + MANAGEMENT_STORE_HOST_ISOLATION_EXCEPTIONS_NAMESPACE, } from '../common/constants'; import { policyDetailsMiddlewareFactory } from '../pages/policy/store/policy_details'; import { endpointMiddlewareFactory } from '../pages/endpoint_hosts/store/middleware'; import { trustedAppsPageMiddlewareFactory } from '../pages/trusted_apps/store/middleware'; import { eventFiltersPageMiddlewareFactory } from '../pages/event_filters/store/middleware'; +import { hostIsolationExceptionsMiddlewareFactory } from '../pages/host_isolation_exceptions/store/middleware'; type ManagementSubStateKey = keyof State[typeof MANAGEMENT_STORE_GLOBAL_NAMESPACE]; @@ -50,5 +52,9 @@ export const managementMiddlewareFactory: SecuritySubPluginMiddlewareFactory = ( createSubStateSelector(MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE), eventFiltersPageMiddlewareFactory(coreStart, depsStart) ), + substateMiddlewareFactory( + createSubStateSelector(MANAGEMENT_STORE_HOST_ISOLATION_EXCEPTIONS_NAMESPACE), + hostIsolationExceptionsMiddlewareFactory(coreStart) + ), ]; }; diff --git a/x-pack/plugins/security_solution/public/management/store/reducer.ts b/x-pack/plugins/security_solution/public/management/store/reducer.ts index 662d2b4322bc..677114a58d56 100644 --- a/x-pack/plugins/security_solution/public/management/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/store/reducer.ts @@ -15,6 +15,7 @@ import { MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE, MANAGEMENT_STORE_TRUSTED_APPS_NAMESPACE, MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE, + MANAGEMENT_STORE_HOST_ISOLATION_EXCEPTIONS_NAMESPACE, } from '../common/constants'; import { ImmutableCombineReducers } from '../../common/store'; import { Immutable } from '../../../common/endpoint/types'; @@ -25,6 +26,8 @@ import { trustedAppsPageReducer } from '../pages/trusted_apps/store/reducer'; import { initialEventFiltersPageState } from '../pages/event_filters/store/builders'; import { eventFiltersPageReducer } from '../pages/event_filters/store/reducer'; import { initialEndpointPageState } from '../pages/endpoint_hosts/store/builders'; +import { initialHostIsolationExceptionsPageState } from '../pages/host_isolation_exceptions/store/builders'; +import { hostIsolationExceptionsPageReducer } from '../pages/host_isolation_exceptions/store/reducer'; const immutableCombineReducers: ImmutableCombineReducers = combineReducers; @@ -36,6 +39,7 @@ export const mockManagementState: Immutable = { [MANAGEMENT_STORE_ENDPOINTS_NAMESPACE]: initialEndpointPageState(), [MANAGEMENT_STORE_TRUSTED_APPS_NAMESPACE]: initialTrustedAppsPageState(), [MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE]: initialEventFiltersPageState(), + [MANAGEMENT_STORE_HOST_ISOLATION_EXCEPTIONS_NAMESPACE]: initialHostIsolationExceptionsPageState(), }; /** @@ -46,4 +50,5 @@ export const managementReducer = immutableCombineReducers({ [MANAGEMENT_STORE_ENDPOINTS_NAMESPACE]: endpointListReducer, [MANAGEMENT_STORE_TRUSTED_APPS_NAMESPACE]: trustedAppsPageReducer, [MANAGEMENT_STORE_EVENT_FILTERS_NAMESPACE]: eventFiltersPageReducer, + [MANAGEMENT_STORE_HOST_ISOLATION_EXCEPTIONS_NAMESPACE]: hostIsolationExceptionsPageReducer, }); diff --git a/x-pack/plugins/security_solution/public/management/types.ts b/x-pack/plugins/security_solution/public/management/types.ts index cadb3b91f66a..8e5fc3e6cfe9 100644 --- a/x-pack/plugins/security_solution/public/management/types.ts +++ b/x-pack/plugins/security_solution/public/management/types.ts @@ -11,6 +11,7 @@ import { PolicyDetailsState } from './pages/policy/types'; import { EndpointState } from './pages/endpoint_hosts/types'; import { TrustedAppsListPageState } from './pages/trusted_apps/state'; import { EventFiltersListPageState } from './pages/event_filters/types'; +import { HostIsolationExceptionsPageState } from './pages/host_isolation_exceptions/types'; /** * The type for the management store global namespace. Used mostly internally to reference @@ -23,6 +24,7 @@ export type ManagementState = CombinedState<{ endpoints: EndpointState; trustedApps: TrustedAppsListPageState; eventFilters: EventFiltersListPageState; + hostIsolationExceptions: HostIsolationExceptionsPageState; }>; /** @@ -33,6 +35,7 @@ export enum AdministrationSubTab { policies = 'policy', trustedApps = 'trusted_apps', eventFilters = 'event_filters', + hostIsolationExceptions = 'host_isolation_exceptions', } /**