From d909a9617fa26d967c71f7574f74b2689b786bbb Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Mon, 14 Dec 2020 10:32:57 -0800 Subject: [PATCH 1/7] Revert "[APM] Alerting: Show preview as chart of threshold (#84080)" This reverts commit 9986aff82ec253b9551eddda80cccda34f9e3831. --- .../action_menu/alerting_popover_flyout.tsx | 2 +- .../index.tsx | 0 .../index.stories.tsx | 0 .../index.tsx | 45 ++----- .../PopoverExpression}/index.tsx | 0 .../index.tsx | 5 +- .../index.stories.tsx | 0 .../index.tsx | 78 ++--------- .../index.tsx | 4 +- .../select_anomaly_severity.test.tsx | 0 .../select_anomaly_severity.tsx | 0 .../index.tsx | 59 ++------- .../alerting/chart_preview/index.tsx | 112 ---------------- .../apm/public/components/alerting/fields.tsx | 2 +- .../apm/public/components/alerting/helper.ts | 17 --- .../alerting/register_apm_alerts.ts | 8 +- .../service_alert_trigger.test.tsx | 33 ----- .../chart_preview/get_transaction_duration.ts | 93 ------------- .../get_transaction_error_count.ts | 63 --------- .../get_transaction_error_rate.ts | 84 ------------ .../apm/server/routes/alerts/chart_preview.ts | 72 ---------- .../apm/server/routes/create_apm_api.ts | 12 +- .../basic/tests/alerts/chart_preview.ts | 124 ------------------ .../apm_api_integration/basic/tests/index.ts | 4 - 24 files changed, 43 insertions(+), 774 deletions(-) rename x-pack/plugins/apm/public/components/alerting/{alerting_flyout => AlertingFlyout}/index.tsx (100%) rename x-pack/plugins/apm/public/components/alerting/{error_count_alert_trigger => ErrorCountAlertTrigger}/index.stories.tsx (100%) rename x-pack/plugins/apm/public/components/alerting/{error_count_alert_trigger => ErrorCountAlertTrigger}/index.tsx (65%) rename x-pack/plugins/apm/public/components/alerting/{service_alert_trigger/popover_expression => ServiceAlertTrigger/PopoverExpression}/index.tsx (100%) rename x-pack/plugins/apm/public/components/alerting/{service_alert_trigger => ServiceAlertTrigger}/index.tsx (92%) rename x-pack/plugins/apm/public/components/alerting/{transaction_duration_alert_trigger => TransactionDurationAlertTrigger}/index.stories.tsx (100%) rename x-pack/plugins/apm/public/components/alerting/{transaction_duration_alert_trigger => TransactionDurationAlertTrigger}/index.tsx (70%) rename x-pack/plugins/apm/public/components/alerting/{transaction_duration_anomaly_alert_trigger => TransactionDurationAnomalyAlertTrigger}/index.tsx (96%) rename x-pack/plugins/apm/public/components/alerting/{transaction_duration_anomaly_alert_trigger => TransactionDurationAnomalyAlertTrigger}/select_anomaly_severity.test.tsx (100%) rename x-pack/plugins/apm/public/components/alerting/{transaction_duration_anomaly_alert_trigger => TransactionDurationAnomalyAlertTrigger}/select_anomaly_severity.tsx (100%) rename x-pack/plugins/apm/public/components/alerting/{transaction_error_rate_alert_trigger => TransactionErrorRateAlertTrigger}/index.tsx (71%) delete mode 100644 x-pack/plugins/apm/public/components/alerting/chart_preview/index.tsx delete mode 100644 x-pack/plugins/apm/public/components/alerting/helper.ts delete mode 100644 x-pack/plugins/apm/public/components/alerting/service_alert_trigger/service_alert_trigger.test.tsx delete mode 100644 x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_duration.ts delete mode 100644 x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_count.ts delete mode 100644 x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_rate.ts delete mode 100644 x-pack/plugins/apm/server/routes/alerts/chart_preview.ts delete mode 100644 x-pack/test/apm_api_integration/basic/tests/alerts/chart_preview.ts diff --git a/x-pack/plugins/apm/public/application/action_menu/alerting_popover_flyout.tsx b/x-pack/plugins/apm/public/application/action_menu/alerting_popover_flyout.tsx index 395233735a9d..394b4caea3e7 100644 --- a/x-pack/plugins/apm/public/application/action_menu/alerting_popover_flyout.tsx +++ b/x-pack/plugins/apm/public/application/action_menu/alerting_popover_flyout.tsx @@ -14,7 +14,7 @@ import { i18n } from '@kbn/i18n'; import React, { useState } from 'react'; import { IBasePath } from '../../../../../../src/core/public'; import { AlertType } from '../../../common/alert_types'; -import { AlertingFlyout } from '../../components/alerting/alerting_flyout'; +import { AlertingFlyout } from '../../components/alerting/AlertingFlyout'; const alertLabel = i18n.translate('xpack.apm.home.alertsMenu.alerts', { defaultMessage: 'Alerts', diff --git a/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx b/x-pack/plugins/apm/public/components/alerting/AlertingFlyout/index.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx rename to x-pack/plugins/apm/public/components/alerting/AlertingFlyout/index.tsx diff --git a/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.stories.tsx b/x-pack/plugins/apm/public/components/alerting/ErrorCountAlertTrigger/index.stories.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.stories.tsx rename to x-pack/plugins/apm/public/components/alerting/ErrorCountAlertTrigger/index.stories.tsx diff --git a/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/ErrorCountAlertTrigger/index.tsx similarity index 65% rename from x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.tsx rename to x-pack/plugins/apm/public/components/alerting/ErrorCountAlertTrigger/index.tsx index cce973f8587d..efa792ff4427 100644 --- a/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/ErrorCountAlertTrigger/index.tsx @@ -8,17 +8,12 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { useParams } from 'react-router-dom'; import { ForLastExpression } from '../../../../../triggers_actions_ui/public'; -import { AlertType, ALERT_TYPES_CONFIG } from '../../../../common/alert_types'; +import { ALERT_TYPES_CONFIG, AlertType } from '../../../../common/alert_types'; import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; -import { asInteger } from '../../../../common/utils/formatters'; -import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher'; -import { useFetcher } from '../../../hooks/use_fetcher'; -import { callApmApi } from '../../../services/rest/createCallApmApi'; -import { ChartPreview } from '../chart_preview'; -import { EnvironmentField, IsAboveField, ServiceField } from '../fields'; -import { getAbsoluteTimeRange } from '../helper'; -import { ServiceAlertTrigger } from '../service_alert_trigger'; +import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { EnvironmentField, ServiceField, IsAboveField } from '../fields'; +import { ServiceAlertTrigger } from '../ServiceAlertTrigger'; export interface AlertParams { windowSize: number; @@ -45,23 +40,6 @@ export function ErrorCountAlertTrigger(props: Props) { end, }); - const { threshold, windowSize, windowUnit, environment } = alertParams; - - const { data } = useFetcher(() => { - if (windowSize && windowUnit) { - return callApmApi({ - endpoint: 'GET /api/apm/alerts/chart_preview/transaction_error_count', - params: { - query: { - ...getAbsoluteTimeRange(windowSize, windowUnit), - environment, - serviceName, - }, - }, - }); - } - }, [windowSize, windowUnit, environment, serviceName]); - const defaults = { threshold: 25, windowSize: 1, @@ -86,14 +64,14 @@ export function ErrorCountAlertTrigger(props: Props) { unit={i18n.translate('xpack.apm.errorCountAlertTrigger.errors', { defaultMessage: ' errors', })} - onChange={(value) => setAlertParams('threshold', value || 0)} + onChange={(value) => setAlertParams('threshold', value)} />, - setAlertParams('windowSize', timeWindowSize || '') + onChangeWindowSize={(windowSize) => + setAlertParams('windowSize', windowSize || '') } - onChangeWindowUnit={(timeWindowUnit) => - setAlertParams('windowUnit', timeWindowUnit) + onChangeWindowUnit={(windowUnit) => + setAlertParams('windowUnit', windowUnit) } timeWindowSize={params.windowSize} timeWindowUnit={params.windowUnit} @@ -104,10 +82,6 @@ export function ErrorCountAlertTrigger(props: Props) { />, ]; - const chartPreview = ( - - ); - return ( ); } diff --git a/x-pack/plugins/apm/public/components/alerting/service_alert_trigger/popover_expression/index.tsx b/x-pack/plugins/apm/public/components/alerting/ServiceAlertTrigger/PopoverExpression/index.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/alerting/service_alert_trigger/popover_expression/index.tsx rename to x-pack/plugins/apm/public/components/alerting/ServiceAlertTrigger/PopoverExpression/index.tsx diff --git a/x-pack/plugins/apm/public/components/alerting/service_alert_trigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/ServiceAlertTrigger/index.tsx similarity index 92% rename from x-pack/plugins/apm/public/components/alerting/service_alert_trigger/index.tsx rename to x-pack/plugins/apm/public/components/alerting/ServiceAlertTrigger/index.tsx index 0a12f79bf61a..b4d3e8f3ad24 100644 --- a/x-pack/plugins/apm/public/components/alerting/service_alert_trigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/ServiceAlertTrigger/index.tsx @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGrid, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import React, { useEffect } from 'react'; +import { EuiSpacer, EuiFlexGrid, EuiFlexItem } from '@elastic/eui'; import { useParams } from 'react-router-dom'; interface Props { @@ -14,7 +14,6 @@ interface Props { setAlertProperty: (key: string, value: any) => void; defaults: Record; fields: React.ReactNode[]; - chartPreview?: React.ReactNode; } export function ServiceAlertTrigger(props: Props) { @@ -26,7 +25,6 @@ export function ServiceAlertTrigger(props: Props) { setAlertProperty, alertTypeName, defaults, - chartPreview, } = props; const params: Record = { @@ -63,7 +61,6 @@ export function ServiceAlertTrigger(props: Props) { ))} - {chartPreview} ); diff --git a/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.stories.tsx b/x-pack/plugins/apm/public/components/alerting/TransactionDurationAlertTrigger/index.stories.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.stories.tsx rename to x-pack/plugins/apm/public/components/alerting/TransactionDurationAlertTrigger/index.stories.tsx diff --git a/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/TransactionDurationAlertTrigger/index.tsx similarity index 70% rename from x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.tsx rename to x-pack/plugins/apm/public/components/alerting/TransactionDurationAlertTrigger/index.tsx index f18e407cc58d..3566850aa24c 100644 --- a/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/TransactionDurationAlertTrigger/index.tsx @@ -4,31 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ import { EuiSelect } from '@elastic/eui'; +import { useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { map } from 'lodash'; import React from 'react'; -import { useParams } from 'react-router-dom'; -import { useFetcher } from '../../../../../observability/public'; import { ForLastExpression } from '../../../../../triggers_actions_ui/public'; import { ALERT_TYPES_CONFIG } from '../../../../common/alert_types'; -import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; -import { TimeSeries } from '../../../../typings/timeseries'; -import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; -import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher'; -import { callApmApi } from '../../../services/rest/createCallApmApi'; -import { getResponseTimeTickFormatter } from '../../shared/charts/transaction_charts/helper'; -import { useFormatter } from '../../shared/charts/transaction_charts/use_formatter'; -import { ChartPreview } from '../chart_preview'; +import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { ServiceAlertTrigger } from '../ServiceAlertTrigger'; +import { PopoverExpression } from '../ServiceAlertTrigger/PopoverExpression'; +import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; import { EnvironmentField, - IsAboveField, ServiceField, TransactionTypeField, + IsAboveField, } from '../fields'; -import { getAbsoluteTimeRange } from '../helper'; -import { ServiceAlertTrigger } from '../service_alert_trigger'; -import { PopoverExpression } from '../service_alert_trigger/popover_expression'; +import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; interface AlertParams { windowSize: number; @@ -70,58 +63,14 @@ interface Props { export function TransactionDurationAlertTrigger(props: Props) { const { setAlertParams, alertParams, setAlertProperty } = props; const { urlParams } = useUrlParams(); - const { transactionTypes, transactionType } = useApmServiceContext(); + const { transactionTypes } = useApmServiceContext(); const { serviceName } = useParams<{ serviceName?: string }>(); - const { start, end } = urlParams; + const { start, end, transactionType } = urlParams; const { environmentOptions } = useEnvironmentsFetcher({ serviceName, start, end, }); - const { - aggregationType, - environment, - threshold, - windowSize, - windowUnit, - } = alertParams; - - const { data } = useFetcher(() => { - if (windowSize && windowUnit) { - return callApmApi({ - endpoint: 'GET /api/apm/alerts/chart_preview/transaction_duration', - params: { - query: { - ...getAbsoluteTimeRange(windowSize, windowUnit), - aggregationType, - environment, - serviceName, - transactionType: alertParams.transactionType, - }, - }, - }); - } - }, [ - aggregationType, - environment, - serviceName, - alertParams.transactionType, - windowSize, - windowUnit, - ]); - - const { formatter } = useFormatter([{ data: data ?? [] } as TimeSeries]); - const yTickFormat = getResponseTimeTickFormatter(formatter); - // The threshold from the form is in ms. Convert to µs. - const thresholdMs = threshold * 1000; - - const chartPreview = ( - - ); if (!transactionTypes.length || !serviceName) { return null; @@ -132,7 +81,9 @@ export function TransactionDurationAlertTrigger(props: Props) { aggregationType: 'avg', windowSize: 5, windowUnit: 'm', - transactionType, + + // use the current transaction type or default to the first in the list + transactionType: transactionType || transactionTypes[0], environment: urlParams.environment || ENVIRONMENT_ALL.value, }; @@ -176,7 +127,7 @@ export function TransactionDurationAlertTrigger(props: Props) { unit={i18n.translate('xpack.apm.transactionDurationAlertTrigger.ms', { defaultMessage: 'ms', })} - onChange={(value) => setAlertParams('threshold', value || 0)} + onChange={(value) => setAlertParams('threshold', value)} />, @@ -197,9 +148,8 @@ export function TransactionDurationAlertTrigger(props: Props) { return ( diff --git a/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/index.tsx similarity index 96% rename from x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/index.tsx rename to x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/index.tsx index 10c4bbff0839..ff5939c60137 100644 --- a/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/index.tsx @@ -11,8 +11,8 @@ import { ANOMALY_SEVERITY } from '../../../../../ml/common'; import { ALERT_TYPES_CONFIG } from '../../../../common/alert_types'; import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; -import { ServiceAlertTrigger } from '../service_alert_trigger'; -import { PopoverExpression } from '../service_alert_trigger/popover_expression'; +import { ServiceAlertTrigger } from '../ServiceAlertTrigger'; +import { PopoverExpression } from '../ServiceAlertTrigger/PopoverExpression'; import { AnomalySeverity, SelectAnomalySeverity, diff --git a/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/select_anomaly_severity.test.tsx b/x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/select_anomaly_severity.test.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/select_anomaly_severity.test.tsx rename to x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/select_anomaly_severity.test.tsx diff --git a/x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/select_anomaly_severity.tsx b/x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/select_anomaly_severity.tsx similarity index 100% rename from x-pack/plugins/apm/public/components/alerting/transaction_duration_anomaly_alert_trigger/select_anomaly_severity.tsx rename to x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/select_anomaly_severity.tsx diff --git a/x-pack/plugins/apm/public/components/alerting/transaction_error_rate_alert_trigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/TransactionErrorRateAlertTrigger/index.tsx similarity index 71% rename from x-pack/plugins/apm/public/components/alerting/transaction_error_rate_alert_trigger/index.tsx rename to x-pack/plugins/apm/public/components/alerting/TransactionErrorRateAlertTrigger/index.tsx index 9707df9e8633..f723febde389 100644 --- a/x-pack/plugins/apm/public/components/alerting/transaction_error_rate_alert_trigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/TransactionErrorRateAlertTrigger/index.tsx @@ -3,26 +3,22 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; import { useParams } from 'react-router-dom'; +import React from 'react'; import { ForLastExpression } from '../../../../../triggers_actions_ui/public'; -import { AlertType, ALERT_TYPES_CONFIG } from '../../../../common/alert_types'; -import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; -import { asPercent } from '../../../../common/utils/formatters'; -import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; -import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { ALERT_TYPES_CONFIG, AlertType } from '../../../../common/alert_types'; import { useEnvironmentsFetcher } from '../../../hooks/use_environments_fetcher'; -import { useFetcher } from '../../../hooks/use_fetcher'; -import { callApmApi } from '../../../services/rest/createCallApmApi'; -import { ChartPreview } from '../chart_preview'; +import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { ServiceAlertTrigger } from '../ServiceAlertTrigger'; + +import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; import { - EnvironmentField, - IsAboveField, ServiceField, TransactionTypeField, + EnvironmentField, + IsAboveField, } from '../fields'; -import { getAbsoluteTimeRange } from '../helper'; -import { ServiceAlertTrigger } from '../service_alert_trigger'; +import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; interface AlertParams { windowSize: number; @@ -51,32 +47,6 @@ export function TransactionErrorRateAlertTrigger(props: Props) { end, }); - const { threshold, windowSize, windowUnit, environment } = alertParams; - - const thresholdAsPercent = (threshold ?? 0) / 100; - - const { data } = useFetcher(() => { - if (windowSize && windowUnit) { - return callApmApi({ - endpoint: 'GET /api/apm/alerts/chart_preview/transaction_error_rate', - params: { - query: { - ...getAbsoluteTimeRange(windowSize, windowUnit), - environment, - serviceName, - transactionType: alertParams.transactionType, - }, - }, - }); - } - }, [ - alertParams.transactionType, - environment, - serviceName, - windowSize, - windowUnit, - ]); - if (serviceName && !transactionTypes.length) { return null; } @@ -109,7 +79,7 @@ export function TransactionErrorRateAlertTrigger(props: Props) { setAlertParams('threshold', value || 0)} + onChange={(value) => setAlertParams('threshold', value)} />, @@ -127,14 +97,6 @@ export function TransactionErrorRateAlertTrigger(props: Props) { />, ]; - const chartPreview = ( - asPercent(d, 1)} - threshold={thresholdAsPercent} - /> - ); - return ( ); } diff --git a/x-pack/plugins/apm/public/components/alerting/chart_preview/index.tsx b/x-pack/plugins/apm/public/components/alerting/chart_preview/index.tsx deleted file mode 100644 index 1ed5748cd757..000000000000 --- a/x-pack/plugins/apm/public/components/alerting/chart_preview/index.tsx +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - AnnotationDomainTypes, - Axis, - BarSeries, - Chart, - LineAnnotation, - niceTimeFormatter, - Position, - RectAnnotation, - RectAnnotationDatum, - ScaleType, - Settings, - TickFormatter, -} from '@elastic/charts'; -import { EuiSpacer } from '@elastic/eui'; -import React from 'react'; -import { Coordinate } from '../../../../typings/timeseries'; -import { useTheme } from '../../../hooks/use_theme'; - -interface ChartPreviewProps { - yTickFormat?: TickFormatter; - data?: Coordinate[]; - threshold: number; -} - -export function ChartPreview({ - data = [], - yTickFormat, - threshold, -}: ChartPreviewProps) { - const theme = useTheme(); - const thresholdOpacity = 0.3; - const timestamps = data.map((d) => d.x); - const xMin = Math.min(...timestamps); - const xMax = Math.max(...timestamps); - const xFormatter = niceTimeFormatter([xMin, xMax]); - - // Make the maximum Y value either the actual max or 20% more than the threshold - const values = data.map((d) => d.y ?? 0); - const yMax = Math.max(...values, threshold * 1.2); - - const style = { - fill: theme.eui.euiColorVis9, - line: { - strokeWidth: 2, - stroke: theme.eui.euiColorVis9, - opacity: 1, - }, - opacity: thresholdOpacity, - }; - - const rectDataValues: RectAnnotationDatum[] = [ - { - coordinates: { - x0: null, - x1: null, - y0: threshold, - y1: null, - }, - }, - ]; - - return ( - <> - - - - - - - - - - - ); -} diff --git a/x-pack/plugins/apm/public/components/alerting/fields.tsx b/x-pack/plugins/apm/public/components/alerting/fields.tsx index 9e814bb1b58c..858604d2baa2 100644 --- a/x-pack/plugins/apm/public/components/alerting/fields.tsx +++ b/x-pack/plugins/apm/public/components/alerting/fields.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiSelectOption } from '@elastic/eui'; import { getEnvironmentLabel } from '../../../common/environment_filter_values'; -import { PopoverExpression } from './service_alert_trigger/popover_expression'; +import { PopoverExpression } from './ServiceAlertTrigger/PopoverExpression'; const ALL_OPTION = i18n.translate('xpack.apm.alerting.fields.all_option', { defaultMessage: 'All', diff --git a/x-pack/plugins/apm/public/components/alerting/helper.ts b/x-pack/plugins/apm/public/components/alerting/helper.ts deleted file mode 100644 index fd3aebc7495a..000000000000 --- a/x-pack/plugins/apm/public/components/alerting/helper.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import datemath from '@elastic/datemath'; - -export function getAbsoluteTimeRange(windowSize: number, windowUnit: string) { - const now = new Date().toISOString(); - - return { - start: - datemath.parse(`now-${windowSize}${windowUnit}`)?.toISOString() ?? now, - end: now, - }; -} diff --git a/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts b/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts index 6dc2cb3163b1..988e335af5b7 100644 --- a/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts +++ b/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts @@ -25,7 +25,7 @@ export function registerApmAlerts( documentationUrl(docLinks) { return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/apm-alerts.html`; }, - alertParamsExpression: lazy(() => import('./error_count_alert_trigger')), + alertParamsExpression: lazy(() => import('./ErrorCountAlertTrigger')), validate: () => ({ errors: [], }), @@ -60,7 +60,7 @@ export function registerApmAlerts( return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/apm-alerts.html`; }, alertParamsExpression: lazy( - () => import('./transaction_duration_alert_trigger') + () => import('./TransactionDurationAlertTrigger') ), validate: () => ({ errors: [], @@ -97,7 +97,7 @@ export function registerApmAlerts( return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/apm-alerts.html`; }, alertParamsExpression: lazy( - () => import('./transaction_error_rate_alert_trigger') + () => import('./TransactionErrorRateAlertTrigger') ), validate: () => ({ errors: [], @@ -134,7 +134,7 @@ export function registerApmAlerts( return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/apm-alerts.html`; }, alertParamsExpression: lazy( - () => import('./transaction_duration_anomaly_alert_trigger') + () => import('./TransactionDurationAnomalyAlertTrigger') ), validate: () => ({ errors: [], diff --git a/x-pack/plugins/apm/public/components/alerting/service_alert_trigger/service_alert_trigger.test.tsx b/x-pack/plugins/apm/public/components/alerting/service_alert_trigger/service_alert_trigger.test.tsx deleted file mode 100644 index 72611043bbed..000000000000 --- a/x-pack/plugins/apm/public/components/alerting/service_alert_trigger/service_alert_trigger.test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { render } from '@testing-library/react'; -import React, { ReactNode } from 'react'; -import { MemoryRouter } from 'react-router-dom'; -import { ServiceAlertTrigger } from './'; - -function Wrapper({ children }: { children?: ReactNode }) { - return {children}; -} - -describe('ServiceAlertTrigger', () => { - it('renders', () => { - expect(() => - render( - {}} - setAlertProperty={() => {}} - />, - { - wrapper: Wrapper, - } - ) - ).not.toThrowError(); - }); -}); diff --git a/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_duration.ts b/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_duration.ts deleted file mode 100644 index 37e3a2f201fb..000000000000 --- a/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_duration.ts +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { MetricsAggregationResponsePart } from '../../../../../../typings/elasticsearch/aggregations'; -import { - PROCESSOR_EVENT, - SERVICE_NAME, - TRANSACTION_DURATION, - TRANSACTION_TYPE, -} from '../../../../common/elasticsearch_fieldnames'; -import { ProcessorEvent } from '../../../../common/processor_event'; -import { rangeFilter } from '../../../../common/utils/range_filter'; -import { AlertParams } from '../../../routes/alerts/chart_preview'; -import { getEnvironmentUiFilterES } from '../../helpers/convert_ui_filters/get_environment_ui_filter_es'; -import { getBucketSize } from '../../helpers/get_bucket_size'; -import { Setup, SetupTimeRange } from '../../helpers/setup_request'; - -export async function getTransactionDurationChartPreview({ - alertParams, - setup, -}: { - alertParams: AlertParams; - setup: Setup & SetupTimeRange; -}) { - const { apmEventClient, start, end } = setup; - const { - aggregationType, - environment, - serviceName, - transactionType, - } = alertParams; - - const query = { - bool: { - filter: [ - { range: rangeFilter(start, end) }, - { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, - ...(serviceName ? [{ term: { [SERVICE_NAME]: serviceName } }] : []), - ...(transactionType - ? [{ term: { [TRANSACTION_TYPE]: transactionType } }] - : []), - ...getEnvironmentUiFilterES(environment), - ], - }, - }; - - const { intervalString } = getBucketSize({ start, end, numBuckets: 20 }); - - const aggs = { - timeseries: { - date_histogram: { - field: '@timestamp', - fixed_interval: intervalString, - }, - aggs: { - agg: - aggregationType === 'avg' - ? { avg: { field: TRANSACTION_DURATION } } - : { - percentiles: { - field: TRANSACTION_DURATION, - percents: [aggregationType === '95th' ? 95 : 99], - }, - }, - }, - }, - }; - const params = { - apm: { events: [ProcessorEvent.transaction] }, - body: { size: 0, query, aggs }, - }; - const resp = await apmEventClient.search(params); - - if (!resp.aggregations) { - return []; - } - - return resp.aggregations.timeseries.buckets.map((bucket) => { - const percentilesKey = aggregationType === '95th' ? '95.0' : '99.0'; - const x = bucket.key; - const y = - aggregationType === 'avg' - ? (bucket.agg as MetricsAggregationResponsePart).value - : (bucket.agg as { values: Record }).values[ - percentilesKey - ]; - - return { x, y }; - }); -} diff --git a/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_count.ts b/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_count.ts deleted file mode 100644 index 28316298aeaa..000000000000 --- a/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_count.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { SERVICE_NAME } from '../../../../common/elasticsearch_fieldnames'; -import { ProcessorEvent } from '../../../../common/processor_event'; -import { rangeFilter } from '../../../../common/utils/range_filter'; -import { AlertParams } from '../../../routes/alerts/chart_preview'; -import { getEnvironmentUiFilterES } from '../../helpers/convert_ui_filters/get_environment_ui_filter_es'; -import { getBucketSize } from '../../helpers/get_bucket_size'; -import { Setup, SetupTimeRange } from '../../helpers/setup_request'; - -export async function getTransactionErrorCountChartPreview({ - setup, - alertParams, -}: { - setup: Setup & SetupTimeRange; - alertParams: AlertParams; -}) { - const { apmEventClient, start, end } = setup; - const { serviceName, environment } = alertParams; - - const query = { - bool: { - filter: [ - { range: rangeFilter(start, end) }, - ...(serviceName ? [{ term: { [SERVICE_NAME]: serviceName } }] : []), - ...getEnvironmentUiFilterES(environment), - ], - }, - }; - - const { intervalString } = getBucketSize({ start, end, numBuckets: 20 }); - - const aggs = { - timeseries: { - date_histogram: { - field: '@timestamp', - fixed_interval: intervalString, - }, - }, - }; - - const params = { - apm: { events: [ProcessorEvent.error] }, - body: { size: 0, query, aggs }, - }; - - const resp = await apmEventClient.search(params); - - if (!resp.aggregations) { - return []; - } - - return resp.aggregations.timeseries.buckets.map((bucket) => { - return { - x: bucket.key, - y: bucket.doc_count, - }; - }); -} diff --git a/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_rate.ts b/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_rate.ts deleted file mode 100644 index fae43ef148cf..000000000000 --- a/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_rate.ts +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - PROCESSOR_EVENT, - SERVICE_NAME, - TRANSACTION_TYPE, -} from '../../../../common/elasticsearch_fieldnames'; -import { ProcessorEvent } from '../../../../common/processor_event'; -import { rangeFilter } from '../../../../common/utils/range_filter'; -import { AlertParams } from '../../../routes/alerts/chart_preview'; -import { getEnvironmentUiFilterES } from '../../helpers/convert_ui_filters/get_environment_ui_filter_es'; -import { getBucketSize } from '../../helpers/get_bucket_size'; -import { Setup, SetupTimeRange } from '../../helpers/setup_request'; -import { - calculateTransactionErrorPercentage, - getOutcomeAggregation, -} from '../../helpers/transaction_error_rate'; - -export async function getTransactionErrorRateChartPreview({ - setup, - alertParams, -}: { - setup: Setup & SetupTimeRange; - alertParams: AlertParams; -}) { - const { apmEventClient, start, end } = setup; - const { serviceName, environment, transactionType } = alertParams; - - const query = { - bool: { - filter: [ - { range: rangeFilter(start, end) }, - { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, - ...(serviceName ? [{ term: { [SERVICE_NAME]: serviceName } }] : []), - ...(transactionType - ? [{ term: { [TRANSACTION_TYPE]: transactionType } }] - : []), - ...getEnvironmentUiFilterES(environment), - ], - }, - }; - - const outcomes = getOutcomeAggregation({ - searchAggregatedTransactions: false, - }); - - const { intervalString } = getBucketSize({ start, end, numBuckets: 20 }); - - const aggs = { - outcomes, - timeseries: { - date_histogram: { - field: '@timestamp', - fixed_interval: intervalString, - }, - aggs: { outcomes }, - }, - }; - - const params = { - apm: { events: [ProcessorEvent.transaction] }, - body: { size: 0, query, aggs }, - }; - - const resp = await apmEventClient.search(params); - - if (!resp.aggregations) { - return []; - } - - return resp.aggregations.timeseries.buckets.map((bucket) => { - const errorPercentage = calculateTransactionErrorPercentage( - bucket.outcomes - ); - return { - x: bucket.key, - y: errorPercentage, - }; - }); -} diff --git a/x-pack/plugins/apm/server/routes/alerts/chart_preview.ts b/x-pack/plugins/apm/server/routes/alerts/chart_preview.ts deleted file mode 100644 index dc8bf45de091..000000000000 --- a/x-pack/plugins/apm/server/routes/alerts/chart_preview.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import * as t from 'io-ts'; -import { getTransactionDurationChartPreview } from '../../lib/alerts/chart_preview/get_transaction_duration'; -import { getTransactionErrorCountChartPreview } from '../../lib/alerts/chart_preview/get_transaction_error_count'; -import { getTransactionErrorRateChartPreview } from '../../lib/alerts/chart_preview/get_transaction_error_rate'; -import { setupRequest } from '../../lib/helpers/setup_request'; -import { createRoute } from '../create_route'; -import { rangeRt } from '../default_api_types'; - -const alertParamsRt = t.intersection([ - t.partial({ - aggregationType: t.union([ - t.literal('avg'), - t.literal('95th'), - t.literal('99th'), - ]), - serviceName: t.string, - environment: t.string, - transactionType: t.string, - }), - rangeRt, -]); - -export type AlertParams = t.TypeOf; - -export const transactionErrorRateChartPreview = createRoute({ - endpoint: 'GET /api/apm/alerts/chart_preview/transaction_error_rate', - params: t.type({ query: alertParamsRt }), - options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { _debug, ...alertParams } = context.params.query; - - return getTransactionErrorRateChartPreview({ - setup, - alertParams, - }); - }, -}); - -export const transactionErrorCountChartPreview = createRoute({ - endpoint: 'GET /api/apm/alerts/chart_preview/transaction_error_count', - params: t.type({ query: alertParamsRt }), - options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { _debug, ...alertParams } = context.params.query; - return getTransactionErrorCountChartPreview({ - setup, - alertParams, - }); - }, -}); - -export const transactionDurationChartPreview = createRoute({ - endpoint: 'GET /api/apm/alerts/chart_preview/transaction_duration', - params: t.type({ query: alertParamsRt }), - options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { _debug, ...alertParams } = context.params.query; - - return getTransactionDurationChartPreview({ - alertParams, - setup, - }); - }, -}); diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index b09175a6841f..d34e67083b03 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -101,11 +101,6 @@ import { rumVisitorsBreakdownRoute, rumWebCoreVitals, } from './rum_client'; -import { - transactionErrorRateChartPreview, - transactionErrorCountChartPreview, - transactionDurationChartPreview, -} from './alerts/chart_preview'; const createApmApi = () => { const api = createApi() @@ -209,12 +204,7 @@ const createApmApi = () => { .add(rumJSErrors) .add(rumUrlSearch) .add(rumLongTaskMetrics) - .add(rumHasDataRoute) - - // Alerting - .add(transactionErrorCountChartPreview) - .add(transactionDurationChartPreview) - .add(transactionErrorRateChartPreview); + .add(rumHasDataRoute); return api; }; diff --git a/x-pack/test/apm_api_integration/basic/tests/alerts/chart_preview.ts b/x-pack/test/apm_api_integration/basic/tests/alerts/chart_preview.ts deleted file mode 100644 index 3119de47a863..000000000000 --- a/x-pack/test/apm_api_integration/basic/tests/alerts/chart_preview.ts +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { format } from 'url'; -import archives from '../../../common/archives_metadata'; -import { FtrProviderContext } from '../../../common/ftr_provider_context'; - -export default function ApiTest({ getService }: FtrProviderContext) { - const esArchiver = getService('esArchiver'); - const supertest = getService('supertest'); - const archiveName = 'apm_8.0.0'; - const { end } = archives[archiveName]; - const start = new Date(Date.parse(end) - 600000).toISOString(); - - describe('Alerting chart previews', () => { - describe('GET /api/apm/alerts/chart_preview/transaction_error_rate', () => { - const url = format({ - pathname: '/api/apm/alerts/chart_preview/transaction_error_rate', - query: { - start, - end, - transactionType: 'request', - serviceName: 'opbeans-java', - }, - }); - - describe('when data is not loaded', () => { - it('handles the empty state', async () => { - const response = await supertest.get(url); - - expect(response.status).to.be(200); - expect(response.body).to.eql([]); - }); - }); - - describe('when data is loaded', () => { - before(() => esArchiver.load(archiveName)); - after(() => esArchiver.unload(archiveName)); - - it('returns the correct data', async () => { - const response = await supertest.get(url); - - expect(response.status).to.be(200); - expect( - response.body.some((item: { x: number; y: number | null }) => item.x && item.y) - ).to.equal(true); - }); - }); - }); - - describe('GET /api/apm/alerts/chart_preview/transaction_error_count', () => { - const url = format({ - pathname: '/api/apm/alerts/chart_preview/transaction_error_count', - query: { - start, - end, - serviceName: 'opbeans-java', - }, - }); - - describe('when data is not loaded', () => { - it('handles the empty state', async () => { - const response = await supertest.get(url); - - expect(response.status).to.be(200); - expect(response.body).to.eql([]); - }); - }); - - describe('when data is loaded', () => { - before(() => esArchiver.load(archiveName)); - after(() => esArchiver.unload(archiveName)); - - it('returns the correct data', async () => { - const response = await supertest.get(url); - - expect(response.status).to.be(200); - expect( - response.body.some((item: { x: number; y: number | null }) => item.x && item.y) - ).to.equal(true); - }); - }); - }); - - describe('GET /api/apm/alerts/chart_preview/transaction_duration', () => { - const url = format({ - pathname: '/api/apm/alerts/chart_preview/transaction_duration', - query: { - start, - end, - serviceName: 'opbeans-java', - transactionType: 'request', - }, - }); - - describe('when data is not loaded', () => { - it('handles the empty state', async () => { - const response = await supertest.get(url); - - expect(response.status).to.be(200); - expect(response.body).to.eql([]); - }); - }); - - describe('when data is loaded', () => { - before(() => esArchiver.load(archiveName)); - after(() => esArchiver.unload(archiveName)); - - it('returns the correct data', async () => { - const response = await supertest.get(url); - - expect(response.status).to.be(200); - expect( - response.body.some((item: { x: number; y: number | null }) => item.x && item.y) - ).to.equal(true); - }); - }); - }); - }); -} diff --git a/x-pack/test/apm_api_integration/basic/tests/index.ts b/x-pack/test/apm_api_integration/basic/tests/index.ts index c0156d92439f..3e625688e245 100644 --- a/x-pack/test/apm_api_integration/basic/tests/index.ts +++ b/x-pack/test/apm_api_integration/basic/tests/index.ts @@ -11,10 +11,6 @@ export default function apmApiIntegrationTests({ loadTestFile }: FtrProviderCont loadTestFile(require.resolve('./feature_controls')); - describe('Alerts', function () { - loadTestFile(require.resolve('./alerts/chart_preview')); - }); - describe('Service Maps', function () { loadTestFile(require.resolve('./service_maps/service_maps')); }); From 06993c469b7baf23e81d268ecdcb4ee13131fc36 Mon Sep 17 00:00:00 2001 From: Kevin Logan <56395104+kevinlog@users.noreply.github.com> Date: Mon, 14 Dec 2020 13:45:56 -0500 Subject: [PATCH 2/7] [Fleet] Installation of hidden field (#85703) Co-authored-by: nnamdifrankie Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../plugins/fleet/common/types/models/epm.ts | 3 ++- .../epm/elasticsearch/template/install.ts | 1 + .../elasticsearch/template/template.test.ts | 25 +++++++++++++++++++ .../epm/elasticsearch/template/template.ts | 16 +++++++++--- 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index 96868fa8cfc3..f518c606d695 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -207,6 +207,7 @@ export type ElasticsearchAssetTypeToParts = Record< export interface RegistryDataStream { type: string; + hidden?: boolean; dataset: string; title: string; release: string; @@ -319,7 +320,7 @@ export interface IndexTemplate { mappings: any; aliases: object; }; - data_stream: object; + data_stream: { hidden?: boolean }; composed_of: string[]; _meta: object; } diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts index 199026da30c1..944f742e5454 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts @@ -314,6 +314,7 @@ export async function installTemplate({ pipelineName, packageName, composedOfTemplates, + hidden: dataStream.hidden, }); // TODO: Check return values for errors diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts index cc1aa79c7491..bdff7e0fb3bc 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts @@ -60,6 +60,31 @@ test('adds empty composed_of correctly', () => { expect(template.composed_of).toStrictEqual(composedOfTemplates); }); +test('adds hidden field correctly', () => { + const templateWithHiddenName = 'logs-nginx-access-abcd'; + + const templateWithHidden = getTemplate({ + type: 'logs', + templateName: templateWithHiddenName, + packageName: 'nginx', + mappings: { properties: {} }, + composedOfTemplates: [], + hidden: true, + }); + expect(templateWithHidden.data_stream.hidden).toEqual(true); + + const templateWithoutHiddenName = 'logs-nginx-access-efgh'; + + const templateWithoutHidden = getTemplate({ + type: 'logs', + templateName: templateWithoutHiddenName, + packageName: 'nginx', + mappings: { properties: {} }, + composedOfTemplates: [], + }); + expect(templateWithoutHidden.data_stream.hidden).toEqual(undefined); +}); + test('tests loading base.yml', () => { const ymlPath = path.join(__dirname, '../../fields/tests/base.yml'); const fieldsYML = readFileSync(ymlPath, 'utf-8'); diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts index 8d33180d6262..d80d54d098db 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts @@ -45,6 +45,7 @@ export function getTemplate({ pipelineName, packageName, composedOfTemplates, + hidden, }: { type: string; templateName: string; @@ -52,8 +53,16 @@ export function getTemplate({ pipelineName?: string | undefined; packageName: string; composedOfTemplates: string[]; + hidden?: boolean; }): IndexTemplate { - const template = getBaseTemplate(type, templateName, mappings, packageName, composedOfTemplates); + const template = getBaseTemplate( + type, + templateName, + mappings, + packageName, + composedOfTemplates, + hidden + ); if (pipelineName) { template.template.settings.index.default_pipeline = pipelineName; } @@ -253,7 +262,8 @@ function getBaseTemplate( templateName: string, mappings: IndexTemplateMappings, packageName: string, - composedOfTemplates: string[] + composedOfTemplates: string[], + hidden?: boolean ): IndexTemplate { // Meta information to identify Ingest Manager's managed templates and indices const _meta = { @@ -324,7 +334,7 @@ function getBaseTemplate( // To be filled with the aliases that we need aliases: {}, }, - data_stream: {}, + data_stream: { hidden }, composed_of: composedOfTemplates, _meta, }; From 5f6ed3dc3ccd034f4e067e4a3d181f1f0c82a5d1 Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Mon, 14 Dec 2020 12:51:14 -0600 Subject: [PATCH 3/7] skip custom detection rules. #83772 --- .../cypress/integration/alerts_detection_rules_custom.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts index b3c82a8d9d6f..3ce507c791f0 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts @@ -217,7 +217,7 @@ describe('Custom detection rules creation', () => { }); }); -describe('Custom detection rules deletion and edition', () => { +describe.skip('Custom detection rules deletion and edition', () => { beforeEach(() => { esArchiverLoad('custom_rules'); loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); From ea4e2224a93bef2ee31f203a88baa71e5594ec9b Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 14 Dec 2020 21:11:53 +0200 Subject: [PATCH 4/7] [Security Solution][Case] Sync cases with alerts (#84731) --- x-pack/plugins/case/common/api/cases/case.ts | 8 +- .../case/common/api/cases/user_actions.ts | 1 + x-pack/plugins/case/kibana.json | 2 +- .../server/client/alerts/update_status.ts | 25 +++++ .../case/server/client/cases/create.test.ts | 28 +++++- .../case/server/client/cases/create.ts | 2 +- .../case/server/client/cases/update.test.ts | 38 ++++++-- .../case/server/client/cases/update.ts | 65 ++++++++++++- .../case/server/client/comments/add.test.ts | 9 ++ .../case/server/client/comments/add.ts | 24 ++++- .../plugins/case/server/client/index.test.ts | 26 +++++- x-pack/plugins/case/server/client/index.ts | 18 ++++ x-pack/plugins/case/server/client/mocks.ts | 54 +++++++++-- x-pack/plugins/case/server/client/types.ts | 16 +++- .../case/server/connectors/case/index.test.ts | 92 ++++++++++++++++++- .../case/server/connectors/case/index.ts | 29 +++++- .../case/server/connectors/case/schema.ts | 30 ++++-- .../plugins/case/server/connectors/index.ts | 4 + x-pack/plugins/case/server/plugin.ts | 21 ++++- .../api/__fixtures__/mock_saved_objects.ts | 15 +++ .../routes/api/__fixtures__/route_contexts.ts | 39 +++++--- .../routes/api/__mocks__/request_responses.ts | 3 + .../routes/api/cases/comments/post_comment.ts | 2 +- .../routes/api/cases/patch_cases.test.ts | 9 ++ .../server/routes/api/cases/patch_cases.ts | 2 +- .../server/routes/api/cases/post_case.test.ts | 18 ++++ .../case/server/routes/api/utils.test.ts | 12 +++ .../case/server/saved_object_types/cases.ts | 7 ++ .../server/saved_object_types/migrations.ts | 36 ++++++-- .../case/server/services/alerts/index.ts | 57 ++++++++++++ x-pack/plugins/case/server/services/index.ts | 1 + x-pack/plugins/case/server/services/mocks.ts | 13 ++- .../server/services/user_actions/helpers.ts | 1 + x-pack/plugins/case/server/types.ts | 5 + .../cases/components/all_cases/index.test.tsx | 3 + .../components/case_action_bar/index.tsx | 75 +++++++++++---- .../case_settings/sync_alerts_switch.tsx | 48 ++++++++++ .../cases/components/case_view/index.tsx | 21 ++++- .../cases/components/create/connector.tsx | 4 +- .../cases/components/create/form.test.tsx | 1 + .../public/cases/components/create/form.tsx | 20 +++- .../cases/components/create/form_context.tsx | 12 ++- .../cases/components/create/index.test.tsx | 8 +- .../public/cases/components/create/schema.tsx | 11 ++- .../components/create/sync_alerts_toggle.tsx | 37 ++++++++ .../cases/components/create/translations.ts | 14 +++ .../user_action_alert_comment_event.tsx | 7 +- .../public/cases/containers/api.test.tsx | 3 + .../public/cases/containers/mock.ts | 3 + .../public/cases/containers/types.ts | 2 + .../public/cases/containers/use_get_case.tsx | 3 + .../cases/containers/use_post_case.test.tsx | 3 + .../cases/containers/use_update_case.tsx | 2 +- .../public/cases/translations.ts | 22 +++++ .../plugins/security_solution/server/index.ts | 2 + .../basic/tests/cases/migrations.ts | 13 +++ .../user_actions/get_all_user_actions.ts | 11 ++- .../basic/tests/connectors/case.ts | 64 ++++++++++++- .../case_api_integration/common/lib/mock.ts | 3 + 59 files changed, 996 insertions(+), 108 deletions(-) create mode 100644 x-pack/plugins/case/server/client/alerts/update_status.ts create mode 100644 x-pack/plugins/case/server/services/alerts/index.ts create mode 100644 x-pack/plugins/security_solution/public/cases/components/case_settings/sync_alerts_switch.tsx create mode 100644 x-pack/plugins/security_solution/public/cases/components/create/sync_alerts_toggle.tsx diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index 9b99bf0e54cc..a08e1fbca66e 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -29,12 +29,17 @@ const CaseStatusRt = rt.union([ export const caseStatuses = Object.values(CaseStatuses); +const SettingsRt = rt.type({ + syncAlerts: rt.boolean, +}); + const CaseBasicRt = rt.type({ - connector: CaseConnectorRt, description: rt.string, status: CaseStatusRt, tags: rt.array(rt.string), title: rt.string, + connector: CaseConnectorRt, + settings: SettingsRt, }); const CaseExternalServiceBasicRt = rt.type({ @@ -74,6 +79,7 @@ export const CasePostRequestRt = rt.type({ tags: rt.array(rt.string), title: rt.string, connector: CaseConnectorRt, + settings: SettingsRt, }); export const CaseExternalServiceRequestRt = CaseExternalServiceBasicRt; diff --git a/x-pack/plugins/case/common/api/cases/user_actions.ts b/x-pack/plugins/case/common/api/cases/user_actions.ts index 1a3ccfc04eed..e7aa67db9287 100644 --- a/x-pack/plugins/case/common/api/cases/user_actions.ts +++ b/x-pack/plugins/case/common/api/cases/user_actions.ts @@ -20,6 +20,7 @@ const UserActionFieldRt = rt.array( rt.literal('tags'), rt.literal('title'), rt.literal('status'), + rt.literal('settings'), ]) ); const UserActionRt = rt.union([ diff --git a/x-pack/plugins/case/kibana.json b/x-pack/plugins/case/kibana.json index 55416ee28c7d..2048ae41fa8a 100644 --- a/x-pack/plugins/case/kibana.json +++ b/x-pack/plugins/case/kibana.json @@ -2,7 +2,7 @@ "configPath": ["xpack", "case"], "id": "case", "kibanaVersion": "kibana", - "requiredPlugins": ["actions"], + "requiredPlugins": ["actions", "securitySolution"], "optionalPlugins": [ "spaces", "security" diff --git a/x-pack/plugins/case/server/client/alerts/update_status.ts b/x-pack/plugins/case/server/client/alerts/update_status.ts new file mode 100644 index 000000000000..d90424eb5fb1 --- /dev/null +++ b/x-pack/plugins/case/server/client/alerts/update_status.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from '@hapi/boom'; +import { CaseClientUpdateAlertsStatus, CaseClientFactoryArguments } from '../types'; + +export const updateAlertsStatus = ({ + alertsService, + request, + context, +}: CaseClientFactoryArguments) => async ({ + ids, + status, +}: CaseClientUpdateAlertsStatus): Promise => { + const securitySolutionClient = context?.securitySolution?.getAppClient(); + if (securitySolutionClient == null) { + throw Boom.notFound('securitySolutionClient client have not been found'); + } + + const index = securitySolutionClient.getSignalsIndex(); + await alertsService.updateAlertsStatus({ ids, status, index, request }); +}; diff --git a/x-pack/plugins/case/server/client/cases/create.test.ts b/x-pack/plugins/case/server/client/cases/create.test.ts index e09ce226b312..90116e372888 100644 --- a/x-pack/plugins/case/server/client/cases/create.test.ts +++ b/x-pack/plugins/case/server/client/cases/create.test.ts @@ -34,6 +34,9 @@ describe('create', () => { type: ConnectorTypes.jira, fields: { issueType: 'Task', priority: 'High', parent: null }, }, + settings: { + syncAlerts: true, + }, } as CasePostRequest; const savedObjectsClient = createMockSavedObjectsRepository({ @@ -65,6 +68,9 @@ describe('create', () => { updated_at: null, updated_by: null, version: 'WzksMV0=', + settings: { + syncAlerts: true, + }, }); expect( @@ -79,9 +85,9 @@ describe('create', () => { full_name: 'Awesome D00d', username: 'awesome', }, - action_field: ['description', 'status', 'tags', 'title', 'connector'], + action_field: ['description', 'status', 'tags', 'title', 'connector', 'settings'], new_value: - '{"description":"This is a brand new case of a bad meanie defacing data","title":"Super Bad Security Issue","tags":["defacement"],"connector":{"id":"123","name":"Jira","type":".jira","fields":{"issueType":"Task","priority":"High","parent":null}}}', + '{"description":"This is a brand new case of a bad meanie defacing data","title":"Super Bad Security Issue","tags":["defacement"],"connector":{"id":"123","name":"Jira","type":".jira","fields":{"issueType":"Task","priority":"High","parent":null}},"settings":{"syncAlerts":true}}', old_value: null, }, references: [ @@ -106,6 +112,9 @@ describe('create', () => { type: ConnectorTypes.none, fields: null, }, + settings: { + syncAlerts: true, + }, }; const savedObjectsClient = createMockSavedObjectsRepository({ @@ -131,6 +140,9 @@ describe('create', () => { updated_at: null, updated_by: null, version: 'WzksMV0=', + settings: { + syncAlerts: true, + }, }); }); @@ -145,6 +157,9 @@ describe('create', () => { type: ConnectorTypes.none, fields: null, }, + settings: { + syncAlerts: true, + }, }; const savedObjectsClient = createMockSavedObjectsRepository({ @@ -174,6 +189,9 @@ describe('create', () => { updated_at: null, updated_by: null, version: 'WzksMV0=', + settings: { + syncAlerts: true, + }, }); }); }); @@ -323,6 +341,9 @@ describe('create', () => { type: ConnectorTypes.none, fields: null, }, + settings: { + syncAlerts: true, + }, }; const savedObjectsClient = createMockSavedObjectsRepository({ @@ -347,6 +368,9 @@ describe('create', () => { type: ConnectorTypes.none, fields: null, }, + settings: { + syncAlerts: true, + }, }; const savedObjectsClient = createMockSavedObjectsRepository({ caseSavedObject: mockCases, diff --git a/x-pack/plugins/case/server/client/cases/create.ts b/x-pack/plugins/case/server/client/cases/create.ts index 59222be062c7..1dca025036c1 100644 --- a/x-pack/plugins/case/server/client/cases/create.ts +++ b/x-pack/plugins/case/server/client/cases/create.ts @@ -64,7 +64,7 @@ export const create = ({ actionAt: createdDate, actionBy: { username, full_name, email }, caseId: newCase.id, - fields: ['description', 'status', 'tags', 'title', 'connector'], + fields: ['description', 'status', 'tags', 'title', 'connector', 'settings'], newValue: JSON.stringify(query), }), ], diff --git a/x-pack/plugins/case/server/client/cases/update.test.ts b/x-pack/plugins/case/server/client/cases/update.test.ts index ae701f16b2bc..1f9e8cc78840 100644 --- a/x-pack/plugins/case/server/client/cases/update.test.ts +++ b/x-pack/plugins/case/server/client/cases/update.test.ts @@ -38,7 +38,10 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); - const res = await caseClient.client.update({ cases: patchCases }); + const res = await caseClient.client.update({ + caseClient: caseClient.client, + cases: patchCases, + }); expect(res).toEqual([ { @@ -63,6 +66,9 @@ describe('update', () => { updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, version: 'WzE3LDFd', + settings: { + syncAlerts: true, + }, }, ]); @@ -115,7 +121,10 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); - const res = await caseClient.client.update({ cases: patchCases }); + const res = await caseClient.client.update({ + caseClient: caseClient.client, + cases: patchCases, + }); expect(res).toEqual([ { @@ -140,6 +149,9 @@ describe('update', () => { updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, version: 'WzE3LDFd', + settings: { + syncAlerts: true, + }, }, ]); }); @@ -160,7 +172,10 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); - const res = await caseClient.client.update({ cases: patchCases }); + const res = await caseClient.client.update({ + caseClient: caseClient.client, + cases: patchCases, + }); expect(res).toEqual([ { @@ -185,6 +200,9 @@ describe('update', () => { updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, version: 'WzE3LDFd', + settings: { + syncAlerts: true, + }, }, ]); }); @@ -210,7 +228,10 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); - const res = await caseClient.client.update({ cases: patchCases }); + const res = await caseClient.client.update({ + caseClient: caseClient.client, + cases: patchCases, + }); expect(res).toEqual([ { @@ -243,6 +264,9 @@ describe('update', () => { username: 'awesome', }, version: 'WzE3LDFd', + settings: { + syncAlerts: true, + }, }, ]); }); @@ -328,7 +352,7 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); - caseClient.client.update({ cases: patchCases }).catch((e) => { + caseClient.client.update({ caseClient: caseClient.client, cases: patchCases }).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); expect(e.output.statusCode).toBe(406); @@ -358,7 +382,7 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); - caseClient.client.update({ cases: patchCases }).catch((e) => { + caseClient.client.update({ caseClient: caseClient.client, cases: patchCases }).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); expect(e.output.statusCode).toBe(404); @@ -385,7 +409,7 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); - caseClient.client.update({ cases: patchCases }).catch((e) => { + caseClient.client.update({ caseClient: caseClient.client, cases: patchCases }).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); expect(e.output.statusCode).toBe(409); diff --git a/x-pack/plugins/case/server/client/cases/update.ts b/x-pack/plugins/case/server/client/cases/update.ts index 406e43a74ccc..e2b6cb833725 100644 --- a/x-pack/plugins/case/server/client/cases/update.ts +++ b/x-pack/plugins/case/server/client/cases/update.ts @@ -9,6 +9,7 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; +import { SavedObjectsFindResponse } from 'kibana/server'; import { flattenCaseSavedObject } from '../../routes/api/utils'; import { @@ -34,7 +35,10 @@ export const update = ({ caseService, userActionService, request, -}: CaseClientFactoryArguments) => async ({ cases }: CaseClientUpdate): Promise => { +}: CaseClientFactoryArguments) => async ({ + caseClient, + cases, +}: CaseClientUpdate): Promise => { const query = pipe( excess(CasesPatchRequestRt).decode(cases), fold(throwErrors(Boom.badRequest), identity) @@ -126,6 +130,65 @@ export const update = ({ }), }); + // If a status update occurred and the case is synced then we need to update all alerts' status + // attached to the case to the new status. + const casesWithStatusChangedAndSynced = updateFilterCases.filter((caseToUpdate) => { + const currentCase = myCases.saved_objects.find((c) => c.id === caseToUpdate.id); + return ( + currentCase != null && + caseToUpdate.status != null && + currentCase.attributes.status !== caseToUpdate.status && + currentCase.attributes.settings.syncAlerts + ); + }); + + // If syncAlerts setting turned on we need to update all alerts' status + // attached to the case to the current status. + const casesWithSyncSettingChangedToOn = updateFilterCases.filter((caseToUpdate) => { + const currentCase = myCases.saved_objects.find((c) => c.id === caseToUpdate.id); + return ( + currentCase != null && + caseToUpdate.settings?.syncAlerts != null && + currentCase.attributes.settings.syncAlerts !== caseToUpdate.settings.syncAlerts && + caseToUpdate.settings.syncAlerts + ); + }); + + for (const theCase of [ + ...casesWithSyncSettingChangedToOn, + ...casesWithStatusChangedAndSynced, + ]) { + const currentCase = myCases.saved_objects.find((c) => c.id === theCase.id); + const totalComments = await caseService.getAllCaseComments({ + client: savedObjectsClient, + caseId: theCase.id, + options: { + fields: [], + filter: 'cases-comments.attributes.type: alert', + page: 1, + perPage: 1, + }, + }); + + const caseComments = (await caseService.getAllCaseComments({ + client: savedObjectsClient, + caseId: theCase.id, + options: { + fields: [], + filter: 'cases-comments.attributes.type: alert', + page: 1, + perPage: totalComments.total, + }, + // The filter guarantees that the comments will be of type alert + })) as SavedObjectsFindResponse<{ alertId: string }>; + + caseClient.updateAlertsStatus({ + ids: caseComments.saved_objects.map(({ attributes: { alertId } }) => alertId), + // Either there is a status update or the syncAlerts got turned on. + status: theCase.status ?? currentCase?.attributes.status ?? CaseStatuses.open, + }); + } + const returnUpdatedCase = myCases.saved_objects .filter((myCase) => updatedCases.saved_objects.some((updatedCase) => updatedCase.id === myCase.id) diff --git a/x-pack/plugins/case/server/client/comments/add.test.ts b/x-pack/plugins/case/server/client/comments/add.test.ts index d00df5a3246b..40b87f6ad17f 100644 --- a/x-pack/plugins/case/server/client/comments/add.test.ts +++ b/x-pack/plugins/case/server/client/comments/add.test.ts @@ -31,6 +31,7 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); const res = await caseClient.client.addComment({ + caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -66,6 +67,7 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); const res = await caseClient.client.addComment({ + caseClient: caseClient.client, caseId: 'mock-id-1', comment: { type: CommentType.alert, @@ -103,6 +105,7 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); const res = await caseClient.client.addComment({ + caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -126,6 +129,7 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); await caseClient.client.addComment({ + caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -173,6 +177,7 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient, true); const res = await caseClient.client.addComment({ + caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -267,6 +272,7 @@ describe('addComment', () => { ['alertId', 'index'].forEach((attribute) => { caseClient.client .addComment({ + caseClient: caseClient.client, caseId: 'mock-id-1', comment: { [attribute]: attribute, @@ -328,6 +334,7 @@ describe('addComment', () => { ['comment'].forEach((attribute) => { caseClient.client .addComment({ + caseClient: caseClient.client, caseId: 'mock-id-1', comment: { [attribute]: attribute, @@ -354,6 +361,7 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); caseClient.client .addComment({ + caseClient: caseClient.client, caseId: 'not-exists', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -377,6 +385,7 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); caseClient.client .addComment({ + caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Throw an error', diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index 169157c95d4c..bb61094cfa3b 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -11,7 +11,14 @@ import { identity } from 'fp-ts/lib/function'; import { decodeComment, flattenCaseSavedObject, transformNewComment } from '../../routes/api/utils'; -import { throwErrors, CaseResponseRt, CommentRequestRt, CaseResponse } from '../../../common/api'; +import { + throwErrors, + CaseResponseRt, + CommentRequestRt, + CaseResponse, + CommentType, + CaseStatuses, +} from '../../../common/api'; import { buildCommentUserActionItem } from '../../services/user_actions/helpers'; import { CaseClientAddComment, CaseClientFactoryArguments } from '../types'; @@ -23,11 +30,11 @@ export const addComment = ({ userActionService, request, }: CaseClientFactoryArguments) => async ({ + caseClient, caseId, comment, }: CaseClientAddComment): Promise => { const query = pipe( - // TODO: Excess CommentRequestRt when the excess() function supports union types CommentRequestRt.decode(comment), fold(throwErrors(Boom.badRequest), identity) ); @@ -39,6 +46,11 @@ export const addComment = ({ caseId, }); + // An alert cannot be attach to a closed case. + if (query.type === CommentType.alert && myCase.attributes.status === CaseStatuses.closed) { + throw Boom.badRequest('Alert cannot be attached to a closed case'); + } + // eslint-disable-next-line @typescript-eslint/naming-convention const { username, full_name, email } = await caseService.getUser({ request }); const createdDate = new Date().toISOString(); @@ -72,6 +84,14 @@ export const addComment = ({ }), ]); + // If the case is synced with alerts the newly attached alert must match the status of the case. + if (newComment.attributes.type === CommentType.alert && myCase.attributes.settings.syncAlerts) { + caseClient.updateAlertsStatus({ + ids: [newComment.attributes.alertId], + status: myCase.attributes.status, + }); + } + const totalCommentsFindByCases = await caseService.getAllCaseComments({ client: savedObjectsClient, caseId, diff --git a/x-pack/plugins/case/server/client/index.test.ts b/x-pack/plugins/case/server/client/index.test.ts index 1ecdc8ea96de..ef4491204d9f 100644 --- a/x-pack/plugins/case/server/client/index.test.ts +++ b/x-pack/plugins/case/server/client/index.test.ts @@ -4,32 +4,38 @@ * you may not use this file except in compliance with the Elastic License. */ -import { KibanaRequest } from 'kibana/server'; +import { KibanaRequest, RequestHandlerContext } from 'kibana/server'; import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; import { createCaseClient } from '.'; import { createCaseServiceMock, createConfigureServiceMock, createUserActionServiceMock, + createAlertServiceMock, } from '../services/mocks'; import { create } from './cases/create'; import { update } from './cases/update'; import { addComment } from './comments/add'; +import { updateAlertsStatus } from './alerts/update_status'; jest.mock('./cases/create'); jest.mock('./cases/update'); jest.mock('./comments/add'); +jest.mock('./alerts/update_status'); const caseService = createCaseServiceMock(); const caseConfigureService = createConfigureServiceMock(); const userActionService = createUserActionServiceMock(); +const alertsService = createAlertServiceMock(); const savedObjectsClient = savedObjectsClientMock.create(); const request = {} as KibanaRequest; +const context = {} as RequestHandlerContext; const createMock = create as jest.Mock; const updateMock = update as jest.Mock; const addCommentMock = addComment as jest.Mock; +const updateAlertsStatusMock = updateAlertsStatus as jest.Mock; describe('createCaseClient()', () => { test('it creates the client correctly', async () => { @@ -39,6 +45,8 @@ describe('createCaseClient()', () => { caseConfigureService, caseService, userActionService, + alertsService, + context, }); expect(createMock).toHaveBeenCalledWith({ @@ -47,6 +55,8 @@ describe('createCaseClient()', () => { caseConfigureService, caseService, userActionService, + alertsService, + context, }); expect(updateMock).toHaveBeenCalledWith({ @@ -55,6 +65,8 @@ describe('createCaseClient()', () => { caseConfigureService, caseService, userActionService, + alertsService, + context, }); expect(addCommentMock).toHaveBeenCalledWith({ @@ -63,6 +75,18 @@ describe('createCaseClient()', () => { caseConfigureService, caseService, userActionService, + alertsService, + context, + }); + + expect(updateAlertsStatusMock).toHaveBeenCalledWith({ + savedObjectsClient, + request, + caseConfigureService, + caseService, + userActionService, + alertsService, + context, }); }); }); diff --git a/x-pack/plugins/case/server/client/index.ts b/x-pack/plugins/case/server/client/index.ts index 75e9e3c4cfeb..bf43921b4646 100644 --- a/x-pack/plugins/case/server/client/index.ts +++ b/x-pack/plugins/case/server/client/index.ts @@ -8,6 +8,7 @@ import { CaseClientFactoryArguments, CaseClient } from './types'; import { create } from './cases/create'; import { update } from './cases/update'; import { addComment } from './comments/add'; +import { updateAlertsStatus } from './alerts/update_status'; export { CaseClient } from './types'; @@ -17,6 +18,8 @@ export const createCaseClient = ({ caseConfigureService, caseService, userActionService, + alertsService, + context, }: CaseClientFactoryArguments): CaseClient => { return { create: create({ @@ -25,6 +28,8 @@ export const createCaseClient = ({ caseConfigureService, caseService, userActionService, + alertsService, + context, }), update: update({ savedObjectsClient, @@ -32,6 +37,8 @@ export const createCaseClient = ({ caseConfigureService, caseService, userActionService, + alertsService, + context, }), addComment: addComment({ savedObjectsClient, @@ -39,6 +46,17 @@ export const createCaseClient = ({ caseConfigureService, caseService, userActionService, + alertsService, + context, + }), + updateAlertsStatus: updateAlertsStatus({ + savedObjectsClient, + request, + caseConfigureService, + caseService, + userActionService, + alertsService, + context, }), }; }; diff --git a/x-pack/plugins/case/server/client/mocks.ts b/x-pack/plugins/case/server/client/mocks.ts index 243dd884f9ef..dd4e8b52b4dc 100644 --- a/x-pack/plugins/case/server/client/mocks.ts +++ b/x-pack/plugins/case/server/client/mocks.ts @@ -4,18 +4,26 @@ * you may not use this file except in compliance with the Elastic License. */ -import { KibanaRequest } from 'kibana/server'; -import { loggingSystemMock } from '../../../../../src/core/server/mocks'; -import { CaseService, CaseConfigureService, CaseUserActionServiceSetup } from '../services'; +import { KibanaRequest, RequestHandlerContext } from 'kibana/server'; +import { loggingSystemMock, elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; +import { actionsClientMock } from '../../../actions/server/mocks'; +import { + CaseService, + CaseConfigureService, + CaseUserActionServiceSetup, + AlertService, +} from '../services'; import { CaseClient } from './types'; import { authenticationMock } from '../routes/api/__fixtures__'; import { createCaseClient } from '.'; +import { getActions } from '../routes/api/__mocks__/request_responses'; export type CaseClientMock = jest.Mocked; export const createCaseClientMock = (): CaseClientMock => ({ create: jest.fn(), update: jest.fn(), addComment: jest.fn(), + updateAlertsStatus: jest.fn(), }); export const createCaseClientWithMockSavedObjectsClient = async ( @@ -25,7 +33,10 @@ export const createCaseClientWithMockSavedObjectsClient = async ( client: CaseClient; services: { userActionService: jest.Mocked }; }> => { + const actionsMock = actionsClientMock.create(); + actionsMock.getAll.mockImplementation(() => Promise.resolve(getActions())); const log = loggingSystemMock.create().get('case'); + const esClientMock = elasticsearchServiceMock.createClusterClient(); const request = {} as KibanaRequest; const caseServicePlugin = new CaseService(log); @@ -39,15 +50,38 @@ export const createCaseClientWithMockSavedObjectsClient = async ( postUserActions: jest.fn(), getUserActions: jest.fn(), }; + const alertsService = new AlertService(); + alertsService.initialize(esClientMock); + + const context = ({ + core: { + savedObjects: { + client: savedObjectsClient, + }, + }, + actions: { getActionsClient: () => actionsMock }, + case: { + getCaseClient: () => caseClient, + }, + securitySolution: { + getAppClient: () => ({ + getSignalsIndex: () => '.siem-signals', + }), + }, + } as unknown) as RequestHandlerContext; + + const caseClient = createCaseClient({ + savedObjectsClient, + request, + caseService, + caseConfigureService, + userActionService, + alertsService, + context, + }); return { - client: createCaseClient({ - savedObjectsClient, - request, - caseService, - caseConfigureService, - userActionService, - }), + client: caseClient, services: { userActionService }, }; }; diff --git a/x-pack/plugins/case/server/client/types.ts b/x-pack/plugins/case/server/client/types.ts index 8db7d8a5747d..a9e8494c43db 100644 --- a/x-pack/plugins/case/server/client/types.ts +++ b/x-pack/plugins/case/server/client/types.ts @@ -4,18 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { KibanaRequest, SavedObjectsClientContract } from '../../../../../src/core/server'; +import { KibanaRequest, SavedObjectsClientContract, RequestHandlerContext } from 'kibana/server'; import { CasePostRequest, CasesPatchRequest, CommentRequest, CaseResponse, CasesResponse, + CaseStatuses, } from '../../common/api'; import { CaseConfigureServiceSetup, CaseServiceSetup, CaseUserActionServiceSetup, + AlertServiceContract, } from '../services'; export interface CaseClientCreate { @@ -23,24 +25,36 @@ export interface CaseClientCreate { } export interface CaseClientUpdate { + caseClient: CaseClient; cases: CasesPatchRequest; } export interface CaseClientAddComment { + caseClient: CaseClient; caseId: string; comment: CommentRequest; } +export interface CaseClientUpdateAlertsStatus { + ids: string[]; + status: CaseStatuses; +} + +type PartialExceptFor = Partial & Pick; + export interface CaseClientFactoryArguments { savedObjectsClient: SavedObjectsClientContract; request: KibanaRequest; caseConfigureService: CaseConfigureServiceSetup; caseService: CaseServiceSetup; userActionService: CaseUserActionServiceSetup; + alertsService: AlertServiceContract; + context?: PartialExceptFor; } export interface CaseClient { create: (args: CaseClientCreate) => Promise; update: (args: CaseClientUpdate) => Promise; addComment: (args: CaseClientAddComment) => Promise; + updateAlertsStatus: (args: CaseClientUpdateAlertsStatus) => Promise; } diff --git a/x-pack/plugins/case/server/connectors/case/index.test.ts b/x-pack/plugins/case/server/connectors/case/index.test.ts index adf94661216c..9f5b186c0c68 100644 --- a/x-pack/plugins/case/server/connectors/case/index.test.ts +++ b/x-pack/plugins/case/server/connectors/case/index.test.ts @@ -14,6 +14,7 @@ import { createCaseServiceMock, createConfigureServiceMock, createUserActionServiceMock, + createAlertServiceMock, } from '../../services/mocks'; import { CaseActionType, CaseActionTypeExecutorOptions, CaseExecutorParams } from './types'; import { getActionType } from '.'; @@ -35,11 +36,13 @@ describe('case connector', () => { const caseService = createCaseServiceMock(); const caseConfigureService = createConfigureServiceMock(); const userActionService = createUserActionServiceMock(); + const alertsService = createAlertServiceMock(); caseActionType = getActionType({ logger, caseService, caseConfigureService, userActionService, + alertsService, }); }); @@ -62,6 +65,9 @@ describe('case connector', () => { parent: null, }, }, + settings: { + syncAlerts: true, + }, }, }; @@ -98,6 +104,9 @@ describe('case connector', () => { parent: null, }, }, + settings: { + syncAlerts: true, + }, }, }, }, @@ -118,6 +127,9 @@ describe('case connector', () => { severityCode: '3', }, }, + settings: { + syncAlerts: true, + }, }, }, }, @@ -139,6 +151,9 @@ describe('case connector', () => { urgency: 'Medium', }, }, + settings: { + syncAlerts: true, + }, }, }, }, @@ -156,6 +171,9 @@ describe('case connector', () => { type: '.none', fields: null, }, + settings: { + syncAlerts: true, + }, }, }, }, @@ -180,6 +198,9 @@ describe('case connector', () => { type: '.servicenow', fields: {}, }, + settings: { + syncAlerts: true, + }, }, }; @@ -195,6 +216,9 @@ describe('case connector', () => { type: '.servicenow', fields: { impact: null, severity: null, urgency: null }, }, + settings: { + syncAlerts: true, + }, }, }); }); @@ -212,6 +236,9 @@ describe('case connector', () => { type: '.none', fields: null, }, + settings: { + syncAlerts: true, + }, }, }; @@ -234,6 +261,9 @@ describe('case connector', () => { parent: null, }, }, + settings: { + syncAlerts: true, + }, }, }; @@ -262,6 +292,9 @@ describe('case connector', () => { excess: null, }, }, + settings: { + syncAlerts: true, + }, }, }; @@ -289,6 +322,9 @@ describe('case connector', () => { parent: null, }, }, + settings: { + syncAlerts: true, + }, }, }; @@ -312,6 +348,9 @@ describe('case connector', () => { type: '.none', fields: {}, }, + settings: { + syncAlerts: true, + }, }, }; @@ -343,6 +382,7 @@ describe('case connector', () => { title: null, status: null, connector: null, + settings: null, ...(params.subActionParams as Record), }, }); @@ -375,6 +415,7 @@ describe('case connector', () => { tags: null, title: null, status: null, + settings: null, ...(params.subActionParams as Record), }, }); @@ -405,6 +446,7 @@ describe('case connector', () => { tags: null, title: null, status: null, + settings: null, ...(params.subActionParams as Record), }, }); @@ -436,6 +478,7 @@ describe('case connector', () => { tags: null, title: null, status: null, + settings: null, ...(params.subActionParams as Record), }, }); @@ -465,6 +508,7 @@ describe('case connector', () => { tags: null, title: null, status: null, + settings: null, connector: { id: 'servicenow', name: 'Servicenow', @@ -497,6 +541,7 @@ describe('case connector', () => { tags: null, title: null, status: null, + settings: null, ...(params.subActionParams as Record), }, }); @@ -630,7 +675,9 @@ describe('case connector', () => { expect(validateParams(caseActionType, params)).toEqual(params); }); - it('succeeds when type is an alert', () => { + // TODO: Enable when the creation of comments of type alert is supported + // https://github.com/elastic/kibana/issues/85750 + it.skip('succeeds when type is an alert', () => { const params: Record = { subAction: 'addComment', subActionParams: { @@ -656,6 +703,26 @@ describe('case connector', () => { }).toThrow(); }); + // TODO: Remove it when the creation of comments of type alert is supported + // https://github.com/elastic/kibana/issues/85750 + it('fails when type is an alert', () => { + const params: Record = { + subAction: 'addComment', + subActionParams: { + caseId: 'case-id', + comment: { + type: CommentType.alert, + alertId: 'test-id', + index: 'test-index', + }, + }, + }; + + expect(() => { + validateParams(caseActionType, params); + }).toThrow(); + }); + it('fails when missing attributes: type user', () => { const allParams = { type: CommentType.user, @@ -678,7 +745,9 @@ describe('case connector', () => { }); }); - it('fails when missing attributes: type alert', () => { + // TODO: Enable when the creation of comments of type alert is supported + // https://github.com/elastic/kibana/issues/85750 + it.skip('fails when missing attributes: type alert', () => { const allParams = { type: CommentType.alert, comment: 'a comment', @@ -720,7 +789,9 @@ describe('case connector', () => { }); }); - it('fails when excess attributes are provided: type alert', () => { + // TODO: Enable when the creation of comments of type alert is supported + // https://github.com/elastic/kibana/issues/85750 + it.skip('fails when excess attributes are provided: type alert', () => { ['comment'].forEach((attribute) => { const params: Record = { subAction: 'addComment', @@ -789,6 +860,9 @@ describe('case connector', () => { updated_at: null, updated_by: null, version: 'WzksMV0=', + settings: { + syncAlerts: true, + }, }; mockCaseClient.create.mockReturnValue(Promise.resolve(createReturn)); @@ -810,6 +884,9 @@ describe('case connector', () => { parent: null, }, }, + settings: { + syncAlerts: true, + }, }, }; @@ -879,6 +956,9 @@ describe('case connector', () => { username: 'awesome', }, version: 'WzE3LDFd', + settings: { + syncAlerts: true, + }, }, ]; @@ -895,6 +975,7 @@ describe('case connector', () => { tags: null, status: null, connector: null, + settings: null, }, }; @@ -910,6 +991,7 @@ describe('case connector', () => { expect(result).toEqual({ actionId, status: 'ok', data: updateReturn }); expect(mockCaseClient.update).toHaveBeenCalledWith({ + caseClient: mockCaseClient, // Null values have been striped out. cases: { cases: [ @@ -960,6 +1042,9 @@ describe('case connector', () => { version: 'WzksMV0=', }, ], + settings: { + syncAlerts: true, + }, }; mockCaseClient.addComment.mockReturnValue(Promise.resolve(commentReturn)); @@ -988,6 +1073,7 @@ describe('case connector', () => { expect(result).toEqual({ actionId, status: 'ok', data: commentReturn }); expect(mockCaseClient.addComment).toHaveBeenCalledWith({ + caseClient: mockCaseClient, caseId: 'case-id', comment: { comment: 'a comment', diff --git a/x-pack/plugins/case/server/connectors/case/index.ts b/x-pack/plugins/case/server/connectors/case/index.ts index dc647d288ec6..48124b8ae32e 100644 --- a/x-pack/plugins/case/server/connectors/case/index.ts +++ b/x-pack/plugins/case/server/connectors/case/index.ts @@ -6,7 +6,7 @@ import { curry } from 'lodash'; -import { KibanaRequest } from 'kibana/server'; +import { KibanaRequest, RequestHandlerContext } from 'kibana/server'; import { ActionTypeExecutorResult } from '../../../../actions/common'; import { CasePatchRequest, CasePostRequest } from '../../../common/api'; import { createCaseClient } from '../../client'; @@ -30,6 +30,7 @@ export function getActionType({ caseService, caseConfigureService, userActionService, + alertsService, }: GetActionTypeParams): CaseActionType { return { id: CASE_ACTION_TYPE_ID, @@ -39,13 +40,25 @@ export function getActionType({ config: CaseConfigurationSchema, params: CaseExecutorParamsSchema, }, - executor: curry(executor)({ logger, caseService, caseConfigureService, userActionService }), + executor: curry(executor)({ + logger, + caseService, + caseConfigureService, + userActionService, + alertsService, + }), }; } // action executor async function executor( - { logger, caseService, caseConfigureService, userActionService }: GetActionTypeParams, + { + logger, + caseService, + caseConfigureService, + userActionService, + alertsService, + }: GetActionTypeParams, execOptions: CaseActionTypeExecutorOptions ): Promise> { const { actionId, params, services } = execOptions; @@ -59,6 +72,9 @@ async function executor( caseService, caseConfigureService, userActionService, + alertsService, + // TODO: When case connector is enabled we should figure out how to pass the context. + context: {} as RequestHandlerContext, }); if (!supportedSubActions.includes(subAction)) { @@ -80,12 +96,15 @@ async function executor( {} as CasePatchRequest ); - data = await caseClient.update({ cases: { cases: [updateParamsWithoutNullValues] } }); + data = await caseClient.update({ + caseClient, + cases: { cases: [updateParamsWithoutNullValues] }, + }); } if (subAction === 'addComment') { const { caseId, comment } = subActionParams as ExecutorSubActionAddCommentParams; - data = await caseClient.addComment({ caseId, comment }); + data = await caseClient.addComment({ caseClient, caseId, comment }); } return { status: 'ok', data: data ?? {}, actionId }; diff --git a/x-pack/plugins/case/server/connectors/case/schema.ts b/x-pack/plugins/case/server/connectors/case/schema.ts index 039c0e2e7e67..d17c9ce6eb1c 100644 --- a/x-pack/plugins/case/server/connectors/case/schema.ts +++ b/x-pack/plugins/case/server/connectors/case/schema.ts @@ -14,13 +14,27 @@ const ContextTypeUserSchema = schema.object({ comment: schema.string(), }); -const ContextTypeAlertSchema = schema.object({ - type: schema.literal('alert'), - alertId: schema.string(), - index: schema.string(), -}); - -export const CommentSchema = schema.oneOf([ContextTypeUserSchema, ContextTypeAlertSchema]); +/** + * ContextTypeAlertSchema has been deleted. + * Comments of type alert need the siem signal index. + * Case connector is not being passed the context which contains the + * security solution app client which in turn provides the siem signal index. + * For that reason, we disable comments of type alert for the case connector until + * we figure out how to pass the security solution app client to the connector. + * See: x-pack/plugins/case/server/connectors/case/index.ts L76. + * + * The schema: + * + * const ContextTypeAlertSchema = schema.object({ + * type: schema.literal('alert'), + * alertId: schema.string(), + * index: schema.string(), + * }); + * + * Issue: https://github.com/elastic/kibana/issues/85750 + * */ + +export const CommentSchema = schema.oneOf([ContextTypeUserSchema]); const JiraFieldsSchema = schema.object({ issueType: schema.string(), @@ -80,6 +94,7 @@ const CaseBasicProps = { title: schema.string(), tags: schema.arrayOf(schema.string()), connector: schema.object(ConnectorProps, { validate: validateConnector }), + settings: schema.object({ syncAlerts: schema.boolean() }), }; const CaseUpdateRequestProps = { @@ -89,6 +104,7 @@ const CaseUpdateRequestProps = { title: schema.nullable(CaseBasicProps.title), tags: schema.nullable(CaseBasicProps.tags), connector: schema.nullable(CaseBasicProps.connector), + settings: schema.nullable(CaseBasicProps.settings), status: schema.nullable(schema.string()), }; diff --git a/x-pack/plugins/case/server/connectors/index.ts b/x-pack/plugins/case/server/connectors/index.ts index bee7b1e47545..f37344571916 100644 --- a/x-pack/plugins/case/server/connectors/index.ts +++ b/x-pack/plugins/case/server/connectors/index.ts @@ -16,6 +16,7 @@ import { CaseServiceSetup, CaseConfigureServiceSetup, CaseUserActionServiceSetup, + AlertServiceContract, } from '../services'; import { getActionType as getCaseConnector } from './case'; @@ -26,6 +27,7 @@ export interface GetActionTypeParams { caseService: CaseServiceSetup; caseConfigureService: CaseConfigureServiceSetup; userActionService: CaseUserActionServiceSetup; + alertsService: AlertServiceContract; } export interface RegisterConnectorsArgs extends GetActionTypeParams { @@ -45,6 +47,7 @@ export const registerConnectors = ({ caseService, caseConfigureService, userActionService, + alertsService, }: RegisterConnectorsArgs) => { actionsRegisterType( getCaseConnector({ @@ -52,6 +55,7 @@ export const registerConnectors = ({ caseService, caseConfigureService, userActionService, + alertsService, }) ); }; diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index 64c4b422d1cf..8d508ce0b76b 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -11,6 +11,7 @@ import { Logger, PluginInitializerContext, RequestHandler, + RequestHandlerContext, } from 'kibana/server'; import { CoreSetup, CoreStart } from 'src/core/server'; @@ -33,6 +34,8 @@ import { CaseServiceSetup, CaseUserActionService, CaseUserActionServiceSetup, + AlertService, + AlertServiceContract, } from './services'; import { createCaseClient } from './client'; import { registerConnectors } from './connectors'; @@ -51,6 +54,7 @@ export class CasePlugin { private caseService?: CaseServiceSetup; private caseConfigureService?: CaseConfigureServiceSetup; private userActionService?: CaseUserActionServiceSetup; + private alertsService?: AlertService; constructor(private readonly initializerContext: PluginInitializerContext) { this.log = this.initializerContext.logger.get(); @@ -79,6 +83,7 @@ export class CasePlugin { }); this.caseConfigureService = await new CaseConfigureService(this.log).setup(); this.userActionService = await new CaseUserActionService(this.log).setup(); + this.alertsService = new AlertService(); core.http.registerRouteHandlerContext( APP_ID, @@ -87,6 +92,7 @@ export class CasePlugin { caseService: this.caseService, caseConfigureService: this.caseConfigureService, userActionService: this.userActionService, + alertsService: this.alertsService, }) ); @@ -104,24 +110,31 @@ export class CasePlugin { caseService: this.caseService, caseConfigureService: this.caseConfigureService, userActionService: this.userActionService, + alertsService: this.alertsService, }); } public async start(core: CoreStart) { this.log.debug(`Starting Case Workflow`); + this.alertsService!.initialize(core.elasticsearch.client); - const getCaseClientWithRequest = async (request: KibanaRequest) => { + const getCaseClientWithRequestAndContext = async ( + context: RequestHandlerContext, + request: KibanaRequest + ) => { return createCaseClient({ savedObjectsClient: core.savedObjects.getScopedClient(request), request, caseService: this.caseService!, caseConfigureService: this.caseConfigureService!, userActionService: this.userActionService!, + alertsService: this.alertsService!, + context, }); }; return { - getCaseClientWithRequest, + getCaseClientWithRequestAndContext, }; } @@ -134,11 +147,13 @@ export class CasePlugin { caseService, caseConfigureService, userActionService, + alertsService, }: { core: CoreSetup; caseService: CaseServiceSetup; caseConfigureService: CaseConfigureServiceSetup; userActionService: CaseUserActionServiceSetup; + alertsService: AlertServiceContract; }): IContextProvider, typeof APP_ID> => { return async (context, request) => { const [{ savedObjects }] = await core.getStartServices(); @@ -149,7 +164,9 @@ export class CasePlugin { caseService, caseConfigureService, userActionService, + alertsService, request, + context, }); }, }; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index 95856dd75d0a..645673fdee75 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -44,6 +44,9 @@ export const mockCases: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + settings: { + syncAlerts: true, + }, }, references: [], updated_at: '2019-11-25T21:54:48.952Z', @@ -78,6 +81,9 @@ export const mockCases: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + settings: { + syncAlerts: true, + }, }, references: [], updated_at: '2019-11-25T22:32:00.900Z', @@ -116,6 +122,9 @@ export const mockCases: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + settings: { + syncAlerts: true, + }, }, references: [], updated_at: '2019-11-25T22:32:17.947Z', @@ -158,6 +167,9 @@ export const mockCases: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + settings: { + syncAlerts: true, + }, }, references: [], updated_at: '2019-11-25T22:32:17.947Z', @@ -188,6 +200,9 @@ export const mockCaseNoConnectorId: SavedObject> = { email: 'testemail@elastic.co', username: 'elastic', }, + settings: { + syncAlerts: true, + }, }, references: [], updated_at: '2019-11-25T21:54:48.952Z', diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts index 67890599fa41..dcae1c6083eb 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts @@ -5,10 +5,10 @@ */ import { RequestHandlerContext, KibanaRequest } from 'src/core/server'; -import { loggingSystemMock } from 'src/core/server/mocks'; +import { loggingSystemMock, elasticsearchServiceMock } from 'src/core/server/mocks'; import { actionsClientMock } from '../../../../../actions/server/mocks'; import { createCaseClient } from '../../../client'; -import { CaseService, CaseConfigureService } from '../../../services'; +import { CaseService, CaseConfigureService, AlertService } from '../../../services'; import { getActions } from '../__mocks__/request_responses'; import { authenticationMock } from '../__fixtures__'; @@ -16,6 +16,7 @@ export const createRouteContext = async (client: any, badAuth = false) => { const actionsMock = actionsClientMock.create(); actionsMock.getAll.mockImplementation(() => Promise.resolve(getActions())); const log = loggingSystemMock.create().get('case'); + const esClientMock = elasticsearchServiceMock.createClusterClient(); const caseServicePlugin = new CaseService(log); const caseConfigureServicePlugin = new CaseConfigureService(log); @@ -24,18 +25,10 @@ export const createRouteContext = async (client: any, badAuth = false) => { authentication: badAuth ? authenticationMock.createInvalid() : authenticationMock.create(), }); const caseConfigureService = await caseConfigureServicePlugin.setup(); - const caseClient = createCaseClient({ - savedObjectsClient: client, - request: {} as KibanaRequest, - caseService, - caseConfigureService, - userActionService: { - postUserActions: jest.fn(), - getUserActions: jest.fn(), - }, - }); + const alertsService = new AlertService(); + alertsService.initialize(esClientMock); - return ({ + const context = ({ core: { savedObjects: { client, @@ -45,5 +38,25 @@ export const createRouteContext = async (client: any, badAuth = false) => { case: { getCaseClient: () => caseClient, }, + securitySolution: { + getAppClient: () => ({ + getSignalsIndex: () => '.siem-signals', + }), + }, } as unknown) as RequestHandlerContext; + + const caseClient = createCaseClient({ + savedObjectsClient: client, + request: {} as KibanaRequest, + caseService, + caseConfigureService, + userActionService: { + postUserActions: jest.fn(), + getUserActions: jest.fn(), + }, + alertsService, + context, + }); + + return context; }; diff --git a/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts b/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts index ce35b9975041..209fa11116c5 100644 --- a/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts +++ b/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts @@ -17,6 +17,9 @@ export const newCase: CasePostRequest = { type: ConnectorTypes.none, fields: null, }, + settings: { + syncAlerts: true, + }, }; export const getActions = (): FindActionResult[] => [ diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts index 08d442bccf2c..139fb7c5f27a 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts @@ -32,7 +32,7 @@ export function initPostCommentApi({ router }: RouteDeps) { try { return response.ok({ - body: await caseClient.addComment({ caseId, comment }), + body: await caseClient.addComment({ caseClient, caseId, comment }), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts index 053f9ec18ab0..6a6f5653375b 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts @@ -74,6 +74,9 @@ describe('PATCH cases', () => { updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, version: 'WzE3LDFd', + settings: { + syncAlerts: true, + }, }, ]); }); @@ -125,6 +128,9 @@ describe('PATCH cases', () => { updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, version: 'WzE3LDFd', + settings: { + syncAlerts: true, + }, }, ]); }); @@ -175,6 +181,9 @@ describe('PATCH cases', () => { updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, version: 'WzE3LDFd', + settings: { + syncAlerts: true, + }, }, ]); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts index 873671a90980..178e40520d9d 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts @@ -27,7 +27,7 @@ export function initPatchCasesApi({ router }: RouteDeps) { try { return response.ok({ - body: await caseClient.update({ cases }), + body: await caseClient.update({ caseClient, cases }), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts index 508684b42289..ea59959b0e84 100644 --- a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts @@ -42,6 +42,9 @@ describe('POST cases', () => { type: ConnectorTypes.none, fields: null, }, + settings: { + syncAlerts: true, + }, }, }); @@ -78,6 +81,9 @@ describe('POST cases', () => { type: '.jira', fields: { issueType: 'Task', priority: 'High', parent: null }, }, + settings: { + syncAlerts: true, + }, }, }); @@ -108,6 +114,9 @@ describe('POST cases', () => { status: CaseStatuses.open, tags: ['defacement'], connector: null, + settings: { + syncAlerts: true, + }, }, }); @@ -130,6 +139,9 @@ describe('POST cases', () => { title: 'Super Bad Security Issue', tags: ['error'], connector: null, + settings: { + syncAlerts: true, + }, }, }); @@ -160,6 +172,9 @@ describe('POST cases', () => { type: ConnectorTypes.none, fields: null, }, + settings: { + syncAlerts: true, + }, }, }); @@ -199,6 +214,9 @@ describe('POST cases', () => { updated_at: null, updated_by: null, version: 'WzksMV0=', + settings: { + syncAlerts: true, + }, }); }); }); diff --git a/x-pack/plugins/case/server/routes/api/utils.test.ts b/x-pack/plugins/case/server/routes/api/utils.test.ts index 7654ae5ff0d1..405da0df1754 100644 --- a/x-pack/plugins/case/server/routes/api/utils.test.ts +++ b/x-pack/plugins/case/server/routes/api/utils.test.ts @@ -302,6 +302,9 @@ describe('Utils', () => { comments: [], totalComment: 2, version: 'WzAsMV0=', + settings: { + syncAlerts: true, + }, }, ]); }); @@ -341,6 +344,9 @@ describe('Utils', () => { comments: [], totalComment: 0, version: 'WzAsMV0=', + settings: { + syncAlerts: true, + }, }, ]); }); @@ -387,6 +393,9 @@ describe('Utils', () => { comments: [], totalComment: 0, version: 'WzAsMV0=', + settings: { + syncAlerts: true, + }, }, ]); }); @@ -497,6 +506,9 @@ describe('Utils', () => { comments: [], totalComment: 2, version: 'WzAsMV0=', + settings: { + syncAlerts: true, + }, }); }); }); diff --git a/x-pack/plugins/case/server/saved_object_types/cases.ts b/x-pack/plugins/case/server/saved_object_types/cases.ts index d8ee2f90f3d9..6468d4b3aa61 100644 --- a/x-pack/plugins/case/server/saved_object_types/cases.ts +++ b/x-pack/plugins/case/server/saved_object_types/cases.ts @@ -134,6 +134,13 @@ export const caseSavedObjectType: SavedObjectsType = { }, }, }, + settings: { + properties: { + syncAlerts: { + type: 'boolean', + }, + }, + }, }, }, migrations: caseMigrations, diff --git a/x-pack/plugins/case/server/saved_object_types/migrations.ts b/x-pack/plugins/case/server/saved_object_types/migrations.ts index 27c363a40af3..9124314ac3f5 100644 --- a/x-pack/plugins/case/server/saved_object_types/migrations.ts +++ b/x-pack/plugins/case/server/saved_object_types/migrations.ts @@ -9,16 +9,16 @@ import { SavedObjectUnsanitizedDoc, SavedObjectSanitizedDoc } from '../../../../../src/core/server'; import { ConnectorTypes, CommentType } from '../../common/api'; -interface UnsanitizedCase { +interface UnsanitizedCaseConnector { connector_id: string; } -interface UnsanitizedConfigure { +interface UnsanitizedConfigureConnector { connector_id: string; connector_name: string; } -interface SanitizedCase { +interface SanitizedCaseConnector { connector: { id: string; name: string | null; @@ -27,7 +27,7 @@ interface SanitizedCase { }; } -interface SanitizedConfigure { +interface SanitizedConfigureConnector { connector: { id: string; name: string | null; @@ -42,10 +42,16 @@ interface UserActions { old_value: string; } +interface SanitizedCaseSettings { + settings: { + syncAlerts: boolean; + }; +} + export const caseMigrations = { '7.10.0': ( - doc: SavedObjectUnsanitizedDoc - ): SavedObjectSanitizedDoc => { + doc: SavedObjectUnsanitizedDoc + ): SavedObjectSanitizedDoc => { const { connector_id, ...attributesWithoutConnectorId } = doc.attributes; return { @@ -62,12 +68,26 @@ export const caseMigrations = { references: doc.references || [], }; }, + '7.11.0': ( + doc: SavedObjectUnsanitizedDoc> + ): SavedObjectSanitizedDoc => { + return { + ...doc, + attributes: { + ...doc.attributes, + settings: { + syncAlerts: true, + }, + }, + references: doc.references || [], + }; + }, }; export const configureMigrations = { '7.10.0': ( - doc: SavedObjectUnsanitizedDoc - ): SavedObjectSanitizedDoc => { + doc: SavedObjectUnsanitizedDoc + ): SavedObjectSanitizedDoc => { const { connector_id, connector_name, ...restAttributes } = doc.attributes; return { diff --git a/x-pack/plugins/case/server/services/alerts/index.ts b/x-pack/plugins/case/server/services/alerts/index.ts new file mode 100644 index 000000000000..4fb98278b8af --- /dev/null +++ b/x-pack/plugins/case/server/services/alerts/index.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import type { PublicMethodsOf } from '@kbn/utility-types'; + +import { IClusterClient, KibanaRequest } from 'kibana/server'; +import { CaseStatuses } from '../../../common/api'; + +export type AlertServiceContract = PublicMethodsOf; + +interface UpdateAlertsStatusArgs { + request: KibanaRequest; + ids: string[]; + status: CaseStatuses; + index: string; +} + +export class AlertService { + private isInitialized = false; + private esClient?: IClusterClient; + + constructor() {} + + public initialize(esClient: IClusterClient) { + if (this.isInitialized) { + throw new Error('AlertService already initialized'); + } + + this.isInitialized = true; + this.esClient = esClient; + } + + public async updateAlertsStatus({ request, ids, status, index }: UpdateAlertsStatusArgs) { + if (!this.isInitialized) { + throw new Error('AlertService not initialized'); + } + + // The above check makes sure that esClient is defined. + const result = await this.esClient!.asScoped(request).asCurrentUser.updateByQuery({ + index, + conflicts: 'abort', + body: { + script: { + source: `ctx._source.signal.status = '${status}'`, + lang: 'painless', + }, + query: { ids: { values: ids } }, + }, + ignore_unavailable: true, + }); + + return result; + } +} diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index 0ce2b196af47..95bcf87361e0 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -31,6 +31,7 @@ import { readTags } from './tags/read_tags'; export { CaseConfigureService, CaseConfigureServiceSetup } from './configure'; export { CaseUserActionService, CaseUserActionServiceSetup } from './user_actions'; +export { AlertService, AlertServiceContract } from './alerts'; export interface ClientArgs { client: SavedObjectsClientContract; diff --git a/x-pack/plugins/case/server/services/mocks.ts b/x-pack/plugins/case/server/services/mocks.ts index 287f80a60ab0..01a8cb09ac2d 100644 --- a/x-pack/plugins/case/server/services/mocks.ts +++ b/x-pack/plugins/case/server/services/mocks.ts @@ -4,11 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CaseConfigureServiceSetup, CaseServiceSetup, CaseUserActionServiceSetup } from '.'; +import { + CaseConfigureServiceSetup, + CaseServiceSetup, + CaseUserActionServiceSetup, + AlertServiceContract, +} from '.'; export type CaseServiceMock = jest.Mocked; export type CaseConfigureServiceMock = jest.Mocked; export type CaseUserActionServiceMock = jest.Mocked; +export type AlertServiceMock = jest.Mocked; export const createCaseServiceMock = (): CaseServiceMock => ({ deleteCase: jest.fn(), @@ -41,3 +47,8 @@ export const createUserActionServiceMock = (): CaseUserActionServiceMock => ({ getUserActions: jest.fn(), postUserActions: jest.fn(), }); + +export const createAlertServiceMock = (): AlertServiceMock => ({ + initialize: jest.fn(), + updateAlertsStatus: jest.fn(), +}); diff --git a/x-pack/plugins/case/server/services/user_actions/helpers.ts b/x-pack/plugins/case/server/services/user_actions/helpers.ts index c9339862b8f2..c7bdc8b10b5a 100644 --- a/x-pack/plugins/case/server/services/user_actions/helpers.ts +++ b/x-pack/plugins/case/server/services/user_actions/helpers.ts @@ -129,6 +129,7 @@ const userActionFieldsAllowed: UserActionField = [ 'tags', 'title', 'status', + 'settings', ]; export const buildCaseUserActions = ({ diff --git a/x-pack/plugins/case/server/types.ts b/x-pack/plugins/case/server/types.ts index b95060ef3045..d0dfc26aa7b8 100644 --- a/x-pack/plugins/case/server/types.ts +++ b/x-pack/plugins/case/server/types.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AppRequestContext } from '../../security_solution/server/types'; import { CaseClient } from './client'; export interface CaseRequestContext { @@ -13,5 +15,8 @@ export interface CaseRequestContext { declare module 'src/core/server' { interface RequestHandlerContext { case?: CaseRequestContext; + // TODO: Remove when triggers_ui do not import case's types. + // PR https://github.com/elastic/kibana/pull/84587. + securitySolution?: AppRequestContext; } } diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx index 755dde9341dc..78bb3a8d2f2f 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx @@ -474,6 +474,9 @@ describe('AllCases', () => { username: 'lknope', }, version: 'WzQ3LDFd', + settings: { + syncAlerts: true, + }, }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx index 945458e92bc8..62ce0cc2cc2f 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo } from 'react'; +import React, { useMemo, useCallback } from 'react'; import styled, { css } from 'styled-components'; import { EuiButtonEmpty, @@ -13,6 +13,7 @@ import { EuiDescriptionListTitle, EuiFlexGroup, EuiFlexItem, + EuiIconTip, } from '@elastic/eui'; import { CaseStatuses } from '../../../../../case/common/api'; import * as i18n from '../case_view/translations'; @@ -22,6 +23,8 @@ import { Case } from '../../containers/types'; import { CaseService } from '../../containers/use_get_case_user_actions'; import { StatusContextMenu } from './status_context_menu'; import { getStatusDate, getStatusTitle } from './helpers'; +import { SyncAlertsSwitch } from '../case_settings/sync_alerts_switch'; +import { OnUpdateFields } from '../case_view'; const MyDescriptionList = styled(EuiDescriptionList)` ${({ theme }) => css` @@ -38,7 +41,7 @@ interface CaseActionBarProps { disabled?: boolean; isLoading: boolean; onRefresh: () => void; - onStatusChanged: (status: CaseStatuses) => void; + onUpdateField: (args: OnUpdateFields) => void; } const CaseActionBarComponent: React.FC = ({ caseData, @@ -46,10 +49,27 @@ const CaseActionBarComponent: React.FC = ({ disabled = false, isLoading, onRefresh, - onStatusChanged, + onUpdateField, }) => { const date = useMemo(() => getStatusDate(caseData), [caseData]); const title = useMemo(() => getStatusTitle(caseData.status), [caseData.status]); + const onStatusChanged = useCallback( + (status: CaseStatuses) => + onUpdateField({ + key: 'status', + value: status, + }), + [onUpdateField] + ); + + const onSyncAlertsChanged = useCallback( + (syncAlerts: boolean) => + onUpdateField({ + key: 'settings', + value: { ...caseData.settings, syncAlerts }, + }), + [caseData.settings, onUpdateField] + ); return ( @@ -78,20 +98,41 @@ const CaseActionBarComponent: React.FC = ({ - - - - {i18n.CASE_REFRESH} - - - - - - + + + + + + + + {i18n.STATUS} + + + + + + + + + + + + {i18n.CASE_REFRESH} + + + + + + + ); diff --git a/x-pack/plugins/security_solution/public/cases/components/case_settings/sync_alerts_switch.tsx b/x-pack/plugins/security_solution/public/cases/components/case_settings/sync_alerts_switch.tsx new file mode 100644 index 000000000000..ab91f2ae8cdf --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/case_settings/sync_alerts_switch.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo, useCallback, useState } from 'react'; +import { EuiSwitch } from '@elastic/eui'; + +import * as i18n from '../../translations'; + +interface Props { + disabled: boolean; + isSynced?: boolean; + showLabel?: boolean; + onSwitchChange?: (isSynced: boolean) => void; +} + +const SyncAlertsSwitchComponent: React.FC = ({ + disabled, + isSynced = true, + showLabel = false, + onSwitchChange, +}) => { + const [isOn, setIsOn] = useState(isSynced); + + const onChange = useCallback(() => { + if (onSwitchChange) { + onSwitchChange(!isOn); + } + + setIsOn(!isOn); + }, [isOn, onSwitchChange]); + + return ( + + ); +}; + +SyncAlertsSwitchComponent.displayName = 'SyncAlertsSwitchComponent'; + +export const SyncAlertsSwitch = memo(SyncAlertsSwitchComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx index 0e6226f69fce..6007038b33ab 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx @@ -16,7 +16,7 @@ import { EuiHorizontalRule, } from '@elastic/eui'; -import { CaseStatuses } from '../../../../../case/common/api'; +import { CaseStatuses, CaseAttributes } from '../../../../../case/common/api'; import { Case, CaseConnector } from '../../containers/types'; import { getCaseDetailsUrl, getCaseUrl, useFormatUrl } from '../../../common/components/link_to'; import { gutterTimeline } from '../../../common/lib/helpers'; @@ -234,6 +234,21 @@ export const CaseComponent = React.memo( onError, }); } + break; + case 'settings': + const settingsUpdate = getTypedPayload(value); + if (caseData.settings !== value) { + updateCaseProperty({ + fetchCaseUserActions, + updateKey: 'settings', + updateValue: settingsUpdate, + updateCase: handleUpdateNewCase, + version: caseData.version, + onSuccess, + onError, + }); + } + break; default: return null; } @@ -397,9 +412,9 @@ export const CaseComponent = React.memo( currentExternalIncident={currentExternalIncident} caseData={caseData} disabled={!userCanCrud} - isLoading={isLoading && updateKey === 'status'} + isLoading={isLoading && (updateKey === 'status' || updateKey === 'settings')} onRefresh={handleRefresh} - onStatusChanged={changeStatus} + onUpdateField={onUpdateField} /> diff --git a/x-pack/plugins/security_solution/public/cases/components/create/connector.tsx b/x-pack/plugins/security_solution/public/cases/components/create/connector.tsx index b2a0f3c35155..67c536f652ec 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/connector.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/connector.tsx @@ -7,13 +7,13 @@ import React, { memo, useEffect } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { ConnectorTypeFields } from '../../../../../case/common/api/connectors'; import { UseField, useFormData, FieldHook } from '../../../shared_imports'; import { useConnectors } from '../../containers/configure/use_connectors'; import { ConnectorSelector } from '../connector_selector/form'; import { SettingFieldsForm } from '../settings/fields_form'; import { ActionConnector } from '../../containers/types'; import { getConnectorById } from '../configure_cases/utils'; +import { FormProps } from './schema'; interface Props { isLoading: boolean; @@ -21,7 +21,7 @@ interface Props { interface SettingsFieldProps { connectors: ActionConnector[]; - field: FieldHook; + field: FieldHook; isEdit: boolean; } diff --git a/x-pack/plugins/security_solution/public/cases/components/create/form.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/form.test.tsx index e64b2b3a0508..3091e6b33d33 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/form.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/form.test.tsx @@ -25,6 +25,7 @@ const initialCaseValue: FormProps = { title: '', connectorId: 'none', fields: null, + syncAlerts: true, }; describe('CreateCaseForm', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/create/form.tsx b/x-pack/plugins/security_solution/public/cases/components/create/form.tsx index 40db4d792c1c..308dc6391693 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/form.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/form.tsx @@ -15,6 +15,7 @@ import { Description } from './description'; import { Tags } from './tags'; import { Connector } from './connector'; import * as i18n from './translations'; +import { SyncAlertsToggle } from './sync_alerts_toggle'; interface ContainerProps { big?: boolean; @@ -61,6 +62,18 @@ export const CreateCaseForm: React.FC = React.memo(({ withSteps = true }) const secondStep = useMemo( () => ({ title: i18n.STEP_TWO_TITLE, + children: ( + + + + ), + }), + [isSubmitting] + ); + + const thirdStep = useMemo( + () => ({ + title: i18n.STEP_THREE_TITLE, children: ( @@ -70,7 +83,11 @@ export const CreateCaseForm: React.FC = React.memo(({ withSteps = true }) [isSubmitting] ); - const allSteps = useMemo(() => [firstStep, secondStep], [firstStep, secondStep]); + const allSteps = useMemo(() => [firstStep, secondStep, thirdStep], [ + firstStep, + secondStep, + thirdStep, + ]); return ( <> @@ -85,6 +102,7 @@ export const CreateCaseForm: React.FC = React.memo(({ withSteps = true }) <> {firstStep.children} {secondStep.children} + {thirdStep.children} )} diff --git a/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx b/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx index e11e508b60eb..4575059a5a6c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx @@ -23,6 +23,7 @@ const initialCaseValue: FormProps = { title: '', connectorId: 'none', fields: null, + syncAlerts: true, }; interface Props { @@ -34,14 +35,21 @@ export const FormContext: React.FC = ({ children, onSuccess }) => { const { caseData, postCase } = usePostCase(); const submitCase = useCallback( - async ({ connectorId: dataConnectorId, fields, ...dataWithoutConnectorId }, isValid) => { + async ( + { connectorId: dataConnectorId, fields, syncAlerts, ...dataWithoutConnectorId }, + isValid + ) => { if (isValid) { const caseConnector = getConnectorById(dataConnectorId, connectors); const connectorToUpdate = caseConnector ? normalizeActionConnector(caseConnector, fields) : getNoneConnector(); - await postCase({ ...dataWithoutConnectorId, connector: connectorToUpdate }); + await postCase({ + ...dataWithoutConnectorId, + connector: connectorToUpdate, + settings: { syncAlerts }, + }); } }, [postCase, connectors] diff --git a/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx index 29073e777415..fe5b3bea6445 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx @@ -8,8 +8,9 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; import { act, waitFor } from '@testing-library/react'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; -import { TestProviders } from '../../../common/mock'; +import { CasePostRequest } from '../../../../../case/common/api'; +import { TestProviders } from '../../../common/mock'; import { usePostCase } from '../../containers/use_post_case'; import { useGetTags } from '../../containers/use_get_tags'; import { useConnectors } from '../../containers/configure/use_connectors'; @@ -41,7 +42,7 @@ const useGetFieldsByIssueTypeMock = useGetFieldsByIssueType as jest.Mock; const postCase = jest.fn(); const sampleTags = ['coke', 'pepsi']; -const sampleData = { +const sampleData: CasePostRequest = { description: 'what a great description', tags: sampleTags, title: 'what a cool title', @@ -51,6 +52,9 @@ const sampleData = { name: 'none', type: ConnectorTypes.none, }, + settings: { + syncAlerts: true, + }, }; const defaultPostCase = { diff --git a/x-pack/plugins/security_solution/public/cases/components/create/schema.tsx b/x-pack/plugins/security_solution/public/cases/components/create/schema.tsx index a336860121c9..34f0bdd05148 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/schema.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/schema.tsx @@ -6,7 +6,7 @@ import { CasePostRequest, ConnectorTypeFields } from '../../../../../case/common/api'; import { FIELD_TYPES, fieldValidators, FormSchema } from '../../../shared_imports'; -import * as i18n from '../../translations'; +import * as i18n from './translations'; import { OptionalFieldLabel } from './optional_field_label'; const { emptyField } = fieldValidators; @@ -18,9 +18,10 @@ export const schemaTags = { labelAppend: OptionalFieldLabel, }; -export type FormProps = Omit & { +export type FormProps = Omit & { connectorId: string; fields: ConnectorTypeFields['fields']; + syncAlerts: boolean; }; export const schema: FormSchema = { @@ -47,4 +48,10 @@ export const schema: FormSchema = { label: i18n.CONNECTORS, defaultValue: 'none', }, + fields: {}, + syncAlerts: { + helpText: i18n.SYNC_ALERTS_HELP, + type: FIELD_TYPES.TOGGLE, + defaultValue: true, + }, }; diff --git a/x-pack/plugins/security_solution/public/cases/components/create/sync_alerts_toggle.tsx b/x-pack/plugins/security_solution/public/cases/components/create/sync_alerts_toggle.tsx new file mode 100644 index 000000000000..0abb2974dd2c --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/create/sync_alerts_toggle.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { Field, getUseField, useFormData } from '../../../shared_imports'; +import * as i18n from './translations'; + +const CommonUseField = getUseField({ component: Field }); + +interface Props { + isLoading: boolean; +} + +const SyncAlertsToggleComponent: React.FC = ({ isLoading }) => { + const [{ syncAlerts }] = useFormData({ watch: ['syncAlerts'] }); + return ( + + ); +}; + +SyncAlertsToggleComponent.displayName = 'SyncAlertsToggleComponent'; + +export const SyncAlertsToggle = memo(SyncAlertsToggleComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/translations.ts b/x-pack/plugins/security_solution/public/cases/components/create/translations.ts index 38916dbddc7d..f892e080af78 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/components/create/translations.ts @@ -17,7 +17,21 @@ export const STEP_ONE_TITLE = i18n.translate( export const STEP_TWO_TITLE = i18n.translate( 'xpack.securitySolution.components.create.stepTwoTitle', + { + defaultMessage: 'Case settings', + } +); + +export const STEP_THREE_TITLE = i18n.translate( + 'xpack.securitySolution.components.create.stepThreeTitle', { defaultMessage: 'External Connector Fields', } ); + +export const SYNC_ALERTS_LABEL = i18n.translate( + 'xpack.securitySolution.components.create.syncAlertsLabel', + { + defaultMessage: 'Sync alert status with case status', + } +); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx index 148ad275b756..be437073e693 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx @@ -9,7 +9,7 @@ import { EuiLink } from '@elastic/eui'; import { APP_ID } from '../../../../common/constants'; import { useKibana } from '../../../common/lib/kibana'; -import { getRuleDetailsUrl, useFormatUrl } from '../../../common/components/link_to'; +import { getRuleDetailsUrl } from '../../../common/components/link_to'; import { SecurityPageName } from '../../../app/types'; import { Alert } from '../case_view'; @@ -23,16 +23,15 @@ const AlertCommentEventComponent: React.FC = ({ alert }) => { const ruleName = alert?.rule?.name ?? null; const ruleId = alert?.rule?.id ?? null; const { navigateToApp } = useKibana().services.application; - const { formatUrl } = useFormatUrl(SecurityPageName.detections); const onLinkClick = useCallback( (ev: { preventDefault: () => void }) => { ev.preventDefault(); navigateToApp(`${APP_ID}:${SecurityPageName.detections}`, { - path: formatUrl(getRuleDetailsUrl(ruleId ?? '')), + path: getRuleDetailsUrl(ruleId ?? ''), }); }, - [ruleId, formatUrl, navigateToApp] + [ruleId, navigateToApp] ); return ruleId != null && ruleName != null ? ( diff --git a/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx index f60993fc9aa0..bec1ab3dd429 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx @@ -384,6 +384,9 @@ describe('Case Configuration API', () => { type: ConnectorTypes.none, fields: null, }, + settings: { + syncAlerts: true, + }, }; test('check url, method, signal', async () => { diff --git a/x-pack/plugins/security_solution/public/cases/containers/mock.ts b/x-pack/plugins/security_solution/public/cases/containers/mock.ts index 40312a871378..f94fb189c90c 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/mock.ts @@ -76,6 +76,9 @@ export const basicCase: Case = { updatedAt: basicUpdatedAt, updatedBy: elasticUser, version: 'WzQ3LDFd', + settings: { + syncAlerts: true, + }, }; export const basicCasePost: Case = { diff --git a/x-pack/plugins/security_solution/public/cases/containers/types.ts b/x-pack/plugins/security_solution/public/cases/containers/types.ts index ec1eaa939fe3..a5c9c65dab62 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/types.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/types.ts @@ -11,6 +11,7 @@ import { CaseConnector, CommentRequest, CaseStatuses, + CaseAttributes, } from '../../../../case/common/api'; export { CaseConnector, ActionConnector } from '../../../../case/common/api'; @@ -63,6 +64,7 @@ export interface Case { updatedAt: string | null; updatedBy: ElasticUser | null; version: string; + settings: CaseAttributes['settings']; } export interface QueryParams { diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx index 44166a14ad29..060ed787c7f4 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx @@ -74,6 +74,9 @@ export const initialData: Case = { updatedAt: null, updatedBy: null, version: '', + settings: { + syncAlerts: true, + }, }; export interface UseGetCase extends CaseState { diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_case.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_case.test.tsx index c4363236a097..8e8432d0d190 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_case.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_case.test.tsx @@ -24,6 +24,9 @@ describe('usePostCase', () => { type: ConnectorTypes.none, fields: null, }, + settings: { + syncAlerts: true, + }, }; beforeEach(() => { jest.clearAllMocks(); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx index c305399ee02d..08333416d3c4 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx @@ -19,7 +19,7 @@ import { Case } from './types'; export type UpdateKey = keyof Pick< CasePatchRequest, - 'connector' | 'description' | 'status' | 'tags' | 'title' + 'connector' | 'description' | 'status' | 'tags' | 'title' | 'settings' >; interface NewCaseState { diff --git a/x-pack/plugins/security_solution/public/cases/translations.ts b/x-pack/plugins/security_solution/public/cases/translations.ts index a79f7a3af18b..fd217457f9e7 100644 --- a/x-pack/plugins/security_solution/public/cases/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/translations.ts @@ -256,3 +256,25 @@ export const IN_PROGRESS_CASES = i18n.translate( defaultMessage: 'In progress cases', } ); + +export const SYNC_ALERTS_SWITCH_LABEL_ON = i18n.translate( + 'xpack.securitySolution.case.settings.syncAlertsSwitchLabelOn', + { + defaultMessage: 'On', + } +); + +export const SYNC_ALERTS_SWITCH_LABEL_OFF = i18n.translate( + 'xpack.securitySolution.case.settings.syncAlertsSwitchLabelOff', + { + defaultMessage: 'Off', + } +); + +export const SYNC_ALERTS_HELP = i18n.translate( + 'xpack.securitySolution.components.create.syncAlertHelpText', + { + defaultMessage: + 'Enabling this option will sync the status of alerts in this case with the case status.', + } +); diff --git a/x-pack/plugins/security_solution/server/index.ts b/x-pack/plugins/security_solution/server/index.ts index 7b84c531dd37..94764fd15936 100644 --- a/x-pack/plugins/security_solution/server/index.ts +++ b/x-pack/plugins/security_solution/server/index.ts @@ -8,6 +8,7 @@ import { PluginInitializerContext, PluginConfigDescriptor } from '../../../../sr import { Plugin, PluginSetup, PluginStart } from './plugin'; import { configSchema, ConfigType } from './config'; import { SIGNALS_INDEX_KEY } from '../common/constants'; +import { AppClient } from './types'; export const plugin = (context: PluginInitializerContext) => { return new Plugin(context); @@ -41,6 +42,7 @@ export const config: PluginConfigDescriptor = { }; export { ConfigType, Plugin, PluginSetup, PluginStart }; +export { AppClient }; // Exports to be shared with plugins such as x-pack/lists plugin export { deleteTemplate } from './lib/detection_engine/index/delete_template'; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/migrations.ts b/x-pack/test/case_api_integration/basic/tests/cases/migrations.ts index 36f07ef92b5f..df200b34dc42 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/migrations.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/migrations.ts @@ -38,5 +38,18 @@ export default function createGetTests({ getService }: FtrProviderContext) { fields: null, }); }); + + it('7.11.0 migrates cases settings', async () => { + const { body } = await supertest + .get(`${CASES_URL}/e1900ac0-017f-11eb-93f8-d161651bf509`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(body).key('settings'); + expect(body.settings).to.eql({ + syncAlerts: true, + }); + }); }); } diff --git a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts index 6949052df470..ec79c8a1ca49 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts @@ -36,7 +36,7 @@ export default ({ getService }: FtrProviderContext): void => { await actionsRemover.removeAll(); }); - it(`on new case, user action: 'create' should be called with actionFields: ['description', 'status', 'tags', 'title', 'connector']`, async () => { + it(`on new case, user action: 'create' should be called with actionFields: ['description', 'status', 'tags', 'title', 'connector', 'settings]`, async () => { const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') @@ -51,7 +51,14 @@ export default ({ getService }: FtrProviderContext): void => { expect(body.length).to.eql(1); - expect(body[0].action_field).to.eql(['description', 'status', 'tags', 'title', 'connector']); + expect(body[0].action_field).to.eql([ + 'description', + 'status', + 'tags', + 'title', + 'connector', + 'settings', + ]); expect(body[0].action).to.eql('create'); expect(body[0].old_value).to.eql(null); expect(body[0].new_value).to.eql(JSON.stringify(postCaseReq)); diff --git a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts index 9a45dd541bb5..e0812d01d0fb 100644 --- a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts +++ b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts @@ -391,6 +391,9 @@ export default ({ getService }: FtrProviderContext): void => { parent: null, }, }, + settings: { + syncAlerts: true, + }, }, }; @@ -442,6 +445,9 @@ export default ({ getService }: FtrProviderContext): void => { type: '.servicenow', fields: {}, }, + settings: { + syncAlerts: true, + }, }, }; @@ -673,7 +679,53 @@ export default ({ getService }: FtrProviderContext): void => { }); }); - it('should respond with a 400 Bad Request when missing attributes of type alert', async () => { + // TODO: Remove it when the creation of comments of type alert is supported + // https://github.com/elastic/kibana/issues/85750 + it('should fail adding a comment of type alert', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); + + createdActionId = createdAction.id; + + const caseRes = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const params = { + subAction: 'addComment', + subActionParams: { + caseId: caseRes.body.id, + comment: { alertId: 'test-id', index: 'test-index', type: CommentType.alert }, + }, + }; + + const caseConnector = await supertest + .post(`/api/actions/action/${createdActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ params }) + .expect(200); + + expect(caseConnector.body).to.eql({ + status: 'error', + actionId: createdActionId, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subAction]: expected value to equal [update]\n- [2.subActionParams.comment]: types that failed validation:\n - [subActionParams.comment.0.type]: expected value to equal [user]', + retry: false, + }); + }); + + // TODO: Enable when the creation of comments of type alert is supported + // https://github.com/elastic/kibana/issues/85750 + it.skip('should respond with a 400 Bad Request when missing attributes of type alert', async () => { const { body: createdAction } = await supertest .post('/api/actions/action') .set('kbn-xsrf', 'foo') @@ -754,13 +806,15 @@ export default ({ getService }: FtrProviderContext): void => { expect(caseConnector.body).to.eql({ status: 'error', actionId: createdActionId, - message: `error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subAction]: expected value to equal [update]\n- [2.subActionParams.comment]: types that failed validation:\n - [subActionParams.comment.0.${attribute}]: definition for this key is missing\n - [subActionParams.comment.1.type]: expected value to equal [alert]`, + message: `error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subAction]: expected value to equal [update]\n- [2.subActionParams.comment]: types that failed validation:\n - [subActionParams.comment.0.${attribute}]: definition for this key is missing`, retry: false, }); } }); - it('should respond with a 400 Bad Request when adding excess attributes for type alert', async () => { + // TODO: Enable when the creation of comments of type alert is supported + // https://github.com/elastic/kibana/issues/85750 + it.skip('should respond with a 400 Bad Request when adding excess attributes for type alert', async () => { const { body: createdAction } = await supertest .post('/api/actions/action') .set('kbn-xsrf', 'foo') @@ -892,7 +946,9 @@ export default ({ getService }: FtrProviderContext): void => { }); }); - it('should add a comment of type alert', async () => { + // TODO: Enable when the creation of comments of type alert is supported + // https://github.com/elastic/kibana/issues/85750 + it.skip('should add a comment of type alert', async () => { const { body: createdAction } = await supertest .post('/api/actions/action') .set('kbn-xsrf', 'foo') diff --git a/x-pack/test/case_api_integration/common/lib/mock.ts b/x-pack/test/case_api_integration/common/lib/mock.ts index dac6b2005a9c..012af6b37f84 100644 --- a/x-pack/test/case_api_integration/common/lib/mock.ts +++ b/x-pack/test/case_api_integration/common/lib/mock.ts @@ -26,6 +26,9 @@ export const postCaseReq: CasePostRequest = { type: '.none' as ConnectorTypes, fields: null, }, + settings: { + syncAlerts: true, + }, }; export const postCommentUserReq: CommentRequestUserType = { From 80ca5a5836e537c2695231b38249a4499d80be5b Mon Sep 17 00:00:00 2001 From: Candace Park <56409205+parkiino@users.noreply.github.com> Date: Mon, 14 Dec 2020 14:26:51 -0500 Subject: [PATCH 5/7] [Security Solution][Endpoint][Admin] Custom malware user notification message allows spaces now (#85207) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../policy/store/policy_details/selectors.ts | 38 ++++++++++++++++++- .../view/policy_forms/protections/malware.tsx | 2 +- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors.ts index 7088f094ddcb..77e975a46d37 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors.ts @@ -31,7 +31,43 @@ export const getPolicyDataForUpdate = ( ): NewPolicyData | Immutable => { // eslint-disable-next-line @typescript-eslint/naming-convention const { id, revision, created_by, created_at, updated_by, updated_at, ...newPolicy } = policy; - return newPolicy; + + // trim custom malware notification string + return { + ...newPolicy, + inputs: (newPolicy as Immutable).inputs.map((input) => ({ + ...input, + config: input.config && { + ...input.config, + policy: { + ...input.config.policy, + value: { + ...input.config.policy.value, + windows: { + ...input.config.policy.value.windows, + popup: { + ...input.config.policy.value.windows.popup, + malware: { + ...input.config.policy.value.windows.popup.malware, + message: input.config.policy.value.windows.popup.malware.message.trim(), + }, + }, + }, + mac: { + ...input.config.policy.value.mac, + popup: { + ...input.config.policy.value.mac.popup, + malware: { + ...input.config.policy.value.mac.popup.malware, + message: input.config.policy.value.mac.popup.malware.message.trim(), + }, + }, + }, + }, + }, + }, + })), + }; }; /** diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx index c78455aa8d99..330a0ba40745 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx @@ -193,7 +193,7 @@ export const MalwareProtections = React.memo(() => { if (policyDetailsConfig) { const newPayload = cloneDeep(policyDetailsConfig); for (const os of OSes) { - newPayload[os].popup[protection].message = event.target.value.trim(); + newPayload[os].popup[protection].message = event.target.value; } dispatch({ type: 'userChangedPolicyConfig', From 8279c2d1a2979d13cf2a9f5455327287d1b1a0b7 Mon Sep 17 00:00:00 2001 From: Candace Park <56409205+parkiino@users.noreply.github.com> Date: Mon, 14 Dec 2020 14:27:58 -0500 Subject: [PATCH 6/7] [Security Solution][Endpoint][Admin] Adds instructional tooltip for malware custom user notification (#85651) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../pages/policy/view/policy_details.test.tsx | 8 +++- .../view/policy_forms/protections/malware.tsx | 40 +++++++++++++++---- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx index bfa592b1f9c8..e9c13b23834b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx @@ -293,7 +293,7 @@ describe('Policy Details', () => { policyView = render(); }); - it('malware popup and message customization options are shown', () => { + it('malware popup, message customization options and tooltip are shown', () => { // use query for finding stuff, if it doesn't find it, just returns null const userNotificationCheckbox = policyView.find( 'EuiCheckbox[data-test-subj="malwareUserNotificationCheckbox"]' @@ -301,8 +301,10 @@ describe('Policy Details', () => { const userNotificationCustomMessageTextArea = policyView.find( 'EuiTextArea[data-test-subj="malwareUserNotificationCustomMessage"]' ); + const tooltip = policyView.find('EuiIconTip'); expect(userNotificationCheckbox).toHaveLength(1); expect(userNotificationCustomMessageTextArea).toHaveLength(1); + expect(tooltip).toHaveLength(1); }); }); describe('when the subscription tier is gold or lower', () => { @@ -311,15 +313,17 @@ describe('Policy Details', () => { policyView = render(); }); - it('malware popup and message customization options are hidden', () => { + it('malware popup, message customization options, and tooltip are hidden', () => { const userNotificationCheckbox = policyView.find( 'EuiCheckbox[data-test-subj="malwareUserNotificationCheckbox"]' ); const userNotificationCustomMessageTextArea = policyView.find( 'EuiTextArea[data-test-subj="malwareUserNotificationCustomMessage"]' ); + const tooltip = policyView.find('EuiIconTip'); expect(userNotificationCheckbox).toHaveLength(0); expect(userNotificationCustomMessageTextArea).toHaveLength(0); + expect(tooltip).toHaveLength(0); }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx index 330a0ba40745..d611c4102e8f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx @@ -18,6 +18,9 @@ import { EuiText, EuiTextArea, htmlIdGenerator, + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, } from '@elastic/eui'; import { cloneDeep } from 'lodash'; import { APP_ID } from '../../../../../../../common/constants'; @@ -252,14 +255,37 @@ export const MalwareProtections = React.memo(() => { {isPlatinumPlus && userNotificationSelected && ( <> - -

- + + +

+ +

+
+
+ + + + + + + } /> -

-
+ + Date: Mon, 14 Dec 2020 13:38:38 -0600 Subject: [PATCH 7/7] [Security Solution] [Sourcerer] Cypress tests (#80410) --- .../cypress/integration/sourcerer.spec.ts | 105 ++++++++++++++ .../cypress/screens/sourcerer.ts | 30 ++++ .../cypress/tasks/sourcerer.ts | 136 ++++++++++++++++++ .../timeline/search_or_filter/pick_events.tsx | 17 ++- 4 files changed, 281 insertions(+), 7 deletions(-) create mode 100644 x-pack/plugins/security_solution/cypress/integration/sourcerer.spec.ts create mode 100644 x-pack/plugins/security_solution/cypress/screens/sourcerer.ts create mode 100644 x-pack/plugins/security_solution/cypress/tasks/sourcerer.ts diff --git a/x-pack/plugins/security_solution/cypress/integration/sourcerer.spec.ts b/x-pack/plugins/security_solution/cypress/integration/sourcerer.spec.ts new file mode 100644 index 000000000000..4126bcfdbf0b --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/sourcerer.spec.ts @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { loginAndWaitForPage } from '../tasks/login'; + +import { HOSTS_URL } from '../urls/navigation'; +import { waitForAllHostsToBeLoaded } from '../tasks/hosts/all_hosts'; +import { + clickOutOfSourcererTimeline, + clickTimelineRadio, + deselectSourcererOptions, + isCustomRadio, + isHostsStatValue, + isNotCustomRadio, + isNotSourcererSelection, + isSourcererOptions, + isSourcererSelection, + openSourcerer, + resetSourcerer, + setSourcererOption, + unsetSourcererOption, +} from '../tasks/sourcerer'; +import { openTimelineUsingToggle } from '../tasks/security_main'; +import { populateTimeline } from '../tasks/timeline'; +import { SERVER_SIDE_EVENT_COUNT } from '../screens/timeline'; + +describe('Sourcerer', () => { + beforeEach(() => { + loginAndWaitForPage(HOSTS_URL); + }); + describe('Default scope', () => { + it('has SIEM index patterns selected on initial load', () => { + openSourcerer(); + isSourcererSelection(`auditbeat-*`); + }); + + it('has Kibana index patterns in the options', () => { + openSourcerer(); + isSourcererOptions([`metrics-*`, `logs-*`]); + }); + it('selected KIP gets added to sourcerer', () => { + setSourcererOption(`metrics-*`); + openSourcerer(); + isSourcererSelection(`metrics-*`); + }); + + it('does not return data without correct pattern selected', () => { + waitForAllHostsToBeLoaded(); + isHostsStatValue('4 '); + setSourcererOption(`metrics-*`); + unsetSourcererOption(`auditbeat-*`); + isHostsStatValue('0 '); + }); + + it('reset button restores to original state', () => { + setSourcererOption(`metrics-*`); + openSourcerer(); + isSourcererSelection(`metrics-*`); + resetSourcerer(); + openSourcerer(); + isNotSourcererSelection(`metrics-*`); + }); + }); + describe('Timeline scope', () => { + const alertPatterns = ['.siem-signals-default']; + const rawPatterns = ['auditbeat-*']; + const allPatterns = [...alertPatterns, ...rawPatterns]; + it('Radio buttons select correct sourcerer patterns', () => { + openTimelineUsingToggle(); + openSourcerer('timeline'); + allPatterns.forEach((ss) => isSourcererSelection(ss, 'timeline')); + clickTimelineRadio('raw'); + rawPatterns.forEach((ss) => isSourcererSelection(ss, 'timeline')); + alertPatterns.forEach((ss) => isNotSourcererSelection(ss, 'timeline')); + clickTimelineRadio('alert'); + alertPatterns.forEach((ss) => isSourcererSelection(ss, 'timeline')); + rawPatterns.forEach((ss) => isNotSourcererSelection(ss, 'timeline')); + }); + it('Adding an option results in the custom radio becoming active', () => { + openTimelineUsingToggle(); + openSourcerer('timeline'); + isNotCustomRadio(); + clickOutOfSourcererTimeline(); + const luckyOption = 'logs-*'; + setSourcererOption(luckyOption, 'timeline'); + openSourcerer('timeline'); + isCustomRadio(); + }); + it('Selected index patterns are properly queried', () => { + openTimelineUsingToggle(); + populateTimeline(); + openSourcerer('timeline'); + deselectSourcererOptions(rawPatterns, 'timeline'); + cy.get(SERVER_SIDE_EVENT_COUNT) + .invoke('text') + .then((strCount) => { + const intCount = +strCount; + cy.wrap(intCount).should('eq', 0); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/screens/sourcerer.ts b/x-pack/plugins/security_solution/cypress/screens/sourcerer.ts new file mode 100644 index 000000000000..3f461c425c54 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/screens/sourcerer.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const SOURCERER_TRIGGER = '[data-test-subj="sourcerer-trigger"]'; +export const SOURCERER_INPUT = + '[data-test-subj="indexPattern-switcher"] [data-test-subj="comboBoxInput"]'; +export const SOURCERER_OPTIONS = + '[data-test-subj="comboBoxOptionsList indexPattern-switcher-optionsList"]'; +export const SOURCERER_SAVE_BUTTON = 'button[data-test-subj="add-index"]'; +export const SOURCERER_RESET_BUTTON = 'button[data-test-subj="sourcerer-reset"]'; +export const SOURCERER_POPOVER_TITLE = '.euiPopoverTitle'; +export const HOSTS_STAT = '[data-test-subj="stat-hosts"] [data-test-subj="stat-title"]'; + +export const SOURCERER_TIMELINE = { + trigger: '[data-test-subj="sourcerer-timeline-trigger"]', + advancedSettings: '[data-test-subj="advanced-settings"]', + sourcerer: '[data-test-subj="timeline-sourcerer"]', + sourcererInput: '[data-test-subj="timeline-sourcerer"] [data-test-subj="comboBoxInput"]', + sourcererOptions: '[data-test-subj="comboBoxOptionsList timeline-sourcerer-optionsList"]', + radioRaw: '[data-test-subj="timeline-sourcerer-radio"] label.euiRadio__label[for="raw"]', + radioAlert: '[data-test-subj="timeline-sourcerer-radio"] label.euiRadio__label[for="alert"]', + radioAll: '[data-test-subj="timeline-sourcerer-radio"] label.euiRadio__label[for="all"]', + radioCustom: '[data-test-subj="timeline-sourcerer-radio"] input.euiRadio__input[id="custom"]', + radioCustomLabel: + '[data-test-subj="timeline-sourcerer-radio"] label.euiRadio__label[for="custom"]', +}; +export const SOURCERER_TIMELINE_ADVANCED = '[data-test-subj="advanced-settings"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/sourcerer.ts b/x-pack/plugins/security_solution/cypress/tasks/sourcerer.ts new file mode 100644 index 000000000000..b224f81ab8f2 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/tasks/sourcerer.ts @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + HOSTS_STAT, + SOURCERER_INPUT, + SOURCERER_OPTIONS, + SOURCERER_POPOVER_TITLE, + SOURCERER_RESET_BUTTON, + SOURCERER_SAVE_BUTTON, + SOURCERER_TIMELINE, + SOURCERER_TRIGGER, +} from '../screens/sourcerer'; +import { TIMELINE_TITLE } from '../screens/timeline'; + +export const openSourcerer = (sourcererScope?: string) => { + if (sourcererScope != null && sourcererScope === 'timeline') { + return openTimelineSourcerer(); + } + cy.get(SOURCERER_TRIGGER).should('be.enabled'); + cy.get(SOURCERER_TRIGGER).should('be.visible'); + cy.get(SOURCERER_TRIGGER).click(); +}; +export const openTimelineSourcerer = () => { + cy.get(SOURCERER_TIMELINE.trigger).should('be.enabled'); + cy.get(SOURCERER_TIMELINE.trigger).should('be.visible'); + cy.get(SOURCERER_TIMELINE.trigger).click(); + cy.get(SOURCERER_TIMELINE.advancedSettings).should(($div) => { + if ($div.text() === 'Show Advanced') { + $div.click(); + } + expect(true).to.eq(true); + }); +}; +export const openAdvancedSettings = () => {}; + +export const clickOutOfSelector = () => { + return cy.get(SOURCERER_POPOVER_TITLE).first().click(); +}; + +const getScopedSelectors = (sourcererScope?: string): { input: string; options: string } => + sourcererScope != null && sourcererScope === 'timeline' + ? { input: SOURCERER_TIMELINE.sourcererInput, options: SOURCERER_TIMELINE.sourcererOptions } + : { input: SOURCERER_INPUT, options: SOURCERER_OPTIONS }; + +export const isSourcererSelection = (patternName: string, sourcererScope?: string) => { + const { input } = getScopedSelectors(sourcererScope); + return cy.get(input).find(`span[title="${patternName}"]`).should('exist'); +}; + +export const isHostsStatValue = (value: string) => { + return cy.get(HOSTS_STAT).first().should('have.text', value); +}; + +export const isNotSourcererSelection = (patternName: string, sourcererScope?: string) => { + const { input } = getScopedSelectors(sourcererScope); + return cy.get(input).find(`span[title="${patternName}"]`).should('not.exist'); +}; + +export const isSourcererOptions = (patternNames: string[], sourcererScope?: string) => { + const { input, options } = getScopedSelectors(sourcererScope); + cy.get(input).click(); + return patternNames.every((patternName) => { + return cy + .get(options) + .find(`button.euiFilterSelectItem[title="${patternName}"]`) + .its('length') + .should('eq', 1); + }); +}; + +export const selectSourcererOption = (patternName: string, sourcererScope?: string) => { + const { input, options } = getScopedSelectors(sourcererScope); + cy.get(input).click(); + cy.get(options).find(`button.euiFilterSelectItem[title="${patternName}"]`).click(); + clickOutOfSelector(); + return cy.get(SOURCERER_SAVE_BUTTON).click({ force: true }); +}; + +export const deselectSourcererOption = (patternName: string, sourcererScope?: string) => { + const { input } = getScopedSelectors(sourcererScope); + cy.get(input).find(`span[title="${patternName}"] button`).click(); + clickOutOfSelector(); + return cy.get(SOURCERER_SAVE_BUTTON).click({ force: true }); +}; + +export const deselectSourcererOptions = (patternNames: string[], sourcererScope?: string) => { + const { input } = getScopedSelectors(sourcererScope); + patternNames.forEach((patternName) => + cy.get(input).find(`span[title="${patternName}"] button`).click() + ); + clickOutOfSelector(); + return cy.get(SOURCERER_SAVE_BUTTON).click({ force: true }); +}; + +export const resetSourcerer = () => { + cy.get(SOURCERER_RESET_BUTTON).click(); + clickOutOfSelector(); + return cy.get(SOURCERER_SAVE_BUTTON).click({ force: true }); +}; + +export const setSourcererOption = (patternName: string, sourcererScope?: string) => { + openSourcerer(sourcererScope); + isNotSourcererSelection(patternName, sourcererScope); + selectSourcererOption(patternName, sourcererScope); +}; + +export const unsetSourcererOption = (patternName: string, sourcererScope?: string) => { + openSourcerer(sourcererScope); + isSourcererSelection(patternName, sourcererScope); + deselectSourcererOption(patternName, sourcererScope); +}; + +export const clickTimelineRadio = (radioName: string) => { + let theRadio = SOURCERER_TIMELINE.radioAll; + if (radioName === 'alert') { + theRadio = SOURCERER_TIMELINE.radioAlert; + } + if (radioName === 'raw') { + theRadio = SOURCERER_TIMELINE.radioRaw; + } + return cy.get(theRadio).first().click(); +}; + +export const isCustomRadio = () => { + return cy.get(SOURCERER_TIMELINE.radioCustom).should('be.enabled'); +}; + +export const isNotCustomRadio = () => { + return cy.get(SOURCERER_TIMELINE.radioCustom).should('be.disabled'); +}; + +export const clickOutOfSourcererTimeline = () => cy.get(TIMELINE_TITLE).first().click(); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/pick_events.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/pick_events.tsx index d7d8d810f697..3bc0eeeef70a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/pick_events.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/pick_events.tsx @@ -251,12 +251,13 @@ const PickEventTypeComponents: React.FC = ({ const comboBox = useMemo( () => ( ), [onChangeCombo, indexesPatternOptions, renderOption, selectedOptions] @@ -269,6 +270,7 @@ const PickEventTypeComponents: React.FC = ({ const filter = useMemo( () => ( = ({ const options = getEventTypeOptions(); return ( = ({ const ButtonContent = useMemo( () => ( - + {showAdvanceSettings ? i18n.HIDE_INDEX_PATTERNS_ADVANCED_SETTINGS : i18n.SHOW_INDEX_PATTERNS_ADVANCED_SETTINGS} @@ -330,11 +333,11 @@ const PickEventTypeComponents: React.FC = ({