[Security Solution][Endpoint] Disable Artifact card menu under space awareness conditions where user is not allowed to edit item under active space (#213820)

## Summary

The following changes are being done to Artifact Card's Menu (which
displays the option to Delete or Update the artifact) in support of
space awareness feature (currently behind Feature Flag:
`endpointManagementSpaceAwarenessEnabled`):

- Global Artifacts: If displaying a global artifact and user does not
have the new Global Artifact Management privilege - disable the Edit
menu icon and display a tooltip on hover
- Per-Policy Artifacts: if displaying a per-policy artifact in a space
other than one of the `ownerSpaceId` spaces that the artifact is
associated with and the user does not have the new Global Artifact
Management privilege - disable the Edit menu icon and display a tooltip
when the user hover over that button


> [!NOTE]
> Changes were **NOT** done to Endpoint Exceptions with this PR.
This commit is contained in:
Paul Tavares 2025-03-13 08:52:33 -04:00 committed by GitHub
parent 535a853133
commit 2b9d2cff6c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 329 additions and 69 deletions

View file

@ -27,6 +27,7 @@ import { PLUGIN_ID } from '@kbn/fleet-plugin/common';
import type { UseBaseQueryResult } from '@tanstack/react-query';
import ReactDOM from 'react-dom';
import type { DeepReadonly } from 'utility-types';
import { spacesPluginMock } from '@kbn/spaces-plugin/public/mocks';
import type { UserPrivilegesState } from '../../components/user_privileges/user_privileges_context';
import { getUserPrivilegesMockDefaultValue } from '../../components/user_privileges/__mocks__';
import type { AppLinkItems } from '../../links/types';
@ -134,7 +135,7 @@ export interface AppContextTestRender {
store: Store<State>;
history: ReturnType<typeof createMemoryHistory>;
coreStart: ReturnType<typeof coreMock.createStart>;
depsStart: Pick<StartPlugins, 'data' | 'fleet' | 'unifiedSearch'>;
depsStart: Pick<StartPlugins, 'data' | 'fleet' | 'unifiedSearch' | 'spaces'>;
startServices: StartServices;
middlewareSpy: MiddlewareActionSpyHelper;
/**
@ -252,9 +253,20 @@ const experimentalFeaturesReducer: Reducer<State['app'], UpdateExperimentalFeatu
export const createAppRootMockRenderer = (): AppContextTestRender => {
const history = createMemoryHistory<never>();
const coreStart = createCoreStartMock(history);
const depsStart = depsStartMock();
const middlewareSpy = createSpyMiddleware();
const startServices: StartServices = createStartServicesMock(coreStart);
const depsStart: AppContextTestRender['depsStart'] = {
...depsStartMock(),
spaces: spacesPluginMock.createStartContract(),
};
(depsStart.spaces.getActiveSpace as jest.Mock).mockImplementation(async () => {
return {
id: 'default',
name: 'default',
disabledFeatures: [],
};
});
const storeReducer = {
...SUB_PLUGINS_REDUCER,

View file

@ -6,7 +6,7 @@
*/
import type { ReactNode } from 'react';
import React, { memo } from 'react';
import React, { memo, useMemo } from 'react';
import { Provider } from 'react-redux';
import { Router } from '@kbn/shared-ux-router';
import type { History } from 'history';
@ -36,15 +36,21 @@ export const AppRootProvider = memo<{
startServices: StartServices;
queryClient: QueryClient;
children: ReactNode | ReactNode[];
}>(({ store, history, coreStart, queryClient, startServices, children }) => {
}>(({ store, history, coreStart, depsStart, queryClient, startServices, children }) => {
const { theme: themeStart } = coreStart;
const theme = useObservable(themeStart.theme$, themeStart.getTheme());
const isDarkMode = theme.darkMode;
const services = useMemo(() => {
return {
...depsStart,
...startServices,
};
}, [depsStart, startServices]);
return (
<KibanaRenderContextProvider {...coreStart}>
<Provider store={store}>
<KibanaContextProvider services={startServices}>
<KibanaContextProvider services={services}>
<EuiThemeProvider darkMode={isDarkMode}>
<QueryClientProvider client={queryClient}>
<UpsellingProvider upsellingService={startServices.upselling}>

View file

@ -5,9 +5,10 @@
* 2.0.
*/
import type { ReactNode } from 'react';
import React, { memo, useCallback, useMemo, useState } from 'react';
import type { EuiPopoverProps, EuiContextMenuPanelProps, EuiIconProps } from '@elastic/eui';
import { EuiButtonIcon, EuiContextMenuPanel, EuiPopover } from '@elastic/eui';
import { EuiToolTip, EuiButtonIcon, EuiContextMenuPanel, EuiPopover } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { v4 as uuidv4 } from 'uuid';
import type { ContextMenuItemNavByRouterProps } from '../context_menu_with_router_support';
@ -19,13 +20,23 @@ export interface ActionsContextMenuProps {
/** Default icon is `boxesHorizontal` */
icon?: EuiIconProps['type'];
'data-test-subj'?: string;
/** If menu button should be disabled */
isDisabled?: boolean;
/** If defined, then the disabled button will be wrapped in on-hover tooltip */
disabledTooltip?: ReactNode;
}
/**
* Display a context menu behind a icon button (which defaults to the three horizontal dots icon)
*/
export const ActionsContextMenu = memo<ActionsContextMenuProps>(
({ items, 'data-test-subj': dataTestSubj, icon = 'boxesHorizontal' }) => {
({
items,
'data-test-subj': dataTestSubj,
icon = 'boxesHorizontal',
isDisabled = false,
disabledTooltip,
}) => {
const getTestId = useTestIdGenerator(dataTestSubj);
const [isOpen, setIsOpen] = useState(false);
@ -53,22 +64,33 @@ export const ActionsContextMenu = memo<ActionsContextMenuProps>(
});
}, [handleCloseMenu, items]);
const menuButton = useMemo(() => {
const button = (
<EuiButtonIcon
data-test-subj={getTestId('button')}
iconType={icon}
onClick={handleToggleMenu}
isDisabled={isDisabled}
aria-label={i18n.translate('xpack.securitySolution.actionsContextMenu.label', {
defaultMessage: 'Open',
})}
/>
);
if (isDisabled && disabledTooltip) {
return <EuiToolTip content={disabledTooltip}>{button}</EuiToolTip>;
}
return button;
}, [disabledTooltip, getTestId, handleToggleMenu, icon, isDisabled]);
return (
<EuiPopover
anchorPosition="downRight"
panelPaddingSize="none"
panelProps={panelProps}
data-test-subj={dataTestSubj}
button={
<EuiButtonIcon
data-test-subj={getTestId('button')}
iconType={icon}
onClick={handleToggleMenu}
aria-label={i18n.translate('xpack.securitySolution.actionsContextMenu.label', {
defaultMessage: 'Open',
})}
/>
}
button={menuButton}
isOpen={isOpen}
closePopover={handleCloseMenu}
>

View file

@ -6,14 +6,14 @@
*/
import React, { memo } from 'react';
import type { AppContextTestRender } from '../../../common/mock/endpoint';
import type { AppContextTestRender, UserPrivilegesMockSetter } from '../../../common/mock/endpoint';
import { createAppRootMockRenderer } from '../../../common/mock/endpoint';
import type {
ArtifactEntryCardDecoratorProps,
ArtifactEntryCardProps,
} from './artifact_entry_card';
import { ArtifactEntryCard } from './artifact_entry_card';
import { act, fireEvent, getByTestId } from '@testing-library/react';
import { act, fireEvent, getByTestId, waitFor } from '@testing-library/react';
import type { AnyArtifact } from './types';
import { isTrustedApp } from './utils';
import { getTrustedAppProviderMock, getExceptionProviderMock } from './test_utils';
@ -21,6 +21,12 @@ import { OS_LINUX, OS_MAC, OS_WINDOWS } from './components/translations';
import type { TrustedApp } from '../../../../common/endpoint/types';
import { useUserPrivileges } from '../../../common/components/user_privileges';
import { getEndpointAuthzInitialStateMock } from '../../../../common/endpoint/service/authz/mocks';
import { GLOBAL_ARTIFACT_TAG } from '../../../../common/endpoint/service/artifacts';
import {
buildPerPolicyTag,
buildSpaceOwnerIdTag,
} from '../../../../common/endpoint/service/artifacts/utils';
import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
jest.mock('../../../common/components/user_privileges');
const mockUserPrivileges = useUserPrivileges as jest.Mock;
@ -286,4 +292,96 @@ describe.each([
expect(passedItem).toBe(item);
});
});
describe('and space awareness is enabled', () => {
let authzMock: UserPrivilegesMockSetter;
let actions: ArtifactEntryCardProps['actions'];
beforeEach(() => {
actions = [
{
'data-test-subj': 'test-action',
children: 'action one',
},
];
});
beforeEach(() => {
appTestContext.setExperimentalFlag({ endpointManagementSpaceAwarenessEnabled: true });
authzMock = appTestContext.getUserPrivilegesMockSetter(mockUserPrivileges);
authzMock.set({ canManageGlobalArtifacts: false });
(item as ExceptionListItemSchema).tags = [GLOBAL_ARTIFACT_TAG, buildSpaceOwnerIdTag('foo')];
});
afterEach(() => {
authzMock.reset();
});
it('should render menu if feature flag is disabled', () => {
appTestContext.setExperimentalFlag({ endpointManagementSpaceAwarenessEnabled: false });
render({ actions });
expect(
(renderResult.getByTestId('testCard-header-actions-button') as HTMLButtonElement).disabled
).toBe(false);
});
it('should disable card actions menu for global artifacts when user does not have global artifact privilege', () => {
render({ actions });
expect(
(renderResult.getByTestId('testCard-header-actions-button') as HTMLButtonElement).disabled
).toBe(true);
});
it('should enable card actions menu for global artifacts when user has the global artifact privilege', () => {
authzMock.set({ canManageGlobalArtifacts: true });
render({ actions });
expect(
(renderResult.getByTestId('testCard-header-actions-button') as HTMLButtonElement).disabled
).toBe(false);
});
it('should disable card actions menu for per-policy artifacts not owned by active space', () => {
(item as ExceptionListItemSchema).tags = [
buildPerPolicyTag('abc'),
buildSpaceOwnerIdTag('foo'),
];
render({ actions });
expect(
(renderResult.getByTestId('testCard-header-actions-button') as HTMLButtonElement).disabled
).toBe(true);
});
it('should enable card actions menu for per-policy artifacts when active space matches artifact owner space id', async () => {
(item as ExceptionListItemSchema).tags = [
buildPerPolicyTag('abc'),
buildSpaceOwnerIdTag('default'),
];
render({ actions });
await waitFor(() => {
expect(
(renderResult.getByTestId('testCard-header-actions-button') as HTMLButtonElement).disabled
).toBe(false);
});
});
it('should enable card actions menu for per-policy artifacts when not owned by active space but user has global artifact privilege', async () => {
authzMock.set({ canManageGlobalArtifacts: true });
(item as ExceptionListItemSchema).tags = [
buildPerPolicyTag('abc'),
buildSpaceOwnerIdTag('foo'),
];
render({ actions });
await waitFor(() => {
expect(
(renderResult.getByTestId('testCard-header-actions-button') as HTMLButtonElement).disabled
).toBe(false);
});
});
});
});

View file

@ -77,7 +77,7 @@ export const ArtifactEntryCard = memo<ArtifactEntryCardProps>(
const policyNavLinks = usePolicyNavLinks(artifact, policies);
return (
<CardContainerPanel {...commonProps} data-test-subj={dataTestSubj}>
<CardContainerPanel {...commonProps} item={item} data-test-subj={dataTestSubj}>
<CardSectionPanel className="top-section">
<CardHeader
name={artifact.name}

View file

@ -18,6 +18,7 @@ import {
EuiButtonEmpty,
} from '@elastic/eui';
import styled from '@emotion/styled';
import { CardArtifactProvider } from './components/card_artifact_context';
import type { CriteriaConditionsProps } from './components/criteria_conditions';
import { CriteriaConditions } from './components/criteria_conditions';
import type { AnyArtifact } from './types';
@ -108,42 +109,44 @@ export const ArtifactEntryCardMinified = memo(
hasShadow={false}
hasBorder
>
{cardTitle}
<EuiSplitPanel.Inner paddingSize="s">
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="s">
<EuiTitle size="xxs">
<h5 data-test-subj={getTestId('descriptionTitle')}>{DESCRIPTION_LABEL}</h5>
</EuiTitle>
<DescriptionField data-test-subj={getTestId('description')}>
{artifact.description}
</DescriptionField>
</EuiPanel>
<CardArtifactProvider item={item}>
{cardTitle}
<EuiSplitPanel.Inner paddingSize="s">
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="s">
<EuiTitle size="xxs">
<h5 data-test-subj={getTestId('descriptionTitle')}>{DESCRIPTION_LABEL}</h5>
</EuiTitle>
<DescriptionField data-test-subj={getTestId('description')}>
{artifact.description}
</DescriptionField>
</EuiPanel>
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="s">
<EuiButtonEmpty
data-test-subj={getTestId('collapse')}
color="primary"
size="s"
flush="left"
iconType={accordionTrigger === 'open' ? 'arrowUp' : 'arrowDown'}
iconSide="right"
iconSize="m"
onClick={handleOnToggleAccordion}
style={{ fontWeight: 400 }}
>
{getAccordionTitle()}
</EuiButtonEmpty>
<EuiAccordion id="showDetails" arrowDisplay="none" forceState={accordionTrigger}>
{Decorator && <Decorator item={item} data-test-subj={getTestId('decorator')} />}
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="s">
<EuiButtonEmpty
data-test-subj={getTestId('collapse')}
color="primary"
size="s"
flush="left"
iconType={accordionTrigger === 'open' ? 'arrowUp' : 'arrowDown'}
iconSide="right"
iconSize="m"
onClick={handleOnToggleAccordion}
style={{ fontWeight: 400 }}
>
{getAccordionTitle()}
</EuiButtonEmpty>
<EuiAccordion id="showDetails" arrowDisplay="none" forceState={accordionTrigger}>
{Decorator && <Decorator item={item} data-test-subj={getTestId('decorator')} />}
<CriteriaConditions
os={artifact.os as CriteriaConditionsProps['os']}
entries={artifact.entries}
data-test-subj={getTestId('criteriaConditions')}
/>
</EuiAccordion>
</EuiPanel>
</EuiSplitPanel.Inner>
<CriteriaConditions
os={artifact.os as CriteriaConditionsProps['os']}
entries={artifact.entries}
data-test-subj={getTestId('criteriaConditions')}
/>
</EuiAccordion>
</EuiPanel>
</EuiSplitPanel.Inner>
</CardArtifactProvider>
</CardContainerPanel>
);
}

View file

@ -7,6 +7,7 @@
import React, { memo } from 'react';
import { EuiHorizontalRule } from '@elastic/eui';
import type { AnyArtifact } from './types';
import type { CommonArtifactEntryCardProps } from './artifact_entry_card';
import { CardContainerPanel } from './components/card_container_panel';
import { useNormalizedArtifact } from './hooks/use_normalized_artifact';
@ -36,7 +37,7 @@ export const ArtifactEntryCollapsibleCard = memo<ArtifactEntryCollapsibleCardPro
const getTestId = useTestIdGenerator(dataTestSubj);
return (
<CardContainerPanel {...commonProps} data-test-subj={dataTestSubj}>
<CardContainerPanel {...commonProps} item={item as AnyArtifact} data-test-subj={dataTestSubj}>
<CardSectionPanel className="artifact-entry-collapsible-card">
<CardCompressedHeader
artifact={artifact}

View file

@ -5,11 +5,23 @@
* 2.0.
*/
import React, { memo } from 'react';
import type { ReactNode } from 'react';
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 { 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 { useSpaceId } from '../../../../common/hooks/use_space_id';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import { isArtifactGlobal } from '../../../../../common/endpoint/service/artifacts';
import { useCardArtifact } from './card_artifact_context';
import type { ActionsContextMenuProps } from '../../actions_context_menu';
import { ActionsContextMenu } from '../../actions_context_menu';
import { getArtifactOwnerSpaceIds } from '../../../../../common/endpoint/service/artifacts/utils';
export interface CardActionsFlexItemProps extends Pick<CommonProps, 'data-test-subj'> {
/** If defined, then an overflow menu will be shown with the actions provided */
@ -18,9 +30,51 @@ export interface CardActionsFlexItemProps extends Pick<CommonProps, 'data-test-s
export const CardActionsFlexItem = memo<CardActionsFlexItemProps>(
({ actions, 'data-test-subj': dataTestSubj }) => {
const item = useCardArtifact() as ExceptionListItemSchema;
const canManageGlobalArtifacts =
useUserPrivileges().endpointPrivileges.canManageGlobalArtifacts;
const isGlobal = useMemo(() => isArtifactGlobal(item), [item]);
const ownerSpaceIds = useMemo(() => getArtifactOwnerSpaceIds(item), [item]);
const isSpacesEnabled = useIsExperimentalFeatureEnabled(
'endpointManagementSpaceAwarenessEnabled'
);
const activeSpaceId = useSpaceId();
interface MenuButtonDisableOptions {
isDisabled: boolean;
disabledTooltip: ReactNode;
}
const { isDisabled, disabledTooltip } = useMemo<MenuButtonDisableOptions>(() => {
if (!isSpacesEnabled || canManageGlobalArtifacts) {
return { isDisabled: false, disabledTooltip: undefined };
}
if (isGlobal) {
return {
isDisabled: true,
disabledTooltip: MANAGEMENT_OF_GLOBAL_ARTIFACT_NOT_ALLOWED_MESSAGE,
};
}
if (!activeSpaceId || !ownerSpaceIds.includes(activeSpaceId)) {
return {
isDisabled: true,
disabledTooltip: MANAGEMENT_OF_SHARED_PER_POLICY_ARTIFACT_NOT_ALLOWED_MESSAGE,
};
}
return { isDisabled: false, disabledTooltip: undefined };
}, [activeSpaceId, canManageGlobalArtifacts, isGlobal, isSpacesEnabled, ownerSpaceIds]);
return actions && actions.length > 0 ? (
<EuiFlexItem grow={false}>
<ActionsContextMenu items={actions} icon="boxesHorizontal" data-test-subj={dataTestSubj} />
<ActionsContextMenu
items={actions}
icon="boxesHorizontal"
isDisabled={isDisabled}
disabledTooltip={disabledTooltip}
data-test-subj={dataTestSubj}
/>
</EuiFlexItem>
) : null;
}

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo, useContext, type PropsWithChildren } from 'react';
import type { MaybeImmutable } from '../../../../../common/endpoint/types';
import type { AnyArtifact } from '..';
const CardArtifactContext = React.createContext<MaybeImmutable<AnyArtifact> | undefined>(undefined);
export interface CardArtifactProviderProps extends PropsWithChildren {
item: MaybeImmutable<AnyArtifact>;
}
/**
* Stores and provides the Artifact item that is being rendered
*/
export const CardArtifactProvider = memo<CardArtifactProviderProps>(({ item, children }) => {
return <CardArtifactContext.Provider value={item}>{children}</CardArtifactContext.Provider>;
});
CardArtifactProvider.displayName = 'CardArtifactProvider';
/**
* Retrieve the artifact item (`ExceptionListItemSchema`) that is currently being rendered
*/
export const useCardArtifact = (): MaybeImmutable<AnyArtifact> => {
const artifact = useContext(CardArtifactContext);
if (!artifact) {
throw new Error('Card has not been initialized correctly - missing Artifact item');
}
return artifact;
};

View file

@ -9,6 +9,9 @@ import styled from '@emotion/styled';
import { EuiPanel } from '@elastic/eui';
import type { EuiPanelProps } from '@elastic/eui/src/components/panel/panel';
import React, { memo } from 'react';
import type { MaybeImmutable } from '../../../../../common/endpoint/types';
import { CardArtifactProvider } from './card_artifact_context';
import type { AnyArtifact } from '..';
export const EuiPanelStyled = styled(EuiPanel)`
&.artifactEntryCard + &.artifactEntryCard {
@ -16,17 +19,23 @@ export const EuiPanelStyled = styled(EuiPanel)`
}
`;
export type CardContainerPanelProps = Exclude<EuiPanelProps, 'hasBorder' | 'paddingSize'>;
export type CardContainerPanelProps = Exclude<EuiPanelProps, 'hasBorder' | 'paddingSize'> & {
item: MaybeImmutable<AnyArtifact>;
};
export const CardContainerPanel = memo<CardContainerPanelProps>(({ className, ...props }) => {
return (
<EuiPanelStyled
{...props}
hasBorder={true}
paddingSize="none"
className={`artifactEntryCard ${className ?? ''}`}
/>
);
});
export const CardContainerPanel = memo<CardContainerPanelProps>(
({ className, item, children, ...props }) => {
return (
<EuiPanelStyled
{...props}
hasBorder={true}
paddingSize="none"
className={`artifactEntryCard ${className ?? ''}`}
>
<CardArtifactProvider item={item}>{children}</CardArtifactProvider>
</EuiPanelStyled>
);
}
);
CardContainerPanel.displayName = 'CardContainerPanel';

View file

@ -162,3 +162,16 @@ export const DESCRIPTION_LABEL = i18n.translate(
defaultMessage: 'Description',
}
);
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',
{
defaultMessage:
'Management of artifacts shared across multiple spaces is only allowed from the space where it was created from',
}
);

View file

@ -881,19 +881,23 @@ interface GetOrCreateDefaultAgentPolicyOptions {
kbnClient: KbnClient;
log: ToolingLog;
policyName?: string;
overrides?: Partial<Omit<CreateAgentPolicyRequest['body'], 'name'>>;
}
/**
* Creates a default Fleet Agent policy (if it does not yet exist) for testing. If
* policy already exists, then it will be reused.
* policy already exists, then it will be reused. It uses the policy name to find an
* existing match.
* @param kbnClient
* @param log
* @param policyName
* @param overrides
*/
export const getOrCreateDefaultAgentPolicy = async ({
kbnClient,
log,
policyName = DEFAULT_AGENT_POLICY_NAME,
overrides = {},
}: GetOrCreateDefaultAgentPolicyOptions): Promise<AgentPolicy> => {
const existingPolicy = await fetchAgentPolicyList(kbnClient, {
kuery: `${LEGACY_AGENT_POLICY_SAVED_OBJECT_TYPE}.name: "${policyName}"`,
@ -917,6 +921,7 @@ export const getOrCreateDefaultAgentPolicy = async ({
description: `Policy created by security solution tooling: ${__filename}`,
namespace: spaceId,
monitoring_enabled: ['logs', 'metrics'],
...overrides,
},
});