[Cloud Security] CSPM dashboard namespaces selection (#224621)

This commit is contained in:
seanrathier 2025-06-24 08:06:40 -04:00 committed by GitHub
parent 3ec2c7e46e
commit bd412c0f83
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 501 additions and 54 deletions

View file

@ -16,3 +16,7 @@ export const getComplianceDashboardSchema = schema.object({
schema.literal(KSPM_POLICY_TEMPLATE),
]),
});
export const getComplianceDashboardQuerySchema = schema.object({
namespace: schema.maybe(schema.string()),
});

View file

@ -102,6 +102,7 @@ export interface ComplianceDashboardDataV2 {
groupedFindingsEvaluation: GroupedFindingsEvaluation[];
trend: PostureTrend[];
benchmarks: BenchmarkData[];
namespaces: string[];
}
export type RuleSection = CspBenchmarkRuleMetadata['section'];

View file

@ -10,35 +10,49 @@ import { CSPM_POLICY_TEMPLATE, KSPM_POLICY_TEMPLATE } from '@kbn/cloud-security-
import { useKibana } from '../hooks/use_kibana';
import { ComplianceDashboardDataV2, PosturePolicyTemplate } from '../../../common/types_old';
import { STATS_ROUTE_PATH } from '../../../common/constants';
import { DEFAULT_NAMESPACE } from '../constants';
// TODO: consolidate both hooks into one hook with a dynamic key
export const CSPM_STATS_QUERY_KEY = ['csp_cspm_dashboard_stats'];
export const KSPM_STATS_QUERY_KEY = ['csp_kspm_dashboard_stats'];
export const CSPM_STATS_QUERY_KEY = 'csp_cspm_dashboard_stats';
export const KSPM_STATS_QUERY_KEY = 'csp_kspm_dashboard_stats';
export const getStatsRoute = (policyTemplate: PosturePolicyTemplate) => {
return STATS_ROUTE_PATH.replace('{policy_template}', policyTemplate);
};
export const useCspmStatsApi = (
options: UseQueryOptions<unknown, unknown, ComplianceDashboardDataV2, string[]>
options: UseQueryOptions<unknown, unknown, ComplianceDashboardDataV2, string[]>,
namespace: string = DEFAULT_NAMESPACE
) => {
const { http } = useKibana().services;
return useQuery(
CSPM_STATS_QUERY_KEY,
() =>
http.get<ComplianceDashboardDataV2>(getStatsRoute(CSPM_POLICY_TEMPLATE), { version: '2' }),
[CSPM_STATS_QUERY_KEY, namespace],
() => {
return http.get<ComplianceDashboardDataV2>(getStatsRoute(CSPM_POLICY_TEMPLATE), {
version: '2',
query: {
namespace,
},
});
},
options
);
};
export const useKspmStatsApi = (
options: UseQueryOptions<unknown, unknown, ComplianceDashboardDataV2, string[]>
options: UseQueryOptions<unknown, unknown, ComplianceDashboardDataV2, string[]>,
namespace: string = DEFAULT_NAMESPACE
) => {
const { http } = useKibana().services;
return useQuery(
KSPM_STATS_QUERY_KEY,
[KSPM_STATS_QUERY_KEY, namespace],
() =>
http.get<ComplianceDashboardDataV2>(getStatsRoute(KSPM_POLICY_TEMPLATE), { version: '2' }),
http.get<ComplianceDashboardDataV2>(getStatsRoute(KSPM_POLICY_TEMPLATE), {
version: '2',
query: {
namespace,
},
}),
options
);
};

View file

@ -39,8 +39,12 @@ export const LOCAL_STORAGE_3P_INTEGRATIONS_CALLOUT_KEY =
export const LOCAL_STORAGE_VULNERABILITIES_GROUPING_KEY = 'cspLatestVulnerabilitiesGrouping';
export const LOCAL_STORAGE_FINDINGS_GROUPING_KEY = 'cspLatestFindingsGrouping';
export const LOCAL_STORAGE_NAMESPACE_KEY = 'cloudPosture:dashboard:namespace';
export const SESSION_STORAGE_FIELDS_MODAL_SHOW_SELECTED = 'cloudPosture:fieldsModal:showSelected';
export const DEFAULT_NAMESPACE = 'default';
export type CloudPostureIntegrations = Record<
CloudSecurityPolicyTemplate,
CloudPostureIntegrationProps

View file

@ -0,0 +1,29 @@
/*
* 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 { useState, useCallback } from 'react';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import { LOCAL_STORAGE_NAMESPACE_KEY, DEFAULT_NAMESPACE } from '../constants';
export const useActiveNamespace = ({ postureType }: { postureType?: 'cspm' | 'kspm' }) => {
const [localStorageActiveNamespace, localStorageSetActiveNamespace] = useLocalStorage(
`${LOCAL_STORAGE_NAMESPACE_KEY}:${postureType}`,
DEFAULT_NAMESPACE
);
const [activeNamespace, setActiveNamespaceState] = useState<string>(
localStorageActiveNamespace || DEFAULT_NAMESPACE
);
const updateActiveNamespace = useCallback(
(namespace: string) => {
setActiveNamespaceState(namespace);
localStorageSetActiveNamespace(namespace);
},
[localStorageSetActiveNamespace]
);
return { activeNamespace, updateActiveNamespace };
};

View file

@ -0,0 +1,63 @@
/*
* 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 { render, waitFor } from '@testing-library/react';
import { NamespaceSelector } from './namespace_selector';
describe('NamespaceSelector', () => {
const mockProps = {
activeNamespace: 'namespace2',
namespaces: ['default', 'namespace1', 'namespace2'],
onNamespaceChange: jest.fn(),
};
it('renders correctly', () => {
const { getByTestId } = render(<NamespaceSelector {...mockProps} />);
expect(getByTestId('namespace-selector')).toBeInTheDocument();
expect(getByTestId('namespace-selector-dropdown-button')).toHaveTextContent('namespace2');
});
it('opens the popover on button click', async () => {
const { getByTestId } = render(<NamespaceSelector {...mockProps} />);
const button = getByTestId('namespace-selector-dropdown-button');
button.click();
await waitFor(() => {
expect(getByTestId('namespace-selector-menu')).toBeVisible();
expect(getByTestId('namespace-selector-menu')).toHaveTextContent('default');
expect(getByTestId('namespace-selector-menu')).toHaveTextContent('namespace1');
expect(getByTestId('namespace-selector-menu')).toHaveTextContent('namespace2');
});
});
it('calls onNamespaceChange when a namespace is selected', async () => {
const { getByTestId } = render(<NamespaceSelector {...mockProps} />);
const button = getByTestId('namespace-selector-dropdown-button');
button.click();
await waitFor(() => {
expect(getByTestId('namespace-selector-menu')).toBeVisible();
});
const namespace1 = getByTestId('namespace-selector-menu-item-namespace1');
namespace1.click();
await waitFor(() => {
expect(mockProps.onNamespaceChange).toHaveBeenCalledWith('namespace1');
});
});
it('is disabled when only one namespace is available', () => {
const propsWithSingleNamespace = {
...mockProps,
activeNamespace: 'default-disabled',
namespaces: ['default-disabled'],
};
const { getByTestId } = render(<NamespaceSelector {...propsWithSingleNamespace} />);
const button = getByTestId('namespace-selector-dropdown-button');
expect(button).toBeDisabled();
expect(button).toHaveTextContent('Namespace: default-disabled');
});
});

View file

@ -0,0 +1,104 @@
/*
* 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, { useCallback, useMemo, useState } from 'react';
import {
EuiButtonEmpty,
EuiContextMenuItem,
EuiContextMenuPanel,
EuiHorizontalRule,
} from '@elastic/eui';
import { EuiPopover } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
interface NamespaceSelectorProps {
activeNamespace: string;
namespaces: string[];
onNamespaceChange: (namespace: string) => void;
}
export const NamespaceSelector = ({
activeNamespace,
namespaces,
onNamespaceChange,
}: NamespaceSelectorProps) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const onSelectedNamespaceChange = useCallback(
(namespaceKey: string) => {
if (namespaceKey !== activeNamespace) {
onNamespaceChange(namespaceKey);
}
setIsPopoverOpen(false);
},
[activeNamespace, onNamespaceChange]
);
const onButtonClick = useCallback(() => setIsPopoverOpen((currentVal) => !currentVal), []);
const closePopover = useCallback(() => setIsPopoverOpen(false), []);
const isSelectedProps = useCallback(
(namespace: string) => {
return namespace === activeNamespace
? { icon: 'check', 'aria-current': true }
: { icon: 'empty', 'aria-current': undefined };
},
[activeNamespace]
);
const menuItems = useMemo(() => {
return namespaces.map((namespace, index) => (
<React.Fragment key={`namespace-selector-${namespace}`}>
<EuiContextMenuItem
{...isSelectedProps(namespace)}
key={namespace}
data-test-subj={`namespace-selector-menu-item-${namespace}`}
onClick={() => {
onSelectedNamespaceChange(namespace);
}}
>
{namespace}
</EuiContextMenuItem>
{index < namespaces.length - 1 && (
<EuiHorizontalRule margin="none" key={`rule-${namespace}`} />
)}
</React.Fragment>
));
}, [namespaces, isSelectedProps, onSelectedNamespaceChange]);
const button = useMemo(() => {
const title = i18n.translate('xpack.csp.namespaceSelector.title', {
defaultMessage: 'Namespace',
});
return (
<EuiButtonEmpty
data-test-subj="namespace-selector-dropdown-button"
flush="both"
iconSide="right"
iconSize="s"
iconType="arrowDown"
onClick={onButtonClick}
title={activeNamespace}
size="xs"
disabled={namespaces.length < 2}
>
{`${title}: ${activeNamespace}`}
</EuiButtonEmpty>
);
}, [onButtonClick, activeNamespace, namespaces.length]);
return (
<EuiPopover
data-test-subj="namespace-selector"
button={button}
closePopover={closePopover}
isOpen={isPopoverOpen}
panelPaddingSize="none"
>
<EuiContextMenuPanel data-test-subj="namespace-selector-menu" size="s" items={menuItems} />
</EuiPopover>
);
};

View file

@ -36,6 +36,7 @@ import { ComplianceDashboardDataV2 } from '../../../common/types_old';
import { cloudPosturePages } from '../../common/navigation/constants';
import { MemoryRouter } from 'react-router-dom';
import userEvent from '@testing-library/user-event';
import { ExperimentalFeaturesService } from '../../common/experimental_features_service';
jest.mock('@kbn/cloud-security-posture/src/hooks/use_csp_setup_status_api');
jest.mock('../../common/api/use_stats_api');
@ -44,6 +45,12 @@ jest.mock('../../common/hooks/use_is_subscription_status_valid');
jest.mock('../../common/navigation/use_navigate_to_cis_integration_policies');
jest.mock('../../common/navigation/use_csp_integration_link');
// jest.mock('../../common/experimental_features_service', () => ({
// ExperimentalFeaturesService: {
// get: jest.fn(() => ({ cloudSecurityNamespaceSupportEnabled: true })),
// },
// }));
describe('<ComplianceDashboard />', () => {
beforeEach(() => {
jest.resetAllMocks();
@ -72,6 +79,18 @@ describe('<ComplianceDashboard />', () => {
status: 'success',
})
);
jest.mock('../../common/experimental_features_service', () => ({
ExperimentalFeaturesService: {
get: jest.fn(() => ({ cloudSecurityNamespaceSupportEnabled: true })),
},
}));
jest.spyOn(ExperimentalFeaturesService, 'get').mockImplementation(() => {
return {
cloudSecurityNamespaceSupportEnabled: true,
cloudConnectorsEnabled: false,
};
});
});
const ComplianceDashboardWithTestProviders = (route: string) => {
@ -838,3 +857,94 @@ describe('getDefaultTab', () => {
expect(getDefaultTab(pluginStatus, undefined, undefined)).toEqual(undefined);
});
});
// describe('Compliance Dashboard CSPM Namespace Selector', () => {
// (useCspSetupStatusApi as jest.Mock).mockImplementation(() =>
// createReactQueryResponse({
// status: 'success',
// data: {
// cspm: { status: 'indexed' },
// kspm: { status: 'indexed' },
// installedPackageVersion: '1.2.13',
// indicesDetails: [
// {
// index: 'security_solution-cloud_security_posture.misconfiguration_latest',
// status: 'not-empty',
// },
// { index: 'logs-cloud_security_posture.findings-default*', status: 'not-empty' },
// ],
// },
// })
// );
// (useKspmStatsApi as jest.Mock).mockImplementation(() => ({
// isSuccess: true,
// isLoading: false,
// data: mockDashboardData,
// }));
// (useCspmStatsApi as jest.Mock).mockImplementation(() => ({
// isSuccess: true,
// isLoading: false,
// data: mockDashboardData,
// }));
// const renderComplianceDashboardPage = (route = cloudPosturePages.dashboard.path) => {
// return render(
// <TestProvider>
// <MemoryRouter initialEntries={[route]}>
// <ComplianceDashboard />
// </MemoryRouter>
// </TestProvider>
// );
// };
// it('should render namespace selector', () => {
// renderComplianceDashboardPage();
// expect(screen.getByTestId('namespace-selector')).toBeInTheDocument();
// });
// it('should render namespace selector with default value', () => {
// renderComplianceDashboardPage();
// expect(screen.getByTestId('namespace-selector')).toHaveTextContent('default');
// });
// it('should change namespace when a different namespace is selected', async () => {
// renderComplianceDashboardPage();
// const user = userEvent.setup();
// const namespaceSelector = screen.getByTestId('namespace-selector-dropdown-button');
// await user.click(namespaceSelector);
// await waitFor(() => {
// expect(screen.getByTestId('namespace-selector-menu')).toBeVisible();
// user.click(screen.getByTestId('namespace-selector-menu-item-namespace1'));
// });
// await waitFor(() => {
// expect(namespaceSelector).toHaveTextContent('namespace1');
// });
// });
// it('should reset the namespace when the active namespace does not exist', async () => {
// Object.defineProperty(window, 'localStorage', {
// value: {
// getItem: jest.fn((key) => {
// if (key === `${LOCAL_STORAGE_NAMESPACE_KEY}-cspm`) return 'non-existent-namespace';
// return null;
// }),
// setItem: jest.fn(),
// removeItem: jest.fn(),
// clear: jest.fn(),
// },
// writable: true,
// });
// renderComplianceDashboardPage();
// const namespaceSelector = screen.getByTestId('namespace-selector-dropdown-button');
// await waitFor(() => {
// expect(namespaceSelector).toHaveTextContent('default');
// });
// });
// });

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useMemo } from 'react';
import React, { useCallback, useMemo } from 'react';
import { UseQueryResult } from '@tanstack/react-query';
import { EuiEmptyPrompt, EuiIcon, EuiLink, EuiPageHeader, EuiSpacer } from '@elastic/eui';
import { css } from '@emotion/react';
@ -17,6 +17,7 @@ import { CSPM_POLICY_TEMPLATE, KSPM_POLICY_TEMPLATE } from '@kbn/cloud-security-
import type { BaseCspSetupStatus } from '@kbn/cloud-security-posture-common';
import { useCspSetupStatusApi } from '@kbn/cloud-security-posture/src/hooks/use_csp_setup_status_api';
import { encodeQuery } from '@kbn/cloud-security-posture';
import { NO_FINDINGS_STATUS_TEST_SUBJ } from '../../components/test_subjects';
import { useCspIntegrationLink } from '../../common/navigation/use_csp_integration_link';
import type { PosturePolicyTemplate, ComplianceDashboardDataV2 } from '../../../common/types_old';
@ -43,6 +44,9 @@ import { BenchmarksSection } from './dashboard_sections/benchmarks_section';
import { cloudPosturePages, cspIntegrationDocsNavigation } from '../../common/navigation/constants';
import { NO_FINDINGS_STATUS_REFRESH_INTERVAL_MS } from '../../common/constants';
import { useKibana } from '../../common/hooks/use_kibana';
import { NamespaceSelector } from '../../components/namespace_selector';
import { useActiveNamespace } from '../../common/hooks/use_active_namespace';
import { ExperimentalFeaturesService } from '../../common/experimental_features_service';
const POSTURE_TYPE_CSPM = CSPM_POLICY_TEMPLATE;
const POSTURE_TYPE_KSPM = KSPM_POLICY_TEMPLATE;
@ -237,8 +241,10 @@ const determineDashboardDataRefetchInterval = (data: ComplianceDashboardDataV2 |
const TabContent = ({
selectedPostureTypeTab,
activeNamespace,
}: {
selectedPostureTypeTab: PosturePolicyTemplate;
activeNamespace: string;
}) => {
const { data: getSetupStatus } = useCspSetupStatusApi({
refetchInterval: (data) => {
@ -250,14 +256,21 @@ const TabContent = ({
},
});
const isCloudSecurityPostureInstalled = !!getSetupStatus?.installedPackageVersion;
const getCspmDashboardData = useCspmStatsApi({
enabled: isCloudSecurityPostureInstalled && selectedPostureTypeTab === POSTURE_TYPE_CSPM,
refetchInterval: determineDashboardDataRefetchInterval,
});
const getKspmDashboardData = useKspmStatsApi({
enabled: isCloudSecurityPostureInstalled && selectedPostureTypeTab === POSTURE_TYPE_KSPM,
refetchInterval: determineDashboardDataRefetchInterval,
});
const getCspmDashboardData = useCspmStatsApi(
{
enabled: isCloudSecurityPostureInstalled && selectedPostureTypeTab === POSTURE_TYPE_CSPM,
refetchInterval: determineDashboardDataRefetchInterval,
},
activeNamespace
);
const getKspmDashboardData = useKspmStatsApi(
{
enabled: isCloudSecurityPostureInstalled && selectedPostureTypeTab === POSTURE_TYPE_KSPM,
refetchInterval: determineDashboardDataRefetchInterval,
},
activeNamespace
);
const setupStatus = getSetupStatus?.[selectedPostureTypeTab]?.status;
const isStatusManagedInDashboard = setupStatus === 'indexed' || setupStatus === 'not-installed';
const shouldRenderNoFindings = !isCloudSecurityPostureInstalled || !isStatusManagedInDashboard;
@ -315,14 +328,11 @@ const TabContent = ({
};
export const ComplianceDashboard = () => {
const cloudSecurityNamespaceSupportEnabled = useMemo(() => {
return ExperimentalFeaturesService.get().cloudSecurityNamespaceSupportEnabled;
}, []);
const { data: getSetupStatus } = useCspSetupStatusApi();
const isCloudSecurityPostureInstalled = !!getSetupStatus?.installedPackageVersion;
const getCspmDashboardData = useCspmStatsApi({
enabled: isCloudSecurityPostureInstalled,
});
const getKspmDashboardData = useKspmStatsApi({
enabled: isCloudSecurityPostureInstalled,
});
const location = useLocation();
const history = useHistory();
@ -342,6 +352,45 @@ export const ComplianceDashboard = () => {
return tab;
}, [location.pathname]);
const { activeNamespace, updateActiveNamespace } = useActiveNamespace({
postureType: currentTabUrlState,
});
const getCspmDashboardData = useCspmStatsApi(
{
enabled: isCloudSecurityPostureInstalled,
},
activeNamespace
);
const getKspmDashboardData = useKspmStatsApi(
{
enabled: isCloudSecurityPostureInstalled,
},
activeNamespace
);
const onActiveNamespaceChange = useCallback(
(selectedNamespace: string) => {
updateActiveNamespace(selectedNamespace);
},
[updateActiveNamespace]
);
const namespaces = useMemo(() => {
const postureNamespaces =
currentTabUrlState === POSTURE_TYPE_CSPM
? getCspmDashboardData.data?.namespaces || []
: getKspmDashboardData.data?.namespaces || [];
return postureNamespaces.sort((a: string, b: string) => a.localeCompare(b));
}, [currentTabUrlState, getCspmDashboardData.data, getKspmDashboardData.data]);
// if the active namespace is not in the list of namespaces, default to the first available namespace
// this can happen when changing between CSPM and KSPM dashboards and if there is no namespace called "default"
if (activeNamespace && namespaces.length > 0 && !namespaces.includes(activeNamespace)) {
onActiveNamespaceChange(namespaces[0]);
}
const preferredTabUrlState = useMemo(
() => getDefaultTab(getSetupStatus, getCspmDashboardData.data, getKspmDashboardData.data),
[getCspmDashboardData.data, getKspmDashboardData.data, getSetupStatus]
@ -371,7 +420,12 @@ export const ComplianceDashboard = () => {
onClick: () => {
navigateToPostureTypeDashboardTab(cloudPosturePages.cspm_dashboard.path);
},
content: <TabContent selectedPostureTypeTab={selectedTab || POSTURE_TYPE_CSPM} />,
content: (
<TabContent
selectedPostureTypeTab={selectedTab || POSTURE_TYPE_CSPM}
activeNamespace={activeNamespace}
/>
),
},
{
label: i18n.translate('xpack.csp.dashboardTabs.kubernetesTab.tabTitle', {
@ -382,18 +436,48 @@ export const ComplianceDashboard = () => {
onClick: () => {
navigateToPostureTypeDashboardTab(cloudPosturePages.kspm_dashboard.path);
},
content: <TabContent selectedPostureTypeTab={selectedTab || POSTURE_TYPE_KSPM} />,
content: (
<TabContent
selectedPostureTypeTab={selectedTab || POSTURE_TYPE_KSPM}
activeNamespace={activeNamespace}
/>
),
},
]
: [];
}, [
isCloudSecurityPostureInstalled,
preferredTabUrlState,
currentTabUrlState,
preferredTabUrlState,
isCloudSecurityPostureInstalled,
activeNamespace,
history,
services,
services.data.query.queryString,
services.data.query.filterManager,
]);
// if there is more than one namespace, show the namespace selector in the header
const rightSideItems = useMemo(
() =>
namespaces.length > 0 && cloudSecurityNamespaceSupportEnabled
? [
<NamespaceSelector
data-test-subj="namespace-selector"
key={`namespace-selector-${currentTabUrlState}`}
namespaces={namespaces}
activeNamespace={activeNamespace}
onNamespaceChange={onActiveNamespaceChange}
/>,
]
: [],
[
namespaces,
cloudSecurityNamespaceSupportEnabled,
currentTabUrlState,
activeNamespace,
onActiveNamespaceChange,
]
);
return (
<CloudPosturePage>
<EuiPageHeader
@ -406,8 +490,10 @@ export const ComplianceDashboard = () => {
})}
/>
}
rightSideItems={rightSideItems}
tabs={tabs.map(({ content, ...rest }) => rest)}
/>
<EuiSpacer />
<div
data-test-subj={DASHBOARD_CONTAINER}

View file

@ -94,6 +94,7 @@ export const getBenchmarkMockData = (): BenchmarkData => ({
});
export const mockDashboardData: ComplianceDashboardDataV2 = {
namespaces: ['default', 'namespace1', 'namespace2'],
stats: {
totalFailed: 17,
totalPassed: 155,

View file

@ -190,8 +190,8 @@ describe('use_change_csp_rule_state', () => {
await waitFor(() => {
expect(mockInvalidateQueriesSpy).toHaveBeenCalledWith(BENCHMARK_INTEGRATION_QUERY_KEY_V2);
expect(mockInvalidateQueriesSpy).toHaveBeenCalledWith(CSPM_STATS_QUERY_KEY);
expect(mockInvalidateQueriesSpy).toHaveBeenCalledWith(KSPM_STATS_QUERY_KEY);
expect(mockInvalidateQueriesSpy).toHaveBeenCalledWith([CSPM_STATS_QUERY_KEY]);
expect(mockInvalidateQueriesSpy).toHaveBeenCalledWith([KSPM_STATS_QUERY_KEY]);
expect(mockInvalidateQueriesSpy).toHaveBeenCalledWith(CSP_RULES_STATES_QUERY_KEY);
});
});

View file

@ -160,8 +160,8 @@ export const useChangeCspRuleState = () => {
},
onSuccess: (data, variables) => {
queryClient.invalidateQueries(BENCHMARK_INTEGRATION_QUERY_KEY_V2);
queryClient.invalidateQueries(CSPM_STATS_QUERY_KEY);
queryClient.invalidateQueries(KSPM_STATS_QUERY_KEY);
queryClient.invalidateQueries([CSPM_STATS_QUERY_KEY]);
queryClient.invalidateQueries([KSPM_STATS_QUERY_KEY]);
queryClient.invalidateQueries(CSP_RULES_STATES_QUERY_KEY);
showChangeBenchmarkRuleStatesSuccessToast(startServices, {
newState: variables?.newState,

View file

@ -9,7 +9,10 @@ import { transformError } from '@kbn/securitysolution-es-utils';
import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types';
import { CDR_LATEST_NATIVE_MISCONFIGURATIONS_INDEX_ALIAS } from '@kbn/cloud-security-posture-common';
import { getComplianceDashboardSchema } from '../../../common/schemas/stats';
import {
getComplianceDashboardSchema,
getComplianceDashboardQuerySchema,
} from '../../../common/schemas/stats';
import { getSafePostureTypeRuntimeMapping } from '../../../common/runtime_mappings/get_safe_posture_type_runtime_mapping';
import type {
PosturePolicyTemplate,
@ -22,7 +25,7 @@ import { getGroupedFindingsEvaluation } from './get_grouped_findings_evaluation'
import { ClusterWithoutTrend, getClusters } from './get_clusters';
import { getStats } from './get_stats';
import { CspRouter } from '../../types';
import { getTrends, Trends } from './get_trends';
import { getTrends, TrendsDetails } from './get_trends';
import { BenchmarkWithoutTrend, getBenchmarks } from './get_benchmarks';
import { toBenchmarkDocFieldKey } from '../../lib/mapping_field_util';
import { getMutedRulesFilterQuery } from '../benchmark_rules/get_states/v1';
@ -32,7 +35,7 @@ export interface KeyDocCount<TKey = string> {
doc_count: number;
}
const getClustersTrends = (clustersWithoutTrends: ClusterWithoutTrend[], trends: Trends) =>
const getClustersTrends = (clustersWithoutTrends: ClusterWithoutTrend[], trends: TrendsDetails) =>
clustersWithoutTrends.map((cluster) => ({
...cluster,
trend: trends.map(({ timestamp, clusters: clustersTrendData }) => ({
@ -41,7 +44,10 @@ const getClustersTrends = (clustersWithoutTrends: ClusterWithoutTrend[], trends:
})),
}));
const getBenchmarksTrends = (benchmarksWithoutTrends: BenchmarkWithoutTrend[], trends: Trends) => {
const getBenchmarksTrends = (
benchmarksWithoutTrends: BenchmarkWithoutTrend[],
trends: TrendsDetails
) => {
return benchmarksWithoutTrends.map((benchmark) => ({
...benchmark,
trend: trends.map(({ timestamp, benchmarks: benchmarksTrendData }) => {
@ -58,7 +64,7 @@ const getBenchmarksTrends = (benchmarksWithoutTrends: BenchmarkWithoutTrend[], t
}));
};
const getSummaryTrend = (trends: Trends) =>
const getSummaryTrend = (trends: TrendsDetails) =>
trends.map(({ timestamp, summary }) => ({ timestamp, ...summary }));
export const defineGetComplianceDashboardRoute = (router: CspRouter) =>
@ -95,7 +101,6 @@ export const defineGetComplianceDashboardRoute = (router: CspRouter) =>
const params: GetComplianceDashboardRequest = request.params;
const policyTemplate = params.policy_template as PosturePolicyTemplate;
// runtime mappings create the `safe_posture_type` field, which equals to `kspm` or `cspm` based on the value and existence of the `posture_type` field which was introduced at 8.7
// the `query` is then being passed to our getter functions to filter per posture type even for older findings before 8.7
const runtimeMappings: MappingRuntimeFields = getSafePostureTypeRuntimeMapping();
@ -119,8 +124,8 @@ export const defineGetComplianceDashboardRoute = (router: CspRouter) =>
logger.warn(`Could not close PIT for stats endpoint: ${err}`);
});
const clusters = getClustersTrends(clustersWithoutTrends, trends);
const trend = getSummaryTrend(trends);
const clusters = getClustersTrends(clustersWithoutTrends, trends.trends);
const trend = getSummaryTrend(trends.trends);
const body: ComplianceDashboardData = {
stats,
@ -150,6 +155,7 @@ export const defineGetComplianceDashboardRoute = (router: CspRouter) =>
validate: {
request: {
params: getComplianceDashboardSchema,
query: getComplianceDashboardQuerySchema,
},
},
},
@ -169,23 +175,31 @@ export const defineGetComplianceDashboardRoute = (router: CspRouter) =>
const params: GetComplianceDashboardRequest = request.params;
const policyTemplate = params.policy_template as PosturePolicyTemplate;
const namespace = request.query.namespace;
// runtime mappings create the `safe_posture_type` field, which equals to `kspm` or `cspm` based on the value and existence of the `posture_type` field which was introduced at 8.7
// the `query` is then being passed to our getter functions to filter per posture type even for older findings before 8.7
const runtimeMappings: MappingRuntimeFields = getSafePostureTypeRuntimeMapping();
const filter = namespace
? [
{ term: { safe_posture_type: policyTemplate } },
{ term: { 'data_stream.namespace': namespace } },
]
: [{ term: { safe_posture_type: policyTemplate } }];
const query: QueryDslQueryContainer = {
bool: {
filter: [{ term: { safe_posture_type: policyTemplate } }],
filter,
must_not: filteredRules,
},
};
const [stats, groupedFindingsEvaluation, benchmarksWithoutTrends, trends] =
const [stats, groupedFindingsEvaluation, benchmarksWithoutTrends, trendDetails] =
await Promise.all([
getStats(esClient, query, pitId, runtimeMappings, logger),
getGroupedFindingsEvaluation(esClient, query, pitId, runtimeMappings, logger),
getBenchmarks(esClient, query, pitId, runtimeMappings, logger),
getTrends(esClient, policyTemplate, logger),
getTrends(esClient, policyTemplate, logger, namespace),
]);
// Try closing the PIT, if it fails we can safely ignore the error since it closes itself after the keep alive
@ -194,14 +208,15 @@ export const defineGetComplianceDashboardRoute = (router: CspRouter) =>
logger.warn(`Could not close PIT for stats endpoint: ${err}`);
});
const benchmarks = getBenchmarksTrends(benchmarksWithoutTrends, trends);
const trend = getSummaryTrend(trends);
const benchmarks = getBenchmarksTrends(benchmarksWithoutTrends, trendDetails.trends);
const trend = getSummaryTrend(trendDetails.trends);
const body: ComplianceDashboardDataV2 = {
stats,
groupedFindingsEvaluation,
benchmarks,
trend,
namespaces: trendDetails.namespaces,
};
return response.ok({

View file

@ -7,6 +7,7 @@
import { ElasticsearchClient, Logger } from '@kbn/core/server';
import type { SearchRequest } from '@elastic/elasticsearch/lib/api/types';
import { DEFAULT_NAMESPACE_STRING } from '@kbn/core-saved-objects-utils-server';
import { calculatePostureScore } from '../../../common/utils/helpers';
import { BENCHMARK_SCORE_INDEX_DEFAULT_NS } from '../../../common/constants';
import type { PosturePolicyTemplate, Stats } from '../../../common/types_old';
@ -37,12 +38,17 @@ export interface ScoreTrendDoc {
score_by_benchmark_id: ScoreByBenchmarkId;
}
export type Trends = Array<{
export type TrendsDetails = Array<{
timestamp: string;
summary: Stats;
clusters: Record<string, Stats>;
benchmarks: Record<string, Stats>;
}>;
export interface Trends {
trends: TrendsDetails;
namespaces: string[];
}
export interface ScoreTrendAggregateResponse {
by_namespace: {
buckets: Array<{
@ -114,7 +120,7 @@ export const getTrendsQuery = (policyTemplate: PosturePolicyTemplate): SearchReq
},
});
export const formatTrends = (scoreTrendDocs: ScoreTrendDoc[]): Trends => {
export const formatTrends = (scoreTrendDocs: ScoreTrendDoc[]): TrendsDetails => {
return scoreTrendDocs.map((data) => {
return {
timestamp: data['@timestamp'],
@ -163,7 +169,8 @@ export const formatTrends = (scoreTrendDocs: ScoreTrendDoc[]): Trends => {
export const getTrends = async (
esClient: ElasticsearchClient,
policyTemplate: PosturePolicyTemplate,
logger: Logger
logger: Logger,
namespace: string = DEFAULT_NAMESPACE_STRING
): Promise<Trends> => {
try {
const trendsQueryResult = await esClient.search<unknown, ScoreTrendAggregateResponse>(
@ -174,20 +181,28 @@ export const getTrends = async (
const scoreTrendDocs =
trendsQueryResult.aggregations.by_namespace.buckets.map((bucket) => {
const namespace = bucket.key;
const namespaceKey = bucket.key;
const documents = bucket.all_scores?.hits?.hits?.map((hit) => hit._source) || [];
return { [namespace]: { documents } };
return { [namespaceKey]: { documents } };
}) ?? [];
if (!scoreTrendDocs.length) return []; // No trends data available
const result = Object.fromEntries(
if (!scoreTrendDocs.length) return { trends: [], namespaces: [] }; // No trends data available
const namespacedData = Object.fromEntries(
scoreTrendDocs.map((entry) => {
const [key, value] = Object.entries(entry)[0];
return [key, value.documents];
})
);
return formatTrends(result.default); // Return the trends for the default namespace until namespace support will be visible to users.
const namespaceKeys = Object.keys(namespacedData);
if (!namespacedData[namespace]) {
logger.warn(`Namespace '${namespace}' not found in trend results.`);
return { trends: [], namespaces: namespaceKeys };
}
return { trends: formatTrends(namespacedData[namespace]), namespaces: namespaceKeys };
} catch (err) {
logger.error(`Failed to fetch trendlines data ${err.message}`);
logger.error(err);

View file

@ -67,6 +67,7 @@
"@kbn/management-settings-ids",
"@kbn/expandable-flyout",
"@kbn/code-editor-mock",
"@kbn/core-saved-objects-utils-server",
],
"exclude": ["target/**/*"]
}