[Security Solution] host isolation exceptions listing under policy integration details tab (#120361)

This commit is contained in:
Esteban Beltran 2021-12-13 16:46:42 +01:00 committed by GitHub
parent b0442e396b
commit b6753241ed
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 796 additions and 56 deletions

View file

@ -9,9 +9,11 @@ import type { FoundExceptionListItemSchema } from '@kbn/securitysolution-io-ts-l
import { getExceptionListItemSchemaMock } from './exception_list_item_schema.mock';
export const getFoundExceptionListItemSchemaMock = (): FoundExceptionListItemSchema => ({
data: [getExceptionListItemSchemaMock()],
export const getFoundExceptionListItemSchemaMock = (
count: number = 1
): FoundExceptionListItemSchema => ({
data: Array.from({ length: count }, getExceptionListItemSchemaMock),
page: 1,
per_page: 1,
total: 1,
total: count,
});

View file

@ -14,6 +14,7 @@ export const MANAGEMENT_ROUTING_POLICIES_PATH = `${MANAGEMENT_PATH}/:tabName(${A
export const MANAGEMENT_ROUTING_POLICY_DETAILS_FORM_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.policies})/:policyId/settings`;
export const MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.policies})/:policyId/trustedApps`;
export const MANAGEMENT_ROUTING_POLICY_DETAILS_EVENT_FILTERS_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.policies})/:policyId/eventFilters`;
export const MANAGEMENT_ROUTING_POLICY_DETAILS_HOST_ISOLATION_EXCEPTIONS_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.policies})/:policyId/hostIsolationExceptions`;
/** @deprecated use the paths defined above instead */
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})`;

View file

@ -6,10 +6,16 @@
*/
import { isEmpty } from 'lodash/fp';
import { generatePath } from 'react-router-dom';
// eslint-disable-next-line import/no-nodejs-modules
import querystring from 'querystring';
import { generatePath } from 'react-router-dom';
import { appendSearch } from '../../common/components/link_to/helpers';
import { EndpointIndexUIQueryParams } from '../pages/endpoint_hosts/types';
import { EventFiltersPageLocation } from '../pages/event_filters/types';
import { HostIsolationExceptionsPageLocation } from '../pages/host_isolation_exceptions/types';
import { PolicyDetailsArtifactsPageLocation } from '../pages/policy/types';
import { TrustedAppsListPageLocation } from '../pages/trusted_apps/state';
import { AdministrationSubTab } from '../types';
import {
MANAGEMENT_DEFAULT_PAGE,
MANAGEMENT_DEFAULT_PAGE_SIZE,
@ -19,17 +25,11 @@ import {
MANAGEMENT_ROUTING_HOST_ISOLATION_EXCEPTIONS_PATH,
MANAGEMENT_ROUTING_POLICIES_PATH,
MANAGEMENT_ROUTING_POLICY_DETAILS_FORM_PATH,
MANAGEMENT_ROUTING_POLICY_DETAILS_HOST_ISOLATION_EXCEPTIONS_PATH,
MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH,
MANAGEMENT_ROUTING_POLICY_DETAILS_EVENT_FILTERS_PATH,
MANAGEMENT_ROUTING_TRUSTED_APPS_PATH,
} from './constants';
import { AdministrationSubTab } from '../types';
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
type ExactKeys<T1, T2> = Exclude<keyof T1, keyof T2> extends never ? T1 : never;
@ -390,3 +390,16 @@ export const getHostIsolationExceptionsListPath = (
querystring.stringify(normalizeHostIsolationExceptionsPageLocation(location))
)}`;
};
export const getPolicyHostIsolationExceptionsPath = (
policyId: string,
location?: Partial<PolicyDetailsArtifactsPageLocation>
) => {
const path = generatePath(MANAGEMENT_ROUTING_POLICY_DETAILS_HOST_ISOLATION_EXCEPTIONS_PATH, {
tabName: AdministrationSubTab.policies,
policyId,
});
return `${path}${appendSearch(
querystring.stringify(normalizePolicyDetailsArtifactsListPageLocation(location))
)}`;
};

View file

@ -49,3 +49,24 @@ export const parsePoliciesToKQL = (includedPolicies: string, excludedPolicies: s
return `(${kuery.join(' AND ')})`;
};
/**
* Takes a list of policies (string[]) and an existing kuery
* (string) and returns an unified KQL with and AND
* @param policies string[] a list of policies ids
* @param kuery string an existing KQL.
*/
export const parsePoliciesAndFilterToKql = ({
policies,
kuery,
}: {
policies?: string[];
kuery?: string;
}): string | undefined => {
if (!policies || !policies.length) {
return kuery;
}
const policiesKQL = parsePoliciesToKQL(policies.join(','), '');
return `(${policiesKQL})${kuery ? ` AND (${kuery})` : ''}`;
};

View file

@ -22,7 +22,7 @@ import {
MANAGEMENT_STORE_HOST_ISOLATION_EXCEPTIONS_NAMESPACE,
} from '../../../common/constants';
import { getHostIsolationExceptionsListPath } from '../../../common/routing';
import { parseQueryFilterToKQL } from '../../../common/utils';
import { parsePoliciesAndFilterToKql, parseQueryFilterToKQL } from '../../../common/utils';
import {
getHostIsolationExceptionItems,
getHostIsolationExceptionSummary,
@ -87,23 +87,37 @@ export function useCanSeeHostIsolationExceptionsMenu(): boolean {
const SEARCHABLE_FIELDS: Readonly<string[]> = [`name`, `description`, `entries.value`];
export function useFetchHostIsolationExceptionsList(): QueryObserverResult<
FoundExceptionListItemSchema,
ServerApiError
> {
export function useFetchHostIsolationExceptionsList({
filter,
page,
perPage,
policies,
enabled = true,
}: {
filter?: string;
page: number;
perPage: number;
policies?: string[];
enabled?: boolean;
}): QueryObserverResult<FoundExceptionListItemSchema, ServerApiError> {
const http = useHttp();
const location = useHostIsolationExceptionsSelector(getCurrentLocation);
return useQuery<FoundExceptionListItemSchema, ServerApiError>(
['hostIsolationExceptions', 'list', location.filter, location.page_size, location.page_index],
['hostIsolationExceptions', 'list', filter, perPage, page, policies],
() => {
const kql = parsePoliciesAndFilterToKql({
policies,
kuery: filter ? parseQueryFilterToKQL(filter, SEARCHABLE_FIELDS) : undefined,
});
return getHostIsolationExceptionItems({
http,
page: location.page_index + 1,
perPage: location.page_size,
filter: parseQueryFilterToKQL(location.filter, SEARCHABLE_FIELDS) || undefined,
page: page + 1,
perPage,
filter: kql,
});
}
},
{ enabled }
);
}
@ -114,7 +128,7 @@ export function useGetHostIsolationExceptionFormEntry({
}: {
id?: string;
onSuccess: (data: CreateExceptionListItemSchema | UpdateExceptionListItemSchema) => void;
onError: (error: ServerApiError) => void;
onError?: (error: ServerApiError) => void;
}): QueryObserverResult {
const http = useHttp();
return useQuery<UpdateExceptionListItemSchema | CreateExceptionListItemSchema, ServerApiError>(

View file

@ -91,7 +91,9 @@ describe('When on the host isolation exceptions page', () => {
describe('And data exists', () => {
beforeEach(async () => {
getHostIsolationExceptionItemsMock.mockImplementation(getFoundExceptionListItemSchemaMock);
getHostIsolationExceptionItemsMock.mockImplementation(() =>
getFoundExceptionListItemSchemaMock(1)
);
});
it('should show loading indicator while retrieving data and hide it when it gets it', async () => {
@ -185,7 +187,9 @@ describe('When on the host isolation exceptions page', () => {
describe('has canIsolateHost privileges', () => {
beforeEach(async () => {
setEndpointPrivileges({ canIsolateHost: true });
getHostIsolationExceptionItemsMock.mockImplementation(getFoundExceptionListItemSchemaMock);
getHostIsolationExceptionItemsMock.mockImplementation(() =>
getFoundExceptionListItemSchemaMock(1)
);
});
it('should show the create flyout when the add button is pressed', async () => {

View file

@ -55,7 +55,12 @@ export const HostIsolationExceptionsList = () => {
const [itemToDelete, setItemToDelete] = useState<ExceptionListItemSchema | null>(null);
const { isLoading, data, error, refetch } = useFetchHostIsolationExceptionsList();
const { isLoading, data, error, refetch } = useFetchHostIsolationExceptionsList({
filter: location.filter,
page: location.page_index,
perPage: location.page_size,
});
const toasts = useToasts();
// load the list of policies>

View file

@ -13,6 +13,7 @@ import {
MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH,
MANAGEMENT_ROUTING_POLICY_DETAILS_EVENT_FILTERS_PATH,
MANAGEMENT_ROUTING_POLICY_DETAILS_PATH_OLD,
MANAGEMENT_ROUTING_POLICY_DETAILS_HOST_ISOLATION_EXCEPTIONS_PATH,
} from '../../common/constants';
import { NotFoundPage } from '../../../app/404';
import { getPolicyDetailPath } from '../../common/routing';
@ -25,6 +26,7 @@ export const PolicyContainer = memo(() => {
MANAGEMENT_ROUTING_POLICY_DETAILS_FORM_PATH,
MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH,
MANAGEMENT_ROUTING_POLICY_DETAILS_EVENT_FILTERS_PATH,
MANAGEMENT_ROUTING_POLICY_DETAILS_HOST_ISOLATION_EXCEPTIONS_PATH,
]}
exact
component={PolicyDetails}

View file

@ -7,12 +7,13 @@
import { matchPath } from 'react-router-dom';
import { createSelector } from 'reselect';
import { PolicyDetailsSelector, PolicyDetailsState } from '../../../types';
import {
MANAGEMENT_ROUTING_POLICY_DETAILS_FORM_PATH,
MANAGEMENT_ROUTING_POLICY_DETAILS_HOST_ISOLATION_EXCEPTIONS_PATH,
MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH,
MANAGEMENT_ROUTING_POLICY_DETAILS_EVENT_FILTERS_PATH,
} from '../../../../../common/constants';
import { PolicyDetailsSelector, PolicyDetailsState } from '../../../types';
/**
* Returns current artifacts location
@ -37,7 +38,7 @@ export const isOnPolicyFormView: PolicyDetailsSelector<boolean> = createSelector
}
);
/** Returns a boolean of whether the user is on the policy trusted app page or not */
/** Returns a boolean of whether the user is on the policy trusted apps page or not */
export const isOnPolicyTrustedAppsView: PolicyDetailsSelector<boolean> = createSelector(
getUrlLocationPathname,
(pathname) => {
@ -62,3 +63,16 @@ export const isOnPolicyEventFiltersView: PolicyDetailsSelector<boolean> = create
);
}
);
/** Returns a boolean of whether the user is on the host isolation exceptions page or not */
export const isOnHostIsolationExceptionsView: PolicyDetailsSelector<boolean> = createSelector(
getUrlLocationPathname,
(pathname) => {
return (
matchPath(pathname ?? '', {
path: MANAGEMENT_ROUTING_POLICY_DETAILS_HOST_ISOLATION_EXCEPTIONS_PATH,
exact: true,
}) !== null
);
}
);

View file

@ -5,8 +5,8 @@
* 2.0.
*/
import { createSelector } from 'reselect';
import { matchPath } from 'react-router-dom';
import { createSelector } from 'reselect';
import { ILicense } from '../../../../../../../../licensing/common/types';
import { unsetPolicyFeaturesAccordingToLicenseLevel } from '../../../../../../../common/license/policy_config';
import { PolicyDetailsState } from '../../../types';
@ -20,6 +20,7 @@ import {
import { policyFactory as policyConfigFactory } from '../../../../../../../common/endpoint/models/policy_config';
import {
MANAGEMENT_ROUTING_POLICY_DETAILS_FORM_PATH,
MANAGEMENT_ROUTING_POLICY_DETAILS_HOST_ISOLATION_EXCEPTIONS_PATH,
MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH,
MANAGEMENT_ROUTING_POLICY_DETAILS_EVENT_FILTERS_PATH,
} from '../../../../../common/constants';
@ -28,6 +29,7 @@ import { getPolicyDataForUpdate } from '../../../../../../../common/endpoint/ser
import {
isOnPolicyTrustedAppsView,
isOnPolicyEventFiltersView,
isOnHostIsolationExceptionsView,
isOnPolicyFormView,
} from './policy_common_selectors';
@ -90,7 +92,8 @@ export const needsToRefresh = (state: Immutable<PolicyDetailsState>): boolean =>
export const isOnPolicyDetailsPage = (state: Immutable<PolicyDetailsState>) =>
isOnPolicyFormView(state) ||
isOnPolicyTrustedAppsView(state) ||
isOnPolicyEventFiltersView(state);
isOnPolicyEventFiltersView(state) ||
isOnHostIsolationExceptionsView(state);
/** Returns the license info fetched from the license service */
export const license = (state: Immutable<PolicyDetailsState>) => {
@ -107,6 +110,7 @@ export const policyIdFromParams: (state: Immutable<PolicyDetailsState>) => strin
MANAGEMENT_ROUTING_POLICY_DETAILS_FORM_PATH,
MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH,
MANAGEMENT_ROUTING_POLICY_DETAILS_EVENT_FILTERS_PATH,
MANAGEMENT_ROUTING_POLICY_DETAILS_HOST_ISOLATION_EXCEPTIONS_PATH,
],
exact: true,
})?.params?.policyId ?? ''

View file

@ -0,0 +1,53 @@
/*
* 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 { EuiEmptyPrompt, EuiLink, EuiPageTemplate } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import React from 'react';
export const PolicyHostIsolationExceptionsEmptyUnassigned = ({
policyName,
toHostIsolationList,
}: {
policyName: string;
toHostIsolationList: string;
}) => {
return (
<EuiPageTemplate template="centeredContent">
<EuiEmptyPrompt
iconType="plusInCircle"
data-test-subj="policy-host-isolation-exceptions-empty-unassigned"
title={
<h2>
<FormattedMessage
id="xpack.securitySolution.endpoint.policy.hostIsolationExceptions.empty.unassigned.title"
defaultMessage="No assigned host isolation exceptions"
/>
</h2>
}
body={
<FormattedMessage
id="xpack.securitySolution.endpoint.policy.hostIsolationExceptions.empty.unassigned.content"
defaultMessage="There are currently no host isolation exceptions assigned to {policyName}. Assign exceptions now or add and manage them on the host isolation exceptions page."
values={{ policyName }}
/>
}
actions={[
<EuiLink href={toHostIsolationList}>
<FormattedMessage
id="xpack.securitySolution.endpoint.policy.hostIsolationExceptions.empty.unassigned.secondaryAction"
defaultMessage="Manage host isolation exceptions"
/>
</EuiLink>,
]}
/>
</EuiPageTemplate>
);
};
PolicyHostIsolationExceptionsEmptyUnassigned.displayName =
'PolicyHostIsolationExceptionsEmptyUnassigned';

View file

@ -0,0 +1,50 @@
/*
* 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 { EuiButton, EuiEmptyPrompt, EuiPageTemplate } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import React from 'react';
export const PolicyHostIsolationExceptionsEmptyUnexisting = ({
toHostIsolationList,
}: {
toHostIsolationList: string;
}) => {
return (
<EuiPageTemplate template="centeredContent">
<EuiEmptyPrompt
iconType="plusInCircle"
data-test-subj="policy-host-isolation-exceptions-empty-unexisting"
title={
<h2>
<FormattedMessage
id="xpack.securitySolution.endpoint.policy.hostIsolationExceptions.empty.unexisting.title"
defaultMessage="No host isolation exceptions exist"
/>
</h2>
}
body={
<FormattedMessage
id="xpack.securitySolution.endpoint.policy.hostIsolationExceptions.empty.unexisting.content"
defaultMessage="There are currently no host isolation exceptions applied to your endpoints."
/>
}
actions={
<EuiButton color="primary" fill href={toHostIsolationList}>
<FormattedMessage
id="xpack.securitySolution.endpoint.policy.hostIsolationExceptions.empty.unexisting.action"
defaultMessage="Add host isolation exceptions"
/>
</EuiButton>
}
/>
</EuiPageTemplate>
);
};
PolicyHostIsolationExceptionsEmptyUnexisting.displayName =
'PolicyHostIsolationExceptionsEmptyUnexisting';

View file

@ -0,0 +1,94 @@
/*
* 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 { act } from '@testing-library/react';
import React from 'react';
import uuid from 'uuid';
import { getPolicyHostIsolationExceptionsPath } from '../../../../../common/routing';
import { getFoundExceptionListItemSchemaMock } from '../../../../../../../../lists/common/schemas/response/found_exception_list_item_schema.mock';
import {
AppContextTestRender,
createAppRootMockRenderer,
} from '../../../../../../common/mock/endpoint';
import { PolicyHostIsolationExceptionsList } from './list';
import userEvent from '@testing-library/user-event';
const emptyList = {
data: [],
page: 1,
per_page: 10,
total: 0,
};
describe('Policy details host isolation exceptions tab', () => {
let policyId: string;
let render: (
exceptions: FoundExceptionListItemSchema
) => ReturnType<AppContextTestRender['render']>;
let renderResult: ReturnType<typeof render>;
let history: AppContextTestRender['history'];
let mockedContext: AppContextTestRender;
beforeEach(() => {
policyId = uuid.v4();
mockedContext = createAppRootMockRenderer();
({ history } = mockedContext);
render = (exceptions: FoundExceptionListItemSchema) =>
(renderResult = mockedContext.render(
<PolicyHostIsolationExceptionsList policyId={policyId} exceptions={exceptions} />
));
act(() => {
history.push(getPolicyHostIsolationExceptionsPath(policyId));
});
});
it('should display a searchbar and count even with no exceptions', () => {
render(emptyList);
expect(
renderResult.getByTestId('policyDetailsHostIsolationExceptionsSearchCount')
).toHaveTextContent('Showing 0 exceptions');
expect(renderResult.getByTestId('searchField')).toBeTruthy();
});
it('should render the list of exceptions collapsed and expand it when clicked', () => {
// render 3
render(getFoundExceptionListItemSchemaMock(3));
expect(renderResult.getAllByTestId('hostIsolationExceptions-collapsed-list-card')).toHaveLength(
3
);
expect(
renderResult.queryAllByTestId(
'hostIsolationExceptions-collapsed-list-card-criteriaConditions'
)
).toHaveLength(0);
});
it('should expand an item when expand is clicked', () => {
render(getFoundExceptionListItemSchemaMock(1));
expect(renderResult.getAllByTestId('hostIsolationExceptions-collapsed-list-card')).toHaveLength(
1
);
userEvent.click(
renderResult.getByTestId('hostIsolationExceptions-collapsed-list-card-header-expandCollapse')
);
expect(
renderResult.queryAllByTestId(
'hostIsolationExceptions-collapsed-list-card-criteriaConditions'
)
).toHaveLength(1);
});
it('should change the address location when a filter is applied', () => {
render(getFoundExceptionListItemSchemaMock(1));
userEvent.type(renderResult.getByTestId('searchField'), 'search me{enter}');
expect(history.location.search).toBe('?filter=search%20me');
});
});

View file

@ -0,0 +1,143 @@
/*
* 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 { EuiSpacer, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FoundExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
import React, { useCallback, useMemo, useState } from 'react';
import { useHistory } from 'react-router-dom';
import {
MANAGEMENT_DEFAULT_PAGE_SIZE,
MANAGEMENT_PAGE_SIZE_OPTIONS,
} from '../../../../../common/constants';
import { getPolicyHostIsolationExceptionsPath } from '../../../../../common/routing';
import {
ArtifactCardGrid,
ArtifactCardGridProps,
} from '../../../../../components/artifact_card_grid';
import { useEndpointPoliciesToArtifactPolicies } from '../../../../../components/artifact_entry_card/hooks/use_endpoint_policies_to_artifact_policies';
import { SearchExceptions } from '../../../../../components/search_exceptions';
import { useGetEndpointSpecificPolicies } from '../../../../../services/policies/hooks';
import { getCurrentArtifactsLocation } from '../../../store/policy_details/selectors';
import { usePolicyDetailsSelector } from '../../policy_hooks';
export const PolicyHostIsolationExceptionsList = ({
exceptions,
policyId,
}: {
exceptions: FoundExceptionListItemSchema;
policyId: string;
}) => {
const history = useHistory();
// load the list of policies>
const policiesRequest = useGetEndpointSpecificPolicies();
const urlParams = usePolicyDetailsSelector(getCurrentArtifactsLocation);
const [expandedItemsMap, setExpandedItemsMap] = useState<Map<string, boolean>>(new Map());
const pagination = {
totalItemCount: exceptions?.total ?? 0,
pageSize: exceptions?.per_page ?? MANAGEMENT_DEFAULT_PAGE_SIZE,
pageSizeOptions: [...MANAGEMENT_PAGE_SIZE_OPTIONS],
pageIndex: (exceptions?.page ?? 1) - 1,
};
const handlePageChange = useCallback<ArtifactCardGridProps['onPageChange']>(
({ pageIndex, pageSize }) => {
history.push(
getPolicyHostIsolationExceptionsPath(policyId, {
...urlParams,
// If user changed page size, then reset page index back to the first page
page_index: pageIndex,
page_size: pageSize,
})
);
},
[history, policyId, urlParams]
);
const handleSearchInput = useCallback(
(filter: string) => {
history.push(
getPolicyHostIsolationExceptionsPath(policyId, {
...urlParams,
filter,
})
);
},
[history, policyId, urlParams]
);
const artifactCardPolicies = useEndpointPoliciesToArtifactPolicies(policiesRequest.data?.items);
const provideCardProps: ArtifactCardGridProps['cardComponentProps'] = (item) => {
return {
expanded: expandedItemsMap.get(item.id) || false,
actions: [],
policies: artifactCardPolicies,
};
};
const handleExpandCollapse: ArtifactCardGridProps['onExpandCollapse'] = ({
expanded,
collapsed,
}) => {
const newExpandedMap = new Map(expandedItemsMap);
for (const item of expanded) {
newExpandedMap.set(item.id, true);
}
for (const item of collapsed) {
newExpandedMap.set(item.id, false);
}
setExpandedItemsMap(newExpandedMap);
};
const totalItemsCountLabel = useMemo<string>(() => {
return i18n.translate(
'xpack.securitySolution.endpoint.policy.hostIsolationExceptions.list.totalItemCount',
{
defaultMessage: 'Showing {totalItemsCount, plural, one {# exception} other {# exceptions}}',
values: { totalItemsCount: pagination.totalItemCount },
}
);
}, [pagination.totalItemCount]);
return (
<>
<SearchExceptions
placeholder={i18n.translate(
'xpack.securitySolution.endpoint.policy.hostIsolationExceptions.list.search.placeholder',
{
defaultMessage: 'Search on the fields below: name, description, value, ip',
}
)}
defaultValue={urlParams.filter}
hideRefreshButton
onSearch={handleSearchInput}
/>
<EuiSpacer size="s" />
<EuiText
color="subdued"
size="xs"
data-test-subj="policyDetailsHostIsolationExceptionsSearchCount"
>
{totalItemsCountLabel}
</EuiText>
<EuiSpacer size="m" />
<ArtifactCardGrid
items={exceptions.data}
onPageChange={handlePageChange}
onExpandCollapse={handleExpandCollapse}
cardComponentProps={provideCardProps}
pagination={pagination}
loading={policiesRequest.isLoading}
data-test-subj={'hostIsolationExceptions-collapsed-list'}
/>
</>
);
};
PolicyHostIsolationExceptionsList.displayName = 'PolicyHostIsolationExceptionsList';

View file

@ -0,0 +1,118 @@
/*
* 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 { getFoundExceptionListItemSchemaMock } from '../../../../../../../lists/common/schemas/response/found_exception_list_item_schema.mock';
import { EndpointDocGenerator } from '../../../../../../common/endpoint/generate_data';
import { PolicyData } from '../../../../../../common/endpoint/types';
import {
AppContextTestRender,
createAppRootMockRenderer,
} from '../../../../../common/mock/endpoint';
import { getPolicyHostIsolationExceptionsPath } from '../../../../common/routing';
import { getHostIsolationExceptionItems } from '../../../host_isolation_exceptions/service';
import { PolicyHostIsolationExceptionsTab } from './host_isolation_exceptions_tab';
jest.mock('../../../host_isolation_exceptions/service');
const getHostIsolationExceptionItemsMock = getHostIsolationExceptionItems as jest.Mock;
const endpointGenerator = new EndpointDocGenerator('seed');
const emptyList = {
data: [],
page: 1,
per_page: 10,
total: 0,
};
describe('Policy details host isolation exceptions tab', () => {
let policyId: string;
let policy: PolicyData;
let render: () => ReturnType<AppContextTestRender['render']>;
let renderResult: ReturnType<typeof render>;
let history: AppContextTestRender['history'];
let mockedContext: AppContextTestRender;
beforeEach(() => {
getHostIsolationExceptionItemsMock.mockClear();
policy = endpointGenerator.generatePolicyPackagePolicy();
policyId = policy.id;
mockedContext = createAppRootMockRenderer();
({ history } = mockedContext);
render = () =>
(renderResult = mockedContext.render(<PolicyHostIsolationExceptionsTab policy={policy} />));
history.push(getPolicyHostIsolationExceptionsPath(policyId));
});
it('should display display a "loading" state while requests happen', async () => {
const promises: Array<() => void> = [];
getHostIsolationExceptionItemsMock.mockImplementation(() => {
return new Promise<void>((resolve) => promises.push(resolve));
});
render();
expect(await renderResult.findByTestId('policyHostIsolationExceptionsTabLoading')).toBeTruthy();
// prevent memory leaks
promises.forEach((resolve) => resolve());
});
it("should display an 'unexistent' empty state if there are no host isolation exceptions at all", async () => {
// mock no data for all requests
getHostIsolationExceptionItemsMock.mockResolvedValue({
...emptyList,
});
render();
expect(
await renderResult.findByTestId('policy-host-isolation-exceptions-empty-unexisting')
).toBeTruthy();
});
it("should display an 'unassigned' empty state if there are no host isolation exceptions assigned", async () => {
// mock no data for all requests
getHostIsolationExceptionItemsMock.mockImplementation((params) => {
// no filter = fetch all exceptions
if (!params.filter) {
return {
...emptyList,
total: 1,
};
}
return {
...emptyList,
};
});
render();
expect(
await renderResult.findByTestId('policy-host-isolation-exceptions-empty-unassigned')
).toBeTruthy();
});
it('Should display the count of total assigned policies', async () => {
getHostIsolationExceptionItemsMock.mockImplementation(() => {
return getFoundExceptionListItemSchemaMock(4);
});
render();
expect(
await renderResult.findByTestId('policyHostIsolationExceptionsTabSubtitle')
).toHaveTextContent('There are 4 exceptions associated with this policy');
});
it('should apply a filter when requested from location search params', async () => {
history.push(getPolicyHostIsolationExceptionsPath(policyId, { filter: 'my filter' }));
getHostIsolationExceptionItemsMock.mockImplementation(() => {
return getFoundExceptionListItemSchemaMock(4);
});
render();
expect(getHostIsolationExceptionItemsMock).toHaveBeenLastCalledWith({
filter: `((exception-list-agnostic.attributes.tags:"policy:${policyId}" OR exception-list-agnostic.attributes.tags:"policy:all")) AND ((exception-list-agnostic.attributes.name:(*my*filter*) OR exception-list-agnostic.attributes.description:(*my*filter*) OR exception-list-agnostic.attributes.entries.value:(*my*filter*)))`,
http: mockedContext.coreStart.http,
page: 1,
perPage: 10,
});
});
});

View file

@ -0,0 +1,164 @@
/*
* 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 {
EuiLink,
EuiPageContent,
EuiPageHeader,
EuiPageHeaderSection,
EuiProgress,
EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { useMemo } from 'react';
import { APP_UI_ID } from '../../../../../../common/constants';
import { PolicyData } from '../../../../../../common/endpoint/types';
import { useAppUrl } from '../../../../../common/lib/kibana';
import {
MANAGEMENT_DEFAULT_PAGE,
MANAGEMENT_DEFAULT_PAGE_SIZE,
} from '../../../../common/constants';
import { getHostIsolationExceptionsListPath } from '../../../../common/routing';
import { useFetchHostIsolationExceptionsList } from '../../../host_isolation_exceptions/view/hooks';
import { getCurrentArtifactsLocation } from '../../store/policy_details/selectors';
import { usePolicyDetailsSelector } from '../policy_hooks';
import { PolicyHostIsolationExceptionsEmptyUnexisting } from './components/empty_unexisting';
import { PolicyHostIsolationExceptionsEmptyUnassigned } from './components/empty_unassigned';
import { PolicyHostIsolationExceptionsList } from './components/list';
export const PolicyHostIsolationExceptionsTab = ({ policy }: { policy: PolicyData }) => {
const { getAppUrl } = useAppUrl();
const policyId = policy.id;
const location = usePolicyDetailsSelector(getCurrentArtifactsLocation);
const toHostIsolationList = getAppUrl({
appId: APP_UI_ID,
path: getHostIsolationExceptionsListPath(),
});
const allPolicyExceptionsListRequest = useFetchHostIsolationExceptionsList({
page: MANAGEMENT_DEFAULT_PAGE,
perPage: MANAGEMENT_DEFAULT_PAGE_SIZE,
policies: [policyId, 'all'],
});
const policySearchedExceptionsListRequest = useFetchHostIsolationExceptionsList({
filter: location.filter,
page: location.page_index,
perPage: location.page_size,
policies: [policyId, 'all'],
});
const allExceptionsListRequest = useFetchHostIsolationExceptionsList({
page: MANAGEMENT_DEFAULT_PAGE,
perPage: MANAGEMENT_DEFAULT_PAGE_SIZE,
// only do this request if no assigned policies found
enabled: allPolicyExceptionsListRequest.data?.total === 0,
});
const hasNoAssignedOrExistingExceptions = allPolicyExceptionsListRequest.data?.total === 0;
const hasNoExistingExceptions = allExceptionsListRequest.data?.total === 0;
const subTitle = useMemo(() => {
const link = (
<EuiLink href={getAppUrl({ appId: APP_UI_ID, path: toHostIsolationList })} target="_blank">
<FormattedMessage
id="xpack.securitySolution.endpoint.policy.hostIsolationExceptions.list.viewAllLinkLabel"
defaultMessage="view all host isolation exceptions"
/>
</EuiLink>
);
return policySearchedExceptionsListRequest.data ? (
<FormattedMessage
id="xpack.securitySolution.endpoint.policy.hostIsolationExceptions.list.about"
defaultMessage="There {count, plural, one {is} other {are}} {count} {count, plural, =1 {exception} other {exceptions}} associated with this policy. Click here to {link}"
values={{
count: allPolicyExceptionsListRequest.data?.total,
link,
}}
/>
) : null;
}, [
allPolicyExceptionsListRequest.data?.total,
getAppUrl,
policySearchedExceptionsListRequest.data,
toHostIsolationList,
]);
const isLoading =
policySearchedExceptionsListRequest.isLoading ||
allPolicyExceptionsListRequest.isLoading ||
allExceptionsListRequest.isLoading ||
!policy;
// render non-existent or non-assigned messages
if (!isLoading && (hasNoAssignedOrExistingExceptions || hasNoExistingExceptions)) {
if (hasNoExistingExceptions) {
return (
<PolicyHostIsolationExceptionsEmptyUnexisting toHostIsolationList={toHostIsolationList} />
);
} else {
return (
<PolicyHostIsolationExceptionsEmptyUnassigned
policyName={policy.name}
toHostIsolationList={toHostIsolationList}
/>
);
}
}
// render header and list
return !isLoading && policySearchedExceptionsListRequest.data ? (
<div data-test-subj={'policyHostIsolationExceptionsTab'}>
<EuiPageHeader alignItems="center">
<EuiPageHeaderSection>
<EuiTitle size="m">
<h2>
{i18n.translate(
'xpack.securitySolution.endpoint.policy.hostIsolationExceptions.list.title',
{
defaultMessage: 'Assigned host isolation exceptions',
}
)}
</h2>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText size="xs" data-test-subj="policyHostIsolationExceptionsTabSubtitle">
<p>{subTitle}</p>
</EuiText>
</EuiPageHeaderSection>
</EuiPageHeader>
<EuiSpacer size="l" />
<EuiPageContent
hasBorder={false}
hasShadow={false}
paddingSize="none"
color="transparent"
borderRadius="none"
>
<PolicyHostIsolationExceptionsList
exceptions={policySearchedExceptionsListRequest.data}
policyId={policyId}
/>
</EuiPageContent>
</div>
) : (
<EuiProgress
size="xs"
color="primary"
data-test-subj="policyHostIsolationExceptionsTabLoading"
/>
);
};
PolicyHostIsolationExceptionsTab.displayName = 'PolicyHostIsolationExceptionsTab';

View file

@ -127,6 +127,7 @@ describe('Policy Details', () => {
expect(pageTitle).toHaveLength(1);
expect(pageTitle.text()).toEqual(policyPackagePolicy.name);
});
it('should navigate to list if back to link is clicked', async () => {
policyView.update();
@ -135,6 +136,7 @@ describe('Policy Details', () => {
backToListLink.simulate('click', { button: 0 });
expect(history.location.pathname).toEqual(endpointListPath);
});
it('should display agent stats', async () => {
await asyncActions;
policyView.update();
@ -143,6 +145,7 @@ describe('Policy Details', () => {
expect(agentsSummary).toHaveLength(1);
expect(agentsSummary.text()).toBe('Total agents5Healthy3Unhealthy1Offline1');
});
it('should display event filters tab', async () => {
await asyncActions;
policyView.update();
@ -151,5 +154,11 @@ describe('Policy Details', () => {
expect(eventFiltersTab).toHaveLength(1);
expect(eventFiltersTab.text()).toBe('Event filters');
});
it('should display the host isolation exceptions tab', async () => {
await asyncActions;
policyView.update();
expect(policyView.find('#hostIsolationExceptions')).toBeTruthy();
});
});
});

View file

@ -5,34 +5,37 @@
* 2.0.
*/
import { EuiSpacer, EuiTabbedContent, EuiTabbedContentTab } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useCallback, useMemo } from 'react';
import { useHistory } from 'react-router-dom';
import { i18n } from '@kbn/i18n';
import { EuiTabbedContent, EuiSpacer, EuiTabbedContentTab } from '@elastic/eui';
import { usePolicyDetailsSelector } from '../policy_hooks';
import {
isOnPolicyFormView,
isOnPolicyTrustedAppsView,
isOnPolicyEventFiltersView,
policyIdFromParams,
policyDetails,
} from '../../store/policy_details/selectors';
import { PolicyTrustedAppsLayout } from '../trusted_apps/layout';
import { PolicyEventFiltersLayout } from '../event_filters/layout';
import { PolicyFormLayout } from '../policy_forms/components';
import { PolicyData } from '../../../../../../common/endpoint/types';
import {
getPolicyDetailPath,
getPolicyTrustedAppsPath,
getPolicyEventFiltersPath,
getPolicyHostIsolationExceptionsPath,
getPolicyTrustedAppsPath,
} from '../../../../common/routing';
import {
isOnHostIsolationExceptionsView,
isOnPolicyEventFiltersView,
isOnPolicyFormView,
isOnPolicyTrustedAppsView,
policyDetails,
policyIdFromParams,
} from '../../store/policy_details/selectors';
import { PolicyEventFiltersLayout } from '../event_filters/layout';
import { PolicyHostIsolationExceptionsTab } from '../host_isolation_exceptions/host_isolation_exceptions_tab';
import { PolicyFormLayout } from '../policy_forms/components';
import { usePolicyDetailsSelector } from '../policy_hooks';
import { PolicyTrustedAppsLayout } from '../trusted_apps/layout';
export const PolicyTabs = React.memo(() => {
const history = useHistory();
const isInSettingsTab = usePolicyDetailsSelector(isOnPolicyFormView);
const isInTrustedAppsTab = usePolicyDetailsSelector(isOnPolicyTrustedAppsView);
const isInEventFilters = usePolicyDetailsSelector(isOnPolicyEventFiltersView);
const isInHostIsolationExceptionsTab = usePolicyDetailsSelector(isOnHostIsolationExceptionsView);
const policyId = usePolicyDetailsSelector(policyIdFromParams);
const policyItem = usePolicyDetailsSelector(policyDetails);
@ -74,6 +77,21 @@ export const PolicyTabs = React.memo(() => {
</>
),
},
{
id: 'hostIsolationExceptions',
name: i18n.translate(
'xpack.securitySolution.endpoint.policy.details.tabs.isInHostIsolationExceptions',
{
defaultMessage: 'Host isolation exceptions',
}
),
content: (
<>
<EuiSpacer />
<PolicyHostIsolationExceptionsTab policy={policyItem as PolicyData} />
</>
),
},
],
[policyItem]
);
@ -87,19 +105,30 @@ export const PolicyTabs = React.memo(() => {
initialTab = tabs[1];
} else if (isInEventFilters) {
initialTab = tabs[2];
} else if (isInHostIsolationExceptionsTab) {
initialTab = tabs[3];
}
return initialTab;
}, [isInSettingsTab, isInTrustedAppsTab, isInEventFilters, tabs]);
}, [isInSettingsTab, isInTrustedAppsTab, isInEventFilters, isInHostIsolationExceptionsTab, tabs]);
const onTabClickHandler = useCallback(
(selectedTab: EuiTabbedContentTab) => {
const path =
selectedTab.id === 'settings'
? getPolicyDetailPath(policyId)
: selectedTab.id === 'trustedApps'
? getPolicyTrustedAppsPath(policyId)
: getPolicyEventFiltersPath(policyId);
let path: string = '';
switch (selectedTab.id) {
case 'settings':
path = getPolicyDetailPath(policyId);
break;
case 'trustedApps':
path = getPolicyTrustedAppsPath(policyId);
break;
case 'hostIsolationExceptions':
path = getPolicyHostIsolationExceptionsPath(policyId);
break;
case 'eventFilters':
path = getPolicyEventFiltersPath(policyId);
break;
}
history.push(path);
},
[history, policyId]

View file

@ -13,8 +13,8 @@ import { sendGetEndpointSpecificPackagePolicies } from './policies';
export function useGetEndpointSpecificPolicies({
onError,
}: {
onError: (error: ServerApiError) => void;
}): QueryObserverResult<GetPolicyListResponse> {
onError?: (error: ServerApiError) => void;
} = {}): QueryObserverResult<GetPolicyListResponse> {
const http = useHttp();
return useQuery<GetPolicyListResponse, ServerApiError>(
['endpointSpecificPolicies'],