[9.0] [Security Solution][Endpoint] Adjust Artifacts policy assignment component in support of spaces (#214487) (#215194)

# Backport

This will backport the following commits from `main` to `9.0`:
- [[Security Solution][Endpoint] Adjust Artifacts policy assignment
component in support of spaces
(#214487)](https://github.com/elastic/kibana/pull/214487)

<!--- Backport version: 9.6.6 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sorenlouv/backport)

<!--BACKPORT [{"author":{"name":"Paul
Tavares","email":"56442535+paul-tavares@users.noreply.github.com"},"sourceCommit":{"committedDate":"2025-03-19T15:12:59Z","message":"[Security
Solution][Endpoint] Adjust Artifacts policy assignment component in
support of spaces (#214487)\n\n## Summary\n\n\n### Fleet\n\n- Exposed
API route for bulk get package policies via the routes service\n-
Created and exposed type
`BulkGetPackagePoliciesRequestBody`\n\n<br/>\n\n\n### Security
Solution\n\nThe following changes were made to Endpoint Artifacts in
support of\nspaces:\n\n> [!NOTE]\n> Space awareness is currently behind
feature flag:\n`endpointManagementSpaceAwarenessEnabled`\n\n\n- The
policy assignment component, which is displayed on artifact's\nCreate
and Update forms, now:\n- Displays the count of policies (if any) that
are associated with the\nartifact, but not currently accessible in the
active space (screen\ncapture 1️⃣ )\n- When a user does NOT have the
Global Artifact privilege, the `Global`\ntoggle selection will be
disabled and a tooltip is displayed. This\nchange also applies to the
create form where the default selection will\nbe per-policy and the
global button will be disabled. (screen capture\n2️⃣ )\n- Artifact
policy assignments that are not accessible in active space\nare
preserved when submitting an update to the artifact\n- The component was
also refactored a bit to simplify its list of props\n- Artifact card
policy assignment menu was adjusted to show any policy\nthat is not
accessible to the user as \"disabled\" along with a tooltip\n(screen
capture 3️⃣ )\n- The update artifact API was changed (via server-side
extension point)\nto not error when validating policies that are not
accessible in active\nspace if they were already associated with the
item being updated.\n- Fixes a bug in the Find artifacts API (impact
only when spaces was\nenabled) where an invalid filter was created when
there was no policies\ncurrently shared with active
space.","sha":"e11c3ecea5119202800d121a73765e26a41ff0a1","branchLabelMapping":{"^v9.1.0$":"main","^v8.19.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","Team:Fleet","Team:Defend
Workflows","backport:prev-minor","v9.1.0"],"title":"[Security
Solution][Endpoint] Adjust Artifacts policy assignment component in
support of
spaces","number":214487,"url":"https://github.com/elastic/kibana/pull/214487","mergeCommit":{"message":"[Security
Solution][Endpoint] Adjust Artifacts policy assignment component in
support of spaces (#214487)\n\n## Summary\n\n\n### Fleet\n\n- Exposed
API route for bulk get package policies via the routes service\n-
Created and exposed type
`BulkGetPackagePoliciesRequestBody`\n\n<br/>\n\n\n### Security
Solution\n\nThe following changes were made to Endpoint Artifacts in
support of\nspaces:\n\n> [!NOTE]\n> Space awareness is currently behind
feature flag:\n`endpointManagementSpaceAwarenessEnabled`\n\n\n- The
policy assignment component, which is displayed on artifact's\nCreate
and Update forms, now:\n- Displays the count of policies (if any) that
are associated with the\nartifact, but not currently accessible in the
active space (screen\ncapture 1️⃣ )\n- When a user does NOT have the
Global Artifact privilege, the `Global`\ntoggle selection will be
disabled and a tooltip is displayed. This\nchange also applies to the
create form where the default selection will\nbe per-policy and the
global button will be disabled. (screen capture\n2️⃣ )\n- Artifact
policy assignments that are not accessible in active space\nare
preserved when submitting an update to the artifact\n- The component was
also refactored a bit to simplify its list of props\n- Artifact card
policy assignment menu was adjusted to show any policy\nthat is not
accessible to the user as \"disabled\" along with a tooltip\n(screen
capture 3️⃣ )\n- The update artifact API was changed (via server-side
extension point)\nto not error when validating policies that are not
accessible in active\nspace if they were already associated with the
item being updated.\n- Fixes a bug in the Find artifacts API (impact
only when spaces was\nenabled) where an invalid filter was created when
there was no policies\ncurrently shared with active
space.","sha":"e11c3ecea5119202800d121a73765e26a41ff0a1"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/214487","number":214487,"mergeCommit":{"message":"[Security
Solution][Endpoint] Adjust Artifacts policy assignment component in
support of spaces (#214487)\n\n## Summary\n\n\n### Fleet\n\n- Exposed
API route for bulk get package policies via the routes service\n-
Created and exposed type
`BulkGetPackagePoliciesRequestBody`\n\n<br/>\n\n\n### Security
Solution\n\nThe following changes were made to Endpoint Artifacts in
support of\nspaces:\n\n> [!NOTE]\n> Space awareness is currently behind
feature flag:\n`endpointManagementSpaceAwarenessEnabled`\n\n\n- The
policy assignment component, which is displayed on artifact's\nCreate
and Update forms, now:\n- Displays the count of policies (if any) that
are associated with the\nartifact, but not currently accessible in the
active space (screen\ncapture 1️⃣ )\n- When a user does NOT have the
Global Artifact privilege, the `Global`\ntoggle selection will be
disabled and a tooltip is displayed. This\nchange also applies to the
create form where the default selection will\nbe per-policy and the
global button will be disabled. (screen capture\n2️⃣ )\n- Artifact
policy assignments that are not accessible in active space\nare
preserved when submitting an update to the artifact\n- The component was
also refactored a bit to simplify its list of props\n- Artifact card
policy assignment menu was adjusted to show any policy\nthat is not
accessible to the user as \"disabled\" along with a tooltip\n(screen
capture 3️⃣ )\n- The update artifact API was changed (via server-side
extension point)\nto not error when validating policies that are not
accessible in active\nspace if they were already associated with the
item being updated.\n- Fixes a bug in the Find artifacts API (impact
only when spaces was\nenabled) where an invalid filter was created when
there was no policies\ncurrently shared with active
space.","sha":"e11c3ecea5119202800d121a73765e26a41ff0a1"}}]}]
BACKPORT-->

Co-authored-by: Paul Tavares <56442535+paul-tavares@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2025-03-19 17:54:39 +01:00 committed by GitHub
parent 0d226d39c9
commit 943300d05e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 926 additions and 386 deletions

View file

@ -155,6 +155,10 @@ export const packagePolicyRouteService = {
getOrphanedIntegrationPoliciesPath: () => {
return PACKAGE_POLICY_API_ROUTES.ORPHANED_INTEGRATION_POLICIES;
},
getBulkGetPath: (): string => {
return PACKAGE_POLICY_API_ROUTES.BULK_GET_PATTERN;
},
};
export const agentPolicyRouteService = {

View file

@ -5,6 +5,10 @@
* 2.0.
*/
import type { TypeOf } from '@kbn/config-schema';
import type { BulkGetPackagePoliciesRequestSchema } from '../../../server/types';
import type {
PackagePolicy,
NewPackagePolicy,
@ -21,6 +25,9 @@ export interface GetPackagePoliciesRequest {
}
export type GetPackagePoliciesResponse = ListResult<PackagePolicy>;
export type BulkGetPackagePoliciesRequestBody = TypeOf<
typeof BulkGetPackagePoliciesRequestSchema.body
>;
export type BulkGetPackagePoliciesResponse = BulkGetResult<PackagePolicy>;
export interface GetOnePackagePolicyRequest {

View file

@ -27,11 +27,15 @@ export const isArtifactGlobal = (item: Partial<Pick<ExceptionListItemSchema, 'ta
return (item.tags ?? []).includes(GLOBAL_ARTIFACT_TAG);
};
export const isArtifactByPolicy = (item: Pick<ExceptionListItemSchema, 'tags'>): boolean => {
export const isArtifactByPolicy = (
item: Partial<Pick<ExceptionListItemSchema, 'tags'>>
): boolean => {
return !isArtifactGlobal(item);
};
export const getPolicyIdsFromArtifact = (item: Pick<ExceptionListItemSchema, 'tags'>): string[] => {
export const getPolicyIdsFromArtifact = (
item: Partial<Pick<ExceptionListItemSchema, 'tags'>>
): string[] => {
const policyIds = [];
const tags = item.tags ?? [];

View file

@ -320,3 +320,15 @@ export const CONFIRM_WARNING_MODAL_LABELS = (entryType: string) => {
),
};
};
export const NO_PRIVILEGE_FOR_MANAGEMENT_OF_GLOBAL_ARTIFACT_MESSAGE = i18n.translate(
'xpack.securitySolution.translations.noGlobalArtifactManagementAllowedMessage',
{ defaultMessage: 'Management of global artifacts requires additional privilege' }
);
export const ARTIFACT_POLICIES_NOT_ACCESSIBLE_IN_ACTIVE_SPACE_MESSAGE = (count: number): string =>
i18n.translate('xpack.securitySolution.translations.artifactPoliciesNotAccessibleInActiveSpace', {
defaultMessage:
'This artifact is associated with {count} {count, plural, =1 {policy that is} other {policies that are}} not accessible in active space',
values: { count },
});

View file

@ -203,8 +203,9 @@ describe.each([
policies = {
'policy-1': {
children: 'Policy one',
children: 'Policy one title',
'data-test-subj': 'policyMenuItem',
href: 'http://some/path/to/policy-1',
},
};
});
@ -239,7 +240,11 @@ describe.each([
).not.toBeNull();
expect(renderResult.getByTestId('policyMenuItem').textContent).toEqual(
'Policy oneView details'
'Policy one titleView details'
);
expect((renderResult.getByTestId('policyMenuItem') as HTMLAnchorElement).href).toEqual(
policies!['policy-1'].href
);
});
@ -259,12 +264,12 @@ describe.each([
renderResult.getByTestId('testCard-subHeader-effectScope-popupMenu-popoverPanel')
).not.toBeNull();
expect(renderResult.getByTestId('policyMenuItem').textContent).toEqual('Policy one');
expect(renderResult.getByTestId('policyMenuItem').textContent).toEqual('Policy one title');
});
});
it('should display policy ID if no policy menu item found in `policies` prop', async () => {
render();
it('should display disabled button with policy ID if no policy menu item found in `policies` prop', async () => {
render(); // Important: no polices provided to component on input
await act(async () => {
await fireEvent.click(
renderResult.getByTestId('testCard-subHeader-effectScope-popupMenu-button')
@ -275,7 +280,18 @@ describe.each([
renderResult.getByTestId('testCard-subHeader-effectScope-popupMenu-popoverPanel')
).not.toBeNull();
expect(renderResult.getByText('policy-1').textContent).not.toBeNull();
expect(
renderResult.getByTestId('testCard-subHeader-effectScope-popupMenu-item-0-truncateWrapper')
.textContent
).toEqual('policy-1');
expect(
(
renderResult.getByTestId(
'testCard-subHeader-effectScope-popupMenu-item-0'
) as HTMLButtonElement
).disabled
).toBe(true);
});
it('should pass item to decorator function and display its result', () => {

View file

@ -10,11 +10,9 @@ import React, { memo, useMemo } from 'react';
import type { CommonProps } from '@elastic/eui';
import { EuiFlexItem } from '@elastic/eui';
import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
import { NO_PRIVILEGE_FOR_MANAGEMENT_OF_GLOBAL_ARTIFACT_MESSAGE } from '../../../common/translations';
import { useUserPrivileges } from '../../../../common/components/user_privileges';
import {
MANAGEMENT_OF_GLOBAL_ARTIFACT_NOT_ALLOWED_MESSAGE,
MANAGEMENT_OF_SHARED_PER_POLICY_ARTIFACT_NOT_ALLOWED_MESSAGE,
} from './translations';
import { MANAGEMENT_OF_SHARED_PER_POLICY_ARTIFACT_NOT_ALLOWED_MESSAGE } from './translations';
import { useSpaceId } from '../../../../common/hooks/use_space_id';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import { isArtifactGlobal } from '../../../../../common/endpoint/service/artifacts';
@ -52,7 +50,7 @@ export const CardActionsFlexItem = memo<CardActionsFlexItemProps>(
if (isGlobal) {
return {
isDisabled: true,
disabledTooltip: MANAGEMENT_OF_GLOBAL_ARTIFACT_NOT_ALLOWED_MESSAGE,
disabledTooltip: NO_PRIVILEGE_FOR_MANAGEMENT_OF_GLOBAL_ARTIFACT_MESSAGE,
};
}

View file

@ -11,6 +11,8 @@ import type { CommonProps } from '@elastic/eui';
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui';
import styled from 'styled-components';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import { useUserPrivileges } from '../../../../common/components/user_privileges';
import {
GLOBAL_EFFECT_SCOPE,
@ -28,6 +30,16 @@ import { useTestIdGenerator } from '../../../hooks/use_test_id_generator';
// So something like: `<EffectScope perPolicyCount={3} />`
// This should display it as "Applied to 3 policies", but NOT as a menu with links
const POLICY_DETAILS_NOT_ACCESSIBLE = i18n.translate(
'xpack.securitySolution.effectScope.policyDetailsNotAccessible',
{ defaultMessage: 'Policy is no longer accessible' }
);
const POLICY_DETAILS_NOT_ACCESSIBLE_IN_ACTIVE_SPACE = i18n.translate(
'xpack.securitySolution.effectScope.policyDetailsNotAccessibleInActiveSpace',
{ defaultMessage: 'Policy is not accessible from the current space' }
);
const StyledWithContextMenuShiftedWrapper = styled('div')`
margin-left: -10px;
`;
@ -104,29 +116,46 @@ const WithContextMenu = memo<WithContextMenuProps>(
'data-test-subj': dataTestSubj,
}) => {
const getTestId = useTestIdGenerator(dataTestSubj);
const hoverInfo = useMemo(
() =>
canReadPolicies ? (
<StyledEuiButtonEmpty flush="right" size="s" iconSide="right" iconType="popout">
<FormattedMessage
id="xpack.securitySolution.contextMenuItemByRouter.viewDetails"
defaultMessage="View details"
/>
</StyledEuiButtonEmpty>
) : undefined,
[canReadPolicies]
const isSpacesEnabled = useIsExperimentalFeatureEnabled(
'endpointManagementSpaceAwarenessEnabled'
);
const menuItems: ContextMenuItemNavByRouterProps[] = useMemo(() => {
return policies.map((policyMenuItem) => {
const hasHref = Boolean(policyMenuItem.href);
return {
...policyMenuItem,
hoverInfo:
hasHref && canReadPolicies ? (
<StyledEuiButtonEmpty flush="right" size="s" iconSide="right" iconType="popout">
<FormattedMessage
id="xpack.securitySolution.contextMenuItemByRouter.viewDetails"
defaultMessage="View details"
/>
</StyledEuiButtonEmpty>
) : undefined,
disabled: !hasHref,
toolTipContent: !hasHref ? (
<>
{isSpacesEnabled
? POLICY_DETAILS_NOT_ACCESSIBLE_IN_ACTIVE_SPACE
: POLICY_DETAILS_NOT_ACCESSIBLE}
</>
) : undefined,
};
});
}, [canReadPolicies, isSpacesEnabled, policies]);
return (
<ContextMenuWithRouterSupport
maxHeight="235px"
fixedWidth={true}
panelPaddingSize="none"
items={policies}
items={menuItems}
anchorPosition={policies.length > 1 ? 'rightCenter' : 'rightUp'}
data-test-subj={dataTestSubj}
loading={loadingPoliciesList}
hoverInfo={hoverInfo}
button={
<EuiButtonEmpty size="xs" data-test-subj={getTestId('button')}>
{children}

View file

@ -163,11 +163,6 @@ export const DESCRIPTION_LABEL = i18n.translate(
}
);
export const MANAGEMENT_OF_GLOBAL_ARTIFACT_NOT_ALLOWED_MESSAGE = i18n.translate(
'xpack.securitySolution.translations.noGlobalArtifactManagementAllowedMessage',
{ defaultMessage: 'Management of global artifacts requires additional privilege' }
);
export const MANAGEMENT_OF_SHARED_PER_POLICY_ARTIFACT_NOT_ALLOWED_MESSAGE = i18n.translate(
'xpack.securitySolution.translations.sharedPerPolicyArtifactNotAllowed',
{

View file

@ -36,11 +36,19 @@ describe('When the flyout is opened in the ArtifactListPage component', () => {
let getLastFormComponentProps: ReturnType<
typeof getFormComponentMock
>['getLastFormComponentProps'];
let setExperimentalFlag: AppContextTestRender['setExperimentalFlag'];
beforeEach(() => {
const renderSetup = getArtifactListPageRenderingSetup();
({ history, coreStart, mockedApi, FormComponentMock, getLastFormComponentProps } = renderSetup);
({
history,
coreStart,
mockedApi,
FormComponentMock,
getLastFormComponentProps,
setExperimentalFlag,
} = renderSetup);
history.push('somepage?show=create');
@ -116,6 +124,36 @@ describe('When the flyout is opened in the ArtifactListPage component', () => {
);
});
it('should initialize form with a per-policy artifact when user does not have global artifact privilege and spaces is enabeld', async () => {
setExperimentalFlag({ endpointManagementSpaceAwarenessEnabled: true });
useUserPrivileges.mockReturnValue({
...useUserPrivileges(),
endpointPrivileges: getEndpointPrivilegesInitialStateMock({
canManageGlobalArtifacts: false,
}),
});
await render();
expect(FormComponentMock).toHaveBeenLastCalledWith(
expect.objectContaining({
item: {
comments: [],
description: '',
entries: [],
item_id: undefined,
list_id: 'endpoint_trusted_apps',
meta: expect.any(Object),
name: '',
namespace_type: 'agnostic',
os_types: ['windows'],
tags: [],
type: 'simple',
},
}),
expect.anything()
);
});
describe('and form data is valid', () => {
beforeEach(async () => {
const _renderAndWaitForFlyout = render;

View file

@ -27,6 +27,8 @@ import type { EuiFlyoutSize } from '@elastic/eui/src/components/flyout/flyout';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import { useIsMounted } from '@kbn/securitysolution-hook-utils';
import { useLocation } from 'react-router-dom';
import { GLOBAL_ARTIFACT_TAG } from '../../../../../common/endpoint/service/artifacts';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import { useMarkInsightAsRemediated } from '../hooks/use_mark_workflow_insight_as_remediated';
import type { WorkflowInsightRouteState } from '../../../pages/endpoint_hosts/types';
import { useUrlParams } from '../../../hooks/use_url_params';
@ -47,6 +49,7 @@ import { useIsArtifactAllowedPerPolicyUsage } from '../hooks/use_is_artifact_all
import { useGetArtifact } from '../../../hooks/artifacts';
import type { PolicyData } from '../../../../../common/endpoint/types';
import { ArtifactConfirmModal } from './artifact_confirm_modal';
import { useUserPrivileges } from '../../../../common/components/user_privileges';
export const ARTIFACT_FLYOUT_LABELS = Object.freeze({
flyoutEditTitle: i18n.translate('xpack.securitySolution.artifactListPage.flyoutEditTitle', {
@ -211,6 +214,11 @@ export const ArtifactFlyout = memo<ArtifactFlyoutProps>(
const setUrlParams = useSetUrlParams();
const { urlParams } = useUrlParams<ArtifactListPageUrlParams>();
const isMounted = useIsMounted();
const isSpaceAwarenessEnabled = useIsExperimentalFeatureEnabled(
'endpointManagementSpaceAwarenessEnabled'
);
const canManageGlobalArtifacts =
useUserPrivileges().endpointPrivileges.canManageGlobalArtifacts;
const labels = useMemo<typeof ARTIFACT_FLYOUT_LABELS>(() => {
return {
...ARTIFACT_FLYOUT_LABELS,
@ -255,9 +263,18 @@ export const ArtifactFlyout = memo<ArtifactFlyoutProps>(
enabled: false,
});
const [formState, setFormState] = useState<ArtifactFormComponentOnChangeCallbackProps>(
createFormInitialState.bind(null, apiClient.listId, item)
);
const [formState, setFormState] = useState<ArtifactFormComponentOnChangeCallbackProps>(() => {
const initialFormState = createFormInitialState(apiClient.listId, item);
// for Create Mode: If user is not able to manage global artifacts then the initial item should be per-policy
if (!item && isSpaceAwarenessEnabled && !canManageGlobalArtifacts) {
initialFormState.item.tags = (initialFormState.item.tags ?? []).filter(
(tag) => tag !== GLOBAL_ARTIFACT_TAG
);
}
return initialFormState;
});
const showExpiredLicenseBanner = useIsArtifactAllowedPerPolicyUsage(
{ tags: formState.item.tags ?? [] },
formMode

View file

@ -87,6 +87,7 @@ export interface ArtifactListPageRenderingSetup {
) => ReturnType<AppContextTestRender['render']>;
history: AppContextTestRender['history'];
coreStart: AppContextTestRender['coreStart'];
setExperimentalFlag: AppContextTestRender['setExperimentalFlag'];
mockedApi: ReturnType<typeof trustedAppsAllHttpMocks>;
FormComponentMock: ReturnType<typeof getFormComponentMock>['FormComponentMock'];
getLastFormComponentProps: ReturnType<typeof getFormComponentMock>['getLastFormComponentProps'];
@ -141,5 +142,6 @@ export const getArtifactListPageRenderingSetup = (): ArtifactListPageRenderingSe
FormComponentMock,
getLastFormComponentProps,
getFirstCard: getCard,
setExperimentalFlag: mockedContext.setExperimentalFlag,
};
};

View file

@ -57,7 +57,6 @@ const StyledEuiText = styled(EuiText)`
* Just like `EuiContextMenuItem`, but allows for additional props to be defined which will
* allow navigation to a URL path via React Router
*/
export const ContextMenuItemNavByRouter = memo<ContextMenuItemNavByRouterProps>(
({
navigateAppId,
@ -74,10 +73,11 @@ export const ContextMenuItemNavByRouter = memo<ContextMenuItemNavByRouterProps>(
onClick,
});
const getTestId = useTestIdGenerator(otherMenuItemProps['data-test-subj']);
const hoverComponentInstance = useMemo(() => {
// If the `hoverInfo` is not an object (ex. text, number), then auto-add the text truncation className.
// Adding this when the `hoverInfo` is a react component could cause issue, thus in htose cases, we
// assume the componet will handle how the data is truncated (if applicable)
// Adding this when the `hoverInfo` is a react component could cause issue, thus in those cases, we
// assume the component will handle how the data is truncated (if applicable)
const cssClassNames = `additional-info ${
'object' !== typeof hoverInfo ? 'eui-textTruncate' : ''
}`;

View file

@ -169,4 +169,13 @@ describe('When using the ContextMenuWithRouterSupport component', () => {
clickMenuTriggerButton();
expect(renderResult.getByTestId('testMenu-item-1').textContent).toEqual('click me 2');
});
it('should display menu item `hoverInfo` when no `hoverInfo` is provided to menu component', () => {
items[1].hoverInfo = 'item hover info here';
render();
clickMenuTriggerButton();
expect(renderResult.getByTestId('testMenu-item-1').textContent).toEqual(
'click me 2item hover info here'
);
});
});

View file

@ -16,7 +16,8 @@ import { useTestIdGenerator } from '../../hooks/use_test_id_generator';
export interface ContextMenuWithRouterSupportProps
extends CommonProps,
Pick<EuiPopoverProps, 'button' | 'anchorPosition' | 'panelPaddingSize'> {
Pick<EuiPopoverProps, 'button' | 'anchorPosition' | 'panelPaddingSize'>,
Pick<ContextMenuItemNavByRouterProps, 'isNavigationDisabled'> {
items: ContextMenuItemNavByRouterProps[];
/**
* The max width for the popup menu. Default is `32ch`.
@ -38,8 +39,12 @@ export interface ContextMenuWithRouterSupportProps
*/
title?: string;
loading?: boolean;
hoverInfo?: React.ReactNode;
isNavigationDisabled?: boolean;
/**
* Additional information to show on ALL menu items.
* The content provided here will be applied to all menu items, thus overriding the
* `hoverInfo` that may be defined on each one.
*/
hoverInfo?: ContextMenuItemNavByRouterProps['hoverInfo'];
}
/**
@ -90,7 +95,7 @@ export const ContextMenuWithRouterSupport = memo<ContextMenuWithRouterSupportPro
key={uuidv4()}
data-test-subj={itemProps['data-test-subj'] ?? getTestId(`item-${index}`)}
textTruncate={Boolean(maxWidth) || itemProps.textTruncate}
hoverInfo={hoverInfo}
hoverInfo={hoverInfo || itemProps.hoverInfo}
onClick={(ev) => {
handleCloseMenu();
if (itemProps.onClick) {

View file

@ -9,14 +9,25 @@ import type { EffectedPolicySelectProps } from './effected_policy_select';
import { EffectedPolicySelect } from './effected_policy_select';
import React from 'react';
import { forceHTMLElementOffsetWidth } from './test_utils';
import { fireEvent, act } from '@testing-library/react';
import { fireEvent, act, waitFor } from '@testing-library/react';
import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data';
import type { AppContextTestRender } from '../../../common/mock/endpoint';
import { createAppRootMockRenderer } from '../../../common/mock/endpoint';
import { useUserPrivileges } from '../../../common/components/user_privileges';
import { initialUserPrivilegesState } from '../../../common/components/user_privileges/user_privileges_context';
import { ExceptionsListItemGenerator } from '../../../../common/endpoint/data_generators/exceptions_list_item_generator';
import { GLOBAL_ARTIFACT_TAG } from '../../../../common/endpoint/service/artifacts';
import { useLicense as _useLicense } from '../../../common/hooks/use_license';
import type { LicenseService } from '../../../../common/license';
import { buildPerPolicyTag } from '../../../../common/endpoint/service/artifacts/utils';
import { ARTIFACT_POLICIES_NOT_ACCESSIBLE_IN_ACTIVE_SPACE_MESSAGE } from '../../common/translations';
import { fleetBulkGetPackagePoliciesListHttpMock } from '../../mocks';
import { FleetPackagePolicyGenerator } from '../../../../common/endpoint/data_generators/fleet_package_policy_generator';
jest.mock('../../../common/components/user_privileges');
jest.mock('../../../common/hooks/use_license');
const useLicenseMock = _useLicense as jest.Mock;
describe('when using EffectedPolicySelect component', () => {
const generator = new EndpointDocGenerator('effected-policy-select');
@ -43,15 +54,19 @@ describe('when using EffectedPolicySelect component', () => {
afterAll(() => resetHTMLElementOffsetWidth());
beforeEach(() => {
mockedContext = createAppRootMockRenderer();
// Default props
componentProps = {
item: new ExceptionsListItemGenerator('seed').generateTrustedApp({
tags: [GLOBAL_ARTIFACT_TAG],
}),
options: [],
isGlobal: true,
isPlatinumPlus: true,
onChange: handleOnChange,
'data-test-subj': 'test',
};
mockedContext = createAppRootMockRenderer();
(useLicenseMock() as jest.Mocked<LicenseService>).isPlatinumPlus.mockReturnValue(true);
});
afterEach(() => {
@ -60,7 +75,8 @@ describe('when using EffectedPolicySelect component', () => {
describe('and no policy entries exist', () => {
it('should display no options available message', () => {
const { getByTestId } = render({ isGlobal: false });
componentProps.item.tags = [];
const { getByTestId } = render();
const euiSelectableMessageElement =
getByTestId('test-policiesSelectable').getElementsByClassName('euiSelectableMessage')[0];
expect(euiSelectableMessageElement).not.toBeNull();
@ -100,45 +116,44 @@ describe('when using EffectedPolicySelect component', () => {
options: [policy],
};
handleOnChange.mockImplementation((selection) => {
handleOnChange.mockImplementation((updatedItem) => {
componentProps = {
...componentProps,
...selection,
item: updatedItem,
};
renderResult.rerender(<EffectedPolicySelect {...componentProps} />);
});
});
it('should display policies', () => {
const { getByTestId } = render({ isGlobal: false });
componentProps.item.tags = [];
const { getByTestId } = render();
expect(getByTestId(policyTestSubj));
});
it('should hide policy items if global is checked', () => {
const { queryByTestId } = render({ isGlobal: true });
const { queryByTestId } = render();
expect(queryByTestId(policyTestSubj)).toBeNull();
});
it('should enable policy items if global is unchecked', async () => {
const { getByTestId } = render({ isGlobal: false });
it('should show policy items when user clicks per-policy', async () => {
const { getByTestId } = render();
selectPerPolicy();
expect(getByTestId(policyTestSubj).getAttribute('aria-disabled')).toEqual('false');
expect(getByTestId(policyTestSubj)).not.toBeNull();
});
it('should call onChange with selection when global is toggled', () => {
it('should call onChange with updated item', () => {
render();
selectPerPolicy();
expect(handleOnChange.mock.calls[0][0]).toEqual({
isGlobal: false,
selected: [],
...componentProps.item,
tags: [],
});
selectGlobalPolicy();
expect(handleOnChange.mock.calls[1][0]).toEqual({
isGlobal: true,
selected: [],
});
expect(handleOnChange.mock.calls[1][0]).toEqual(componentProps.item);
});
it('should maintain policies selection even if global was checked, and user switched back to per policy', () => {
@ -147,24 +162,28 @@ describe('when using EffectedPolicySelect component', () => {
selectPerPolicy();
clickOnPolicy();
expect(handleOnChange.mock.calls[1][0]).toEqual({
isGlobal: false,
selected: [componentProps.options[0]],
});
// FYI: If wondering why `componentProps.item` is being used successfully here and below:
// its because `handlOnChange` is setup above to re-render the component everytime an update
// is received, thus it will always reflect the latest state of the artifact
expect(handleOnChange).toHaveBeenLastCalledWith(componentProps.item);
// Toggle isGlobal back to True
selectGlobalPolicy();
expect(handleOnChange.mock.calls[2][0]).toEqual({
isGlobal: true,
selected: [componentProps.options[0]],
});
expect(handleOnChange).toHaveBeenLastCalledWith(componentProps.item);
});
it('should show loader only when by polocy selected', () => {
const { queryByTestId } = render({ isLoading: true });
it('should show loader only when by policy selected', () => {
componentProps.isLoading = true;
const { queryByTestId, getByTestId, rerender } = render();
expect(queryByTestId('loading-spinner')).toBeNull();
selectPerPolicy();
expect(queryByTestId('loading-spinner')).not.toBeNull();
componentProps.item = {
...componentProps.item,
tags: [],
};
rerender(<EffectedPolicySelect {...componentProps} />);
expect(getByTestId('loading-spinner')).not.toBeNull();
});
it('should hide policy link when no policy management privileges', () => {
@ -176,7 +195,8 @@ describe('when using EffectedPolicySelect component', () => {
canReadPolicyManagement: false,
},
});
const { queryByTestId } = render({ isGlobal: false });
componentProps.item.tags = [];
const { queryByTestId } = render();
expect(queryByTestId('test-policyLink')).toBeNull();
});
@ -189,7 +209,8 @@ describe('when using EffectedPolicySelect component', () => {
canReadPolicyManagement: true,
},
});
const { getByTestId } = render({ isGlobal: false });
componentProps.item.tags = [];
const { getByTestId } = render();
expect(getByTestId('test-policyLink'));
});
@ -202,8 +223,57 @@ describe('when using EffectedPolicySelect component', () => {
canReadPolicyManagement: true,
},
});
const { getByTestId } = render({ isGlobal: false });
componentProps.item.tags = [];
const { getByTestId } = render();
expect(getByTestId('test-policyLink'));
});
describe('and space awareness is enabled', () => {
let httpMocks: ReturnType<typeof fleetBulkGetPackagePoliciesListHttpMock>;
beforeEach(() => {
mockedContext.setExperimentalFlag({ endpointManagementSpaceAwarenessEnabled: true });
componentProps.item.tags = [buildPerPolicyTag('321')];
httpMocks = fleetBulkGetPackagePoliciesListHttpMock(mockedContext.coreStart.http);
httpMocks.responseProvider.bulkPackagePolicies.mockReturnValue({
items: [new FleetPackagePolicyGenerator('seed').generate({ id: 'abc123' })],
});
});
it('should display count of policies assigned to artifact that are not accessible in active space', async () => {
const { getByTestId } = render();
await waitFor(() => {
expect(getByTestId('test-unAccessiblePoliciesCallout').textContent).toEqual(
ARTIFACT_POLICIES_NOT_ACCESSIBLE_IN_ACTIVE_SPACE_MESSAGE(1)
);
});
});
it('should disable global button if user has no global artifact privilege', async () => {
(useUserPrivileges as jest.Mock).mockReturnValue({
...initialUserPrivilegesState(),
endpointPrivileges: {
loading: false,
canManageGroupPolicies: false,
},
});
const { getByTestId } = render();
expect((getByTestId('test-global') as HTMLButtonElement).disabled).toBe(true);
});
it('should preserve assignment to policies not currently accessible in active space', async () => {
const { getByTestId } = render();
await waitFor(() => {
expect(getByTestId('test-unAccessiblePoliciesCallout'));
});
clickOnPolicy();
expect(handleOnChange).toHaveBeenLastCalledWith(
expect.objectContaining({ tags: ['policy:321', 'policy:abc123'] })
);
});
});
});
});

View file

@ -5,9 +5,10 @@
* 2.0.
*/
import React, { memo, useCallback, useMemo } from 'react';
import React, { memo, useCallback, useMemo, useState } from 'react';
import type { EuiButtonGroupOptionProps, EuiSelectableProps } from '@elastic/eui';
import {
EuiCallOut,
EuiButtonGroup,
EuiCheckbox,
EuiFlexGroup,
@ -22,6 +23,18 @@ import { i18n } from '@kbn/i18n';
import type { EuiSelectableOption } from '@elastic/eui/src/components/selectable/selectable_option';
import { FormattedMessage } from '@kbn/i18n-react';
import styled from '@emotion/styled';
import type {
CreateExceptionListItemSchema,
ExceptionListItemSchema,
} from '@kbn/securitysolution-io-ts-list-types';
import { useArtifactRestrictedPolicyAssignments } from '../../hooks/artifacts/use_artifact_restricted_policy_assignments';
import { useGetUpdatedTags } from '../../hooks/artifacts';
import { useLicense } from '../../../common/hooks/use_license';
import {
ARTIFACT_POLICIES_NOT_ACCESSIBLE_IN_ACTIVE_SPACE_MESSAGE,
NO_PRIVILEGE_FOR_MANAGEMENT_OF_GLOBAL_ARTIFACT_MESSAGE,
} from '../../common/translations';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import { useUserPrivileges } from '../../../common/components/user_privileges';
import type { PolicyData } from '../../../../common/endpoint/types';
import { LinkToApp } from '../../../common/components/endpoint/link_to_app';
@ -29,6 +42,12 @@ import { getPolicyDetailPath } from '../../common/routing';
import { useTestIdGenerator } from '../../hooks/use_test_id_generator';
import { useAppUrl } from '../../../common/lib/kibana/hooks';
import { Loader } from '../../../common/components/loader';
import {
getPolicyIdsFromArtifact,
GLOBAL_ARTIFACT_TAG,
isArtifactGlobal,
} from '../../../../common/endpoint/service/artifacts';
import { buildPerPolicyTag } from '../../../../common/endpoint/service/artifacts/utils';
const NOOP = () => {};
const DEFAULT_LIST_PROPS: EuiSelectableProps['listProps'] = { bordered: true, showIcons: false };
@ -82,57 +101,76 @@ export type EffectedPolicySelectProps = Omit<
EuiSelectableProps<OptionPolicyData>,
'onChange' | 'options' | 'children' | 'searchable'
> & {
item: ExceptionListItemSchema | CreateExceptionListItemSchema;
options: PolicyData[];
isGlobal: boolean;
isPlatinumPlus: boolean;
description?: string;
onChange: (selection: EffectedPolicySelection) => void;
selected?: PolicyData[];
onChange: (updatedItem: ExceptionListItemSchema | CreateExceptionListItemSchema) => void;
disabled?: boolean;
};
export const EffectedPolicySelect = memo<EffectedPolicySelectProps>(
({
isGlobal,
isPlatinumPlus,
item,
description,
isLoading = false,
onChange,
listProps,
options,
selected = [],
disabled = false,
'data-test-subj': dataTestSubj,
...otherSelectableProps
}) => {
const { getAppUrl } = useAppUrl();
const { canReadPolicyManagement } = useUserPrivileges().endpointPrivileges;
const getTestId = useTestIdGenerator(dataTestSubj);
const isPlatinumPlus = useLicense().isPlatinumPlus();
const isSpaceAwarenessEnabled = useIsExperimentalFeatureEnabled(
'endpointManagementSpaceAwarenessEnabled'
);
const canManageGlobalArtifacts =
useUserPrivileges().endpointPrivileges.canManageGlobalArtifacts;
const { getTagsUpdatedBy } = useGetUpdatedTags(item);
const artifactRestrictedPolicyIds = useArtifactRestrictedPolicyAssignments(item);
const [selectedPolicyIds, setSelectedPolicyIds] = useState(getPolicyIdsFromArtifact(item));
const toggleGlobal: EuiButtonGroupOptionProps[] = useMemo(
() => [
const isGlobal = useMemo(() => isArtifactGlobal(item), [item]);
const selectedAssignmentType = useMemo(() => {
if (isSpaceAwarenessEnabled) {
return canManageGlobalArtifacts && isGlobal ? 'globalPolicy' : 'perPolicy';
}
return isGlobal ? 'globalPolicy' : 'perPolicy';
}, [canManageGlobalArtifacts, isGlobal, isSpaceAwarenessEnabled]);
const toggleGlobal: EuiButtonGroupOptionProps[] = useMemo(() => {
const isGlobalButtonDisabled = !isSpaceAwarenessEnabled ? false : !canManageGlobalArtifacts;
return [
{
id: 'globalPolicy',
label: i18n.translate('xpack.securitySolution.endpoint.effectedPolicySelect.global', {
defaultMessage: 'Global',
}),
iconType: isGlobal ? 'checkInCircleFilled' : 'empty',
iconType: selectedAssignmentType === 'globalPolicy' ? 'checkInCircleFilled' : 'empty',
'data-test-subj': getTestId('global'),
isDisabled: isGlobalButtonDisabled,
toolTipContent: isGlobalButtonDisabled
? NO_PRIVILEGE_FOR_MANAGEMENT_OF_GLOBAL_ARTIFACT_MESSAGE
: undefined,
},
{
id: 'perPolicy',
label: i18n.translate('xpack.securitySolution.endpoint.effectedPolicySelect.perPolicy', {
defaultMessage: 'Per Policy',
}),
iconType: !isGlobal ? 'checkInCircleFilled' : 'empty',
iconType: selectedAssignmentType === 'perPolicy' ? 'checkInCircleFilled' : 'empty',
'data-test-subj': getTestId('perPolicy'),
},
],
[getTestId, isGlobal]
);
];
}, [canManageGlobalArtifacts, getTestId, isSpaceAwarenessEnabled, selectedAssignmentType]);
const selectableOptions: EffectedPolicyOption[] = useMemo(() => {
const isPolicySelected = new Set<string>(selected.map((policy) => policy.id));
const isPolicySelected = new Set<string>(selectedPolicyIds);
return options
.map<EffectedPolicyOption>((policy) => ({
@ -174,29 +212,48 @@ export const EffectedPolicySelect = memo<EffectedPolicySelectProps>(
isGlobal,
isPlatinumPlus,
options,
selected,
selectedPolicyIds,
]);
const handleOnPolicySelectChange = useCallback<
Required<EuiSelectableProps<OptionPolicyData>>['onChange']
>(
(currentOptions) => {
const newPolicyAssignmentTags: string[] =
artifactRestrictedPolicyIds.policyIds.map(buildPerPolicyTag);
const newPolicyIds: string[] = [];
for (const opt of currentOptions) {
if (opt.checked) {
newPolicyIds.push(opt.policy.id);
newPolicyAssignmentTags.push(buildPerPolicyTag(opt.policy.id));
}
}
setSelectedPolicyIds(newPolicyIds);
onChange({
isGlobal,
selected: currentOptions.filter((opt) => opt.checked).map((opt) => opt.policy),
...item,
tags: getTagsUpdatedBy('policySelection', newPolicyAssignmentTags),
});
},
[isGlobal, onChange]
[artifactRestrictedPolicyIds.policyIds, getTagsUpdatedBy, item, onChange]
);
const handleGlobalButtonChange = useCallback(
(selectedId: string) => {
onChange({
isGlobal: selectedId === 'globalPolicy',
selected,
...item,
tags: getTagsUpdatedBy(
'policySelection',
selectedId === 'globalPolicy'
? [GLOBAL_ARTIFACT_TAG]
: selectedPolicyIds
.concat(artifactRestrictedPolicyIds.policyIds)
.map(buildPerPolicyTag)
),
});
},
[onChange, selected]
[artifactRestrictedPolicyIds.policyIds, getTagsUpdatedBy, item, onChange, selectedPolicyIds]
);
const listBuilderCallback = useCallback<NonNullable<EuiSelectableProps['children']>>(
@ -243,7 +300,7 @@ export const EffectedPolicySelect = memo<EffectedPolicySelectProps>(
<StyledButtonGroup
legend="Global Policy Toggle"
options={toggleGlobal}
idSelected={isGlobal ? 'globalPolicy' : 'perPolicy'}
idSelected={selectedAssignmentType}
onChange={handleGlobalButtonChange}
color="primary"
data-test-subj={getTestId('byPolicyGlobalButtonGroup')}
@ -253,7 +310,8 @@ export const EffectedPolicySelect = memo<EffectedPolicySelectProps>(
</StyledEuiFlexItemButtonGroup>
</EuiFlexGroup>
<EuiSpacer />
{!isGlobal &&
{selectedAssignmentType === 'perPolicy' &&
(isLoading ? (
<Loader size="l" data-test-subj={getTestId('policiesLoader')} />
) : (
@ -273,9 +331,19 @@ export const EffectedPolicySelect = memo<EffectedPolicySelectProps>(
</StyledEuiSelectable>
</EuiFormRow>
))}
{artifactRestrictedPolicyIds.policyIds.length > 0 && !isGlobal && (
<>
<EuiSpacer />
<EuiCallOut size="s" data-test-subj={getTestId('unAccessiblePoliciesCallout')}>
{ARTIFACT_POLICIES_NOT_ACCESSIBLE_IN_ACTIVE_SPACE_MESSAGE(
artifactRestrictedPolicyIds.policyIds.length
)}
</EuiCallOut>
</>
)}
</EffectivePolicyFormContainer>
);
}
);
EffectedPolicySelect.displayName = 'EffectedPolicySelect';
EffectedPolicySelect.displayName = 'EffectedPolicySelectNew';

View file

@ -0,0 +1,86 @@
/*
* 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 { ArtifactFormComponentProps } from '../../components/artifact_list_page';
import type { AppContextTestRender } from '../../../common/mock/endpoint';
import { createAppRootMockRenderer } from '../../../common/mock/endpoint';
import { ExceptionsListItemGenerator } from '../../../../common/endpoint/data_generators/exceptions_list_item_generator';
import { buildPerPolicyTag } from '../../../../common/endpoint/service/artifacts/utils';
import type { ArtifactRestrictedPolicyAssignments } from './use_artifact_restricted_policy_assignments';
import { useArtifactRestrictedPolicyAssignments } from './use_artifact_restricted_policy_assignments';
import { fleetBulkGetPackagePoliciesListHttpMock } from '../../mocks';
import { FleetPackagePolicyGenerator } from '../../../../common/endpoint/data_generators/fleet_package_policy_generator';
import { waitFor } from '@testing-library/dom';
import { packagePolicyRouteService } from '@kbn/fleet-plugin/common';
import type { RenderHookResult } from '@testing-library/react';
describe('useArtifactRestrictedPolicyAssignments()', () => {
let testContext: AppContextTestRender;
let item: ArtifactFormComponentProps['item'];
let renderHook: () => RenderHookResult<ArtifactRestrictedPolicyAssignments, unknown>;
beforeEach(() => {
testContext = createAppRootMockRenderer();
renderHook = () => {
return testContext.renderHook(() => {
return useArtifactRestrictedPolicyAssignments(item);
});
};
testContext.setExperimentalFlag({ endpointManagementSpaceAwarenessEnabled: true });
item = new ExceptionsListItemGenerator('seed').generateTrustedApp({
tags: [buildPerPolicyTag('1'), buildPerPolicyTag('2'), buildPerPolicyTag('3')],
});
const fleetApiMocks = fleetBulkGetPackagePoliciesListHttpMock(testContext.coreStart.http);
const fleetPackagePolicyGenerator = new FleetPackagePolicyGenerator('seed');
fleetApiMocks.responseProvider.bulkPackagePolicies.mockReturnValue({
items: [
fleetPackagePolicyGenerator.generate({ id: '1' }),
fleetPackagePolicyGenerator.generate({ id: '2' }),
],
});
});
it('should return empty array when feature flag is disabled', () => {
testContext.setExperimentalFlag({ endpointManagementSpaceAwarenessEnabled: false });
const { result } = renderHook();
expect(result.current).toEqual({ isLoading: false, policyIds: [] });
});
it('should set loading property to true while fetching policies', async () => {
const { result } = renderHook();
expect(result.current).toEqual({ isLoading: true, policyIds: [] });
});
it('should call fleet api with list of policy ids', async () => {
renderHook();
await waitFor(() => {
expect(testContext.coreStart.http.post).toHaveBeenCalledWith(
packagePolicyRouteService.getBulkGetPath(),
expect.objectContaining({
body: JSON.stringify({ ids: ['1', '2', '3'], ignoreMissing: true }),
})
);
});
});
it('should return list of policies that were not found in fleet', async () => {
const { result } = renderHook();
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.policyIds).toEqual(['3']);
});
});

View file

@ -0,0 +1,70 @@
/*
* 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 { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
import { useEffect, useMemo, useState } from 'react';
import { keyBy } from 'lodash';
import { useBulkFetchFleetIntegrationPolicies } from '../policy/use_bulk_fetch_fleet_integration_policies';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import { getPolicyIdsFromArtifact } from '../../../../common/endpoint/service/artifacts';
export interface ArtifactRestrictedPolicyAssignments {
isLoading: boolean;
/** The list of policy IDs assigned to the artifact that are NOT currently accessible in active space */
policyIds: string[];
}
/**
* Given an artifact item, hook will calculate if any of the policies assigned to it
* are restricted (not accessible by the current user) in the active space.
*
* NOTE: this hook's logic is executed at most once per artifact
*
* @param item
*/
export const useArtifactRestrictedPolicyAssignments = (
item: Partial<Pick<ExceptionListItemSchema, 'tags' | 'item_id'>>
): ArtifactRestrictedPolicyAssignments => {
const isSpaceAwarenessEnabled = useIsExperimentalFeatureEnabled(
'endpointManagementSpaceAwarenessEnabled'
);
const [{ itemId, policies }, setOriginalItem] = useState<{ itemId: string; policies: string[] }>({
itemId: item.item_id ?? '',
policies: getPolicyIdsFromArtifact(item),
});
const { data, isFetching } = useBulkFetchFleetIntegrationPolicies(
{ ids: policies },
{ enabled: isSpaceAwarenessEnabled && policies.length > 0 }
);
const restrictedPolicyIds = useMemo(() => {
if (!isSpaceAwarenessEnabled || !data?.items) {
return [];
}
const policiesFoundById = keyBy(data.items, 'id');
return policies.filter((id) => !policiesFoundById[id]);
}, [data?.items, isSpaceAwarenessEnabled, policies]);
useEffect(() => {
if (item.item_id !== itemId) {
setOriginalItem({
itemId: item.item_id ?? '',
policies: getPolicyIdsFromArtifact(item),
});
}
}, [item, itemId]);
return useMemo(() => {
return {
isLoading: isSpaceAwarenessEnabled && policies.length > 0 ? isFetching : false,
policyIds: restrictedPolicyIds,
};
}, [isFetching, isSpaceAwarenessEnabled, policies.length, restrictedPolicyIds]);
};

View file

@ -0,0 +1,81 @@
/*
* 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 { ArtifactFormComponentProps } from '../../components/artifact_list_page';
import { ExceptionsListItemGenerator } from '../../../../common/endpoint/data_generators/exceptions_list_item_generator';
import { useLicense } from '../../../common/hooks/use_license';
import type { LicenseService } from '../../../../common/license';
import { buildPerPolicyTag } from '../../../../common/endpoint/service/artifacts/utils';
import {
createAppRootMockRenderer,
type AppContextTestRender,
} from '../../../common/mock/endpoint';
import { useCanAssignArtifactPerPolicy } from './use_can_assign_artifact_per_policy';
import { GLOBAL_ARTIFACT_TAG } from '../../../../common/endpoint/service/artifacts';
jest.mock('../../../common/hooks/use_license');
const useLicenseMock = useLicense as jest.Mock<jest.Mocked<LicenseService>>;
describe('useCanAssignArtifactPerPolicy()', () => {
let item: ArtifactFormComponentProps['item'];
let mode: ArtifactFormComponentProps['mode'];
let hasItemBeenUpdated: boolean;
let renderHook: () => ReturnType<AppContextTestRender['renderHook']>;
beforeEach(() => {
const testContext = createAppRootMockRenderer();
renderHook = () => {
return testContext.renderHook(() => {
return useCanAssignArtifactPerPolicy(item, mode, hasItemBeenUpdated);
});
};
item = new ExceptionsListItemGenerator('seed').generateTrustedApp({
tags: [buildPerPolicyTag('123')],
});
mode = 'edit';
hasItemBeenUpdated = false;
useLicenseMock().isPlatinumPlus.mockReturnValue(false);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should return `true` when license is platinum plus', () => {
mode = 'create';
useLicenseMock().isPlatinumPlus.mockReturnValue(true);
const { result } = renderHook();
expect(result.current).toEqual(true);
});
it('should return `false` when license is not platinum and artifact is global', () => {
mode = 'create';
useLicenseMock().isPlatinumPlus.mockReturnValue(false);
const { result } = renderHook();
expect(result.current).toEqual(false);
});
it('should return `true` when license is not platinum but artifact is currently per-policy', () => {
const { result } = renderHook();
expect(result.current).toEqual(true);
});
it('should return `true` when license is not platinum and per-policy item was updated to global', () => {
const { result, rerender } = renderHook();
item.tags = [GLOBAL_ARTIFACT_TAG];
hasItemBeenUpdated = true;
rerender();
expect(result.current).toEqual(true);
});
});

View file

@ -0,0 +1,54 @@
/*
* 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 { useEffect, useMemo, useState } from 'react';
import { useLicense } from '../../../common/hooks/use_license';
import {
isArtifactByPolicy,
isArtifactGlobal,
} from '../../../../common/endpoint/service/artifacts';
import type { ArtifactFormComponentProps } from '../../components/artifact_list_page';
/**
* Calculates if by-policy assignments can be made to an artifact.
*
* Per-Policy assignment is a Platinum+ licensed feature only, but the component can
* be displayed in down-grade conditions: meaning - when user downgrades the license,
* we will still allow the component to be displayed in the UI so that user has the
* ability to set the artifact to `global`.
*/
export const useCanAssignArtifactPerPolicy = (
item: ArtifactFormComponentProps['item'],
mode: ArtifactFormComponentProps['mode'],
hasItemBeenUpdated: boolean
): boolean => {
const isPlatinumPlus = useLicense().isPlatinumPlus();
const isGlobal = useMemo(() => isArtifactGlobal(item), [item]);
const [wasByPolicy, setWasByPolicy] = useState(isArtifactByPolicy(item));
useEffect(() => {
if (!hasItemBeenUpdated && item.tags) {
setWasByPolicy(!isArtifactGlobal({ tags: item.tags }));
}
}, [item.tags, hasItemBeenUpdated]);
return useMemo(() => {
if (isPlatinumPlus) {
return true;
}
if (mode !== 'edit') {
return false;
}
if (!isGlobal) {
return true;
}
return wasByPolicy && hasItemBeenUpdated;
}, [mode, isGlobal, hasItemBeenUpdated, isPlatinumPlus, wasByPolicy]);
};

View file

@ -0,0 +1,89 @@
/*
* 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 { useQuery as _useQuery } from '@tanstack/react-query';
import { useBulkFetchFleetIntegrationPolicies } from './use_bulk_fetch_fleet_integration_policies';
import type { AppContextTestRender, ReactQueryHookRenderer } from '../../../common/mock/endpoint';
import { createAppRootMockRenderer } from '../../../common/mock/endpoint';
import { allFleetHttpMocks } from '../../mocks';
import type { BulkGetPackagePoliciesRequestBody } from '@kbn/fleet-plugin/common/types';
import { packagePolicyRouteService } from '@kbn/fleet-plugin/common';
import type { Mutable } from 'utility-types';
const useQueryMock = _useQuery as jest.Mock;
jest.mock('@tanstack/react-query', () => {
const actualReactQueryModule = jest.requireActual('@tanstack/react-query');
return {
...actualReactQueryModule,
useQuery: jest.fn((...args) => actualReactQueryModule.useQuery(...args)),
};
});
describe('useBulkFetchFleetIntegrationPolicies()', () => {
type HookRenderer = ReactQueryHookRenderer<
Parameters<typeof useBulkFetchFleetIntegrationPolicies>,
ReturnType<typeof useBulkFetchFleetIntegrationPolicies>
>;
let reqOptions: Mutable<BulkGetPackagePoliciesRequestBody>;
let queryOptions: NonNullable<Parameters<typeof useBulkFetchFleetIntegrationPolicies>[1]>;
let http: AppContextTestRender['coreStart']['http'];
let renderHook: () => ReturnType<HookRenderer>;
beforeEach(() => {
const testContext = createAppRootMockRenderer();
reqOptions = { ids: ['1'] };
queryOptions = {};
http = testContext.coreStart.http;
allFleetHttpMocks(http);
renderHook = () => {
return (testContext.renderReactQueryHook as HookRenderer)(() =>
useBulkFetchFleetIntegrationPolicies(reqOptions, queryOptions)
);
};
});
afterEach(() => {
useQueryMock.mockClear();
});
it('should call the correct fleet api with the query data provided', async () => {
const { data } = await renderHook();
expect(http.post).toHaveBeenCalledWith(
packagePolicyRouteService.getBulkGetPath(),
expect.objectContaining({
body: JSON.stringify({ ...reqOptions, ignoreMissing: true }),
})
);
expect(data).toEqual(expect.objectContaining({ items: expect.any(Array) }));
});
it('should allow use of `ignoreMissing` request property', async () => {
reqOptions.ignoreMissing = false;
await renderHook();
expect(http.post).toHaveBeenCalledWith(
packagePolicyRouteService.getBulkGetPath(),
expect.objectContaining({
body: JSON.stringify(reqOptions),
})
);
});
it('should allow useQuery options overrides', async () => {
queryOptions.queryKey = ['a', 'b'];
queryOptions.retry = false;
queryOptions.refetchInterval = 5;
await renderHook();
expect(useQueryMock).toHaveBeenCalledWith(expect.objectContaining(queryOptions));
});
});

View file

@ -0,0 +1,39 @@
/*
* 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 { QueryObserverResult } from '@tanstack/react-query';
import { useQuery, type UseQueryOptions } from '@tanstack/react-query';
import type { BulkGetPackagePoliciesResponse } from '@kbn/fleet-plugin/common';
import { packagePolicyRouteService } from '@kbn/fleet-plugin/common';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import type { BulkGetPackagePoliciesRequestBody } from '@kbn/fleet-plugin/common/types';
import { useHttp } from '../../../common/lib/kibana';
/**
* Retrieve multiple integration policies (aka: package policies) from fleet using their IDs
* @param ids
* @param ignoreMissing
* @param options
*/
export const useBulkFetchFleetIntegrationPolicies = (
{ ids, ignoreMissing = true }: BulkGetPackagePoliciesRequestBody,
options: UseQueryOptions<BulkGetPackagePoliciesResponse, IHttpFetchError> = {}
): QueryObserverResult<BulkGetPackagePoliciesResponse> => {
const http = useHttp();
return useQuery<BulkGetPackagePoliciesResponse, IHttpFetchError>({
queryKey: ['bulkFetchFleetIntegrationPolicies', ids, ignoreMissing],
refetchOnWindowFocus: false,
...options,
queryFn: async () => {
return http.post(packagePolicyRouteService.getBulkGetPath(), {
body: JSON.stringify({ ids, ignoreMissing }),
version: '2023-10-31',
});
},
});
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useMemo, useState, useCallback, memo, useEffect, useRef } from 'react';
import React, { useMemo, useState, useCallback, memo, useRef } from 'react';
import type { EuiSuperSelectOption, EuiComboBoxOptionOption } from '@elastic/eui';
import {
EuiForm,
@ -29,6 +29,7 @@ import { isOneOfOperator, isOperator } from '@kbn/securitysolution-list-utils';
import { uniq } from 'lodash';
import { ListOperatorEnum, ListOperatorTypeEnum } from '@kbn/securitysolution-io-ts-list-types';
import { useCanAssignArtifactPerPolicy } from '../../../../hooks/artifacts/use_can_assign_artifact_per_policy';
import { FormattedError } from '../../../../components/formatted_error';
import { OS_TITLES } from '../../../../common/translations';
import type {
@ -53,17 +54,10 @@ import {
VALUE_LABEL_HELPER,
SINGLE_VALUE_LABEL_HELPER,
} from '../../translations';
import type { EffectedPolicySelection } from '../../../../components/effected_policy_select';
import type { EffectedPolicySelectProps } from '../../../../components/effected_policy_select';
import { EffectedPolicySelect } from '../../../../components/effected_policy_select';
import { useLicense } from '../../../../../common/hooks/use_license';
import { isValidHash } from '../../../../../../common/endpoint/service/artifacts/validations';
import {
getArtifactTagsByPolicySelection,
isArtifactGlobal,
} from '../../../../../../common/endpoint/service/artifacts';
import type { PolicyData } from '../../../../../../common/endpoint/types';
import { useTestIdGenerator } from '../../../../hooks/use_test_id_generator';
import { useGetUpdatedTags } from '../../../../hooks/artifacts';
const testIdPrefix = 'blocklist-form';
@ -120,37 +114,8 @@ export const BlockListForm = memo<ArtifactFormComponentProps>(
const [valueVisited, setValueVisited] = useState({ value: false }); // Use object to trigger re-render
const warningsRef = useRef<ItemValidation>({ name: {}, value: {} });
const errorsRef = useRef<ItemValidation>({ name: {}, value: {} });
const [selectedPolicies, setSelectedPolicies] = useState<PolicyData[]>([]);
const isPlatinumPlus = useLicense().isPlatinumPlus();
const isGlobal = useMemo(() => isArtifactGlobal(item), [item]);
const [wasByPolicy, setWasByPolicy] = useState(!isArtifactGlobal(item));
const [hasFormChanged, setHasFormChanged] = useState(false);
const { getTagsUpdatedBy } = useGetUpdatedTags(item);
const showAssignmentSection = useMemo(() => {
return (
isPlatinumPlus ||
(mode === 'edit' && (!isGlobal || (wasByPolicy && isGlobal && hasFormChanged)))
);
}, [mode, isGlobal, hasFormChanged, isPlatinumPlus, wasByPolicy]);
// set initial state of `wasByPolicy` that checks if the initial state of the exception was by policy or not
useEffect(() => {
if (!hasFormChanged && item.tags) {
setWasByPolicy(!isArtifactGlobal({ tags: item.tags }));
}
}, [item.tags, hasFormChanged]);
// select policies if editing
useEffect(() => {
if (hasFormChanged) return;
const policyIds = item.tags?.map((tag) => tag.split(':')[1]) ?? [];
if (!policyIds.length) return;
const policiesData = policies.filter((policy) => policyIds.includes(policy.id));
setSelectedPolicies(policiesData);
}, [hasFormChanged, item.tags, policies]);
const showAssignmentSection = useCanAssignArtifactPerPolicy(item, mode, hasFormChanged);
const getTestId = useTestIdGenerator(testIdPrefix);
const blocklistEntry = useMemo((): BlocklistEntry => {
@ -547,23 +512,16 @@ export const BlockListForm = memo<ArtifactFormComponentProps>(
[validateValues, onChange, item, blocklistEntry]
);
const handleOnPolicyChange = useCallback(
(change: EffectedPolicySelection) => {
const tags = getTagsUpdatedBy('policySelection', getArtifactTagsByPolicySelection(change));
const nextItem = { ...item, tags };
// Preserve old selected policies when switching to global
if (!change.isGlobal) {
setSelectedPolicies(change.selected);
}
validateValues(nextItem);
const handleEffectedPolicyOnChange: EffectedPolicySelectProps['onChange'] = useCallback(
(updatedItem) => {
validateValues(updatedItem);
onChange({
isValid: isValid(errorsRef.current),
item: nextItem,
item: updatedItem,
});
setHasFormChanged(true);
},
[getTagsUpdatedBy, item, validateValues, onChange]
[onChange, validateValues]
);
return (
@ -710,11 +668,9 @@ export const BlockListForm = memo<ArtifactFormComponentProps>(
<EuiHorizontalRule />
<EuiFormRow fullWidth>
<EffectedPolicySelect
isGlobal={isGlobal}
isPlatinumPlus={isPlatinumPlus}
selected={selectedPolicies}
item={item}
options={policies}
onChange={handleOnPolicyChange}
onChange={handleEffectedPolicyOnChange}
isLoading={policiesIsLoading}
description={POLICY_SELECT_DESCRIPTION}
data-test-subj={getTestId('effectedPolicies')}

View file

@ -41,6 +41,7 @@ import { OperatingSystem } from '@kbn/securitysolution-utils';
import { getExceptionBuilderComponentLazy } from '@kbn/lists-plugin/public';
import type { OnChangeProps } from '@kbn/lists-plugin/public';
import type { ValueSuggestionsGetFn } from '@kbn/unified-search-plugin/public/autocomplete/providers/value_suggestion_provider';
import { useCanAssignArtifactPerPolicy } from '../../../../hooks/artifacts/use_can_assign_artifact_per_policy';
import { FormattedError } from '../../../../components/formatted_error';
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
import { useGetUpdatedTags } from '../../../../hooks/artifacts';
@ -56,17 +57,10 @@ import {
} from '../../../../../../common/endpoint/constants';
import { useSuggestions } from '../../../../hooks/use_suggestions';
import { useTestIdGenerator } from '../../../../hooks/use_test_id_generator';
import type { PolicyData } from '../../../../../../common/endpoint/types';
import { useFetchIndex } from '../../../../../common/containers/source';
import { Loader } from '../../../../../common/components/loader';
import { useLicense } from '../../../../../common/hooks/use_license';
import { useKibana } from '../../../../../common/lib/kibana';
import type { ArtifactFormComponentProps } from '../../../../components/artifact_list_page';
import {
isArtifactGlobal,
getPolicyIdsFromArtifact,
getArtifactTagsByPolicySelection,
} from '../../../../../../common/endpoint/service/artifacts';
import {
ABOUT_EVENT_FILTERS,
@ -79,7 +73,7 @@ import {
import { OS_TITLES, CONFIRM_WARNING_MODAL_LABELS } from '../../../../common/translations';
import { ENDPOINT_EVENT_FILTERS_LIST_ID, EVENT_FILTER_LIST_TYPE } from '../../constants';
import type { EffectedPolicySelection } from '../../../../components/effected_policy_select';
import type { EffectedPolicySelectProps } from '../../../../components/effected_policy_select';
import { EffectedPolicySelect } from '../../../../components/effected_policy_select';
import { ExceptionItemComments } from '../../../../../detection_engine/rule_exceptions/components/item_comments';
import { EventFiltersApiClient } from '../../service/api_client';
@ -166,10 +160,6 @@ export const EventFiltersForm: React.FC<ArtifactFormComponentProps & { allowSele
const [newComment, setNewComment] = useState('');
const [hasCommentError, setHasCommentError] = useState(false);
const [hasBeenInputNameVisited, setHasBeenInputNameVisited] = useState(false);
const [selectedPolicies, setSelectedPolicies] = useState<PolicyData[]>([]);
const isPlatinumPlus = useLicense().isPlatinumPlus();
const isGlobal = useMemo(() => isArtifactGlobal(exception), [exception]);
const [wasByPolicy, setWasByPolicy] = useState(!isArtifactGlobal(exception));
const [hasDuplicateFields, setHasDuplicateFields] = useState<boolean>(false);
const [hasWildcardWithWrongOperator, setHasWildcardWithWrongOperator] = useState<boolean>(
hasWrongOperatorWithWildcard([exception])
@ -205,12 +195,7 @@ export const EventFiltersForm: React.FC<ArtifactFormComponentProps & { allowSele
[]
);
const showAssignmentSection = useMemo(() => {
return (
isPlatinumPlus ||
(mode === 'edit' && (!isGlobal || (wasByPolicy && isGlobal && hasFormChanged)))
);
}, [mode, isGlobal, hasFormChanged, isPlatinumPlus, wasByPolicy]);
const showAssignmentSection = useCanAssignArtifactPerPolicy(exception, mode, hasFormChanged);
const isFormValid = useMemo(() => {
// verify that it has legit entries
@ -259,24 +244,6 @@ export const EventFiltersForm: React.FC<ArtifactFormComponentProps & { allowSele
]
);
// set initial state of `wasByPolicy` that checks
// if the initial state of the exception was by policy or not
useEffect(() => {
if (!hasFormChanged && exception.tags) {
setWasByPolicy(!isArtifactGlobal({ tags: exception.tags }));
}
}, [exception.tags, hasFormChanged]);
// select policies if editing
useEffect(() => {
if (hasFormChanged) return;
const policyIds = exception.tags ? getPolicyIdsFromArtifact({ tags: exception.tags }) : [];
if (!policyIds.length) return;
const policiesData = policies.filter((policy) => policyIds.includes(policy.id));
setSelectedPolicies(policiesData);
}, [hasFormChanged, exception, policies]);
const eventFilterItem = useMemo<ArtifactFormComponentProps['item']>(() => {
const ef: ArtifactFormComponentProps['item'] = exception;
ef.entries = exception.entries.length
@ -689,44 +656,27 @@ export const EventFiltersForm: React.FC<ArtifactFormComponentProps & { allowSele
[allowSelectOs, exceptionBuilderComponentMemo, osInputMemo, filterTypeSubsection]
);
// policy and handler
const handleOnPolicyChange = useCallback(
(change: EffectedPolicySelection) => {
const policySelectionTags = getArtifactTagsByPolicySelection(change);
// Preserve old selected policies when switching to global
if (!change.isGlobal) {
setSelectedPolicies(change.selected);
const handleEffectedPolicyOnChange: EffectedPolicySelectProps['onChange'] = useCallback(
(updatedItem) => {
processChanged({ tags: updatedItem.tags ?? [] });
if (!hasFormChanged) {
setHasFormChanged(true);
}
const tags = getTagsUpdatedBy('policySelection', policySelectionTags);
processChanged({ tags });
if (!hasFormChanged) setHasFormChanged(true);
},
[getTagsUpdatedBy, processChanged, hasFormChanged]
[hasFormChanged, processChanged]
);
const policiesSection = useMemo(
() => (
<EffectedPolicySelect
selected={selectedPolicies}
item={exception}
options={policies}
isGlobal={isGlobal}
isLoading={policiesIsLoading}
isPlatinumPlus={isPlatinumPlus}
onChange={handleOnPolicyChange}
onChange={handleEffectedPolicyOnChange}
data-test-subj={getTestId('effectedPolicies')}
/>
),
[
selectedPolicies,
policies,
isGlobal,
policiesIsLoading,
isPlatinumPlus,
handleOnPolicyChange,
getTestId,
]
[exception, policies, policiesIsLoading, handleEffectedPolicyOnChange, getTestId]
);
useEffect(() => {

View file

@ -19,16 +19,8 @@ import { FormattedMessage } from '@kbn/i18n-react';
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useTestIdGenerator } from '../../../../hooks/use_test_id_generator';
import { isValidIPv4OrCIDR } from '../../../../../../common/endpoint/utils/is_valid_ip';
import type {
EffectedPolicySelection,
EffectedPolicySelectProps,
} from '../../../../components/effected_policy_select';
import type { EffectedPolicySelectProps } from '../../../../components/effected_policy_select';
import { EffectedPolicySelect } from '../../../../components/effected_policy_select';
import {
getArtifactTagsByPolicySelection,
getEffectedPolicySelectionByTags,
isArtifactGlobal,
} from '../../../../../../common/endpoint/service/artifacts';
import {
DESCRIPTION_LABEL,
DESCRIPTION_PLACEHOLDER,
@ -41,7 +33,6 @@ import {
} from './translations';
import type { ArtifactFormComponentProps } from '../../../../components/artifact_list_page';
import { FormattedError } from '../../../../components/formatted_error';
import { useGetUpdatedTags } from '../../../../hooks/artifacts';
export const testIdPrefix = 'hostIsolationExceptions-form';
@ -68,12 +59,6 @@ export const HostIsolationExceptionsForm = memo<ArtifactFormComponentProps>(
const [hasNameError, setHasNameError] = useState(!exception.name);
const [hasIpError, setHasIpError] = useState(!ipEntry.value);
const getTestId = useTestIdGenerator(testIdPrefix);
const { getTagsUpdatedBy } = useGetUpdatedTags(exception);
const [selectedPolicies, setSelectedPolicies] = useState<EffectedPolicySelection>({
isGlobal: isArtifactGlobal(exception),
selected: [],
});
const isFormContentValid = useMemo(() => {
return !hasNameError && !hasIpError;
@ -129,23 +114,11 @@ export const HostIsolationExceptionsForm = memo<ArtifactFormComponentProps>(
[ipEntry, notifyOfChange]
);
const handlePolicySelectChange: EffectedPolicySelectProps['onChange'] = useCallback(
(selection) => {
// preserve the previous selection between global and not global toggle
if (selection.isGlobal) {
setSelectedPolicies({ isGlobal: true, selected: selection.selected });
} else {
setSelectedPolicies(selection);
}
const tags = getTagsUpdatedBy(
'policySelection',
getArtifactTagsByPolicySelection(selection)
);
notifyOfChange({ tags });
const handleEffectedPolicyOnChange: EffectedPolicySelectProps['onChange'] = useCallback(
(updatedItem) => {
notifyOfChange(updatedItem);
},
[getTagsUpdatedBy, notifyOfChange]
[notifyOfChange]
);
const handleOnDescriptionChange = useCallback(
@ -235,13 +208,6 @@ export const HostIsolationExceptionsForm = memo<ArtifactFormComponentProps>(
[disabled, exception.description, handleOnDescriptionChange]
);
// set current policies if not previously selected
useEffect(() => {
if (selectedPolicies.selected.length === 0 && exception.tags) {
setSelectedPolicies(getEffectedPolicySelectionByTags(exception.tags, policies));
}
}, [exception.tags, policies, selectedPolicies.selected.length]);
// Anytime the `notificyOfChange()` is re-defined, call it with current values.
// This will happen
useEffect(() => {
@ -311,11 +277,9 @@ export const HostIsolationExceptionsForm = memo<ArtifactFormComponentProps>(
isDisabled={disabled}
>
<EffectedPolicySelect
isGlobal={selectedPolicies.isGlobal}
isPlatinumPlus={true}
selected={selectedPolicies.selected}
item={exception}
options={policies}
onChange={handlePolicySelectChange}
onChange={handleEffectedPolicyOnChange}
data-test-subj={getTestId('effectedPolicies')}
disabled={disabled}
/>

View file

@ -20,10 +20,7 @@ import {
exceptionsListAllHttpMocks,
fleetGetEndpointPackagePolicyListHttpMock,
} from '../../../../../mocks';
import {
clickOnEffectedPolicy,
isEffectedPolicySelected,
} from '../../../../../components/effected_policy_select/test_utils';
import { isEffectedPolicySelected } from '../../../../../components/effected_policy_select/test_utils';
import { BY_POLICY_ARTIFACT_TAG_PREFIX } from '../../../../../../../common/endpoint/service/artifacts';
import type { HttpFetchOptionsWithPath, IHttpFetchError } from '@kbn/core/public';
import { testIdPrefix } from '../form';
@ -150,40 +147,6 @@ describe('When on the host isolation exceptions entry form', () => {
.classList.contains('euiButtonGroupButton-isSelected')
).toBe(true);
});
it('should show policy as selected when user clicks on it', async () => {
await userEvent.click(
renderResult.getByTestId('hostIsolationExceptions-form-effectedPolicies-perPolicy')
);
await clickOnEffectedPolicy(renderResult, testIdPrefix);
await expect(isEffectedPolicySelected(renderResult, testIdPrefix)).resolves.toBe(true);
});
it('should retain the previous policy selection when switching from per-policy to global', async () => {
// move to per-policy and select the first
await userEvent.click(
renderResult.getByTestId('hostIsolationExceptions-form-effectedPolicies-perPolicy')
);
await clickOnEffectedPolicy(renderResult, testIdPrefix);
await expect(isEffectedPolicySelected(renderResult, testIdPrefix)).resolves.toBe(true);
// move back to global
await userEvent.click(
renderResult.getByTestId('hostIsolationExceptions-form-effectedPolicies-global')
);
expect(
renderResult.queryByTestId(`${testIdPrefix}-effectedPolicies-policiesSelectable`)
).toBeFalsy();
// move back to per-policy
await userEvent.click(
renderResult.getByTestId('hostIsolationExceptions-form-effectedPolicies-perPolicy')
);
await expect(isEffectedPolicySelected(renderResult, testIdPrefix)).resolves.toBe(true);
});
});
describe('and editing an existing exception with global policy', () => {

View file

@ -6,7 +6,7 @@
*/
import type { ChangeEventHandler } from 'react';
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import React, { memo, useCallback, useMemo, useState } from 'react';
import type { EuiFieldTextProps, EuiSuperSelectOption } from '@elastic/eui';
import {
EuiFieldText,
@ -29,23 +29,17 @@ import {
OperatingSystem,
} from '@kbn/securitysolution-utils';
import { WildCardWithWrongOperatorCallout } from '@kbn/securitysolution-exception-list-components';
import { useGetUpdatedTags } from '../../../../hooks/artifacts';
import { useCanAssignArtifactPerPolicy } from '../../../../hooks/artifacts/use_can_assign_artifact_per_policy';
import { FormattedError } from '../../../../components/formatted_error';
import type {
TrustedAppConditionEntry,
NewTrustedApp,
PolicyData,
} from '../../../../../../common/endpoint/types';
import {
isValidHash,
getDuplicateFields,
} from '../../../../../../common/endpoint/service/artifacts/validations';
import {
isArtifactGlobal,
getPolicyIdsFromArtifact,
getArtifactTagsByPolicySelection,
} from '../../../../../../common/endpoint/service/artifacts';
import { isSignerFieldExcluded } from '../../state/type_guards';
import {
@ -63,8 +57,7 @@ import { OS_TITLES, CONFIRM_WARNING_MODAL_LABELS } from '../../../../common/tran
import type { LogicalConditionBuilderProps } from './logical_condition';
import { LogicalConditionBuilder } from './logical_condition';
import { useTestIdGenerator } from '../../../../hooks/use_test_id_generator';
import { useLicense } from '../../../../../common/hooks/use_license';
import type { EffectedPolicySelection } from '../../../../components/effected_policy_select';
import type { EffectedPolicySelectProps } from '../../../../components/effected_policy_select';
import { EffectedPolicySelect } from '../../../../components/effected_policy_select';
import type { ArtifactFormComponentProps } from '../../../../components/artifact_list_page';
import { TrustedAppsArtifactsDocsLink } from './artifacts_docs_link';
@ -244,41 +237,8 @@ export const TrustedAppsForm = memo<ArtifactFormComponentProps>(
[key in keyof NewTrustedApp]: boolean;
}>
>({});
const [selectedPolicies, setSelectedPolicies] = useState<PolicyData[]>([]);
const isPlatinumPlus = useLicense().isPlatinumPlus();
const isGlobal = useMemo(() => isArtifactGlobal(item), [item]);
const [wasByPolicy, setWasByPolicy] = useState(!isArtifactGlobal(item));
const [hasFormChanged, setHasFormChanged] = useState(false);
const { getTagsUpdatedBy } = useGetUpdatedTags(item);
useEffect(() => {
if (!hasFormChanged && item.tags) {
setWasByPolicy(!isArtifactGlobal({ tags: item.tags }));
}
}, [item.tags, hasFormChanged]);
// select policies if editing
useEffect(() => {
if (hasFormChanged) {
return;
}
const policyIds = item.tags ? getPolicyIdsFromArtifact({ tags: item.tags }) : [];
if (!policyIds.length) {
return;
}
const policiesData = policies.filter((policy) => policyIds.includes(policy.id));
setSelectedPolicies(policiesData);
}, [hasFormChanged, item, policies]);
const showAssignmentSection = useMemo(() => {
return (
isPlatinumPlus ||
(mode === 'edit' && (!isGlobal || (wasByPolicy && isGlobal && hasFormChanged)))
);
}, [mode, isGlobal, hasFormChanged, isPlatinumPlus, wasByPolicy]);
const showAssignmentSection = useCanAssignArtifactPerPolicy(item, mode, hasFormChanged);
const [validationResult, setValidationResult] = useState<ValidationResult>(() =>
validateValues(item)
);
@ -302,19 +262,12 @@ export const TrustedAppsForm = memo<ArtifactFormComponentProps>(
[onChange]
);
const handleOnPolicyChange = useCallback(
(change: EffectedPolicySelection) => {
const tags = getTagsUpdatedBy('policySelection', getArtifactTagsByPolicySelection(change));
const nextItem = { ...item, tags };
// Preserve old selected policies when switching to global
if (!change.isGlobal) {
setSelectedPolicies(change.selected);
}
processChanged(nextItem);
const handleEffectedPolicyOnChange: EffectedPolicySelectProps['onChange'] = useCallback(
(updatedItem) => {
processChanged(updatedItem);
setHasFormChanged(true);
},
[getTagsUpdatedBy, item, processChanged]
[processChanged]
);
const handleOnNameOrDescriptionChange = useCallback<
@ -589,14 +542,12 @@ export const TrustedAppsForm = memo<ArtifactFormComponentProps>(
<EuiHorizontalRule />
<EuiFormRow fullWidth data-test-subj={getTestId('policySelection')}>
<EffectedPolicySelect
isGlobal={isGlobal}
isPlatinumPlus={isPlatinumPlus}
selected={selectedPolicies}
item={item}
options={policies}
onChange={handleOnPolicyChange}
isLoading={policiesIsLoading}
description={POLICY_SELECT_DESCRIPTION}
data-test-subj={getTestId('effectedPolicies')}
isLoading={policiesIsLoading}
onChange={handleEffectedPolicyOnChange}
/>
</EuiFormRow>
</>

View file

@ -29,7 +29,7 @@ import { HOST_ISOLATION_EXCEPTIONS_LIST_DEFINITION } from '../../../public/manag
import type { NewTrustedApp } from '../../../common/endpoint/types';
import { newTrustedAppToCreateExceptionListItem } from '../../../public/management/pages/trusted_apps/service/mappers';
const ensureArtifactListExists = memoize(
export const ensureArtifactListExists = memoize(
async (
kbnClient: KbnClient,
artifactType: keyof typeof ENDPOINT_ARTIFACT_LISTS | 'endpointExceptions'
@ -91,7 +91,7 @@ const ensureArtifactListExists = memoize(
* @param kbnClient
* @param data
*/
const createExceptionListItem = async (
export const createExceptionListItem = async (
kbnClient: KbnClient,
data: CreateExceptionListItemSchema
): Promise<ExceptionListItemSchema> => {

View file

@ -106,6 +106,36 @@ describe('Artifacts: setFindRequestFilterScopeToActiveSpace()', () => {
) AND (somevalue:match-this)`);
});
it('should inject additional filtering when there is no visible policies in active space', async () => {
(
endpointAppContextServices.getInternalFleetServices()
.packagePolicy as jest.Mocked<PackagePolicyClient>
).listIds.mockResolvedValue({
items: [],
total: 0,
page: 1,
perPage: 20,
});
await setFindRequestFilterScopeToActiveSpace(
endpointAppContextServices,
kibanaRequest,
findOptionsMock
);
expect(findOptionsMock.filter).toEqual(`
(
(
exception-list-agnostic.attributes.tags:("policy:all")
)
OR
(
NOT exception-list-agnostic.attributes.tags:"policy:*"
AND
exception-list-agnostic.attributes.tags:"ownerSpaceId:default"
)
)`);
});
it('should inject additional filtering when using multi-list search format', async () => {
findOptionsMock.listId = [
ENDPOINT_ARTIFACT_LISTS.trustedApps.id,

View file

@ -70,7 +70,8 @@ export const setFindRequestFilterScopeToActiveSpace = async (
: ` OR ${allEndpointPolicyIds
.map((policyId) => `"${buildPerPolicyTag(policyId)}"`)
.join(' OR ')}
)
)`
}
)
OR
(
@ -78,8 +79,7 @@ export const setFindRequestFilterScopeToActiveSpace = async (
AND
exception-list-agnostic.attributes.tags:"${buildSpaceOwnerIdTag(spaceId)}"
)
)`
}`;
)`;
if (isSingleListFindOptions(findOptions)) {
findOptions.filter = `${spaceVisibleDataFilter}${

View file

@ -241,6 +241,24 @@ describe('When using Artifacts Exceptions BaseValidator', () => {
]);
});
describe('#validateByPolicyItem()', () => {
let currentItem: ExceptionListItemSchema;
beforeEach(() => {
currentItem = createExceptionListItemMock({
tags: exceptionLikeItem.tags,
});
});
it('should not error if policy is not returned by fleet for active space, but it is already associated with item', async () => {
packagePolicyService.getByIDs.mockResolvedValue([]);
await expect(
initValidator()._validateByPolicyItem(exceptionLikeItem, currentItem)
).resolves.toBeUndefined();
});
});
describe('#validateCreateOnwerSpaceIds()', () => {
it('should error if adding an spaceOwnerId but has no global artifact management authz', async () => {
setArtifactOwnerSpaceId(exceptionLikeItem, 'foo');

View file

@ -14,6 +14,7 @@ import { OperatingSystem } from '@kbn/securitysolution-utils';
import { i18n } from '@kbn/i18n';
import {} from '@kbn/lists-plugin/server/services/exception_lists/exception_list_client_types';
import { groupBy } from 'lodash';
import type { PackagePolicy } from '@kbn/fleet-plugin/common';
import { stringify } from '../../../endpoint/utils/stringify';
import { ENDPOINT_AUTHZ_ERROR_MESSAGE } from '../../../endpoint/errors';
import {
@ -175,7 +176,11 @@ export class BaseValidator {
* Validates that by-policy artifacts is permitted and that each policy referenced in the item is valid
* @protected
*/
protected async validateByPolicyItem(item: ExceptionItemLikeOptions): Promise<void> {
protected async validateByPolicyItem(
item: ExceptionItemLikeOptions,
/** Should be provided when an existing item is being updated. Will `undefined` on create flows */
currentItem?: ExceptionListItemSchema
): Promise<void> {
if (this.isItemByPolicy(item)) {
const spaceId = this.endpointAppContext.experimentalFeatures
.endpointManagementSpaceAwarenessEnabled
@ -190,16 +195,17 @@ export class BaseValidator {
return;
}
const policiesFromFleet = await packagePolicy.getByIDs(soClient, policyIds, {
ignoreMissing: true,
});
const policiesFromFleet: PackagePolicy[] =
(await packagePolicy.getByIDs(soClient, policyIds, {
ignoreMissing: true,
})) ?? [];
this.logger.debug(
() =>
`Lookup of policy ids:\n[${policyIds.join(
' | '
)}] for space [${spaceId}] returned:\n${stringify(
(policiesFromFleet ?? []).map((policy) => ({
policiesFromFleet.map((policy) => ({
id: policy.id,
name: policy.name,
spaceIds: policy.spaceIds,
@ -207,16 +213,22 @@ export class BaseValidator {
)}`
);
if (!policiesFromFleet) {
throw new EndpointArtifactExceptionValidationError(
`invalid policy ids: ${policyIds.join(', ')}`
);
}
const invalidPolicyIds = policyIds.filter(
let invalidPolicyIds: string[] = policyIds.filter(
(policyId) => !policiesFromFleet.some((policy) => policyId === policy.id)
);
if (
this.endpointAppContext.experimentalFeatures.endpointManagementSpaceAwarenessEnabled &&
invalidPolicyIds.length > 0 &&
currentItem
) {
const currentItemPolicyIds = getPolicyIdsFromArtifact(currentItem);
// Check to see if the invalid policy IDs are ones that the current item (pre-update) already has,
// which implies that they are valid, but not visible in the active space.
invalidPolicyIds = invalidPolicyIds.filter((id) => !currentItemPolicyIds.includes(id));
}
if (invalidPolicyIds.length) {
throw new EndpointArtifactExceptionValidationError(
`invalid policy ids: ${invalidPolicyIds.join(', ')}`
@ -226,7 +238,7 @@ export class BaseValidator {
}
/**
* If the item being updated is `by policy`, method validates if anyting was changes in regard to
* If the item being updated is `by policy`, method validates if anything was changes in regard to
* the effected scope of the by policy settings.
*
* @param updatedItem

View file

@ -298,7 +298,7 @@ export class BlocklistValidator extends BaseValidator {
}
}
await this.validateByPolicyItem(updatedItem);
await this.validateByPolicyItem(updatedItem, currentItem);
await this.validateUpdateOwnerSpaceIds(updatedItem, currentItem);
await this.validateCanUpdateItemInActiveSpace(_updatedItem, currentItem);

View file

@ -84,7 +84,7 @@ export class EventFilterValidator extends BaseValidator {
}
}
await this.validateByPolicyItem(updatedItem);
await this.validateByPolicyItem(updatedItem, currentItem);
await this.validateUpdateOwnerSpaceIds(_updatedItem, currentItem);
await this.validateCanUpdateItemInActiveSpace(_updatedItem, currentItem);

View file

@ -92,7 +92,7 @@ export class HostIsolationExceptionsValidator extends BaseValidator {
await this.validateHasWritePrivilege();
await this.validateHostIsolationData(updatedItem);
await this.validateByPolicyItem(updatedItem);
await this.validateByPolicyItem(updatedItem, currentItem);
await this.validateUpdateOwnerSpaceIds(_updatedItem, currentItem);
await this.validateCanUpdateItemInActiveSpace(_updatedItem, currentItem);

View file

@ -40,8 +40,11 @@ export class BaseValidatorMock extends BaseValidator {
return this.validateCanCreateByPolicyArtifacts(item);
}
async _validateByPolicyItem(item: ExceptionItemLikeOptions): Promise<void> {
return this.validateByPolicyItem(item);
async _validateByPolicyItem(
item: ExceptionItemLikeOptions,
currentItem?: ExceptionListItemSchema
): Promise<void> {
return this.validateByPolicyItem(item, currentItem);
}
_wasByPolicyEffectScopeChanged(

View file

@ -259,7 +259,7 @@ export class TrustedAppValidator extends BaseValidator {
}
}
await this.validateByPolicyItem(updatedItem);
await this.validateByPolicyItem(updatedItem, currentItem);
await this.validateUpdateOwnerSpaceIds(_updatedItem, currentItem);
await this.validateCanUpdateItemInActiveSpace(_updatedItem, currentItem);