[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} />;
},
[ASSET_FIELDS.ASSET_CRITICALITY]: ({ rowIndex }: EuiDataGridCellValueElementProps) => {
const criticality = rows[rowIndex].flattened[
ASSET_FIELDS.ASSET_CRITICALITY
] as CriticalityLevelWithUnassigned;
return <AssetCriticalityBadge criticalityLevel={criticality} />;
const criticality = rows[rowIndex].flattened[ASSET_FIELDS.ASSET_CRITICALITY] as
| CriticalityLevelWithUnassigned
| 'deleted';
return (
<AssetCriticalityBadge
criticalityLevel={criticality === 'deleted' ? 'unassigned' : criticality}
/>
);
},
});

View file

@ -7,7 +7,7 @@
import React, { useState, useEffect } from 'react';
import type { Filter } from '@kbn/es-query';
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 { useAssetInventoryGrouping } from './grouping/use_asset_inventory_grouping';
import { AssetInventoryDataTable } from './asset_inventory_data_table';
@ -82,9 +82,9 @@ const GroupWithURLPagination = ({
<GroupWrapper
data={groupData}
grouping={grouping}
renderChildComponent={(childFilters) => (
renderChildComponent={(currentGroupFilters) => (
<GroupContent
currentGroupFilters={childFilters}
currentGroupFilters={currentGroupFilters}
state={state}
groupingLevel={1}
selectedGroupOptions={selectedGroupOptions}
@ -112,6 +112,44 @@ interface GroupContentProps {
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 = ({
currentGroupFilters,
state,
@ -122,16 +160,21 @@ const GroupContent = ({
}: GroupContentProps) => {
if (groupingLevel < selectedGroupOptions.length) {
const nextGroupingLevel = groupingLevel + 1;
const newParentGroupFilters = mergeCurrentAndParentFilters(
currentGroupFilters,
parentGroupFilters
)
.map(groupFilterMap)
.filter(filterTypeGuard);
return (
<GroupWithLocalPagination
state={state}
groupingLevel={nextGroupingLevel}
selectedGroup={selectedGroupOptions[groupingLevel]}
selectedGroupOptions={selectedGroupOptions}
parentGroupFilters={JSON.stringify([
...currentGroupFilters,
...(parentGroupFilters ? JSON.parse(parentGroupFilters) : []),
])}
parentGroupFilters={JSON.stringify(newParentGroupFilters)}
groupSelectorComponent={groupSelectorComponent}
/>
);
@ -162,10 +205,12 @@ const GroupWithLocalPagination = ({
const [subgroupPageIndex, setSubgroupPageIndex] = useState(0);
const [subgroupPageSize, setSubgroupPageSize] = useState(10);
const groupFilters = parentGroupFilters ? JSON.parse(parentGroupFilters) : [];
const { groupData, grouping, isFetching } = useAssetInventoryGrouping({
state: { ...state, pageIndex: subgroupPageIndex, pageSize: subgroupPageSize },
selectedGroup,
groupFilters: parentGroupFilters ? JSON.parse(parentGroupFilters) : [],
groupFilters,
});
/**
@ -180,13 +225,14 @@ const GroupWithLocalPagination = ({
<GroupWrapper
data={groupData}
grouping={grouping}
renderChildComponent={(childFilters) => (
renderChildComponent={(currentGroupFilters) => (
<GroupContent
currentGroupFilters={childFilters}
currentGroupFilters={currentGroupFilters.map(groupFilterMap).filter(filterTypeGuard)}
state={state}
groupingLevel={groupingLevel}
selectedGroupOptions={selectedGroupOptions}
groupSelectorComponent={groupSelectorComponent}
parentGroupFilters={JSON.stringify(groupFilters)}
/>
)}
activePageIndex={subgroupPageIndex}
@ -207,6 +253,13 @@ interface DataTableWithLocalPagination {
parentGroupFilters?: string;
}
const getDataGridFilter = (filter: Filter | null) => {
if (!filter) return null;
return {
...(filter?.query ?? {}),
};
};
const DataTableWithLocalPagination = ({
state,
currentGroupFilters,
@ -215,12 +268,11 @@ const DataTableWithLocalPagination = ({
const [tablePageIndex, setTablePageIndex] = useState(0);
const [tablePageSize, setTablePageSize] = useState(10);
const combinedFilters = [
...currentGroupFilters,
...(parentGroupFilters ? JSON.parse(parentGroupFilters) : []),
]
.map(({ query }) => (query?.match_phrase || query?.bool?.should ? query : null))
.filter(Boolean);
const combinedFilters = mergeCurrentAndParentFilters(currentGroupFilters, parentGroupFilters)
.map(groupFilterMap)
.filter(filterTypeGuard)
.map(getDataGridFilter)
.filter((filter): filter is NonNullable<Filter['query']> => Boolean(filter));
const newState: AssetInventoryURLStateResult = {
...state,

View file

@ -22,6 +22,8 @@ import {
} from '@kbn/cloud-security-posture-common/utils/ui_metrics';
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 type {
AssetInventoryURLStateResult,
@ -159,24 +161,81 @@ export const useAssetInventoryGrouping = ({
// 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 groupingQuery = getGroupingQuery({
additionalFilters: query ? [query, additionalFilters] : [additionalFilters],
groupByField: currentSelectedGroup,
uniqueValue,
pageNumber: pageIndex * pageSize,
size: pageSize,
sort: [{ groupByField: { order: 'desc' } }],
statsAggregations: getAggregationsByGroupField(currentSelectedGroup),
rootAggregations: [
{
...(!isNoneGroup([currentSelectedGroup]) && {
nullGroupItems: {
missing: { field: currentSelectedGroup },
const groupingQuery = useMemo(
() => ({
...getGroupingQuery({
additionalFilters: query ? [query, additionalFilters] : [additionalFilters],
groupByField: currentSelectedGroup,
uniqueValue,
pageNumber: pageIndex * pageSize,
size: pageSize,
sort: [{ groupByField: { order: 'desc' } }],
statsAggregations: getAggregationsByGroupField(currentSelectedGroup),
rootAggregations: [
{
...(!isNoneGroup([currentSelectedGroup]) && {
nullGroupItems:
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({
query: groupingQuery,

View file

@ -69,6 +69,13 @@ export const groupPanelRenderer: GroupPanelRenderer<AssetsGroupingAggregation> =
switch (selectedGroup) {
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 ? (
<EuiFlexGroup alignItems="center">
<EuiFlexItem>
@ -84,13 +91,7 @@ export const groupPanelRenderer: GroupPanelRenderer<AssetsGroupingAggregation> =
<EuiFlexItem>
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem>
<AssetCriticalityBadge
criticalityLevel={
firstNonNullValue(
bucket.assetCriticality?.buckets?.[0]?.key
) as CriticalityLevelWithUnassigned
}
/>
<AssetCriticalityBadge criticalityLevel={criticalityLevel} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>

View file

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