diff --git a/packages/kbn-rule-data-utils/src/rule_types/o11y_rules.ts b/packages/kbn-rule-data-utils/src/rule_types/o11y_rules.ts index 0b870bc5a11fe..dd2b3af12cc4b 100644 --- a/packages/kbn-rule-data-utils/src/rule_types/o11y_rules.ts +++ b/packages/kbn-rule-data-utils/src/rule_types/o11y_rules.ts @@ -10,6 +10,7 @@ export const OBSERVABILITY_THRESHOLD_RULE_TYPE_ID = 'observability.rules.custom_ export const SLO_BURN_RATE_RULE_TYPE_ID = 'slo.rules.burnRate'; export const METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID = 'metrics.alert.inventory.threshold'; +export const METRIC_THRESHOLD_ALERT_TYPE_ID = 'metrics.alert.threshold'; export const LOG_THRESHOLD_ALERT_TYPE_ID = 'logs.alert.document.count'; export enum ApmRuleType { diff --git a/x-pack/plugins/observability_solution/infra/public/pages/logs/settings/indices_configuration_panel.tsx b/x-pack/plugins/observability_solution/infra/public/pages/logs/settings/indices_configuration_panel.tsx index 7d63dcf57a7ab..4202b4babe728 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/logs/settings/indices_configuration_panel.tsx +++ b/x-pack/plugins/observability_solution/infra/public/pages/logs/settings/indices_configuration_panel.tsx @@ -7,7 +7,7 @@ import { EuiCheckableCard, EuiFormFieldset, EuiSpacer, EuiTitle } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useUiTracker } from '@kbn/observability-shared-plugin/public'; import { logIndexNameReferenceRT, @@ -15,6 +15,14 @@ import { logDataViewReferenceRT, LogIndexReference, } from '@kbn/logs-shared-plugin/common'; +import { EuiCallOut } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { loadRuleAggregations } from '@kbn/triggers-actions-ui-plugin/public'; +import { LOG_THRESHOLD_ALERT_TYPE_ID } from '@kbn/rule-data-utils'; + +import { rulesLocatorID, RulesParams } from '@kbn/observability-plugin/public'; +import { EuiLink } from '@elastic/eui'; +import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; import { FormElement, isFormElementForType } from './form_elements'; import { IndexNamesConfigurationPanel } from './index_names_configuration_panel'; import { IndexPatternConfigurationPanel } from './index_pattern_configuration_panel'; @@ -25,6 +33,19 @@ export const IndicesConfigurationPanel = React.memo<{ isReadOnly: boolean; indicesFormElement: FormElement; }>(({ isLoading, isReadOnly, indicesFormElement }) => { + const { + services: { + http, + share: { + url: { locators }, + }, + }, + } = useKibanaContextForPlugin(); + const [numberOfLogsRules, setNumberOfLogsRules] = useState(0); + + const rulesLocator = locators.get(rulesLocatorID); + const viewAffectedRulesLink = rulesLocator?.useUrl({ type: [LOG_THRESHOLD_ALERT_TYPE_ID] }); + const trackChangeIndexSourceType = useUiTracker({ app: 'infra_logs' }); const changeToIndexPatternType = useCallback(() => { @@ -54,6 +75,23 @@ export const IndicesConfigurationPanel = React.memo<{ }); }, [indicesFormElement, trackChangeIndexSourceType]); + useEffect(() => { + const getNumberOfInfraRules = async () => { + if (http) { + const { ruleExecutionStatus } = await loadRuleAggregations({ + http, + typesFilter: [LOG_THRESHOLD_ALERT_TYPE_ID], + }); + const numberOfRules = Object.values(ruleExecutionStatus).reduce( + (acc, value) => acc + value, + 0 + ); + setNumberOfLogsRules(numberOfRules); + } + }; + getNumberOfInfraRules(); + }, [http]); + return ( )} + {numberOfLogsRules > 0 && indicesFormElement.isDirty && ( + <> + + + + + + + + + + )} ); }); diff --git a/x-pack/plugins/observability_solution/infra/public/pages/metrics/settings.tsx b/x-pack/plugins/observability_solution/infra/public/pages/metrics/settings.tsx index 62d23ef4f8470..a10e26805cc2b 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/metrics/settings.tsx +++ b/x-pack/plugins/observability_solution/infra/public/pages/metrics/settings.tsx @@ -11,11 +11,12 @@ import { useKibana } from '@kbn/kibana-react-plugin/public'; import { SourceConfigurationSettings } from './settings/source_configuration_settings'; export const MetricsSettingsPage = () => { - const uiCapabilities = useKibana().services.application?.capabilities; + const { application, http } = useKibana().services; return ( ); diff --git a/x-pack/plugins/observability_solution/infra/public/pages/metrics/settings/indices_configuration_panel.tsx b/x-pack/plugins/observability_solution/infra/public/pages/metrics/settings/indices_configuration_panel.tsx index b0b53ce34a858..907d4353915ce 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/metrics/settings/indices_configuration_panel.tsx +++ b/x-pack/plugins/observability_solution/infra/public/pages/metrics/settings/indices_configuration_panel.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { EuiLink } from '@elastic/eui'; import { EuiCallOut, EuiCode, @@ -18,8 +19,14 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import React from 'react'; +import { rulesLocatorID, RulesParams } from '@kbn/observability-plugin/public'; +import { + METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID, + METRIC_THRESHOLD_ALERT_TYPE_ID, +} from '@kbn/rule-data-utils'; import { METRICS_INDEX_PATTERN } from '../../../../common/constants'; import { InputFieldProps } from './input_fields'; +import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; interface IndicesConfigurationPanelProps { isLoading: boolean; @@ -27,6 +34,8 @@ interface IndicesConfigurationPanelProps { metricAliasFieldProps: InputFieldProps; metricIndicesExist?: boolean; remoteClustersExist?: boolean; + isMetricAliasChanged?: boolean; + numberOfInfraRules: number; } const METRIC_INDICES_WARNING_TITLE = i18n.translate( @@ -36,6 +45,13 @@ const METRIC_INDICES_WARNING_TITLE = i18n.translate( } ); +const METRIC_INDICES_USED_BY_RULES = i18n.translate( + 'xpack.infra.sourceConfiguration.metricIndicesUsedByRulesTitle', + { + defaultMessage: 'Rules utilize this data source.', + } +); + const REMOTE_CLUSTER_ERROR_TITLE = i18n.translate( 'xpack.infra.sourceConfiguration.remoteClusterConnectionDoNotExistTitle', { @@ -49,97 +65,142 @@ export const IndicesConfigurationPanel = ({ metricAliasFieldProps, metricIndicesExist, remoteClustersExist, -}: IndicesConfigurationPanelProps) => ( - - -

- -

-
- - - - - } - description={ - - } - > - { + const { + services: { + share: { + url: { locators }, + }, + }, + } = useKibanaContextForPlugin(); + + const rulesLocator = locators.get(rulesLocatorID); + const viewAffectedRulesLink = rulesLocator?.useUrl({ + type: [METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID, METRIC_THRESHOLD_ALERT_TYPE_ID], + }); + + return ( + + +

{METRICS_INDEX_PATTERN}, - }} + id="xpack.infra.sourceConfiguration.indicesSectionTitle" + defaultMessage="Indices" /> +

+
+ + + + } - isInvalid={metricAliasFieldProps.isInvalid} - label={ + description={ } > - -
- {remoteClustersExist && !metricIndicesExist && ( - <> - - + helpText={ {METRICS_INDEX_PATTERN}, + }} /> - - - )} - {!remoteClustersExist && !metricIndicesExist && ( - <> - - + } + isInvalid={metricAliasFieldProps.isInvalid} + label={ - - - )} -
-
-); + } + > + + + {isMetricAliasChanged && numberOfInfraRules > 0 && ( + <> + + + + + + + + + + )} + {remoteClustersExist && !metricIndicesExist && ( + <> + + + + + + )} + {!remoteClustersExist && !metricIndicesExist && ( + <> + + + + + + )} + + + ); +}; diff --git a/x-pack/plugins/observability_solution/infra/public/pages/metrics/settings/source_configuration_settings.tsx b/x-pack/plugins/observability_solution/infra/public/pages/metrics/settings/source_configuration_settings.tsx index e3b9794300e85..cbfe09df53b2d 100644 --- a/x-pack/plugins/observability_solution/infra/public/pages/metrics/settings/source_configuration_settings.tsx +++ b/x-pack/plugins/observability_solution/infra/public/pages/metrics/settings/source_configuration_settings.tsx @@ -7,7 +7,7 @@ import { EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { BottomBarActions, Prompt, @@ -17,6 +17,12 @@ import { enableInfrastructureHostsView, enableInfrastructureProfilingIntegration, } from '@kbn/observability-plugin/common'; +import { loadRuleAggregations } from '@kbn/triggers-actions-ui-plugin/public'; +import { HttpSetup } from '@kbn/core-http-browser'; +import { + METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID, + METRIC_THRESHOLD_ALERT_TYPE_ID, +} from '@kbn/rule-data-utils'; import { SourceLoadingPage } from '../../../components/source_loading_page'; import { useSourceContext } from '../../../containers/metrics_source'; import { useInfraMLCapabilitiesContext } from '../../../containers/ml/infra_ml_capabilities'; @@ -31,10 +37,12 @@ import { MetricsPageTemplate } from '../page_template'; import { FeaturesConfigurationPanel } from './features_configuration_panel'; interface SourceConfigurationSettingsProps { shouldAllowEdit: boolean; + http?: HttpSetup; } export const SourceConfigurationSettings = ({ shouldAllowEdit, + http, }: SourceConfigurationSettingsProps) => { useMetricsBreadcrumbs([ { @@ -42,6 +50,25 @@ export const SourceConfigurationSettings = ({ }, ]); + const [numberOfInfraRules, setNumberOfInfraRules] = useState(0); + + useEffect(() => { + const getNumberOfInfraRules = async () => { + if (http) { + const { ruleExecutionStatus } = await loadRuleAggregations({ + http, + typesFilter: [METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID, METRIC_THRESHOLD_ALERT_TYPE_ID], + }); + const numberOfRules = Object.values(ruleExecutionStatus).reduce( + (acc, value) => acc + value, + 0 + ); + setNumberOfInfraRules(numberOfRules); + } + }; + getNumberOfInfraRules(); + }, [http]); + const { createSourceConfiguration, source, @@ -137,6 +164,8 @@ export const SourceConfigurationSettings = ({ readOnly={!isWriteable} metricIndicesExist={metricIndicesExist} remoteClustersExist={remoteClustersExist} + isMetricAliasChanged={Boolean(getUnsavedChanges().metricAlias)} + numberOfInfraRules={numberOfInfraRules} /> diff --git a/x-pack/plugins/observability_solution/observability/public/pages/alert_details/components/page_title.tsx b/x-pack/plugins/observability_solution/observability/public/pages/alert_details/components/page_title.tsx index 78fb0415fbe87..c9f43ee75bf0c 100644 --- a/x-pack/plugins/observability_solution/observability/public/pages/alert_details/components/page_title.tsx +++ b/x-pack/plugins/observability_solution/observability/public/pages/alert_details/components/page_title.tsx @@ -30,10 +30,9 @@ import { css } from '@emotion/react'; import { asDuration } from '../../../../common/utils/formatters'; import { TopAlert } from '../../../typings/alerts'; import { ExperimentalBadge } from '../../../components/experimental_badge'; -import { - METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID, - METRIC_THRESHOLD_ALERT_TYPE_ID, -} from '../alert_details'; +import { METRIC_THRESHOLD_ALERT_TYPE_ID } from '../alert_details'; +import { isAlertDetailsEnabledPerApp } from '../../../utils/is_alert_details_enabled'; +import { usePluginContext } from '../../../hooks/use_plugin_context'; export interface PageTitleProps { alert: TopAlert | null; @@ -53,18 +52,19 @@ export function pageTitleContent(ruleCategory: string) { export function PageTitle({ alert, alertStatus, dataTestSubj }: PageTitleProps) { const { euiTheme } = useEuiTheme(); + const { config } = usePluginContext(); if (!alert) return ; - const showExperimentalBadge = - alert.fields[ALERT_RULE_TYPE_ID] === METRIC_THRESHOLD_ALERT_TYPE_ID || - alert.fields[ALERT_RULE_TYPE_ID] === METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID; + const showExperimentalBadge = alert.fields[ALERT_RULE_TYPE_ID] === METRIC_THRESHOLD_ALERT_TYPE_ID; return (
{pageTitleContent(alert.fields[ALERT_RULE_CATEGORY])} - {showExperimentalBadge && } + {isAlertDetailsEnabledPerApp(alert, config) && showExperimentalBadge && ( + + )} diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/helpers.test.tsx index 31dc79605b167..458902a92fdab 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/helpers.test.tsx @@ -12,6 +12,7 @@ import { updateAppLinks } from '../../links'; import { mockGlobalState } from '../../mock'; import type { Capabilities } from '@kbn/core-capabilities-common'; import { UpsellingService } from '@kbn/security-solution-upselling/service'; +import { uiSettingsServiceMock } from '@kbn/core-ui-settings-browser-mocks'; const defaultAppLinks: AppLinkItems = [ { @@ -29,6 +30,7 @@ const defaultAppLinks: AppLinkItems = [ ]; const mockUpselling = new UpsellingService(); +const mockUiSettingsClient = uiSettingsServiceMock.createStartContract(); describe('helpers', () => { beforeAll(() => { @@ -36,6 +38,7 @@ describe('helpers', () => { capabilities: {} as unknown as Capabilities, experimentalFeatures: mockGlobalState.app.enableExperimental, upselling: mockUpselling, + uiSettingsClient: mockUiSettingsClient, }); }); it('returns the search string', () => { diff --git a/x-pack/plugins/security_solution/public/common/links/links.test.tsx b/x-pack/plugins/security_solution/public/common/links/links.test.tsx index 7e84c26dfa0e9..b54dfbc3dbc42 100644 --- a/x-pack/plugins/security_solution/public/common/links/links.test.tsx +++ b/x-pack/plugins/security_solution/public/common/links/links.test.tsx @@ -9,7 +9,7 @@ import { CASES_FEATURE_ID, SecurityPageName, SERVER_APP_ID } from '../../../comm import type { Capabilities } from '@kbn/core/types'; import { mockGlobalState, TestProviders } from '../mock'; import type { ILicense, LicenseType } from '@kbn/licensing-plugin/common/types'; -import type { AppLinkItems } from './types'; +import type { AppLinkItems, LinkItem, LinksPermissions } from './types'; import { act, renderHook } from '@testing-library/react-hooks'; import { useAppLinks, @@ -18,11 +18,13 @@ import { needsUrlState, updateAppLinks, useLinkExists, + isLinkUiSettingsAllowed, } from './links'; import { createCapabilities } from './test_utils'; import { hasCapabilities } from '../lib/capabilities'; import { UpsellingService } from '@kbn/security-solution-upselling/service'; import React from 'react'; +import { uiSettingsServiceMock } from '@kbn/core-ui-settings-browser-mocks'; const defaultAppLinks: AppLinkItems = [ { @@ -79,6 +81,8 @@ const mockLicense = { hasAtLeast: licensePremiumMock, } as unknown as ILicense; +const mockUiSettingsClient = uiSettingsServiceMock.createStartContract(); + const renderUseAppLinks = () => renderHook<{}, AppLinkItems>(() => useAppLinks(), { wrapper: TestProviders }); const renderUseLinkExists = (id: SecurityPageName) => @@ -95,6 +99,7 @@ describe('Security links', () => { experimentalFeatures: mockExperimentalDefaults, license: mockLicense, upselling: mockUpselling, + uiSettingsClient: mockUiSettingsClient, }); }); @@ -174,6 +179,7 @@ describe('Security links', () => { } as unknown as typeof mockExperimentalDefaults, license: { hasAtLeast: licenseBasicMock } as unknown as ILicense, upselling: mockUpselling, + uiSettingsClient: mockUiSettingsClient, } ); await waitForNextUpdate(); @@ -240,6 +246,7 @@ describe('Security links', () => { } as unknown as typeof mockExperimentalDefaults, license: { hasAtLeast: licenseBasicMock } as unknown as ILicense, upselling, + uiSettingsClient: mockUiSettingsClient, } ); await waitForNextUpdate(); @@ -269,6 +276,7 @@ describe('Security links', () => { experimentalFeatures: mockExperimentalDefaults, license: { hasAtLeast: licenseBasicMock } as unknown as ILicense, upselling: mockUpselling, + uiSettingsClient: mockUiSettingsClient, }); await waitForNextUpdate(); }); @@ -300,6 +308,7 @@ describe('Security links', () => { experimentalFeatures: mockExperimentalDefaults, license: { hasAtLeast: licenseBasicMock } as unknown as ILicense, upselling: mockUpselling, + uiSettingsClient: mockUiSettingsClient, }); await waitForNextUpdate(); }); @@ -338,6 +347,7 @@ describe('Security links', () => { experimentalFeatures: mockExperimentalDefaults, license: mockLicense, upselling: new UpsellingService(), + uiSettingsClient: mockUiSettingsClient, } ); await waitForNextUpdate(); @@ -369,6 +379,7 @@ describe('Security links', () => { experimentalFeatures: mockExperimentalDefaults, license: mockLicense, upselling: mockUpselling, + uiSettingsClient: mockUiSettingsClient, } ); await waitForNextUpdate(); @@ -532,4 +543,74 @@ describe('Security links', () => { ).toBeFalsy(); }); }); + + describe('isLinkUiSettingsAllowed', () => { + const SETTING_KEY = 'test setting'; + const mockedLink: LinkItem = { + id: SecurityPageName.entityAnalyticsAssetClassification, + title: 'test title', + path: '/test_path', + }; + + const mockedPermissions = { + uiSettingsClient: mockUiSettingsClient, + } as unknown as LinksPermissions; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('returns true when uiSettingRequired is not set', () => { + const link: LinkItem = { + ...mockedLink, + uiSettingRequired: undefined, + }; + expect(isLinkUiSettingsAllowed(link, mockedPermissions)).toBeTruthy(); + expect(mockUiSettingsClient.get).not.toHaveBeenCalled(); + }); + + it('returns true when uiSettingRequired is a string and the corresponding UI setting is true', () => { + mockUiSettingsClient.get = jest.fn().mockReturnValue(true); + const link: LinkItem = { + ...mockedLink, + uiSettingRequired: SETTING_KEY, + }; + + expect(isLinkUiSettingsAllowed(link, mockedPermissions)).toBeTruthy(); + expect(mockUiSettingsClient.get).toHaveBeenCalledWith(SETTING_KEY); + }); + + it('returns false when uiSettingRequired is a string and the corresponding UI setting is false', () => { + mockUiSettingsClient.get = jest.fn().mockReturnValue(false); + const link: LinkItem = { + ...mockedLink, + uiSettingRequired: SETTING_KEY, + }; + + expect(isLinkUiSettingsAllowed(link, mockedPermissions)).toBeFalsy(); + expect(mockUiSettingsClient.get).toHaveBeenCalledWith(SETTING_KEY); + }); + + it('returns true when uiSettingRequired is an object and the corresponding UI setting matches the value', () => { + const link: LinkItem = { + ...mockedLink, + uiSettingRequired: { key: SETTING_KEY, value: 'any text' }, + }; + mockUiSettingsClient.get = jest.fn().mockReturnValue('any text'); + + expect(isLinkUiSettingsAllowed(link, mockedPermissions)).toBeTruthy(); + expect(mockUiSettingsClient.get).toHaveBeenCalledWith(SETTING_KEY); + }); + + it('returns false when uiSettingRequired is an object and the corresponding UI setting does not match the value', () => { + const link: LinkItem = { + ...mockedLink, + uiSettingRequired: { key: SETTING_KEY, value: 'any text' }, + }; + mockUiSettingsClient.get = jest.fn().mockReturnValue('different text'); + + expect(isLinkUiSettingsAllowed(link, mockedPermissions)).toBeFalsy(); + expect(mockUiSettingsClient.get).toHaveBeenCalledWith(SETTING_KEY); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/links/links.ts b/x-pack/plugins/security_solution/public/common/links/links.ts index e519ace88336a..a3cfb58f900eb 100644 --- a/x-pack/plugins/security_solution/public/common/links/links.ts +++ b/x-pack/plugins/security_solution/public/common/links/links.ts @@ -153,7 +153,10 @@ const getNormalizedLink = (id: SecurityPageName): Readonly | und const processAppLinks = (appLinks: AppLinkItems, linksPermissions: LinksPermissions): LinkItem[] => appLinks.reduce((acc, { links, ...appLinkWithoutSublinks }) => { - if (!isLinkExperimentalKeyAllowed(appLinkWithoutSublinks, linksPermissions)) { + if ( + !isLinkExperimentalKeyAllowed(appLinkWithoutSublinks, linksPermissions) || + !isLinkUiSettingsAllowed(appLinkWithoutSublinks, linksPermissions) + ) { return acc; } @@ -179,6 +182,23 @@ const processAppLinks = (appLinks: AppLinkItems, linksPermissions: LinksPermissi return acc; }, []); +export const isLinkUiSettingsAllowed = (link: LinkItem, { uiSettingsClient }: LinksPermissions) => { + if (!link.uiSettingRequired) { + return true; + } + + if (typeof link.uiSettingRequired === 'string') { + return uiSettingsClient.get(link.uiSettingRequired) === true; + } + + if (typeof link.uiSettingRequired === 'object') { + return uiSettingsClient.get(link.uiSettingRequired.key) === link.uiSettingRequired.value; + } + + // unsupported uiSettingRequired type + return false; +}; + const isLinkExperimentalKeyAllowed = ( link: LinkItem, { experimentalFeatures }: LinksPermissions diff --git a/x-pack/plugins/security_solution/public/common/links/types.ts b/x-pack/plugins/security_solution/public/common/links/types.ts index 3cb3ab01dd0d4..8b7a05f4a0242 100644 --- a/x-pack/plugins/security_solution/public/common/links/types.ts +++ b/x-pack/plugins/security_solution/public/common/links/types.ts @@ -19,6 +19,7 @@ import type { UpsellingService } from '@kbn/security-solution-upselling/service' import type { AppDeepLinkLocations } from '@kbn/core-application-browser'; import type { Observable } from 'rxjs'; import type { SolutionSideNavItem as ClassicSolutionSideNavItem } from '@kbn/security-solution-side-nav'; +import type { IUiSettingsClient } from '@kbn/core/public'; import type { ExperimentalFeatures } from '../../../common/experimental_features'; import type { RequiredCapabilities } from '../lib/capabilities'; @@ -37,6 +38,7 @@ export type SolutionSideNavItem = ClassicSolutionSideNavItem; export interface LinksPermissions { capabilities: Capabilities; experimentalFeatures: Readonly; + uiSettingsClient: IUiSettingsClient; upselling: UpsellingService; license?: ILicense; } @@ -154,6 +156,13 @@ export interface LinkItem { * Locations where the link is visible in the UI */ visibleIn?: AppDeepLinkLocations[]; + + /** + * Required UI setting to enable a link. + * To enable a link when a boolean UiSetting is true, pass the key as a string. + * To enable a link when a specific value is set for a UiSetting, pass an object with key and value. + */ + uiSettingRequired?: string | { key: string; value: unknown }; } export type AppLinkItems = Readonly; diff --git a/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx b/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx index afca086fcd779..0ecca67e79755 100644 --- a/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx @@ -11,6 +11,7 @@ import { UpsellingService } from '@kbn/security-solution-upselling/service'; import { updateAppLinks } from '../../links'; import { appLinks } from '../../../app_links'; import { useShowTimeline } from './use_show_timeline'; +import { uiSettingsServiceMock } from '@kbn/core-ui-settings-browser-mocks'; const mockUseLocation = jest.fn().mockReturnValue({ pathname: '/overview' }); jest.mock('react-router-dom', () => { @@ -53,6 +54,7 @@ jest.mock('../../lib/kibana', () => { }); const mockUpselling = new UpsellingService(); +const mockUiSettingsClient = uiSettingsServiceMock.createStartContract(); describe('use show timeline', () => { beforeAll(() => { @@ -70,6 +72,7 @@ describe('use show timeline', () => { }, }, upselling: mockUpselling, + uiSettingsClient: mockUiSettingsClient, }); }); diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/use_asset_criticality.ts b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/use_asset_criticality.ts index 5c926c9026535..403ca5c656bf0 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/use_asset_criticality.ts +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/use_asset_criticality.ts @@ -8,6 +8,7 @@ import type { UseMutationResult, UseQueryResult } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useUiSetting$ } from '@kbn/kibana-react-plugin/public'; +import type { SecurityAppError } from '@kbn/securitysolution-t-grid'; import type { CriticalityLevelWithUnassigned } from '../../../../common/entity_analytics/asset_criticality/types'; import { ENABLE_ASSET_CRITICALITY_SETTING } from '../../../../common/constants'; import { useHasSecurityCapability } from '../../../helper_hooks'; @@ -21,21 +22,23 @@ const PRIVILEGES_KEY = 'PRIVILEGES'; const nonAuthorizedResponse: Promise = Promise.resolve({ has_all_required: false, + has_write_permissions: false, + has_read_permissions: false, privileges: { elasticsearch: {}, }, }); export const useAssetCriticalityPrivileges = ( - entityName: string -): UseQueryResult => { + queryKey: string +): UseQueryResult => { const { fetchAssetCriticalityPrivileges } = useEntityAnalyticsRoutes(); const hasEntityAnalyticsCapability = useHasSecurityCapability('entity-analytics'); const [isAssetCriticalityEnabled] = useUiSetting$(ENABLE_ASSET_CRITICALITY_SETTING); const isEnabled = isAssetCriticalityEnabled && hasEntityAnalyticsCapability; return useQuery({ - queryKey: [ASSET_CRITICALITY_KEY, PRIVILEGES_KEY, entityName, isEnabled], + queryKey: [ASSET_CRITICALITY_KEY, PRIVILEGES_KEY, queryKey, isEnabled], queryFn: isEnabled ? fetchAssetCriticalityPrivileges : () => nonAuthorizedResponse, }); }; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/pages/asset_criticality_upload_page.tsx b/x-pack/plugins/security_solution/public/entity_analytics/pages/asset_criticality_upload_page.tsx index dbbd6f6b09cd3..67fd97d2e8026 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/pages/asset_criticality_upload_page.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/pages/asset_criticality_upload_page.tsx @@ -16,16 +16,92 @@ import { EuiSpacer, EuiText, EuiTitle, + EuiEmptyPrompt, + EuiCallOut, + EuiCode, } from '@elastic/eui'; import React from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; - +import { ASSET_CRITICALITY_INDEX_PATTERN } from '../../../common/entity_analytics/asset_criticality'; +import { useUiSetting$, useKibana } from '../../common/lib/kibana'; +import { ENABLE_ASSET_CRITICALITY_SETTING } from '../../../common/constants'; import { AssetCriticalityFileUploader } from '../components/asset_criticality_file_uploader/asset_criticality_file_uploader'; -import { useKibana } from '../../common/lib/kibana'; +import { useAssetCriticalityPrivileges } from '../components/asset_criticality/use_asset_criticality'; +import { useHasSecurityCapability } from '../../helper_hooks'; export const AssetCriticalityUploadPage = () => { const { docLinks } = useKibana().services; const entityAnalyticsLinks = docLinks.links.securitySolution.entityAnalytics; + const hasEntityAnalyticsCapability = useHasSecurityCapability('entity-analytics'); + const [isAssetCriticalityEnabled] = useUiSetting$(ENABLE_ASSET_CRITICALITY_SETTING); + const { + data: privileges, + error: privilegesError, + isLoading, + } = useAssetCriticalityPrivileges('AssetCriticalityUploadPage'); + const hasWritePermissions = privileges?.has_write_permissions; + + if (isLoading) { + // Wait for permission before rendering content to avoid flickering + return null; + } + + if ( + !hasEntityAnalyticsCapability || + !isAssetCriticalityEnabled || + privilegesError?.body.status_code === 403 + ) { + const errorMessage = privilegesError?.body.message ?? ( + + ); + + return ( + + + + } + body={

{errorMessage}

} + /> + ); + } + + if (!hasWritePermissions) { + return ( + + } + color="primary" + iconType="iInCircle" + > + + {ASSET_CRITICALITY_INDEX_PATTERN}, + }} + /> + + + ); + } + return ( <> { - +

diff --git a/x-pack/plugins/security_solution/public/entity_analytics/translations.ts b/x-pack/plugins/security_solution/public/entity_analytics/translations.ts index 8d8e46d9dcfdc..2e06ec9ad1eb9 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/translations.ts +++ b/x-pack/plugins/security_solution/public/entity_analytics/translations.ts @@ -101,7 +101,7 @@ export const EA_DOCS_ENTITY_RISK_SCORE = i18n.translate( export const PREVIEW_MISSING_PERMISSIONS_TITLE = i18n.translate( 'xpack.securitySolution.riskScore.riskScorePreview.missingPermissionsCallout.title', { - defaultMessage: 'Insifficient index privileges to preview data', + defaultMessage: 'Insufficient index privileges to preview data', } ); diff --git a/x-pack/plugins/security_solution/public/management/links.ts b/x-pack/plugins/security_solution/public/management/links.ts index 6700a6a6dc389..91bf4e958f6fb 100644 --- a/x-pack/plugins/security_solution/public/management/links.ts +++ b/x-pack/plugins/security_solution/public/management/links.ts @@ -15,6 +15,7 @@ import { } from '../../common/endpoint/service/authz'; import { BLOCKLIST_PATH, + ENABLE_ASSET_CRITICALITY_SETTING, ENDPOINTS_PATH, ENTITY_ANALYTICS_ASSET_CRITICALITY_PATH, ENTITY_ANALYTICS_MANAGEMENT_PATH, @@ -200,7 +201,7 @@ export const links: LinkItem = { skipUrlState: true, hideTimeline: true, capabilities: [`${SERVER_APP_ID}.entity-analytics`], - licenseType: 'platinum', + uiSettingRequired: ENABLE_ASSET_CRITICALITY_SETTING, }, { id: SecurityPageName.responseActionsHistory, diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 69ab997fefd22..be4194000ea5f 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -383,6 +383,7 @@ export class Plugin implements IPlugin { await pageObjects.infraHome.getWaffleMap(); }); }); + + describe('Infrastructure Source Configuration with Rules', function () { + const esClient = getService('es'); + const supertest = getService('supertest'); + const esDeleteAllIndices = getService('esDeleteAllIndices'); + const logger = getService('log'); + const retryService = getService('retry'); + + describe('Create Metric threshold', () => { + let ruleId: string; + let dataForgeConfig: PartialConfig; + let dataForgeIndices: string[]; + + const METRICS_ALERTS_INDEX = '.alerts-observability.metrics.alerts-default'; + const ALERT_ACTION_INDEX = 'alert-action-metric-threshold'; + + describe('alert and action creation', () => { + before(async () => { + await supertest.patch(`/api/metrics/source/default`).set('kbn-xsrf', 'foo').send({ + anomalyThreshold: 50, + description: '', + metricAlias: 'kbn-data-forge-fake_hosts.fake_hosts-*', + name: 'Default', + }); + dataForgeConfig = { + schedule: [ + { + template: 'good', + start: 'now-10m', + end: 'now+5m', + metrics: [ + { name: 'system.cpu.user.pct', method: 'linear', start: 0.9, end: 0.9 }, + ], + }, + ], + indexing: { dataset: 'fake_hosts' as Dataset }, + }; + dataForgeIndices = await generate({ + client: esClient, + config: dataForgeConfig, + logger, + }); + await waitForDocumentInIndex({ + esClient, + indexName: dataForgeIndices.join(','), + docCountTarget: 45, + retryService, + logger, + }); + const createdRule = await createRule({ + supertest, + logger, + esClient, + ruleTypeId: InfraRuleType.MetricThreshold, + consumer: 'infrastructure', + tags: ['infrastructure'], + name: 'Metric threshold rule', + params: { + criteria: [ + { + aggType: Aggregators.AVERAGE, + comparator: Comparator.GT, + threshold: [0.5], + timeSize: 5, + timeUnit: 'm', + metric: 'system.cpu.user.pct', + }, + ], + sourceId: 'default', + alertOnNoData: true, + alertOnGroupDisappear: true, + }, + schedule: { + interval: '1m', + }, + }); + ruleId = createdRule.id; + }); + + after(async () => { + await supertest.delete(`/api/alerting/rule/${ruleId}`).set('kbn-xsrf', 'foo'); + await esDeleteAllIndices([ALERT_ACTION_INDEX, ...dataForgeIndices]); + await esClient.deleteByQuery({ + index: METRICS_ALERTS_INDEX, + query: { term: { 'kibana.alert.rule.uuid': ruleId } }, + }); + await esClient.deleteByQuery({ + index: '.kibana-event-log-*', + query: { term: { 'kibana.alert.rule.consumer': 'infrastructure' } }, + }); + await cleanup({ client: esClient, config: dataForgeConfig, logger }); + }); + + it('rule should be active', async () => { + const executionStatus = await waitForRuleStatus({ + id: ruleId, + expectedStatus: 'active', + supertest, + retryService, + logger, + }); + expect(executionStatus.status).to.be('active'); + }); + + it('should show a warning callout when user edit the index pattern while at least one rule utilize it ', async () => { + await pageObjects.common.navigateToUrlWithBrowserHistory('infraOps', '/settings'); + const metricIndicesInput = await infraSourceConfigurationForm.getMetricIndicesInput(); + await metricIndicesInput.clearValueWithKeyboard(); + await metricIndicesInput.type('newMatch'); + await pageObjects.infraHome.getInfraIndicesPanelSettingsWarningCalloutUsedByRules(); + }); + }); + }); + }); }); }; diff --git a/x-pack/test/functional/page_objects/infra_home_page.ts b/x-pack/test/functional/page_objects/infra_home_page.ts index 0247ecde88ac9..68df8c69784a9 100644 --- a/x-pack/test/functional/page_objects/infra_home_page.ts +++ b/x-pack/test/functional/page_objects/infra_home_page.ts @@ -294,9 +294,12 @@ export function InfraHomePageProvider({ getService, getPageObjects }: FtrProvide async getInfraMissingMetricsIndicesCallout() { return testSubjects.find('infraIndicesPanelSettingsWarningCallout'); }, + async getInfraIndicesPanelSettingsWarningCalloutUsedByRules() { + return testSubjects.find('infraIndicesPanelSettingsWarningCalloutUsedByRules'); + }, async getInfraMissingRemoteClusterIndicesCallout() { - return testSubjects.find('infraIndicesPanelSettingsDangerCallout'); + return testSubjects.find('infraIndicesPanelSettingsWarningCallout'); }, async openSourceConfigurationFlyout() {