[Cloud Security] Asset Inventory table flyout controls (#208452)

This commit is contained in:
Jordan 2025-02-11 19:30:28 +02:00 committed by GitHub
parent 06801d82fe
commit 000e913e7a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 494 additions and 22 deletions

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
// TODO: Asset Inventory - This file is a placeholder for the ECS schema that will be used in the Asset Inventory app
export interface EntityEcs {
id: string;
name: string;
type: 'universal' | 'user' | 'host' | 'service';
timestamp: Date;
}

View file

@ -48,6 +48,11 @@ export const CHANGE_RULE_STATE = 'change-rule-state' as const;
export const GRAPH_PREVIEW = 'graph-preview' as const;
export const GRAPH_INVESTIGATION = 'graph-investigation' as const;
export const ASSET_INVENTORY_EXPAND_FLYOUT_SUCCESS =
'asset-inventory-expand-flyout-success' as const;
export const ASSET_INVENTORY_EXPAND_FLYOUT_ERROR = 'asset-inventory-expand-flyout-error' as const;
export const UNIVERSAL_ENTITY_FLYOUT_OPENED = 'universal-entity-flyout-opened' as const;
export type CloudSecurityUiCounters =
| typeof ENTITY_FLYOUT_WITH_MISCONFIGURATION_VISIT
| typeof ENTITY_FLYOUT_WITH_VULNERABILITY_PREVIEW
@ -68,7 +73,10 @@ export type CloudSecurityUiCounters =
| typeof VULNERABILITIES_INSIGHT_HOST_DETAILS
| typeof VULNERABILITIES_INSIGHT_HOST_ENTITY_OVERVIEW
| typeof GRAPH_PREVIEW
| typeof GRAPH_INVESTIGATION;
| typeof GRAPH_INVESTIGATION
| typeof ASSET_INVENTORY_EXPAND_FLYOUT_SUCCESS
| typeof ASSET_INVENTORY_EXPAND_FLYOUT_ERROR
| typeof UNIVERSAL_ENTITY_FLYOUT_OPENED;
export class UiMetricService {
private usageCollection: UsageCollectionSetup | undefined;

View file

@ -0,0 +1,199 @@
/*
* 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 { renderHook, act } from '@testing-library/react';
import { useDynamicEntityFlyout } from './use_dynamic_entity_flyout';
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import { useKibana } from '../../common/lib/kibana';
import { useOnExpandableFlyoutClose } from '../../flyout/shared/hooks/use_on_expandable_flyout_close';
import {
UniversalEntityPanelKey,
UserPanelKey,
HostPanelKey,
ServicePanelKey,
} from '../../flyout/entity_details/shared/constants';
import type { EntityEcs } from '@kbn/securitysolution-ecs/src/entity';
jest.mock('@kbn/expandable-flyout', () => ({
useExpandableFlyoutApi: jest.fn(),
}));
jest.mock('../../common/lib/kibana', () => ({
useKibana: jest.fn(),
}));
jest.mock('../../flyout/shared/hooks/use_on_expandable_flyout_close', () => ({
useOnExpandableFlyoutClose: jest.fn(),
}));
const entity = {
id: '123',
name: 'test-entity',
type: 'universal',
timestamp: new Date(),
};
describe('useDynamicEntityFlyout', () => {
let openFlyoutMock: jest.Mock;
let closeFlyoutMock: jest.Mock;
let toastsMock: { addDanger: jest.Mock };
let onFlyoutCloseMock: jest.Mock;
beforeEach(() => {
openFlyoutMock = jest.fn();
closeFlyoutMock = jest.fn();
toastsMock = { addDanger: jest.fn() };
onFlyoutCloseMock = jest.fn();
(useExpandableFlyoutApi as jest.Mock).mockReturnValue({
openFlyout: openFlyoutMock,
closeFlyout: closeFlyoutMock,
});
(useKibana as jest.Mock).mockReturnValue({
services: { notifications: { toasts: toastsMock } },
});
(useOnExpandableFlyoutClose as jest.Mock).mockImplementation(({ callback }) => callback);
});
it('should open the flyout with correct params for a universal entity', () => {
const { result } = renderHook(() =>
useDynamicEntityFlyout({ onFlyoutClose: onFlyoutCloseMock })
);
act(() => {
result.current.openDynamicFlyout({
entity: { ...entity, type: 'universal', name: 'testUniversal' },
scopeId: 'scope1',
contextId: 'context1',
});
});
expect(openFlyoutMock).toHaveBeenCalledWith({
right: {
id: UniversalEntityPanelKey,
params: { entity: { ...entity, type: 'universal', name: 'testUniversal' } },
},
});
});
it('should open the flyout with correct params for a user entity', () => {
const { result } = renderHook(() =>
useDynamicEntityFlyout({ onFlyoutClose: onFlyoutCloseMock })
);
act(() => {
result.current.openDynamicFlyout({
entity: { ...entity, type: 'user', name: 'testUser' },
scopeId: 'scope1',
contextId: 'context1',
});
});
expect(openFlyoutMock).toHaveBeenCalledWith({
right: {
id: UserPanelKey,
params: { userName: 'testUser', scopeId: 'scope1', contextId: 'context1' },
},
});
});
it('should open the flyout with correct params for a host entity', () => {
const { result } = renderHook(() =>
useDynamicEntityFlyout({ onFlyoutClose: onFlyoutCloseMock })
);
act(() => {
result.current.openDynamicFlyout({
entity: { ...entity, type: 'host', name: 'testHost' },
scopeId: 'scope1',
contextId: 'context1',
});
});
expect(openFlyoutMock).toHaveBeenCalledWith({
right: {
id: HostPanelKey,
params: { hostName: 'testHost', scopeId: 'scope1', contextId: 'context1' },
},
});
});
it('should open the flyout with correct params for a service entity', () => {
const { result } = renderHook(() =>
useDynamicEntityFlyout({ onFlyoutClose: onFlyoutCloseMock })
);
act(() => {
result.current.openDynamicFlyout({
entity: { ...entity, type: 'service', name: 'testService' },
scopeId: 'scope1',
contextId: 'context1',
});
});
expect(openFlyoutMock).toHaveBeenCalledWith({
right: {
id: ServicePanelKey,
params: { serviceName: 'testService', scopeId: 'scope1', contextId: 'context1' },
},
});
});
it('should show an error toast and close flyout if entity name is missing for user, host, or service entities', () => {
const { result } = renderHook(() =>
useDynamicEntityFlyout({ onFlyoutClose: onFlyoutCloseMock })
);
act(() => {
result.current.openDynamicFlyout({ entity: { type: 'user' } as EntityEcs });
});
expect(toastsMock.addDanger).toHaveBeenCalledWith(
expect.objectContaining({
title: expect.any(String),
text: expect.any(String),
})
);
expect(onFlyoutCloseMock).toHaveBeenCalled();
act(() => {
result.current.openDynamicFlyout({ entity: { type: 'host' } as EntityEcs });
});
expect(toastsMock.addDanger).toHaveBeenCalledWith(
expect.objectContaining({
title: expect.any(String),
text: expect.any(String),
})
);
expect(onFlyoutCloseMock).toHaveBeenCalled();
act(() => {
result.current.openDynamicFlyout({ entity: { type: 'service' } as EntityEcs });
});
expect(toastsMock.addDanger).toHaveBeenCalledWith(
expect.objectContaining({
title: expect.any(String),
text: expect.any(String),
})
);
expect(onFlyoutCloseMock).toHaveBeenCalled();
});
it('should close the flyout when closeDynamicFlyout is called', () => {
const { result } = renderHook(() =>
useDynamicEntityFlyout({ onFlyoutClose: onFlyoutCloseMock })
);
act(() => {
result.current.closeDynamicFlyout();
});
expect(closeFlyoutMock).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,109 @@
/*
* 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 { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import type { EntityEcs } from '@kbn/securitysolution-ecs/src/entity';
import {
ASSET_INVENTORY_EXPAND_FLYOUT_SUCCESS,
ASSET_INVENTORY_EXPAND_FLYOUT_ERROR,
uiMetricService,
} from '@kbn/cloud-security-posture-common/utils/ui_metrics';
import { METRIC_TYPE } from '@kbn/analytics';
import { i18n } from '@kbn/i18n';
import { useKibana } from '../../common/lib/kibana';
import {
HostPanelKey,
ServicePanelKey,
UniversalEntityPanelKey,
UserPanelKey,
} from '../../flyout/entity_details/shared/constants';
import { useOnExpandableFlyoutClose } from '../../flyout/shared/hooks/use_on_expandable_flyout_close';
interface InventoryFlyoutProps {
entity: EntityEcs;
scopeId?: string;
contextId?: string;
}
interface SecurityFlyoutPanelsCommonParams {
scopeId?: string;
contextId?: string;
[key: string]: unknown;
}
type FlyoutParams =
| {
id: typeof UniversalEntityPanelKey;
params: { entity: EntityEcs };
}
| { id: typeof UserPanelKey; params: { userName: string } & SecurityFlyoutPanelsCommonParams }
| { id: typeof HostPanelKey; params: { hostName: string } & SecurityFlyoutPanelsCommonParams }
| {
id: typeof ServicePanelKey;
params: { serviceName: string } & SecurityFlyoutPanelsCommonParams;
};
const getFlyoutParamsByEntity = ({
entity,
scopeId,
contextId,
}: InventoryFlyoutProps): FlyoutParams => {
const entitiesFlyoutParams: Record<EntityEcs['type'], FlyoutParams> = {
universal: { id: UniversalEntityPanelKey, params: { entity } },
user: { id: UserPanelKey, params: { userName: entity.name, scopeId, contextId } },
host: { id: HostPanelKey, params: { hostName: entity.name, scopeId, contextId } },
service: { id: ServicePanelKey, params: { serviceName: entity.name, scopeId, contextId } },
} as const;
return entitiesFlyoutParams[entity.type];
};
export const useDynamicEntityFlyout = ({ onFlyoutClose }: { onFlyoutClose: () => void }) => {
const { openFlyout, closeFlyout } = useExpandableFlyoutApi();
const { notifications } = useKibana().services;
useOnExpandableFlyoutClose({ callback: onFlyoutClose });
const openDynamicFlyout = ({ entity, scopeId, contextId }: InventoryFlyoutProps) => {
const entityFlyoutParams = getFlyoutParamsByEntity({ entity, scopeId, contextId });
// User, Host, and Service entity flyouts rely on entity name to fetch required data
if (entity.type !== 'universal' && !entity.name) {
notifications.toasts.addDanger({
title: i18n.translate(
'xpack.securitySolution.assetInventory.openFlyout.missingEntityNameTitle',
{ defaultMessage: 'Missing Entity Name' }
),
text: i18n.translate(
'xpack.securitySolution.assetInventory.openFlyout.missingEntityNameText',
{ defaultMessage: 'Entity name is required for User, Host, and Service entities' }
),
});
uiMetricService.trackUiMetric(METRIC_TYPE.CLICK, ASSET_INVENTORY_EXPAND_FLYOUT_ERROR);
onFlyoutClose();
return;
}
uiMetricService.trackUiMetric(METRIC_TYPE.CLICK, ASSET_INVENTORY_EXPAND_FLYOUT_SUCCESS);
openFlyout({
right: {
id: entityFlyoutParams.id || UniversalEntityPanelKey,
params: entityFlyoutParams.params,
},
});
};
const closeDynamicFlyout = () => {
closeFlyout();
};
return {
openDynamicFlyout,
closeDynamicFlyout,
};
};

View file

@ -39,6 +39,9 @@ import { type DocViewFilterFn } from '@kbn/unified-doc-viewer/types';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import { css } from '@emotion/react';
import type { EntityEcs } from '@kbn/securitysolution-ecs/src/entity';
import { EmptyComponent } from '../../common/lib/cell_actions/helpers';
import { useDynamicEntityFlyout } from '../hooks/use_dynamic_entity_flyout';
import { type CriticalityLevelWithUnassigned } from '../../../common/entity_analytics/asset_criticality/types';
import { useKibana } from '../../common/lib/kibana';
@ -135,14 +138,21 @@ export interface AllAssetsProps {
* This function will be used in the control column to create a rule for a specific finding.
*/
createFn?: (rowIndex: number) => ((http: HttpSetup) => Promise<unknown>) | undefined;
/**
* This is the component that will be rendered in the flyout when a row is expanded.
* This component will receive the row data and a function to close the flyout.
*/
flyoutComponent: (hit: DataTableRecord, onCloseFlyout: () => void) => JSX.Element;
'data-test-subj'?: string;
}
// TODO: Asset Inventory - adjust and remove type casting once we have real universal entity data
const getEntity = (row: DataTableRecord): EntityEcs => {
return {
id: (row.flattened['asset.name'] as string) || '',
name: (row.flattened['asset.name'] as string) || '',
timestamp: row.flattened['@timestamp'] as Date,
type: 'universal',
};
};
const ASSET_INVENTORY_TABLE_ID = 'asset-inventory-table';
const AllAssets = ({
rows,
isLoading,
@ -151,7 +161,6 @@ const AllAssets = ({
height,
hasDistributionBar = true,
createFn,
flyoutComponent,
...rest
}: AllAssetsProps) => {
const { euiTheme } = useEuiTheme();
@ -162,6 +171,31 @@ const AllAssets = ({
nonPersistedFilters,
});
// Table Flyout Controls -------------------------------------------------------------------
const [expandedDoc, setExpandedDoc] = useState<DataTableRecord | undefined>(undefined);
const { openDynamicFlyout, closeDynamicFlyout } = useDynamicEntityFlyout({
onFlyoutClose: () => setExpandedDoc(undefined),
});
const onExpandDocClick = (doc?: DataTableRecord | undefined) => {
if (doc) {
const entity = getEntity(doc);
setExpandedDoc(doc); // Table is expecting the same doc ref to highlight the selected row
openDynamicFlyout({
entity,
scopeId: ASSET_INVENTORY_TABLE_ID,
contextId: ASSET_INVENTORY_TABLE_ID,
});
} else {
closeDynamicFlyout();
setExpandedDoc(undefined);
}
};
// -----------------------------------------------------------------------------------------
const {
// columnsLocalStorageKey,
pageSize,
@ -206,11 +240,6 @@ const AllAssets = ({
const { dataView, dataViewIsLoading, dataViewIsRefetching } = useDataViewContext();
const [expandedDoc, setExpandedDoc] = useState<DataTableRecord | undefined>(undefined);
const renderDocumentView = (hit: DataTableRecord) =>
flyoutComponent(hit, () => setExpandedDoc(undefined));
const {
uiActions,
uiSettings,
@ -413,7 +442,6 @@ const AllAssets = ({
className={styles.gridStyle}
ariaLabelledBy={title}
columns={currentColumns}
expandedDoc={expandedDoc}
dataView={dataView}
loadingState={loadingState}
onFilter={onAddFilter as DocViewFilterFn}
@ -422,8 +450,9 @@ const AllAssets = ({
onSort={onSort}
rows={rows}
sampleSizeState={MAX_ASSETS_TO_LOAD}
setExpandedDoc={setExpandedDoc}
renderDocumentView={renderDocumentView}
expandedDoc={expandedDoc}
setExpandedDoc={onExpandDocClick}
renderDocumentView={EmptyComponent}
sort={sort}
rowsPerPageState={pageSize}
totalHits={rows.length}

View file

@ -49,12 +49,7 @@ export const AssetInventoryRoutes = () => {
<DataViewContext.Provider value={dataViewContextValue}>
<SecuritySolutionPageWrapper noPadding>
<Suspense fallback={<EuiLoadingSpinner />}>
<AllAssetsLazy
rows={[]}
isLoading={false}
loadMore={() => {}}
flyoutComponent={() => <></>}
/>
<AllAssetsLazy rows={[]} isLoading={false} loadMore={() => {}} />
</Suspense>
</SecuritySolutionPageWrapper>
</DataViewContext.Provider>

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import type { UniversalEntityPanelExpandableFlyoutProps } from '../universal_right';
import { EntityType } from '../../../../common/entity_analytics/types';
import type { HostPanelExpandableFlyoutProps } from '../host_right';
import type { ServicePanelExpandableFlyoutProps } from '../service_right';
@ -27,6 +28,8 @@ export const MANAGED_USER_QUERY_ID = 'managedUserDetailsQuery';
export const HostPanelKey: HostPanelExpandableFlyoutProps['key'] = 'host-panel';
export const UserPanelKey: UserPanelExpandableFlyoutProps['key'] = 'user-panel';
export const ServicePanelKey: ServicePanelExpandableFlyoutProps['key'] = 'service-panel';
export const UniversalEntityPanelKey: UniversalEntityPanelExpandableFlyoutProps['key'] =
'universal-entity-panel';
export const EntityPanelKeyByType: Record<EntityType, string | undefined> = {
[EntityType.host]: HostPanelKey,

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import type { EntityEcs } from '@kbn/securitysolution-ecs/src/entity';
import { FlyoutBody } from '../../shared/components/flyout_body';
interface UniversalEntityFlyoutContentProps {
entity: EntityEcs;
}
export const UniversalEntityFlyoutContent = ({ entity }: UniversalEntityFlyoutContentProps) => {
return <FlyoutBody>{entity.type}</FlyoutBody>;
};

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 { EuiSpacer, EuiText, EuiFlexItem, EuiFlexGroup } from '@elastic/eui';
import React from 'react';
import type { EntityEcs } from '@kbn/securitysolution-ecs/src/entity';
import { EntityIconByType } from '../../../entity_analytics/components/entity_store/helpers';
import { PreferenceFormattedDate } from '../../../common/components/formatted_date';
import { FlyoutHeader } from '../../shared/components/flyout_header';
import { FlyoutTitle } from '../../shared/components/flyout_title';
interface UniversalEntityFlyoutHeaderProps {
entity: EntityEcs;
}
export const UniversalEntityFlyoutHeader = ({ entity }: UniversalEntityFlyoutHeaderProps) => {
return (
<FlyoutHeader data-test-subj="service-panel-header">
<EuiFlexGroup gutterSize="s" responsive={false} direction="column">
<EuiFlexItem grow={false}>
<EuiText size="xs" data-test-subj={'service-panel-header-lastSeen'}>
<PreferenceFormattedDate value={entity?.timestamp} />
<EuiSpacer size="xs" />
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<FlyoutTitle title={entity?.id} iconType={EntityIconByType[entity?.type]} />
</EuiFlexItem>
</EuiFlexGroup>
</FlyoutHeader>
);
};

View file

@ -0,0 +1,45 @@
/*
* 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, { useEffect } from 'react';
import type { FlyoutPanelProps } from '@kbn/expandable-flyout';
import {
uiMetricService,
UNIVERSAL_ENTITY_FLYOUT_OPENED,
} from '@kbn/cloud-security-posture-common/utils/ui_metrics';
import { METRIC_TYPE } from '@kbn/analytics';
import type { EntityEcs } from '@kbn/securitysolution-ecs/src/entity';
import { UniversalEntityFlyoutHeader } from './header';
import { UniversalEntityFlyoutContent } from './content';
import { FlyoutNavigation } from '../../shared/components/flyout_navigation';
export interface UniversalEntityPanelProps {
entity: EntityEcs;
/** this is because FlyoutPanelProps defined params as Record<string, unknown> {@link FlyoutPanelProps#params} */
[key: string]: unknown;
}
export interface UniversalEntityPanelExpandableFlyoutProps extends FlyoutPanelProps {
key: 'universal-entity-panel';
params: UniversalEntityPanelProps;
}
export const UniversalEntityPanel = ({ entity }: UniversalEntityPanelProps) => {
useEffect(() => {
uiMetricService.trackUiMetric(METRIC_TYPE.COUNT, UNIVERSAL_ENTITY_FLYOUT_OPENED);
}, [entity]);
return (
<>
<FlyoutNavigation flyoutIsExpandable={false} />
<UniversalEntityFlyoutHeader entity={entity} />
<UniversalEntityFlyoutContent entity={entity} />
</>
);
};
UniversalEntityPanel.displayName = 'UniversalEntityPanel';

View file

@ -8,6 +8,8 @@
import React, { memo, useCallback } from 'react';
import { ExpandableFlyout, type ExpandableFlyoutProps } from '@kbn/expandable-flyout';
import { useEuiTheme } from '@elastic/eui';
import type { UniversalEntityPanelExpandableFlyoutProps } from './entity_details/universal_right';
import { UniversalEntityPanel } from './entity_details/universal_right';
import { SessionViewPanelProvider } from './document_details/session_view/context';
import type { SessionViewPanelProps } from './document_details/session_view';
import { SessionViewPanel } from './document_details/session_view';
@ -46,7 +48,12 @@ import { HostDetailsPanel, HostDetailsPanelKey } from './entity_details/host_det
import { NetworkPanel, NetworkPanelKey, NetworkPreviewPanelKey } from './network_details';
import type { AnalyzerPanelExpandableFlyoutProps } from './document_details/analyzer_panels';
import { AnalyzerPanel } from './document_details/analyzer_panels';
import { UserPanelKey, HostPanelKey, ServicePanelKey } from './entity_details/shared/constants';
import {
UserPanelKey,
HostPanelKey,
ServicePanelKey,
UniversalEntityPanelKey,
} from './entity_details/shared/constants';
import type { ServicePanelExpandableFlyoutProps } from './entity_details/service_right';
import { ServicePanel } from './entity_details/service_right';
import type { ServiceDetailsExpandableFlyoutProps } from './entity_details/service_details_left';
@ -174,6 +181,12 @@ const expandableFlyoutDocumentsPanels: ExpandableFlyoutProps['registeredPanels']
<ServiceDetailsPanel {...(props as ServiceDetailsExpandableFlyoutProps).params} />
),
},
{
key: UniversalEntityPanelKey,
component: (props) => (
<UniversalEntityPanel {...(props as UniversalEntityPanelExpandableFlyoutProps).params} />
),
},
];
export const SECURITY_SOLUTION_ON_CLOSE_EVENT = `expandable-flyout-on-close-${Flyouts.securitySolution}`;