[Asset Inventory] Handling Asset Criticality soft delete mechanism (#224630)

## Summary

This PR updates Asset Inventory Grouping and Datagrid functionalities to
accommodate the `asset.criticality` soft delete mechanisms, which sets
the value of `asset.criticality` to "delete" once an entity is manually
"Unassigned" after having a value before:

- Added condition on grouping to treat both "deleted" and "missing
value" as Unassigned.
- Added rendering condition on the datatable to display the Unassigned
badge when `asset.criticality` value is either missing or "deleted".

### Recording


https://github.com/user-attachments/assets/37d7e44f-cb57-4c29-b49d-cda9b341497d

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Paulo Silva 2025-06-20 16:04:42 -07:00 committed by GitHub
parent 22f115e476
commit 917d2cf09f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 171 additions and 51 deletions

View file

@ -97,10 +97,14 @@ const customCellRenderer = (rows: DataTableRecord[]): CustomCellRenderer => ({
return <RiskBadge risk={risk} />; return <RiskBadge risk={risk} />;
}, },
[ASSET_FIELDS.ASSET_CRITICALITY]: ({ rowIndex }: EuiDataGridCellValueElementProps) => { [ASSET_FIELDS.ASSET_CRITICALITY]: ({ rowIndex }: EuiDataGridCellValueElementProps) => {
const criticality = rows[rowIndex].flattened[ const criticality = rows[rowIndex].flattened[ASSET_FIELDS.ASSET_CRITICALITY] as
ASSET_FIELDS.ASSET_CRITICALITY | CriticalityLevelWithUnassigned
] as CriticalityLevelWithUnassigned; | 'deleted';
return <AssetCriticalityBadge criticalityLevel={criticality} />; return (
<AssetCriticalityBadge
criticalityLevel={criticality === 'deleted' ? 'unassigned' : criticality}
/>
);
}, },
}); });

View file

@ -7,7 +7,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import type { Filter } from '@kbn/es-query'; import type { Filter } from '@kbn/es-query';
import type { AssetInventoryURLStateResult } from '../hooks/use_asset_inventory_url_state/use_asset_inventory_url_state'; import type { AssetInventoryURLStateResult } from '../hooks/use_asset_inventory_url_state/use_asset_inventory_url_state';
import { DEFAULT_TABLE_SECTION_HEIGHT } from '../constants'; import { ASSET_FIELDS, DEFAULT_TABLE_SECTION_HEIGHT } from '../constants';
import { GroupWrapper } from './grouping/asset_inventory_grouping'; import { GroupWrapper } from './grouping/asset_inventory_grouping';
import { useAssetInventoryGrouping } from './grouping/use_asset_inventory_grouping'; import { useAssetInventoryGrouping } from './grouping/use_asset_inventory_grouping';
import { AssetInventoryDataTable } from './asset_inventory_data_table'; import { AssetInventoryDataTable } from './asset_inventory_data_table';
@ -82,9 +82,9 @@ const GroupWithURLPagination = ({
<GroupWrapper <GroupWrapper
data={groupData} data={groupData}
grouping={grouping} grouping={grouping}
renderChildComponent={(childFilters) => ( renderChildComponent={(currentGroupFilters) => (
<GroupContent <GroupContent
currentGroupFilters={childFilters} currentGroupFilters={currentGroupFilters}
state={state} state={state}
groupingLevel={1} groupingLevel={1}
selectedGroupOptions={selectedGroupOptions} selectedGroupOptions={selectedGroupOptions}
@ -112,6 +112,44 @@ interface GroupContentProps {
groupSelectorComponent?: JSX.Element; groupSelectorComponent?: JSX.Element;
} }
/**
* This function is used to modify the filters for the asset inventory grouping
* It is needed because the asset.criticality field has a soft delete mechanism
* So asset.criticality = "deleted" should be excluded from the grouping
* (treated as a missing value)
*/
const groupFilterMap = (filter: Filter | null): Filter | null => {
const query = filter?.query;
if (query?.bool?.should?.[0]?.bool?.must_not?.exists?.field === ASSET_FIELDS.ASSET_CRITICALITY) {
return {
meta: filter?.meta ?? { alias: null, disabled: false, negate: false },
query: {
bool: {
filter: {
bool: {
should: [
{ term: { [ASSET_FIELDS.ASSET_CRITICALITY]: 'deleted' } },
{ bool: { must_not: { exists: { field: ASSET_FIELDS.ASSET_CRITICALITY } } } },
],
minimum_should_match: 1,
},
},
},
},
};
}
return query?.match_phrase || query?.bool?.should || query?.bool?.filter ? filter : null;
};
const filterTypeGuard = (filter: Filter | null): filter is Filter => filter !== null;
const mergeCurrentAndParentFilters = (
currentGroupFilters: Filter[],
parentGroupFilters: string | undefined
) => {
return [...currentGroupFilters, ...(parentGroupFilters ? JSON.parse(parentGroupFilters) : [])];
};
const GroupContent = ({ const GroupContent = ({
currentGroupFilters, currentGroupFilters,
state, state,
@ -122,16 +160,21 @@ const GroupContent = ({
}: GroupContentProps) => { }: GroupContentProps) => {
if (groupingLevel < selectedGroupOptions.length) { if (groupingLevel < selectedGroupOptions.length) {
const nextGroupingLevel = groupingLevel + 1; const nextGroupingLevel = groupingLevel + 1;
const newParentGroupFilters = mergeCurrentAndParentFilters(
currentGroupFilters,
parentGroupFilters
)
.map(groupFilterMap)
.filter(filterTypeGuard);
return ( return (
<GroupWithLocalPagination <GroupWithLocalPagination
state={state} state={state}
groupingLevel={nextGroupingLevel} groupingLevel={nextGroupingLevel}
selectedGroup={selectedGroupOptions[groupingLevel]} selectedGroup={selectedGroupOptions[groupingLevel]}
selectedGroupOptions={selectedGroupOptions} selectedGroupOptions={selectedGroupOptions}
parentGroupFilters={JSON.stringify([ parentGroupFilters={JSON.stringify(newParentGroupFilters)}
...currentGroupFilters,
...(parentGroupFilters ? JSON.parse(parentGroupFilters) : []),
])}
groupSelectorComponent={groupSelectorComponent} groupSelectorComponent={groupSelectorComponent}
/> />
); );
@ -162,10 +205,12 @@ const GroupWithLocalPagination = ({
const [subgroupPageIndex, setSubgroupPageIndex] = useState(0); const [subgroupPageIndex, setSubgroupPageIndex] = useState(0);
const [subgroupPageSize, setSubgroupPageSize] = useState(10); const [subgroupPageSize, setSubgroupPageSize] = useState(10);
const groupFilters = parentGroupFilters ? JSON.parse(parentGroupFilters) : [];
const { groupData, grouping, isFetching } = useAssetInventoryGrouping({ const { groupData, grouping, isFetching } = useAssetInventoryGrouping({
state: { ...state, pageIndex: subgroupPageIndex, pageSize: subgroupPageSize }, state: { ...state, pageIndex: subgroupPageIndex, pageSize: subgroupPageSize },
selectedGroup, selectedGroup,
groupFilters: parentGroupFilters ? JSON.parse(parentGroupFilters) : [], groupFilters,
}); });
/** /**
@ -180,13 +225,14 @@ const GroupWithLocalPagination = ({
<GroupWrapper <GroupWrapper
data={groupData} data={groupData}
grouping={grouping} grouping={grouping}
renderChildComponent={(childFilters) => ( renderChildComponent={(currentGroupFilters) => (
<GroupContent <GroupContent
currentGroupFilters={childFilters} currentGroupFilters={currentGroupFilters.map(groupFilterMap).filter(filterTypeGuard)}
state={state} state={state}
groupingLevel={groupingLevel} groupingLevel={groupingLevel}
selectedGroupOptions={selectedGroupOptions} selectedGroupOptions={selectedGroupOptions}
groupSelectorComponent={groupSelectorComponent} groupSelectorComponent={groupSelectorComponent}
parentGroupFilters={JSON.stringify(groupFilters)}
/> />
)} )}
activePageIndex={subgroupPageIndex} activePageIndex={subgroupPageIndex}
@ -207,6 +253,13 @@ interface DataTableWithLocalPagination {
parentGroupFilters?: string; parentGroupFilters?: string;
} }
const getDataGridFilter = (filter: Filter | null) => {
if (!filter) return null;
return {
...(filter?.query ?? {}),
};
};
const DataTableWithLocalPagination = ({ const DataTableWithLocalPagination = ({
state, state,
currentGroupFilters, currentGroupFilters,
@ -215,12 +268,11 @@ const DataTableWithLocalPagination = ({
const [tablePageIndex, setTablePageIndex] = useState(0); const [tablePageIndex, setTablePageIndex] = useState(0);
const [tablePageSize, setTablePageSize] = useState(10); const [tablePageSize, setTablePageSize] = useState(10);
const combinedFilters = [ const combinedFilters = mergeCurrentAndParentFilters(currentGroupFilters, parentGroupFilters)
...currentGroupFilters, .map(groupFilterMap)
...(parentGroupFilters ? JSON.parse(parentGroupFilters) : []), .filter(filterTypeGuard)
] .map(getDataGridFilter)
.map(({ query }) => (query?.match_phrase || query?.bool?.should ? query : null)) .filter((filter): filter is NonNullable<Filter['query']> => Boolean(filter));
.filter(Boolean);
const newState: AssetInventoryURLStateResult = { const newState: AssetInventoryURLStateResult = {
...state, ...state,

View file

@ -22,6 +22,8 @@ import {
} from '@kbn/cloud-security-posture-common/utils/ui_metrics'; } from '@kbn/cloud-security-posture-common/utils/ui_metrics';
import { METRIC_TYPE } from '@kbn/analytics'; import { METRIC_TYPE } from '@kbn/analytics';
import dedent from 'dedent';
import type { MappingRuntimeFieldType } from '@elastic/elasticsearch/lib/api/types';
import { useDataViewContext } from '../../hooks/data_view_context'; import { useDataViewContext } from '../../hooks/data_view_context';
import type { import type {
AssetInventoryURLStateResult, AssetInventoryURLStateResult,
@ -159,7 +161,9 @@ export const useAssetInventoryGrouping = ({
// This is recommended by the grouping component to cover an edge case where `selectedGroup` has multiple values // This is recommended by the grouping component to cover an edge case where `selectedGroup` has multiple values
const uniqueValue = useMemo(() => `${selectedGroup}-${uuid.v4()}`, [selectedGroup]); const uniqueValue = useMemo(() => `${selectedGroup}-${uuid.v4()}`, [selectedGroup]);
const groupingQuery = getGroupingQuery({ const groupingQuery = useMemo(
() => ({
...getGroupingQuery({
additionalFilters: query ? [query, additionalFilters] : [additionalFilters], additionalFilters: query ? [query, additionalFilters] : [additionalFilters],
groupByField: currentSelectedGroup, groupByField: currentSelectedGroup,
uniqueValue, uniqueValue,
@ -170,13 +174,68 @@ export const useAssetInventoryGrouping = ({
rootAggregations: [ rootAggregations: [
{ {
...(!isNoneGroup([currentSelectedGroup]) && { ...(!isNoneGroup([currentSelectedGroup]) && {
nullGroupItems: { nullGroupItems:
missing: { field: currentSelectedGroup }, currentSelectedGroup === ASSET_FIELDS.ASSET_CRITICALITY
? {
filter: {
bool: {
should: [
{ term: { [ASSET_FIELDS.ASSET_CRITICALITY]: 'deleted' } },
{
bool: {
must_not: { exists: { field: ASSET_FIELDS.ASSET_CRITICALITY } },
}, },
},
],
minimum_should_match: 1,
},
},
}
: { missing: { field: currentSelectedGroup } },
}), }),
}, },
], ],
}); }),
runtime_mappings: {
groupByField: {
type: 'keyword' as MappingRuntimeFieldType,
script: {
source: dedent(`
def groupValues = [];
if (doc.containsKey(params['selectedGroup']) && !doc[params['selectedGroup']].empty) {
groupValues = doc[params['selectedGroup']];
}
// If selectedGroup is 'asset.criticality', treat 'deleted' as undefined group
boolean treatAsUndefined = false;
int count = groupValues.size();
if (params['selectedGroup'] == 'asset.criticality') {
boolean isDeleted = false;
for (def v : groupValues) {
if (v == 'deleted') {
isDeleted = true;
break;
}
}
treatAsUndefined = (count == 0 || count > 100 || isDeleted);
} else {
treatAsUndefined = (count == 0 || count > 100);
}
if (treatAsUndefined) {
emit(params['uniqueValue']);
} else {
emit(groupValues.join(params['uniqueValue']));
}
`),
params: {
selectedGroup: currentSelectedGroup,
uniqueValue,
},
},
},
},
}),
[currentSelectedGroup, uniqueValue, additionalFilters, query, pageIndex, pageSize]
);
const { data, isFetching } = useFetchGroupedData({ const { data, isFetching } = useFetchGroupedData({
query: groupingQuery, query: groupingQuery,

View file

@ -69,6 +69,13 @@ export const groupPanelRenderer: GroupPanelRenderer<AssetsGroupingAggregation> =
switch (selectedGroup) { switch (selectedGroup) {
case ASSET_GROUPING_OPTIONS.ASSET_CRITICALITY: case ASSET_GROUPING_OPTIONS.ASSET_CRITICALITY:
const rawCriticalityLevel = firstNonNullValue(bucket.assetCriticality?.buckets?.[0]?.key) as
| CriticalityLevelWithUnassigned
| 'deleted';
const criticalityLevel =
rawCriticalityLevel === 'deleted' ? 'unassigned' : rawCriticalityLevel;
return nullGroupMessage ? ( return nullGroupMessage ? (
<EuiFlexGroup alignItems="center"> <EuiFlexGroup alignItems="center">
<EuiFlexItem> <EuiFlexItem>
@ -84,13 +91,7 @@ export const groupPanelRenderer: GroupPanelRenderer<AssetsGroupingAggregation> =
<EuiFlexItem> <EuiFlexItem>
<EuiFlexGroup direction="column" gutterSize="none"> <EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem> <EuiFlexItem>
<AssetCriticalityBadge <AssetCriticalityBadge criticalityLevel={criticalityLevel} />
criticalityLevel={
firstNonNullValue(
bucket.assetCriticality?.buckets?.[0]?.key
) as CriticalityLevelWithUnassigned
}
/>
</EuiFlexItem> </EuiFlexItem>
</EuiFlexGroup> </EuiFlexGroup>
</EuiFlexItem> </EuiFlexItem>

View file

@ -165,6 +165,7 @@ export class AssetInventoryDataClient {
} }
} }
try {
await installDataView( await installDataView(
secSolutionContext.getSpaceId(), secSolutionContext.getSpaceId(),
secSolutionContext.getDataViewsService(), secSolutionContext.getDataViewsService(),
@ -173,6 +174,9 @@ export class AssetInventoryDataClient {
ASSET_INVENTORY_DATA_VIEW_ID_PREFIX, ASSET_INVENTORY_DATA_VIEW_ID_PREFIX,
logger logger
); );
} catch (error) {
logger.error(`Error installing asset inventory data view: ${error.message}`);
}
logger.debug(`Enabled asset inventory`); logger.debug(`Enabled asset inventory`);